refactor(react-doctor): raise score from 79 to 90

This commit is contained in:
plebeius
2026-02-24 15:15:44 +08:00
parent 5bd1dc0e71
commit e51f5b968c
36 changed files with 2372 additions and 1030 deletions
@@ -16,6 +16,34 @@ import { isBoardView, isPostPageView } from '../../../lib/utils/view-utils';
import { useLocation, useParams } from 'react-router-dom';
import { PostMenuProps } from '../../../lib/utils/post-menu-props';
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;
isReply?: boolean;
@@ -31,19 +59,19 @@ const CopyLinkButton = ({ cid, subplebbitAddress, linkType, onClose }: CopyLinkB
const { t } = useTranslation();
const directories = useDirectories();
const boardIdentifier = getBoardPath(subplebbitAddress, directories);
const handleClick = async () => {
await copyShareLinkSafe(boardIdentifier, linkType, linkType === 'thread' ? cid : undefined);
onClose();
};
return (
<div
onClick={async () => {
try {
if (linkType === 'thread') {
await copyShareLinkToClipboard(boardIdentifier, linkType, cid);
} else {
await copyShareLinkToClipboard(boardIdentifier, linkType);
}
} catch (error) {
console.error('Failed to copy share link', error);
} finally {
onClose();
role='button'
tabIndex={0}
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
}}
>
@@ -54,15 +82,19 @@ const CopyLinkButton = ({ cid, subplebbitAddress, linkType, onClose }: CopyLinkB
const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => void }) => {
const { t } = useTranslation();
const handleClick = async () => {
await copyContentIdSafe(cid);
onClose();
};
return (
<div
onClick={async () => {
try {
await copyToClipboard(cid);
} catch (error) {
console.error('Failed to copy content id', error);
} finally {
onClose();
role='button'
tabIndex={0}
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
}}
>
@@ -73,15 +105,19 @@ const CopyContentIdButton = ({ cid, onClose }: { cid: string; onClose: () => voi
const CopyUserIdButton = ({ address, onClose }: { address: string; onClose: () => void }) => {
const { t } = useTranslation();
const handleClick = async () => {
await copyUserIdSafe(address);
onClose();
};
return (
<div
onClick={async () => {
try {
await copyToClipboard(address);
} catch (error) {
console.error('Failed to copy user id', error);
} finally {
onClose();
role='button'
tabIndex={0}
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
}}
>
@@ -93,7 +129,17 @@ const CopyUserIdButton = ({ address, onClose }: { address: string; onClose: () =
const ImageSearchButtons = ({ url, onClose }: { url: string; onClose: () => void }) => {
const { t } = useTranslation();
return (
<div onClick={onClose}>
<div
role='button'
tabIndex={0}
onClick={onClose}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClose();
}
}}
>
<a href={`https://lens.google.com/uploadbyurl?url=${url}`} target='_blank' rel='noreferrer'>
<div className={styles.postMenuItem}>{t('search_image_on_google')}</div>
</a>
@@ -112,12 +158,21 @@ const HidePostButton = ({ cid, isReply, onClose, postCid }: HideButtonProps) =>
const { hide, hidden, unhide } = useHide({ cid: cid || '' });
const isInPostView = isPostPageView(useLocation().pathname, useParams());
const handleClick = () => {
hidden ? unhide() : hide();
onClose && onClose();
};
return (
(!isInPostView || isReply) && (
<div
onClick={() => {
hidden ? unhide() : hide();
onClose && onClose();
role='button'
tabIndex={0}
onClick={handleClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
}}
>
<div className={styles.postMenuItem}>
@@ -169,7 +224,21 @@ const PostMenuMobile = ({ postMenu, editMenuPost }: PostMenuMobileProps) => {
<>
{!(deleted || removed) && (
<>
<span className={styles.postMenuBtn} title='Post menu' onClick={handleMenuClick} ref={refs.setReference} {...getReferenceProps()}>
<span
className={styles.postMenuBtn}
title='Post menu'
role='button'
tabIndex={0}
onClick={handleMenuClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleMenuClick();
}
}}
ref={refs.setReference}
{...getReferenceProps()}
>
...
</span>
{isMenuOpen &&
+73 -41
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { useEffect, useRef, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useLocation, useNavigationType, useParams } from 'react-router-dom';
import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso';
@@ -187,22 +187,20 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos
const isOverThreshold = isAwaitingApproval && timeWaiting > alertThresholdSeconds;
const hasFailedState = state === 'failed';
const postMenuProps = useMemo(() => selectPostMenuProps(post), [post]);
const postMenuProps = selectPostMenuProps(post);
const pseudonymityMode = useSubplebbitField(subplebbitAddress, (sub) => sub?.features?.pseudonymityMode);
const showUserID = pseudonymityMode !== 'per-reply';
const handleUserAddressClick = useAuthorAddressClick();
const numberOfPostsByAuthor = useMemo(() => {
const numberOfPostsByAuthor = (() => {
if (!showUserID || deleted || removed || !shortAddress || !postCid || typeof document === 'undefined') {
return 0;
}
const domCount = document.querySelectorAll(`[data-author-address="${shortAddress}"][data-post-cid="${postCid}"]`).length;
// DOM-based count can be 0 on initial mount (before commit) or when parent isn't in DOM yet (e.g. Virtuoso board feed).
// The current post is always at least 1 when we're displaying it.
return Math.max(domCount, 1);
}, [showUserID, deleted, removed, shortAddress, postCid, postReplyCount]);
})();
const userID = address && Plebbit.getShortAddress({ address }); // shortened to 8 chars for display; users can verify the full user ID via "Copy user ID" in the post menu to guard against spoofing
const userIDBackgroundColor = hashStringToColor(userID);
@@ -239,10 +237,9 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos
displayName.length <= 20 ? (
displayName
) : (
<Tooltip
children={displayName.slice(0, 20) + '(...)'}
content={displayName.length < 1000 ? displayName : displayName.slice(0, 1000) + `... ${t('display_name_too_long')}`}
/>
<Tooltip content={displayName.length < 1000 ? displayName : displayName.slice(0, 1000) + `... ${t('display_name_too_long')}`}>
{displayName.slice(0, 20) + '(...)'}
</Tooltip>
)
) : (
capitalize(t('anonymous'))
@@ -272,19 +269,26 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos
<span className={styles.pendingCid}>{hasFailedState ? capitalize(t('failed')) : capitalize(t('pending'))}</span>
) : (
<Tooltip
children={
<span
title={t('highlight_posts')}
className={styles.userAddress}
onClick={() => handleUserAddressClick(userID, postCid)}
style={{ backgroundColor: userIDBackgroundColor, color: userIDTextColor }}
>
{formatUserIDForDisplay(userID)}
</span>
}
content={`${numberOfPostsByAuthor === 1 ? t('1_post_by_this_id') : t('x_posts_by_this_id', { number: numberOfPostsByAuthor })}`}
showTooltip={isInPostPageView || postReplyCount < 6}
/>
>
<span
title={t('highlight_posts')}
className={styles.userAddress}
role='button'
tabIndex={0}
onClick={() => handleUserAddressClick(userID, postCid)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleUserAddressClick(userID, postCid);
}
}}
style={{ backgroundColor: userIDBackgroundColor, color: userIDTextColor }}
>
{formatUserIDForDisplay(userID)}
</span>
</Tooltip>
)}
){' '}
</>
@@ -304,10 +308,9 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos
{title.length <= 30 ? (
<span className={styles.subject}>{title}</span>
) : (
<Tooltip
children={<span className={styles.subject}>{title.slice(0, 30) + '(...)'}</span>}
content={title.length < 1000 ? title : title.slice(0, 1000) + `... ${t('title_too_long')}`}
/>
<Tooltip content={title.length < 1000 ? title : title.slice(0, 1000) + `... ${t('title_too_long')}`}>
<span className={styles.subject}>{title.slice(0, 30) + '(...)'}</span>
</Tooltip>
)}
</span>
)}
@@ -321,11 +324,15 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos
)}
{isInModQueueView && isOverThreshold ? (
<>
<Tooltip children={<span>{getFormattedDate(timestamp)}</span>} content={getFormattedTimeAgo(timestamp)} /> (
<span className={styles.alert}>{getFormattedTimeAgo(timestamp)}</span>)
<Tooltip content={getFormattedTimeAgo(timestamp)}>
<span>{getFormattedDate(timestamp)}</span>
</Tooltip>{' '}
(<span className={styles.alert}>{getFormattedTimeAgo(timestamp)}</span>)
</>
) : (
<Tooltip children={<span>{getFormattedDate(timestamp)}</span>} content={getFormattedTimeAgo(timestamp)} />
<Tooltip content={getFormattedTimeAgo(timestamp)}>
<span>{getFormattedDate(timestamp)}</span>
</Tooltip>
)}{' '}
{cid ? (
<span className={styles.postNumLink}>
@@ -337,7 +344,19 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos
>
No.
</Link>
<span className={styles.replyToPost} title={t('reply_to_post')} onMouseDown={onReplyModalClick}>
<span
className={styles.replyToPost}
title={t('reply_to_post')}
role='button'
tabIndex={0}
onMouseDown={onReplyModalClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onReplyModalClick();
}
}}
>
{post?.number || '?'}
</span>
</span>
@@ -425,16 +444,18 @@ const ReplyBacklinks = ({ post, quotedByMap, directRepliesByParentCid }: ReplyBa
const replyBacklinks = cid && parentCid && (directReplies.length > 0 || quotedByMap?.get(cid)?.length) && (
<>
{directReplies.map(
(reply: Comment, index: number) =>
reply?.parentCid === cid && reply?.cid && !(reply?.deleted || reply?.removed) && <ReplyQuotePreview key={index} isBacklinkReply={true} backlinkReply={reply} />,
(reply: Comment) =>
reply?.parentCid === cid &&
reply?.cid &&
!(reply?.deleted || reply?.removed) && <ReplyQuotePreview key={reply.cid} isBacklinkReply={true} backlinkReply={reply} />,
)}
{quotedByMap
?.get(cid)
?.map(
(reply: Comment, index: number) =>
(reply: Comment) =>
reply?.parentCid !== cid &&
reply?.cid &&
!(reply?.deleted || reply?.removed) && <ReplyQuotePreview key={`qb-${index}`} isBacklinkReply={true} backlinkReply={reply} />,
!(reply?.deleted || reply?.removed) && <ReplyQuotePreview key={`qb-${reply.cid}`} isBacklinkReply={true} backlinkReply={reply} />,
)}
</>
);
@@ -564,9 +585,9 @@ const PostMobile = ({
const hasFailedState = state === 'failed';
// Filter out deleted replies with no children for both virtuoso and non-virtuoso rendering
const filteredReplies = useMemo(() => repliesForRender.filter((reply) => !(reply.deleted && (reply.replyCount === 0 || !reply.replyCount))), [repliesForRender]);
const filteredReplies = repliesForRender.filter((reply) => !(reply.deleted && (reply.replyCount === 0 || !reply.replyCount)));
const directRepliesByParentCid = useMemo(() => {
const directRepliesByParentCid = (() => {
const map = new Map<string, Comment[]>();
for (const reply of filteredReplies) {
const directParentCid = reply?.parentCid;
@@ -579,7 +600,7 @@ const PostMobile = ({
}
}
return map;
}, [filteredReplies]);
})();
const quotedByMap = useQuotedByMap(filteredReplies);
@@ -629,7 +650,18 @@ const PostMobile = ({
<>
<hr className={styles.unhideButtonHr} />
<span className={styles.mobileUnhideButton}>
<span className='button' onClick={unhide}>
<span
className='button'
role='button'
tabIndex={0}
onClick={unhide}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
unhide();
}
}}
>
Show Hidden Thread
</span>
</span>
@@ -724,8 +756,8 @@ const PostMobile = ({
!isInPendingPostView &&
showReplies &&
!hasMore &&
visibleReplies.map((reply, index) => (
<div key={index} className={styles.replyContainer}>
visibleReplies.map((reply) => (
<div key={reply.cid} className={styles.replyContainer}>
<Reply
postReplyCount={replyCount}
reply={reply}
@@ -742,8 +774,8 @@ const PostMobile = ({
!isInPendingPostView &&
repliesForRender &&
showReplies &&
filteredReplies.slice(-BOARD_REPLIES_PREVIEW_VISIBLE_COUNT).map((reply, index) => (
<div key={index} className={styles.replyContainer}>
filteredReplies.slice(-BOARD_REPLIES_PREVIEW_VISIBLE_COUNT).map((reply) => (
<div key={reply.cid} className={styles.replyContainer}>
<Reply
postReplyCount={replyCount}
reply={reply}