From cb15128ee8dff7f34d4b6d51682e3da50891d477 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Fri, 17 Jul 2026 16:32:01 +0700 Subject: [PATCH] fix(post): resolve thread community from CID and redirect stale routes Fetch the comment community from the CID payload for initial useComment hints, and redirect directory thread URLs to the authoritative community instead of 404. --- .../use-comment-cid-payload.test.tsx | 85 ++++++++++++ src/hooks/use-comment-cid-payload.ts | 131 ++++++++++++++++++ src/lib/utils/comment-utils.ts | 18 +++ src/views/post/__tests__/post.test.tsx | 74 +++++++++- src/views/post/post.tsx | 55 +++++--- 5 files changed, 341 insertions(+), 22 deletions(-) create mode 100644 src/hooks/__tests__/use-comment-cid-payload.test.tsx create mode 100644 src/hooks/use-comment-cid-payload.ts diff --git a/src/hooks/__tests__/use-comment-cid-payload.test.tsx b/src/hooks/__tests__/use-comment-cid-payload.test.tsx new file mode 100644 index 00000000..f3b0f987 --- /dev/null +++ b/src/hooks/__tests__/use-comment-cid-payload.test.tsx @@ -0,0 +1,85 @@ +import * as React from 'react'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { decodeCommentCidCommunityAddress, useCommentCidPayload } from '../use-comment-cid-payload'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise; + +const testState = vi.hoisted(() => ({ + account: undefined as { pkc?: { fetchCid: ReturnType } } | undefined, +})); + +vi.mock('@bitsocial/bitsocial-react-hooks', () => ({ + useAccount: () => testState.account, +})); + +let container: HTMLDivElement; +let latestSnapshot: ReturnType | undefined; +let root: Root; + +const HookHarness = ({ cid }: { cid: string }) => { + latestSnapshot = useCommentCidPayload(cid); + return null; +}; + +describe('useCommentCidPayload', () => { + beforeEach(() => { + latestSnapshot = undefined; + testState.account = undefined; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('decodes the community name from a fetched CID wrapper', () => { + expect( + decodeCommentCidCommunityAddress('comment-cid', { + content: JSON.stringify({ + communityName: 'business-and-finance.bso', + communityPublicKey: 'community-key', + content: 'thread body', + }), + }), + ).toBe('business-and-finance.bso'); + }); + + it('falls back to the community public key for unnamed communities', () => { + const encoded = new TextEncoder().encode(JSON.stringify({ communityPublicKey: 'community-key', content: 'thread body' })); + + expect(decodeCommentCidCommunityAddress('comment-cid', { content: encoded })).toBe('community-key'); + }); + + it('accepts an already decoded comment payload', () => { + expect(decodeCommentCidCommunityAddress('comment-cid', { communityName: 'outdoors.bso', content: 'thread body' })).toBe('outdoors.bso'); + }); + + it('rejects CID payloads without a community identifier', () => { + expect(() => decodeCommentCidCommunityAddress('comment-cid', { content: JSON.stringify({ content: 'thread body' }) })).toThrow( + "CID 'comment-cid' did not contain a community identifier", + ); + }); + + it('fetches the immutable CID once and publishes its community address', async () => { + const fetchCid = vi.fn().mockResolvedValue({ + content: JSON.stringify({ communityName: 'videogames-strategy.bso', content: 'thread body' }), + }); + testState.account = { pkc: { fetchCid } }; + + await act(async () => { + root.render(createElement(React.Fragment, null, createElement(HookHarness, { cid: 'comment-cid' }), createElement(HookHarness, { cid: 'comment-cid' }))); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(fetchCid).toHaveBeenCalledOnce(); + expect(fetchCid).toHaveBeenCalledWith({ cid: 'comment-cid' }); + expect(latestSnapshot).toEqual({ communityAddress: 'videogames-strategy.bso', state: 'succeeded' }); + }); +}); diff --git a/src/hooks/use-comment-cid-payload.ts b/src/hooks/use-comment-cid-payload.ts new file mode 100644 index 00000000..78ca07c9 --- /dev/null +++ b/src/hooks/use-comment-cid-payload.ts @@ -0,0 +1,131 @@ +import { useCallback, useSyncExternalStore } from 'react'; +import { useAccount } from '@bitsocial/bitsocial-react-hooks'; + +type PkcWithCidFetch = { + fetchCid: (options: { cid: string }) => Promise; +}; + +type CommentCidPayloadSnapshot = { + communityAddress?: string; + error?: Error; + state: 'idle' | 'fetching' | 'succeeded' | 'failed'; +}; + +type CommentCidPayloadEntry = { + listeners: Set<() => void>; + request?: Promise; + snapshot: CommentCidPayloadSnapshot; +}; + +const IDLE_SNAPSHOT: CommentCidPayloadSnapshot = { state: 'idle' }; +const entriesByClient = new WeakMap>(); + +const isRecord = (value: unknown): value is Record => Boolean(value && typeof value === 'object'); + +const decodeText = (value: unknown): string | undefined => { + if (typeof value === 'string') return value; + if (ArrayBuffer.isView(value)) return new TextDecoder().decode(new Uint8Array(value.buffer, value.byteOffset, value.byteLength)); + if (value instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(value)); + return undefined; +}; + +const getCommunityAddress = (payload: Record): string | undefined => { + for (const key of ['communityName', 'communityAddress', 'subplebbitAddress', 'communityPublicKey']) { + const value = payload[key]; + if (typeof value === 'string' && value) return value; + } + return undefined; +}; + +export const decodeCommentCidCommunityAddress = (cid: string, fetchedCid: unknown): string => { + const fetchedRecord = isRecord(fetchedCid) ? fetchedCid : undefined; + const encodedPayload = fetchedRecord && getCommunityAddress(fetchedRecord) ? fetchedRecord : (fetchedRecord?.content ?? fetchedCid); + const payloadText = decodeText(encodedPayload); + const payload = payloadText ? JSON.parse(payloadText) : encodedPayload; + + if (!isRecord(payload)) { + throw new Error(`CID '${cid}' did not contain a comment object`); + } + + const communityAddress = getCommunityAddress(payload); + if (!communityAddress) { + throw new Error(`CID '${cid}' did not contain a community identifier`); + } + + return communityAddress; +}; + +const getClientEntries = (pkc: PkcWithCidFetch): Map => { + let entries = entriesByClient.get(pkc); + if (!entries) { + entries = new Map(); + entriesByClient.set(pkc, entries); + } + return entries; +}; + +const getEntry = (pkc: PkcWithCidFetch, cid: string): CommentCidPayloadEntry => { + const entries = getClientEntries(pkc); + let entry = entries.get(cid); + if (!entry) { + entry = { + listeners: new Set(), + snapshot: IDLE_SNAPSHOT, + }; + entries.set(cid, entry); + } + return entry; +}; + +const notify = (entry: CommentCidPayloadEntry) => { + for (const listener of entry.listeners) listener(); +}; + +const startFetch = (pkc: PkcWithCidFetch, cid: string, entry: CommentCidPayloadEntry) => { + if (entry.request || entry.snapshot.state !== 'idle') return; + + entry.snapshot = { state: 'fetching' }; + notify(entry); + entry.request = pkc + .fetchCid({ cid }) + .then((fetchedCid) => { + entry.snapshot = { + communityAddress: decodeCommentCidCommunityAddress(cid, fetchedCid), + state: 'succeeded', + }; + }) + .catch((error: unknown) => { + entry.snapshot = { + error: error instanceof Error ? error : new Error(String(error)), + state: 'failed', + }; + }) + .finally(() => { + entry.request = undefined; + notify(entry); + if (entry.listeners.size === 0) getClientEntries(pkc).delete(cid); + }); +}; + +const subscribe = (pkc: PkcWithCidFetch, cid: string, listener: () => void) => { + const entry = getEntry(pkc, cid); + entry.listeners.add(listener); + startFetch(pkc, cid, entry); + + return () => { + entry.listeners.delete(listener); + if (entry.listeners.size === 0 && !entry.request) getClientEntries(pkc).delete(cid); + }; +}; + +const isPkcWithCidFetch = (value: unknown): value is PkcWithCidFetch => isRecord(value) && typeof value.fetchCid === 'function'; + +export const useCommentCidPayload = (commentCid: string | undefined): CommentCidPayloadSnapshot => { + const account = useAccount(); + const pkc = isPkcWithCidFetch(account?.pkc) ? account.pkc : undefined; + + const subscribeToPayload = useCallback((listener: () => void) => (pkc && commentCid ? subscribe(pkc, commentCid, listener) : () => {}), [commentCid, pkc]); + const getSnapshot = useCallback(() => (pkc && commentCid ? getEntry(pkc, commentCid).snapshot : IDLE_SNAPSHOT), [commentCid, pkc]); + + return useSyncExternalStore(subscribeToPayload, getSnapshot, () => IDLE_SNAPSHOT); +}; diff --git a/src/lib/utils/comment-utils.ts b/src/lib/utils/comment-utils.ts index 241b4098..04663ef4 100644 --- a/src/lib/utils/comment-utils.ts +++ b/src/lib/utils/comment-utils.ts @@ -24,6 +24,24 @@ export const getCommentCommunityAddress = (comment?: unknown) => { return undefined; }; +export const hasAuthoritativeCommentPayload = (comment?: unknown): boolean => { + if (!comment || typeof comment !== 'object') { + return false; + } + + const record = comment as { + timestamp?: unknown; + content?: unknown; + title?: unknown; + link?: unknown; + thumbnailUrl?: unknown; + deleted?: unknown; + removed?: unknown; + }; + + return Boolean(record.timestamp !== undefined || record.content || record.title || record.link || record.thumbnailUrl || record.deleted || record.removed); +}; + const withResolvedReplyPages = (replies: T): T => { const replyCollection = replies as CommentWithCommunityAddress['replies']; if (!replyCollection?.pages) { diff --git a/src/views/post/__tests__/post.test.tsx b/src/views/post/__tests__/post.test.tsx index fbe339a5..a87688a4 100644 --- a/src/views/post/__tests__/post.test.tsx +++ b/src/views/post/__tests__/post.test.tsx @@ -45,6 +45,7 @@ type TestComment = { const testState = vi.hoisted(() => ({ accountCommentsByCid: {} as Record, cachedComments: {} as Record, + cidCommunityAddress: undefined as string | undefined, communityFieldAddress: undefined as string | undefined, commentsByCid: {} as Record, directories: [{ address: 'music-posting.eth', name: 'music-posting.eth', publicKey: 'music-public-key', title: '/mu/ - Music' }] as Array<{ @@ -58,6 +59,8 @@ const testState = vi.hoisted(() => ({ navigateMock: vi.fn(), repliesByCommentCid: {} as Record, resolvedCommunityAddress: 'music-posting.eth' as string | undefined, + resolvedDirectoryBoardPath: undefined as string | undefined, + isDirectoryCandidate: false, community: { error: undefined as Error | undefined, shortAddress: 'music-posting.eth', @@ -147,8 +150,16 @@ vi.mock('../../../hooks/use-stable-community', () => ({ }, })); +vi.mock('../../../hooks/use-comment-cid-payload', () => ({ + useCommentCidPayload: () => ({ communityAddress: testState.cidCommunityAddress, state: testState.cidCommunityAddress ? 'succeeded' : 'idle' }), +})); + vi.mock('../../../hooks/use-resolved-community-address', () => ({ useResolvedCommunityAddress: () => testState.resolvedCommunityAddress, + useResolvedDirectoryBoardPath: () => ({ + boardPath: testState.resolvedDirectoryBoardPath, + isDirectoryCandidate: testState.isDirectoryCandidate, + }), })); vi.mock('../../../hooks/use-directories', async () => { @@ -302,12 +313,15 @@ describe('Post', () => { vi.clearAllMocks(); testState.accountCommentsByCid = {}; testState.cachedComments = {}; + testState.cidCommunityAddress = undefined; testState.communityFieldAddress = undefined; testState.commentsByCid = {}; testState.directories = [{ address: 'music-posting.eth', name: 'music-posting.eth', publicKey: 'music-public-key', title: '/mu/ - Music' }]; testState.editedCommentsByCid = {}; testState.isMobile = false; testState.resolvedCommunityAddress = 'music-posting.eth'; + testState.resolvedDirectoryBoardPath = undefined; + testState.isDirectoryCandidate = false; testState.repliesByCommentCid = {}; testState.useCommentCalls = []; testState.evictThreadRefreshCachesMock.mockReset(); @@ -808,19 +822,69 @@ describe('Post', () => { expect(window.scrollTo).not.toHaveBeenCalledWith(0, 0); }); - it('redirects thread routes whose fetched comment belongs to a different board', async () => { + it('redirects stale directory thread routes to the loaded comment community address', async () => { + testState.resolvedCommunityAddress = 'bizraelis.bso'; + testState.isDirectoryCandidate = true; testState.commentsByCid = { 'comment-1': { cid: 'comment-1', postCid: 'comment-1', - communityAddress: 'other.eth', - title: 'Other board thread', + communityAddress: 'business-and-finance.bso', + timestamp: 1, + title: 'Business thread', }, }; - await renderPostPage('/mu/thread/comment-1'); + await renderPostPage('/biz/thread/comment-1?focus=1#reply-2'); - expect(testState.navigateMock).toHaveBeenCalledWith('/not-found', { replace: true }); + expect(testState.navigateMock).toHaveBeenCalledWith('/business-and-finance.bso/thread/comment-1?focus=1#reply-2', { replace: true }); + }); + + it('uses the CID community as the initial useComment hint without rendering the raw CID payload', async () => { + testState.cidCommunityAddress = 'business-and-finance.bso'; + testState.resolvedCommunityAddress = 'bizraelis.bso'; + + await renderPostPage('/biz/thread/comment-1'); + + expect(testState.useCommentCalls).toContainEqual({ + autoUpdate: false, + commentCid: 'comment-1', + community: expect.objectContaining({ name: 'business-and-finance.bso' }), + }); + expect(testState.navigateMock).not.toHaveBeenCalled(); + }); + + it('does not trust a loading comment shell as the CID community source of truth', async () => { + testState.resolvedCommunityAddress = 'bizraelis.bso'; + testState.commentsByCid = { + 'comment-shell': { + cid: 'comment-shell', + communityAddress: 'business-and-finance.bso', + replyCount: 0, + state: 'initializing', + }, + }; + + await renderPostPage('/biz/thread/comment-shell'); + + expect(testState.navigateMock).not.toHaveBeenCalled(); + }); + + it('uses the directory code when the loaded comment community is the current winner', async () => { + testState.resolvedCommunityAddress = 'wrong-board.bso'; + testState.resolvedDirectoryBoardPath = 'biz'; + testState.isDirectoryCandidate = true; + testState.commentsByCid = { + 'comment-1': { + cid: 'comment-1', + communityAddress: 'business-and-finance.bso', + timestamp: 1, + }, + }; + + await renderPostPage('/wrong-board.bso/thread/comment-1'); + + expect(testState.navigateMock).toHaveBeenCalledWith('/biz/thread/comment-1', { replace: true }); }); it('hydrates multiboard thread pages from a legacy-only comment address', async () => { diff --git a/src/views/post/post.tsx b/src/views/post/post.tsx index 8e05ced4..925f59cf 100644 --- a/src/views/post/post.tsx +++ b/src/views/post/post.tsx @@ -14,12 +14,13 @@ import { communitiesPagesStore as useCommunitiesPagesStore } from '../../lib/bit import { useCommunityField } from '../../hooks/use-stable-community'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { isAllView } from '../../lib/utils/view-utils'; -import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address'; +import { useResolvedCommunityAddress, useResolvedDirectoryBoardPath } from '../../hooks/use-resolved-community-address'; import { useDirectories } from '../../hooks/use-directories'; import { useCommunityIdentifier } from '../../hooks/use-community-identifiers'; +import { useCommentCidPayload } from '../../hooks/use-comment-cid-payload'; import { isCommentArchived } from '../../lib/utils/comment-moderation-utils'; -import { areSameBoardAddress, isDirectoryBoard } from '../../lib/utils/route-utils'; -import { getCommentCommunityAddress } from '../../lib/utils/comment-utils'; +import { areSameBoardAddress, getBoardPath, isDirectoryBoard } from '../../lib/utils/route-utils'; +import { getCommentCommunityAddress, hasAuthoritativeCommentPayload } from '../../lib/utils/comment-utils'; import useIsMobile from '../../hooks/use-is-mobile'; import ErrorDisplay from '../../components/error-display/error-display'; import { PageFooterDesktop, ThreadFooterFirstRow, ThreadFooterStyleRow, ThreadFooterMobile } from '../../components/footer/footer'; @@ -347,16 +348,14 @@ export const Post = memo( const PostPage = () => { const { t } = useTranslation(); - const params = useParams(); - const { key: locationKey, pathname, state: locationState } = useLocation(); - const { commentCid } = params; + const { hash, key: locationKey, pathname, search, state: locationState } = useLocation(); + const { boardIdentifier, commentCid } = useParams(); const autoUpdateEnabled = useThreadLiveUpdatesStore((state) => state.enabled); const updateRequestId = useThreadLiveUpdatesStore((state) => state.updateRequestId); const startUpdate = useThreadLiveUpdatesStore((state) => state.startUpdate); const finishUpdate = useThreadLiveUpdatesStore((state) => state.finishUpdate); const resetThreadLiveUpdates = useThreadLiveUpdatesStore((state) => state.resetState); const resolvedCommunityAddress = useResolvedCommunityAddress(); - const resolvedCommunityIdentifier = useCommunityIdentifier(resolvedCommunityAddress); const isInAllView = isAllView(pathname); const routeState = useMemo(() => { // locationKey/pathname are intentional deps: getEffectiveRouteUserState falls back to the @@ -366,12 +365,20 @@ const PostPage = () => { return getEffectiveRouteUserState(locationState); }, [locationKey, pathname, locationState]); - const resolvedComment = useCommentWithFeedCache({ commentCid, autoUpdate: autoUpdateEnabled, community: resolvedCommunityIdentifier }); + const { communityAddress: cidCommunityAddress } = useCommentCidPayload(commentCid); + const commentCommunityIdentifier = useCommunityIdentifier(cidCommunityAddress ?? resolvedCommunityAddress); + const resolvedComment = useCommentWithFeedCache({ commentCid, autoUpdate: autoUpdateEnabled, community: commentCommunityIdentifier }); const queuedComment = useMemo(() => getQueuedCommentFromRouteState(routeState, commentCid), [routeState, commentCid]); const comment = useMemo(() => mergeLocalCommentAuthor(mergeCommentFallback(resolvedComment, queuedComment), queuedComment), [resolvedComment, queuedComment]); const commentCommunityAddress = getCommentCommunityAddress(comment); - const communityAddress = resolvedCommunityAddress ?? commentCommunityAddress; - const communityIdentifier = useCommunityIdentifier(communityAddress); + const authoritativeCommentCommunityAddress = hasAuthoritativeCommentPayload(comment) ? commentCommunityAddress : undefined; + const communityAddress = authoritativeCommentCommunityAddress ?? resolvedCommunityAddress; + const communityIdentifier = useCommunityIdentifier(authoritativeCommentCommunityAddress ?? cidCommunityAddress ?? resolvedCommunityAddress); + const directories = useDirectories(); + const { boardPath: resolvedCommentBoardPath, isDirectoryCandidate: isCommentDirectoryCandidate } = useResolvedDirectoryBoardPath(authoritativeCommentCommunityAddress); + const canonicalCommentBoardPath = authoritativeCommentCommunityAddress + ? (resolvedCommentBoardPath ?? (isCommentDirectoryCandidate ? authoritativeCommentCommunityAddress : getBoardPath(authoritativeCommentCommunityAddress, directories))) + : undefined; const consumedThreadTopScrollRef = useRef(null); const previousThreadCidRef = useRef(undefined); const lastProcessedUpdateRequestIdRef = useRef(0); @@ -379,17 +386,32 @@ const PostPage = () => { const navigate = useNavigate(); useEffect(() => { - if (commentCommunityAddress && resolvedCommunityAddress && !areSameBoardAddress(commentCommunityAddress, resolvedCommunityAddress)) { - navigate('/not-found', { replace: true }); + if ( + !boardIdentifier || + !canonicalCommentBoardPath || + !authoritativeCommentCommunityAddress || + !resolvedCommunityAddress || + areSameBoardAddress(authoritativeCommentCommunityAddress, resolvedCommunityAddress) + ) { + return; } - }, [commentCommunityAddress, resolvedCommunityAddress, navigate]); + + const routePrefix = `/${boardIdentifier}`; + if (!pathname.startsWith(`${routePrefix}/`)) return; + const canonicalPathname = `/${canonicalCommentBoardPath}${pathname.slice(routePrefix.length)}`; + if (canonicalPathname === pathname) return; + navigate(`${canonicalPathname}${search}${hash}`, { replace: true }); + }, [authoritativeCommentCommunityAddress, boardIdentifier, canonicalCommentBoardPath, hash, navigate, pathname, resolvedCommunityAddress, search]); const community = useCommunity(communityIdentifier ? { community: communityIdentifier } : undefined); const { error: communityError, shortAddress, title } = community || {}; - const directories = useDirectories(); // if the comment is a reply, return the post comment instead, then the reply will be highlighted in the thread - const postComment = useCommentWithFeedCache({ commentCid: comment?.postCid, autoUpdate: autoUpdateEnabled, community: communityIdentifier }); + const postComment = useCommentWithFeedCache({ + commentCid: comment?.postCid, + autoUpdate: autoUpdateEnabled, + community: authoritativeCommentCommunityAddress ? communityIdentifier : undefined, + }); const post = useMemo(() => (comment?.parentCid ? mergeCommentFallback(postComment, comment) : comment), [comment, postComment]); threadRefreshCommentsRef.current = [comment, post]; const requestedThreadTopCid = getRequestedThreadTopCid(routeState); @@ -420,7 +442,6 @@ const PostPage = () => { }, [commentCid, locationKey, post?.cid, requestedThreadTopCid]); useEffect(() => { - const boardIdentifier = params.boardIdentifier; const isDirectory = boardIdentifier ? isDirectoryBoard(boardIdentifier, directories) : false; let boardTitle: string; @@ -435,7 +456,7 @@ const PostPage = () => { const postTitle = post?.title?.slice(0, 30) || post?.content?.slice(0, 30); const postTitlePart = postTitle ? ` - ${postTitle.trim()}...` : ''; document.title = `${boardTitle}${postTitlePart} - 5chan`; - }, [title, shortAddress, communityAddress, post?.title, post?.content, isInAllView, t, params.boardIdentifier, directories]); + }, [title, shortAddress, communityAddress, post?.title, post?.content, isInAllView, t, boardIdentifier, directories]); const shouldShowCommentError = comment?.error?.message && !comment?.cid; const shouldShowPostError = post?.error && post?.replyCount > 0 && post?.replies?.length === 0;