// @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 { getUriForSearchTerm } from 'util/search'; const DEBOUNCE_REFRESH_MS = 1000; const FEATURE_IS_READY = false; 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: (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 [moderatorSearchTerm, setModeratorSearchTerm] = React.useState(''); const [moderatorSearchError, setModeratorSearchError] = React.useState(''); const [moderatorSearchClaimUri, setModeratorSearchClaimUri] = React.useState(''); const [minTipAmountComment, setMinTipAmountComment] = React.useState(0); const [minTipAmountSuperChat, setMinTipAmountSuperChat] = React.useState(0); const [slowModeMinGap, setSlowModeMinGap] = React.useState(0); const [lastUpdated, setLastUpdated] = React.useState(1); function settingsToStates(settings: PerChannelSettings) { if (settings.comments_enabled !== undefined) { setCommentsEnabled(settings.comments_enabled); } if (settings.min_tip_amount_comment !== undefined) { setMinTipAmountComment(settings.min_tip_amount_comment); } if (settings.min_tip_amount_super_chat !== undefined) { setMinTipAmountSuperChat(settings.min_tip_amount_super_chat); } if (settings.slow_mode_min_gap !== undefined) { setSlowModeMinGap(settings.slow_mode_min_gap); } if (settings.words) { const tagArray = Array.from(new Set(settings.words)); setMutedWordTags( tagArray .filter((t) => t !== '') .map((x) => { return { name: x }; }) ); } } function setSettings(newSettings: PerChannelSettings) { settingsToStates(newSettings); updateCreatorSettings(activeChannelClaim, newSettings); setLastUpdated(Date.now()); } 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); } }, [activeChannelClaim, settingsByChannelId, lastUpdated]); // Re-sync list, 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={slowModeMinGap} onChange={(e) => setSettings({ slow_mode_min_gap: parseInt(e.target.value) })} /> </> } /> <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> } /> {FEATURE_IS_READY && ( <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" min={0} step="any" type="number" placeholder="1" value={minTipAmountComment} onChange={(e) => setSettings({ min_tip_amount_comment: parseFloat(e.target.value) })} /> <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.' )} className="form-field--price-amount" min={0} step="any" type="number" placeholder="1" value={minTipAmountSuperChat} onChange={(e) => setSettings({ min_tip_amount_super_chat: parseFloat(e.target.value) })} /> </> } /> )} <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 moderators')} onRemove={removeModerator} onSelect={addModerator} tagsPassedIn={moderatorTags} disableAutoFocus hideInputField hideSuggestions disableControlTags /> </div> } /> </> )} </Page> ); }