mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
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.
355 lines
12 KiB
TypeScript
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);
|