diff --git a/flow-typed/Comment.js b/flow-typed/Comment.js index ffda63876..f62993f9e 100644 --- a/flow-typed/Comment.js +++ b/flow-typed/Comment.js @@ -60,6 +60,9 @@ declare type CommentsState = { fetchingModerationDelegators: boolean, blockingByUri: {}, unBlockingByUri: {}, + personalTimeoutMap: { [uri: string]: { blockedAt: string, bannedFor: number, banRemaining: number } }, + adminTimeoutMap: { [uri: string]: { blockedAt: string, bannedFor: number, banRemaining: number } }, + moderatorTimeoutMap: { [uri: string]: { blockedAt: string, bannedFor: number, banRemaining: number } }, togglingForDelegatorMap: {[string]: Array}, // {"blockedUri": ["delegatorUri1", ""delegatorUri2", ...]} settingsByChannelId: { [string]: PerChannelSettings }, // ChannelID -> settings fetchingSettings: boolean, @@ -189,7 +192,43 @@ declare type SuperListResponse = { has_hidden_comments: boolean, }; -declare type ModerationBlockParams = {}; +declare type ModerationBlockParams = { + // Publisher, Moderator, or Commentron Admin + mod_channel_id: string, + mod_channel_name: string, + // Offender being blocked + blocked_channel_id: string, + blocked_channel_name: string, + // Creator that Moderator is delegated from. Used for delegated moderation + creator_channel_id?: string, + creator_channel_name?: string, + // Blocks identity from comment universally, requires Admin rights on commentron instance + block_all?: boolean, + time_out?: number, + // If true will delete all comments of the offender, requires Admin rights on commentron for universal delete + delete_all?: boolean, + // The usual signature stuff + signature: string, + signing_ts: string, +}; + +declare type ModerationBlockResponse = { + deleted_comment_ids: Array, + banned_channel_id: string, + all_blocked: boolean, + banned_from: string, +}; + +declare type BlockedListArgs = { + // Publisher, Moderator or Commentron Admin + mod_channel_id: string, + mod_channel_name: string, + // Creator that Moderator is delegated from. Used for delegated moderation + creator_channel_id?: string, + creator_channel_name?: string, + signature: string, + signing_ts: string, +}; declare type ModerationAddDelegateParams = { mod_channel_id: string, diff --git a/package.json b/package.json index e7303aa87..f9caeed7a 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,9 @@ "electron-notarize": "^1.0.0", "electron-updater": "^4.2.4", "express": "^4.17.1", + "humanize-duration": "^3.27.0", "if-env": "^1.0.4", + "parse-duration": "^1.0.0", "react-datetime-picker": "^3.2.1", "react-plastic": "^1.1.1", "react-top-loading-bar": "^2.0.1", diff --git a/static/app-strings.json b/static/app-strings.json index 9ce061707..381022242 100644 --- a/static/app-strings.json +++ b/static/app-strings.json @@ -1744,6 +1744,12 @@ "Moderator Block": "Moderator Block", "Block this channel on behalf of %creator%": "Block this channel on behalf of %creator%", "creator": "creator", + "Enter the timeout duration. Examples: %examples%": "Enter the timeout duration. Examples: %examples%", + "Wow, banned for more than 100 years?": "Wow, banned for more than 100 years?", + "Invalid duration.": "Invalid duration.", + "Permanent": "Permanent", + "Timeout --[time-based ban instead of permanent]--": "Timeout", + "(Remaining: %duration%) --[timeout ban duration]--": "(Remaining: %duration%)", "Create a channel to change this setting.": "Create a channel to change this setting.", "Invalid channel URL \"%url%\"": "Invalid channel URL \"%url%\"", "Delegation": "Delegation", diff --git a/ui/comments.js b/ui/comments.js index 58593895f..315756401 100644 --- a/ui/comments.js +++ b/ui/comments.js @@ -14,7 +14,7 @@ const Comments = { moderation_block: (params: ModerationBlockParams) => fetchCommentsApi('moderation.Block', params), moderation_unblock: (params: ModerationBlockParams) => fetchCommentsApi('moderation.UnBlock', params), - moderation_block_list: (params: ModerationBlockParams) => fetchCommentsApi('moderation.BlockedList', params), + moderation_block_list: (params: BlockedListArgs) => fetchCommentsApi('moderation.BlockedList', params), moderation_add_delegate: (params: ModerationAddDelegateParams) => fetchCommentsApi('moderation.AddDelegate', params), moderation_remove_delegate: (params: ModerationRemoveDelegateParams) => fetchCommentsApi('moderation.RemoveDelegate', params), diff --git a/ui/component/channelBlockButton/view.jsx b/ui/component/channelBlockButton/view.jsx index dce36b899..7f10eeb35 100644 --- a/ui/component/channelBlockButton/view.jsx +++ b/ui/component/channelBlockButton/view.jsx @@ -12,7 +12,7 @@ type Props = { isBlockingOrUnBlocking: boolean, isToggling: boolean, doCommentModUnBlock: (string, boolean) => void, - doCommentModBlock: (string, boolean) => void, + doCommentModBlock: (string, ?Number, boolean) => void, doCommentModUnBlockAsAdmin: (string, string) => void, doCommentModBlockAsAdmin: (string, string) => void, doCommentModUnBlockAsModerator: (string, string, string) => void, @@ -42,7 +42,7 @@ function ChannelBlockButton(props: Props) { if (isBlocked) { doCommentModUnBlock(uri, false); } else { - doCommentModBlock(uri, false); + doCommentModBlock(uri, undefined, false); } break; diff --git a/ui/component/commentMenuList/index.js b/ui/component/commentMenuList/index.js index 5ebdf41b5..9d8c4ce48 100644 --- a/ui/component/commentMenuList/index.js +++ b/ui/component/commentMenuList/index.js @@ -1,19 +1,13 @@ import { connect } from 'react-redux'; import { makeSelectChannelPermUrlForClaimUri, makeSelectClaimIsMine, makeSelectClaimForUri } from 'lbry-redux'; -import { - doCommentPin, - doCommentModBlock, - doCommentModBlockAsAdmin, - doCommentModBlockAsModerator, - doCommentModAddDelegate, -} from 'redux/actions/comments'; +import { doCommentPin, doCommentModAddDelegate } from 'redux/actions/comments'; import { doChannelMute } from 'redux/actions/blocked'; // import { doSetActiveChannel } from 'redux/actions/app'; import { doOpenModal } from 'redux/actions/app'; import { doSetPlayingUri } from 'redux/actions/content'; import { selectActiveChannelClaim } from 'redux/selectors/app'; import { selectPlayingUri } from 'redux/selectors/content'; -import { selectModerationDelegatorsById } from 'redux/selectors/comments'; + import CommentMenuList from './view'; const select = (state, props) => ({ @@ -22,7 +16,6 @@ const select = (state, props) => ({ contentChannelPermanentUrl: makeSelectChannelPermUrlForClaimUri(props.uri)(state), activeChannelClaim: selectActiveChannelClaim(state), playingUri: selectPlayingUri(state), - moderationDelegatorsById: selectModerationDelegatorsById(state), }); const perform = (dispatch) => ({ @@ -31,10 +24,6 @@ const perform = (dispatch) => ({ muteChannel: (channelUri) => dispatch(doChannelMute(channelUri)), pinComment: (commentId, claimId, remove) => dispatch(doCommentPin(commentId, claimId, remove)), // setActiveChannel: channelId => dispatch(doSetActiveChannel(channelId)), - commentModBlock: (commenterUri) => dispatch(doCommentModBlock(commenterUri)), - commentModBlockAsAdmin: (commenterUri, blockerId) => dispatch(doCommentModBlockAsAdmin(commenterUri, blockerId)), - commentModBlockAsModerator: (commenterUri, creatorId, blockerId) => - dispatch(doCommentModBlockAsModerator(commenterUri, creatorId, blockerId)), commentModAddDelegate: (modChanId, modChanName, creatorChannelClaim) => dispatch(doCommentModAddDelegate(modChanId, modChanName, creatorChannelClaim, true)), }); diff --git a/ui/component/commentMenuList/view.jsx b/ui/component/commentMenuList/view.jsx index 0698652dd..d008c153d 100644 --- a/ui/component/commentMenuList/view.jsx +++ b/ui/component/commentMenuList/view.jsx @@ -8,6 +8,7 @@ import Icon from 'component/common/icon'; import { parseURI } from 'lbry-redux'; type Props = { + uri: ?string, authorUri: string, // full LBRY Channel URI: lbry://@channel#123... commentId: string, // sha256 digest identifying the comment isTopLevel: boolean, @@ -23,21 +24,18 @@ type Props = { contentChannelPermanentUrl: any, activeChannelClaim: ?ChannelClaim, playingUri: ?PlayingUri, - moderationDelegatorsById: { [string]: { global: boolean, delegators: { name: string, claimId: string } } }, // --- perform --- openModal: (id: string, {}) => void, clearPlayingUri: () => void, muteChannel: (string) => void, pinComment: (string, string, boolean) => Promise, - commentModBlock: (string) => void, - commentModBlockAsAdmin: (string, string) => void, - commentModBlockAsModerator: (string, string, string) => void, commentModAddDelegate: (string, string, ChannelClaim) => void, setQuickReply: (any) => void, }; function CommentMenuList(props: Props) { const { + uri, claim, authorUri, commentIsMine, @@ -50,35 +48,16 @@ function CommentMenuList(props: Props) { isTopLevel, isPinned, handleEditComment, - commentModBlock, - commentModBlockAsAdmin, - commentModBlockAsModerator, commentModAddDelegate, playingUri, disableEdit, disableRemove, - moderationDelegatorsById, openModal, supportAmount, setQuickReply, } = props; - const contentChannelClaim = !claim - ? null - : claim.value_type === 'channel' - ? claim - : claim.signing_channel && claim.is_channel_signature_valid - ? claim.signing_channel - : null; - - const activeModeratorInfo = activeChannelClaim && moderationDelegatorsById[activeChannelClaim.claim_id]; const activeChannelIsCreator = activeChannelClaim && activeChannelClaim.permanent_url === contentChannelPermanentUrl; - const activeChannelIsAdmin = activeChannelClaim && activeModeratorInfo && activeModeratorInfo.global; - const activeChannelIsModerator = - activeChannelClaim && - contentChannelClaim && - activeModeratorInfo && - Object.values(activeModeratorInfo.delegators).includes(contentChannelClaim.claim_id); function handlePinComment(commentId, claimId, remove) { pinComment(commentId, claimId, remove); @@ -98,7 +77,7 @@ function CommentMenuList(props: Props) { } function handleCommentBlock() { - commentModBlock(authorUri); + openModal(MODALS.BLOCK_CHANNEL, { contentUri: uri, commenterUri: authorUri }); } function handleCommentMute() { @@ -112,18 +91,6 @@ function CommentMenuList(props: Props) { } } - function blockCommentAsModerator() { - if (activeChannelClaim && contentChannelClaim) { - commentModBlockAsModerator(authorUri, contentChannelClaim.claim_id, activeChannelClaim.claim_id); - } - } - - function blockCommentAsAdmin() { - if (activeChannelClaim) { - commentModBlockAsAdmin(authorUri, activeChannelClaim.claim_id); - } - } - return ( {activeChannelIsCreator &&
{__('Creator tools')}
} @@ -197,34 +164,6 @@ function CommentMenuList(props: Props) { )} - {!commentIsMine && (activeChannelIsAdmin || activeChannelIsModerator) && ( -
{__('Moderator tools')}
- )} - - {!commentIsMine && activeChannelIsAdmin && ( - -
- - {__('Global Block')} -
- {__('Block this channel as global admin')} -
- )} - - {!commentIsMine && activeChannelIsModerator && ( - -
- - {__('Moderator Block')} -
- - {__('Block this channel on behalf of %creator%', { - creator: contentChannelClaim ? contentChannelClaim.name : __('creator'), - })} - -
- )} - {activeChannelClaim && (
diff --git a/ui/constants/modal_types.js b/ui/constants/modal_types.js index c43f04399..2355f3087 100644 --- a/ui/constants/modal_types.js +++ b/ui/constants/modal_types.js @@ -43,6 +43,7 @@ export const IMAGE_UPLOAD = 'image_upload'; export const MOBILE_SEARCH = 'mobile_search'; export const VIEW_IMAGE = 'view_image'; export const CONFIRM_REMOVE_BTC_SWAP_ADDRESS = 'confirm_remove_btc_swap_address'; +export const BLOCK_CHANNEL = 'block_channel'; export const COLLECTION_ADD = 'collection_add'; export const COLLECTION_DELETE = 'collection_delete'; export const CONFIRM_REMOVE_CARD = 'CONFIRM_REMOVE_CARD'; diff --git a/ui/modal/modalBlockChannel/index.js b/ui/modal/modalBlockChannel/index.js new file mode 100644 index 000000000..cf2fbc6df --- /dev/null +++ b/ui/modal/modalBlockChannel/index.js @@ -0,0 +1,25 @@ +import { connect } from 'react-redux'; +import { makeSelectClaimForUri } from 'lbry-redux'; +import { doHideModal } from 'redux/actions/app'; +import { doCommentModBlock, doCommentModBlockAsAdmin, doCommentModBlockAsModerator } from 'redux/actions/comments'; +import { selectActiveChannelClaim } from 'redux/selectors/app'; +import { selectModerationDelegatorsById } from 'redux/selectors/comments'; + +import ModalBlockChannel from './view'; + +const select = (state, props) => ({ + activeChannelClaim: selectActiveChannelClaim(state), + contentClaim: makeSelectClaimForUri(props.contentUri)(state), + moderationDelegatorsById: selectModerationDelegatorsById(state), +}); + +const perform = (dispatch) => ({ + closeModal: () => dispatch(doHideModal()), + commentModBlock: (commenterUri, timeoutHours) => dispatch(doCommentModBlock(commenterUri, timeoutHours)), + commentModBlockAsAdmin: (commenterUri, blockerId, timeoutHours) => + dispatch(doCommentModBlockAsAdmin(commenterUri, blockerId, timeoutHours)), + commentModBlockAsModerator: (commenterUri, creatorId, blockerId, timeoutHours) => + dispatch(doCommentModBlockAsModerator(commenterUri, creatorId, blockerId, timeoutHours)), +}); + +export default connect(select, perform)(ModalBlockChannel); diff --git a/ui/modal/modalBlockChannel/view.jsx b/ui/modal/modalBlockChannel/view.jsx new file mode 100644 index 000000000..454f1a370 --- /dev/null +++ b/ui/modal/modalBlockChannel/view.jsx @@ -0,0 +1,304 @@ +// @flow +import React from 'react'; +import classnames from 'classnames'; +import parseDuration from 'parse-duration'; +import Button from 'component/button'; +import ChannelThumbnail from 'component/channelThumbnail'; +import ClaimPreview from 'component/claimPreview'; +import Card from 'component/common/card'; +import { FormField } from 'component/common/form'; +import Icon from 'component/common/icon'; +import * as ICONS from 'constants/icons'; +import usePersistedState from 'effects/use-persisted-state'; +import { Modal } from 'modal/modal'; + +const TAB = { + PERSONAL: 'personal', + MODERATOR: 'moderator', + ADMIN: 'admin', +}; + +const BLOCK = { + PERMANENT: 'permanent', + TIMEOUT: 'timeout', +}; + +type Props = { + contentUri: string, + commenterUri: string, + // --- select --- + activeChannelClaim: ?ChannelClaim, + contentClaim: ?Claim, + moderationDelegatorsById: { [string]: { global: boolean, delegators: { name: string, claimId: string } } }, + // --- perform --- + closeModal: () => void, + commentModBlock: (string, ?number) => void, + commentModBlockAsAdmin: (string, string, ?number) => void, + commentModBlockAsModerator: (string, string, string, ?number) => void, +}; + +export default function ModalBlockChannel(props: Props) { + const { + commenterUri, + activeChannelClaim, + contentClaim, + moderationDelegatorsById, + closeModal, + commentModBlock, + commentModBlockAsAdmin, + commentModBlockAsModerator, + } = props; + + const contentChannelClaim = !contentClaim + ? null + : contentClaim.value_type === 'channel' + ? contentClaim + : contentClaim.signing_channel && contentClaim.is_channel_signature_valid + ? contentClaim.signing_channel + : null; + + const activeModeratorInfo = activeChannelClaim && moderationDelegatorsById[activeChannelClaim.claim_id]; + const activeChannelIsAdmin = activeChannelClaim && activeModeratorInfo && activeModeratorInfo.global; + const activeChannelIsModerator = + activeChannelClaim && + contentChannelClaim && + activeModeratorInfo && + Object.values(activeModeratorInfo.delegators).includes(contentChannelClaim.claim_id); + + const [tab, setTab] = usePersistedState('ModalBlockChannel:tab', TAB.PERSONAL); + const [blockType, setBlockType] = usePersistedState('ModalBlockChannel:blockType', BLOCK.PERMANENT); + const [timeoutInput, setTimeoutInput] = usePersistedState('ModalBlockChannel:timeoutInput', '10m'); + const [timeoutInputErr, setTimeoutInputErr] = React.useState(''); + const [timeoutSec, setTimeoutSec] = React.useState(-1); + + const isPersonalTheOnlyTab = !activeChannelIsModerator && !activeChannelIsAdmin; + const isTimeoutAvail = contentClaim && contentClaim.is_my_output; + const blockButtonDisabled = blockType === BLOCK.TIMEOUT && timeoutSec < 1; + + // ************************************************************************** + // ************************************************************************** + + // Check settings validity on mount. + React.useEffect(() => { + if ( + isPersonalTheOnlyTab || + (tab === TAB.MODERATOR && !activeChannelIsModerator) || + (tab === TAB.ADMIN && !activeChannelIsAdmin) + ) { + setTab(TAB.PERSONAL); + } + + if (!isTimeoutAvail && blockType === BLOCK.TIMEOUT) { + setBlockType(BLOCK.PERMANENT); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // 'timeoutInput' to 'timeoutSec' conversion. + React.useEffect(() => { + const setInvalid = (errMsg: string) => { + if (timeoutSec !== -1) { + setTimeoutSec(-1); + } + if (!timeoutInputErr) { + setTimeoutInputErr(errMsg); + } + }; + + const setValid = (seconds) => { + if (seconds !== timeoutSec) { + setTimeoutSec(seconds); + } + if (timeoutInputErr) { + setTimeoutInputErr(''); + } + }; + + const ONE_HUNDRED_YEARS_IN_SECONDS = 3154000000; + const seconds = parseDuration(timeoutInput, 's'); + + if (Number.isInteger(seconds) && seconds > 0) { + if (seconds > ONE_HUNDRED_YEARS_IN_SECONDS) { + setInvalid('Wow, banned for more than 100 years?'); + } else { + setValid(seconds); + } + } else { + setInvalid('Invalid duration.'); + } + }, [timeoutInput, timeoutInputErr, timeoutSec]); + + // ************************************************************************** + // ************************************************************************** + + function getTabElem(value, label) { + return ( +
+ + + } + /> + + ); +} diff --git a/ui/modal/modalRouter/view.jsx b/ui/modal/modalRouter/view.jsx index 3f8e729f2..856824f28 100644 --- a/ui/modal/modalRouter/view.jsx +++ b/ui/modal/modalRouter/view.jsx @@ -5,32 +5,71 @@ import { lazyImport } from 'util/lazyImport'; import * as MODALS from 'constants/modal_types'; import LoadingBarOneOff from 'component/loadingBarOneOff'; -const ModalAffirmPurchase = lazyImport(() => import('modal/modalAffirmPurchase' /* webpackChunkName: "modalAffirmPurchase" */)); -const ModalAutoGenerateThumbnail = lazyImport(() => import('modal/modalAutoGenerateThumbnail' /* webpackChunkName: "modalAutoGenerateThumbnail" */)); -const ModalAutoUpdateDownloaded = lazyImport(() => import('modal/modalAutoUpdateDownloaded' /* webpackChunkName: "modalAutoUpdateDownloaded" */)); -const ModalClaimCollectionAdd = lazyImport(() => import('modal/modalClaimCollectionAdd' /* webpackChunkName: "modalClaimCollectionAdd" */)); -const ModalCommentAcknowledgement = lazyImport(() => import('modal/modalCommentAcknowledgement' /* webpackChunkName: "modalCommentAcknowledgement" */)); +const ModalAffirmPurchase = lazyImport(() => + import('modal/modalAffirmPurchase' /* webpackChunkName: "modalAffirmPurchase" */) +); +const ModalAutoGenerateThumbnail = lazyImport(() => + import('modal/modalAutoGenerateThumbnail' /* webpackChunkName: "modalAutoGenerateThumbnail" */) +); +const ModalAutoUpdateDownloaded = lazyImport(() => + import('modal/modalAutoUpdateDownloaded' /* webpackChunkName: "modalAutoUpdateDownloaded" */) +); +const ModalBlockChannel = lazyImport(() => + import('modal/modalBlockChannel' /* webpackChunkName: "modalBlockChannel" */) +); +const ModalClaimCollectionAdd = lazyImport(() => + import('modal/modalClaimCollectionAdd' /* webpackChunkName: "modalClaimCollectionAdd" */) +); +const ModalCommentAcknowledgement = lazyImport(() => + import('modal/modalCommentAcknowledgement' /* webpackChunkName: "modalCommentAcknowledgement" */) +); const ModalConfirmAge = lazyImport(() => import('modal/modalConfirmAge' /* webpackChunkName: "modalConfirmAge" */)); -const ModalConfirmThumbnailUpload = lazyImport(() => import('modal/modalConfirmThumbnailUpload' /* webpackChunkName: "modalConfirmThumbnailUpload" */)); -const ModalConfirmTransaction = lazyImport(() => import('modal/modalConfirmTransaction' /* webpackChunkName: "modalConfirmTransaction" */)); -const ModalDeleteCollection = lazyImport(() => import('modal/modalRemoveCollection' /* webpackChunkName: "modalRemoveCollection" */)); +const ModalConfirmThumbnailUpload = lazyImport(() => + import('modal/modalConfirmThumbnailUpload' /* webpackChunkName: "modalConfirmThumbnailUpload" */) +); +const ModalConfirmTransaction = lazyImport(() => + import('modal/modalConfirmTransaction' /* webpackChunkName: "modalConfirmTransaction" */) +); +const ModalDeleteCollection = lazyImport(() => + import('modal/modalRemoveCollection' /* webpackChunkName: "modalRemoveCollection" */) +); const ModalDownloading = lazyImport(() => import('modal/modalDownloading' /* webpackChunkName: "modalDownloading" */)); const ModalError = lazyImport(() => import('modal/modalError' /* webpackChunkName: "modalError" */)); -const ModalFileSelection = lazyImport(() => import('modal/modalFileSelection' /* webpackChunkName: "modalFileSelection" */)); +const ModalFileSelection = lazyImport(() => + import('modal/modalFileSelection' /* webpackChunkName: "modalFileSelection" */) +); const ModalFileTimeout = lazyImport(() => import('modal/modalFileTimeout' /* webpackChunkName: "modalFileTimeout" */)); const ModalFirstReward = lazyImport(() => import('modal/modalFirstReward' /* webpackChunkName: "modalFirstReward" */)); -const ModalFirstSubscription = lazyImport(() => import('modal/modalFirstSubscription' /* webpackChunkName: "modalFirstSubscription" */)); +const ModalFirstSubscription = lazyImport(() => + import('modal/modalFirstSubscription' /* webpackChunkName: "modalFirstSubscription" */) +); const ModalImageUpload = lazyImport(() => import('modal/modalImageUpload' /* webpackChunkName: "modalImageUpload" */)); -const ModalMassTipsUnlock = lazyImport(() => import('modal/modalMassTipUnlock' /* webpackChunkName: "modalMassTipUnlock" */)); -const ModalMobileSearch = lazyImport(() => import('modal/modalMobileSearch' /* webpackChunkName: "modalMobileSearch" */)); -const ModalOpenExternalResource = lazyImport(() => import('modal/modalOpenExternalResource' /* webpackChunkName: "modalOpenExternalResource" */)); -const ModalPasswordUnsave = lazyImport(() => import('modal/modalPasswordUnsave' /* webpackChunkName: "modalPasswordUnsave" */)); -const ModalPhoneCollection = lazyImport(() => import('modal/modalPhoneCollection' /* webpackChunkName: "modalPhoneCollection" */)); +const ModalMassTipsUnlock = lazyImport(() => + import('modal/modalMassTipUnlock' /* webpackChunkName: "modalMassTipUnlock" */) +); +const ModalMobileSearch = lazyImport(() => + import('modal/modalMobileSearch' /* webpackChunkName: "modalMobileSearch" */) +); +const ModalOpenExternalResource = lazyImport(() => + import('modal/modalOpenExternalResource' /* webpackChunkName: "modalOpenExternalResource" */) +); +const ModalPasswordUnsave = lazyImport(() => + import('modal/modalPasswordUnsave' /* webpackChunkName: "modalPasswordUnsave" */) +); +const ModalPhoneCollection = lazyImport(() => + import('modal/modalPhoneCollection' /* webpackChunkName: "modalPhoneCollection" */) +); const ModalPublish = lazyImport(() => import('modal/modalPublish' /* webpackChunkName: "modalPublish" */)); -const ModalPublishPreview = lazyImport(() => import('modal/modalPublishPreview' /* webpackChunkName: "modalPublishPreview" */)); -const ModalRemoveBtcSwapAddress = lazyImport(() => import('modal/modalRemoveBtcSwapAddress' /* webpackChunkName: "modalRemoveBtcSwapAddress" */)); +const ModalPublishPreview = lazyImport(() => + import('modal/modalPublishPreview' /* webpackChunkName: "modalPublishPreview" */) +); +const ModalRemoveBtcSwapAddress = lazyImport(() => + import('modal/modalRemoveBtcSwapAddress' /* webpackChunkName: "modalRemoveBtcSwapAddress" */) +); const ModalRemoveCard = lazyImport(() => import('modal/modalRemoveCard' /* webpackChunkName: "modalRemoveCard" */)); -const ModalRemoveComment = lazyImport(() => import('modal/modalRemoveComment' /* webpackChunkName: "modalRemoveComment" */)); +const ModalRemoveComment = lazyImport(() => + import('modal/modalRemoveComment' /* webpackChunkName: "modalRemoveComment" */) +); const ModalRemoveFile = lazyImport(() => import('modal/modalRemoveFile' /* webpackChunkName: "modalRemoveFile" */)); const ModalRevokeClaim = lazyImport(() => import('modal/modalRevokeClaim' /* webpackChunkName: "modalRevokeClaim" */)); const ModalRewardCode = lazyImport(() => import('modal/modalRewardCode' /* webpackChunkName: "modalRewardCode" */)); @@ -38,15 +77,27 @@ const ModalSendTip = lazyImport(() => import('modal/modalSendTip' /* webpackChun const ModalSetReferrer = lazyImport(() => import('modal/modalSetReferrer' /* webpackChunkName: "modalSetReferrer" */)); const ModalSignOut = lazyImport(() => import('modal/modalSignOut' /* webpackChunkName: "modalSignOut" */)); const ModalSocialShare = lazyImport(() => import('modal/modalSocialShare' /* webpackChunkName: "modalSocialShare" */)); -const ModalSupportsLiquidate = lazyImport(() => import('modal/modalSupportsLiquidate' /* webpackChunkName: "modalSupportsLiquidate" */)); +const ModalSupportsLiquidate = lazyImport(() => + import('modal/modalSupportsLiquidate' /* webpackChunkName: "modalSupportsLiquidate" */) +); const ModalSyncEnable = lazyImport(() => import('modal/modalSyncEnable' /* webpackChunkName: "modalSyncEnable" */)); -const ModalTransactionFailed = lazyImport(() => import('modal/modalTransactionFailed' /* webpackChunkName: "modalTransactionFailed" */)); +const ModalTransactionFailed = lazyImport(() => + import('modal/modalTransactionFailed' /* webpackChunkName: "modalTransactionFailed" */) +); const ModalUpgrade = lazyImport(() => import('modal/modalUpgrade' /* webpackChunkName: "modalUpgrade" */)); const ModalViewImage = lazyImport(() => import('modal/modalViewImage' /* webpackChunkName: "modalViewImage" */)); -const ModalWalletDecrypt = lazyImport(() => import('modal/modalWalletDecrypt' /* webpackChunkName: "modalWalletDecrypt" */)); -const ModalWalletEncrypt = lazyImport(() => import('modal/modalWalletEncrypt' /* webpackChunkName: "modalWalletEncrypt" */)); -const ModalWalletUnlock = lazyImport(() => import('modal/modalWalletUnlock' /* webpackChunkName: "modalWalletUnlock" */)); -const ModalYoutubeWelcome = lazyImport(() => import('modal/modalYoutubeWelcome' /* webpackChunkName: "modalYoutubeWelcome" */)); +const ModalWalletDecrypt = lazyImport(() => + import('modal/modalWalletDecrypt' /* webpackChunkName: "modalWalletDecrypt" */) +); +const ModalWalletEncrypt = lazyImport(() => + import('modal/modalWalletEncrypt' /* webpackChunkName: "modalWalletEncrypt" */) +); +const ModalWalletUnlock = lazyImport(() => + import('modal/modalWalletUnlock' /* webpackChunkName: "modalWalletUnlock" */) +); +const ModalYoutubeWelcome = lazyImport(() => + import('modal/modalYoutubeWelcome' /* webpackChunkName: "modalYoutubeWelcome" */) +); type Props = { modal: { id: string, modalProps: {} }, @@ -149,6 +200,8 @@ function ModalRouter(props: Props) { return ModalMassTipsUnlock; case MODALS.CONFIRM_REMOVE_BTC_SWAP_ADDRESS: return ModalRemoveBtcSwapAddress; + case MODALS.BLOCK_CHANNEL: + return ModalBlockChannel; case MODALS.COLLECTION_ADD: return ModalClaimCollectionAdd; case MODALS.COLLECTION_DELETE: diff --git a/ui/page/listBlocked/index.js b/ui/page/listBlocked/index.js index e3e1d5c55..178b11aa9 100644 --- a/ui/page/listBlocked/index.js +++ b/ui/page/listBlocked/index.js @@ -8,6 +8,9 @@ import { selectModeratorBlockListDelegatorsMap, selectFetchingModerationBlockList, selectModerationDelegatorsById, + selectAdminTimeoutMap, + selectModeratorTimeoutMap, + selectPersonalTimeoutMap, } from 'redux/selectors/comments'; import { selectMyChannelClaims } from 'lbry-redux'; import ListBlocked from './view'; @@ -17,6 +20,9 @@ const select = (state) => ({ personalBlockList: selectModerationBlockList(state), adminBlockList: selectAdminBlockList(state), moderatorBlockList: selectModeratorBlockList(state), + personalTimeoutMap: selectPersonalTimeoutMap(state), + adminTimeoutMap: selectAdminTimeoutMap(state), + moderatorTimeoutMap: selectModeratorTimeoutMap(state), moderatorBlockListDelegatorsMap: selectModeratorBlockListDelegatorsMap(state), delegatorsById: selectModerationDelegatorsById(state), myChannelClaims: selectMyChannelClaims(state), diff --git a/ui/page/listBlocked/view.jsx b/ui/page/listBlocked/view.jsx index 85f355947..8036a8f1f 100644 --- a/ui/page/listBlocked/view.jsx +++ b/ui/page/listBlocked/view.jsx @@ -3,6 +3,8 @@ import * as ICONS from 'constants/icons'; import { BLOCK_LEVEL } from 'constants/comment'; import React from 'react'; import classnames from 'classnames'; +import moment from 'moment'; +import humanizeDuration from 'humanize-duration'; import ClaimList from 'component/claimList'; import ClaimPreview from 'component/claimPreview'; import Page from 'component/page'; @@ -25,6 +27,9 @@ type Props = { personalBlockList: ?Array, adminBlockList: ?Array, moderatorBlockList: ?Array, + personalTimeoutMap: { [uri: string]: { blockedAt: string, bannedFor: number, banRemaining: number } }, + adminTimeoutMap: { [uri: string]: { blockedAt: string, bannedFor: number, banRemaining: number } }, + moderatorTimeoutMap: { [uri: string]: { blockedAt: string, bannedFor: number, banRemaining: number } }, moderatorBlockListDelegatorsMap: { [string]: Array }, fetchingModerationBlockList: boolean, fetchModBlockedList: () => void, @@ -39,6 +44,9 @@ function ListBlocked(props: Props) { personalBlockList, adminBlockList, moderatorBlockList, + personalTimeoutMap, + adminTimeoutMap, + moderatorTimeoutMap, moderatorBlockListDelegatorsMap, fetchingModerationBlockList, fetchModBlockedList, @@ -97,17 +105,45 @@ function ListBlocked(props: Props) { } function getButtons(view, uri) { + const getDurationStr = (durationNs) => { + const NANO_TO_MS = 1000000; + return humanizeDuration(durationNs / NANO_TO_MS, { round: true }); + }; + + const getBanInfoElem = (timeoutInfo) => { + return ( +
+
+
+ {moment(timeoutInfo.blockedAt).format('MMMM Do, YYYY @ HH:mm')} +
+ {getDurationStr(timeoutInfo.bannedFor)}{' '} + {__('(Remaining: %duration%) --[timeout ban duration]--', { + duration: getDurationStr(timeoutInfo.banRemaining), + })} +
+
+
+ ); + }; + switch (view) { case VIEW.BLOCKED: return ( <> + {personalTimeoutMap[uri] && getBanInfoElem(personalTimeoutMap[uri])} ); case VIEW.ADMIN: - return ; + return ( + <> + + {adminTimeoutMap[uri] && getBanInfoElem(adminTimeoutMap[uri])} + + ); case VIEW.MODERATOR: const delegatorUrisForBlockedUri = localModeratorListDelegatorsMap && localModeratorListDelegatorsMap[uri]; @@ -121,6 +157,7 @@ function ListBlocked(props: Props) { + {moderatorTimeoutMap[uri] && getBanInfoElem(moderatorTimeoutMap[uri])} ); })} diff --git a/ui/redux/actions/comments.js b/ui/redux/actions/comments.js index a1ce7d175..dc85ba754 100644 --- a/ui/redux/actions/comments.js +++ b/ui/redux/actions/comments.js @@ -738,6 +738,7 @@ function doCommentModToggleBlock( creatorId: string, blockerIds: Array, // [] = use all my channels blockLevel: string, + timeoutSec?: number, showLink: boolean = false ) { return async (dispatch: Dispatch, getState: GetState) => { @@ -844,6 +845,7 @@ function doCommentModToggleBlock( block_all: unblock ? undefined : blockLevel === BLOCK_LEVEL.ADMIN, global_un_block: unblock ? blockLevel === BLOCK_LEVEL.ADMIN : undefined, ...sharedModBlockParams, + time_out: unblock ? undefined : timeoutSec, }) ) ) @@ -920,12 +922,13 @@ function doCommentModToggleBlock( * Blocks the commenter for all channels that I own. * * @param commenterUri + * @param timeoutHours * @param showLink * @returns {function(Dispatch): *} */ -export function doCommentModBlock(commenterUri: string, showLink: boolean = true) { +export function doCommentModBlock(commenterUri: string, timeoutHours?: number, showLink: boolean = true) { return (dispatch: Dispatch) => { - return dispatch(doCommentModToggleBlock(false, commenterUri, '', [], BLOCK_LEVEL.SELF, showLink)); + return dispatch(doCommentModToggleBlock(false, commenterUri, '', [], BLOCK_LEVEL.SELF, timeoutHours, showLink)); }; } @@ -934,11 +937,14 @@ export function doCommentModBlock(commenterUri: string, showLink: boolean = true * * @param commenterUri * @param blockerId + * @param timeoutHours * @returns {function(Dispatch): *} */ -export function doCommentModBlockAsAdmin(commenterUri: string, blockerId: string) { +export function doCommentModBlockAsAdmin(commenterUri: string, blockerId: string, timeoutHours?: number) { return (dispatch: Dispatch) => { - return dispatch(doCommentModToggleBlock(false, commenterUri, '', blockerId ? [blockerId] : [], BLOCK_LEVEL.ADMIN)); + return dispatch( + doCommentModToggleBlock(false, commenterUri, '', blockerId ? [blockerId] : [], BLOCK_LEVEL.ADMIN, timeoutHours) + ); }; } @@ -949,12 +955,25 @@ export function doCommentModBlockAsAdmin(commenterUri: string, blockerId: string * @param commenterUri * @param creatorId * @param blockerId + * @param timeoutHours * @returns {function(Dispatch): *} */ -export function doCommentModBlockAsModerator(commenterUri: string, creatorId: string, blockerId: string) { +export function doCommentModBlockAsModerator( + commenterUri: string, + creatorId: string, + blockerId: string, + timeoutHours?: number +) { return (dispatch: Dispatch) => { return dispatch( - doCommentModToggleBlock(false, commenterUri, creatorId, blockerId ? [blockerId] : [], BLOCK_LEVEL.MODERATOR) + doCommentModToggleBlock( + false, + commenterUri, + creatorId, + blockerId ? [blockerId] : [], + BLOCK_LEVEL.MODERATOR, + timeoutHours + ) ); }; } @@ -968,7 +987,7 @@ export function doCommentModBlockAsModerator(commenterUri: string, creatorId: st */ export function doCommentModUnBlock(commenterUri: string, showLink: boolean = true) { return (dispatch: Dispatch) => { - return dispatch(doCommentModToggleBlock(true, commenterUri, '', [], BLOCK_LEVEL.SELF, showLink)); + return dispatch(doCommentModToggleBlock(true, commenterUri, '', [], BLOCK_LEVEL.SELF, undefined, showLink)); }; } @@ -1035,13 +1054,20 @@ export function doFetchModBlockedList() { let moderatorBlockList = []; let moderatorBlockListDelegatorsMap = {}; + // These should just be part of the block list above, but it is + // separated for now because there are too many clients that we need + // to update. + const personalTimeoutMap = {}; + const adminTimeoutMap = {}; + const moderatorTimeoutMap = {}; + const blockListsPerChannel = res.map((r) => r.value); blockListsPerChannel .sort((a, b) => { return 1; }) .forEach((channelBlockLists) => { - const storeList = (fetchedList, blockedList, blockedByMap) => { + const storeList = (fetchedList, blockedList, timeoutMap, blockedByMap) => { if (fetchedList) { fetchedList.forEach((blockedChannel) => { if (blockedChannel.blocked_channel_name) { @@ -1052,6 +1078,14 @@ export function doFetchModBlockedList() { if (!blockedList.find((blockedChannel) => isURIEqual(blockedChannel.channelUri, channelUri))) { blockedList.push({ channelUri, blockedAt: blockedChannel.blocked_at }); + + if (blockedChannel.banned_for) { + timeoutMap[channelUri] = { + blockedAt: blockedChannel.blocked_at, + bannedFor: blockedChannel.banned_for, + banRemaining: blockedChannel.ban_remaining, + }; + } } if (blockedByMap !== undefined) { @@ -1077,9 +1111,14 @@ export function doFetchModBlockedList() { const globally_blocked_channels = channelBlockLists && channelBlockLists.globally_blocked_channels; const delegated_blocked_channels = channelBlockLists && channelBlockLists.delegated_blocked_channels; - storeList(blocked_channels, personalBlockList); - storeList(globally_blocked_channels, adminBlockList); - storeList(delegated_blocked_channels, moderatorBlockList, moderatorBlockListDelegatorsMap); + storeList(blocked_channels, personalBlockList, personalTimeoutMap); + storeList(globally_blocked_channels, adminBlockList, adminTimeoutMap); + storeList( + delegated_blocked_channels, + moderatorBlockList, + moderatorTimeoutMap, + moderatorBlockListDelegatorsMap + ); }); dispatch({ @@ -1104,6 +1143,9 @@ export function doFetchModBlockedList() { .map((blockedChannel) => blockedChannel.channelUri) : null, moderatorBlockListDelegatorsMap: moderatorBlockListDelegatorsMap, + personalTimeoutMap, + adminTimeoutMap, + moderatorTimeoutMap, }, }); }) diff --git a/ui/redux/reducers/comments.js b/ui/redux/reducers/comments.js index 815025698..04c0bed8d 100644 --- a/ui/redux/reducers/comments.js +++ b/ui/redux/reducers/comments.js @@ -40,6 +40,9 @@ const defaultState: CommentsState = { fetchingModerationDelegators: false, blockingByUri: {}, unBlockingByUri: {}, + personalTimeoutMap: {}, + adminTimeoutMap: {}, + moderatorTimeoutMap: {}, togglingForDelegatorMap: {}, settingsByChannelId: {}, // ChannelId -> PerChannelSettings fetchingSettings: false, @@ -671,14 +674,25 @@ export default handleActions( fetchingModerationBlockList: true, }), [ACTIONS.COMMENT_MODERATION_BLOCK_LIST_COMPLETED]: (state: CommentsState, action: any) => { - const { personalBlockList, adminBlockList, moderatorBlockList, moderatorBlockListDelegatorsMap } = action.data; + const { + personalBlockList, + adminBlockList, + moderatorBlockList, + moderatorBlockListDelegatorsMap, + personalTimeoutMap, + adminTimeoutMap, + moderatorTimeoutMap, + } = action.data; return { ...state, moderationBlockList: personalBlockList, adminBlockList: adminBlockList, moderatorBlockList: moderatorBlockList, - moderatorBlockListDelegatorsMap: moderatorBlockListDelegatorsMap, + moderatorBlockListDelegatorsMap, + personalTimeoutMap, + adminTimeoutMap, + moderatorTimeoutMap, fetchingModerationBlockList: false, }; }, diff --git a/ui/redux/selectors/comments.js b/ui/redux/selectors/comments.js index e4c21ff54..82365b9fa 100644 --- a/ui/redux/selectors/comments.js +++ b/ui/redux/selectors/comments.js @@ -45,6 +45,10 @@ export const selectModeratorBlockList = createSelector(selectState, (state) => state.moderatorBlockList ? state.moderatorBlockList.reverse() : [] ); +export const selectPersonalTimeoutMap = createSelector(selectState, (state) => state.personalTimeoutMap); +export const selectAdminTimeoutMap = createSelector(selectState, (state) => state.adminTimeoutMap); +export const selectModeratorTimeoutMap = createSelector(selectState, (state) => state.moderatorTimeoutMap); + export const selectModeratorBlockListDelegatorsMap = createSelector( selectState, (state) => state.moderatorBlockListDelegatorsMap @@ -210,7 +214,7 @@ export const makeSelectCommentsForUri = (uri: string) => (state, byClaimId, byUri) => { const claimId = byUri[uri]; const comments = byClaimId && byClaimId[claimId]; - return makeSelectFilteredComments(comments)(state); + return makeSelectFilteredComments(comments, claimId)(state); } ); @@ -222,7 +226,7 @@ export const makeSelectTopLevelCommentsForUri = (uri: string) => (state, byClaimId, byUri) => { const claimId = byUri[uri]; const comments = byClaimId && byClaimId[claimId]; - return makeSelectFilteredComments(comments)(state); + return makeSelectFilteredComments(comments, claimId)(state); } ); @@ -258,7 +262,13 @@ export const makeSelectRepliesForParentId = (id: string) => } ); -const makeSelectFilteredComments = (comments: Array) => +/** + * makeSelectFilteredComments + * + * @param comments List of comments to filter. + * @param claimId The claim that `comments` reside in. + */ +const makeSelectFilteredComments = (comments: Array, claimId?: string) => createSelector( selectClaimsById, selectMyActiveClaims, @@ -311,12 +321,18 @@ const makeSelectFilteredComments = (comments: Array) => } } - return !( - mutedChannels.includes(comment.channel_url) || - personalBlockList.includes(comment.channel_url) || - adminBlockList.includes(comment.channel_url) || - moderatorBlockList.includes(comment.channel_url) - ); + if (claimId) { + const claimIdIsMine = myClaims && myClaims.size > 0 && myClaims.has(claimId); + if (!claimIdIsMine) { + return !( + personalBlockList.includes(comment.channel_url) || + adminBlockList.includes(comment.channel_url) || + moderatorBlockList.includes(comment.channel_url) + ); + } + } + + return !mutedChannels.includes(comment.channel_url); }) : []; } diff --git a/ui/scss/component/_block-list.scss b/ui/scss/component/_block-list.scss index 4caf7d1a4..bf46aed68 100644 --- a/ui/scss/component/_block-list.scss +++ b/ui/scss/component/_block-list.scss @@ -16,3 +16,43 @@ margin-top: var(--spacing-xxs); } } + +.block-modal--values { + margin-left: var(--spacing-s); + + .help { + font-style: italic; + font-size: var(--font-xsmall); + } +} + +.block-modal--finalize { + margin-top: var(--spacing-l); +} + +.block-modal--active-channel { + padding: var(--spacing-xs); + display: flex; + align-items: center; + + .channel-thumbnail { + margin-right: var(--spacing-xs); + height: 1.8rem; + width: 1.8rem; + } + + @media (min-width: $breakpoint-small) { + border-left: 1px solid var(--color-border); + padding-left: var(--spacing-m); + margin-left: calc(var(--spacing-l) * 2); + } +} + +.block-modal--active-channel-label { + @extend .help; + font-size: var(--font-xxsmall); + margin-top: 0; + max-width: 10rem; + white-space: pre-line; + margin-right: var(--spacing-s); +} diff --git a/yarn.lock b/yarn.lock index 2b521ec43..c65da973f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8558,6 +8558,11 @@ https-proxy-agent@^4.0.0: agent-base "5" debug "4" +humanize-duration@^3.27.0: + version "3.27.0" + resolved "https://registry.yarnpkg.com/humanize-duration/-/humanize-duration-3.27.0.tgz#3f781b7cf8022ad587f76b9839b60bc2b29636b2" + integrity sha512-qLo/08cNc3Tb0uD7jK0jAcU5cnqCM0n568918E7R2XhMr/+7F37p4EY062W/stg7tmzvknNn9b/1+UhVRzsYrQ== + humanize-plus@^1.8.1: version "1.8.2" resolved "https://registry.yarnpkg.com/humanize-plus/-/humanize-plus-1.8.2.tgz#a65b34459ad6367adbb3707a82a3c9f916167030" @@ -11916,6 +11921,11 @@ parse-asn1@^5.0.0: pbkdf2 "^3.0.3" safe-buffer "^5.1.1" +parse-duration@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/parse-duration/-/parse-duration-1.0.0.tgz#8605651745f61088f6fb14045c887526c291858c" + integrity sha512-X4kUkCTHU1N/kEbwK9FpUJ0UZQa90VzeczfS704frR30gljxDG0pSziws06XlK+CGRSo/1wtG1mFIdBFQTMQNw== + parse-entities@^1.0.2, parse-entities@^1.1.0: version "1.2.2" resolved "https://registry.yarnpkg.com/parse-entities/-/parse-entities-1.2.2.tgz#c31bf0f653b6661354f8973559cb86dd1d5edf50"