mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
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:
@@ -23,6 +23,7 @@ const testState = vi.hoisted(() => ({
|
|||||||
authorPrivilegesOptions: undefined as Record<string, any> | undefined,
|
authorPrivilegesOptions: undefined as Record<string, any> | undefined,
|
||||||
isMobile: false,
|
isMobile: false,
|
||||||
modOptions: undefined as Record<string, any> | undefined,
|
modOptions: undefined as Record<string, any> | undefined,
|
||||||
|
pseudonymityMode: undefined as string | undefined,
|
||||||
privileges: {
|
privileges: {
|
||||||
isAccountCommentAuthor: false,
|
isAccountCommentAuthor: false,
|
||||||
isAccountMod: false,
|
isAccountMod: false,
|
||||||
@@ -95,6 +96,10 @@ vi.mock('../../../hooks/use-is-mobile', () => ({
|
|||||||
default: () => testState.isMobile,
|
default: () => testState.isMobile,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../hooks/use-board-pseudonymity-mode', () => ({
|
||||||
|
useBoardPseudonymityMode: () => testState.pseudonymityMode,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../../../stores/use-challenges-store', () => {
|
vi.mock('../../../stores/use-challenges-store', () => {
|
||||||
const hook = () => ({ challenges: [] });
|
const hook = () => ({ challenges: [] });
|
||||||
return {
|
return {
|
||||||
@@ -187,6 +192,7 @@ describe('EditMenu', () => {
|
|||||||
testState.authorPrivilegesOptions = undefined;
|
testState.authorPrivilegesOptions = undefined;
|
||||||
testState.isMobile = false;
|
testState.isMobile = false;
|
||||||
testState.modOptions = undefined;
|
testState.modOptions = undefined;
|
||||||
|
testState.pseudonymityMode = undefined;
|
||||||
testState.privileges = {
|
testState.privileges = {
|
||||||
isAccountCommentAuthor: false,
|
isAccountCommentAuthor: false,
|
||||||
isAccountMod: false,
|
isAccountMod: false,
|
||||||
@@ -266,6 +272,58 @@ describe('EditMenu', () => {
|
|||||||
expect(testState.authorPrivilegesOptions).not.toHaveProperty('subplebbitAddress');
|
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 () => {
|
it('lets moderators change moderation flags, ban duration, and save them', async () => {
|
||||||
testState.privileges = {
|
testState.privileges = {
|
||||||
isAccountCommentAuthor: false,
|
isAccountCommentAuthor: false,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import useChallengesStore from '../../stores/use-challenges-store';
|
|||||||
import capitalize from 'lodash/capitalize';
|
import capitalize from 'lodash/capitalize';
|
||||||
import useIsMobile from '../../hooks/use-is-mobile';
|
import useIsMobile from '../../hooks/use-is-mobile';
|
||||||
import useAuthorPrivileges from '../../hooks/use-author-privileges';
|
import useAuthorPrivileges from '../../hooks/use-author-privileges';
|
||||||
|
import { useBoardPseudonymityMode } from '../../hooks/use-board-pseudonymity-mode';
|
||||||
import { getCommentCommunityAddress, withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils';
|
import { getCommentCommunityAddress, withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils';
|
||||||
|
|
||||||
const { addChallenge } = useChallengesStore.getState();
|
const { addChallenge } = useChallengesStore.getState();
|
||||||
@@ -50,7 +51,12 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
|||||||
communityAddress: communityAddress || '',
|
communityAddress: communityAddress || '',
|
||||||
postCid,
|
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);
|
const latestPostRef = useRef(resolvedPost);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
latestPostRef.current = resolvedPost;
|
latestPostRef.current = resolvedPost;
|
||||||
@@ -63,7 +69,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
|||||||
communityAddress,
|
communityAddress,
|
||||||
// Author edit properties
|
// Author edit properties
|
||||||
content: isAccountCommentAuthor ? content : undefined,
|
content: isAccountCommentAuthor ? content : undefined,
|
||||||
deleted: isAccountCommentAuthor ? (deleted ?? false) : undefined,
|
deleted: canAttemptAuthorDelete ? (deleted ?? false) : undefined,
|
||||||
spoiler: isAccountCommentAuthor ? (spoiler ?? false) : undefined,
|
spoiler: isAccountCommentAuthor ? (spoiler ?? false) : undefined,
|
||||||
// Mod edit properties
|
// Mod edit properties
|
||||||
commentModeration: isAccountMod
|
commentModeration: isAccountMod
|
||||||
@@ -88,6 +94,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
|||||||
}, [
|
}, [
|
||||||
isAccountMod,
|
isAccountMod,
|
||||||
isAccountCommentAuthor,
|
isAccountCommentAuthor,
|
||||||
|
canAttemptAuthorDelete,
|
||||||
archived,
|
archived,
|
||||||
cid,
|
cid,
|
||||||
content,
|
content,
|
||||||
@@ -105,12 +112,10 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
|||||||
|
|
||||||
const [publishCommentEditOptions, setPublishCommentEditOptions] = useState<PublishCommentEditOptions>(defaultPublishEditOptions);
|
const [publishCommentEditOptions, setPublishCommentEditOptions] = useState<PublishCommentEditOptions>(defaultPublishEditOptions);
|
||||||
|
|
||||||
const authorEditOptions = useMemo<PublishCommentEditOptions>(
|
const authorEditOptions = useMemo<PublishCommentEditOptions>(() => {
|
||||||
() => ({
|
const options: PublishCommentEditOptions = {
|
||||||
commentCid: cid,
|
commentCid: cid,
|
||||||
communityAddress,
|
communityAddress,
|
||||||
signer,
|
|
||||||
author: signer?.address === author?.address ? { address: signer?.address, displayName: authorDisplayName } : account?.author,
|
|
||||||
content: publishCommentEditOptions.content,
|
content: publishCommentEditOptions.content,
|
||||||
deleted: publishCommentEditOptions.deleted,
|
deleted: publishCommentEditOptions.deleted,
|
||||||
reason: publishCommentEditOptions.reason,
|
reason: publishCommentEditOptions.reason,
|
||||||
@@ -121,9 +126,18 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
|||||||
console.warn(error);
|
console.warn(error);
|
||||||
alert('Comment edit failed. ' + error.message);
|
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>(
|
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;
|
newState[id] = checked;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,13 +263,20 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
|||||||
const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss, role]);
|
const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss, role]);
|
||||||
|
|
||||||
const headingId = useId();
|
const headingId = useId();
|
||||||
|
const canSave = !requiresDeleteSelection || publishCommentEditOptions.deleted === true;
|
||||||
|
|
||||||
const _publishCommentEdit = async () => {
|
const _publishCommentEdit = async () => {
|
||||||
|
if (!canSave) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldPublishAuthorEdit = isAccountCommentAuthor || (canAttemptAuthorDelete && publishCommentEditOptions.deleted === true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isAccountCommentAuthor && isAccountMod) {
|
if (shouldPublishAuthorEdit && isAccountMod) {
|
||||||
await publishAuthorEdit();
|
await publishAuthorEdit();
|
||||||
await publishCommentModeration();
|
await publishCommentModeration();
|
||||||
} else if (isAccountCommentAuthor) {
|
} else if (shouldPublishAuthorEdit) {
|
||||||
await publishAuthorEdit();
|
await publishAuthorEdit();
|
||||||
} else if (isAccountMod) {
|
} else if (isAccountMod) {
|
||||||
await publishCommentModeration();
|
await publishCommentModeration();
|
||||||
@@ -273,7 +296,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
|||||||
<input
|
<input
|
||||||
type='checkbox'
|
type='checkbox'
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
if (cid && (isAccountCommentAuthor || isAccountMod)) {
|
if (cid && canOpenEditMenu) {
|
||||||
if (!isEditMenuOpen) {
|
if (!isEditMenuOpen) {
|
||||||
resetMenuState();
|
resetMenuState();
|
||||||
setIsEditMenuOpen(true);
|
setIsEditMenuOpen(true);
|
||||||
@@ -288,12 +311,12 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
|||||||
checked={isEditMenuOpen}
|
checked={isEditMenuOpen}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
{isEditMenuOpen && (isAccountCommentAuthor || isAccountMod) && (
|
{isEditMenuOpen && canOpenEditMenu && (
|
||||||
<FloatingPortal>
|
<FloatingPortal>
|
||||||
<FloatingFocusManager context={context} modal={false}>
|
<FloatingFocusManager context={context} modal={false}>
|
||||||
<div className={styles.modal} ref={refs.setFloating} style={floatingStyles} aria-labelledby={headingId} {...getFloatingProps()}>
|
<div className={styles.modal} ref={refs.setFloating} style={floatingStyles} aria-labelledby={headingId} {...getFloatingProps()}>
|
||||||
<div className={styles.editMenu}>
|
<div className={styles.editMenu}>
|
||||||
{isAccountCommentAuthor && (
|
{canAttemptAuthorDelete && (
|
||||||
<>
|
<>
|
||||||
<div className={styles.menuItem}>
|
<div className={styles.menuItem}>
|
||||||
<label>
|
<label>
|
||||||
@@ -302,6 +325,10 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
|||||||
{capitalize(t('delete'))}?]
|
{capitalize(t('delete'))}?]
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isAccountCommentAuthor && (
|
||||||
|
<>
|
||||||
<div className={styles.menuItem}>
|
<div className={styles.menuItem}>
|
||||||
<label>
|
<label>
|
||||||
[
|
[
|
||||||
@@ -422,7 +449,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.bottom}>
|
<div className={styles.bottom}>
|
||||||
<button className={isMobile ? 'button' : ''} onClick={_publishCommentEdit}>
|
<button className={isMobile ? 'button' : ''} onClick={_publishCommentEdit} disabled={!canSave}>
|
||||||
{t('save')}
|
{t('save')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const testState = vi.hoisted(() => ({
|
|||||||
hidden: false,
|
hidden: false,
|
||||||
hideMock: vi.fn(),
|
hideMock: vi.fn(),
|
||||||
mediaInfo: undefined as { thumbnail?: string; type?: string; url?: string } | undefined,
|
mediaInfo: undefined as { thumbnail?: string; type?: string; url?: string } | undefined,
|
||||||
|
pseudonymityMode: undefined as string | undefined,
|
||||||
privileges: {
|
privileges: {
|
||||||
isAccountCommentAuthor: false,
|
isAccountCommentAuthor: false,
|
||||||
isAccountMod: false,
|
isAccountMod: false,
|
||||||
@@ -90,6 +91,10 @@ vi.mock('../../../../hooks/use-author-privileges', () => ({
|
|||||||
default: () => testState.privileges,
|
default: () => testState.privileges,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../hooks/use-board-pseudonymity-mode', () => ({
|
||||||
|
useBoardPseudonymityMode: () => testState.pseudonymityMode,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../../../../hooks/use-hide', () => ({
|
vi.mock('../../../../hooks/use-hide', () => ({
|
||||||
default: () => ({
|
default: () => ({
|
||||||
hidden: testState.hidden,
|
hidden: testState.hidden,
|
||||||
@@ -151,6 +156,7 @@ describe('PostMenuMobile', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
testState.hidden = false;
|
testState.hidden = false;
|
||||||
testState.mediaInfo = undefined;
|
testState.mediaInfo = undefined;
|
||||||
|
testState.pseudonymityMode = undefined;
|
||||||
testState.privileges = {
|
testState.privileges = {
|
||||||
isAccountCommentAuthor: false,
|
isAccountCommentAuthor: false,
|
||||||
isAccountMod: false,
|
isAccountMod: false,
|
||||||
@@ -197,6 +203,22 @@ describe('PostMenuMobile', () => {
|
|||||||
expect(testState.hideMock).toHaveBeenCalled();
|
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 () => {
|
it('omits thread hiding on the root thread route and suppresses the menu for deleted posts', async () => {
|
||||||
await renderMenu('/mu/thread/cid-1');
|
await renderMenu('/mu/thread/cid-1');
|
||||||
await openMenu();
|
await openMenu();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { copyToClipboard } from '../../../lib/utils/clipboard-utils';
|
|||||||
import { getBoardPath } from '../../../lib/utils/route-utils';
|
import { getBoardPath } from '../../../lib/utils/route-utils';
|
||||||
import { useDirectories } from '../../../hooks/use-directories';
|
import { useDirectories } from '../../../hooks/use-directories';
|
||||||
import useEditCommentPrivileges from '../../../hooks/use-author-privileges';
|
import useEditCommentPrivileges from '../../../hooks/use-author-privileges';
|
||||||
|
import { useBoardPseudonymityMode } from '../../../hooks/use-board-pseudonymity-mode';
|
||||||
import useHide from '../../../hooks/use-hide';
|
import useHide from '../../../hooks/use-hide';
|
||||||
import EditMenu from '../../edit-menu/edit-menu';
|
import EditMenu from '../../edit-menu/edit-menu';
|
||||||
import { isBoardView, isPostPageView } from '../../../lib/utils/view-utils';
|
import { isBoardView, isPostPageView } from '../../../lib/utils/view-utils';
|
||||||
@@ -198,6 +199,8 @@ const PostMenuMobile = ({ postMenu, editMenuPost }: PostMenuMobileProps) => {
|
|||||||
commentAuthorAddress: authorAddress || '',
|
commentAuthorAddress: authorAddress || '',
|
||||||
subplebbitAddress: resolvedCommunityAddress || '',
|
subplebbitAddress: resolvedCommunityAddress || '',
|
||||||
});
|
});
|
||||||
|
const pseudonymityMode = useBoardPseudonymityMode(resolvedCommunityAddress);
|
||||||
|
const canAttemptAuthorDelete = pseudonymityMode !== undefined && pseudonymityMode !== 'none';
|
||||||
const commentMediaInfo = getCommentMediaInfo(link || '', thumbnailUrl || '', linkWidth || 0, linkHeight || 0);
|
const commentMediaInfo = getCommentMediaInfo(link || '', thumbnailUrl || '', linkWidth || 0, linkHeight || 0);
|
||||||
const { thumbnail, type, url } = commentMediaInfo || {};
|
const { thumbnail, type, url } = commentMediaInfo || {};
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
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}>
|
<span className={styles.checkbox}>
|
||||||
<EditMenu post={editMenuPost} />
|
<EditMenu post={editMenuPost} />
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user