Comment: minimum channel age (#940)

This commit is contained in:
infinite-persistence 2022-02-23 23:06:52 +08:00
commit d6cd3caa77
No known key found for this signature in database
GPG key ID: B9C3252EDC3D0AA0
13 changed files with 250 additions and 61 deletions

View file

@ -37,6 +37,7 @@ declare type PerChannelSettings = {
min_tip_amount_comment?: number, min_tip_amount_comment?: number,
min_tip_amount_super_chat?: number, min_tip_amount_super_chat?: number,
slow_mode_min_gap?: number, slow_mode_min_gap?: number,
time_since_first_comment?: number,
}; };
// todo: relate individual comments to their commentId // todo: relate individual comments to their commentId
@ -318,6 +319,7 @@ declare type UpdateSettingsParams = {
min_tip_amount_comment?: number, min_tip_amount_comment?: number,
min_tip_amount_super_chat?: number, min_tip_amount_super_chat?: number,
slow_mode_min_gap?: number, slow_mode_min_gap?: number,
time_since_first_comment?: number,
}; };
declare type BlockWordParams = { declare type BlockWordParams = {

View file

@ -1401,6 +1401,7 @@
"Unable to comment. Your channel has been blocked by an admin.": "Unable to comment. Your channel has been blocked by an admin.", "Unable to comment. Your channel has been blocked by an admin.": "Unable to comment. Your channel has been blocked by an admin.",
"Unable to comment. The content owner has disabled comments.": "Unable to comment. The content owner has disabled comments.", "Unable to comment. The content owner has disabled comments.": "Unable to comment. The content owner has disabled comments.",
"Please do not spam.": "Please do not spam.", "Please do not spam.": "Please do not spam.",
"Your channel does not meet the creator's minimum channel-age limit.": "Your channel does not meet the creator's minimum channel-age limit.",
"Slow mode is on. Please wait up to %1% seconds before commenting again.": "Slow mode is on. Please wait up to %1% seconds before commenting again.", "Slow mode is on. Please wait up to %1% seconds before commenting again.": "Slow mode is on. Please wait up to %1% seconds before commenting again.",
"The comment contains contents that are blocked by %1%.": "The comment contains contents that are blocked by %1%.", "The comment contains contents that are blocked by %1%.": "The comment contains contents that are blocked by %1%.",
"Your user name \"%1%\" is too close to the creator's user name \"%2%\" and may cause confusion. Please use another identity.": "Your user name \"%1%\" is too close to the creator's user name \"%2%\" and may cause confusion. Please use another identity.", "Your user name \"%1%\" is too close to the creator's user name \"%2%\" and may cause confusion. Please use another identity.": "Your user name \"%1%\" is too close to the creator's user name \"%2%\" and may cause confusion. Please use another identity.",
@ -2197,5 +2198,10 @@
"When do you want to go live?": "When do you want to go live?", "When do you want to go live?": "When do you want to go live?",
"Anytime": "Anytime", "Anytime": "Anytime",
"Scheduled Time": "Scheduled Time", "Scheduled Time": "Scheduled Time",
"Minimum channel age for comments": "Minimum channel age for comments",
"Channels with a lifespan lower than the specified duration will not be able to comment on your content.": "Channels with a lifespan lower than the specified duration will not be able to comment on your content.",
"Set minimum channel age": "Set minimum channel age",
"The minimum duration must not exceed Feb 8th, 2022.": "The minimum duration must not exceed Feb 8th, 2022.",
"No limit": "No limit",
"--end--": "--end--" "--end--": "--end--"
} }

View file

@ -0,0 +1,3 @@
import FormFieldDuration from './view';
export default FormFieldDuration;

View file

@ -0,0 +1,90 @@
// @flow
import type { Node } from 'react';
import React from 'react';
import parseDuration from 'parse-duration';
import { FormField } from 'component/common/form';
import Icon from 'component/common/icon';
import * as ICONS from 'constants/icons';
const INPUT_EXAMPLES = '\n- 30s\n- 10m\n- 1h\n- 2d\n- 3mo\n- 1y';
const ONE_HUNDRED_YEARS_IN_SECONDS = 3154000000;
type Props = {
name: string,
label?: string | Node,
placeholder?: string | number,
disabled?: boolean,
value: string | number,
onChange: (any) => void,
onResolve: (valueInSeconds: number) => void, // Returns parsed/resolved value in seconds; "-1" for invalid input.
maxDurationInSeconds?: number,
};
export default function FormFieldDuration(props: Props) {
const { name, label, placeholder, disabled, value, onChange, onResolve, maxDurationInSeconds } = props;
const [valueSec, setValueSec] = React.useState(-1);
const [valueErr, setValueErr] = React.useState('');
React.useEffect(() => {
const handleInvalidInput = (errMsg: string) => {
if (valueSec !== -1) {
setValueSec(-1);
}
if (valueErr !== errMsg) {
setValueErr(errMsg);
}
onResolve(-1);
};
const handleValidInput = (seconds) => {
if (seconds !== valueSec) {
setValueSec(seconds);
onResolve(seconds);
}
if (valueErr) {
setValueErr('');
}
};
if (!value) {
handleValidInput(-1); // Reset
return;
}
const seconds = parseDuration(value, 's');
if (Number.isInteger(seconds) && seconds > 0) {
const max = maxDurationInSeconds || ONE_HUNDRED_YEARS_IN_SECONDS;
if (seconds > max) {
handleInvalidInput(__('Value exceeded maximum.'));
} else {
handleValidInput(seconds);
}
} else {
handleInvalidInput(__('Invalid duration.'));
}
}, [value, valueSec, valueErr, maxDurationInSeconds, onResolve]);
return (
<FormField
name={name}
type="text"
disabled={disabled}
label={
<>
{label || __('Duration')}
<Icon
customTooltipText={__('Examples: %examples%', { examples: INPUT_EXAMPLES })}
className="icon--help"
icon={ICONS.HELP}
tooltip
size={16}
/>
</>
}
placeholder={placeholder || '30s, 10m, 1h, 2d, 3mo, 1y'}
value={value}
onChange={onChange}
error={valueErr}
/>
);
}

View file

@ -42,6 +42,7 @@ export const IMAGE_UPLOAD = 'image_upload';
export const MOBILE_SEARCH = 'mobile_search'; export const MOBILE_SEARCH = 'mobile_search';
export const VIEW_IMAGE = 'view_image'; export const VIEW_IMAGE = 'view_image';
export const BLOCK_CHANNEL = 'block_channel'; export const BLOCK_CHANNEL = 'block_channel';
export const MIN_CHANNEL_AGE = 'min_channel_age';
export const COLLECTION_ADD = 'collection_add'; export const COLLECTION_ADD = 'collection_add';
export const COLLECTION_DELETE = 'collection_delete'; export const COLLECTION_DELETE = 'collection_delete';
export const CONFIRM_REMOVE_CARD = 'CONFIRM_REMOVE_CARD'; export const CONFIRM_REMOVE_CARD = 'CONFIRM_REMOVE_CARD';

View file

@ -1,14 +1,12 @@
// @flow // @flow
import React from 'react'; import React from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
import parseDuration from 'parse-duration';
import Button from 'component/button'; import Button from 'component/button';
import ChannelThumbnail from 'component/channelThumbnail'; import ChannelThumbnail from 'component/channelThumbnail';
import ClaimPreview from 'component/claimPreview'; import ClaimPreview from 'component/claimPreview';
import Card from 'component/common/card'; import Card from 'component/common/card';
import { FormField } from 'component/common/form'; import { FormField } from 'component/common/form';
import Icon from 'component/common/icon'; import FormFieldDuration from 'component/formFieldDuration';
import * as ICONS from 'constants/icons';
import usePersistedState from 'effects/use-persisted-state'; import usePersistedState from 'effects/use-persisted-state';
import { Modal } from 'modal/modal'; import { Modal } from 'modal/modal';
import { getChannelFromClaim } from 'util/claim'; import { getChannelFromClaim } from 'util/claim';
@ -76,7 +74,6 @@ export default function ModalBlockChannel(props: Props) {
const [tab, setTab] = usePersistedState('ModalBlockChannel:tab', TAB.PERSONAL); const [tab, setTab] = usePersistedState('ModalBlockChannel:tab', TAB.PERSONAL);
const [blockType, setBlockType] = usePersistedState('ModalBlockChannel:blockType', BLOCK.PERMANENT); const [blockType, setBlockType] = usePersistedState('ModalBlockChannel:blockType', BLOCK.PERMANENT);
const [timeoutInput, setTimeoutInput] = usePersistedState('ModalBlockChannel:timeoutInput', '10m'); const [timeoutInput, setTimeoutInput] = usePersistedState('ModalBlockChannel:timeoutInput', '10m');
const [timeoutInputErr, setTimeoutInputErr] = React.useState('');
const [timeoutSec, setTimeoutSec] = React.useState(-1); const [timeoutSec, setTimeoutSec] = React.useState(-1);
const isPersonalTheOnlyTab = !activeChannelIsModerator && !activeChannelIsAdmin; const isPersonalTheOnlyTab = !activeChannelIsModerator && !activeChannelIsAdmin;
@ -101,45 +98,6 @@ export default function ModalBlockChannel(props: Props) {
} }
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
// 'timeoutInput' to 'timeoutSec' conversion.
React.useEffect(() => {
const handleInvalidInput = (errMsg: string) => {
if (timeoutSec !== -1) {
setTimeoutSec(-1);
}
if (timeoutInputErr !== errMsg) {
setTimeoutInputErr(errMsg);
}
};
const handleValidInput = (seconds) => {
if (seconds !== timeoutSec) {
setTimeoutSec(seconds);
}
if (timeoutInputErr) {
setTimeoutInputErr('');
}
};
if (!timeoutInput) {
handleValidInput(-1); // Reset
return;
}
const ONE_HUNDRED_YEARS_IN_SECONDS = 3154000000;
const seconds = parseDuration(timeoutInput, 's');
if (Number.isInteger(seconds) && seconds > 0) {
if (seconds > ONE_HUNDRED_YEARS_IN_SECONDS) {
handleInvalidInput(__('Wow, banned for more than 100 years?'));
} else {
handleValidInput(seconds);
}
} else {
handleInvalidInput(__('Invalid duration.'));
}
}, [timeoutInput, timeoutInputErr, timeoutSec]);
// ************************************************************************** // **************************************************************************
// ************************************************************************** // **************************************************************************
@ -187,27 +145,12 @@ export default function ModalBlockChannel(props: Props) {
} }
function getTimeoutDurationElem() { function getTimeoutDurationElem() {
const examples = '\n- 30s\n- 10m\n- 1h\n- 2d\n- 3mo\n- 1y';
return ( return (
<FormField <FormFieldDuration
name="time_out" name="time_out"
label={
<>
{__('Duration')}
<Icon
customTooltipText={__('Enter the timeout duration. Examples: %examples%', { examples })}
className="icon--help"
icon={ICONS.HELP}
tooltip
size={16}
/>
</>
}
type="text"
placeholder="30s, 10m, 1h, 2d, 3mo, 1y"
value={timeoutInput} value={timeoutInput}
onChange={(e) => setTimeoutInput(e.target.value)} onChange={(e) => setTimeoutInput(e.target.value)}
error={timeoutInputErr} onResolve={(valueInSeconds) => setTimeoutSec(valueInSeconds)}
/> />
); );
} }

View file

@ -0,0 +1,9 @@
import { connect } from 'react-redux';
import ModalMinChannelAge from './view';
import { doHideModal } from 'redux/actions/app';
const perform = {
doHideModal,
};
export default connect(null, perform)(ModalMinChannelAge);

View file

@ -0,0 +1,76 @@
// @flow
import React from 'react';
import moment from 'moment';
import Button from 'component/button';
import Card from 'component/common/card';
import { FormField } from 'component/common/form-components/form-field';
import FormFieldDuration from 'component/formFieldDuration';
import { Modal } from 'modal/modal';
const CHANNEL_AGE_LIMIT_MIN_DATE = new Date('February 8, 2022 00:00:00');
const LIMITATION_WARNING = 'The minimum duration must not exceed Feb 8th, 2022.';
type Props = {
onConfirm: (limitInMinutes: number, closeModal: () => void) => void,
doHideModal: () => void,
};
export default function ModalMinChannelAge(props: Props) {
const { onConfirm, doHideModal } = props;
const [showLimitationWarning, setShowLimitationWarning] = React.useState('');
const [limitDisabled, setLimitDisabled] = React.useState(false);
const [minChannelAgeInput, setMinChannelAgeInput] = React.useState('');
const [minChannelAgeMinutes, setMinChannelAgeMinutes] = React.useState(-1);
const inputOk = limitDisabled || (minChannelAgeMinutes > 0 && !showLimitationWarning);
function handleOnClick() {
if (onConfirm) {
onConfirm(limitDisabled ? 0 : minChannelAgeMinutes, doHideModal);
}
}
function handleOnInputResolved(valueInSeconds) {
if (valueInSeconds > 0) {
const minCreationDate = moment().subtract(valueInSeconds, 'seconds').toDate();
setShowLimitationWarning(minCreationDate.getTime() < CHANNEL_AGE_LIMIT_MIN_DATE.getTime());
setMinChannelAgeMinutes(Math.ceil(valueInSeconds / 60));
} else {
setShowLimitationWarning(false);
setMinChannelAgeMinutes(-1);
}
}
return (
<Modal isOpen type="card" onAborted={doHideModal}>
<Card
title={__('Set minimum channel age')}
body={
<>
<FormFieldDuration
name="time_since_first_comment"
value={minChannelAgeInput}
disabled={limitDisabled}
onChange={(e) => setMinChannelAgeInput(e.target.value)}
onResolve={handleOnInputResolved}
/>
{showLimitationWarning && !limitDisabled && <p className="help--warning">{__(LIMITATION_WARNING)}</p>}
<FormField
type="checkbox"
name="no_limit"
label={__('No limit')}
checked={limitDisabled}
onChange={() => setLimitDisabled(!limitDisabled)}
/>
</>
}
actions={
<div className="section__actions">
<Button button="primary" label={__('OK')} onClick={handleOnClick} disabled={!inputOk} />
<Button button="link" label={__('Cancel')} onClick={doHideModal} />
</div>
}
/>
</Modal>
);
}

View file

@ -45,6 +45,9 @@ const ModalImageUpload = lazyImport(() => import('modal/modalImageUpload' /* web
const ModalMassTipsUnlock = lazyImport(() => const ModalMassTipsUnlock = lazyImport(() =>
import('modal/modalMassTipUnlock' /* webpackChunkName: "modalMassTipUnlock" */) import('modal/modalMassTipUnlock' /* webpackChunkName: "modalMassTipUnlock" */)
); );
const ModalMinChannelAge = lazyImport(() =>
import('modal/modalMinChannelAge' /* webpackChunkName: "modalMinChannelAge" */)
);
const ModalMobileSearch = lazyImport(() => const ModalMobileSearch = lazyImport(() =>
import('modal/modalMobileSearch' /* webpackChunkName: "modalMobileSearch" */) import('modal/modalMobileSearch' /* webpackChunkName: "modalMobileSearch" */)
); );
@ -169,6 +172,8 @@ function getModal(id) {
return ModalMassTipsUnlock; return ModalMassTipsUnlock;
case MODALS.BLOCK_CHANNEL: case MODALS.BLOCK_CHANNEL:
return ModalBlockChannel; return ModalBlockChannel;
case MODALS.MIN_CHANNEL_AGE:
return ModalMinChannelAge;
case MODALS.COLLECTION_ADD: case MODALS.COLLECTION_ADD:
return ModalClaimCollectionAdd; return ModalClaimCollectionAdd;
case MODALS.COLLECTION_DELETE: case MODALS.COLLECTION_DELETE:

View file

@ -1,5 +1,6 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import SettingsCreatorPage from './view'; import SettingsCreatorPage from './view';
import { doOpenModal } from 'redux/actions/app';
import { import {
doCommentBlockWords, doCommentBlockWords,
doCommentUnblockWords, doCommentUnblockWords,
@ -35,6 +36,7 @@ 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)),
doOpenModal: (modal, props) => dispatch(doOpenModal(modal, props)),
}); });
export default connect(select, perform)(SettingsCreatorPage); export default connect(select, perform)(SettingsCreatorPage);

View file

@ -1,5 +1,9 @@
// @flow // @flow
import * as React from 'react'; import * as React from 'react';
import humanizeDuration from 'humanize-duration';
import * as ICONS from 'constants/icons';
import * as MODALS from 'constants/modal_types';
import Button from 'component/button';
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';
@ -36,6 +40,7 @@ type Props = {
fetchCreatorSettings: (channelId: string) => void, fetchCreatorSettings: (channelId: string) => void,
updateCreatorSettings: (ChannelClaim, PerChannelSettings) => void, updateCreatorSettings: (ChannelClaim, PerChannelSettings) => void,
doToast: ({ message: string }) => void, doToast: ({ message: string }) => void,
doOpenModal: (id: string, {}) => void,
}; };
export default function SettingsCreatorPage(props: Props) { export default function SettingsCreatorPage(props: Props) {
@ -50,6 +55,7 @@ export default function SettingsCreatorPage(props: Props) {
commentModListDelegates, commentModListDelegates,
fetchCreatorSettings, fetchCreatorSettings,
updateCreatorSettings, updateCreatorSettings,
doOpenModal,
} = props; } = props;
const [commentsEnabled, setCommentsEnabled] = React.useState(true); const [commentsEnabled, setCommentsEnabled] = React.useState(true);
@ -58,6 +64,7 @@ export default function SettingsCreatorPage(props: Props) {
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 [minChannelAgeMinutes, setMinChannelAgeMinutes] = React.useState(0);
const [lastUpdated, setLastUpdated] = React.useState(1); const [lastUpdated, setLastUpdated] = React.useState(1);
const pushSlowModeMinDebounced = React.useMemo(() => debounce(pushSlowModeMin, 1000), []); // eslint-disable-line react-hooks/exhaustive-deps const pushSlowModeMinDebounced = React.useMemo(() => debounce(pushSlowModeMin, 1000), []); // eslint-disable-line react-hooks/exhaustive-deps
@ -91,6 +98,7 @@ export default function SettingsCreatorPage(props: Props) {
setMinTip(settings.min_tip_amount_comment || 0); setMinTip(settings.min_tip_amount_comment || 0);
setMinSuper(settings.min_tip_amount_super_chat || 0); setMinSuper(settings.min_tip_amount_super_chat || 0);
setSlowModeMin(settings.slow_mode_min_gap || 0); setSlowModeMin(settings.slow_mode_min_gap || 0);
setMinChannelAgeMinutes(settings.time_since_first_comment || 0);
doSetMutedWordTags(settings.words || []); doSetMutedWordTags(settings.words || []);
} else { } else {
if (settings.comments_enabled !== undefined) { if (settings.comments_enabled !== undefined) {
@ -105,6 +113,9 @@ export default function SettingsCreatorPage(props: Props) {
if (settings.slow_mode_min_gap !== undefined) { if (settings.slow_mode_min_gap !== undefined) {
setSlowModeMin(settings.slow_mode_min_gap); setSlowModeMin(settings.slow_mode_min_gap);
} }
if (settings.time_since_first_comment) {
setMinChannelAgeMinutes(settings.time_since_first_comment);
}
if (settings.words) { if (settings.words) {
doSetMutedWordTags(settings.words); doSetMutedWordTags(settings.words);
} }
@ -285,6 +296,41 @@ export default function SettingsCreatorPage(props: Props) {
/> />
</SettingsRow> </SettingsRow>
<SettingsRow title={__('Minimum channel age for comments')} subtitle={__(HELP.CHANNEL_AGE)}>
<div className="section__actions">
<FormField
name="time_since_first_comment"
className="form-field--copyable"
disabled={minChannelAgeMinutes <= 0}
type="text"
readOnly
value={
minChannelAgeMinutes > 0
? humanizeDuration(minChannelAgeMinutes * 60 * 1000, { round: true })
: __('No limit')
}
inputButton={
<Button
button="secondary"
icon={ICONS.EDIT}
title={__('Change')}
onClick={() => {
doOpenModal(MODALS.MIN_CHANNEL_AGE, {
onConfirm: (limitInMinutes: number, closeModal: () => void) => {
setMinChannelAgeMinutes(limitInMinutes);
updateCreatorSettings(activeChannelClaim, {
time_since_first_comment: limitInMinutes,
});
closeModal();
},
});
}}
/>
}
/>
</div>
</SettingsRow>
<SettingsRow <SettingsRow
title={ title={
<I18nMessage tokens={{ lbc: <LbcSymbol /> }}>Minimum %lbc% tip amount for comments</I18nMessage> <I18nMessage tokens={{ lbc: <LbcSymbol /> }}>Minimum %lbc% tip amount for comments</I18nMessage>
@ -386,6 +432,7 @@ export default function SettingsCreatorPage(props: Props) {
// prettier-ignore // prettier-ignore
const HELP = { const HELP = {
SLOW_MODE: 'Minimum time gap in seconds between comments (affects livestream chat as well).', SLOW_MODE: 'Minimum time gap in seconds between comments (affects livestream chat as well).',
CHANNEL_AGE: 'Channels with a lifespan lower than the specified duration will not be able to comment on your content.',
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_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: '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.)', MIN_SUPER_OFF: '(This settings is not applicable if all comments require a tip.)',

View file

@ -340,7 +340,8 @@
//padding-left: var(--spacing-m); //padding-left: var(--spacing-m);
.button, .button,
.checkbox { .checkbox,
.section__actions {
&:only-child { &:only-child {
float: right; float: right;
} }

View file

@ -64,6 +64,10 @@ const ERR_MAP: CommentronErrorMap = {
commentron: 'duplicate comment!', commentron: 'duplicate comment!',
replacement: 'Please do not spam.', replacement: 'Please do not spam.',
}, },
CHANNEL_AGE: {
commentron: 'this creator has set minimum account age requirements that are not currently met',
replacement: "Your channel does not meet the creator's minimum channel-age limit.",
},
}; };
export function resolveCommentronError(commentronMsg: string) { export function resolveCommentronError(commentronMsg: string) {