feat(post-menu): add Report/Delete, reorder buttons, edit-only-for-mods, copy link 5chan.app (#1089)

* Update crypto address UI for .bso accounts

* feat(post-menu): add Report/Delete, reorder buttons, edit-only-for-mods, copy link 5chan.app

- Remove redundant reason field from edit menu (purged posts unreachable)
- Add Report post (placeholder alert) and Delete post (mobile) to post menus
- Reorder: Report, Hide, Delete, Copy x3, Image search
- Mobile edit checkbox only for mods; authors use Delete in post menu
- Copy direct link always uses 5chan.app domain
- 10px margin-top on mod menu save button

* fix(post-menu): add confirmation before delete post (Bugbot)
This commit is contained in:
Tommaso Casaburi
2026-03-16 17:54:17 +08:00
committed by GitHub
parent 6ecf3c40d3
commit f4ff7a23f7
43 changed files with 299 additions and 84 deletions
@@ -29,7 +29,10 @@ vi.mock('react-i18next', () => ({
}),
}));
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({}));
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
useAccount: () => ({ author: { address: '0xmod' }, signer: { address: '0xauthor' } }),
usePublishCommentEdit: () => ({ publishCommentEdit: vi.fn().mockResolvedValue(undefined) }),
}));
vi.mock('@bitsocialnet/bitsocial-react-hooks/dist/lib/localforage-lru/index.js', () => ({
default: {
@@ -172,10 +175,10 @@ describe('PostMenuMobile', () => {
container.remove();
});
it('opens the mobile menu, copies share metadata, and shows edit controls for privileged users', async () => {
it('opens the mobile menu, copies share metadata, and shows edit controls for mods', async () => {
testState.privileges = {
isAccountCommentAuthor: true,
isAccountMod: false,
isAccountMod: true,
};
await renderMenu('/mu');
@@ -203,12 +206,16 @@ describe('PostMenuMobile', () => {
expect(testState.hideMock).toHaveBeenCalled();
});
it('shows edit controls on pseudonymous boards even without a local author-address match', async () => {
it('does not show edit controls on pseudonymous boards when user is not a mod', async () => {
testState.pseudonymityMode = 'per-post';
testState.privileges = {
isAccountCommentAuthor: false,
isAccountMod: false,
};
await renderMenu('/mu');
expect(document.body.querySelector('[data-testid="edit-menu"]')?.textContent).toBe('cid-1');
expect(document.body.querySelector('[data-testid="edit-menu"]')).toBeNull();
});
it("does not show edit controls when pseudonymity mode is 'none' and the user lacks privileges", async () => {
@@ -1,7 +1,8 @@
import { memo, useState } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import type { Comment } from '@bitsocialnet/bitsocial-react-hooks';
import type { ChallengeVerification, Comment } from '@bitsocialnet/bitsocial-react-hooks';
import { useAccount, usePublishCommentEdit } from '@bitsocialnet/bitsocial-react-hooks';
import { autoUpdate, flip, FloatingFocusManager, offset, shift, useClick, useDismiss, useFloating, useId, useInteractions, useRole } from '@floating-ui/react';
import styles from './post-menu-mobile.module.css';
import { getCommentMediaInfo } from '../../../lib/utils/media-utils';
@@ -16,6 +17,9 @@ import EditMenu from '../../edit-menu/edit-menu';
import { isBoardView, isPostPageView } from '../../../lib/utils/view-utils';
import { useLocation, useParams } from 'react-router-dom';
import { PostMenuProps } from '../../../lib/utils/post-menu-props';
import { getCommentCommunityAddress, withResolvedCommentCommunityAddress } from '../../../lib/utils/comment-utils';
import { alertChallengeVerificationFailed } from '../../../lib/utils/challenge-utils';
import useChallengesStore from '../../../stores/use-challenges-store';
async function copyShareLinkSafe(boardIdentifier: string, linkType: ShareLinkType, cid?: string): Promise<void> {
try {
@@ -154,6 +158,113 @@ const ImageSearchButtons = ({ url, onClose }: { url: string; onClose: () => void
);
};
const { addChallenge } = useChallengesStore.getState();
const ReportPostButton = ({ onClose }: { onClose: () => void }) => {
const { t } = useTranslation();
const handleClick = () => {
alert("Reporting isn't available yet.");
onClose();
};
return (
<div
role='button'
tabIndex={0}
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
}}
>
<div className={styles.postMenuItem}>{t('report_post')}</div>
</div>
);
};
type DeletePostButtonProps = {
post: Comment;
onClose: () => void;
};
const DeletePostButton = ({ post, onClose }: DeletePostButtonProps) => {
const { t } = useTranslation();
const account = useAccount();
const resolvedPost = withResolvedCommentCommunityAddress(post);
const { author, cid } = resolvedPost || {};
const communityAddress = getCommentCommunityAddress(resolvedPost);
const { isAccountCommentAuthor } = useEditCommentPrivileges({
commentAuthorAddress: author?.address || '',
communityAddress: communityAddress || '',
postCid: resolvedPost?.postCid,
});
const signer = isAccountCommentAuthor ? account?.signer : undefined;
const latestPostRef = useRef(resolvedPost);
useEffect(() => {
latestPostRef.current = resolvedPost;
}, [resolvedPost]);
const onChallenge = useCallback(async (...args: unknown[]) => {
addChallenge([...args, latestPostRef.current]);
}, []);
const deleteOptions = useMemo(
() => ({
commentCid: cid,
communityAddress,
deleted: true,
...(isAccountCommentAuthor && signer
? {
signer,
author: signer?.address === author?.address ? { address: signer.address, displayName: resolvedPost?.author?.displayName } : account?.author,
}
: {}),
onChallenge,
onChallengeVerification: async (challengeVerification: ChallengeVerification, publication: unknown) => {
alertChallengeVerificationFailed(challengeVerification, publication);
},
onError: (error: Error) => {
console.warn(error);
alert('Comment edit failed. ' + error.message);
},
}),
[cid, communityAddress, isAccountCommentAuthor, signer, author?.address, resolvedPost?.author?.displayName, account?.author, onChallenge],
);
const { publishCommentEdit } = usePublishCommentEdit(deleteOptions);
const handleClick = async () => {
const confirmed = window.confirm(t('delete_post_confirm'));
if (!confirmed) {
return;
}
try {
await publishCommentEdit();
onClose();
} catch (error) {
if (error instanceof Error) {
alert(error.message);
}
}
};
return (
<div
role='button'
tabIndex={0}
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
}}
>
<div className={styles.postMenuItem}>{t('delete_post')}</div>
</div>
);
};
const HidePostButton = ({ cid, isReply, onClose, postCid }: HideButtonProps) => {
const { t } = useTranslation();
const { hide, hidden, unhide } = useHide({ cid: cid || '' });
@@ -253,10 +364,12 @@ const PostMenuMobile = ({ postMenu, editMenuPost }: PostMenuMobileProps) => {
createPortal(
<FloatingFocusManager context={context} modal={false}>
<div className={styles.postMenu} ref={refs.setFloating} style={floatingStyles} aria-labelledby={headingId} {...getFloatingProps()}>
<ReportPostButton onClose={handleClose} />
{cid && resolvedCommunityAddress && <HidePostButton cid={cid} isReply={!!parentCid} postCid={postCid} onClose={handleClose} />}
{(isAccountCommentAuthor || canAttemptAuthorDelete) && cid && <DeletePostButton post={editMenuPost} onClose={handleClose} />}
{cid && resolvedCommunityAddress && <CopyLinkButton cid={cid} communityAddress={resolvedCommunityAddress} linkType='thread' onClose={handleClose} />}
{cid && <CopyContentIdButton cid={cid} onClose={handleClose} />}
{authorAddress && <CopyUserIdButton address={authorAddress} onClose={handleClose} />}
{cid && resolvedCommunityAddress && <HidePostButton cid={cid} isReply={!!parentCid} postCid={postCid} onClose={handleClose} />}
{link && isValidURL(link) && (type === 'image' || type === 'gif' || thumbnail) && url && <ImageSearchButtons url={url} onClose={handleClose} />}
</div>
</FloatingFocusManager>,
@@ -264,7 +377,7 @@ const PostMenuMobile = ({ postMenu, editMenuPost }: PostMenuMobileProps) => {
)}
</>
)}
{(isAccountMod || isAccountCommentAuthor || canAttemptAuthorDelete) && cid && (
{isAccountMod && cid && (
<span className={styles.checkbox}>
<EditMenu post={editMenuPost} />
</span>