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
+81 -1
View File
@@ -11,13 +11,23 @@ import usePublishReplyStore from '../../stores/use-publish-reply-store';
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(() => ({
account: { id: 'account-1' } as Record<string, any>,
abandonPublishMock: vi.fn(async () => undefined),
directories: [] as Array<Record<string, unknown>>,
index: 7,
lastPublishOptions: undefined as Record<string, any> | undefined,
publishCommentMock: vi.fn(),
resolveExternalQuoteTargetMock: vi.fn(),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => (options ? `${key}:${JSON.stringify(options)}` : key),
}),
}));
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
useAccount: () => testState.account,
usePublishComment: (options: Record<string, any>) => {
testState.lastPublishOptions = options;
return {
@@ -28,6 +38,14 @@ vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
},
}));
vi.mock('../../hooks/use-directories', () => ({
useDirectories: () => testState.directories,
}));
vi.mock('../../lib/utils/external-quote-resolver', () => ({
resolveExternalQuoteTarget: (...args: any[]) => testState.resolveExternalQuoteTargetMock(...args),
}));
let container: HTMLDivElement;
let latestValue: ReturnType<typeof usePublishReply>;
let root: Root;
@@ -46,6 +64,8 @@ const renderHook = () => {
describe('usePublishReply', () => {
beforeEach(() => {
vi.clearAllMocks();
testState.account = { id: 'account-1' };
testState.directories = [];
testState.index = 7;
testState.lastPublishOptions = undefined;
useChallengesStore.setState({ challenges: [] });
@@ -81,7 +101,7 @@ describe('usePublishReply', () => {
});
expect(latestValue.replyIndex).toBe(7);
expect(latestValue.publishReply).toBe(testState.publishCommentMock);
expect(typeof latestValue.publishReply).toBe('function');
expect(testState.lastPublishOptions).toMatchObject({
author: { displayName: 'Bob' },
content: 'Replying to >>12',
@@ -94,6 +114,66 @@ describe('usePublishReply', () => {
});
});
it('resolves same-board external quote references before triggering publish', async () => {
testState.resolveExternalQuoteTargetMock.mockResolvedValue({
cid: 'external-cid',
route: '/music/thread/external-cid',
subplebbitAddress: 'music.eth',
});
await act(async () => {
latestValue.setPublishReplyOptions({
content: 'Replying to >>44',
} as never);
});
await act(async () => {
await latestValue.publishReply();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(testState.resolveExternalQuoteTargetMock).toHaveBeenCalledTimes(1);
expect(testState.lastPublishOptions?.quotedCids).toEqual(['external-cid']);
expect(testState.publishCommentMock).toHaveBeenCalledTimes(1);
});
it('does not resolve cross-board numeric quotes before publish', async () => {
await act(async () => {
latestValue.setPublishReplyOptions({
content: 'Replying to >>>/fit/44',
} as never);
});
await act(async () => {
await latestValue.publishReply();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(testState.resolveExternalQuoteTargetMock).not.toHaveBeenCalled();
expect(testState.lastPublishOptions?.quotedCids).toBeUndefined();
expect(testState.publishCommentMock).toHaveBeenCalledTimes(1);
});
it('blocks publish when a same-board external quote cannot be resolved', async () => {
testState.resolveExternalQuoteTargetMock.mockResolvedValue(null);
await act(async () => {
latestValue.setPublishReplyOptions({
content: 'Replying to >>44',
} as never);
});
await act(async () => {
await latestValue.publishReply();
await Promise.resolve();
});
expect(latestValue.publishReplyError).toContain('external_quote_publish_missing');
expect(testState.publishCommentMock).not.toHaveBeenCalled();
});
it('queues reply challenges and clears the scoped reply store on reset', async () => {
await act(async () => {
latestValue.setPublishReplyOptions({
+116 -4
View File
@@ -1,12 +1,19 @@
import { useCallback, useMemo, useRef } from 'react';
import { Comment, usePublishComment } from '@bitsocialnet/bitsocial-react-hooks';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Comment, useAccount, usePublishComment } from '@bitsocialnet/bitsocial-react-hooks';
import { useDirectories } from './use-directories';
import usePublishReplyStore from '../stores/use-publish-reply-store';
import usePostNumberStore from '../stores/use-post-number-store';
import { getQuotedCidsFromContent, mergeQuotedCids } from '../lib/utils/reply-quote-utils';
import { extractUnresolvedExternalQuoteReferences, getExternalQuoteStatusMessage } from '../lib/utils/external-quote-utils';
import { resolveExternalQuoteTarget } from '../lib/utils/external-quote-resolver';
import useChallengesStore from '../stores/use-challenges-store';
const usePublishReply = ({ cid, subplebbitAddress, postCid }: { cid: string; subplebbitAddress: string; postCid?: string }) => {
const { t } = useTranslation();
const parentCid = cid;
const account = useAccount();
const directories = useDirectories();
const { author, content, link, spoiler, publishCommentOptions } = usePublishReplyStore((state) => ({
author: state.author[parentCid],
@@ -20,6 +27,12 @@ const usePublishReply = ({ cid, subplebbitAddress, postCid }: { cid: string; sub
const resetPublishReplyStore = usePublishReplyStore((state) => state.resetPublishReplyStore);
const addChallenge = useChallengesStore((state) => state.addChallenge);
const abandonPublishRef = useRef<(() => Promise<void>) | undefined>();
const startedPublishRequestIdRef = useRef(0);
const [resolvedExternalQuotedCids, setResolvedExternalQuotedCids] = useState<string[] | undefined>();
const [pendingPublishRequestId, setPendingPublishRequestId] = useState(0);
const [isResolvingExternalQuotes, setIsResolvingExternalQuotes] = useState(false);
const [publishReplyError, setPublishReplyError] = useState<string | null>(null);
const [publishReplyStateMessage, setPublishReplyStateMessage] = useState<string | null>(null);
const abandonCurrentPublish = useCallback(async () => {
await abandonPublishRef.current?.();
}, []);
@@ -63,8 +76,35 @@ const usePublishReply = ({ cid, subplebbitAddress, postCid }: { cid: string; sub
const scopedNumberToCid = usePostNumberStore((state) => (subplebbitAddress ? state.numberToCid[subplebbitAddress] : undefined));
const quotedCids = useMemo(() => getQuotedCidsFromContent(content, scopedNumberToCid), [content, scopedNumberToCid]);
const unresolvedExternalQuoteReferences = useMemo(
() =>
extractUnresolvedExternalQuoteReferences({
content,
scopedNumberToCid,
subplebbitAddress,
}),
[content, scopedNumberToCid, subplebbitAddress],
);
const publishResolvableQuoteReferences = useMemo(
() => unresolvedExternalQuoteReferences.filter((reference) => reference.kind === 'same-board'),
[unresolvedExternalQuoteReferences],
);
const mergedPublishOptions = useMemo(() => mergeQuotedCids(publishCommentOptions, quotedCids), [publishCommentOptions, quotedCids]);
const mergedQuotedCids = useMemo(() => {
const merged = new Set<string>();
for (const cid of quotedCids ?? []) {
merged.add(cid);
}
for (const cid of resolvedExternalQuotedCids ?? []) {
merged.add(cid);
}
return merged.size > 0 ? [...merged] : undefined;
}, [quotedCids, resolvedExternalQuotedCids]);
const mergedPublishOptions = useMemo(() => mergeQuotedCids(publishCommentOptions, mergedQuotedCids), [publishCommentOptions, mergedQuotedCids]);
const publishOptionsWithAbandon = useMemo(
() => ({
...mergedPublishOptions,
@@ -78,11 +118,83 @@ const usePublishReply = ({ cid, subplebbitAddress, postCid }: { cid: string; sub
const { index, publishComment, abandonPublish } = usePublishComment(publishOptionsWithAbandon);
abandonPublishRef.current = abandonPublish;
useEffect(() => {
setResolvedExternalQuotedCids(undefined);
setPublishReplyError(null);
setPublishReplyStateMessage(null);
setIsResolvingExternalQuotes(false);
}, [content, subplebbitAddress]);
useEffect(() => {
if (pendingPublishRequestId === 0 || pendingPublishRequestId === startedPublishRequestIdRef.current) {
return;
}
startedPublishRequestIdRef.current = pendingPublishRequestId;
publishComment();
}, [pendingPublishRequestId, publishComment]);
const publishReply = useCallback(async () => {
setPublishReplyError(null);
if (publishResolvableQuoteReferences.length === 0) {
setResolvedExternalQuotedCids(undefined);
setPublishReplyStateMessage(null);
setPendingPublishRequestId((requestId) => requestId + 1);
return;
}
if (!account?.id) {
setPublishReplyError(t('external_quote_resolution_unavailable'));
return;
}
setIsResolvingExternalQuotes(true);
try {
const resolvedCids = new Set<string>();
for (const reference of publishResolvableQuoteReferences) {
const resolvedTarget = await resolveExternalQuoteTarget({
account,
directories,
onStatus: (status) => {
setPublishReplyStateMessage(getExternalQuoteStatusMessage(t, status));
},
reference,
});
if (!resolvedTarget?.cid) {
setPublishReplyError(
t('external_quote_publish_missing', {
interpolation: { escapeValue: false },
quote: reference.raw,
}),
);
return;
}
resolvedCids.add(resolvedTarget.cid);
}
setResolvedExternalQuotedCids(resolvedCids.size > 0 ? [...resolvedCids] : undefined);
setPublishReplyStateMessage(null);
setPendingPublishRequestId((requestId) => requestId + 1);
} catch {
setPublishReplyError(t('external_quote_resolution_unavailable'));
} finally {
setIsResolvingExternalQuotes(false);
}
}, [account, directories, publishResolvableQuoteReferences, t]);
return {
isResolvingExternalQuotes,
publishReply,
publishReplyError,
publishReplyStateMessage,
setPublishReplyOptions,
resetPublishReplyOptions,
replyIndex: index,
publishReply: publishComment,
};
};