diff --git a/flow-typed/Comment.js b/flow-typed/Comment.js index ffda63876..2ac88e1f1 100644 --- a/flow-typed/Comment.js +++ b/flow-typed/Comment.js @@ -189,7 +189,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_hrs?: 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/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..50de3ac7b --- /dev/null +++ b/ui/modal/modalBlockChannel/view.jsx @@ -0,0 +1,253 @@ +// @flow +import React from 'react'; +import classnames from 'classnames'; +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 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 [timeoutHrs, setTimeoutHrs] = usePersistedState('ModalBlockChannel:timeoutHrs', 1); + const [timeoutHrsError, setTimeoutHrsError] = React.useState(''); + + const personalIsTheOnlyTab = !activeChannelIsModerator && !activeChannelIsAdmin; + const blockButtonDisabled = blockType === BLOCK.TIMEOUT && (timeoutHrs === 0 || !Number.isInteger(timeoutHrs)); + + // ************************************************************************** + // ************************************************************************** + + // Check 'tab' validity on mount. + React.useEffect(() => { + if ( + personalIsTheOnlyTab || + (tab === TAB.MODERATOR && !activeChannelIsModerator) || + (tab === TAB.ADMIN && !activeChannelIsAdmin) + ) { + setTab(TAB.PERSONAL); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // 'timeoutHrs' sanity check. + React.useEffect(() => { + if (Number.isInteger(timeoutHrs) && timeoutHrs > 0) { + if (timeoutHrsError) { + setTimeoutHrsError(''); + } + } else { + if (!timeoutHrsError) { + setTimeoutHrsError('Invalid duration.'); + } + } + }, [timeoutHrs, timeoutHrsError]); + + // ************************************************************************** + // ************************************************************************** + + function getTabElem(value, label) { + return ( +
+ + + } + /> + + ); +} diff --git a/ui/modal/modalRouter/view.jsx b/ui/modal/modalRouter/view.jsx index 3f8e729f2..143dc6e11 100644 --- a/ui/modal/modalRouter/view.jsx +++ b/ui/modal/modalRouter/view.jsx @@ -8,6 +8,7 @@ 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 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" */)); @@ -149,6 +150,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/redux/actions/comments.js b/ui/redux/actions/comments.js index a1ce7d175..272f6048a 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, + timeoutHours?: 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_hrs: unblock ? undefined : timeoutHours, }) ) ) @@ -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)); }; } 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); +}