From 65731ceb3a6cb03445cc3898f1a688994161497a Mon Sep 17 00:00:00 2001 From: plebeius Date: Fri, 6 Mar 2026 14:55:40 +0800 Subject: [PATCH] fix(markdown): handle unavailable reply quote links --- .../comment-content/comment-content.tsx | 5 +- src/components/markdown/markdown.tsx | 7 +- .../reply-quote-preview.tsx | 124 +++++++++++++----- .../utils/__tests__/quote-link-utils.test.ts | 45 +++++++ src/lib/utils/quote-link-utils.ts | 17 +++ src/views/post/post.module.css | 7 + 6 files changed, 172 insertions(+), 33 deletions(-) create mode 100644 src/lib/utils/__tests__/quote-link-utils.test.ts create mode 100644 src/lib/utils/quote-link-utils.ts diff --git a/src/components/comment-content/comment-content.tsx b/src/components/comment-content/comment-content.tsx index d8c00dd5..ac412b59 100644 --- a/src/components/comment-content/comment-content.tsx +++ b/src/components/comment-content/comment-content.tsx @@ -17,12 +17,15 @@ import styles from '../../views/post/post.module.css'; import capitalize from 'lodash/capitalize'; const QuotedCidLink = ({ cid, postCid }: { cid: string; postCid: string }) => { + const quotedNumber = usePostNumberStore((state) => state.cidToNumber[cid]); const commentFromStore = useSubplebbitsPagesStore((state) => state.comments[cid]); const commentFromHook = useComment({ commentCid: cid, onlyIfCached: true }); // Prefer hook version to ensure 'number' property is populated for deeper nested replies in Virtuoso const quotedComment = commentFromHook?.number !== undefined ? commentFromHook : commentFromStore; const isOP = cid === postCid; - return ; + const isUnavailable = !quotedComment || quotedComment.deleted || quotedComment.removed; + + return ; }; const useScopedCidToNumber = (cids: string[]) => { diff --git a/src/components/markdown/markdown.tsx b/src/components/markdown/markdown.tsx index 0beac50a..20f551f4 100644 --- a/src/components/markdown/markdown.tsx +++ b/src/components/markdown/markdown.tsx @@ -10,6 +10,7 @@ import styles from './markdown.module.css'; import { Link, useLocation, useParams } from 'react-router-dom'; import { canEmbed } from '../embed'; import { is5chanLink, transform5chanLinkToInternal, isValidCrossboardPattern } from '../../lib/utils/url-utils'; +import { isUnavailableQuoteTarget } from '../../lib/utils/quote-link-utils'; import usePostNumberStore from '../../stores/use-post-number-store'; import useSubplebbitsPagesStore from '@bitsocialhq/bitsocial-react-hooks/dist/stores/subplebbits-pages'; import { useComment } from '@bitsocialhq/bitsocial-react-hooks'; @@ -276,8 +277,10 @@ const NumberQuoteLink = ({ number, threadPostCid, subplebbitAddress }: { number: const comment = commentFromHook?.number !== undefined ? commentFromHook : commentFromStore; const isOP = Boolean(threadPostCid && cid === threadPostCid); - if (!comment) { - return {`>>${number}`}; + if (!comment || isUnavailableQuoteTarget(comment)) { + return ( + + ); } return ; diff --git a/src/components/reply-quote-preview/reply-quote-preview.tsx b/src/components/reply-quote-preview/reply-quote-preview.tsx index 534d2a39..5d7c4f96 100644 --- a/src/components/reply-quote-preview/reply-quote-preview.tsx +++ b/src/components/reply-quote-preview/reply-quote-preview.tsx @@ -5,6 +5,7 @@ import { Comment, useAccount } from '@bitsocialhq/bitsocial-react-hooks'; import { useFloating, offset, shift, size, autoUpdate, Placement } from '@floating-ui/react'; import { useDirectories } from '../../hooks/use-directories'; import { getBoardPath } from '../../lib/utils/route-utils'; +import { formatQuoteNumber, isUnavailableQuoteTarget, shouldShowFloatingQuotePreview } from '../../lib/utils/quote-link-utils'; import useIsMobile from '../../hooks/use-is-mobile'; import styles from '../../views/post/post.module.css'; import { Post } from '../../views/post'; @@ -14,6 +15,8 @@ interface ReplyQuotePreviewProps { backlinkReply?: Comment; isQuotelinkReply?: boolean; quotelinkReply?: Comment; + quotelinkNumber?: number; + isQuotelinkUnavailable?: boolean; isOP?: boolean; showTrailingBreak?: boolean; } @@ -80,7 +83,16 @@ const scrollToReplyOnPage = (cid: string) => { return true; }; -const DesktopQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, isQuotelinkReply, isOP, showTrailingBreak = true }: ReplyQuotePreviewProps) => { +const DesktopQuotePreview = ({ + backlinkReply, + quotelinkReply, + quotelinkNumber, + isBacklinkReply, + isQuotelinkReply, + isQuotelinkUnavailable, + isOP, + showTrailingBreak = true, +}: ReplyQuotePreviewProps) => { const [hoveredCid, setHoveredCid] = useState(null); const [outOfViewCid, setOutOfViewCid] = useState(null); const [placement, setPlacement] = useState('right'); @@ -194,26 +206,45 @@ const DesktopQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, i const account = useAccount(); + const resolvedQuotelinkNumber = quotelinkReply?.number ?? quotelinkNumber; + const resolvedQuotelinkCid = quotelinkReply?.cid; + const resolvedQuotelinkSubplebbitAddress = quotelinkReply?.subplebbitAddress; + const quotelinkUnavailable = Boolean(isQuotelinkUnavailable || isUnavailableQuoteTarget(quotelinkReply)); + const quotelinkClassName = quotelinkUnavailable ? `${styles.quoteLink} ${styles.quoteLinkUnavailable}` : styles.quoteLink; const quotelinkBoardPath = quotelinkReply?.subplebbitAddress ? getBoardPath(quotelinkReply.subplebbitAddress, directories) : undefined; const quotelinkRoute = quotelinkReply?.cid ? (quotelinkBoardPath ? `/${quotelinkBoardPath}/thread/${quotelinkReply.cid}` : `/thread/${quotelinkReply.cid}`) : '#'; + const shouldShowQuotelinkPreview = shouldShowFloatingQuotePreview({ + hoveredCid, + outOfViewCid, + quoteCid: resolvedQuotelinkCid, + isUnavailable: quotelinkUnavailable, + }); + const quotelinkLabel = ( + <> + {formatQuoteNumber(resolvedQuotelinkNumber)} + {isOP && ' (OP)'} + {quotelinkReply?.author?.address === account?.author?.address && ' (You)'} + + ); const replyQuotelink = ( <> - handleMouseOver(quotelinkReply?.cid)} - onMouseLeave={() => handleMouseLeave(quotelinkReply?.cid)} - onClick={(e) => handleClick(e, quotelinkReply?.cid, quotelinkReply?.subplebbitAddress, !!isOP)} - > - {`>>${quotelinkReply?.number ?? '?'}`} - {isOP && ' (OP)'} - {quotelinkReply?.author?.address === account?.author?.address && ' (You)'} - + {quotelinkUnavailable ? ( + {quotelinkLabel} + ) : ( + handleMouseOver(resolvedQuotelinkCid)} + onMouseLeave={() => handleMouseLeave(resolvedQuotelinkCid)} + onClick={(e) => handleClick(e, resolvedQuotelinkCid, resolvedQuotelinkSubplebbitAddress, !!isOP)} + > + {quotelinkLabel} + + )} {showTrailingBreak &&
} - {hoveredCid === quotelinkReply?.cid && - outOfViewCid === quotelinkReply?.cid && + {shouldShowQuotelinkPreview && createPortal(
@@ -226,7 +257,16 @@ const DesktopQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, i return isBacklinkReply ? replyBacklink : isQuotelinkReply && replyQuotelink; }; -const MobileQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, isQuotelinkReply, isOP, showTrailingBreak = true }: ReplyQuotePreviewProps) => { +const MobileQuotePreview = ({ + backlinkReply, + quotelinkReply, + quotelinkNumber, + isBacklinkReply, + isQuotelinkReply, + isQuotelinkUnavailable, + isOP, + showTrailingBreak = true, +}: ReplyQuotePreviewProps) => { const [hoveredCid, setHoveredCid] = useState(null); const [outOfViewCid, setOutOfViewCid] = useState(null); const directories = useDirectories(); @@ -320,37 +360,48 @@ const MobileQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, is ); const account = useAccount(); + const resolvedQuotelinkNumber = quotelinkReply?.number ?? quotelinkNumber; + const resolvedQuotelinkCid = quotelinkReply?.cid; + const resolvedQuotelinkSubplebbitAddress = quotelinkReply?.subplebbitAddress; + const quotelinkUnavailable = Boolean(isQuotelinkUnavailable || isUnavailableQuoteTarget(quotelinkReply)); + const quotelinkClassName = quotelinkUnavailable ? `${styles.quoteLink} ${styles.quoteLinkUnavailable}` : styles.quoteLink; + const shouldShowQuotelinkPreview = shouldShowFloatingQuotePreview({ + hoveredCid, + outOfViewCid, + quoteCid: resolvedQuotelinkCid, + isUnavailable: quotelinkUnavailable, + }); const replyQuotelink = ( <> handleMouseOver(quotelinkReply?.cid)} - onMouseLeave={() => handleMouseLeave(quotelinkReply?.cid)} + ref={quotelinkUnavailable ? undefined : refs.setReference} + className={quotelinkClassName} + onMouseOver={quotelinkUnavailable ? undefined : () => handleMouseOver(resolvedQuotelinkCid)} + onMouseLeave={quotelinkUnavailable ? undefined : () => handleMouseLeave(resolvedQuotelinkCid)} > - {`>>${quotelinkReply?.number ?? '?'}`} + {formatQuoteNumber(resolvedQuotelinkNumber)} {isOP && ' (OP)'} {quotelinkReply?.author?.address === account?.author?.address && ' (You)'} - {quotelinkReply?.number && + {!quotelinkUnavailable && + resolvedQuotelinkNumber && (() => { - const quotelinkBoardPath = quotelinkReply?.subplebbitAddress ? getBoardPath(quotelinkReply.subplebbitAddress, directories) : undefined; - const quotelinkRoute = quotelinkReply?.cid + const quotelinkBoardPath = resolvedQuotelinkSubplebbitAddress ? getBoardPath(resolvedQuotelinkSubplebbitAddress, directories) : undefined; + const quotelinkRoute = resolvedQuotelinkCid ? quotelinkBoardPath - ? `/${quotelinkBoardPath}/thread/${quotelinkReply.cid}` - : `/thread/${quotelinkReply.cid}` + ? `/${quotelinkBoardPath}/thread/${resolvedQuotelinkCid}` + : `/thread/${resolvedQuotelinkCid}` : '#'; return ( - handleClick(e, quotelinkReply?.cid, quotelinkReply?.subplebbitAddress, !!isOP)}> + handleClick(e, resolvedQuotelinkCid, resolvedQuotelinkSubplebbitAddress, !!isOP)}> {' '} # ); })()} {showTrailingBreak &&
} - {hoveredCid === quotelinkReply?.cid && - outOfViewCid === quotelinkReply?.cid && + {shouldShowQuotelinkPreview && createPortal(
@@ -363,15 +414,26 @@ const MobileQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, is return isBacklinkReply ? replyBacklink : isQuotelinkReply && replyQuotelink; }; -const ReplyQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, isQuotelinkReply, isOP, showTrailingBreak }: ReplyQuotePreviewProps) => { +const ReplyQuotePreview = ({ + backlinkReply, + quotelinkReply, + quotelinkNumber, + isBacklinkReply, + isQuotelinkReply, + isQuotelinkUnavailable, + isOP, + showTrailingBreak, +}: ReplyQuotePreviewProps) => { const isMobile = useIsMobile(); return isMobile ? ( @@ -379,8 +441,10 @@ const ReplyQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, isQ diff --git a/src/lib/utils/__tests__/quote-link-utils.test.ts b/src/lib/utils/__tests__/quote-link-utils.test.ts new file mode 100644 index 00000000..1642f81f --- /dev/null +++ b/src/lib/utils/__tests__/quote-link-utils.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { formatQuoteNumber, isUnavailableQuoteTarget, shouldShowFloatingQuotePreview } from '../quote-link-utils'; + +describe('quote-link-utils', () => { + it('formats quote numbers with the expected prefix', () => { + expect(formatQuoteNumber(123)).toBe('>>123'); + expect(formatQuoteNumber()).toBe('>>?'); + }); + + it('marks deleted and removed comments as unavailable quote targets', () => { + expect(isUnavailableQuoteTarget(undefined)).toBe(false); + expect(isUnavailableQuoteTarget({ deleted: true, removed: false })).toBe(true); + expect(isUnavailableQuoteTarget({ deleted: false, removed: true })).toBe(true); + expect(isUnavailableQuoteTarget({ deleted: false, removed: false })).toBe(false); + }); + + it('only shows floating previews for available targets that are hovered out of view', () => { + expect( + shouldShowFloatingQuotePreview({ + hoveredCid: 'cid-1', + outOfViewCid: 'cid-1', + quoteCid: 'cid-1', + isUnavailable: false, + }), + ).toBe(true); + + expect( + shouldShowFloatingQuotePreview({ + hoveredCid: 'cid-1', + outOfViewCid: 'cid-1', + quoteCid: 'cid-1', + isUnavailable: true, + }), + ).toBe(false); + + expect( + shouldShowFloatingQuotePreview({ + hoveredCid: 'cid-1', + outOfViewCid: 'cid-2', + quoteCid: 'cid-1', + isUnavailable: false, + }), + ).toBe(false); + }); +}); diff --git a/src/lib/utils/quote-link-utils.ts b/src/lib/utils/quote-link-utils.ts new file mode 100644 index 00000000..83a4593c --- /dev/null +++ b/src/lib/utils/quote-link-utils.ts @@ -0,0 +1,17 @@ +import type { Comment } from '@bitsocialhq/bitsocial-react-hooks'; + +export const formatQuoteNumber = (number?: number) => `>>${number ?? '?'}`; + +export const isUnavailableQuoteTarget = (comment?: Partial> | null) => Boolean(comment?.deleted || comment?.removed); + +export const shouldShowFloatingQuotePreview = ({ + hoveredCid, + outOfViewCid, + quoteCid, + isUnavailable, +}: { + hoveredCid: string | null; + outOfViewCid: string | null; + quoteCid?: string; + isUnavailable?: boolean; +}) => Boolean(quoteCid && !isUnavailable && hoveredCid === quoteCid && outOfViewCid === quoteCid); diff --git a/src/views/post/post.module.css b/src/views/post/post.module.css index 1a9b8bc0..0298b931 100644 --- a/src/views/post/post.module.css +++ b/src/views/post/post.module.css @@ -490,6 +490,13 @@ cursor: pointer; } +.quoteLinkUnavailable, +.quoteLinkUnavailable:hover { + color: var(--post-quotelink-text-color); + text-decoration: line-through; + cursor: default; +} + .replyMobile { padding-top: 7px; content-visibility: auto;