fix(thread): show optimistic reply count after publishing own reply (#1162)

post.replyCount lags until a reply propagates back through the community, so a thread's stats kept showing the old count even though the reply list already appended the fresh account comment. Add useOptimisticReplyCount, which bumps the count for the account's own successfully-published replies in the thread and retires the bump once the post refreshes past them (updatedAt > timestamp) so the propagated copy is never double-counted. Applied to desktop PostPageStats and mobile ThreadFooterMobile.
This commit is contained in:
Tommaso Casaburi
2026-06-08 19:09:46 +07:00
committed by GitHub
parent cf43934708
commit 182ff0eadc
6 changed files with 207 additions and 2 deletions
@@ -84,6 +84,7 @@ vi.mock('react-router-dom', async () => {
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
useAccount: () => testState.account,
useAccountComment: () => testState.accountComment,
useAccountComments: () => ({ accountComments: [] }),
useComment: ({ commentCid, community }: { commentCid?: string; community?: { name?: string; publicKey?: string } }) => {
testState.useCommentCalls.push({ commentCid, community });
return commentCid ? testState.commentsByCid[commentCid] : undefined;
@@ -21,6 +21,7 @@ import useModQueueStore from '../../stores/use-mod-queue-store';
import useFeedViewSettingsStore from '../../stores/use-feed-view-settings-store';
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
import useCountLinksInReplies from '../../hooks/use-count-links-in-replies';
import useOptimisticReplyCount from '../../hooks/use-optimistic-reply-count';
import useIsMobile from '../../hooks/use-is-mobile';
import useTimeFilter from '../../hooks/use-time-filter';
import CatalogFilters from '../catalog-filters';
@@ -654,7 +655,8 @@ export const PostPageStats = () => {
const post = useComment({ commentCid: postCid, autoUpdate: autoUpdateEnabled, community: communityIdentifier });
const archived = isCommentArchived(post);
const { closed, pinned, replyCount } = post || {};
const { closed, pinned } = post || {};
const replyCount = useOptimisticReplyCount(post);
const linkCount = useCountLinksInReplies(post);
const directoryEntry = useDirectoryByAddress(communityAddress);
const requirePostLinkIsMedia = directoryEntry?.features?.requirePostLinkIsMedia === true;
@@ -38,6 +38,7 @@ vi.mock('react-i18next', () => ({
}));
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
useAccountComments: () => ({ accountComments: [] }),
useComment: ({ commentCid, community }: { commentCid?: string; community?: { name?: string; publicKey?: string } }) => {
testState.useCommentCalls.push({ commentCid, community });
return testState.post;
+2 -1
View File
@@ -19,6 +19,7 @@ import { isAllView, isSubscriptionsView, isModView } from '../../lib/utils/view-
import useReplyModalStore from '../../stores/use-reply-modal-store';
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
import useCountLinksInReplies from '../../hooks/use-count-links-in-replies';
import useOptimisticReplyCount from '../../hooks/use-optimistic-reply-count';
import { usePostPageNumber } from '../../hooks/use-post-page-number';
import { useDirectories, useDirectoryByAddress } from '../../hooks/use-directories';
import { useCommunityIdentifier } from '../../hooks/use-community-identifiers';
@@ -250,7 +251,7 @@ export const ThreadFooterMobile = ({ postCid, threadNumber, communityAddress, is
const communityIdentifier = useCommunityIdentifier(communityAddress);
const post = useComment({ commentCid: postCid, autoUpdate: autoUpdateEnabled, community: communityIdentifier });
const { replyCount } = post || {};
const replyCount = useOptimisticReplyCount(post);
const linkCount = useCountLinksInReplies(post);
const directoryEntry = useDirectoryByAddress(communityAddress);
const requirePostLinkIsMedia = directoryEntry?.features?.requirePostLinkIsMedia === true;
@@ -0,0 +1,139 @@
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 useOptimisticReplyCount from '../use-optimistic-reply-count';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
type TestAccountComment = {
cid?: string;
parentCid?: string;
postCid?: string;
state?: string;
timestamp?: number;
deleted?: boolean;
removed?: boolean;
communityAddress?: string;
};
const testState = vi.hoisted(() => ({
accountComments: [] as TestAccountComment[],
accountCommentsCalls: [] as Array<unknown>,
post: undefined as Record<string, unknown> | undefined,
}));
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
useAccountComments: (options?: unknown) => {
testState.accountCommentsCalls.push(options);
return { accountComments: testState.accountComments };
},
}));
const THREAD_CID = 'thread-cid';
const COMMUNITY = 'music.eth';
const makeThread = (overrides: Record<string, unknown> = {}) => ({
cid: THREAD_CID,
communityAddress: COMMUNITY,
replyCount: 1,
updatedAt: 1000,
...overrides,
});
const makeReply = (overrides: TestAccountComment = {}): TestAccountComment => ({
cid: 'reply-cid',
parentCid: THREAD_CID,
postCid: THREAD_CID,
state: 'succeeded',
timestamp: 1500,
communityAddress: COMMUNITY,
...overrides,
});
let container: HTMLDivElement;
let latestValue: ReturnType<typeof useOptimisticReplyCount>;
let root: Root;
const HookHarness = () => {
latestValue = useOptimisticReplyCount(testState.post as never);
return null;
};
const renderHook = () => {
act(() => {
root.render(createElement(HookHarness));
});
};
describe('useOptimisticReplyCount', () => {
beforeEach(() => {
testState.accountComments = [];
testState.accountCommentsCalls = [];
testState.post = makeThread();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('returns undefined while replyCount is still loading', () => {
testState.post = makeThread({ replyCount: undefined });
renderHook();
expect(latestValue).toBeUndefined();
});
it('returns the protocol replyCount unchanged when there are no account replies', () => {
renderHook();
expect(latestValue).toBe(1);
});
it('optimistically adds a freshly published reply not yet folded into replyCount', () => {
// Thread refreshed at 1000, the reply was published at 1500, so the propagated copy is not counted yet.
testState.accountComments = [makeReply()];
renderHook();
expect(latestValue).toBe(2);
});
it('drops the optimistic bump once the post has refreshed past the reply', () => {
// The community has since republished the post (updatedAt 2000 > 1500) with replyCount already at 2.
testState.post = makeThread({ replyCount: 2, updatedAt: 2000 });
testState.accountComments = [makeReply()];
renderHook();
expect(latestValue).toBe(2);
});
it('counts nested replies in the same thread but excludes the OP and other threads', () => {
testState.accountComments = [
makeReply({ cid: 'nested', parentCid: 'some-reply-cid', timestamp: 1500 }),
makeReply({ cid: THREAD_CID, parentCid: undefined, postCid: THREAD_CID }), // OP itself
makeReply({ cid: 'other-thread-reply', postCid: 'other-thread', parentCid: 'other-thread' }),
];
renderHook();
expect(latestValue).toBe(2); // 1 protocol + 1 nested reply
});
it('ignores replies that are pending, failed, deleted, or removed', () => {
testState.accountComments = [
makeReply({ cid: 'pending', state: 'pending' }),
makeReply({ cid: undefined, state: 'succeeded' }), // no cid yet
makeReply({ cid: 'failed', state: 'failed' }),
makeReply({ cid: 'deleted', deleted: true }),
makeReply({ cid: 'removed', removed: true }),
];
renderHook();
expect(latestValue).toBe(1);
});
it('counts multiple distinct fresh replies in the thread', () => {
testState.accountComments = [makeReply({ cid: 'r1', timestamp: 1500 }), makeReply({ cid: 'r2', timestamp: 1600 })];
renderHook();
expect(latestValue).toBe(3);
});
});
+61
View File
@@ -0,0 +1,61 @@
import { useMemo } from 'react';
import { Comment, useAccountComments } from '@bitsocial/bitsocial-react-hooks';
import { getCommentCommunityAddress } from '../lib/utils/comment-utils';
// Bound the account-comment lookup to recently published replies; propagation lag is seconds to
// minutes, so an hour is plenty while keeping the scanned set small. Matches the board/catalog feeds.
const RECENT_ACCOUNT_COMMENT_WINDOW_SECONDS = 60 * 60;
// Keep useAccountComments on its indexed fast path (and returning nothing) when there's no thread yet.
const EMPTY_ACCOUNT_COMMENT_LOOKUP = { commentIndices: [-1] };
/**
* The protocol's `post.replyCount` only changes once a freshly published reply propagates back
* through the community, which can take seconds to minutes. Until then the thread itself already
* shows the reply (`useReplies` appends fresh account comments), so the stats count looks stale
* a thread with 1 reply still reads "1" right after you successfully publish your own reply.
*
* This hook returns `replyCount` plus the account's own replies in this thread that have been
* published but aren't yet folded into `replyCount`. The optimistic bump is dropped automatically
* once the post refreshes past the reply (`updatedAt > timestamp`), at which point the propagated
* copy same cid is already counted, so we never double-count. Returns `undefined` while
* `replyCount` is still loading so callers can keep rendering their placeholder.
*/
const useOptimisticReplyCount = (post: Comment | undefined): number | undefined => {
const replyCount: number | undefined = typeof post?.replyCount === 'number' ? post.replyCount : undefined;
const postCid: string | undefined = post?.cid;
const postUpdatedAt: number | undefined = typeof post?.updatedAt === 'number' ? post.updatedAt : undefined;
const communityAddress = getCommentCommunityAddress(post);
const accountCommentsLookup = useMemo(
() => (communityAddress ? { communityAddress, newerThan: RECENT_ACCOUNT_COMMENT_WINDOW_SECONDS, sortType: 'old' as const } : EMPTY_ACCOUNT_COMMENT_LOOKUP),
[communityAddress],
);
const { accountComments } = useAccountComments(accountCommentsLookup);
return useMemo(() => {
if (replyCount === undefined || !postCid || !accountComments?.length) {
return replyCount;
}
let pendingReplyCount = 0;
for (const accountComment of accountComments) {
const { cid, deleted, parentCid, postCid: replyPostCid, removed, state, timestamp } = accountComment || {};
// A successfully published reply in this thread, excluding the OP itself (its postCid === its cid).
if (replyPostCid !== postCid || !parentCid || !cid || cid === postCid) {
continue;
}
if (deleted || removed || state !== 'succeeded') {
continue;
}
// Once the post has refreshed past the reply, its propagated copy is already in replyCount.
if (postUpdatedAt !== undefined && typeof timestamp === 'number' && postUpdatedAt > timestamp) {
continue;
}
pendingReplyCount += 1;
}
return replyCount + pendingReplyCount;
}, [accountComments, postCid, postUpdatedAt, replyCount]);
};
export default useOptimisticReplyCount;