Files
5chan/src/components/edit-menu/edit-menu.tsx
T

356 lines
14 KiB
TypeScript
Raw Normal View History

import { useEffect, useState, useMemo, useCallback } from 'react';
2024-06-04 16:06:28 +02:00
import { Trans, useTranslation } from 'react-i18next';
2024-08-28 14:23:16 +02:00
import { autoUpdate, FloatingFocusManager, offset, shift, useClick, useDismiss, useFloating, useId, useInteractions, useRole } from '@floating-ui/react';
import {
Comment,
PublishCommentEditOptions,
useAccount,
usePublishCommentEdit,
usePublishCommentModeration,
PublishCommentModerationOptions,
} from '@plebbit/plebbit-react-hooks';
import styles from './edit-menu.module.css';
import { alertChallengeVerificationFailed } from '../../lib/utils/challenge-utils';
import useChallengesStore from '../../stores/use-challenges-store';
2024-06-03 13:51:41 +02:00
import _ from 'lodash';
import useIsMobile from '../../hooks/use-is-mobile';
import useAnonMode from '../../hooks/use-anon-mode';
2024-09-04 16:15:47 +02:00
import useAuthorPrivileges from '../../hooks/use-author-privileges';
import useAnonModeStore from '../../stores/use-anon-mode-store';
2024-06-03 13:51:41 +02:00
const { addChallenge } = useChallengesStore.getState();
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)));
};
2024-09-04 16:15:47 +02:00
const EditMenu = ({ post }: { post: Comment }) => {
2024-06-03 13:51:41 +02:00
const { t } = useTranslation();
2024-06-04 16:13:20 +02:00
const isMobile = useIsMobile();
const { author, cid, content, deleted, locked, parentCid, pinned, postCid, reason, removed, spoiler, subplebbitAddress } = post || {};
const isReply = parentCid;
const [isEditMenuOpen, setIsEditMenuOpen] = useState(false);
const [isContentEditorOpen, setIsContentEditorOpen] = useState(false);
const account = useAccount();
2024-09-04 16:15:47 +02:00
const { getNewSigner, getExistingSigner } = useAnonMode(post?.postCid);
const { getThreadSigner } = useAnonModeStore();
const [signer, setSigner] = useState<any>(account?.signer);
2024-09-04 16:15:47 +02:00
const { isCommentAuthorMod, isAccountMod, isAccountCommentAuthor } = useAuthorPrivileges({
commentAuthorAddress: author?.address,
subplebbitAddress,
postCid,
});
const checkSigner = useCallback(async () => {
2024-09-04 16:15:47 +02:00
if (isAccountCommentAuthor) {
if (author?.address !== account?.author?.address) {
// Check for existing thread signer first
const threadSigner = getThreadSigner(postCid);
if (threadSigner && threadSigner?.address === author?.address) {
2024-09-04 16:15:47 +02:00
setSigner(threadSigner);
return;
}
// If no thread signer, check for existing address signer
const existingSigner = getExistingSigner(author?.address);
if (existingSigner) {
setSigner(existingSigner);
return;
}
// If no existing signer, create a new one
const newSigner = await getNewSigner();
if (newSigner) {
setSigner(newSigner);
}
} else {
setSigner(account?.signer);
}
} else {
setSigner(null);
}
2024-09-04 16:15:47 +02:00
}, [isAccountCommentAuthor, author?.address, postCid, account?.author?.address, account?.signer, getThreadSigner, getExistingSigner, getNewSigner]);
useEffect(() => {
checkSigner();
}, [checkSigner]);
const defaultPublishEditOptions = useMemo(() => {
return {
commentCid: cid,
subplebbitAddress,
// Author edit properties
content: isAccountCommentAuthor ? content : undefined,
deleted: isAccountCommentAuthor ? deleted ?? false : undefined,
spoiler: isAccountCommentAuthor ? spoiler ?? false : undefined,
// Mod edit properties
commentModeration: isAccountMod
? {
locked: locked ?? false,
pinned: pinned ?? false,
removed: removed ?? false,
spoiler: spoiler ?? false,
reason,
banExpiresAt: post?.commentModeration?.banExpiresAt,
}
: undefined,
onChallenge: (...args: any) => addChallenge([...args, post]),
onChallengeVerification: alertChallengeVerificationFailed,
onError: (error: Error) => {
console.warn(error);
alert('Comment edit failed. ' + error.message);
},
};
}, [isAccountMod, isAccountCommentAuthor, cid, content, deleted, locked, pinned, reason, removed, spoiler, subplebbitAddress, post]);
const [publishCommentEditOptions, setPublishCommentEditOptions] = useState<PublishCommentEditOptions>(defaultPublishEditOptions);
2024-09-04 16:15:47 +02:00
const authorEditOptions = useMemo<PublishCommentEditOptions>(
() => ({
commentCid: cid,
subplebbitAddress,
signer,
author: signer?.address === author?.address ? { address: signer?.address, displayName: post?.author?.displayName } : account?.author,
2024-09-04 16:15:47 +02:00
content: publishCommentEditOptions.content,
deleted: publishCommentEditOptions.deleted,
reason: publishCommentEditOptions.reason,
spoiler: publishCommentEditOptions.spoiler,
onChallenge: (...args: any) => addChallenge([...args, post]),
onChallengeVerification: alertChallengeVerificationFailed,
onError: (error: Error) => {
console.warn(error);
alert('Comment edit failed. ' + error.message);
},
}),
[publishCommentEditOptions, cid, subplebbitAddress, signer, post, account?.author, author?.address],
);
const modEditOptions = useMemo<PublishCommentModerationOptions>(
2024-09-04 16:15:47 +02:00
() => ({
commentCid: cid,
subplebbitAddress,
commentModeration: {
locked: parentCid === undefined ? publishCommentEditOptions.commentModeration?.locked : undefined,
pinned: publishCommentEditOptions.commentModeration?.pinned,
removed: publishCommentEditOptions.commentModeration?.removed,
spoiler: publishCommentEditOptions.commentModeration?.spoiler,
reason: publishCommentEditOptions.reason,
banExpiresAt: publishCommentEditOptions.commentModeration?.banExpiresAt,
},
2024-09-04 16:15:47 +02:00
author: account?.author,
onChallenge: (...args: any) => addChallenge([...args, post]),
onChallengeVerification: alertChallengeVerificationFailed,
onError: (error: Error) => {
console.warn(error);
alert('Comment moderation failed. ' + error.message);
2024-09-04 16:15:47 +02:00
},
}),
[publishCommentEditOptions, cid, subplebbitAddress, post, account?.author, parentCid],
2024-09-04 16:15:47 +02:00
);
const { publishCommentEdit: publishAuthorEdit } = usePublishCommentEdit(authorEditOptions);
const { publishCommentModeration } = usePublishCommentModeration(modEditOptions);
2024-06-03 13:51:41 +02:00
useEffect(() => {
setPublishCommentEditOptions(defaultPublishEditOptions);
}, [defaultPublishEditOptions]);
const [banDuration, setBanDuration] = useState(() =>
publishCommentEditOptions.commentModeration?.banExpiresAt ? timestampToDays(publishCommentEditOptions.commentModeration.banExpiresAt) : 1,
);
2024-06-03 13:51:41 +02:00
const onCheckbox = (e: React.ChangeEvent<HTMLInputElement>) => {
const { id, checked } = e.target;
setPublishCommentEditOptions((state) => {
const newState = { ...state };
if (isAccountMod) {
newState.commentModeration = {
...newState.commentModeration,
[id === 'banUser' ? 'banExpiresAt' : id]: id === 'banUser' ? (checked ? daysToTimestampInSeconds(banDuration) : undefined) : checked,
};
}
if (isAccountCommentAuthor) {
newState[id] = checked;
}
return newState;
});
2024-06-03 13:51:41 +02:00
};
const onBanDurationChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const days = parseInt(e.target.value, 10) || 1;
setBanDuration(days);
setPublishCommentEditOptions((state) => ({
...state,
commentAuthor: { ...state.commentAuthor, banExpiresAt: daysToTimestampInSeconds(days) },
}));
};
const { refs, floatingStyles, context } = useFloating({
placement: 'bottom-start',
open: isEditMenuOpen,
onOpenChange: setIsEditMenuOpen,
middleware: [offset(2), shift()],
2024-06-03 13:51:41 +02:00
whileElementsMounted: autoUpdate,
});
const click = useClick(context);
const dismiss = useDismiss(context);
const role = useRole(context);
const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss, role]);
const headingId = useId();
const _publishCommentEdit = async () => {
try {
2024-09-04 16:15:47 +02:00
if (isAccountCommentAuthor && isAccountMod) {
await publishAuthorEdit();
await publishCommentModeration();
2024-09-04 16:15:47 +02:00
} else if (isAccountCommentAuthor) {
await publishAuthorEdit();
} else if (isAccountMod) {
await publishCommentModeration();
2024-09-04 16:15:47 +02:00
}
} catch (error) {
if (error instanceof Error) {
console.warn(error);
alert(error.message);
}
}
setIsEditMenuOpen(false);
2024-06-03 13:51:41 +02:00
};
return (
<>
<span className={`${styles.checkbox} ${isReply && styles.replyCheckbox}`} ref={refs.setReference} {...(cid && getReferenceProps())}>
2024-06-30 15:38:34 +02:00
<input type='checkbox' onChange={() => setIsEditMenuOpen(cid && (isAccountCommentAuthor || isAccountMod) ? !isEditMenuOpen : false)} checked={isEditMenuOpen} />
2024-06-03 13:51:41 +02:00
</span>
{isEditMenuOpen && (isAccountCommentAuthor || isAccountMod) && (
2024-06-03 13:51:41 +02:00
<FloatingFocusManager context={context} modal={false}>
<div className={styles.modal} ref={refs.setFloating} style={floatingStyles} aria-labelledby={headingId} {...getFloatingProps()}>
<div className={styles.editMenu}>
{isAccountCommentAuthor && (
<>
<div className={styles.menuItem}>
<label>
[
2024-06-12 10:46:14 +02:00
<input onChange={onCheckbox} checked={publishCommentEditOptions.deleted ?? false} type='checkbox' id='deleted' />
2024-06-21 22:12:59 +02:00
{_.capitalize(t('delete'))}?]
</label>
</div>
<div className={styles.menuItem}>
<label>
[
<input type='checkbox' onChange={() => setIsContentEditorOpen(!isContentEditorOpen)} checked={isContentEditorOpen} />
2024-06-21 22:12:59 +02:00
{_.capitalize(t('edit'))}?]
</label>
</div>
{isContentEditorOpen && (
<div>
<textarea
className={styles.editTextarea}
2024-09-04 16:15:47 +02:00
value={publishCommentEditOptions.content || ''}
onChange={(e) => {
const newContent = e.target.value;
setPublishCommentEditOptions((state) => ({ ...state, content: newContent }));
}}
/>
</div>
)}
</>
2024-06-03 13:51:41 +02:00
)}
{isAccountMod && (
<>
<div className={styles.menuItem}>
<label>
[
<input onChange={onCheckbox} checked={publishCommentEditOptions.commentModeration?.removed ?? false} type='checkbox' id='removed' />
2024-06-11 15:21:54 +02:00
{_.capitalize(t('remove'))}?]
</label>
</div>
{!isReply && (
<div className={styles.menuItem}>
[
<label>
<input onChange={onCheckbox} checked={publishCommentEditOptions.commentModeration?.locked ?? false} type='checkbox' id='locked' />
{_.capitalize(t('close_thread'))}?
</label>
]
</div>
)}
<div className={styles.menuItem}>
[
<label>
<input onChange={onCheckbox} checked={publishCommentEditOptions.commentModeration?.spoiler ?? false} type='checkbox' id='spoiler' />
{_.capitalize(t('spoiler'))}?
</label>
]
</div>
<div className={styles.menuItem}>
[
<label>
<input onChange={onCheckbox} checked={publishCommentEditOptions.commentModeration?.pinned ?? false} type='checkbox' id='pinned' />
{_.capitalize(t('sticky'))}?
</label>
]
</div>
2024-09-04 16:15:47 +02:00
{!isCommentAuthorMod && isAccountMod && !isAccountCommentAuthor && (
<div className={styles.menuItem}>
[
<label>
<input onChange={onCheckbox} checked={publishCommentEditOptions.commentModeration?.banExpiresAt !== undefined} type='checkbox' id='banUser' />
<Trans
i18nKey='ban_user_for'
shouldUnescape={true}
components={{
2024-09-04 16:15:47 +02:00
1: <input className={styles.banInput} onChange={onBanDurationChange} type='number' min={1} max={100} value={banDuration || ''} />,
}}
/>
?
</label>
]
</div>
)}
</>
2024-06-03 13:51:41 +02:00
)}
<div className={`${styles.menuItem} ${styles.menuReason}`}>
{_.capitalize(t('reason'))}? ({t('optional')})
2024-09-04 16:15:47 +02:00
<input
type='text'
value={publishCommentEditOptions.reason || ''}
onChange={(e) => {
const newReason = e.target.value;
setPublishCommentEditOptions((state) => ({ ...state, reason: newReason }));
}}
size={14}
/>
2024-06-03 13:51:41 +02:00
</div>
<div className={styles.bottom}>
2024-06-04 16:13:20 +02:00
<button className={isMobile ? 'button' : ''} onClick={_publishCommentEdit}>
{t('save')}
</button>
2024-06-03 13:51:41 +02:00
</div>
</div>
</div>
</FloatingFocusManager>
)}
</>
);
};
export default EditMenu;