fix(quotes): resolve external quote links across boards (#1064)

This commit is contained in:
Tommaso Casaburi
2026-03-12 17:41:11 +08:00
committed by GitHub
parent ef375e3578
commit fe55e6f332
58 changed files with 1719 additions and 92 deletions
@@ -0,0 +1,149 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import ExternalNumberQuoteLink from '../external-number-quote-link';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
const testState = vi.hoisted(() => ({
resolveExternalQuoteTargetMock: vi.fn(),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (options?.quote && options?.board) {
return `${key}:${options.quote}:${options.board}`;
}
return key;
},
}),
}));
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
useAccount: () => ({ id: 'account-1' }),
}));
vi.mock('@floating-ui/react', () => ({
autoUpdate: () => undefined,
offset: () => ({}),
shift: () => ({}),
size: () => ({}),
useFloating: () => ({
floatingStyles: {},
refs: {
setFloating: () => undefined,
setReference: () => undefined,
},
}),
}));
vi.mock('../../../hooks/use-directories', () => ({
findDirectoryByAddress: () => undefined,
useDirectories: () => [],
}));
vi.mock('../../../hooks/use-is-mobile', () => ({
default: () => false,
}));
vi.mock('../../../lib/utils/external-quote-resolver', () => ({
resolveExternalQuoteTarget: (...args: unknown[]) => testState.resolveExternalQuoteTargetMock(...args),
}));
vi.mock('../../../stores/use-external-quote-status-store', () => ({
default: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
clearStatus: vi.fn(),
setErrorStatus: vi.fn(),
setLoadingStatus: vi.fn(),
}),
}));
vi.mock('../../loading-ellipsis', () => ({
default: ({ string }: { string: string }) => createElement('div', { 'data-testid': 'loading-ellipsis' }, string),
}));
vi.mock('../../../views/post', () => ({
Post: ({ post }: { post?: { cid?: string } }) => createElement('div', { 'data-testid': 'post-preview' }, post?.cid || 'missing-post'),
}));
let container: HTMLDivElement;
let root: Root;
describe('ExternalNumberQuoteLink', () => {
beforeEach(() => {
vi.clearAllMocks();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('uses hash-router hrefs for external numeric quotes', async () => {
await act(async () => {
root.render(
createElement(
MemoryRouter,
{},
createElement(ExternalNumberQuoteLink, {
reference: {
boardIdentifier: 'fit',
kind: 'cross-board',
number: 77,
raw: '>>>/fit/77',
},
}),
),
);
});
const link = container.querySelector('a');
expect(link?.getAttribute('href')).toBe('#/fit');
expect(link?.textContent).toBe('>>>/fit/77');
});
it('shows the resolved post in the hover preview after lazy resolution', async () => {
testState.resolveExternalQuoteTargetMock.mockResolvedValue({
boardPath: 'fit',
cid: 'cid-77',
comment: { cid: 'cid-77' },
isUnavailable: false,
route: '/fit/thread/cid-77',
subplebbitAddress: 'fit',
});
await act(async () => {
root.render(
createElement(
MemoryRouter,
{},
createElement(ExternalNumberQuoteLink, {
reference: {
boardIdentifier: 'fit',
kind: 'cross-board',
number: 77,
raw: '>>>/fit/77',
},
}),
),
);
});
await act(async () => {
container.querySelector('a')?.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(document.body.querySelector('[data-testid="post-preview"]')?.textContent).toBe('cid-77');
});
});
@@ -128,6 +128,10 @@ vi.mock('../../reply-quote-preview', () => ({
),
}));
vi.mock('../external-number-quote-link', () => ({
default: ({ reference }: { reference: { raw: string } }) => createElement('a', { 'data-testid': 'external-number-quote-link', href: '#' }, reference.raw),
}));
let container: HTMLDivElement;
let root: Root;
@@ -204,6 +208,16 @@ describe('Markdown', () => {
expect(quotePreview?.textContent).toBe('comment-42');
});
it('renders lazy same-board and cross-board number quotes when the cid is not cached', async () => {
await renderMarkdown({
content: '>>42 >>>/fit/77',
subplebbitAddress: 'music-posting.eth',
});
const lazyLinks = Array.from(container.querySelectorAll('[data-testid="external-number-quote-link"]'));
expect(lazyLinks.map((node) => node.textContent)).toEqual(['>>42', '>>>/fit/77']);
});
it('toggles inline media embeds for embeddable links outside catalog view', async () => {
testState.mediaInfoByHref = {
'https://cdn.example/image.png': {
@@ -0,0 +1,296 @@
import type { MouseEvent } from 'react';
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useAccount } from '@bitsocialnet/bitsocial-react-hooks';
import { useDirectories } from '../../hooks/use-directories';
import useIsMobile from '../../hooks/use-is-mobile';
import { resolveExternalQuoteTarget } from '../../lib/utils/external-quote-resolver';
import { ExternalQuoteReference, getExternalQuoteBoardLabel, getExternalQuoteStatusMessage } from '../../lib/utils/external-quote-utils';
import useExternalQuoteStatusStore from '../../stores/use-external-quote-status-store';
import LoadingEllipsis from '../loading-ellipsis';
import postStyles from '../../views/post/post.module.css';
import { Post } from '../../views/post';
import styles from './markdown.module.css';
interface ExternalNumberQuoteLinkProps {
reference: ExternalQuoteReference;
}
type ResolvedExternalQuoteTarget = NonNullable<Awaited<ReturnType<typeof resolveExternalQuoteTarget>>>;
type PreviewState =
| {
kind: 'idle';
}
| {
kind: 'loading';
message: string;
}
| {
kind: 'error';
message: string;
}
| {
kind: 'resolved';
target: ResolvedExternalQuoteTarget;
};
type PreviewPosition = {
left: number;
top: number;
};
const ExternalNumberQuoteLink = ({ reference }: ExternalNumberQuoteLinkProps) => {
const { t } = useTranslation();
const account = useAccount();
const directories = useDirectories();
const isMobile = useIsMobile();
const navigate = useNavigate();
const setErrorStatus = useExternalQuoteStatusStore((state) => state.setErrorStatus);
const [isResolving, setIsResolving] = useState(false);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [previewState, setPreviewState] = useState<PreviewState>({ kind: 'idle' });
const [previewPosition, setPreviewPosition] = useState<PreviewPosition | null>(null);
const anchorRef = useRef<HTMLAnchorElement | null>(null);
const previewRef = useRef<HTMLDivElement | null>(null);
const resolvedTargetRef = useRef<ResolvedExternalQuoteTarget | null | undefined>(undefined);
const resolutionPromiseRef = useRef<Promise<ResolvedExternalQuoteTarget | null> | null>(null);
const previewOpenRef = useRef(false);
const latestStatusMessageRef = useRef('');
const boardLabel = getExternalQuoteBoardLabel(reference, directories);
const updatePreviewPosition = (anchor: HTMLElement | null) => {
if (!anchor || isMobile) {
return;
}
const padding = 10;
const rect = anchor.getBoundingClientRect();
const previewWidth = previewRef.current?.offsetWidth ?? Math.min(360, window.innerWidth - padding * 2);
const previewHeight = previewRef.current?.offsetHeight ?? 0;
const shouldPlaceLeft = rect.right + previewWidth + padding > window.innerWidth && rect.left - previewWidth - padding >= padding;
const left = shouldPlaceLeft ? Math.max(padding, rect.left - previewWidth - 8) : Math.min(window.innerWidth - previewWidth - padding, rect.right + 8);
const top = Math.min(Math.max(padding, rect.top - 8), Math.max(padding, window.innerHeight - previewHeight - padding));
setPreviewPosition({ left, top });
};
useEffect(() => {
if (!isPreviewOpen || isMobile) {
return;
}
const reposition = () => updatePreviewPosition(anchorRef.current);
reposition();
window.addEventListener('resize', reposition);
window.addEventListener('scroll', reposition, true);
return () => {
window.removeEventListener('resize', reposition);
window.removeEventListener('scroll', reposition, true);
};
}, [isMobile, isPreviewOpen, previewState.kind]);
const getInitialSearchMessage = () =>
getExternalQuoteStatusMessage(t, {
boardLabel,
phase: 'search-board',
quoteDisplay: reference.raw,
});
const getUnavailableMessage = () => t('external_quote_unavailable');
const getCannotResolveMessage = () =>
t('external_quote_cannot_resolve', {
board: boardLabel,
interpolation: { escapeValue: false },
quote: reference.raw,
});
const resolveTarget = async ({ updatePreview }: { updatePreview: boolean }) => {
if (!account?.id) {
throw new Error('Missing active account while resolving external quote');
}
if (resolvedTargetRef.current !== undefined) {
return resolvedTargetRef.current;
}
if (!resolutionPromiseRef.current) {
resolutionPromiseRef.current = resolveExternalQuoteTarget({
account,
directories,
onStatus: (status) => {
const message = getExternalQuoteStatusMessage(t, status);
latestStatusMessageRef.current = message;
if (updatePreview && previewOpenRef.current) {
setPreviewState({ kind: 'loading', message });
}
},
reference,
}).then((target) => {
resolvedTargetRef.current = target;
return target;
});
}
try {
const target = await resolutionPromiseRef.current;
resolutionPromiseRef.current = null;
return target;
} catch (error) {
resolutionPromiseRef.current = null;
throw error;
}
};
const handleMouseEnter = async (e: MouseEvent<HTMLAnchorElement>) => {
if (isMobile) {
return;
}
anchorRef.current = e.currentTarget;
previewOpenRef.current = true;
setIsPreviewOpen(true);
updatePreviewPosition(e.currentTarget);
if (resolvedTargetRef.current === null) {
setPreviewState({ kind: 'error', message: getCannotResolveMessage() });
return;
}
if (resolvedTargetRef.current?.comment) {
setPreviewState({ kind: 'resolved', target: resolvedTargetRef.current });
return;
}
setPreviewState({ kind: 'loading', message: latestStatusMessageRef.current || getInitialSearchMessage() });
let target: ResolvedExternalQuoteTarget | null;
try {
target = await resolveTarget({ updatePreview: true });
} catch {
if (previewOpenRef.current) {
setPreviewState({ kind: 'error', message: t('external_quote_resolution_unavailable') });
}
return;
}
if (!previewOpenRef.current) {
return;
}
if (!target) {
setPreviewState({ kind: 'error', message: getCannotResolveMessage() });
return;
}
if (target.isUnavailable || !target.comment) {
setPreviewState({ kind: 'error', message: getUnavailableMessage() });
return;
}
setPreviewState({ kind: 'resolved', target });
};
const handleMouseLeave = () => {
previewOpenRef.current = false;
setIsPreviewOpen(false);
setPreviewPosition(null);
};
const handleClick = async (e: MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
if (isResolving) {
return;
}
if (!account?.id) {
setErrorStatus(t('external_quote_resolution_unavailable'));
return;
}
setIsResolving(true);
const finishWithError = (message: string) => {
setErrorStatus(message);
setIsResolving(false);
};
let target: ResolvedExternalQuoteTarget | null;
try {
target = await resolveTarget({ updatePreview: false });
} catch {
finishWithError(getCannotResolveMessage());
return;
}
if (!target) {
finishWithError(getCannotResolveMessage());
return;
}
if (target.isUnavailable) {
finishWithError(getUnavailableMessage());
return;
}
setIsResolving(false);
navigate(target.route);
};
const previewContent =
previewState.kind === 'idle' ? null : previewState.kind === 'resolved' ? (
previewState.target.comment ? (
<Post post={previewState.target.comment} showReplies={false} />
) : (
<div className={`${styles.externalQuotePreviewState} ${styles.externalQuotePreviewError}`}>{getUnavailableMessage()}</div>
)
) : previewState.kind === 'loading' ? (
<div className={styles.externalQuotePreviewState}>
<LoadingEllipsis string={previewState.message} />
</div>
) : (
<div className={`${styles.externalQuotePreviewState} ${styles.externalQuotePreviewError}`}>{previewState.message}</div>
);
return (
<>
<a
aria-busy={isResolving || undefined}
className={isResolving ? styles.inlineQuoteLinkResolving : undefined}
href={`#/${boardLabel}`}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
ref={anchorRef}
>
{reference.raw}
</a>
{!isMobile &&
isPreviewOpen &&
previewPosition &&
previewContent &&
createPortal(
<div
className={postStyles.replyQuotePreview}
data-thread-scroll-preview='true'
ref={previewRef}
style={{ left: previewPosition.left, position: 'fixed', top: previewPosition.top, zIndex: 1000 }}
>
{previewContent}
</div>,
document.body,
)}
</>
);
};
export default ExternalNumberQuoteLink;
+16 -1
View File
@@ -18,6 +18,21 @@
text-decoration: var(--post-quotelink-text-decoration-hover);
}
.inlineQuoteLinkResolving {
cursor: progress;
opacity: 0.75;
pointer-events: none;
}
.externalQuotePreviewState {
min-width: 220px;
padding: 8px 12px;
}
.externalQuotePreviewError {
color: #a40000;
}
.embedButton {
color: var(--button-desktop-text-color);
text-transform: capitalize;
@@ -46,4 +61,4 @@
.inline {
display: inline;
}
}
+46 -5
View File
@@ -10,11 +10,13 @@ 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 { CROSSBOARD_NUMBER_QUOTE_TOKEN_REGEX, type ExternalQuoteReference } from '../../lib/utils/external-quote-utils';
import { isUnavailableQuoteTarget } from '../../lib/utils/quote-link-utils';
import usePostNumberStore from '../../stores/use-post-number-store';
import useSubplebbitsPagesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/subplebbits-pages';
import { useComment } from '@bitsocialnet/bitsocial-react-hooks';
import ReplyQuotePreview from '../reply-quote-preview';
import ExternalNumberQuoteLink from './external-number-quote-link';
const safeParseUrl = (href: string): URL | null => {
try {
@@ -142,6 +144,7 @@ type Token =
| { type: 'text'; value: string }
| { type: 'url'; href: string }
| { type: 'quoteLink'; number: number }
| { type: 'crossBoardNumberQuoteLink'; reference: ExternalQuoteReference }
| { type: 'crossBoardLink'; display: string; route: string }
| { type: 'spoiler'; tokens: Token[] };
@@ -150,7 +153,10 @@ const CROSSBOARD_REGEX = />>>\/((?:[a-zA-Z0-9]{1,10}\/(?:[a-zA-Z0-9]{46})?|[a-zA
const QUOTE_LINK_REGEX = /(?<![>/\w])>>(\d+)(?![\d/])/;
const URL_REGEX = /https?:\/\/[^\s<\[\]]*[^\s<\[\].,;:!?\"'\)\]>]/;
const COMBINED_REGEX = new RegExp(`(${SPOILER_REGEX.source})|(${CROSSBOARD_REGEX.source})|(${QUOTE_LINK_REGEX.source})|(${URL_REGEX.source})`, 'g');
const COMBINED_REGEX = new RegExp(
`(${SPOILER_REGEX.source})|(${CROSSBOARD_NUMBER_QUOTE_TOKEN_REGEX.source})|(${CROSSBOARD_REGEX.source})|(${QUOTE_LINK_REGEX.source})|(${URL_REGEX.source})`,
'g',
);
function getCrossboardRoute(fullPattern: string): string | null {
const pathPart = fullPattern.replace(/^>>>\//, '').replace(/[.,:;!?]+$/, '');
@@ -190,7 +196,23 @@ function tokenize(text: string): Token[] {
const innerContent = match[2];
tokens.push({ type: 'spoiler', tokens: tokenize(innerContent) });
} else if (match[3] !== undefined) {
const pathPart = match[4];
const boardIdentifier = match[4];
const number = parseInt(match[5], 10);
if (boardIdentifier && !Number.isNaN(number)) {
tokens.push({
type: 'crossBoardNumberQuoteLink',
reference: {
boardIdentifier,
kind: 'cross-board',
number,
raw: `>>>/${boardIdentifier}/${number}`,
},
});
} else {
tokens.push({ type: 'text', value: fullMatch });
}
} else if (match[6] !== undefined) {
const pathPart = match[7];
const fullPattern = `>>>/${pathPart}`;
const route = getCrossboardRoute(fullPattern);
if (route) {
@@ -198,10 +220,10 @@ function tokenize(text: string): Token[] {
} else {
tokens.push({ type: 'text', value: fullMatch });
}
} else if (match[5] !== undefined) {
const number = parseInt(match[6], 10);
} else if (match[8] !== undefined) {
const number = parseInt(match[9], 10);
tokens.push({ type: 'quoteLink', number });
} else if (match[7] !== undefined) {
} else if (match[10] !== undefined) {
tokens.push({ type: 'url', href: fullMatch });
}
@@ -247,6 +269,12 @@ function renderTokens(tokens: Token[], context: RenderContext): React.ReactNode[
<NumberQuoteLink number={token.number} threadPostCid={postCid} subplebbitAddress={subplebbitAddress} />
</span>
);
case 'crossBoardNumberQuoteLink':
return (
<span key={i} className={styles.inlineQuoteLink}>
<ExternalNumberQuoteLink reference={token.reference} />
</span>
);
case 'crossBoardLink':
return (
<Link key={i} to={token.route}>
@@ -283,6 +311,19 @@ const NumberQuoteLink = ({ number, threadPostCid, subplebbitAddress }: { number:
);
}
if (!cid && subplebbitAddress) {
return (
<ExternalNumberQuoteLink
reference={{
kind: 'same-board',
number,
raw: `>>${number}`,
subplebbitAddress,
}}
/>
);
}
return <ReplyQuotePreview isQuotelinkReply={true} quotelinkReply={comment} quotelinkNumber={number} isOP={isOP} showTrailingBreak={false} />;
};