From e366dac25cbf42bfa27a386922d42cac185db5e8 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Thu, 16 Apr 2026 15:33:03 +0700 Subject: [PATCH] fix(quotes): keep same-thread board previews local --- .../markdown/__tests__/markdown.test.tsx | 127 +++++++++++++++++- .../markdown/external-number-quote-link.tsx | 6 +- src/components/markdown/markdown.tsx | 10 +- src/components/post-form/post-form.tsx | 4 +- src/components/reply-modal/reply-modal.tsx | 2 +- src/hooks/__tests__/use-publish-post.test.tsx | 3 +- .../__tests__/use-publish-reply.test.tsx | 6 +- .../__tests__/use-quoted-by-map.test.tsx | 28 ++++ src/hooks/use-publish-post.ts | 14 +- src/hooks/use-publish-reply.ts | 18 +-- src/hooks/use-quoted-by-map.ts | 14 +- .../__tests__/external-quote-utils.test.ts | 41 ++++++ src/lib/utils/external-quote-resolver.ts | 2 +- src/lib/utils/external-quote-utils.ts | 27 ++-- .../__tests__/interaction-stores.test.ts | 22 ++- src/stores/use-post-number-store.ts | 33 +++++ 16 files changed, 293 insertions(+), 64 deletions(-) create mode 100644 src/lib/utils/__tests__/external-quote-utils.test.ts diff --git a/src/components/markdown/__tests__/markdown.test.tsx b/src/components/markdown/__tests__/markdown.test.tsx index 1923bf8d..ca05efb9 100644 --- a/src/components/markdown/__tests__/markdown.test.tsx +++ b/src/components/markdown/__tests__/markdown.test.tsx @@ -15,7 +15,13 @@ type TestComment = { const testState = vi.hoisted(() => ({ comments: {} as Record, + directories: [{ address: 'music-posting.eth', name: 'music-posting.bso', title: '/mu/ - Music' }] as Array<{ + address: string; + name?: string; + title?: string; + }>, embeddableHosts: new Set(), + cidToNumber: {} as Record, internalPathByHref: {} as Record, isMobile: false, mediaInfoByHref: {} as Record, @@ -88,9 +94,46 @@ vi.mock('../../../lib/utils/url-utils', () => ({ transform5chanLinkToInternal: (href: string) => testState.internalPathByHref[href] ?? null, })); +vi.mock('../../../hooks/use-directories', () => ({ + findDirectoryByAddress: (directories: Array<{ address: string; name?: string }>, address: string | undefined) => { + if (!address) { + return undefined; + } + + const normalize = (value: string) => value.replace(/(\.bso|\.eth)$/, ''); + return directories.find((directory) => [directory.address, directory.name].some((identifier) => identifier && normalize(identifier) === normalize(address))); + }, + useDirectories: () => testState.directories, +})); + vi.mock('../../../stores/use-post-number-store', () => ({ - default: (selector: (state: { numberToCid: typeof testState.numberToCid }) => unknown) => + getCidForPostNumber: (numberToCid: typeof testState.numberToCid, communityAddress: string | undefined, postNumber: number) => { + if (!communityAddress) { + return undefined; + } + + const normalize = (value: string) => value.replace(/(\.bso|\.eth)$/, ''); + const exactMatch = numberToCid[communityAddress]; + const matchingEntries = Object.entries(numberToCid).filter(([address]) => normalize(address) === normalize(communityAddress)); + const scoped = + exactMatch && matchingEntries.length <= 1 + ? exactMatch + : matchingEntries.reduce>( + (mergedMap, [address, scopedMap]) => { + if (address === communityAddress) { + return { ...mergedMap, ...scopedMap }; + } + + return { ...scopedMap, ...mergedMap }; + }, + exactMatch ? { ...exactMatch } : {}, + ); + + return scoped?.[postNumber]; + }, + default: (selector: (state: { cidToNumber: typeof testState.cidToNumber; numberToCid: typeof testState.numberToCid }) => unknown) => selector({ + cidToNumber: testState.cidToNumber, numberToCid: testState.numberToCid, }), })); @@ -129,7 +172,8 @@ 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), + default: ({ isOP, reference }: { isOP?: boolean; reference: { raw: string } }) => + createElement('a', { 'data-op': String(Boolean(isOP)), 'data-testid': 'external-number-quote-link', href: '#' }, `${reference.raw}${isOP ? ' (OP)' : ''}`), })); let container: HTMLDivElement; @@ -145,7 +189,9 @@ describe('Markdown', () => { beforeEach(() => { vi.clearAllMocks(); testState.comments = {}; + testState.directories = [{ address: 'music-posting.eth', name: 'music-posting.bso', title: '/mu/ - Music' }]; testState.embeddableHosts = new Set(); + testState.cidToNumber = {}; testState.internalPathByHref = {}; testState.isMobile = false; testState.mediaInfoByHref = {}; @@ -188,6 +234,9 @@ describe('Markdown', () => { testState.comments = { 'comment-42': { cid: 'comment-42', number: 42 }, }; + testState.cidToNumber = { + 'comment-42': 42, + }; testState.numberToCid = { 'music-posting.eth': { 42: 'comment-42', @@ -208,6 +257,80 @@ describe('Markdown', () => { expect(quotePreview?.textContent).toBe('comment-42'); }); + it('resolves same-board OP quotes across alias-scoped post number store entries', async () => { + testState.comments = { + 'thread-cid': { cid: 'thread-cid', number: 42 }, + }; + testState.cidToNumber = { + 'thread-cid': 42, + }; + testState.numberToCid = { + 'music-posting.eth': { + 42: 'thread-cid', + }, + }; + testState.directories = [{ address: 'music-posting.bso', title: '/mu/ - Music' }]; + + await renderMarkdown({ + content: '>>42', + postCid: 'thread-cid', + communityAddress: 'music-posting.bso', + }); + + expect(container.querySelector('[data-testid="external-number-quote-link"]')).toBeNull(); + const quotePreview = container.querySelector('[data-testid="reply-quote-preview"]'); + expect(quotePreview?.getAttribute('data-number')).toBe('42'); + expect(quotePreview?.getAttribute('data-op')).toBe('true'); + expect(quotePreview?.textContent).toBe('thread-cid'); + }); + + it('resolves board-preview quotes when the exact alias scope exists but another alias owns the quoted number', async () => { + testState.comments = { + 'thread-cid': { cid: 'thread-cid', number: 3 }, + }; + testState.cidToNumber = { + 'thread-cid': 3, + }; + testState.numberToCid = { + 'music-posting.eth': { + 3: 'thread-cid', + 6: 'reply-cid', + }, + 'music-posting.bso': { + 28: 'later-reply-cid', + }, + }; + + await renderMarkdown({ + content: '>>3', + postCid: 'thread-cid', + communityAddress: 'music-posting.bso', + }); + + expect(container.querySelector('[data-testid="external-number-quote-link"]')).toBeNull(); + const quotePreview = container.querySelector('[data-testid="reply-quote-preview"]'); + expect(quotePreview?.getAttribute('data-number')).toBe('3'); + expect(quotePreview?.getAttribute('data-op')).toBe('true'); + expect(quotePreview?.textContent).toBe('thread-cid'); + }); + + it('preserves the OP label when a same-board quote still falls back to external resolution', async () => { + testState.cidToNumber = { + 'thread-cid': 42, + }; + testState.directories = [{ address: 'music-posting.bso', title: '/mu/ - Music' }]; + + await renderMarkdown({ + content: '>>42', + postCid: 'thread-cid', + communityAddress: 'music-posting.bso', + }); + + const externalLink = container.querySelector('[data-testid="external-number-quote-link"]'); + expect(externalLink?.getAttribute('data-op')).toBe('true'); + expect(externalLink?.textContent).toBe('>>42 (OP)'); + }); + it('renders lazy same-board and cross-board number quotes when the cid is not cached', async () => { await renderMarkdown({ content: '>>42 >>>/fit/77', diff --git a/src/components/markdown/external-number-quote-link.tsx b/src/components/markdown/external-number-quote-link.tsx index f0716274..b8f801be 100644 --- a/src/components/markdown/external-number-quote-link.tsx +++ b/src/components/markdown/external-number-quote-link.tsx @@ -15,6 +15,7 @@ import { Post } from '../../views/post'; import styles from './markdown.module.css'; interface ExternalNumberQuoteLinkProps { + isOP?: boolean; reference: ExternalQuoteReference; } @@ -42,7 +43,7 @@ type PreviewPosition = { top: number; }; -const ExternalNumberQuoteLink = ({ reference }: ExternalNumberQuoteLinkProps) => { +const ExternalNumberQuoteLink = ({ isOP = false, reference }: ExternalNumberQuoteLinkProps) => { const { t } = useTranslation(); const account = useAccount(); const directories = useDirectories(); @@ -61,6 +62,7 @@ const ExternalNumberQuoteLink = ({ reference }: ExternalNumberQuoteLinkProps) => const latestStatusMessageRef = useRef(''); const boardLabel = getExternalQuoteBoardLabel(reference, directories); + const linkLabel = isOP ? `${reference.raw} (OP)` : reference.raw; const updatePreviewPosition = (anchor: HTMLElement | null) => { if (!anchor || isMobile) { @@ -276,7 +278,7 @@ const ExternalNumberQuoteLink = ({ reference }: ExternalNumberQuoteLinkProps) => onMouseLeave={handleMouseLeave} ref={anchorRef} > - {reference.raw} + {linkLabel} {!isMobile && isPreviewOpen && diff --git a/src/components/markdown/markdown.tsx b/src/components/markdown/markdown.tsx index f2cf869e..db9d6f17 100644 --- a/src/components/markdown/markdown.tsx +++ b/src/components/markdown/markdown.tsx @@ -12,7 +12,7 @@ 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 usePostNumberStore, { getCidForPostNumber } from '../../stores/use-post-number-store'; import useCommunitiesPagesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/communities-pages'; import { useComment } from '@bitsocialnet/bitsocial-react-hooks'; import ReplyQuotePreview from '../reply-quote-preview'; @@ -299,11 +299,12 @@ interface MarkdownProps { } const NumberQuoteLink = ({ number, threadPostCid, communityAddress }: { number: number; threadPostCid?: string; communityAddress?: string }) => { - const cid = usePostNumberStore((state) => (communityAddress ? state.numberToCid[communityAddress]?.[number] : undefined)); + const cid = usePostNumberStore((state) => getCidForPostNumber(state.numberToCid, communityAddress, number)); + const threadPostNumber = usePostNumberStore((state) => (threadPostCid ? state.cidToNumber[threadPostCid] : undefined)); const commentFromStore = useCommunitiesPagesStore((state) => (cid ? state.comments[cid] : undefined)); const commentFromHook = useComment({ commentCid: cid, onlyIfCached: true }); const comment = commentFromHook?.number !== undefined ? commentFromHook : commentFromStore; - const isOP = Boolean(threadPostCid && cid === threadPostCid); + const isOP = Boolean((threadPostCid && cid === threadPostCid) || (threadPostNumber !== undefined && number === threadPostNumber)); if (isUnavailableQuoteTarget(comment)) { return ( @@ -314,11 +315,12 @@ const NumberQuoteLink = ({ number, threadPostCid, communityAddress }: { number: if (!cid && communityAddress) { return ( >${number}`, - subplebbitAddress: communityAddress, + communityAddress, }} /> ); diff --git a/src/components/post-form/post-form.tsx b/src/components/post-form/post-form.tsx index e2ac1a8d..9e1e6561 100644 --- a/src/components/post-form/post-form.tsx +++ b/src/components/post-form/post-form.tsx @@ -305,7 +305,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: const resolvedAddress = useResolvedCommunityAddress(); const communityAddress = resolvedAddress || accountComment?.communityAddress; const { setPublishPostOptions, postIndex, publishPost, publishPostError, publishPostOptions, resetPublishPostOptions } = usePublishPost({ - subplebbitAddress: communityAddress, + communityAddress, }); const effectiveBoardAddress = communityAddress || publishPostOptions.communityAddress; @@ -396,7 +396,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: const isInPostView = isPostPageView(location.pathname, params); const cid = params?.commentCid as string; const { isResolvingExternalQuotes, publishReply, publishReplyError, publishReplyStateMessage, resetPublishReplyOptions, replyIndex, setPublishReplyOptions } = - usePublishReply({ cid, subplebbitAddress: communityAddress, postCid }); + usePublishReply({ cid, communityAddress, postCid }); const handleContentChange = (e: React.ChangeEvent) => { const content = e.target.value; diff --git a/src/components/reply-modal/reply-modal.tsx b/src/components/reply-modal/reply-modal.tsx index 165f4e2e..279c10e4 100644 --- a/src/components/reply-modal/reply-modal.tsx +++ b/src/components/reply-modal/reply-modal.tsx @@ -44,7 +44,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa const { isResolvingExternalQuotes, publishReply, publishReplyError, publishReplyStateMessage, resetPublishReplyOptions, replyIndex, setPublishReplyOptions } = usePublishReply({ cid: parentCid, - subplebbitAddress: communityAddress, + communityAddress, postCid, }); const account = useAccount(); diff --git a/src/hooks/__tests__/use-publish-post.test.tsx b/src/hooks/__tests__/use-publish-post.test.tsx index 8f6c418c..bdaebe89 100644 --- a/src/hooks/__tests__/use-publish-post.test.tsx +++ b/src/hooks/__tests__/use-publish-post.test.tsx @@ -41,7 +41,7 @@ let latestValue: ReturnType; let root: Root; const HookHarness = () => { - latestValue = usePublishPost({ subplebbitAddress: 'music.eth' }); + latestValue = usePublishPost({ communityAddress: 'music.eth' }); return null; }; @@ -92,7 +92,6 @@ describe('usePublishPost', () => { spoiler: true, title: 'Hello world', }); - expect('subplebbitAddress' in latestValue.publishPostOptions).toBe(false); expect(typeof latestValue.publishPostOptions.onChallengeVerification).toBe('function'); expect(typeof latestValue.publishPostOptions.onError).toBe('function'); }); diff --git a/src/hooks/__tests__/use-publish-reply.test.tsx b/src/hooks/__tests__/use-publish-reply.test.tsx index 8f51b500..1c758d19 100644 --- a/src/hooks/__tests__/use-publish-reply.test.tsx +++ b/src/hooks/__tests__/use-publish-reply.test.tsx @@ -41,6 +41,7 @@ vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({ vi.mock('../../hooks/use-directories', () => ({ useDirectories: () => testState.directories, + normalizeBoardAddress: (address?: string) => address?.replace(/(\.bso|\.eth)$/u, ''), })); vi.mock('../../lib/utils/external-quote-resolver', () => ({ @@ -60,7 +61,7 @@ let latestValue: ReturnType; let root: Root; const HookHarness = () => { - latestValue = usePublishReply({ cid: 'parent-cid', subplebbitAddress: 'music.eth' }); + latestValue = usePublishReply({ cid: 'parent-cid', communityAddress: 'music.eth' }); return null; }; @@ -122,14 +123,13 @@ describe('usePublishReply', () => { quotedCids: ['quoted-cid'], spoiler: true, }); - expect('subplebbitAddress' in (testState.lastPublishOptions || {})).toBe(false); }); 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', + communityAddress: 'music.eth', }); await act(async () => { diff --git a/src/hooks/__tests__/use-quoted-by-map.test.tsx b/src/hooks/__tests__/use-quoted-by-map.test.tsx index 083fb4d2..e5794d2e 100644 --- a/src/hooks/__tests__/use-quoted-by-map.test.tsx +++ b/src/hooks/__tests__/use-quoted-by-map.test.tsx @@ -72,4 +72,32 @@ describe('useQuotedByMap', () => { expect(latestValue.get('op-cid')?.[0]?.number).toBe(42); }); + + it('matches same-board quote numbers across .eth and .bso aliases', () => { + usePostNumberStore.setState({ + cidToNumber: { 'op-cid': 1 }, + numberToCid: { 'music.eth': { 1: 'op-cid' } }, + }); + + testState.replies = [ + { + cid: 'reply-cid', + content: 'replying to >>1', + number: 42, + state: 'succeeded', + subplebbitAddress: 'music.bso', + }, + ]; + + act(() => { + root.render( + createElement(() => { + latestValue = useQuotedByMap(testState.replies as never, 'music.bso'); + return null; + }), + ); + }); + + expect(latestValue.get('op-cid')?.[0]?.cid).toBe('reply-cid'); + }); }); diff --git a/src/hooks/use-publish-post.ts b/src/hooks/use-publish-post.ts index 48174a65..2649b96c 100644 --- a/src/hooks/use-publish-post.ts +++ b/src/hooks/use-publish-post.ts @@ -6,11 +6,9 @@ import usePublishAuthorDomainGuard, { getPublishAuthorDomainErrorMessage } from type UsePublishPostOptions = { communityAddress?: string; - /** legacy compatibility */ - subplebbitAddress?: string; }; -const usePublishPost = ({ communityAddress: requestedCommunityAddress, subplebbitAddress }: UsePublishPostOptions) => { +const usePublishPost = ({ communityAddress }: UsePublishPostOptions) => { const { author, title, content, link, spoiler, publishCommentOptions } = usePublishPostStore((state) => ({ author: state.author, title: state.title || undefined, @@ -30,8 +28,6 @@ const usePublishPost = ({ communityAddress: requestedCommunityAddress, subplebbi await abandonPublishRef.current?.(); }, []); - const communityAddress = requestedCommunityAddress ?? subplebbitAddress; - const createBaseOptions = useCallback(() => { const baseOptions: Comment = { communityAddress, @@ -60,12 +56,8 @@ const usePublishPost = ({ communityAddress: requestedCommunityAddress, subplebbi {} as Partial, ); - const { - communityAddress: nextCommunityAddress, - subplebbitAddress: legacyCommunityAddress, - ...restOptions - } = sanitizedOptions as Partial & { subplebbitAddress?: string }; - const resolvedCommunityAddress = nextCommunityAddress ?? legacyCommunityAddress ?? baseOptions.communityAddress; + const { communityAddress: nextCommunityAddress, ...restOptions } = sanitizedOptions; + const resolvedCommunityAddress = nextCommunityAddress ?? baseOptions.communityAddress; const newOptions = { ...baseOptions, ...restOptions, diff --git a/src/hooks/use-publish-reply.ts b/src/hooks/use-publish-reply.ts index 06da611e..fb5964bf 100644 --- a/src/hooks/use-publish-reply.ts +++ b/src/hooks/use-publish-reply.ts @@ -3,7 +3,7 @@ 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 usePostNumberStore, { getScopedNumberToCidMap } 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'; @@ -13,14 +13,10 @@ import usePublishAuthorDomainGuard, { getPublishAuthorDomainErrorMessage } from type UsePublishReplyOptions = { cid: string; communityAddress?: string; - /** legacy compatibility */ - subplebbitAddress?: string; postCid?: string; }; -const usePublishReply = ({ cid, communityAddress: requestedCommunityAddress, subplebbitAddress, postCid }: UsePublishReplyOptions) => { - const communityAddress = requestedCommunityAddress ?? subplebbitAddress; - +const usePublishReply = ({ cid, communityAddress, postCid }: UsePublishReplyOptions) => { const { t } = useTranslation(); const parentCid = cid; const account = useAccount(); @@ -78,12 +74,8 @@ const usePublishReply = ({ cid, communityAddress: requestedCommunityAddress, sub {} as Partial, ); - const { - communityAddress: nextCommunityAddress, - subplebbitAddress: legacyCommunityAddress, - ...restOptions - } = sanitizedOptions as Partial & { subplebbitAddress?: string }; - const resolvedCommunityAddress = nextCommunityAddress ?? legacyCommunityAddress ?? baseOptions.communityAddress; + const { communityAddress: nextCommunityAddress, ...restOptions } = sanitizedOptions; + const resolvedCommunityAddress = nextCommunityAddress ?? baseOptions.communityAddress; const newOptions = { ...baseOptions, ...restOptions, @@ -96,7 +88,7 @@ const usePublishReply = ({ cid, communityAddress: requestedCommunityAddress, sub const resetPublishReplyOptions = useCallback(() => resetPublishReplyStore(parentCid), [parentCid, resetPublishReplyStore]); - const scopedNumberToCid = usePostNumberStore((state) => (communityAddress ? state.numberToCid[communityAddress] : undefined)); + const scopedNumberToCid = usePostNumberStore((state) => getScopedNumberToCidMap(state.numberToCid, communityAddress)); const quotedCids = useMemo(() => getQuotedCidsFromContent(content, scopedNumberToCid), [content, scopedNumberToCid]); const unresolvedExternalQuoteReferences = useMemo( () => diff --git a/src/hooks/use-quoted-by-map.ts b/src/hooks/use-quoted-by-map.ts index 0df83ab0..66dbece4 100644 --- a/src/hooks/use-quoted-by-map.ts +++ b/src/hooks/use-quoted-by-map.ts @@ -1,7 +1,7 @@ import { useCallback, useMemo, useRef } from 'react'; import { Comment } from '@bitsocialnet/bitsocial-react-hooks'; import { QUOTE_NUMBER_REGEX } from '../lib/utils/url-utils'; -import usePostNumberStore from '../stores/use-post-number-store'; +import usePostNumberStore, { getScopedNumberToCidMap } from '../stores/use-post-number-store'; interface ReplyQuoteTargets { reply: Comment; @@ -61,27 +61,27 @@ const extractReplyQuoteTargets = (replies: Comment[]) => { }; }; -const useQuotedByMap = (replies: Comment[] = [], subplebbitAddress?: string) => { +const useQuotedByMap = (replies: Comment[] = [], communityAddress?: string) => { const stableQuotedByMapRef = useRef>(new Map()); const { replyQuoteTargets, quotedPostNumbers } = useMemo(() => extractReplyQuoteTargets(replies), [replies]); const quotedNumbersSignature = usePostNumberStore( useCallback( (state) => { - const scoped = subplebbitAddress ? state.numberToCid[subplebbitAddress] : undefined; + const scoped = getScopedNumberToCidMap(state.numberToCid, communityAddress); return quotedPostNumbers.map((postNumber) => `${postNumber}:${scoped?.[postNumber] ?? ''}`).join('|'); }, - [quotedPostNumbers, subplebbitAddress], + [quotedPostNumbers, communityAddress], ), ); const quotedNumberToCid = useMemo(() => { - if (quotedPostNumbers.length === 0 || !subplebbitAddress) { + if (quotedPostNumbers.length === 0 || !communityAddress) { return {} as Record; } const { numberToCid } = usePostNumberStore.getState(); - const scoped = numberToCid[subplebbitAddress]; + const scoped = getScopedNumberToCidMap(numberToCid, communityAddress); if (!scoped) return {} as Record; const nextQuotedNumberToCid: Record = {}; @@ -94,7 +94,7 @@ const useQuotedByMap = (replies: Comment[] = [], subplebbitAddress?: string) => } return nextQuotedNumberToCid; - }, [quotedPostNumbers, subplebbitAddress, quotedNumbersSignature]); + }, [quotedPostNumbers, communityAddress, quotedNumbersSignature]); return useMemo(() => { const map = new Map(); diff --git a/src/lib/utils/__tests__/external-quote-utils.test.ts b/src/lib/utils/__tests__/external-quote-utils.test.ts new file mode 100644 index 00000000..103f34ea --- /dev/null +++ b/src/lib/utils/__tests__/external-quote-utils.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { getExternalQuoteBoardAddress, getExternalQuoteBoardLabel } from '../external-quote-utils'; + +const directories = [ + { + address: 'music-posting.eth', + name: 'music-posting.bso', + title: '/mu/ - Music', + }, + { + address: 'random.eth', + directoryCode: 'b', + title: 'Random', + }, +]; + +describe('external quote board resolution', () => { + it('canonicalizes same-board aliases through the directories list', () => { + const reference = { + communityAddress: 'music-posting.bso', + kind: 'same-board' as const, + number: 42, + raw: '>>42', + }; + + expect(getExternalQuoteBoardAddress(reference, directories)).toBe('music-posting.eth'); + expect(getExternalQuoteBoardLabel(reference, directories)).toBe('mu'); + }); + + it('canonicalizes cross-board aliases through the directories list', () => { + const reference = { + boardIdentifier: 'music-posting.bso', + kind: 'cross-board' as const, + number: 77, + raw: '>>>/music-posting.bso/77', + }; + + expect(getExternalQuoteBoardAddress(reference, directories)).toBe('music-posting.eth'); + expect(getExternalQuoteBoardLabel(reference, directories)).toBe('mu'); + }); +}); diff --git a/src/lib/utils/external-quote-resolver.ts b/src/lib/utils/external-quote-resolver.ts index ebdc2a52..04c22f6d 100644 --- a/src/lib/utils/external-quote-resolver.ts +++ b/src/lib/utils/external-quote-resolver.ts @@ -155,7 +155,7 @@ const loadBoardThreads = async ({ const feedName = getBoardFeedName(accountId, communityAddress); const feedState = feedsStore.getState(); if (!feedState.feedsOptions[feedName]) { - await feedState.addFeedToStore(feedName, [communityAddress], BOARD_FEED_SORT_TYPE, account, false, BOARD_SEARCH_POSTS_PER_PAGE); + await feedState.addFeedToStore(feedName, [{ address: communityAddress }], [communityAddress], BOARD_FEED_SORT_TYPE, account, false, BOARD_SEARCH_POSTS_PER_PAGE); } await waitForBoardFeedPage(feedName, 0, 1); diff --git a/src/lib/utils/external-quote-utils.ts b/src/lib/utils/external-quote-utils.ts index e5f63d92..d8b1bac4 100644 --- a/src/lib/utils/external-quote-utils.ts +++ b/src/lib/utils/external-quote-utils.ts @@ -1,5 +1,6 @@ import type { DirectoryCommunity } from '../../hooks/use-directories'; -import { getBoardPath, getCommunityAddress, getSubplebbitAddress } from './route-utils'; +import { findDirectoryByAddress } from '../../hooks/use-directories'; +import { getBoardPath, getCommunityAddress } from './route-utils'; import { QUOTE_NUMBER_REGEX } from './url-utils'; const CROSSBOARD_NUMBER_BOARD_PART = '(?:[a-zA-Z0-9]{1,10}|12D3KooW[a-zA-Z0-9]{44}|[a-zA-Z0-9\\-.]+)'; @@ -12,8 +13,6 @@ export type SameBoardExternalQuoteReference = { number: number; raw: string; communityAddress?: string; - // legacy compatibility alias - subplebbitAddress?: string; }; export type CrossBoardExternalQuoteReference = { @@ -30,23 +29,27 @@ const getAddressForCanonicalReference = (reference: ExternalQuoteReference, dire return resolveLegacyCommunityAddress(reference.boardIdentifier, directories); } - return resolveLegacyCommunityAddress(reference.communityAddress || reference.subplebbitAddress || '', directories); + return resolveLegacyCommunityAddress(reference.communityAddress || '', directories); }; const getExternalQuoteKey = (reference: ExternalQuoteReference) => reference.kind === 'cross-board' ? `${reference.kind}:${reference.boardIdentifier}:${reference.number}` - : `${reference.kind}:${reference.communityAddress || reference.subplebbitAddress}:${reference.number}`; + : `${reference.kind}:${reference.communityAddress}:${reference.number}`; const resolveLegacyCommunityAddress = (boardIdentifier: string, communities: DirectoryCommunity[]) => { + const matchingDirectory = findDirectoryByAddress(communities, boardIdentifier); + if (matchingDirectory?.address) { + return matchingDirectory.address; + } + // Canonical resolver in route utils handles directory or address mapping. const address = getCommunityAddress(boardIdentifier, communities); if (address) { return address; } - // Backward-compat helper alias if needed by callers with older util behavior. - return getSubplebbitAddress(boardIdentifier, communities); + return boardIdentifier; }; export const getExternalQuoteBoardAddress = (reference: ExternalQuoteReference, directories: DirectoryCommunity[]) => @@ -61,23 +64,18 @@ export const extractUnresolvedExternalQuoteReferences = ({ content, scopedNumberToCid, communityAddress, - subplebbitAddress, }: { content?: string; scopedNumberToCid?: Record; - // canonical input communityAddress?: string; - // backward-compatible input name - subplebbitAddress?: string; }) => { - const effectiveCommunityAddress = communityAddress || subplebbitAddress; if (!content) { return [] as ExternalQuoteReference[]; } const references = new Map(); - if (effectiveCommunityAddress) { + if (communityAddress) { for (const match of content.matchAll(new RegExp(QUOTE_NUMBER_REGEX.source, 'g'))) { const number = Number.parseInt(match[1], 10); if (Number.isNaN(number) || scopedNumberToCid?.[number]) { @@ -88,8 +86,7 @@ export const extractUnresolvedExternalQuoteReferences = ({ kind: 'same-board', number, raw: `>>${number}`, - communityAddress: effectiveCommunityAddress, - subplebbitAddress: effectiveCommunityAddress, + communityAddress, }; references.set(getExternalQuoteKey(reference), reference); } diff --git a/src/stores/__tests__/interaction-stores.test.ts b/src/stores/__tests__/interaction-stores.test.ts index 2a053790..b703682a 100644 --- a/src/stores/__tests__/interaction-stores.test.ts +++ b/src/stores/__tests__/interaction-stores.test.ts @@ -4,7 +4,7 @@ import useCreateBoardModalStore from '../use-create-board-modal-store'; import useDirectoryModalStore from '../use-directory-modal-store'; import useDisclaimerModalStore, { DISCLAIMER_ACCEPTED_KEY } from '../use-disclaimer-modal-store'; import useFeedResetStore from '../use-feed-reset-store'; -import usePostNumberStore from '../use-post-number-store'; +import usePostNumberStore, { getCidForPostNumber, getScopedNumberToCidMap } from '../use-post-number-store'; import useReplyModalStore from '../use-reply-modal-store'; import useSelectedTextStore from '../use-selected-text-store'; import useSortingStore from '../use-sorting-store'; @@ -235,6 +235,26 @@ describe('interaction stores', () => { expect(usePostNumberStore.getState().numberToCid).toBe(numberToCidRef); }); + it('merges alias-scoped post number maps when the exact board scope is only partially populated', () => { + const numberToCid = { + 'music.eth': { + 1: 'op-cid', + 2: 'reply-cid', + }, + 'music.bso': { + 30: 'late-reply-cid', + }, + }; + + expect(getScopedNumberToCidMap(numberToCid, 'music.bso')).toEqual({ + 1: 'op-cid', + 2: 'reply-cid', + 30: 'late-reply-cid', + }); + expect(getCidForPostNumber(numberToCid, 'music.bso', 1)).toBe('op-cid'); + expect(getCidForPostNumber(numberToCid, 'music.bso', 30)).toBe('late-reply-cid'); + }); + it('opens reply modals with quoted selection and mobile scroll state', () => { Object.defineProperty(window, 'innerWidth', { configurable: true, diff --git a/src/stores/use-post-number-store.ts b/src/stores/use-post-number-store.ts index e5dc4080..de278303 100644 --- a/src/stores/use-post-number-store.ts +++ b/src/stores/use-post-number-store.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; import type { Comment } from '@bitsocialnet/bitsocial-react-hooks'; +import { normalizeBoardAddress } from '../hooks/use-directories'; interface PostNumberState { // Post numbers are only unique within a board, so scope by canonical community address. @@ -8,6 +9,38 @@ interface PostNumberState { registerComments: (comments: Comment[]) => void; } +export const getScopedNumberToCidMap = (numberToCid: Record>, communityAddress?: string) => { + if (!communityAddress) { + return undefined; + } + + const normalizedCommunityAddress = normalizeBoardAddress(communityAddress); + const exactMatch = numberToCid[communityAddress]; + const matchingEntries = Object.entries(numberToCid).filter(([address]) => normalizeBoardAddress(address) === normalizedCommunityAddress); + + if (matchingEntries.length === 0) { + return exactMatch; + } + + if (!exactMatch && matchingEntries.length === 1) { + return matchingEntries[0][1]; + } + + if (!exactMatch) { + return matchingEntries.reduce>((mergedMap, [, scopedMap]) => ({ ...mergedMap, ...scopedMap }), {}); + } + + if (matchingEntries.length === 1) { + return exactMatch; + } + + const aliasEntries = matchingEntries.filter(([address]) => address !== communityAddress); + return aliasEntries.reduce>((mergedMap, [, scopedMap]) => ({ ...mergedMap, ...scopedMap }), { ...exactMatch }); +}; + +export const getCidForPostNumber = (numberToCid: Record>, communityAddress: string | undefined, postNumber: number) => + getScopedNumberToCidMap(numberToCid, communityAddress)?.[postNumber]; + const usePostNumberStore = create((set) => ({ numberToCid: {}, cidToNumber: {},