From 463509e10514cd061232bd44e2cfc551efd9caef Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Mon, 27 Apr 2026 15:12:52 +0700 Subject: [PATCH] fix(replies): order approved queue replies --- .../__tests__/use-fresh-replies.test.tsx | 62 ++++++++++++++++ src/hooks/use-fresh-replies.ts | 11 ++- src/lib/utils/__tests__/misc-utils.test.ts | 36 ++++++++- src/lib/utils/replies-preview-utils.ts | 73 ++++++++++++++++--- 4 files changed, 168 insertions(+), 14 deletions(-) diff --git a/src/hooks/__tests__/use-fresh-replies.test.tsx b/src/hooks/__tests__/use-fresh-replies.test.tsx index f8eee315..ac7941f5 100644 --- a/src/hooks/__tests__/use-fresh-replies.test.tsx +++ b/src/hooks/__tests__/use-fresh-replies.test.tsx @@ -12,6 +12,7 @@ type TestComment = { content?: string; index?: number; number?: number; + pendingApproval?: boolean; communityAddress?: string; }; @@ -144,4 +145,65 @@ describe('useFreshReplies', () => { expect(latestValue[0]).toBe(testState.accountComments[0] as never); expect(testState.accountCommentsCalls).toContainEqual({ commentIndices: [0] }); }); + + it('orders replies by final post number after a pending reply is approved', () => { + testState.replies = [ + { + cid: 'reply-8', + index: 8, + number: 8, + communityAddress: 'music.eth', + }, + { + cid: 'reply-12', + index: 12, + number: 12, + communityAddress: 'music.eth', + }, + { + cid: 'pending-reply', + index: 11, + number: undefined, + pendingApproval: true, + communityAddress: 'music.eth', + }, + ]; + testState.accountComments = [ + { + cid: 'reply-11', + index: 11, + number: 11, + communityAddress: 'music.eth', + }, + ]; + + renderHook(); + + expect(latestValue.map((reply) => reply.cid)).toEqual(['reply-8', 'reply-11', 'reply-12']); + expect(testState.accountCommentsCalls).toContainEqual({ commentIndices: [8, 12, 11] }); + }); + + it('orders already-numbered replies when an approved queued reply was appended', () => { + testState.replies = [ + { + cid: 'reply-8', + number: 8, + communityAddress: 'music.eth', + }, + { + cid: 'reply-12', + number: 12, + communityAddress: 'music.eth', + }, + { + cid: 'reply-11', + number: 11, + communityAddress: 'music.eth', + }, + ]; + + renderHook(); + + expect(latestValue.map((reply) => reply.cid)).toEqual(['reply-8', 'reply-11', 'reply-12']); + }); }); diff --git a/src/hooks/use-fresh-replies.ts b/src/hooks/use-fresh-replies.ts index 56e437f2..e467a426 100644 --- a/src/hooks/use-fresh-replies.ts +++ b/src/hooks/use-fresh-replies.ts @@ -1,5 +1,6 @@ import { useMemo } from 'react'; import { Comment, useAccountComments } from '@bitsocial/bitsocial-react-hooks'; +import { sortRepliesForDisplay } from '../lib/utils/replies-preview-utils'; // Keep the hook on its indexed fast path when there are no reply indices to resolve. const EMPTY_ACCOUNT_COMMENT_LOOKUP = { commentIndices: [-1] }; @@ -13,10 +14,14 @@ const useFreshReplies = (replies: Comment[] = []) => { const { accountComments } = useAccountComments(accountCommentLookupOptions); return useMemo(() => { - if (!replies.length || !accountComments?.length) { + if (!replies.length) { return replies; } + if (!accountComments?.length) { + return sortRepliesForDisplay(replies); + } + const accountCommentsByIndex = new Map(); for (const accountComment of accountComments) { if (typeof accountComment?.index === 'number') { @@ -40,7 +45,7 @@ const useFreshReplies = (replies: Comment[] = []) => { }); if (!hasFreshReplies) { - return replies; + return sortRepliesForDisplay(replies); } const seenReplyIndices = new Set(); @@ -59,7 +64,7 @@ const useFreshReplies = (replies: Comment[] = []) => { return true; }); - return hasDuplicateReplyIndices ? dedupedReplies : nextReplies; + return sortRepliesForDisplay(hasDuplicateReplyIndices ? dedupedReplies : nextReplies); }, [accountComments, replies]); }; diff --git a/src/lib/utils/__tests__/misc-utils.test.ts b/src/lib/utils/__tests__/misc-utils.test.ts index 915f0010..89e5b0c9 100644 --- a/src/lib/utils/__tests__/misc-utils.test.ts +++ b/src/lib/utils/__tests__/misc-utils.test.ts @@ -2,7 +2,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { copyToClipboard } from '../clipboard-utils'; import { hashStringToColor, getTextColorForBackground, removeMarkdown } from '../post-utils'; import { preloadReplyModal, preloadThemeAssets, resolveAssetUrl } from '../preload-utils'; -import { computeOmittedCount, filterRepliesForDisplay, getPreviewDisplayReplies, getTotalReplyCount, hasEnoughPreviewReplies } from '../replies-preview-utils'; +import { + computeOmittedCount, + filterRepliesForDisplay, + getPreviewDisplayReplies, + getTotalReplyCount, + hasEnoughPreviewReplies, + sortRepliesForDisplay, +} from '../replies-preview-utils'; import { getQuotedCidsFromContent, mergeQuotedCids } from '../reply-quote-utils'; import { formatUserIDForDisplay, truncateWithEllipsisInMiddle } from '../string-utils'; import { getFormattedDate, getFormattedTimeAgo, isChristmas } from '../time-utils'; @@ -184,6 +191,33 @@ describe('misc utils', () => { { cid: 'pending', pendingApproval: true }, ]); + expect( + sortRepliesForDisplay([ + { cid: 'reply-8', number: 8 }, + { cid: 'reply-12', number: 12 }, + { cid: 'reply-11', number: 11 }, + ]), + ).toEqual([ + { cid: 'reply-8', number: 8 }, + { cid: 'reply-11', number: 11 }, + { cid: 'reply-12', number: 12 }, + ]); + + expect( + getPreviewDisplayReplies( + [ + { cid: 'reply-8', number: 8, timestamp: 8 }, + { cid: 'reply-12', number: 12, timestamp: 12 }, + { cid: 'reply-11', number: 11, timestamp: 99 }, + ], + 3, + ), + ).toEqual([ + { cid: 'reply-8', number: 8, timestamp: 8 }, + { cid: 'reply-11', number: 11, timestamp: 99 }, + { cid: 'reply-12', number: 12, timestamp: 12 }, + ]); + expect(computeOmittedCount({ totalReplyCount: 2, visibleCount: 5 })).toBe(0); expect(computeOmittedCount({ totalReplyCount: 9, visibleCount: 5 })).toBe(4); expect(hasEnoughPreviewReplies({ replyCount: 2, loadedCount: 2, visibleCount: 5 })).toBe(true); diff --git a/src/lib/utils/replies-preview-utils.ts b/src/lib/utils/replies-preview-utils.ts index 908d37dc..31317f2b 100644 --- a/src/lib/utils/replies-preview-utils.ts +++ b/src/lib/utils/replies-preview-utils.ts @@ -4,11 +4,45 @@ interface CommentLike { cid?: string | null; deleted?: boolean; index?: number; + number?: number; pendingApproval?: boolean; state?: string; timestamp?: number; } +const getReplyNumber = (reply: CommentLike): number | undefined => (typeof reply?.number === 'number' && Number.isFinite(reply.number) ? reply.number : undefined); + +export function sortRepliesForDisplay(replies: T[]): T[] { + if (replies.length < 2) { + return replies; + } + + const taggedReplies = replies.map((reply, index) => ({ + index, + number: getReplyNumber(reply), + reply, + })); + + taggedReplies.sort((a, b) => { + if (a.number !== undefined && b.number !== undefined) { + return a.number === b.number ? a.index - b.index : a.number - b.number; + } + + if (a.number !== undefined) { + return -1; + } + + if (b.number !== undefined) { + return 1; + } + + return a.index - b.index; + }); + + const sortedReplies = taggedReplies.map((taggedReply) => taggedReply.reply); + return sortedReplies.every((reply, index) => reply === replies[index]) ? replies : sortedReplies; +} + export function filterRepliesForDisplay(replies: T[]): T[] { return replies.filter((reply) => !reply.deleted); } @@ -21,23 +55,42 @@ export function filterRepliesForDisplay(replies: T[]): T[ * board previews. */ export function getPreviewDisplayReplies(replies: T[], visibleCount: number = BOARD_REPLIES_PREVIEW_VISIBLE_COUNT): T[] { - const getRecency = (reply: T): number => { - if (typeof reply?.timestamp === 'number') { - return reply.timestamp; + const getRecency = (reply: T): { group: number; value: number } => { + const number = getReplyNumber(reply); + if (number !== undefined) { + return { group: 2, value: number }; } + // Pending/local account replies can be missing timestamp early on. if (typeof reply?.index === 'number' || reply?.pendingApproval || (reply?.state && reply.state !== 'succeeded')) { - return Number.POSITIVE_INFINITY; + return { group: 3, value: 0 }; } - return Number.NEGATIVE_INFINITY; + + if (typeof reply?.timestamp === 'number') { + return { group: 1, value: reply.timestamp }; + } + + return { group: 0, value: 0 }; }; const tagged = replies.map((reply, i) => ({ reply, recency: getRecency(reply), i })); - tagged.sort((a, b) => (a.recency !== b.recency ? b.recency - a.recency : a.i - b.i)); - return tagged - .slice(0, visibleCount) - .reverse() - .map((t) => t.reply); + tagged.sort((a, b) => { + if (a.recency.group !== b.recency.group) { + return b.recency.group - a.recency.group; + } + + if (a.recency.value !== b.recency.value) { + return b.recency.value - a.recency.value; + } + + return a.i - b.i; + }); + return sortRepliesForDisplay( + tagged + .slice(0, visibleCount) + .reverse() + .map((t) => t.reply), + ); } interface ComputeOmittedParams {