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,
|
||||
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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user