Improve "moderator search" usability (#7043)

This commit is contained in:
infinite-persistence 2021-09-08 21:40:39 +08:00
commit 4ddf75f836
No known key found for this signature in database
GPG key ID: B9C3252EDC3D0AA0
6 changed files with 291 additions and 160 deletions

View file

@ -0,0 +1,9 @@
import { connect } from 'react-redux';
import { doToast } from 'redux/actions/notifications';
import SearchChannelField from './view';
const perform = (dispatch) => ({
doToast: (options) => dispatch(doToast(options)),
});
export default connect(null, perform)(SearchChannelField);

View file

@ -0,0 +1,199 @@
// @flow
import React from 'react';
import { isNameValid, parseURI } from 'lbry-redux';
import Button from 'component/button';
import ClaimPreview from 'component/claimPreview';
import { FormField } from 'component/common/form-components/form-field';
import Icon from 'component/common/icon';
import TagsSearch from 'component/tagsSearch';
import * as ICONS from 'constants/icons';
import { getUriForSearchTerm } from 'util/search';
type Props = {
label: string,
labelAddNew: string,
labelFoundAction: string,
values: Array<string>, // [ 'name#id', 'name#id' ]
onAdd?: (channelUri: string) => void,
onRemove?: (channelUri: string) => void,
// --- perform ---
doToast: ({ message: string }) => void,
};
export default function SearchChannelField(props: Props) {
const { label, labelAddNew, labelFoundAction, values, onAdd, onRemove, doToast } = props;
const [searchTerm, setSearchTerm] = React.useState('');
const [searchTermError, setSearchTermError] = React.useState('');
const [searchUri, setSearchUri] = React.useState('');
const addTagRef = React.useRef<any>();
function parseUri(name: string) {
try {
return parseURI(name);
} catch (e) {}
return undefined;
}
function addTag(newTags: Array<Tag>) {
// Ignoring multiple entries for now, although <TagsSearch> supports it.
const uri = parseUri(newTags[0].name);
if (uri && uri.isChannel && uri.claimName && uri.claimId) {
if (!values.includes(newTags[0].name)) {
if (onAdd) {
onAdd(newTags[0].name);
}
}
} else {
doToast({ message: __('Invalid channel URL "%url%"', { url: newTags[0].name }), isError: true });
}
}
function removeTag(tagToRemove: Tag) {
const uri = parseUri(tagToRemove.name);
if (uri && uri.isChannel && uri.claimName && uri.claimId) {
if (values.includes(tagToRemove.name)) {
if (onRemove) {
onRemove(tagToRemove.name);
}
}
}
}
function clearSearchTerm() {
setSearchTerm('');
setSearchTermError('');
setSearchUri('');
}
function handleKeyPress(e) {
// We have to use 'key' instead of 'keyCode' in this event.
if (e.key === 'Enter' && addTagRef && addTagRef.current && addTagRef.current.click) {
e.preventDefault();
addTagRef.current.click();
}
}
function getFoundChannelRenderActionsFn() {
function handleFoundChannelClick(claim) {
if (claim && claim.name && claim.claim_id) {
addTag([{ name: claim.name + '#' + claim.claim_id }]);
clearSearchTerm();
}
}
return (claim) => {
return (
<Button
ref={addTagRef}
requiresAuth
button="primary"
label={labelFoundAction}
onClick={() => handleFoundChannelClick(claim)}
/>
);
};
}
// 'searchTerm' sanitization
React.useEffect(() => {
if (!searchTerm) {
clearSearchTerm();
} else {
const isUrl = searchTerm.startsWith('https://') || searchTerm.startsWith('lbry://');
const autoAlias = !isUrl && !searchTerm.startsWith('@') ? '@' : '';
const [uri, error] = getUriForSearchTerm(`${autoAlias}${searchTerm}`);
setSearchTermError(error ? __('Something not quite right..') : '');
try {
const { streamName, channelName, isChannel } = parseURI(uri);
if (!isChannel && streamName && isNameValid(streamName)) {
setSearchTermError(__('Not a channel (prefix with "@", or enter the channel URL)'));
setSearchUri('');
} else if (isChannel && channelName && isNameValid(channelName)) {
setSearchUri(uri);
}
} catch (e) {
setSearchTermError(e.message);
setSearchUri('');
}
}
}, [searchTerm, setSearchTermError]);
return (
<div className="search__channel tag--blocked-words">
<TagsSearch
label={label}
labelAddNew={labelAddNew}
tagsPassedIn={values.map((x) => ({ name: x }))}
onSelect={addTag}
onRemove={removeTag}
disableAutoFocus
hideInputField
hideSuggestions
disableControlTags
/>
<div className="search__channel--popup">
<FormField
type="text"
name="moderator_search"
className="form-field--address"
label={
<>
{labelAddNew}
<Icon
customTooltipText={__(HELP.CHANNEL_SEARCH)}
className="icon--help"
icon={ICONS.HELP}
tooltip
size={16}
/>
</>
}
placeholder={__('Enter full channel name or URL')}
value={searchTerm}
error={searchTermError}
onKeyPress={(e) => handleKeyPress(e)}
onChange={(e) => setSearchTerm(e.target.value)}
/>
{searchUri && (
<div className="search__channel--popup-results">
<ClaimPreview
uri={searchUri}
hideMenu
hideRepostLabel
disableNavigation
showNullPlaceholder
properties={''}
renderActions={getFoundChannelRenderActionsFn()}
empty={
<div className="claim-preview claim-preview--inactive claim-preview--large claim-preview__empty">
{__('Channel not found')}
<Icon
customTooltipText={__(HELP.CHANNEL_SEARCH)}
className="icon--help"
icon={ICONS.HELP}
tooltip
size={22}
/>
</div>
}
/>
</div>
)}
</div>
</div>
);
}
// prettier-ignore
const HELP = {
CHANNEL_SEARCH: 'Enter the full channel name or URL to search.\n\nExamples:\n - @channel\n - @channel#3\n - https://odysee.com/@Odysee:8\n - lbry://@Odysee#8',
};

View file

@ -16,7 +16,6 @@ import {
selectFetchingBlockedWords, selectFetchingBlockedWords,
selectModerationDelegatesById, selectModerationDelegatesById,
} from 'redux/selectors/comments'; } from 'redux/selectors/comments';
import { doToast } from 'redux/actions/notifications';
const select = (state) => ({ const select = (state) => ({
activeChannelClaim: selectActiveChannelClaim(state), activeChannelClaim: selectActiveChannelClaim(state),
@ -36,7 +35,6 @@ const perform = (dispatch) => ({
commentModRemoveDelegate: (modChanId, modChanName, creatorChannelClaim) => commentModRemoveDelegate: (modChanId, modChanName, creatorChannelClaim) =>
dispatch(doCommentModRemoveDelegate(modChanId, modChanName, creatorChannelClaim)), dispatch(doCommentModRemoveDelegate(modChanId, modChanName, creatorChannelClaim)),
commentModListDelegates: (creatorChannelClaim) => dispatch(doCommentModListDelegates(creatorChannelClaim)), commentModListDelegates: (creatorChannelClaim) => dispatch(doCommentModListDelegates(creatorChannelClaim)),
doToast: (options) => dispatch(doToast(options)),
}); });
export default connect(select, perform)(SettingsCreatorPage); export default connect(select, perform)(SettingsCreatorPage);

View file

@ -1,21 +1,17 @@
// @flow // @flow
import * as ICONS from 'constants/icons';
import * as React from 'react'; import * as React from 'react';
import Card from 'component/common/card'; import Card from 'component/common/card';
import TagsSearch from 'component/tagsSearch'; import TagsSearch from 'component/tagsSearch';
import Page from 'component/page'; import Page from 'component/page';
import Button from 'component/button';
import ChannelSelector from 'component/channelSelector'; import ChannelSelector from 'component/channelSelector';
import SearchChannelField from 'component/searchChannelField';
import SettingsRow from 'component/settingsRow'; import SettingsRow from 'component/settingsRow';
import Spinner from 'component/spinner'; import Spinner from 'component/spinner';
import { FormField } from 'component/common/form-components/form-field'; import { FormField } from 'component/common/form-components/form-field';
import Icon from 'component/common/icon';
import LbcSymbol from 'component/common/lbc-symbol'; import LbcSymbol from 'component/common/lbc-symbol';
import I18nMessage from 'component/i18nMessage'; import I18nMessage from 'component/i18nMessage';
import { isNameValid, parseURI } from 'lbry-redux'; import { parseURI } from 'lbry-redux';
import ClaimPreview from 'component/claimPreview';
import debounce from 'util/debounce'; import debounce from 'util/debounce';
import { getUriForSearchTerm } from 'util/search';
const DEBOUNCE_REFRESH_MS = 1000; const DEBOUNCE_REFRESH_MS = 1000;
@ -54,23 +50,19 @@ export default function SettingsCreatorPage(props: Props) {
commentModListDelegates, commentModListDelegates,
fetchCreatorSettings, fetchCreatorSettings,
updateCreatorSettings, updateCreatorSettings,
doToast,
} = props; } = props;
const [commentsEnabled, setCommentsEnabled] = React.useState(true); const [commentsEnabled, setCommentsEnabled] = React.useState(true);
const [mutedWordTags, setMutedWordTags] = React.useState([]); const [mutedWordTags, setMutedWordTags] = React.useState([]);
const [moderatorTags, setModeratorTags] = React.useState([]); const [moderatorUris, setModeratorUris] = React.useState([]);
const [moderatorSearchTerm, setModeratorSearchTerm] = React.useState('');
const [moderatorSearchError, setModeratorSearchError] = React.useState('');
const [moderatorSearchClaimUri, setModeratorSearchClaimUri] = React.useState('');
const [minTip, setMinTip] = React.useState(0); const [minTip, setMinTip] = React.useState(0);
const [minSuper, setMinSuper] = React.useState(0); const [minSuper, setMinSuper] = React.useState(0);
const [slowModeMin, setSlowModeMin] = React.useState(0); const [slowModeMin, setSlowModeMin] = React.useState(0);
const [lastUpdated, setLastUpdated] = React.useState(1); const [lastUpdated, setLastUpdated] = React.useState(1);
const pushSlowModeMinDebounced = React.useMemo(() => debounce(pushSlowModeMin, 1000), []); const pushSlowModeMinDebounced = React.useMemo(() => debounce(pushSlowModeMin, 1000), []); // eslint-disable-line react-hooks/exhaustive-deps
const pushMinTipDebounced = React.useMemo(() => debounce(pushMinTip, 1000), []); const pushMinTipDebounced = React.useMemo(() => debounce(pushMinTip, 1000), []); // eslint-disable-line react-hooks/exhaustive-deps
const pushMinSuperDebounced = React.useMemo(() => debounce(pushMinSuper, 1000), []); const pushMinSuperDebounced = React.useMemo(() => debounce(pushMinSuper, 1000), []); // eslint-disable-line react-hooks/exhaustive-deps
// ************************************************************************** // **************************************************************************
// ************************************************************************** // **************************************************************************
@ -79,8 +71,8 @@ export default function SettingsCreatorPage(props: Props) {
* Updates corresponding GUI states with the given PerChannelSettings values. * Updates corresponding GUI states with the given PerChannelSettings values.
* *
* @param settings * @param settings
* @param fullSync If true, update all states and consider 'undefined' settings as "cleared/false"; * @param fullSync If true, update all states and consider 'undefined'
* if false, only update defined settings. * settings as "cleared/false"; if false, only update defined settings.
*/ */
function settingsToStates(settings: PerChannelSettings, fullSync: boolean) { function settingsToStates(settings: PerChannelSettings, fullSync: boolean) {
const doSetMutedWordTags = (words: Array<string>) => { const doSetMutedWordTags = (words: Array<string>) => {
@ -137,6 +129,31 @@ export default function SettingsCreatorPage(props: Props) {
updateCreatorSettings(activeChannelClaim, { min_tip_amount_super_chat: value }); updateCreatorSettings(activeChannelClaim, { min_tip_amount_super_chat: value });
} }
function parseModUri(uri) {
try {
return parseURI(uri);
} catch (e) {}
return undefined;
}
function handleModeratorAdded(channelUri: string) {
setModeratorUris([...moderatorUris, channelUri]);
const parsedUri = parseModUri(channelUri);
if (parsedUri && parsedUri.claimId && parsedUri.claimName) {
commentModAddDelegate(parsedUri.claimId, parsedUri.claimName, activeChannelClaim);
setLastUpdated(Date.now());
}
}
function handleModeratorRemoved(channelUri: string) {
setModeratorUris(moderatorUris.filter((x) => x !== channelUri));
const parsedUri = parseModUri(channelUri);
if (parsedUri && parsedUri.claimId && parsedUri.claimName) {
commentModRemoveDelegate(parsedUri.claimId, parsedUri.claimName, activeChannelClaim);
setLastUpdated(Date.now());
}
}
function addMutedWords(newTags: Array<Tag>) { function addMutedWords(newTags: Array<Tag>) {
const validatedNewTags = []; const validatedNewTags = [];
newTags.forEach((newTag) => { newTags.forEach((newTag) => {
@ -162,74 +179,9 @@ export default function SettingsCreatorPage(props: Props) {
setLastUpdated(Date.now()); 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(claim) {
if (claim && claim.name && claim.claim_id) {
addModerator([{ name: claim.name + '#' + claim.claim_id }]);
}
}
// ************************************************************************** // **************************************************************************
// ************************************************************************** // **************************************************************************
// 'moderatorSearchTerm' to 'moderatorSearchClaimUri'
React.useEffect(() => {
if (!moderatorSearchTerm) {
setModeratorSearchError('');
setModeratorSearchClaimUri('');
} else {
const [searchUri, error] = getUriForSearchTerm(moderatorSearchTerm);
setModeratorSearchError(error ? __('Something not quite right..') : '');
try {
const { streamName, channelName, isChannel } = parseURI(searchUri);
if (!isChannel && streamName && isNameValid(streamName)) {
setModeratorSearchError(__('Not a channel (prefix with "@", or enter the channel URL)'));
setModeratorSearchClaimUri('');
} else if (isChannel && channelName && isNameValid(channelName)) {
setModeratorSearchClaimUri(searchUri);
}
} catch (e) {
if (moderatorSearchTerm !== '@') {
setModeratorSearchError('');
}
setModeratorSearchClaimUri('');
}
}
}, [moderatorSearchTerm, setModeratorSearchError]);
// Update local moderator states with data from API. // Update local moderator states with data from API.
React.useEffect(() => { React.useEffect(() => {
commentModListDelegates(activeChannelClaim); commentModListDelegates(activeChannelClaim);
@ -239,15 +191,9 @@ export default function SettingsCreatorPage(props: Props) {
if (activeChannelClaim) { if (activeChannelClaim) {
const delegates = moderationDelegatesById[activeChannelClaim.claim_id]; const delegates = moderationDelegatesById[activeChannelClaim.claim_id];
if (delegates) { if (delegates) {
setModeratorTags( setModeratorUris(delegates.map((d) => `${d.channelName}#${d.channelId}`));
delegates.map((d) => {
return {
name: d.channelName + '#' + d.channelId,
};
})
);
} else { } else {
setModeratorTags([]); setModeratorUris([]);
} }
} }
}, [activeChannelClaim, moderationDelegatesById]); }, [activeChannelClaim, moderationDelegatesById]);
@ -400,6 +346,17 @@ export default function SettingsCreatorPage(props: Props) {
/> />
</SettingsRow> </SettingsRow>
<SettingsRow title={__('Moderators')} subtitle={__(HELP.MODERATORS)} multirow>
<SearchChannelField
label={__('Moderators')}
labelAddNew={__('Add moderator')}
labelFoundAction={__('Add')}
values={moderatorUris}
onAdd={handleModeratorAdded}
onRemove={handleModeratorRemoved}
/>
</SettingsRow>
<SettingsRow title={__('Filter')} subtitle={__(HELP.BLOCKED_WORDS)} multirow> <SettingsRow title={__('Filter')} subtitle={__(HELP.BLOCKED_WORDS)} multirow>
<div className="tag--blocked-words"> <div className="tag--blocked-words">
<TagsSearch <TagsSearch
@ -416,67 +373,6 @@ export default function SettingsCreatorPage(props: Props) {
/> />
</div> </div>
</SettingsRow> </SettingsRow>
<SettingsRow title={__('Moderators')} subtitle={__(HELP.MODERATORS)} multirow>
<div className="tag--blocked-words">
<TagsSearch
label={__('Moderators')}
labelAddNew={__('Add moderator')}
onRemove={removeModerator}
onSelect={addModerator}
tagsPassedIn={moderatorTags}
disableAutoFocus
hideInputField
hideSuggestions
disableControlTags
/>
<FormField
type="text"
name="moderator_search"
className="form-field--address"
label={
<>
{__('Search channel')}
<Icon
customTooltipText={__(HELP.MODERATOR_SEARCH)}
className="icon--help"
icon={ICONS.HELP}
tooltip
size={16}
/>
</>
}
placeholder={__('Enter a @username or URL')}
value={moderatorSearchTerm}
onChange={(e) => setModeratorSearchTerm(e.target.value)}
error={moderatorSearchError}
/>
{moderatorSearchClaimUri && (
<div className="section">
<ClaimPreview
key={moderatorSearchClaimUri}
uri={moderatorSearchClaimUri}
// type={'small'}
// showNullPlaceholder
hideMenu
hideRepostLabel
disableNavigation
properties={''}
renderActions={(claim) => {
return (
<Button
requiresAuth
button="primary"
label={__('Add as moderator')}
onClick={() => handleChannelSearchSelect(claim)}
/>
);
}}
/>
</div>
)}
</div>
</SettingsRow>
</> </>
} }
/> />

View file

@ -137,3 +137,40 @@
max-width: 15rem; max-width: 15rem;
} }
} }
.search__channel {
margin-bottom: var(--spacing-l);
fieldset-section {
margin-top: 0;
}
}
.search__channel--popup {
position: relative;
display: inline-block;
width: 100%;
}
.search__channel--popup-results {
position: absolute;
z-index: 99;
top: 100%;
left: 0;
right: 0;
background: #21252980;
color: white;
.claim-preview__title {
color: #eff1f4;
}
.button--uri-indicator,
.media__subtitle {
color: #d8dde1;
}
.icon--help {
vertical-align: middle;
}
}

View file

@ -230,10 +230,6 @@
.settings__row--value { .settings__row--value {
width: 100%; width: 100%;
fieldset-section:not(:only-child) {
margin-top: var(--spacing-s);
}
fieldset-section.radio { fieldset-section.radio {
margin-top: var(--spacing-s); margin-top: var(--spacing-s);
} }
@ -242,10 +238,6 @@
margin-top: var(--spacing-m); margin-top: var(--spacing-m);
} }
.tags--remove {
margin-bottom: 0;
}
.tags__input-wrapper { .tags__input-wrapper {
.tag__input { .tag__input {
height: unset; height: unset;