From 9896c4400bceab98575cfc4553c12665e05b4374 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Thu, 18 Jun 2026 18:13:31 +0700 Subject: [PATCH] fix(replies): keep fresh replies scoped to thread (#1174) --- src/components/post-desktop/post-desktop.tsx | 2 +- src/components/post-mobile/post-mobile.tsx | 2 +- .../__tests__/use-fresh-replies.test.tsx | 70 ++++++++++++++++++- src/hooks/use-fresh-replies.ts | 52 +++++++++++++- 4 files changed, 121 insertions(+), 5 deletions(-) diff --git a/src/components/post-desktop/post-desktop.tsx b/src/components/post-desktop/post-desktop.tsx index 9529fefe..ef641d58 100644 --- a/src/components/post-desktop/post-desktop.tsx +++ b/src/components/post-desktop/post-desktop.tsx @@ -939,7 +939,7 @@ const PostDesktop = ({ ? fullReplies : previewReplies : getPreviewDisplayReplies(previewReplies, BOARD_REPLIES_PREVIEW_VISIBLE_COUNT); - const freshRepliesForRender = useFreshReplies(repliesForRender); + const freshRepliesForRender = useFreshReplies(repliesForRender, { post: resolvedPost }); useRegisterFreshReplies(resolvedPost, freshRepliesForRender); const setResetFunction = useFeedResetStore((s) => s.setResetFunction); const repliesResetRequestId = useThreadLiveUpdatesStore((state) => state.repliesResetRequestId); diff --git a/src/components/post-mobile/post-mobile.tsx b/src/components/post-mobile/post-mobile.tsx index 30403d41..4448507f 100644 --- a/src/components/post-mobile/post-mobile.tsx +++ b/src/components/post-mobile/post-mobile.tsx @@ -676,7 +676,7 @@ const PostMobile = ({ const { replies, hasMore, loadMore } = repliesResult; const updatedReplies = repliesResult.updatedReplies; const repliesForRender = updatedReplies?.length ? updatedReplies : replies || []; - const freshRepliesForRender = useFreshReplies(repliesForRender); + const freshRepliesForRender = useFreshReplies(repliesForRender, { post: resolvedPost }); useRegisterFreshReplies(resolvedPost, freshRepliesForRender); const reset = (repliesResult as { reset?: () => Promise }).reset; const setResetFunction = useFeedResetStore((s) => s.setResetFunction); diff --git a/src/hooks/__tests__/use-fresh-replies.test.tsx b/src/hooks/__tests__/use-fresh-replies.test.tsx index 1d981c3a..b4a0be2c 100644 --- a/src/hooks/__tests__/use-fresh-replies.test.tsx +++ b/src/hooks/__tests__/use-fresh-replies.test.tsx @@ -12,13 +12,16 @@ type TestComment = { content?: string; index?: number; number?: number; + parentCid?: string; pendingApproval?: boolean; + postCid?: string; communityAddress?: string; }; const testState = vi.hoisted(() => ({ accountComments: [] as TestComment[], accountCommentsCalls: [] as Array<{ commentIndices?: number[]; filter?: (comment: TestComment) => boolean } | undefined>, + post: undefined as TestComment | undefined, replies: [] as TestComment[], })); @@ -46,7 +49,7 @@ let latestValue: ReturnType; let root: Root; const HookHarness = () => { - latestValue = useFreshReplies(testState.replies as never); + latestValue = useFreshReplies(testState.replies as never, { post: testState.post as never }); return null; }; @@ -60,6 +63,7 @@ describe('useFreshReplies', () => { beforeEach(() => { testState.accountComments = []; testState.accountCommentsCalls = []; + testState.post = undefined; testState.replies = []; container = document.createElement('div'); @@ -237,4 +241,68 @@ describe('useFreshReplies', () => { expect(latestValue.map((reply) => reply.cid)).toEqual(['reply-8', 'reply-11', 'reply-12']); }); + + it('does not replace a failed reply placeholder with a same-index post from another thread', () => { + testState.post = { + cid: 'g-thread-cid', + communityAddress: 'technology.eth', + }; + testState.replies = [ + { + content: 'failed g reply', + index: 4, + parentCid: 'g-thread-cid', + postCid: 'g-thread-cid', + communityAddress: 'technology.eth', + }, + ]; + testState.accountComments = [ + { + cid: 'mu-thread-cid', + content: 'mu op', + index: 4, + postCid: 'mu-thread-cid', + communityAddress: 'music.eth', + }, + ]; + + renderHook(); + + expect(latestValue).toHaveLength(1); + expect(latestValue[0]).toBe(testState.replies[0] as never); + expect(latestValue[0]?.content).toBe('failed g reply'); + expect(testState.accountCommentsCalls).toContainEqual({ commentIndices: [4] }); + }); + + it('replaces indexed replies when the account comment still belongs to the same thread', () => { + testState.post = { + cid: 'thread-cid', + communityAddress: 'music.eth', + }; + testState.replies = [ + { + content: 'pending reply', + index: 5, + parentCid: 'thread-cid', + postCid: 'thread-cid', + communityAddress: 'music.eth', + }, + ]; + testState.accountComments = [ + { + cid: 'reply-cid', + content: 'fresh reply', + index: 5, + number: 9, + parentCid: 'thread-cid', + postCid: 'thread-cid', + communityAddress: 'music.bso', + }, + ]; + + renderHook(); + + expect(latestValue[0]).toBe(testState.accountComments[0] as never); + expect(latestValue[0]?.number).toBe(9); + }); }); diff --git a/src/hooks/use-fresh-replies.ts b/src/hooks/use-fresh-replies.ts index 234541f3..07c6eaf1 100644 --- a/src/hooks/use-fresh-replies.ts +++ b/src/hooks/use-fresh-replies.ts @@ -1,11 +1,55 @@ import { useMemo } from 'react'; import { Comment, useAccountComments } from '@bitsocial/bitsocial-react-hooks'; import { sortRepliesForDisplay } from '../lib/utils/replies-preview-utils'; +import { getCommentCommunityAddress } from '../lib/utils/comment-utils'; +import { normalizeBoardAddress } from '../lib/utils/directory-list-lookup-utils'; // Keep the hook on its indexed fast path when there are no reply indices to resolve. const EMPTY_ACCOUNT_COMMENT_LOOKUP = { commentIndices: [-1] }; -const useFreshReplies = (replies: Comment[] = []) => { +type UseFreshRepliesOptions = { + post?: Comment; +}; + +const getString = (value: unknown): string | undefined => (typeof value === 'string' && value.length > 0 ? value : undefined); + +const getThreadPostCid = (post: Comment | undefined): string | undefined => getString(post?.postCid) ?? getString(post?.cid); + +const hasSameCommunityAddress = (a: string | undefined, b: string | undefined): boolean => { + if (!a || !b) return a === b; + return normalizeBoardAddress(a) === normalizeBoardAddress(b); +}; + +const isFreshReplyForOriginalReply = (reply: Comment, freshReply: Comment, post: Comment | undefined): boolean => { + const replyCid = getString(reply?.cid); + const freshReplyCid = getString(freshReply?.cid); + if (replyCid && reply?.pendingApproval !== true && replyCid !== freshReplyCid) { + return false; + } + + const expectedPostCid = getString(reply?.postCid) ?? getThreadPostCid(post); + if (expectedPostCid) { + if (getString(freshReply?.postCid) !== expectedPostCid) { + return false; + } + + if (!getString(freshReply?.parentCid)) { + return false; + } + } + + const expectedParentCid = getString(reply?.parentCid); + if (expectedParentCid && getString(freshReply?.parentCid) !== expectedParentCid) { + return false; + } + + const expectedCommunityAddress = getCommentCommunityAddress(reply) ?? getCommentCommunityAddress(post); + const freshCommunityAddress = getCommentCommunityAddress(freshReply); + return hasSameCommunityAddress(expectedCommunityAddress, freshCommunityAddress); +}; + +const useFreshReplies = (replies: Comment[] = [], options: UseFreshRepliesOptions = {}) => { + const { post } = options; const replyIndices = useMemo( () => Array.from(new Set(replies.map((reply) => reply?.index).filter((replyIndex): replyIndex is number => typeof replyIndex === 'number'))), [replies], @@ -74,6 +118,10 @@ const useFreshReplies = (replies: Comment[] = []) => { return reply; } + if (!isFreshReplyForOriginalReply(reply, freshReply, post)) { + return reply; + } + hasFreshReplies = true; return freshReply; }); @@ -99,7 +147,7 @@ const useFreshReplies = (replies: Comment[] = []) => { }); return sortRepliesForDisplay(hasDuplicateReplyIndices ? dedupedReplies : nextReplies); - }, [accountCommentsByCidList, accountCommentsByIndexList, replies]); + }, [accountCommentsByCidList, accountCommentsByIndexList, post, replies]); }; export default useFreshReplies;