diff --git a/src/components/edit-menu/__tests__/edit-menu.test.tsx b/src/components/edit-menu/__tests__/edit-menu.test.tsx index 389dc423..da032301 100644 --- a/src/components/edit-menu/__tests__/edit-menu.test.tsx +++ b/src/components/edit-menu/__tests__/edit-menu.test.tsx @@ -23,6 +23,7 @@ const testState = vi.hoisted(() => ({ authorPrivilegesOptions: undefined as Record | undefined, isMobile: false, modOptions: undefined as Record | undefined, + pseudonymityMode: undefined as string | undefined, privileges: { isAccountCommentAuthor: false, isAccountMod: false, @@ -95,6 +96,10 @@ vi.mock('../../../hooks/use-is-mobile', () => ({ default: () => testState.isMobile, })); +vi.mock('../../../hooks/use-board-pseudonymity-mode', () => ({ + useBoardPseudonymityMode: () => testState.pseudonymityMode, +})); + vi.mock('../../../stores/use-challenges-store', () => { const hook = () => ({ challenges: [] }); return { @@ -187,6 +192,7 @@ describe('EditMenu', () => { testState.authorPrivilegesOptions = undefined; testState.isMobile = false; testState.modOptions = undefined; + testState.pseudonymityMode = undefined; testState.privileges = { isAccountCommentAuthor: false, isAccountMod: false, @@ -266,6 +272,58 @@ describe('EditMenu', () => { expect(testState.authorPrivilegesOptions).not.toHaveProperty('subplebbitAddress'); }); + it('allows pseudonymous boards to attempt author-side deletion without a local author address match', async () => { + testState.pseudonymityMode = 'per-post'; + testState.privileges = { + isAccountCommentAuthor: false, + isAccountMod: false, + isCommentAuthorMod: false, + }; + + await renderMenu(basePost); + await openMenu(); + + expect(alertSpy).not.toHaveBeenCalled(); + expect(getCheckbox('deleted')).not.toBeNull(); + expect(getLabelCheckbox('Edit?')).toBeNull(); + + const saveButton = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'save'); + expect(saveButton).not.toBeNull(); + expect((saveButton as HTMLButtonElement).disabled).toBe(true); + + await click(getCheckbox('deleted')); + expect((saveButton as HTMLButtonElement).disabled).toBe(false); + + await clickButton('save'); + + expect(testState.publishAuthorEditMock).toHaveBeenCalledOnce(); + expect(testState.publishCommentModerationMock).not.toHaveBeenCalled(); + expect(testState.authorOptions).toMatchObject({ + commentCid: 'comment-1', + communityAddress: 'music-posting.eth', + deleted: true, + }); + expect(testState.authorOptions?.content).toBeUndefined(); + expect(testState.authorOptions?.spoiler).toBeUndefined(); + expect(testState.authorOptions).not.toHaveProperty('author'); + expect(testState.authorOptions).not.toHaveProperty('signer'); + }); + + it("does not allow delete-only access when pseudonymity mode is 'none'", async () => { + testState.pseudonymityMode = 'none'; + testState.privileges = { + isAccountCommentAuthor: false, + isAccountMod: false, + isCommentAuthorMod: false, + }; + + await renderMenu(basePost); + await openMenu(); + + expect(alertSpy).toHaveBeenCalledWith('cannot_edit_thread'); + expect(testState.publishAuthorEditMock).not.toHaveBeenCalled(); + }); + it('lets moderators change moderation flags, ban duration, and save them', async () => { testState.privileges = { isAccountCommentAuthor: false, diff --git a/src/components/edit-menu/edit-menu.tsx b/src/components/edit-menu/edit-menu.tsx index 6368bfd4..1b321fe7 100644 --- a/src/components/edit-menu/edit-menu.tsx +++ b/src/components/edit-menu/edit-menu.tsx @@ -16,6 +16,7 @@ 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 { addChallenge } = useChallengesStore.getState(); @@ -50,7 +51,12 @@ const EditMenu = ({ post }: { post: Comment }) => { communityAddress: communityAddress || '', postCid, }); - const signer = isAccountCommentAuthor ? account?.signer : null; + const pseudonymityMode = useBoardPseudonymityMode(communityAddress); + const allowsPseudonymousDelete = pseudonymityMode !== undefined && pseudonymityMode !== 'none'; + const canAttemptAuthorDelete = isAccountCommentAuthor || allowsPseudonymousDelete; + const canOpenEditMenu = isAccountMod || canAttemptAuthorDelete; + const requiresDeleteSelection = canAttemptAuthorDelete && !isAccountCommentAuthor && !isAccountMod; + const signer = isAccountCommentAuthor ? account?.signer : undefined; const latestPostRef = useRef(resolvedPost); useEffect(() => { latestPostRef.current = resolvedPost; @@ -63,7 +69,7 @@ const EditMenu = ({ post }: { post: Comment }) => { communityAddress, // Author edit properties content: isAccountCommentAuthor ? content : undefined, - deleted: isAccountCommentAuthor ? (deleted ?? false) : undefined, + deleted: canAttemptAuthorDelete ? (deleted ?? false) : undefined, spoiler: isAccountCommentAuthor ? (spoiler ?? false) : undefined, // Mod edit properties commentModeration: isAccountMod @@ -88,6 +94,7 @@ const EditMenu = ({ post }: { post: Comment }) => { }, [ isAccountMod, isAccountCommentAuthor, + canAttemptAuthorDelete, archived, cid, content, @@ -105,12 +112,10 @@ const EditMenu = ({ post }: { post: Comment }) => { const [publishCommentEditOptions, setPublishCommentEditOptions] = useState(defaultPublishEditOptions); - const authorEditOptions = useMemo( - () => ({ + const authorEditOptions = useMemo(() => { + const options: PublishCommentEditOptions = { commentCid: cid, communityAddress, - signer, - author: signer?.address === author?.address ? { address: signer?.address, displayName: authorDisplayName } : account?.author, content: publishCommentEditOptions.content, deleted: publishCommentEditOptions.deleted, reason: publishCommentEditOptions.reason, @@ -121,9 +126,18 @@ const EditMenu = ({ post }: { post: Comment }) => { console.warn(error); alert('Comment edit failed. ' + error.message); }, - }), - [publishCommentEditOptions, cid, communityAddress, signer, account?.author, author?.address, authorDisplayName, onChallenge], - ); + }; + + if (!isAccountCommentAuthor) { + return options; + } + + return { + ...options, + signer, + author: signer?.address === author?.address ? { address: signer?.address, displayName: authorDisplayName } : account?.author, + }; + }, [publishCommentEditOptions, cid, communityAddress, isAccountCommentAuthor, signer, account?.author, author?.address, authorDisplayName, onChallenge]); const modEditOptions = useMemo( () => ({ @@ -186,7 +200,9 @@ const EditMenu = ({ post }: { post: Comment }) => { } } - if (isAccountCommentAuthor) { + if (id === 'deleted' && canAttemptAuthorDelete) { + newState.deleted = checked; + } else if (isAccountCommentAuthor) { newState[id] = checked; } @@ -247,13 +263,20 @@ const EditMenu = ({ post }: { post: Comment }) => { const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss, role]); const headingId = useId(); + const canSave = !requiresDeleteSelection || publishCommentEditOptions.deleted === true; const _publishCommentEdit = async () => { + if (!canSave) { + return; + } + + const shouldPublishAuthorEdit = isAccountCommentAuthor || (canAttemptAuthorDelete && publishCommentEditOptions.deleted === true); + try { - if (isAccountCommentAuthor && isAccountMod) { + if (shouldPublishAuthorEdit && isAccountMod) { await publishAuthorEdit(); await publishCommentModeration(); - } else if (isAccountCommentAuthor) { + } else if (shouldPublishAuthorEdit) { await publishAuthorEdit(); } else if (isAccountMod) { await publishCommentModeration(); @@ -273,7 +296,7 @@ const EditMenu = ({ post }: { post: Comment }) => { { - if (cid && (isAccountCommentAuthor || isAccountMod)) { + if (cid && canOpenEditMenu) { if (!isEditMenuOpen) { resetMenuState(); setIsEditMenuOpen(true); @@ -288,12 +311,12 @@ const EditMenu = ({ post }: { post: Comment }) => { checked={isEditMenuOpen} /> - {isEditMenuOpen && (isAccountCommentAuthor || isAccountMod) && ( + {isEditMenuOpen && canOpenEditMenu && (
- {isAccountCommentAuthor && ( + {canAttemptAuthorDelete && ( <>
+ + )} + {isAccountCommentAuthor && ( + <>
-
diff --git a/src/components/post-mobile/post-menu-mobile/__tests__/post-menu-mobile.test.tsx b/src/components/post-mobile/post-menu-mobile/__tests__/post-menu-mobile.test.tsx index e147ee2d..0d488097 100644 --- a/src/components/post-mobile/post-menu-mobile/__tests__/post-menu-mobile.test.tsx +++ b/src/components/post-mobile/post-menu-mobile/__tests__/post-menu-mobile.test.tsx @@ -14,6 +14,7 @@ const testState = vi.hoisted(() => ({ hidden: false, hideMock: vi.fn(), mediaInfo: undefined as { thumbnail?: string; type?: string; url?: string } | undefined, + pseudonymityMode: undefined as string | undefined, privileges: { isAccountCommentAuthor: false, isAccountMod: false, @@ -90,6 +91,10 @@ vi.mock('../../../../hooks/use-author-privileges', () => ({ default: () => testState.privileges, })); +vi.mock('../../../../hooks/use-board-pseudonymity-mode', () => ({ + useBoardPseudonymityMode: () => testState.pseudonymityMode, +})); + vi.mock('../../../../hooks/use-hide', () => ({ default: () => ({ hidden: testState.hidden, @@ -151,6 +156,7 @@ describe('PostMenuMobile', () => { vi.clearAllMocks(); testState.hidden = false; testState.mediaInfo = undefined; + testState.pseudonymityMode = undefined; testState.privileges = { isAccountCommentAuthor: false, isAccountMod: false, @@ -197,6 +203,22 @@ describe('PostMenuMobile', () => { expect(testState.hideMock).toHaveBeenCalled(); }); + it('shows edit controls on pseudonymous boards even without a local author-address match', async () => { + testState.pseudonymityMode = 'per-post'; + + await renderMenu('/mu'); + + expect(document.body.querySelector('[data-testid="edit-menu"]')?.textContent).toBe('cid-1'); + }); + + it("does not show edit controls when pseudonymity mode is 'none' and the user lacks privileges", async () => { + testState.pseudonymityMode = 'none'; + + await renderMenu('/mu'); + + expect(document.body.querySelector('[data-testid="edit-menu"]')).toBeNull(); + }); + it('omits thread hiding on the root thread route and suppresses the menu for deleted posts', async () => { await renderMenu('/mu/thread/cid-1'); await openMenu(); diff --git a/src/components/post-mobile/post-menu-mobile/post-menu-mobile.tsx b/src/components/post-mobile/post-menu-mobile/post-menu-mobile.tsx index 32c75b92..5d7f3fa7 100644 --- a/src/components/post-mobile/post-menu-mobile/post-menu-mobile.tsx +++ b/src/components/post-mobile/post-menu-mobile/post-menu-mobile.tsx @@ -10,6 +10,7 @@ import { copyToClipboard } from '../../../lib/utils/clipboard-utils'; import { getBoardPath } from '../../../lib/utils/route-utils'; import { useDirectories } from '../../../hooks/use-directories'; import useEditCommentPrivileges from '../../../hooks/use-author-privileges'; +import { useBoardPseudonymityMode } from '../../../hooks/use-board-pseudonymity-mode'; import useHide from '../../../hooks/use-hide'; import EditMenu from '../../edit-menu/edit-menu'; import { isBoardView, isPostPageView } from '../../../lib/utils/view-utils'; @@ -198,6 +199,8 @@ const PostMenuMobile = ({ postMenu, editMenuPost }: PostMenuMobileProps) => { commentAuthorAddress: authorAddress || '', subplebbitAddress: resolvedCommunityAddress || '', }); + const pseudonymityMode = useBoardPseudonymityMode(resolvedCommunityAddress); + const canAttemptAuthorDelete = pseudonymityMode !== undefined && pseudonymityMode !== 'none'; const commentMediaInfo = getCommentMediaInfo(link || '', thumbnailUrl || '', linkWidth || 0, linkHeight || 0); const { thumbnail, type, url } = commentMediaInfo || {}; const [isMenuOpen, setIsMenuOpen] = useState(false); @@ -261,7 +264,7 @@ const PostMenuMobile = ({ postMenu, editMenuPost }: PostMenuMobileProps) => { )} )} - {(isAccountMod || isAccountCommentAuthor) && cid && ( + {(isAccountMod || isAccountCommentAuthor || canAttemptAuthorDelete) && cid && (