mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
Show a content editor in the mod menu when the signed-in mod is the post author, and publish author edits alongside moderation actions on save.
527 lines
20 KiB
TypeScript
527 lines
20 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { Trans, useTranslation } from 'react-i18next';
|
|
import { autoUpdate, FloatingFocusManager, FloatingPortal, offset, shift, useDismiss, useFloating, useId, useInteractions, useRole } from '@floating-ui/react';
|
|
import {
|
|
Comment,
|
|
PublishCommentEditOptions,
|
|
useAccount,
|
|
usePublishCommentEdit,
|
|
usePublishCommentModeration,
|
|
PublishCommentModerationOptions,
|
|
} from '@bitsocial/bitsocial-react-hooks';
|
|
import styles from './edit-menu.module.css';
|
|
import { alertChallengeVerificationFailed } from '../../lib/utils/challenge-utils';
|
|
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
|
|
import useChallengesStore from '../../stores/use-challenges-store';
|
|
import capitalize from 'lodash/capitalize';
|
|
import useIsMobile from '../../hooks/use-is-mobile';
|
|
import useAuthorPrivileges from '../../hooks/use-author-privileges';
|
|
import { useBoardPseudonymityMode } from '../../hooks/use-board-pseudonymity-mode';
|
|
import { getCommentCommunityAddress, withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils';
|
|
|
|
const daysToTimestampInSeconds = (days: number) => {
|
|
const now = new Date();
|
|
now.setDate(now.getDate() + days);
|
|
return Math.floor(now.getTime() / 1000);
|
|
};
|
|
|
|
const timestampToDays = (timestamp: number) => {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
return Math.max(1, Math.floor((timestamp - now) / (24 * 60 * 60)));
|
|
};
|
|
|
|
const EditMenu = ({ post }: { post: Comment }) => {
|
|
const { t } = useTranslation();
|
|
const isMobile = useIsMobile();
|
|
const resolvedPost = withResolvedCommentCommunityAddress(post);
|
|
const { author, cid, content, deleted, locked, parentCid, pinned, postCid, reason, removed, spoiler } = resolvedPost || {};
|
|
const communityAddress = getCommentCommunityAddress(resolvedPost);
|
|
const archived = isCommentArchived(resolvedPost);
|
|
const authorDisplayName = resolvedPost?.author?.displayName;
|
|
const modBanExpiresAt = resolvedPost?.author?.community?.banExpiresAt ?? resolvedPost?.commentModeration?.author?.banExpiresAt;
|
|
const purged = resolvedPost?.commentModeration?.purged ?? false;
|
|
const [isEditMenuOpen, setIsEditMenuOpen] = useState(false);
|
|
|
|
const account = useAccount();
|
|
const { isCommentAuthorMod, isAccountMod, isAccountCommentAuthor } = useAuthorPrivileges({
|
|
commentAuthorAddress: author?.address,
|
|
communityAddress: communityAddress || '',
|
|
postCid,
|
|
});
|
|
const pseudonymityMode = useBoardPseudonymityMode(communityAddress);
|
|
const allowsPseudonymousDelete = pseudonymityMode !== undefined && pseudonymityMode !== 'none';
|
|
const canAttemptAuthorDelete = isAccountCommentAuthor || allowsPseudonymousDelete;
|
|
const canOpenEditMenu = isAccountMod || canAttemptAuthorDelete;
|
|
const canEditOwnModPost = isAccountMod && isAccountCommentAuthor;
|
|
const signer = isAccountCommentAuthor ? account?.signer : undefined;
|
|
const [isContentEditorOpen, setIsContentEditorOpen] = useState(false);
|
|
const latestPostRef = useRef(resolvedPost);
|
|
useEffect(() => {
|
|
latestPostRef.current = resolvedPost;
|
|
}, [resolvedPost]);
|
|
const onChallenge = useCallback((...args: any) => useChallengesStore.getState().addChallenge([...args, latestPostRef.current]), []);
|
|
|
|
const defaultPublishEditOptions = useMemo(() => {
|
|
return {
|
|
commentCid: cid,
|
|
communityAddress,
|
|
// Author edit properties
|
|
content: canEditOwnModPost ? content : undefined,
|
|
deleted: canAttemptAuthorDelete ? (deleted ?? false) : undefined,
|
|
// Mod edit properties
|
|
commentModeration: isAccountMod
|
|
? {
|
|
locked: locked ?? false,
|
|
archived: parentCid === undefined ? (archived ?? false) : undefined,
|
|
pinned: pinned ?? false,
|
|
removed: removed ?? false,
|
|
purged: purged ?? false,
|
|
reason: reason ?? '',
|
|
spoiler: spoiler ?? false,
|
|
author: modBanExpiresAt ? { banExpiresAt: modBanExpiresAt } : undefined,
|
|
}
|
|
: undefined,
|
|
onChallenge,
|
|
onChallengeVerification: alertChallengeVerificationFailed,
|
|
onError: (error: Error) => {
|
|
console.warn(error);
|
|
alert('Comment edit failed. ' + error.message);
|
|
},
|
|
};
|
|
}, [
|
|
isAccountMod,
|
|
canEditOwnModPost,
|
|
canAttemptAuthorDelete,
|
|
archived,
|
|
cid,
|
|
content,
|
|
deleted,
|
|
locked,
|
|
pinned,
|
|
reason,
|
|
removed,
|
|
parentCid,
|
|
purged,
|
|
spoiler,
|
|
communityAddress,
|
|
modBanExpiresAt,
|
|
onChallenge,
|
|
]);
|
|
|
|
const [publishCommentEditOptions, setPublishCommentEditOptions] = useState<PublishCommentEditOptions>(defaultPublishEditOptions);
|
|
|
|
const authorEditOptions = useMemo<PublishCommentEditOptions>(() => {
|
|
const options: PublishCommentEditOptions = {
|
|
commentCid: cid,
|
|
communityAddress,
|
|
content: canEditOwnModPost ? publishCommentEditOptions.content : undefined,
|
|
deleted: publishCommentEditOptions.deleted,
|
|
onChallenge,
|
|
onChallengeVerification: alertChallengeVerificationFailed,
|
|
onError: (error: Error) => {
|
|
console.warn(error);
|
|
alert('Comment edit failed. ' + error.message);
|
|
},
|
|
};
|
|
|
|
if (!isAccountCommentAuthor) {
|
|
return options;
|
|
}
|
|
|
|
return {
|
|
...options,
|
|
signer,
|
|
author: signer?.address === author?.address ? { address: signer?.address, displayName: authorDisplayName } : account?.author,
|
|
};
|
|
}, [
|
|
publishCommentEditOptions,
|
|
cid,
|
|
communityAddress,
|
|
canEditOwnModPost,
|
|
isAccountCommentAuthor,
|
|
signer,
|
|
account?.author,
|
|
author?.address,
|
|
authorDisplayName,
|
|
onChallenge,
|
|
]);
|
|
|
|
const modEditOptions = useMemo<PublishCommentModerationOptions>(
|
|
() => ({
|
|
commentCid: cid,
|
|
communityAddress,
|
|
commentModeration: {
|
|
locked: parentCid === undefined ? publishCommentEditOptions.commentModeration?.locked : undefined,
|
|
archived: parentCid === undefined ? publishCommentEditOptions.commentModeration?.archived : undefined,
|
|
pinned: publishCommentEditOptions.commentModeration?.pinned,
|
|
removed: publishCommentEditOptions.commentModeration?.removed,
|
|
purged: publishCommentEditOptions.commentModeration?.purged,
|
|
reason: publishCommentEditOptions.commentModeration?.reason,
|
|
spoiler: publishCommentEditOptions.commentModeration?.spoiler,
|
|
author: publishCommentEditOptions.commentModeration?.author,
|
|
},
|
|
author: account?.author,
|
|
onChallenge,
|
|
onChallengeVerification: alertChallengeVerificationFailed,
|
|
onError: (error: Error) => {
|
|
console.warn(error);
|
|
alert('Comment moderation failed. ' + error.message);
|
|
},
|
|
}),
|
|
[publishCommentEditOptions, cid, communityAddress, account?.author, parentCid, onChallenge],
|
|
);
|
|
|
|
const { publishCommentEdit: publishAuthorEdit } = usePublishCommentEdit(authorEditOptions);
|
|
const { publishCommentModeration } = usePublishCommentModeration(modEditOptions);
|
|
|
|
const [banDuration, setBanDuration] = useState(() =>
|
|
defaultPublishEditOptions.commentModeration?.author?.banExpiresAt ? timestampToDays(defaultPublishEditOptions.commentModeration.author.banExpiresAt) : 1,
|
|
);
|
|
|
|
const resetMenuState = () => {
|
|
setPublishCommentEditOptions(defaultPublishEditOptions);
|
|
setIsContentEditorOpen(false);
|
|
setBanDuration(
|
|
defaultPublishEditOptions.commentModeration?.author?.banExpiresAt ? timestampToDays(defaultPublishEditOptions.commentModeration.author.banExpiresAt) : 1,
|
|
);
|
|
};
|
|
|
|
const onCheckbox = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const { id, checked } = e.target;
|
|
|
|
setPublishCommentEditOptions((state) => {
|
|
const newState = { ...state };
|
|
|
|
if (isAccountMod) {
|
|
if (id === 'banUser') {
|
|
const banValue = checked ? daysToTimestampInSeconds(banDuration) : undefined;
|
|
newState.commentModeration = {
|
|
...newState.commentModeration,
|
|
author: banValue ? { banExpiresAt: banValue } : undefined,
|
|
};
|
|
} else {
|
|
newState.commentModeration = {
|
|
...newState.commentModeration,
|
|
[id]: checked,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (id === 'deleted' && canAttemptAuthorDelete) {
|
|
newState.deleted = checked;
|
|
}
|
|
|
|
return newState;
|
|
});
|
|
};
|
|
|
|
const onBanDurationChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const days = parseInt(e.target.value, 10) || 1;
|
|
setBanDuration(days);
|
|
setPublishCommentEditOptions((state) => {
|
|
// Only update ban expiration if ban is currently enabled (checkbox is checked)
|
|
const isBanEnabled = state.commentModeration?.author?.banExpiresAt !== undefined;
|
|
return {
|
|
...state,
|
|
commentModeration: {
|
|
...state.commentModeration,
|
|
author: isBanEnabled ? { banExpiresAt: daysToTimestampInSeconds(days) } : state.commentModeration?.author,
|
|
},
|
|
};
|
|
});
|
|
};
|
|
|
|
const onPurgeCheckbox = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const { checked } = e.target;
|
|
|
|
if (checked) {
|
|
const confirmed = window.confirm(t('purge_confirm'));
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
setPublishCommentEditOptions((state) => {
|
|
const newState = { ...state };
|
|
if (isAccountMod) {
|
|
newState.commentModeration = {
|
|
...newState.commentModeration,
|
|
purged: checked,
|
|
};
|
|
}
|
|
return newState;
|
|
});
|
|
};
|
|
|
|
const { refs, floatingStyles, context } = useFloating({
|
|
placement: 'bottom-start',
|
|
open: isEditMenuOpen,
|
|
onOpenChange: setIsEditMenuOpen,
|
|
middleware: [offset(2), shift()],
|
|
whileElementsMounted: autoUpdate,
|
|
});
|
|
|
|
const dismiss = useDismiss(context);
|
|
const role = useRole(context);
|
|
|
|
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, role]);
|
|
|
|
const headingId = useId();
|
|
const hasDeleteStateChanged = (deleted ?? false) !== (publishCommentEditOptions.deleted ?? false);
|
|
const hasContentStateChanged = canEditOwnModPost && (content ?? '') !== (publishCommentEditOptions.content ?? '');
|
|
|
|
const _publishCommentEdit = async () => {
|
|
const shouldPublishAuthorEdit = (canAttemptAuthorDelete && hasDeleteStateChanged) || hasContentStateChanged;
|
|
|
|
try {
|
|
if (shouldPublishAuthorEdit && isAccountMod) {
|
|
await publishAuthorEdit();
|
|
await publishCommentModeration();
|
|
} else if (shouldPublishAuthorEdit) {
|
|
await publishAuthorEdit();
|
|
} else if (isAccountMod) {
|
|
await publishCommentModeration();
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
console.warn(error);
|
|
alert(error.message);
|
|
}
|
|
}
|
|
setIsEditMenuOpen(false);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<span className={`${styles.checkbox} ${parentCid && styles.replyCheckbox}`} ref={refs.setReference} {...(cid && getReferenceProps())}>
|
|
<input
|
|
type='checkbox'
|
|
aria-label={t('edit')}
|
|
onChange={() => {
|
|
if (cid && canOpenEditMenu) {
|
|
if (!isEditMenuOpen) {
|
|
resetMenuState();
|
|
setIsEditMenuOpen(true);
|
|
} else {
|
|
setIsEditMenuOpen(false);
|
|
}
|
|
} else {
|
|
setIsEditMenuOpen(false);
|
|
alert(parentCid ? t('cannot_edit_reply') : t('cannot_edit_thread'));
|
|
}
|
|
}}
|
|
checked={isEditMenuOpen}
|
|
/>
|
|
</span>
|
|
{isEditMenuOpen && canOpenEditMenu && (
|
|
<FloatingPortal>
|
|
<FloatingFocusManager context={context} modal={false}>
|
|
<div className={styles.modal} ref={refs.setFloating} style={floatingStyles} aria-labelledby={headingId} {...getFloatingProps()}>
|
|
<div className={styles.editMenu}>
|
|
{canAttemptAuthorDelete && (
|
|
<>
|
|
<div className={styles.menuItem}>
|
|
<label>
|
|
[
|
|
<input
|
|
aria-label={capitalize(t('delete'))}
|
|
onChange={onCheckbox}
|
|
checked={publishCommentEditOptions.deleted ?? false}
|
|
type='checkbox'
|
|
id='deleted'
|
|
/>
|
|
{capitalize(t('delete'))}?]
|
|
</label>
|
|
</div>
|
|
</>
|
|
)}
|
|
{isAccountMod && (
|
|
<>
|
|
{canEditOwnModPost && (
|
|
<>
|
|
<div className={styles.menuItem}>
|
|
<label>
|
|
[
|
|
<input
|
|
aria-label={capitalize(t('edit'))}
|
|
type='checkbox'
|
|
onChange={() => setIsContentEditorOpen((isOpen) => !isOpen)}
|
|
checked={isContentEditorOpen}
|
|
/>
|
|
{capitalize(t('edit'))}?]
|
|
</label>
|
|
</div>
|
|
{isContentEditorOpen && (
|
|
<div>
|
|
<textarea
|
|
className={styles.editTextarea}
|
|
aria-label={capitalize(t('edit'))}
|
|
value={publishCommentEditOptions.content || ''}
|
|
onChange={(e) => {
|
|
const newContent = e.target.value;
|
|
setPublishCommentEditOptions((state) => ({ ...state, content: newContent }));
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
<div className={styles.menuItem}>
|
|
<label>
|
|
[
|
|
<input
|
|
aria-label={capitalize(t('remove'))}
|
|
onChange={onCheckbox}
|
|
checked={publishCommentEditOptions.commentModeration?.removed ?? false}
|
|
type='checkbox'
|
|
id='removed'
|
|
/>
|
|
{capitalize(t('remove'))}?]
|
|
</label>{' '}
|
|
<span className={styles.purgeItem}>
|
|
<label>
|
|
[
|
|
<input
|
|
aria-label={capitalize(t('purge'))}
|
|
onChange={onPurgeCheckbox}
|
|
checked={publishCommentEditOptions.commentModeration?.purged ?? false}
|
|
type='checkbox'
|
|
id='purged'
|
|
/>
|
|
{capitalize(t('purge'))}?]
|
|
</label>
|
|
</span>
|
|
</div>
|
|
{!parentCid && (
|
|
<div className={styles.menuItem}>
|
|
[
|
|
<label>
|
|
<input
|
|
aria-label={capitalize(t('close_thread'))}
|
|
onChange={onCheckbox}
|
|
checked={publishCommentEditOptions.commentModeration?.locked ?? false}
|
|
type='checkbox'
|
|
id='locked'
|
|
/>
|
|
{capitalize(t('close_thread'))}?
|
|
</label>
|
|
]
|
|
</div>
|
|
)}
|
|
<div className={styles.menuItem}>
|
|
[
|
|
<label>
|
|
<input
|
|
aria-label={capitalize(t('spoiler'))}
|
|
onChange={onCheckbox}
|
|
checked={publishCommentEditOptions.commentModeration?.spoiler ?? false}
|
|
type='checkbox'
|
|
id='spoiler'
|
|
/>
|
|
{capitalize(t('spoiler'))}?
|
|
</label>
|
|
]
|
|
</div>
|
|
{!parentCid && (
|
|
<div className={styles.menuItem}>
|
|
[
|
|
<label>
|
|
<input
|
|
aria-label={capitalize(t('archived'))}
|
|
onChange={onCheckbox}
|
|
checked={publishCommentEditOptions.commentModeration?.archived ?? false}
|
|
type='checkbox'
|
|
id='archived'
|
|
/>
|
|
{capitalize(t('archived'))}?
|
|
</label>
|
|
]
|
|
</div>
|
|
)}
|
|
<div className={styles.menuItem}>
|
|
[
|
|
<label>
|
|
<input
|
|
aria-label={capitalize(t('sticky'))}
|
|
onChange={onCheckbox}
|
|
checked={publishCommentEditOptions.commentModeration?.pinned ?? false}
|
|
type='checkbox'
|
|
id='pinned'
|
|
/>
|
|
{capitalize(t('sticky'))}?
|
|
</label>
|
|
]
|
|
</div>
|
|
{!isCommentAuthorMod && isAccountMod && !isAccountCommentAuthor && (
|
|
<div className={styles.menuItem}>
|
|
[
|
|
<label>
|
|
<input
|
|
onChange={onCheckbox}
|
|
aria-label={capitalize(t('ban'))}
|
|
checked={publishCommentEditOptions.commentModeration?.author?.banExpiresAt !== undefined}
|
|
type='checkbox'
|
|
id='banUser'
|
|
/>
|
|
<Trans
|
|
i18nKey='ban_user_for'
|
|
shouldUnescape={true}
|
|
components={{
|
|
1: (
|
|
<input
|
|
key='ban-duration-input'
|
|
className={styles.banInput}
|
|
aria-label={t('ban_duration_days')}
|
|
onChange={onBanDurationChange}
|
|
type='number'
|
|
min={1}
|
|
max={100}
|
|
value={banDuration || ''}
|
|
/>
|
|
),
|
|
}}
|
|
/>
|
|
?
|
|
</label>
|
|
]
|
|
</div>
|
|
)}
|
|
<div className={`${styles.menuItem} ${styles.menuReason}`}>
|
|
<label>
|
|
{capitalize(t('reason'))}? ({t('optional')})
|
|
<input
|
|
type='text'
|
|
aria-label={capitalize(t('reason'))}
|
|
value={publishCommentEditOptions.commentModeration?.reason || ''}
|
|
onChange={(e) => {
|
|
const newReason = e.target.value;
|
|
setPublishCommentEditOptions((state) => ({
|
|
...state,
|
|
commentModeration: {
|
|
...state.commentModeration,
|
|
reason: newReason,
|
|
},
|
|
}));
|
|
}}
|
|
size={14}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</>
|
|
)}
|
|
<div className={styles.bottom}>
|
|
<button type='button' className={isMobile ? 'button' : ''} onClick={_publishCommentEdit}>
|
|
{t('save')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</FloatingFocusManager>
|
|
</FloatingPortal>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default EditMenu;
|