Moderator Delegation GUI

This commit is contained in:
infinite-persistence 2021-06-16 10:27:58 +08:00 committed by jessopb
parent 60780db94b
commit 337cfd8769
21 changed files with 700 additions and 114 deletions

View file

@ -1302,7 +1302,7 @@
"Blocked": "Blocked",
"Unblock": "Unblock",
"Unblocking...": "Unblocking...",
"Channel blocked. You will not see them again.": "Channel blocked. You will not see them again.",
"Channel \"%channel%\" blocked.": "Channel \"%channel%\" blocked.",
"Mute Channel": "Mute Channel",
"Unmute Channel": "Unmute Channel",
"Muted": "Muted",
@ -1690,13 +1690,32 @@
"Transaction limit reached. Try reducing the Description length.": "Transaction limit reached. Try reducing the Description length.",
"You do not have any muted channels": "You do not have any muted channels",
"You do not have any blocked channels": "You do not have any blocked channels",
"Blocked channels will be invisible to you in the app. They will not be able to comment on your content, or reply to you comments left on other channels' content.": "Blocked channels will be invisible to you in the app. They will not be able to comment on your content, or reply to you comments left on other channels' content.",
"You do not have any globally-blocked channels": "You do not have any globally-blocked channels",
"You do not have any blocked channels as a moderator": "You do not have any blocked channels as a moderator",
"Blocked channels will be invisible to you in the app. They will not be able to comment on your content, nor reply to your comments left on other channels' content.": "Blocked channels will be invisible to you in the app. They will not be able to comment on your content, nor reply to your comments left on other channels' content.",
"Muted channels will be invisible to you in the app. They will not know they are muted and can still interact with you and your content.": "Muted channels will be invisible to you in the app. They will not know they are muted and can still interact with you and your content.",
"List of channels that you have blocked as a moderator. To unblock a channel, notify the content creator.": "List of channels that you have blocked as a moderator. To unblock a channel, notify the content creator.",
"This is the global block list.": "This is the global block list.",
"This channel is blocked": "This channel is blocked",
"This channel is muted": "This channel is muted",
"Are you sure you want to view this content? Viewing will not unblock @%channel%": "Are you sure you want to view this content? Viewing will not unblock @%channel%",
"Are you sure you want to view this content? Viewing will not unmute @%channel%": "Are you sure you want to view this content? Viewing will not unmute @%channel%",
"View Content": "View Content",
"Global": "Global",
"Moderator": "Moderator",
"Global Unblock Channel": "Global Unblock Channel",
"Global Block Channel": "Global Block Channel",
"Moderator tools": "Moderator tools",
"Global Block": "Global Block",
"Block this channel as global admin": "Block this channel as global admin",
"Moderator Block": "Moderator Block",
"Block this channel on behalf of %creator%": "Block this channel on behalf of %creator%",
"creator": "creator",
"Invalid channel URL \"%url%\"": "Invalid channel URL \"%url%\"",
"Delegation": "Delegation",
"Add moderator": "Add moderator",
"Add moderators": "Add moderators",
"Add moderator channel URL (e.g. \"@lbry#3f\")": "Add moderator channel URL (e.g. \"@lbry#3f\")",
"Mute (m)": "Mute (m)",
"Playback Rate (<, >)": "Playback Rate (<, >)",
"Fullscreen (f)": "Fullscreen (f)",

View file

@ -1,14 +1,57 @@
import { connect } from 'react-redux';
import { doCommentModUnBlock, doCommentModBlock } from 'redux/actions/comments';
import { makeSelectChannelIsBlocked, makeSelectUriIsBlockingOrUnBlocking } from 'redux/selectors/comments';
import { makeSelectClaimIdForUri } from 'lbry-redux';
import {
doCommentModUnBlock,
doCommentModBlock,
doCommentModBlockAsAdmin,
doCommentModUnBlockAsAdmin,
doCommentModUnBlockAsModerator,
doCommentModBlockAsModerator,
} from 'redux/actions/comments';
import {
makeSelectChannelIsBlocked,
makeSelectChannelIsAdminBlocked,
makeSelectChannelIsModeratorBlockedForCreator,
makeSelectUriIsBlockingOrUnBlocking,
makeSelectIsTogglingForDelegator,
} from 'redux/selectors/comments';
import { BLOCK_LEVEL } from 'constants/comment';
import ChannelBlockButton from './view';
const select = (state, props) => ({
isBlocked: makeSelectChannelIsBlocked(props.uri)(state),
isBlockingOrUnBlocking: makeSelectUriIsBlockingOrUnBlocking(props.uri)(state),
});
const select = (state, props) => {
let isBlocked;
let isToggling;
switch (props.blockLevel) {
default:
case BLOCK_LEVEL.SELF:
isBlocked = makeSelectChannelIsBlocked(props.uri)(state);
break;
case BLOCK_LEVEL.MODERATOR:
isBlocked = makeSelectChannelIsModeratorBlockedForCreator(props.uri, props.creatorUri)(state);
isToggling = makeSelectIsTogglingForDelegator(props.uri, props.creatorUri)(state);
break;
case BLOCK_LEVEL.ADMIN:
isBlocked = makeSelectChannelIsAdminBlocked(props.uri)(state);
break;
}
return {
isBlocked,
isToggling,
isBlockingOrUnBlocking: makeSelectUriIsBlockingOrUnBlocking(props.uri)(state),
creatorId: makeSelectClaimIdForUri(props.creatorUri)(state),
};
};
export default connect(select, {
doCommentModUnBlock,
doCommentModBlock,
doCommentModUnBlockAsAdmin,
doCommentModBlockAsAdmin,
doCommentModUnBlockAsModerator,
doCommentModBlockAsModerator,
})(ChannelBlockButton);

View file

@ -1,41 +1,95 @@
// @flow
import React from 'react';
import Button from 'component/button';
import { BLOCK_LEVEL } from 'constants/comment';
import { parseURI } from 'lbry-redux';
type Props = {
uri: string,
blockLevel?: string,
creatorUri?: string,
isBlocked: boolean,
isBlockingOrUnBlocking: boolean,
isToggling: boolean,
doCommentModUnBlock: (string, boolean) => void,
doCommentModBlock: (string, boolean) => void,
doCommentModUnBlockAsAdmin: (string, string) => void,
doCommentModBlockAsAdmin: (string, string) => void,
doCommentModUnBlockAsModerator: (string, string, string) => void,
doCommentModBlockAsModerator: (string, string, string) => void,
};
function ChannelBlockButton(props: Props) {
const { uri, doCommentModUnBlock, doCommentModBlock, isBlocked, isBlockingOrUnBlocking } = props;
const {
uri,
blockLevel,
creatorUri,
doCommentModUnBlock,
doCommentModBlock,
doCommentModUnBlockAsAdmin,
doCommentModBlockAsAdmin,
doCommentModUnBlockAsModerator,
doCommentModBlockAsModerator,
isBlocked,
isBlockingOrUnBlocking,
isToggling,
} = props;
function handleClick() {
if (isBlocked) {
doCommentModUnBlock(uri, false);
} else {
doCommentModBlock(uri, false);
switch (blockLevel) {
default:
case BLOCK_LEVEL.SELF:
if (isBlocked) {
doCommentModUnBlock(uri, false);
} else {
doCommentModBlock(uri, false);
}
break;
case BLOCK_LEVEL.MODERATOR:
if (creatorUri) {
const { channelClaimId } = parseURI(creatorUri);
if (isBlocked) {
doCommentModUnBlockAsModerator(uri, channelClaimId, '');
} else {
doCommentModBlockAsModerator(uri, channelClaimId, '');
}
}
break;
case BLOCK_LEVEL.ADMIN:
if (isBlocked) {
doCommentModUnBlockAsAdmin(uri, '');
} else {
doCommentModBlockAsAdmin(uri, '');
}
break;
}
}
return (
<Button
button={isBlocked ? 'alt' : 'secondary'}
label={
isBlocked
function getButtonText(blockLevel) {
switch (blockLevel) {
default:
case BLOCK_LEVEL.SELF:
case BLOCK_LEVEL.ADMIN:
return isBlocked
? isBlockingOrUnBlocking
? __('Unblocking...')
: __('Unblock')
: isBlockingOrUnBlocking
? __('Blocking...')
: __('Block')
}
onClick={handleClick}
/>
);
? __('Blocking...')
: __('Block');
case BLOCK_LEVEL.MODERATOR:
if (isToggling) {
return isBlocked ? __('Unblocking...') : __('Blocking...');
} else {
return isBlocked ? __('Unblock') : __('Block');
}
}
}
return <Button button={isBlocked ? 'alt' : 'secondary'} label={getButtonText(blockLevel)} onClick={handleClick} />;
}
export default ChannelBlockButton;

View file

@ -13,8 +13,17 @@ import {
import { makeSelectChannelIsMuted } from 'redux/selectors/blocked';
import { doChannelMute, doChannelUnmute } from 'redux/actions/blocked';
import { doSetActiveChannel, doSetIncognito, doOpenModal } from 'redux/actions/app';
import { doCommentModBlock, doCommentModUnBlock } from 'redux/actions/comments';
import { makeSelectChannelIsBlocked } from 'redux/selectors/comments';
import {
doCommentModBlock,
doCommentModUnBlock,
doCommentModBlockAsAdmin,
doCommentModUnBlockAsAdmin,
} from 'redux/actions/comments';
import {
selectHasAdminChannel,
makeSelectChannelIsBlocked,
makeSelectChannelIsAdminBlocked,
} from 'redux/selectors/comments';
import { doToast } from 'redux/actions/notifications';
import { makeSelectSigningIsMine } from 'redux/selectors/content';
import { doChannelSubscribe, doChannelUnsubscribe } from 'redux/actions/subscriptions';
@ -33,6 +42,8 @@ const select = (state, props) => {
channelIsBlocked: makeSelectChannelIsBlocked(props.uri)(state),
fileInfo: makeSelectFileInfoForUri(props.uri)(state),
isSubscribed: makeSelectIsSubscribed(props.channelUri, true)(state),
channelIsAdminBlocked: makeSelectChannelIsAdminBlocked(props.uri)(state),
isAdmin: selectHasAdminChannel(state),
claimInCollection: makeSelectCollectionForIdHasClaimUrl(props.collectionId, permanentUri)(state),
collectionName: makeSelectNameForCollectionId(props.collectionId)(state),
isMyCollection: makeSelectCollectionIsMine(props.collectionId)(state),
@ -57,6 +68,9 @@ const perform = (dispatch) => ({
doChannelUnmute: (channelUri) => dispatch(doChannelUnmute(channelUri)),
doCommentModBlock: (channelUri) => dispatch(doCommentModBlock(channelUri)),
doCommentModUnBlock: (channelUri) => dispatch(doCommentModUnBlock(channelUri)),
doCommentModBlockAsAdmin: (commenterUri, blockerId) => dispatch(doCommentModBlockAsAdmin(commenterUri, blockerId)),
doCommentModUnBlockAsAdmin: (commenterUri, blockerId) =>
dispatch(doCommentModUnBlockAsAdmin(commenterUri, blockerId)),
doChannelSubscribe: (subscription) => dispatch(doChannelSubscribe(subscription)),
doChannelUnsubscribe: (subscription) => dispatch(doChannelUnsubscribe(subscription)),
doCollectionEdit: (collection, props) => dispatch(doCollectionEdit(collection, props)),

View file

@ -29,10 +29,14 @@ type Props = {
inline?: boolean,
channelIsMuted: boolean,
channelIsBlocked: boolean,
channelIsAdminBlocked: boolean,
isAdmin: boolean,
doChannelMute: (string) => void,
doChannelUnmute: (string) => void,
doCommentModBlock: (string) => void,
doCommentModUnBlock: (string) => void,
doCommentModBlockAsAdmin: (string, string) => void,
doCommentModUnBlockAsAdmin: (string, string) => void,
isRepost: boolean,
doCollectionEdit: (string, any) => void,
hasClaimInWatchLater: boolean,
@ -62,9 +66,13 @@ function ClaimMenuList(props: Props) {
doChannelUnmute,
channelIsMuted,
channelIsBlocked,
channelIsAdminBlocked,
isAdmin,
doCommentModBlock,
doCommentModUnBlock,
isRepost,
doCommentModBlockAsAdmin,
doCommentModUnBlockAsAdmin,
doCollectionEdit,
hasClaimInWatchLater,
collectionId,
@ -166,6 +174,14 @@ function ClaimMenuList(props: Props) {
openModal(MODALS.SEND_TIP, { uri, isSupport: true });
}
function handleToggleAdminBlock() {
if (channelIsAdminBlocked) {
doCommentModUnBlockAsAdmin(channelUri, '');
} else {
doCommentModBlockAsAdmin(channelUri, '');
}
}
function handleCopyLink() {
navigator.clipboard.writeText(shareUrl);
}
@ -292,6 +308,15 @@ function ClaimMenuList(props: Props) {
</div>
</MenuItem>
{isAdmin && (
<MenuItem className="comment__menu-option" onSelect={handleToggleAdminBlock}>
<div className="menu__link">
<Icon aria-hidden icon={ICONS.GLOBE} />
{channelIsAdminBlocked ? __('Global Unblock Channel') : __('Global Block Channel')}
</div>
</MenuItem>
)}
<MenuItem className="comment__menu-option" onSelect={handleToggleMute}>
<div className="menu__link">
<Icon aria-hidden icon={ICONS.MUTE} />

View file

@ -1,11 +1,19 @@
import { connect } from 'react-redux';
import { makeSelectChannelPermUrlForClaimUri, makeSelectClaimIsMine, makeSelectClaimForUri } from 'lbry-redux';
import { doCommentAbandon, doCommentPin, doCommentList, doCommentModBlock } from 'redux/actions/comments';
import {
doCommentAbandon,
doCommentPin,
doCommentList,
doCommentModBlock,
doCommentModBlockAsAdmin,
doCommentModBlockAsModerator,
} from 'redux/actions/comments';
import { doChannelMute } from 'redux/actions/blocked';
// import { doSetActiveChannel } 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) => ({
@ -14,6 +22,7 @@ const select = (state, props) => ({
contentChannelPermanentUrl: makeSelectChannelPermUrlForClaimUri(props.uri)(state),
activeChannelClaim: selectActiveChannelClaim(state),
playingUri: selectPlayingUri(state),
moderationDelegatorsById: selectModerationDelegatorsById(state),
});
const perform = (dispatch) => ({
@ -23,7 +32,10 @@ const perform = (dispatch) => ({
pinComment: (commentId, remove) => dispatch(doCommentPin(commentId, remove)),
fetchComments: (uri) => dispatch(doCommentList(uri)),
// setActiveChannel: channelId => dispatch(doSetActiveChannel(channelId)),
commentModBlock: (commentAuthor) => dispatch(doCommentModBlock(commentAuthor)),
commentModBlock: (commenterUri) => dispatch(doCommentModBlock(commenterUri)),
commentModBlockAsAdmin: (commenterUri, blockerId) => dispatch(doCommentModBlockAsAdmin(commenterUri, blockerId)),
commentModBlockAsModerator: (commenterUri, creatorId, blockerId) =>
dispatch(doCommentModBlockAsModerator(commenterUri, creatorId, blockerId)),
});
export default connect(select, perform)(CommentMenuList);

View file

@ -7,6 +7,7 @@ import Icon from 'component/common/icon';
type Props = {
uri: string,
claim: ?Claim,
clearPlayingUri: () => void,
authorUri: string, // full LBRY Channel URI: lbry://@channel#123...
commentId: string, // sha256 digest identifying the comment
@ -22,14 +23,18 @@ type Props = {
activeChannelClaim: ?ChannelClaim,
isTopLevel: boolean,
commentModBlock: (string) => void,
commentModBlockAsAdmin: (string, string) => void,
commentModBlockAsModerator: (string, string, string) => void,
playingUri: ?PlayingUri,
disableEdit?: boolean,
disableRemove?: boolean,
moderationDelegatorsById: { [string]: { global: boolean, delegators: { name: string, claimId: string } } },
};
function CommentMenuList(props: Props) {
const {
uri,
claim,
authorUri,
commentIsMine,
commentId,
@ -44,11 +49,30 @@ function CommentMenuList(props: Props) {
handleEditComment,
fetchComments,
commentModBlock,
commentModBlockAsAdmin,
commentModBlockAsModerator,
playingUri,
disableEdit,
disableRemove,
moderationDelegatorsById,
} = 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, remove) {
pinComment(commentId, remove).then(() => fetchComments(uri));
@ -69,6 +93,18 @@ function CommentMenuList(props: Props) {
muteChannel(authorUri);
}
function blockCommentAsModerator() {
if (activeChannelClaim && contentChannelClaim) {
commentModBlockAsModerator(authorUri, contentChannelClaim.claim_id, activeChannelClaim.claim_id);
}
}
function blockCommentAsAdmin() {
if (activeChannelClaim) {
commentModBlockAsAdmin(authorUri, activeChannelClaim.claim_id);
}
}
return (
<MenuList className="menu__list">
{activeChannelIsCreator && <div className="comment__menu-title">{__('Creator tools')}</div>}
@ -128,6 +164,34 @@ function CommentMenuList(props: Props) {
</MenuItem>
)}
{(activeChannelIsAdmin || activeChannelIsModerator) && (
<div className="comment__menu-title">{__('Moderator tools')}</div>
)}
{!commentIsMine && activeChannelIsAdmin && (
<MenuItem className="comment__menu-option" onSelect={blockCommentAsAdmin}>
<div className="menu__link">
<Icon aria-hidden icon={ICONS.GLOBE} />
{__('Global Block')}
</div>
<span className="comment__menu-help">{__('Block this channel as global admin')}</span>
</MenuItem>
)}
{!commentIsMine && activeChannelIsModerator && (
<MenuItem className="comment__menu-option" onSelect={blockCommentAsModerator}>
<div className="menu__link">
<Icon aria-hidden icon={ICONS.BLOCK} />
{__('Moderator Block')}
</div>
<span className="comment__menu-help">
{__('Block this channel on behalf of %creator%', {
creator: contentChannelClaim ? contentChannelClaim.name : __('creator'),
})}
</span>
</MenuItem>
)}
{activeChannelClaim && (
<div className="comment__menu-active">
<ChannelThumbnail uri={activeChannelClaim.permanent_url} />

View file

@ -392,6 +392,13 @@ export const icons = {
<line x1="12" y1="12" x2="12" y2="16" />
</g>
),
[ICONS.GLOBE]: buildIcon(
<g>
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</g>
),
[ICONS.UNLOCK]: buildIcon(
<g>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />

View file

@ -15,6 +15,7 @@ type Props = {
doAddTag: (string) => void,
onSelect?: (Tag) => void,
hideSuggestions?: boolean,
hideInputField?: boolean,
suggestMature?: boolean,
disableAutoFocus?: boolean,
onRemove: (Tag) => void,
@ -49,6 +50,7 @@ export default function TagsSearch(props: Props) {
onSelect,
onRemove,
hideSuggestions,
hideInputField,
suggestMature,
disableAutoFocus,
placeholder,
@ -189,16 +191,18 @@ export default function TagsSearch(props: Props) {
/>
))}
</ul>
<FormField
autoFocus={!disableAutoFocus}
className="tag__input"
onChange={onChange}
placeholder={placeholder || __('gaming, crypto')}
type="text"
value={newTag}
disabled={disabled}
label={labelAddNew || __('Add Tags')}
/>
{!hideInputField && (
<FormField
autoFocus={!disableAutoFocus}
className="tag__input"
onChange={onChange}
placeholder={placeholder || __('gaming, crypto')}
type="text"
value={newTag}
disabled={disabled}
label={labelAddNew || __('Add Tags')}
/>
)}
{!hideSuggestions && (
<section>
<label>{labelSuggestions || (newTag.length ? __('Matching') : __('Known Tags'))}</label>

View file

@ -4,7 +4,7 @@ import { doOpenModal } from 'redux/actions/app';
import Wunderbar from './view';
const perform = (dispatch, ownProps) => ({
doOpenMobileSearch: () => dispatch(doOpenModal(MODALS.MOBILE_SEARCH)),
doOpenMobileSearch: (props) => dispatch(doOpenModal(MODALS.MOBILE_SEARCH, props)),
});
export default connect(null, perform)(Wunderbar);

View file

@ -6,7 +6,7 @@ import { useIsMobile } from 'effects/use-screensize';
import WunderbarSuggestions from 'component/wunderbarSuggestions';
type Props = {
doOpenMobileSearch: () => void,
doOpenMobileSearch: (any) => void,
channelsOnly?: boolean,
noTopSuggestion?: boolean,
noBottomLinks?: boolean,
@ -18,7 +18,7 @@ export default function WunderBar(props: Props) {
const isMobile = useIsMobile();
return isMobile ? (
<Button icon={ICONS.SEARCH} className="wunderbar__mobile-search" onClick={() => doOpenMobileSearch()} />
<Button icon={ICONS.SEARCH} className="wunderbar__mobile-search" onClick={() => doOpenMobileSearch({ ...props })} />
) : (
<WunderbarSuggestions
channelsOnly={channelsOnly}

View file

@ -160,3 +160,4 @@ export const LIVESTREAM_SOLID = 'LivestreamSolid';
export const LIVESTREAM_MONOCHROME = 'LivestreamMono';
export const STACK = 'stack';
export const TIME = 'time';
export const GLOBE = 'globe';

View file

@ -5,14 +5,24 @@ import WunderbarSuggestions from 'component/wunderbarSuggestions';
type Props = {
closeModal: () => void,
channelsOnly?: boolean,
noTopSuggestion?: boolean,
noBottomLinks?: boolean,
customSelectAction?: (string) => void,
};
export default function ModalMobileSearch(props: Props) {
const { closeModal } = props;
const { closeModal, channelsOnly, noTopSuggestion, noBottomLinks, customSelectAction } = props;
return (
<Modal onAborted={closeModal} isOpen type="card">
<WunderbarSuggestions isMobile />
<WunderbarSuggestions
isMobile
channelsOnly={channelsOnly}
noTopSuggestion={noTopSuggestion}
noBottomLinks={noBottomLinks}
customSelectAction={customSelectAction}
/>
</Modal>
);
}

View file

@ -1,17 +1,31 @@
import { connect } from 'react-redux';
import { doFetchModBlockedList } from 'redux/actions/comments';
import { doFetchModBlockedList, doFetchCommentModAmIList } from 'redux/actions/comments';
import { selectMutedChannels } from 'redux/selectors/blocked';
import { selectModerationBlockList, selectFetchingModerationBlockList } from 'redux/selectors/comments';
import {
selectModerationBlockList,
selectAdminBlockList,
selectModeratorBlockList,
selectModeratorBlockListDelegatorsMap,
selectFetchingModerationBlockList,
selectModerationDelegatorsById,
} from 'redux/selectors/comments';
import { selectMyChannelClaims } from 'lbry-redux';
import ListBlocked from './view';
const select = (state) => ({
mutedUris: selectMutedChannels(state),
blockedUris: selectModerationBlockList(state),
personalBlockList: selectModerationBlockList(state),
adminBlockList: selectAdminBlockList(state),
moderatorBlockList: selectModeratorBlockList(state),
moderatorBlockListDelegatorsMap: selectModeratorBlockListDelegatorsMap(state),
delegatorsById: selectModerationDelegatorsById(state),
myChannelClaims: selectMyChannelClaims(state),
fetchingModerationBlockList: selectFetchingModerationBlockList(state),
});
const perform = (dispatch) => ({
fetchModBlockedList: () => dispatch(doFetchModBlockedList()),
fetchModAmIList: () => dispatch(doFetchCommentModAmIList()),
});
export default connect(select, perform)(ListBlocked);

View file

@ -1,8 +1,10 @@
// @flow
import * as ICONS from 'constants/icons';
import { BLOCK_LEVEL } from 'constants/comment';
import React from 'react';
import classnames from 'classnames';
import ClaimList from 'component/claimList';
import ClaimPreview from 'component/claimPreview';
import Page from 'component/page';
import Spinner from 'component/spinner';
import Button from 'component/button';
@ -11,64 +13,220 @@ import ChannelBlockButton from 'component/channelBlockButton';
import ChannelMuteButton from 'component/channelMuteButton';
import Yrbl from 'component/yrbl';
type Props = {
mutedUris: ?Array<string>,
blockedUris: ?Array<string>,
fetchingModerationBlockList: boolean,
fetchModBlockedList: () => void,
const VIEW = {
BLOCKED: 'blocked',
ADMIN: 'admin',
MODERATOR: 'moderator',
MUTED: 'muted',
};
const VIEW_BLOCKED = 'blocked';
const VIEW_MUTED = 'muted';
type Props = {
mutedUris: ?Array<string>,
personalBlockList: ?Array<string>,
adminBlockList: ?Array<string>,
moderatorBlockList: ?Array<string>,
moderatorBlockListDelegatorsMap: { [string]: Array<string> },
fetchingModerationBlockList: boolean,
fetchModBlockedList: () => void,
fetchModAmIList: () => void,
delegatorsById: { [string]: { global: boolean, delegators: { name: string, claimId: string } } },
myChannelClaims: ?Array<ChannelClaim>,
};
function ListBlocked(props: Props) {
const { mutedUris, blockedUris, fetchingModerationBlockList, fetchModBlockedList } = props;
const [viewMode, setViewMode] = usePersistedState('blocked-muted:display', VIEW_BLOCKED);
const {
mutedUris,
personalBlockList,
adminBlockList,
moderatorBlockList,
moderatorBlockListDelegatorsMap,
fetchingModerationBlockList,
fetchModBlockedList,
fetchModAmIList,
delegatorsById,
myChannelClaims,
} = props;
const [viewMode, setViewMode] = usePersistedState('blocked-muted:display', VIEW.BLOCKED);
// Keep a local list to allow for undoing actions in this component
const [localBlockedList, setLocalBlockedList] = React.useState(undefined);
const [localPersonalList, setLocalPersonalList] = React.useState(undefined);
const [localAdminList, setLocalAdminList] = React.useState(undefined);
const [localModeratorList, setLocalModeratorList] = React.useState(undefined);
const [localModeratorListDelegatorsMap, setLocalModeratorListDelegatorsMap] = React.useState(undefined);
const [localMutedList, setLocalMutedList] = React.useState(undefined);
const hasLocalMuteList = localMutedList && localMutedList.length > 0;
const hasLocalBlockList = localBlockedList && localBlockedList.length > 0;
const stringifiedMutedChannels = JSON.stringify(mutedUris);
const hasLocalPersonalList = localPersonalList && localPersonalList.length > 0;
const stringifiedMutedList = JSON.stringify(mutedUris);
const stringifiedPersonalList = JSON.stringify(personalBlockList);
const stringifiedAdminList = JSON.stringify(adminBlockList);
const stringifiedModeratorList = JSON.stringify(moderatorBlockList);
const stringifiedModeratorListDelegatorsMap = JSON.stringify(moderatorBlockListDelegatorsMap);
const stringifiedLocalAdminList = JSON.stringify(localAdminList);
const stringifiedLocalModeratorList = JSON.stringify(localModeratorList);
const stringifiedLocalModeratorListDelegatorsMap = JSON.stringify(localModeratorListDelegatorsMap);
const justMuted = localMutedList && mutedUris && localMutedList.length < mutedUris.length;
const justBlocked = localBlockedList && blockedUris && localBlockedList.length < blockedUris.length;
const stringifiedBlockedChannels = JSON.stringify(blockedUris);
const showUris = (viewMode === VIEW_MUTED && hasLocalMuteList) || (viewMode === VIEW_BLOCKED && hasLocalBlockList);
const justPersonalBlocked =
localPersonalList && personalBlockList && localPersonalList.length < personalBlockList.length;
const isAdmin =
myChannelClaims && myChannelClaims.some((c) => delegatorsById[c.claim_id] && delegatorsById[c.claim_id].global);
const isModerator =
myChannelClaims &&
myChannelClaims.some(
(c) => delegatorsById[c.claim_id] && Object.keys(delegatorsById[c.claim_id].delegators).length > 0
);
const listForView = getLocalList(viewMode);
const showUris = listForView && listForView.length > 0;
function getLocalList(view) {
switch (view) {
case VIEW.BLOCKED:
return localPersonalList;
case VIEW.ADMIN:
return localAdminList;
case VIEW.MODERATOR:
return localModeratorList;
case VIEW.MUTED:
return localMutedList;
}
}
function getButtons(view, uri) {
switch (view) {
case VIEW.BLOCKED:
return (
<>
<ChannelBlockButton uri={uri} />
<ChannelMuteButton uri={uri} />
</>
);
case VIEW.ADMIN:
return <ChannelBlockButton uri={uri} blockLevel={BLOCK_LEVEL.ADMIN} />;
case VIEW.MODERATOR:
const delegatorUrisForBlockedUri = localModeratorListDelegatorsMap && localModeratorListDelegatorsMap[uri];
if (!delegatorUrisForBlockedUri) return null;
return (
<>
{delegatorUrisForBlockedUri.map((delegatorUri) => {
return (
<div className="block-list--delegator" key={delegatorUri}>
<ul className="section content__non-clickable">
<ClaimPreview uri={delegatorUri} hideMenu hideActions type="small" />
</ul>
<ChannelBlockButton uri={uri} blockLevel={BLOCK_LEVEL.MODERATOR} creatorUri={delegatorUri} />
</div>
);
})}
</>
);
case VIEW.MUTED:
return (
<>
<ChannelMuteButton uri={uri} />
<ChannelBlockButton uri={uri} />
</>
);
}
}
function getHelpText(view) {
switch (view) {
case VIEW.BLOCKED:
return "Blocked channels will be invisible to you in the app. They will not be able to comment on your content, nor reply to your comments left on other channels' content.";
case VIEW.ADMIN:
return 'This is the global block list.';
case VIEW.MODERATOR:
return 'List of channels that you have blocked as a moderator, along with the list of delegators.';
case VIEW.MUTED:
return 'Muted channels will be invisible to you in the app. They will not know they are muted and can still interact with you and your content.';
}
}
function getEmptyListTitle(view) {
switch (view) {
case VIEW.BLOCKED:
return 'You do not have any blocked channels';
case VIEW.MUTED:
return 'You do not have any muted channels';
case VIEW.ADMIN:
return 'You do not have any globally-blocked channels';
case VIEW.MODERATOR:
return 'You do not have any blocked channels as a moderator';
}
}
function getEmptyListSubtitle(view) {
switch (view) {
case VIEW.BLOCKED:
case VIEW.MUTED:
return getHelpText(view);
case VIEW.ADMIN:
case VIEW.MODERATOR:
return null;
}
}
function isSourceListLarger(source, local) {
// Comparing the length of stringified is not perfect, but what are the
// chances of having different lists with the exact same length?
return source && (!local || local.length < source.length);
}
React.useEffect(() => {
const jsonMutedChannels = stringifiedMutedChannels && JSON.parse(stringifiedMutedChannels);
const jsonMutedChannels = stringifiedMutedList && JSON.parse(stringifiedMutedList);
if (!hasLocalMuteList && jsonMutedChannels && jsonMutedChannels.length > 0) {
setLocalMutedList(jsonMutedChannels);
}
}, [stringifiedMutedChannels, hasLocalMuteList]);
}, [stringifiedMutedList, hasLocalMuteList]);
React.useEffect(() => {
const jsonBlockedChannels = stringifiedBlockedChannels && JSON.parse(stringifiedBlockedChannels);
if (!hasLocalBlockList && jsonBlockedChannels && jsonBlockedChannels.length > 0) {
setLocalBlockedList(jsonBlockedChannels);
const jsonBlockedChannels = stringifiedPersonalList && JSON.parse(stringifiedPersonalList);
if (!hasLocalPersonalList && jsonBlockedChannels && jsonBlockedChannels.length > 0) {
setLocalPersonalList(jsonBlockedChannels);
}
}, [stringifiedBlockedChannels, hasLocalBlockList]);
}, [stringifiedPersonalList, hasLocalPersonalList]);
React.useEffect(() => {
if (justMuted && stringifiedMutedChannels) {
setLocalMutedList(JSON.parse(stringifiedMutedChannels));
if (stringifiedAdminList && isSourceListLarger(stringifiedAdminList, stringifiedLocalAdminList)) {
setLocalAdminList(JSON.parse(stringifiedAdminList));
}
}, [stringifiedMutedChannels, justMuted, setLocalMutedList]);
}, [stringifiedAdminList, stringifiedLocalAdminList]);
React.useEffect(() => {
if (justBlocked && stringifiedBlockedChannels) {
setLocalBlockedList(JSON.parse(stringifiedBlockedChannels));
if (stringifiedModeratorList && isSourceListLarger(stringifiedModeratorList, stringifiedLocalModeratorList)) {
setLocalModeratorList(JSON.parse(stringifiedModeratorList));
}
}, [stringifiedBlockedChannels, justBlocked, setLocalBlockedList]);
}, [stringifiedModeratorList, stringifiedLocalModeratorList]);
const mutedHelpText = __(
'Muted channels will be invisible to you in the app. They will not know they are muted and can still interact with you and your content.'
);
const blockedHelpText = __(
"Blocked channels will be invisible to you in the app. They will not be able to comment on your content, or reply to you comments left on other channels' content."
);
React.useEffect(() => {
if (
stringifiedModeratorListDelegatorsMap &&
isSourceListLarger(stringifiedModeratorListDelegatorsMap, stringifiedLocalModeratorListDelegatorsMap)
) {
setLocalModeratorListDelegatorsMap(JSON.parse(stringifiedModeratorListDelegatorsMap));
}
}, [stringifiedModeratorListDelegatorsMap, stringifiedLocalModeratorListDelegatorsMap]);
React.useEffect(() => {
if (justMuted && stringifiedMutedList) {
setLocalMutedList(JSON.parse(stringifiedMutedList));
}
}, [stringifiedMutedList, justMuted, setLocalMutedList]);
React.useEffect(() => {
if (justPersonalBlocked && stringifiedPersonalList) {
setLocalPersonalList(JSON.parse(stringifiedPersonalList));
}
}, [stringifiedPersonalList, justPersonalBlocked, setLocalPersonalList]);
return (
<Page>
@ -87,60 +245,74 @@ function ListBlocked(props: Props) {
button="alt"
label={__('Blocked')}
className={classnames(`button-toggle`, {
'button-toggle--active': viewMode === VIEW_BLOCKED,
'button-toggle--active': viewMode === VIEW.BLOCKED,
})}
onClick={() => setViewMode(VIEW_BLOCKED)}
onClick={() => setViewMode(VIEW.BLOCKED)}
/>
{isAdmin && (
<Button
icon={ICONS.BLOCK}
button="alt"
label={__('Global')}
className={classnames(`button-toggle`, {
'button-toggle--active': viewMode === VIEW.ADMIN,
})}
onClick={() => setViewMode(VIEW.ADMIN)}
/>
)}
{isModerator && (
<Button
icon={ICONS.BLOCK}
button="alt"
label={__('Moderator')}
className={classnames(`button-toggle`, {
'button-toggle--active': viewMode === VIEW.MODERATOR,
})}
onClick={() => setViewMode(VIEW.MODERATOR)}
/>
)}
<Button
icon={ICONS.MUTE}
button="alt"
label={__('Muted')}
className={classnames(`button-toggle`, {
'button-toggle--active': viewMode === VIEW_MUTED,
'button-toggle--active': viewMode === VIEW.MUTED,
})}
onClick={() => setViewMode(VIEW_MUTED)}
onClick={() => setViewMode(VIEW.MUTED)}
/>
</div>
<div className="section__actions--inline">
<Button icon={ICONS.REFRESH} button="alt" label={__('Refresh')} onClick={() => fetchModBlockedList()} />
<Button
icon={ICONS.REFRESH}
button="alt"
label={__('Refresh')}
onClick={() => {
fetchModBlockedList();
fetchModAmIList();
}}
/>
</div>
</div>
{showUris && <div className="help--notice">{viewMode === VIEW_MUTED ? mutedHelpText : blockedHelpText}</div>}
{showUris && <div className="help--notice">{getHelpText(viewMode)}</div>}
{showUris ? (
<ClaimList
uris={viewMode === VIEW_MUTED ? localMutedList : localBlockedList}
showUnresolvedClaims
showHiddenByUser
hideMenu
renderActions={(claim) => {
return (
<div className="section__actions">
{viewMode === VIEW_MUTED ? (
<>
<ChannelMuteButton uri={claim.permanent_url} />
<ChannelBlockButton uri={claim.permanent_url} />
</>
) : (
<>
<ChannelBlockButton uri={claim.permanent_url} />
<ChannelMuteButton uri={claim.permanent_url} />
</>
)}
</div>
);
}}
/>
<div className={viewMode === VIEW.MODERATOR ? 'block-list--moderator' : 'block-list'}>
<ClaimList
uris={getLocalList(viewMode)}
showUnresolvedClaims
showHiddenByUser
hideMenu
renderActions={(claim) => {
return <div className="section__actions">{getButtons(viewMode, claim.permanent_url)}</div>;
}}
/>
</div>
) : (
<div className="main--empty">
<Yrbl
title={
viewMode === VIEW_MUTED
? __('You do not have any muted channels')
: __('You do not have any blocked channels')
}
subtitle={viewMode === VIEW_MUTED ? mutedHelpText : blockedHelpText}
title={getEmptyListTitle(viewMode)}
subtitle={getEmptyListSubtitle(viewMode)}
actions={
<div className="section__actions">
<Button button="primary" label={__('Go Home')} navigate="/" />

View file

@ -3,6 +3,9 @@ import SettingsCreatorPage from './view';
import {
doCommentBlockWords,
doCommentUnblockWords,
doCommentModAddDelegate,
doCommentModRemoveDelegate,
doCommentModListDelegates,
doFetchCreatorSettings,
doUpdateCreatorSettings,
} from 'redux/actions/comments';
@ -11,13 +14,16 @@ import {
selectSettingsByChannelId,
selectFetchingCreatorSettings,
selectFetchingBlockedWords,
selectModerationDelegatesById,
} from 'redux/selectors/comments';
import { doToast } from 'redux/actions/notifications';
const select = (state) => ({
activeChannelClaim: selectActiveChannelClaim(state),
settingsByChannelId: selectSettingsByChannelId(state),
fetchingCreatorSettings: selectFetchingCreatorSettings(state),
fetchingBlockedWords: selectFetchingBlockedWords(state),
moderationDelegatesById: selectModerationDelegatesById(state),
});
const perform = (dispatch) => ({
@ -25,6 +31,12 @@ const perform = (dispatch) => ({
commentUnblockWords: (channelClaim, words) => dispatch(doCommentUnblockWords(channelClaim, words)),
fetchCreatorSettings: (channelClaimIds) => dispatch(doFetchCreatorSettings(channelClaimIds)),
updateCreatorSettings: (channelClaim, settings) => dispatch(doUpdateCreatorSettings(channelClaim, settings)),
commentModAddDelegate: (modChanId, modChanName, creatorChannelClaim) =>
dispatch(doCommentModAddDelegate(modChanId, modChanName, creatorChannelClaim)),
commentModRemoveDelegate: (modChanId, modChanName, creatorChannelClaim) =>
dispatch(doCommentModRemoveDelegate(modChanId, modChanName, creatorChannelClaim)),
commentModListDelegates: (creatorChannelClaim) => dispatch(doCommentModListDelegates(creatorChannelClaim)),
doToast: (options) => dispatch(doToast(options)),
});
export default connect(select, perform)(SettingsCreatorPage);

View file

@ -8,6 +8,8 @@ import Spinner from 'component/spinner';
import { FormField } from 'component/common/form-components/form-field';
import LbcSymbol from 'component/common/lbc-symbol';
import I18nMessage from 'component/i18nMessage';
import { parseURI } from 'lbry-redux';
import WunderBar from 'component/wunderbar';
const DEBOUNCE_REFRESH_MS = 1000;
@ -18,24 +20,35 @@ type Props = {
settingsByChannelId: { [string]: PerChannelSettings },
fetchingCreatorSettings: boolean,
fetchingBlockedWords: boolean,
moderationDelegatesById: { [string]: Array<{ channelId: string, channelName: string }> },
commentBlockWords: (ChannelClaim, Array<string>) => void,
commentUnblockWords: (ChannelClaim, Array<string>) => void,
commentModAddDelegate: (string, string, ChannelClaim) => void,
commentModRemoveDelegate: (string, string, ChannelClaim) => void,
commentModListDelegates: (ChannelClaim) => void,
fetchCreatorSettings: (Array<string>) => void,
updateCreatorSettings: (ChannelClaim, PerChannelSettings) => void,
doToast: ({ message: string }) => void,
};
export default function SettingsCreatorPage(props: Props) {
const {
activeChannelClaim,
settingsByChannelId,
moderationDelegatesById,
commentBlockWords,
commentUnblockWords,
commentModAddDelegate,
commentModRemoveDelegate,
commentModListDelegates,
fetchCreatorSettings,
updateCreatorSettings,
doToast,
} = props;
const [commentsEnabled, setCommentsEnabled] = React.useState(true);
const [mutedWordTags, setMutedWordTags] = React.useState([]);
const [moderatorTags, setModeratorTags] = React.useState([]);
const [minTipAmountComment, setMinTipAmountComment] = React.useState(0);
const [minTipAmountSuperChat, setMinTipAmountSuperChat] = React.useState(0);
const [slowModeMinGap, setSlowModeMinGap] = React.useState(0);
@ -97,6 +110,71 @@ export default function SettingsCreatorPage(props: Props) {
setLastUpdated(Date.now());
}
function addModerator(newTags: Array<Tag>) {
// Ignoring multiple entries for now, although <TagsSearch> supports it.
let modUri;
try {
modUri = parseURI(newTags[0].name);
} catch (e) {}
if (modUri && modUri.isChannel && modUri.claimName && modUri.claimId) {
if (!moderatorTags.some((modTag) => modTag.name === newTags[0].name)) {
setModeratorTags([...moderatorTags, newTags[0]]);
commentModAddDelegate(modUri.claimId, modUri.claimName, activeChannelClaim);
setLastUpdated(Date.now());
}
} else {
doToast({ message: __('Invalid channel URL "%url%"', { url: newTags[0].name }), isError: true });
}
}
function removeModerator(tagToRemove: Tag) {
let modUri;
try {
modUri = parseURI(tagToRemove.name);
} catch (e) {}
if (modUri && modUri.isChannel && modUri.claimName && modUri.claimId) {
const newModeratorTags = moderatorTags.slice().filter((t) => t.name !== tagToRemove.name);
setModeratorTags(newModeratorTags);
commentModRemoveDelegate(modUri.claimId, modUri.claimName, activeChannelClaim);
setLastUpdated(Date.now());
}
}
function handleChannelSearchSelect(value: string) {
let uriInfo;
try {
uriInfo = parseURI(value);
} catch (e) {}
if (uriInfo && uriInfo.path) {
addModerator([{ name: uriInfo.path }]);
}
}
// Update local moderator states with data from API.
React.useEffect(() => {
commentModListDelegates(activeChannelClaim);
}, [activeChannelClaim, commentModListDelegates]);
React.useEffect(() => {
if (activeChannelClaim) {
const delegates = moderationDelegatesById[activeChannelClaim.claim_id];
if (delegates) {
setModeratorTags(
delegates.map((d) => {
return {
name: d.channelName + '#' + d.channelId,
};
})
);
} else {
setModeratorTags([]);
}
}
}, [activeChannelClaim, moderationDelegatesById]);
// Update local states with data from API.
React.useEffect(() => {
if (lastUpdated !== 0 && Date.now() - lastUpdated < DEBOUNCE_REFRESH_MS) {
@ -235,6 +313,34 @@ export default function SettingsCreatorPage(props: Props) {
}
/>
)}
<Card
title={__('Delegation')}
className="card--enable-overflow"
actions={
<div className="tag--blocked-words">
<label>{__('Add moderator')}</label>
<WunderBar
channelsOnly
noTopSuggestion
noBottomLinks
customSelectAction={handleChannelSearchSelect}
placeholder={__('Add moderator')}
/>
<TagsSearch
label={__('Moderators')}
labelAddNew={__('Add moderators')}
placeholder={__('Add moderator channel URL (e.g. "@lbry#3f")')}
onRemove={removeModerator}
onSelect={addModerator}
tagsPassedIn={moderatorTags}
disableAutoFocus
hideInputField
hideSuggestions
disableControlTags
/>
</div>
}
/>
</>
)}
</Page>

View file

@ -10,6 +10,7 @@
@import 'component/ads';
@import 'component/animation';
@import 'component/badge';
@import 'component/block-list';
@import 'component/button';
@import 'component/card';
@import 'component/channel';

View file

@ -0,0 +1,18 @@
.block-list--moderator {
.button__content {
align-items: flex-start;
}
.section__actions {
flex-wrap: wrap;
}
}
.block-list--delegator {
padding: var(--spacing-s);
.button--alt,
.button--secondary {
margin-top: var(--spacing-xxs);
}
}

View file

@ -318,6 +318,12 @@ $thumbnailWidthSmall: 1rem;
font-size: var(--font-small);
border-bottom: 1px solid var(--color-border);
&:not(:first-child) {
margin-top: var(--spacing-xs);
padding-top: var(--spacing-xs);
border-top: 1px solid var(--color-border);
}
&:not(:last-child) {
margin-bottom: var(--spacing-xs);
}

View file

@ -84,4 +84,8 @@
background-color: var(--color-tag-words-bg-hover);
}
}
.tag__input {
max-width: 40rem;
}
}