mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(quotes): resolve external quote links across boards (#1064)
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user