diff --git a/static/app-strings.json b/static/app-strings.json index 3609586204..6776b788e6 100644 --- a/static/app-strings.json +++ b/static/app-strings.json @@ -2983,6 +2983,10 @@ "Creator only": "Creator only", "Would you like to enable them? Homepage recommendations placement can be configured from the homepage customization.": "Would you like to enable them? Homepage recommendations placement can be configured from the homepage customization.", "Homepage recommendations available": "Homepage recommendations available", + "Remove this user as a moderator of your channel.": "Remove this user as a moderator of your channel.", + "Remove this user as a moderator of %channel%.": "Remove this user as a moderator of %channel%.", + "Remove as moderator": "Remove as moderator", + "Removed %user% from moderators of %myChannel%": "Removed %user% from moderators of %myChannel%", "--end--": "--end--" } diff --git a/ui/component/app/index.js b/ui/component/app/index.js index 0404b04b90..4536e1af49 100644 --- a/ui/component/app/index.js +++ b/ui/component/app/index.js @@ -32,7 +32,11 @@ import { } from 'redux/actions/settings'; import { doSyncLoop } from 'redux/actions/sync'; import { doSignIn, doSetIncognito, doSetAssignedLbrynetServer, doOpenModal } from 'redux/actions/app'; -import { doFetchModBlockedList, doFetchCommentModAmIList } from 'redux/actions/comments'; +import { + doFetchModBlockedList, + doFetchCommentModAmIList, + doCommentModListDelegatesForMyChannels, +} from 'redux/actions/comments'; import App from './view'; const select = (state) => ({ @@ -68,6 +72,7 @@ const perform = { setIncognito: doSetIncognito, fetchModBlockedList: doFetchModBlockedList, fetchModAmIList: doFetchCommentModAmIList, + fetchDelegatesForMyChannels: doCommentModListDelegatesForMyChannels, doOpenAnnouncements, doSetLastViewedAnnouncement, doSetDefaultChannel, diff --git a/ui/component/app/view.jsx b/ui/component/app/view.jsx index 5b246714df..83b65c5476 100644 --- a/ui/component/app/view.jsx +++ b/ui/component/app/view.jsx @@ -89,6 +89,7 @@ type Props = { setIncognito: (boolean) => void, fetchModBlockedList: () => void, fetchModAmIList: () => void, + fetchDelegatesForMyChannels: () => void, homepageFetched: boolean, defaultChannelClaim: ?any, nagsShown: boolean, @@ -134,6 +135,7 @@ function App(props: Props) { setIncognito, fetchModBlockedList, fetchModAmIList, + fetchDelegatesForMyChannels, defaultChannelClaim, announcement, homepageOrder, @@ -357,6 +359,7 @@ function App(props: Props) { if (hasMyChannels) { fetchModBlockedList(); fetchModAmIList(); + fetchDelegatesForMyChannels(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [hasMyChannels, hasNoChannels, setIncognito]); diff --git a/ui/component/commentMenuList/index.js b/ui/component/commentMenuList/index.js index c0ae84d557..adfafec0ab 100644 --- a/ui/component/commentMenuList/index.js +++ b/ui/component/commentMenuList/index.js @@ -1,12 +1,12 @@ import { connect } from 'react-redux'; import { doChannelMute } from 'redux/actions/blocked'; -import { doCommentPin, doCommentModAddDelegate } from 'redux/actions/comments'; +import { doCommentPin, doCommentModAddDelegate, doCommentModRemoveDelegate } from 'redux/actions/comments'; import { doOpenModal, doSetActiveChannel } from 'redux/actions/app'; import { doClearPlayingUri } from 'redux/actions/content'; import { doToast } from 'redux/actions/notifications'; import { selectClaimIsMine, selectClaimForUri } from 'redux/selectors/claims'; import { selectActiveChannelClaim } from 'redux/selectors/app'; -import { selectModerationDelegatorsById } from 'redux/selectors/comments'; +import { selectModerationDelegatorsById, selectModerationDelegatesById } from 'redux/selectors/comments'; import { selectPlayingUri } from 'redux/selectors/content'; import { selectUserVerifiedEmail } from 'redux/selectors/user'; import CommentMenuList from './view'; @@ -25,6 +25,7 @@ const select = (state, props) => { channelIsMine: selectClaimIsMine(state, authorClaim), playingUri: selectPlayingUri(state), moderationDelegatorsById: selectModerationDelegatorsById(state), + moderationDelegatesById: selectModerationDelegatesById(state), authorCanonicalUri, authorId, }; @@ -38,6 +39,8 @@ const perform = (dispatch) => ({ pinComment: (commentId, claimId, remove) => dispatch(doCommentPin(commentId, claimId, remove)), commentModAddDelegate: (modChanId, modChanName, creatorChannelClaim) => dispatch(doCommentModAddDelegate(modChanId, modChanName, creatorChannelClaim, true)), + commentModRemoveDelegate: (modChanId, modChanName, creatorChannelClaim) => + dispatch(doCommentModRemoveDelegate(modChanId, modChanName, creatorChannelClaim, true)), doSetActiveChannel: (authorId) => dispatch(doSetActiveChannel(authorId)), }); diff --git a/ui/component/commentMenuList/view.jsx b/ui/component/commentMenuList/view.jsx index 48ba7aa5ab..8d9dd98f24 100644 --- a/ui/component/commentMenuList/view.jsx +++ b/ui/component/commentMenuList/view.jsx @@ -36,6 +36,7 @@ type Props = { activeChannelClaim: ?ChannelClaim, playingUri: PlayingUri, moderationDelegatorsById: { [string]: { global: boolean, delegators: { name: string, claimId: string } } }, + moderationDelegatesById: { [?string]: ?Array }, authorCanonicalUri: ?string, authorId: string, // --- perform --- @@ -47,6 +48,7 @@ type Props = { doSetActiveChannel: (string) => void, pinComment: (string, string, boolean) => Promise, commentModAddDelegate: (string, string, ChannelClaim) => void, + commentModRemoveDelegate: (string, string, ChannelClaim) => void, setQuickReply: (any) => void, handleDismissPin?: () => void, }; @@ -66,6 +68,7 @@ function CommentMenuList(props: Props) { isPinned, playingUri, moderationDelegatorsById, + moderationDelegatesById, authorCanonicalUri, isAuthenticated, disableEdit, @@ -81,6 +84,7 @@ function CommentMenuList(props: Props) { doSetActiveChannel, pinComment, commentModAddDelegate, + commentModRemoveDelegate, setQuickReply, handleDismissPin, } = props; @@ -95,6 +99,8 @@ function CommentMenuList(props: Props) { const contentChannelClaim = getChannelFromClaim(claim); const contentChannelPermanentUrl = contentChannelClaim && contentChannelClaim.permanent_url; + const delegates = moderationDelegatesById[contentChannelClaim?.claim_id]; + const activeModeratorInfo = activeChannelClaim && moderationDelegatorsById[activeChannelClaim.claim_id]; const activeChannelIsCreator = activeChannelClaim && activeChannelClaim.permanent_url === contentChannelPermanentUrl; const activeChannelIsAdmin = activeChannelClaim && activeModeratorInfo && activeModeratorInfo.global; @@ -103,6 +109,10 @@ function CommentMenuList(props: Props) { contentChannelClaim && activeModeratorInfo && Object.values(activeModeratorInfo.delegators).includes(contentChannelClaim.claim_id); + const authorIsModerator = + claimIsMine && // only check for own claims + Array.isArray(delegates) && + delegates.some((delegate) => delegate.channelId === authorId); function handleDeleteComment() { if (playingUri.source === 'comment') { @@ -126,6 +136,13 @@ function CommentMenuList(props: Props) { } } + function removeModerator() { + if (activeChannelClaim && authorUri) { + const { channelName, channelClaimId } = parseURI(authorUri); + if (channelName && channelClaimId) commentModRemoveDelegate(channelClaimId, channelName, activeChannelClaim); + } + } + function getBlockOptionElem() { const isPersonalBlockTheOnlyOption = !activeChannelIsModerator && !activeChannelIsAdmin; const isTimeoutBlockAvailable = claimIsMine || activeChannelIsModerator; @@ -256,17 +273,32 @@ function CommentMenuList(props: Props) { {__('Dismiss Pin')} )} - {/* todo: filter out already active mods (bug with activeModeratorInfo?) */} - {activeChannelIsCreator && activeChannelClaim && activeChannelClaim.permanent_url !== authorUri && ( - + {activeChannelIsCreator && + activeChannelClaim && + activeChannelClaim.permanent_url !== authorUri && + !authorIsModerator && ( + +
+ + {__('Add as moderator')} +
+ + {activeChannelClaim + ? __('Assign this user to moderate %channel%.', { channel: activeChannelClaim.name }) + : __('Assign this user to moderate your channel.')} + +
+ )} + {activeChannelIsCreator && authorIsModerator && ( +
- - {__('Add as moderator')} + + {__('Remove as moderator')}
{activeChannelClaim - ? __('Assign this user to moderate %channel%.', { channel: activeChannelClaim.name }) - : __('Assign this user to moderate your channel.')} + ? __('Remove this user as a moderator of %channel%.', { channel: activeChannelClaim.name }) + : __('Remove this user as a moderator of your channel.')}
)} diff --git a/ui/constants/action_types.js b/ui/constants/action_types.js index 81ccf73fc4..8ca7ac167c 100644 --- a/ui/constants/action_types.js +++ b/ui/constants/action_types.js @@ -553,6 +553,8 @@ export const COMMENT_PIN_STARTED = 'COMMENT_PIN_STARTED'; export const COMMENT_PIN_COMPLETED = 'COMMENT_PIN_COMPLETED'; export const COMMENT_PIN_FAILED = 'COMMENT_PIN_FAILED'; export const COMMENT_MARK_AS_REMOVED = 'COMMENT_MARK_AS_REMOVED'; +export const ADD_MODERATOR_COMPLETED = 'ADD_MODERATOR_COMPLETED'; +export const REMOVE_MODERATOR_COMPLETED = 'REMOVE_MODERATOR_COMPLETED'; export const COMMENT_MODERATION_BLOCK_LIST_STARTED = 'COMMENT_MODERATION_BLOCK_LIST_STARTED'; export const COMMENT_MODERATION_BLOCK_LIST_COMPLETED = 'COMMENT_MODERATION_BLOCK_LIST_COMPLETED'; export const COMMENT_MODERATION_BLOCK_LIST_FAILED = 'COMMENT_MODERATION_BLOCK_LIST_FAILED'; @@ -568,6 +570,12 @@ export const COMMENT_FETCH_MODERATION_DELEGATES_COMPLETED = 'COMMENT_FETCH_MODER export const COMMENT_MODERATION_AM_I_LIST_STARTED = 'COMMENT_MODERATION_AM_I_LIST_STARTED'; export const COMMENT_MODERATION_AM_I_LIST_FAILED = 'COMMENT_MODERATION_AM_I_LIST_FAILED'; export const COMMENT_MODERATION_AM_I_LIST_COMPLETED = 'COMMENT_MODERATION_AM_I_LIST_COMPLETED'; +export const COMMENT_MODERATION_DELEGATES_FOR_MY_CHANNELS_STARTED = + 'COMMENT_MODERATION_DELEGATES_FOR_MY_CHANNELS_STARTED'; +export const COMMENT_MODERATION_DELEGATES_FOR_MY_CHANNELS_FAILED = + 'COMMENT_MODERATION_DELEGATES_FOR_MY_CHANNELS_FAILED'; +export const COMMENT_MODERATION_DELEGATES_FOR_MY_CHANNELS_COMPLETED = + 'COMMENT_MODERATION_DELEGATES_FOR_MY_CHANNELS_COMPLETED'; export const COMMENT_FETCH_SETTINGS_STARTED = 'COMMENT_FETCH_SETTINGS_STARTED'; export const COMMENT_FETCH_SETTINGS_FAILED = 'COMMENT_FETCH_SETTINGS_FAILED'; export const COMMENT_FETCH_SETTINGS_COMPLETED = 'COMMENT_FETCH_SETTINGS_COMPLETED'; diff --git a/ui/redux/actions/comments.js b/ui/redux/actions/comments.js index 65c39eef26..5b47fe502a 100644 --- a/ui/redux/actions/comments.js +++ b/ui/redux/actions/comments.js @@ -1712,7 +1712,11 @@ export function doCommentModAddDelegate( channel_name: creatorChannelClaim.name, ...signature, }) - .then(() => { + .then((res) => { + dispatch({ + type: ACTIONS.ADD_MODERATOR_COMPLETED, + data: { newDelegates: res?.Delegates, creatorChannelId: creatorChannelClaim.claim_id }, + }); if (showToast) { dispatch( doToast({ @@ -1735,7 +1739,8 @@ export function doCommentModAddDelegate( export function doCommentModRemoveDelegate( modChannelId: string, modChannelName: string, - creatorChannelClaim: ChannelClaim + creatorChannelClaim: ChannelClaim, + showToast: boolean = false ) { return async (dispatch: Dispatch, getState: GetState) => { const signature = await ChannelSign.sign(creatorChannelClaim.claim_id, creatorChannelClaim.name, false); @@ -1750,9 +1755,28 @@ export function doCommentModRemoveDelegate( channel_id: creatorChannelClaim.claim_id, channel_name: creatorChannelClaim.name, ...signature, - }).catch((err) => { - dispatch(doToast({ message: err.message, isError: true })); - }); + }) + .then(() => { + dispatch({ + type: ACTIONS.REMOVE_MODERATOR_COMPLETED, + data: { removedDelegateId: modChannelId, creatorChannelId: creatorChannelClaim.claim_id }, + }); + if (showToast) { + dispatch( + doToast({ + message: __('Removed %user% from moderators of %myChannel%', { + user: modChannelName, + myChannel: creatorChannelClaim.name, + }), + linkText: __('Manage'), + linkTarget: `/${PAGES.SETTINGS_CREATOR}`, + }) + ); + } + }) + .catch((err) => { + dispatch(doToast({ message: err.message, isError: true })); + }); }; } @@ -1788,6 +1812,60 @@ export function doCommentModListDelegates(channelClaim: ChannelClaim) { }; } +export function doCommentModListDelegatesForMyChannels() { + return async (dispatch: Dispatch, getState: GetState) => { + const state = getState(); + const myChannels = selectMyChannelClaims(state); + if (!myChannels) { + dispatch({ type: ACTIONS.COMMENT_MODERATION_DELEGATES_FOR_MY_CHANNELS_FAILED }); + return; + } + + dispatch({ type: ACTIONS.COMMENT_MODERATION_DELEGATES_FOR_MY_CHANNELS_STARTED }); + + let channelSignatures = []; + + return Promise.all(myChannels.map((channel) => channelSignName(channel.claim_id, channel.name, true))) + .then((response) => { + channelSignatures = response; + // $FlowFixMe + return Promise.allSettled( + channelSignatures + .filter((x) => x !== undefined && x !== null) + .map((signatureData) => + Comments.moderation_list_delegates({ + channel_name: signatureData.name, + channel_id: signatureData.claim_id, + signature: signatureData.signature, + signing_ts: signatureData.signing_ts, + }).then((value) => ({ signatureData, value })) + ) + ) + .then((results) => { + const delegatesById = {}; + + results.forEach((result) => { + if (result.status === PROMISE_FULFILLED) { + const { signatureData, value } = result.value; + delegatesById[signatureData.claim_id] = value.Delegates; + } + }); + dispatch({ + type: ACTIONS.COMMENT_MODERATION_DELEGATES_FOR_MY_CHANNELS_COMPLETED, + data: delegatesById, + }); + }) + .catch((err) => { + devToast(dispatch, `Fetch delegates for my channels: ${err}`); + dispatch({ type: ACTIONS.COMMENT_MODERATION_DELEGATES_FOR_MY_CHANNELS_FAILED }); + }); + }) + .catch(() => { + dispatch({ type: ACTIONS.COMMENT_MODERATION_DELEGATES_FOR_MY_CHANNELS_FAILED }); + }); + }; +} + export function doFetchCommentModAmIList(channelClaim: ChannelClaim) { return async (dispatch: Dispatch, getState: GetState) => { const state = getState(); diff --git a/ui/redux/reducers/comments.js b/ui/redux/reducers/comments.js index 32414e9697..2442c8d5ad 100644 --- a/ui/redux/reducers/comments.js +++ b/ui/redux/reducers/comments.js @@ -1048,6 +1048,29 @@ export default handleActions( }; }, + [ACTIONS.COMMENT_MODERATION_DELEGATES_FOR_MY_CHANNELS_COMPLETED]: (state: CommentsState, action: any) => { + const moderationDelegatesById = Object.assign({}, state.moderationDelegatesById); + const newDelegatesById = action.data; + + for (const [creatorChannelId, delegates] of Object.entries(newDelegatesById)) { + if (Array.isArray(delegates)) { + moderationDelegatesById[creatorChannelId] = delegates.map((delegate: any) => { + return { + channelId: delegate.channel_id, + channelName: delegate.channel_name, + }; + }); + } else { + moderationDelegatesById[creatorChannelId] = []; + } + } + + return { + ...state, + moderationDelegatesById: moderationDelegatesById, + }; + }, + [ACTIONS.COMMENT_MODERATION_AM_I_LIST_STARTED]: (state: CommentsState, action: any) => ({ ...state, fetchingModerationDelegators: true, @@ -1101,6 +1124,40 @@ export default handleActions( }; }, + [ACTIONS.ADD_MODERATOR_COMPLETED]: (state: CommentsState, action: any) => { + const { newDelegates, creatorChannelId } = action.data; + const moderationDelegatesById = Object.assign({}, state.moderationDelegatesById); + + const parsedNewDelegates = newDelegates.map((delegate) => { + return { + channelId: delegate.channel_id, + channelName: delegate.channel_name, + }; + }); + + moderationDelegatesById[creatorChannelId] = Array.isArray(moderationDelegatesById[creatorChannelId]) + ? moderationDelegatesById[creatorChannelId].concat(parsedNewDelegates) + : parsedNewDelegates; + + return { + ...state, + moderationDelegatesById, + }; + }, + [ACTIONS.REMOVE_MODERATOR_COMPLETED]: (state: CommentsState, action: any) => { + const { removedDelegateId, creatorChannelId } = action.data; + const moderationDelegatesById = Object.assign({}, state.moderationDelegatesById); + + moderationDelegatesById[creatorChannelId] = Array.isArray(moderationDelegatesById[creatorChannelId]) + ? moderationDelegatesById[creatorChannelId].filter((delegate) => delegate.channelId !== removedDelegateId) + : []; + + return { + ...state, + moderationDelegatesById, + }; + }, + [ACTIONS.COMMENT_FETCH_BLOCKED_WORDS_STARTED]: (state: CommentsState, action: any) => ({ ...state, fetchingBlockedWords: true,