Files
5chan/src/components/post-mobile/post-menu-mobile/post-menu-mobile.tsx
T
Tommaso Casaburi 404a464310 feat: use s.5chan.app share links and remove report menu items
Copy share links as path-based s.5chan.app URLs so the share server can render social previews, rename the menu label, remove the stub report action, and update the FAQ.
2026-06-06 19:06:04 +07:00

355 lines
12 KiB
TypeScript

import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import type { ChallengeVerification, Comment } from '@bitsocial/bitsocial-react-hooks';
import { useAccount, usePublishCommentEdit } from '@bitsocial/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';
import { copyShareLinkToClipboard, isValidURL, type ShareLinkType } from '../../../lib/utils/url-utils';
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 { 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, type ChallengePublication } from '../../../lib/utils/challenge-utils';
import useChallengesStore from '../../../stores/use-challenges-store';
async function copyShareLinkSafe(boardIdentifier: string, linkType: ShareLinkType, cid?: string): Promise<void> {
try {
if (linkType === 'thread' && cid) {
await copyShareLinkToClipboard(boardIdentifier, linkType, cid);
} else {
await copyShareLinkToClipboard(boardIdentifier, linkType as Exclude<ShareLinkType, 'thread'>);
}
} catch (error) {
console.error('Failed to copy share link', error);
}
}
async function copyContentIdSafe(cid: string): Promise<void> {
try {
await copyToClipboard(cid);
} catch (error) {
console.error('Failed to copy content id', error);
}
}
async function copyUserIdSafe(address: string): Promise<void> {
try {
await copyToClipboard(address);
} catch (error) {
console.error('Failed to copy user id', error);
}
}
type HideButtonProps = {
cid?: string;
comment?: Comment;
isReply?: boolean;
postCid?: string;
onClose?: () => void;
};
type CopyLinkButtonProps =
| { cid: string; communityAddress: string; linkType: 'thread'; onClose: () => void }
| { communityAddress: string; linkType: Exclude<ShareLinkType, 'thread'>; onClose: () => void; cid?: undefined };
const CopyLinkButton = ({ cid, communityAddress, linkType, onClose }: CopyLinkButtonProps) => {
const { t } = useTranslation();
const directories = useDirectories();
const boardIdentifier = getBoardPath(communityAddress, directories);
const copyShareLink = async () => {
await copyShareLinkSafe(boardIdentifier, linkType, linkType === 'thread' ? cid : undefined);
onClose();
};
return (
<button
type='button'
tabIndex={0}
onClick={copyShareLink}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
copyShareLink();
}
}}
>
<div className={styles.postMenuItem}>{t('copy_share_link')}</div>
</button>
);
};
const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => void }) => {
const { t } = useTranslation();
const copyContentId = async () => {
await copyContentIdSafe(cid);
onClose();
};
return (
<button
type='button'
tabIndex={0}
onClick={copyContentId}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
copyContentId();
}
}}
>
<div className={styles.postMenuItem}>{t('copy_content_id')}</div>
</button>
);
};
const CopyUserIdButton = ({ address, onClose }: { address: string; onClose: () => void }) => {
const { t } = useTranslation();
const copyUserId = async () => {
await copyUserIdSafe(address);
onClose();
};
return (
<button
type='button'
tabIndex={0}
onClick={copyUserId}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
copyUserId();
}
}}
>
<div className={styles.postMenuItem}>{t('copy_user_id')}</div>
</button>
);
};
const ImageSearchButtons = ({ url, onClose }: { url: string; onClose: () => void }) => {
const { t } = useTranslation();
const encodedUrl = encodeURIComponent(url);
return (
<div>
<a href={`https://lens.google.com/uploadbyurl?url=${encodedUrl}`} target='_blank' rel='noopener noreferrer' onClick={onClose}>
<div className={styles.postMenuItem}>{t('search_image_on_google')}</div>
</a>
<a href={`https://www.yandex.com/images/search?url=${encodedUrl}&rpt=imageview`} target='_blank' rel='noopener noreferrer' onClick={onClose}>
<div className={styles.postMenuItem}>{t('search_image_on_yandex')}</div>
</a>
<a href={`https://saucenao.com/search.php?url=${encodedUrl}`} target='_blank' rel='noopener noreferrer' onClick={onClose}>
<div className={styles.postMenuItem}>{t('search_image_on_saucenao')}</div>
</a>
</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 signerMatchesAuthor = Boolean(signer?.address && author?.address && signer.address === author.address);
const latestPostRef = useRef(resolvedPost);
useEffect(() => {
latestPostRef.current = resolvedPost;
}, [resolvedPost]);
const onChallenge = useCallback(async (...args: unknown[]) => {
useChallengesStore.getState().addChallenge([...args, latestPostRef.current]);
}, []);
const deleteOptions = useMemo(
() => ({
commentCid: cid,
communityAddress,
deleted: true,
...(isAccountCommentAuthor && signer
? {
signer,
author: signerMatchesAuthor ? { address: signer.address, displayName: resolvedPost?.author?.displayName } : account?.author,
}
: {}),
onChallenge,
onChallengeVerification: async (challengeVerification: ChallengeVerification, publication: ChallengePublication | undefined) => {
alertChallengeVerificationFailed(challengeVerification, publication);
},
onError: (error: Error) => {
console.warn(error);
alert('Comment edit failed. ' + error.message);
},
}),
[cid, communityAddress, isAccountCommentAuthor, signer, signerMatchesAuthor, resolvedPost?.author?.displayName, account?.author, onChallenge],
);
const { publishCommentEdit } = usePublishCommentEdit(deleteOptions);
const deletePost = 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 (
<button
type='button'
tabIndex={0}
onClick={deletePost}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
deletePost();
}
}}
>
<div className={styles.postMenuItem}>{t('delete_post')}</div>
</button>
);
};
const HidePostButton = ({ cid, comment, isReply, onClose, postCid }: HideButtonProps) => {
const { t } = useTranslation();
const { hide, hidden, unhide } = useHide({ cid: cid || '', comment });
const isInPostView = isPostPageView(useLocation().pathname, useParams());
const togglePostHidden = () => {
if (hidden) {
unhide();
} else {
hide();
}
onClose?.();
};
return (
(!isInPostView || isReply) && (
<button
type='button'
tabIndex={0}
onClick={togglePostHidden}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
togglePostHidden();
}
}}
>
<div className={styles.postMenuItem}>
{hidden ? (postCid === cid ? t('unhide_thread') : t('unhide_post')) : postCid === cid ? t('hide_thread') : t('hide_post')}
</div>
</button>
)
);
};
type PostMenuMobileProps = {
postMenu: PostMenuProps;
editMenuPost?: Comment;
};
const PostMenuMobile = ({ postMenu, editMenuPost }: PostMenuMobileProps) => {
const { authorAddress, cid, communityAddress, deleted, link, linkHeight, linkWidth, parentCid, postCid, removed, thumbnailUrl } = postMenu || {};
const { isAccountMod, isAccountCommentAuthor } = useEditCommentPrivileges({
commentAuthorAddress: authorAddress || '',
communityAddress: communityAddress || '',
});
const pseudonymityMode = useBoardPseudonymityMode(communityAddress);
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);
const { refs, floatingStyles, context } = useFloating({
placement: 'bottom-start',
open: isMenuOpen,
onOpenChange: setIsMenuOpen,
middleware: [offset({ mainAxis: 3, crossAxis: 8 }), flip(), shift({ padding: 10 })],
whileElementsMounted: autoUpdate,
});
const click = useClick(context);
const dismiss = useDismiss(context);
const role = useRole(context);
const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss, role]);
const headingId = useId();
const handleMenuClick = () => {
if (cid) {
setIsMenuOpen((prev) => !prev);
}
};
const handleClose = () => setIsMenuOpen(false);
return (
<>
{!(deleted || removed) && (
<>
<button
type='button'
className={styles.postMenuBtn}
title='Post menu'
tabIndex={0}
onClick={handleMenuClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleMenuClick();
}
}}
ref={refs.setReference}
{...getReferenceProps()}
>
...
</button>
{isMenuOpen &&
cid &&
createPortal(
<FloatingFocusManager context={context} modal={false}>
<div className={styles.postMenu} ref={refs.setFloating} style={floatingStyles} aria-labelledby={headingId} {...getFloatingProps()}>
{cid && communityAddress && <HidePostButton cid={cid} comment={postMenu.comment} isReply={!!parentCid} postCid={postCid} onClose={handleClose} />}
{(isAccountCommentAuthor || canAttemptAuthorDelete) && cid && editMenuPost && <DeletePostButton post={editMenuPost} onClose={handleClose} />}
{cid && communityAddress && <CopyLinkButton cid={cid} communityAddress={communityAddress} 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 && <ImageSearchButtons url={url} onClose={handleClose} />}
</div>
</FloatingFocusManager>,
document.body,
)}
</>
)}
{isAccountMod && cid && editMenuPost && (
<span className={styles.checkbox}>
<EditMenu post={editMenuPost} />
</span>
)}
</>
);
};
export default memo(PostMenuMobile);