fix(edit-menu): allow pseudonymous reply deletion (#1076)

* fix(edit-menu): allow pseudonymous reply deletion

* fix(edit-menu): ignore non-pseudonymous mode
This commit is contained in:
Tommaso Casaburi
2026-03-13 16:37:44 +08:00
committed by GitHub
parent aedee9fb83
commit 726522586c
4 changed files with 127 additions and 17 deletions
@@ -23,6 +23,7 @@ const testState = vi.hoisted(() => ({
authorPrivilegesOptions: undefined as Record<string, any> | undefined,
isMobile: false,
modOptions: undefined as Record<string, any> | 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,
+43 -16
View File
@@ -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<PublishCommentEditOptions>(defaultPublishEditOptions);
const authorEditOptions = useMemo<PublishCommentEditOptions>(
() => ({
const authorEditOptions = useMemo<PublishCommentEditOptions>(() => {
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<PublishCommentModerationOptions>(
() => ({
@@ -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 }) => {
<input
type='checkbox'
onChange={() => {
if (cid && (isAccountCommentAuthor || isAccountMod)) {
if (cid && canOpenEditMenu) {
if (!isEditMenuOpen) {
resetMenuState();
setIsEditMenuOpen(true);
@@ -288,12 +311,12 @@ const EditMenu = ({ post }: { post: Comment }) => {
checked={isEditMenuOpen}
/>
</span>
{isEditMenuOpen && (isAccountCommentAuthor || isAccountMod) && (
{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}>
{isAccountCommentAuthor && (
{canAttemptAuthorDelete && (
<>
<div className={styles.menuItem}>
<label>
@@ -302,6 +325,10 @@ const EditMenu = ({ post }: { post: Comment }) => {
{capitalize(t('delete'))}?]
</label>
</div>
</>
)}
{isAccountCommentAuthor && (
<>
<div className={styles.menuItem}>
<label>
[
@@ -422,7 +449,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
/>
</div>
<div className={styles.bottom}>
<button className={isMobile ? 'button' : ''} onClick={_publishCommentEdit}>
<button className={isMobile ? 'button' : ''} onClick={_publishCommentEdit} disabled={!canSave}>
{t('save')}
</button>
</div>
@@ -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();
@@ -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 && (
<span className={styles.checkbox}>
<EditMenu post={editMenuPost} />
</span>