mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
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:
@@ -128,7 +128,6 @@ const basePost = {
|
||||
parentCid: undefined as string | undefined,
|
||||
pinned: false,
|
||||
postCid: 'post-1',
|
||||
reason: '',
|
||||
removed: false,
|
||||
spoiler: false,
|
||||
communityAddress: 'music-posting.eth',
|
||||
@@ -228,7 +227,7 @@ describe('EditMenu', () => {
|
||||
expect(alertSpy).toHaveBeenLastCalledWith('cannot_edit_reply');
|
||||
});
|
||||
|
||||
it('lets comment authors update content, deletion, and reason', async () => {
|
||||
it('lets comment authors update content and deletion', async () => {
|
||||
testState.privileges = {
|
||||
isAccountCommentAuthor: true,
|
||||
isAccountMod: false,
|
||||
@@ -242,12 +241,9 @@ describe('EditMenu', () => {
|
||||
await click(getLabelCheckbox('Edit?'));
|
||||
|
||||
const textarea = container.querySelector('textarea');
|
||||
const reasonInput = container.querySelector<HTMLInputElement>('input[type="text"]');
|
||||
expect(textarea).not.toBeNull();
|
||||
expect(reasonInput).not.toBeNull();
|
||||
|
||||
await dispatchInput(textarea as HTMLTextAreaElement, 'Updated body');
|
||||
await dispatchInput(reasonInput as HTMLInputElement, 'cleanup');
|
||||
await clickButton('save');
|
||||
|
||||
expect(testState.publishAuthorEditMock).toHaveBeenCalledOnce();
|
||||
@@ -260,7 +256,6 @@ describe('EditMenu', () => {
|
||||
commentCid: 'comment-1',
|
||||
content: 'Updated body',
|
||||
deleted: true,
|
||||
reason: 'cleanup',
|
||||
spoiler: false,
|
||||
communityAddress: 'music-posting.eth',
|
||||
});
|
||||
@@ -343,12 +338,9 @@ describe('EditMenu', () => {
|
||||
await click(getCheckbox('banUser'));
|
||||
|
||||
const banDurationInput = container.querySelector<HTMLInputElement>('[data-testid="ban-duration-input"]');
|
||||
const reasonInput = container.querySelector<HTMLInputElement>('input[type="text"]');
|
||||
expect(banDurationInput).not.toBeNull();
|
||||
expect(reasonInput).not.toBeNull();
|
||||
|
||||
await dispatchInput(banDurationInput as HTMLInputElement, '7');
|
||||
await dispatchInput(reasonInput as HTMLInputElement, 'rule violation');
|
||||
await clickButton('save');
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith('purge_confirm');
|
||||
@@ -363,7 +355,6 @@ describe('EditMenu', () => {
|
||||
communityAddress: 'music-posting.eth',
|
||||
});
|
||||
expect(testState.modOptions?.commentModeration).toMatchObject({
|
||||
reason: 'rule violation',
|
||||
archived: true,
|
||||
locked: true,
|
||||
pinned: true,
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
.bottom button {
|
||||
text-transform: capitalize;
|
||||
margin-left: 2px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
@@ -90,7 +91,8 @@
|
||||
@media (min-width: 640px) {
|
||||
.bottom button {
|
||||
cursor: pointer;
|
||||
padding: 2px 6px 3px;
|
||||
margin-top: 10px;
|
||||
padding: 2px 6px 3px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
const resolvedPost = withResolvedCommentCommunityAddress(post);
|
||||
const { author, cid, content, deleted, locked, parentCid, pinned, postCid, reason, removed, spoiler } = resolvedPost || {};
|
||||
const { author, cid, content, deleted, locked, parentCid, pinned, postCid, removed, spoiler } = resolvedPost || {};
|
||||
const communityAddress = getCommentCommunityAddress(resolvedPost);
|
||||
const archived = isCommentArchived(resolvedPost);
|
||||
const authorDisplayName = resolvedPost?.author?.displayName;
|
||||
@@ -80,7 +80,6 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
||||
removed: removed ?? false,
|
||||
purged: purged ?? false,
|
||||
spoiler: spoiler ?? false,
|
||||
reason,
|
||||
author: modBanExpiresAt ? { banExpiresAt: modBanExpiresAt } : undefined,
|
||||
}
|
||||
: undefined,
|
||||
@@ -101,7 +100,6 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
||||
deleted,
|
||||
locked,
|
||||
pinned,
|
||||
reason,
|
||||
removed,
|
||||
purged,
|
||||
spoiler,
|
||||
@@ -118,7 +116,6 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
||||
communityAddress,
|
||||
content: publishCommentEditOptions.content,
|
||||
deleted: publishCommentEditOptions.deleted,
|
||||
reason: publishCommentEditOptions.reason,
|
||||
spoiler: publishCommentEditOptions.spoiler,
|
||||
onChallenge,
|
||||
onChallengeVerification: alertChallengeVerificationFailed,
|
||||
@@ -150,7 +147,6 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
||||
removed: publishCommentEditOptions.commentModeration?.removed,
|
||||
purged: publishCommentEditOptions.commentModeration?.purged,
|
||||
spoiler: publishCommentEditOptions.commentModeration?.spoiler,
|
||||
reason: publishCommentEditOptions.reason,
|
||||
author: publishCommentEditOptions.commentModeration?.author,
|
||||
},
|
||||
author: account?.author,
|
||||
@@ -436,18 +432,6 @@ const EditMenu = ({ post }: { post: Comment }) => {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className={`${styles.menuItem} ${styles.menuReason}`}>
|
||||
{capitalize(t('reason'))}? ({t('optional')})
|
||||
<input
|
||||
type='text'
|
||||
value={publishCommentEditOptions.reason || ''}
|
||||
onChange={(e) => {
|
||||
const newReason = e.target.value;
|
||||
setPublishCommentEditOptions((state) => ({ ...state, reason: newReason }));
|
||||
}}
|
||||
size={14}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.bottom}>
|
||||
<button className={isMobile ? 'button' : ''} onClick={_publishCommentEdit} disabled={!canSave}>
|
||||
{t('save')}
|
||||
|
||||
@@ -229,9 +229,24 @@ const PostMenuDesktop = ({ postMenu }: PostMenuDesktopProps) => {
|
||||
createPortal(
|
||||
<FloatingFocusManager context={context} modal={false}>
|
||||
<div className={styles.postMenu} ref={refs.setFloating} style={floatingStyles} aria-labelledby={headingId} {...getFloatingProps()}>
|
||||
{cid && resolvedCommunityAddress && <CopyLinkButton cid={cid} communityAddress={resolvedCommunityAddress} linkType='thread' onClose={handleClose} />}
|
||||
{cid && <CopyContentIdButton cid={cid} onClose={handleClose} />}
|
||||
{authorAddress && <CopyUserIdButton address={authorAddress} onClose={handleClose} />}
|
||||
<div
|
||||
className={styles.postMenuItem}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
alert("Reporting isn't available yet.");
|
||||
handleClose();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
alert("Reporting isn't available yet.");
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('report_post')}
|
||||
</div>
|
||||
{!(isInPostPageView && postCid === cid) && (
|
||||
<div
|
||||
className={styles.postMenuItem}
|
||||
@@ -252,6 +267,9 @@ const PostMenuDesktop = ({ postMenu }: PostMenuDesktopProps) => {
|
||||
{hidden ? (postCid === cid ? t('unhide_thread') : t('unhide_post')) : postCid === cid ? t('hide_thread') : t('hide_post')}
|
||||
</div>
|
||||
)}
|
||||
{cid && resolvedCommunityAddress && <CopyLinkButton cid={cid} communityAddress={resolvedCommunityAddress} linkType='thread' onClose={handleClose} />}
|
||||
{cid && <CopyContentIdButton cid={cid} onClose={handleClose} />}
|
||||
{authorAddress && <CopyUserIdButton address={authorAddress} onClose={handleClose} />}
|
||||
{link && isValidURL(link) && (type === 'image' || type === 'gif' || thumbnail) && url && <ImageSearchButton url={url} onClose={handleClose} />}
|
||||
</div>
|
||||
</FloatingFocusManager>,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -126,7 +126,7 @@ const CryptoAddressSetting = () => {
|
||||
<div className={styles.cryptoAddressInput}>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='myaddress.eth'
|
||||
placeholder='myaddress.bso'
|
||||
value={cryptoState.cryptoAddress}
|
||||
onChange={(e) => {
|
||||
setInputValue(e.target.value);
|
||||
@@ -141,18 +141,18 @@ const CryptoAddressSetting = () => {
|
||||
</button>
|
||||
{showCryptoAddressInfo && (
|
||||
<div className={styles.cryptoAddressInfo}>
|
||||
steps to set a .eth address as your ID:
|
||||
steps to set a .bso account address:
|
||||
<br />
|
||||
<ol>
|
||||
<li>
|
||||
go to{' '}
|
||||
buy your desired .bso address as .eth on{' '}
|
||||
<a href='https://app.ens.domains/' target='_blank' rel='noopener noreferrer'>
|
||||
app.ens.domains
|
||||
</a>{' '}
|
||||
and search the address
|
||||
</li>
|
||||
<li>once you own the address, go to its page, click on "records", then "edit records"</li>
|
||||
<li>once you own the .eth address, go to its page on ENS, click on "records", then "edit records"</li>
|
||||
<li>add a new text record with name "bitsocial" and value: {account?.signer?.address}</li>
|
||||
<li>enter your .bso address in the input field above, click "check" to verify it's yours, then click "save"</li>
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -22,11 +22,6 @@ export const isValidURL = (url: string) => {
|
||||
const CHAN_5_HOSTNAMES = ['5chan.app', '5chan.eth.limo', '5chan.eth.link', '5chan.eth.sucks', '5chan.netlify.app'];
|
||||
|
||||
function getShareBaseUrl(): string {
|
||||
const { protocol, hostname, origin } = window.location;
|
||||
if ((protocol === 'https:' || protocol === 'http:') && hostname !== 'localhost' && hostname !== '127.0.0.1') {
|
||||
return origin;
|
||||
}
|
||||
// Electron / Capacitor / local dev fallback
|
||||
return `https://${CHAN_5_HOSTNAMES[0]}`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user