7817c57689
(1) The GUI currently behaves such that if MinTip is set, MinSuper will be ineffective. However, Commentron actually checks MinSuper first. Just zero out MinSuper for now to enforce our desired behavior. (2) Add debouncing to numerical settings. Refresh everything during onBlur to always reflect what's in server (e.g. in case there was an error).
477 lines
17 KiB
JavaScript
477 lines
17 KiB
JavaScript
// @flow
|
|
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 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 { 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
|
|
backout={{
|
|
title: __('Creator settings'),
|
|
backLabel: __('Done'),
|
|
}}
|
|
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
|
|
title={__('General')}
|
|
actions={
|
|
<>
|
|
<FormField
|
|
type="checkbox"
|
|
name="comments_enabled"
|
|
label={__('Enable comments for channel.')}
|
|
checked={commentsEnabled}
|
|
onChange={() => setSettings({ comments_enabled: !commentsEnabled })}
|
|
/>
|
|
<FormField
|
|
name="slow_mode_min_gap"
|
|
label={__('Minimum time gap in seconds between comments (affects livestream chat as well).')}
|
|
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())}
|
|
/>
|
|
</>
|
|
}
|
|
/>
|
|
<Card
|
|
title={__('Filter')}
|
|
actions={
|
|
<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>
|
|
}
|
|
/>
|
|
<Card
|
|
title={__('Tip')}
|
|
actions={
|
|
<>
|
|
<FormField
|
|
name="min_tip_amount_comment"
|
|
label={
|
|
<I18nMessage tokens={{ lbc: <LbcSymbol /> }}>Minimum %lbc% tip amount for comments</I18nMessage>
|
|
}
|
|
helper={__(
|
|
'Enabling a minimum amount to comment will force all comments, including livestreams, to have tips associated with them. This can help prevent spam.'
|
|
)}
|
|
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())}
|
|
/>
|
|
<FormField
|
|
name="min_tip_amount_super_chat"
|
|
label={
|
|
<I18nMessage tokens={{ lbc: <LbcSymbol /> }}>Minimum %lbc% tip amount for hyperchats</I18nMessage>
|
|
}
|
|
helper={
|
|
<>
|
|
{__(
|
|
'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.'
|
|
)}
|
|
{minTip !== 0 && (
|
|
<p className="help--inline">
|
|
<em>{__('(This settings is not applicable if all comments require a tip.)')}</em>
|
|
</p>
|
|
)}
|
|
</>
|
|
}
|
|
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())}
|
|
/>
|
|
</>
|
|
}
|
|
/>
|
|
<Card
|
|
title={__('Delegation')}
|
|
className="card--enable-overflow"
|
|
actions={
|
|
<div className="tag--blocked-words">
|
|
<FormField
|
|
type="text"
|
|
name="moderator_search"
|
|
className="form-field--address"
|
|
label={__('Add moderator')}
|
|
placeholder={__('Enter a @username or URL')}
|
|
helper={__('examples: @channel, @channel#3, https://odysee.com/@Odysee:8, lbry://@Odysee#8')}
|
|
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>
|
|
)}
|
|
<TagsSearch
|
|
label={__('Moderators')}
|
|
labelAddNew={__('Add moderator')}
|
|
onRemove={removeModerator}
|
|
onSelect={addModerator}
|
|
tagsPassedIn={moderatorTags}
|
|
disableAutoFocus
|
|
hideInputField
|
|
hideSuggestions
|
|
disableControlTags
|
|
/>
|
|
</div>
|
|
}
|
|
/>
|
|
</>
|
|
)}
|
|
</Page>
|
|
);
|
|
}
|