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(defaultPublishEditOptions); const authorEditOptions = useMemo(() => { 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( () => ({ 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) => { 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) => { 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) => { 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 ( <> { 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} /> {isEditMenuOpen && canOpenEditMenu && (
{canAttemptAuthorDelete && ( <>
)} {isAccountMod && ( <> {canEditOwnModPost && ( <>
{isContentEditorOpen && (