lbry-desktop/ui/page/settingsCreator/view.jsx
infinite-persistence 2a3fa58e11
Swap "Moderator" and "Muted words" location.
Quick fix for the lack of scroll lines for the upcoming "moderator search popup". The proper fix would be to move the popup above the input when we are at the extreme bottom.
2021-09-08 21:22:08 +08:00

499 lines
19 KiB
JavaScript

// @flow
import * as ICONS from 'constants/icons';
import * as React from 'react';
import Card from 'component/common/card';
import TagsSearch from 'component/tagsSearch';
import Page from 'component/page';
import Button from 'component/button';
import ChannelSelector from 'component/channelSelector';
import SettingsRow from 'component/settingsRow';
import Spinner from 'component/spinner';
import { FormField } from 'component/common/form-components/form-field';
import Icon from 'component/common/icon';
import LbcSymbol from 'component/common/lbc-symbol';
import I18nMessage from 'component/i18nMessage';
import { isNameValid, parseURI } from 'lbry-redux';
import ClaimPreview from 'component/claimPreview';
import debounce from 'util/debounce';
import { getUriForSearchTerm } from 'util/search';
const DEBOUNCE_REFRESH_MS = 1000;
const LBC_MAX = 21000000;
const LBC_MIN = 0;
const LBC_STEP = 1.0;
// ****************************************************************************
// ****************************************************************************
type Props = {
activeChannelClaim: ChannelClaim,
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: (channelId: 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 [moderatorSearchTerm, setModeratorSearchTerm] = React.useState('');
const [moderatorSearchError, setModeratorSearchError] = React.useState('');
const [moderatorSearchClaimUri, setModeratorSearchClaimUri] = React.useState('');
const [minTip, setMinTip] = React.useState(0);
const [minSuper, setMinSuper] = React.useState(0);
const [slowModeMin, setSlowModeMin] = React.useState(0);
const [lastUpdated, setLastUpdated] = React.useState(1);
const pushSlowModeMinDebounced = React.useMemo(() => debounce(pushSlowModeMin, 1000), []);
const pushMinTipDebounced = React.useMemo(() => debounce(pushMinTip, 1000), []);
const pushMinSuperDebounced = React.useMemo(() => debounce(pushMinSuper, 1000), []);
// **************************************************************************
// **************************************************************************
/**
* Updates corresponding GUI states with the given PerChannelSettings values.
*
* @param settings
* @param fullSync If true, update all states and consider 'undefined' settings as "cleared/false";
* if false, only update defined settings.
*/
function settingsToStates(settings: PerChannelSettings, fullSync: boolean) {
const doSetMutedWordTags = (words: Array<string>) => {
const tagArray = Array.from(new Set(words));
setMutedWordTags(
tagArray
.filter((t) => t !== '')
.map((x) => {
return { name: x };
})
);
};
if (fullSync) {
setCommentsEnabled(settings.comments_enabled || false);
setMinTip(settings.min_tip_amount_comment || 0);
setMinSuper(settings.min_tip_amount_super_chat || 0);
setSlowModeMin(settings.slow_mode_min_gap || 0);
doSetMutedWordTags(settings.words || []);
} else {
if (settings.comments_enabled !== undefined) {
setCommentsEnabled(settings.comments_enabled);
}
if (settings.min_tip_amount_comment !== undefined) {
setMinTip(settings.min_tip_amount_comment);
}
if (settings.min_tip_amount_super_chat !== undefined) {
setMinSuper(settings.min_tip_amount_super_chat);
}
if (settings.slow_mode_min_gap !== undefined) {
setSlowModeMin(settings.slow_mode_min_gap);
}
if (settings.words) {
doSetMutedWordTags(settings.words);
}
}
}
function setSettings(newSettings: PerChannelSettings) {
settingsToStates(newSettings, false);
updateCreatorSettings(activeChannelClaim, newSettings);
setLastUpdated(Date.now());
}
function pushSlowModeMin(value: number, activeChannelClaim: ChannelClaim) {
updateCreatorSettings(activeChannelClaim, { slow_mode_min_gap: value });
}
function pushMinTip(value: number, activeChannelClaim: ChannelClaim) {
updateCreatorSettings(activeChannelClaim, { min_tip_amount_comment: value });
}
function pushMinSuper(value: number, activeChannelClaim: ChannelClaim) {
updateCreatorSettings(activeChannelClaim, { min_tip_amount_super_chat: value });
}
function addMutedWords(newTags: Array<Tag>) {
const validatedNewTags = [];
newTags.forEach((newTag) => {
if (!mutedWordTags.some((tag) => tag.name === newTag.name)) {
validatedNewTags.push(newTag);
}
});
if (validatedNewTags.length !== 0) {
setMutedWordTags([...mutedWordTags, ...validatedNewTags]);
commentBlockWords(
activeChannelClaim,
validatedNewTags.map((x) => x.name)
);
setLastUpdated(Date.now());
}
}
function removeMutedWord(tagToRemove: Tag) {
const newMutedWordTags = mutedWordTags.slice().filter((t) => t.name !== tagToRemove.name);
setMutedWordTags(newMutedWordTags);
commentUnblockWords(activeChannelClaim, ['', tagToRemove.name]);
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.
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) {
// Still debouncing. Skip update.
return;
}
if (activeChannelClaim && settingsByChannelId && settingsByChannelId[activeChannelClaim.claim_id]) {
const channelSettings = settingsByChannelId[activeChannelClaim.claim_id];
settingsToStates(channelSettings, true);
}
}, [activeChannelClaim, settingsByChannelId, lastUpdated]);
// Re-sync list on first idle time; mainly to correct any invalid settings.
React.useEffect(() => {
if (lastUpdated && activeChannelClaim) {
const timer = setTimeout(() => {
fetchCreatorSettings(activeChannelClaim.claim_id);
}, DEBOUNCE_REFRESH_MS);
return () => clearTimeout(timer);
}
}, [lastUpdated, activeChannelClaim, fetchCreatorSettings]);
// **************************************************************************
// **************************************************************************
const isBusy =
!activeChannelClaim || !settingsByChannelId || settingsByChannelId[activeChannelClaim.claim_id] === undefined;
const isDisabled =
activeChannelClaim && settingsByChannelId && settingsByChannelId[activeChannelClaim.claim_id] === null;
return (
<Page
noFooter
noSideNavigation
settingsPage
backout={{ title: __('Creator settings'), backLabel: __('Back') }}
className="card-stack"
>
<div className="card-stack">
<ChannelSelector hideAnon />
{isBusy && (
<div className="main--empty">
<Spinner />
</div>
)}
{isDisabled && (
<Card
title={__('Settings unavailable for this channel')}
subtitle={__("This channel isn't staking enough LBRY Credits to enable Creator Settings.")}
/>
)}
{!isBusy && !isDisabled && (
<>
<Card
isBodyList
body={
<>
<SettingsRow title={__('Enable comments for channel.')}>
<FormField
type="checkbox"
name="comments_enabled"
checked={commentsEnabled}
onChange={() => setSettings({ comments_enabled: !commentsEnabled })}
/>
</SettingsRow>
<SettingsRow title={__('Slow mode')} subtitle={__(HELP.SLOW_MODE)}>
<FormField
name="slow_mode_min_gap"
min={0}
step={1}
type="number"
placeholder="1"
value={slowModeMin}
onChange={(e) => {
const value = parseInt(e.target.value);
setSlowModeMin(value);
pushSlowModeMinDebounced(value, activeChannelClaim);
}}
onBlur={() => setLastUpdated(Date.now())}
/>
</SettingsRow>
<SettingsRow
title={
<I18nMessage tokens={{ lbc: <LbcSymbol /> }}>Minimum %lbc% tip amount for comments</I18nMessage>
}
subtitle={__(HELP.MIN_TIP)}
>
<FormField
name="min_tip_amount_comment"
className="form-field--price-amount"
max={LBC_MAX}
min={LBC_MIN}
step={LBC_STEP}
type="number"
placeholder="1"
value={minTip}
onChange={(e) => {
const newMinTip = parseFloat(e.target.value);
setMinTip(newMinTip);
pushMinTipDebounced(newMinTip, activeChannelClaim);
if (newMinTip !== 0 && minSuper !== 0) {
setMinSuper(0);
pushMinSuperDebounced(0, activeChannelClaim);
}
}}
onBlur={() => setLastUpdated(Date.now())}
/>
</SettingsRow>
<SettingsRow
title={
<I18nMessage tokens={{ lbc: <LbcSymbol /> }}>Minimum %lbc% tip amount for hyperchats</I18nMessage>
}
subtitle={
<>
{__(HELP.MIN_SUPER)}
{minTip !== 0 && (
<p className="help--inline">
<em>{__(HELP.MIN_SUPER_OFF)}</em>
</p>
)}
</>
}
>
<FormField
name="min_tip_amount_super_chat"
className="form-field--price-amount"
min={0}
step="any"
type="number"
placeholder="1"
value={minSuper}
disabled={minTip !== 0}
onChange={(e) => {
const newMinSuper = parseFloat(e.target.value);
setMinSuper(newMinSuper);
pushMinSuperDebounced(newMinSuper, activeChannelClaim);
}}
onBlur={() => setLastUpdated(Date.now())}
/>
</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>
<SettingsRow title={__('Filter')} subtitle={__(HELP.BLOCKED_WORDS)} multirow>
<div className="tag--blocked-words">
<TagsSearch
label={__('Muted words')}
labelAddNew={__('Add words')}
labelSuggestions={__('Suggestions')}
onRemove={removeMutedWord}
onSelect={addMutedWords}
disableAutoFocus
tagsPassedIn={mutedWordTags}
placeholder={__('Add words to block')}
hideSuggestions
disableControlTags
/>
</div>
</SettingsRow>
</>
}
/>
</>
)}
</div>
</Page>
);
}
// prettier-ignore
const HELP = {
SLOW_MODE: 'Minimum time gap in seconds between comments (affects livestream chat as well).',
MIN_TIP: 'Enabling a minimum amount to comment will force all comments, including livestreams, to have tips associated with them. This can help prevent spam.',
MIN_SUPER: 'Enabling a minimum amount to hyperchat will force all TIPPED comments to have this value in order to be shown. This still allows regular comments to be posted.',
MIN_SUPER_OFF: '(This settings is not applicable if all comments require a tip.)',
BLOCKED_WORDS: 'Comments and livestream chat containing these words will be blocked.',
MODERATORS: 'Moderators can block channels on your behalf. Blocked channels will appear in your "Blocked and Muted" list.',
MODERATOR_SEARCH: 'Enter a channel name or URL to add as a moderator.\nExamples:\n - @channel\n - @channel#3\n - https://odysee.com/@Odysee:8\n - lbry://@Odysee#8',
};