mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user