fix(home): stabilize initial popular threads box

Keep usePopularPosts() keyed to the requested board list, wait to reveal the box until the first result set is stable, and limit the grid to one thread per board. Freeze the first revealed threads until refresh or filter changes so later board loads cannot displace visible cards.
This commit is contained in:
plebeius
2026-03-08 16:48:38 +08:00
parent 2b32866b6b
commit 824c14f538
4 changed files with 256 additions and 36 deletions
@@ -0,0 +1,172 @@
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 usePopularPosts from '../use-popular-posts';
(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>;
const testState = vi.hoisted(() => ({
currentTime: 1_704_067_200,
loadingTimestamps: [] as number[],
requestedAddresses: undefined as string[] | undefined,
}));
vi.mock('../use-current-time', () => ({
useCurrentTime: () => testState.currentTime,
}));
vi.mock('../../stores/use-subplebbits-loading-start-timestamps-store', () => ({
default: (addresses?: string[]) => {
testState.requestedAddresses = addresses;
return testState.loadingTimestamps;
},
}));
vi.mock('../../lib/utils/media-utils', () => ({
getCommentMediaInfo: (link: string) => ({ link }),
getHasThumbnail: (_commentMediaInfo: unknown, link?: string) => Boolean(link),
}));
let latestValue: ReturnType<typeof usePopularPosts>;
let container: HTMLDivElement;
let root: Root;
const createPost = (boardAddress: string, suffix: string, replyCount: number, timestamp = 1_704_067_000) =>
({
cid: `${boardAddress}-${suffix}`,
content: `${suffix} content`,
link: `https://cdn.example/${boardAddress}/${suffix}.jpg`,
replyCount,
subplebbitAddress: boardAddress,
thumbnailUrl: `https://cdn.example/${boardAddress}/${suffix}.thumb.jpg`,
timestamp,
title: `${suffix} title`,
}) as never;
const createSubplebbit = (boardAddress: string, posts: Array<{ cid: string }>, updatedAt = 1_704_067_150) =>
({
address: boardAddress,
updatedAt,
posts: {
pages: {
hot: {
comments: Object.fromEntries(posts.map((post: { cid: string }) => [post.cid, post])),
},
},
},
}) as never;
const HookHarness = ({ addresses, subplebbits }: { addresses: string[]; subplebbits: Array<unknown> }) => {
latestValue = usePopularPosts(subplebbits as never, addresses);
return null;
};
const renderHook = async (addresses: string[], subplebbits: Array<unknown>) => {
await act(async () => {
root.render(createElement(HookHarness, { addresses, subplebbits }));
});
};
describe('usePopularPosts', () => {
beforeEach(() => {
vi.clearAllMocks();
testState.currentTime = 1_704_067_200;
testState.loadingTimestamps = [];
testState.requestedAddresses = undefined;
latestValue = {
error: null,
isLoading: true,
popularPosts: [],
};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('keeps the box loading until eight unique boards can supply eight posts', async () => {
const addresses = Array.from({ length: 8 }, (_, index) => `board-${index}.eth`);
testState.loadingTimestamps = addresses.map(() => 1_704_067_180);
await renderHook(addresses, [
createSubplebbit(addresses[0], [createPost(addresses[0], 'top', 20), createPost(addresses[0], 'backup', 10)]),
createSubplebbit(addresses[1], [createPost(addresses[1], 'top', 18), createPost(addresses[1], 'backup', 9)]),
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
]);
expect(testState.requestedAddresses).toEqual(addresses);
expect(latestValue.isLoading).toBe(true);
expect(latestValue.popularPosts).toEqual([]);
await renderHook(
addresses,
addresses.map((address, index) => createSubplebbit(address, [createPost(address, 'top', 20 - index), createPost(address, 'backup', 5 - index)])),
);
expect(latestValue.isLoading).toBe(false);
expect(latestValue.popularPosts).toHaveLength(8);
expect(new Set(latestValue.popularPosts.map((post) => post.subplebbitAddress)).size).toBe(8);
expect(latestValue.popularPosts.every((post) => post.cid.endsWith('-top'))).toBe(true);
});
it('freezes the revealed set until the input board list changes', async () => {
const addresses = Array.from({ length: 8 }, (_, index) => `board-${index}.eth`);
testState.loadingTimestamps = addresses.map(() => 1_704_067_180);
await renderHook(
addresses,
addresses.map((address, index) => createSubplebbit(address, [createPost(address, 'initial', 30 - index)])),
);
const initialCids = latestValue.popularPosts.map((post) => post.cid);
expect(latestValue.isLoading).toBe(false);
await renderHook(
addresses,
addresses.map((address, index) => createSubplebbit(address, [createPost(address, 'replacement', 100 - index), createPost(address, 'initial', 30 - index)])),
);
expect(latestValue.isLoading).toBe(false);
expect(latestValue.popularPosts.map((post) => post.cid)).toEqual(initialCids);
});
it('reveals the committed posts once the remaining boards time out', async () => {
const addresses = ['board-0.eth', 'board-1.eth'];
testState.loadingTimestamps = [1_704_067_180, 1_704_067_180];
await renderHook(addresses, [createSubplebbit(addresses[0], [createPost(addresses[0], 'only', 12)]), undefined]);
expect(latestValue.isLoading).toBe(true);
expect(latestValue.popularPosts).toEqual([]);
testState.currentTime = 1_704_067_211;
await renderHook(addresses, [createSubplebbit(addresses[0], [createPost(addresses[0], 'only', 12)]), undefined]);
expect(latestValue.isLoading).toBe(false);
expect(latestValue.popularPosts.map((post) => post.cid)).toEqual([`${addresses[0]}-only`]);
});
it('keeps loading forever when no board ever produces a thread', async () => {
const addresses = ['board-0.eth'];
testState.currentTime = 1_704_067_240;
testState.loadingTimestamps = [1_704_067_180];
await renderHook(addresses, [undefined]);
expect(latestValue.isLoading).toBe(true);
expect(latestValue.popularPosts).toEqual([]);
});
});
+82 -34
View File
@@ -1,13 +1,27 @@
import { useMemo, useRef } from 'react'; import { useMemo, useRef } from 'react';
import { Comment, Subplebbit } from '@bitsocialhq/bitsocial-react-hooks'; import { Comment, Subplebbit } from '@bitsocialhq/bitsocial-react-hooks';
import { getCommentMediaInfo, getHasThumbnail } from '../lib/utils/media-utils'; import { getCommentMediaInfo, getHasThumbnail } from '../lib/utils/media-utils';
import useSubplebbitsLoadingStartTimestamps from '../stores/use-subplebbits-loading-start-timestamps-store';
import { useCurrentTime } from './use-current-time';
const MAX_POSTS = 8; const MAX_POSTS = 8;
const MAX_PER_SUB = 3; const BOARD_LOADING_TIMEOUT_SECONDS = 30;
// Activity relevance halves every 3 days // Activity relevance halves every 3 days
const HALF_LIFE_SECONDS = 72 * 3600; const HALF_LIFE_SECONDS = 72 * 3600;
type PopularPostCandidate = {
boardAddress: string;
post: Comment;
};
type CommittedPopularPosts = {
boardAddresses: Set<string>;
cids: Set<string>;
posts: Comment[];
revealed: boolean;
};
/** /**
* Time-decayed popularity: replyCount divided by age of latest * Time-decayed popularity: replyCount divided by age of latest
* activity so a stale post with many old replies loses to a newer * activity so a stale post with many old replies loses to a newer
@@ -20,54 +34,75 @@ function popularityScore(post: Comment, nowSeconds: number): number {
return Math.max(replies, 0.1) / (1 + ageSeconds / HALF_LIFE_SECONDS); return Math.max(replies, 0.1) / (1 + ageSeconds / HALF_LIFE_SECONDS);
} }
function isBoardStillLoading(subplebbit: Subplebbit | undefined, loadingStartTimestamp: number | undefined, nowSeconds: number): boolean {
if (subplebbit?.updatedAt) {
return false;
}
if (!loadingStartTimestamp) {
return true;
}
return nowSeconds - loadingStartTimestamp < BOARD_LOADING_TIMEOUT_SECONDS;
}
/** /**
* Ranked by time-decayed popularity so the box surfaces posts with * Ranked by time-decayed popularity so the box surfaces posts with
* recent engagement rather than stale all-time reply leaders. * recent engagement rather than stale all-time reply leaders.
* *
* Grow-only commit: once a post enters the grid it never shifts or * The first revealed set is frozen until the user refreshes or changes
* disappears — new posts fill remaining slots until the cap is reached. * the board filter, so threads never disappear during background loads.
*/ */
const usePopularPosts = (subplebbits: Subplebbit[]) => { const usePopularPosts = (subplebbits: Array<Subplebbit | undefined>, subplebbitAddresses: string[]) => {
const committedRef = useRef<{ posts: Comment[]; cids: Set<string> }>({ const inputKey = [...subplebbitAddresses].sort().join(',');
const committedRef = useRef<CommittedPopularPosts>({
boardAddresses: new Set(),
posts: [], posts: [],
cids: new Set(), cids: new Set(),
revealed: false,
}); });
const prevInputKeyRef = useRef(''); const prevInputKeyRef = useRef('');
// Reset committed when the board set changes (e.g. NSFW filter toggle) // Reset committed when the requested board set changes (e.g. NSFW filter toggle).
const inputKey = subplebbits
.map((s) => s?.address)
.filter(Boolean)
.sort()
.join(',');
if (prevInputKeyRef.current !== inputKey) { if (prevInputKeyRef.current !== inputKey) {
prevInputKeyRef.current = inputKey; prevInputKeyRef.current = inputKey;
committedRef.current = { posts: [], cids: new Set() }; committedRef.current = {
boardAddresses: new Set(),
posts: [],
cids: new Set(),
revealed: false,
};
} }
const candidates = useMemo(() => { const currentTime = useCurrentTime(committedRef.current.revealed ? 300 : 5);
if (committedRef.current.posts.length >= MAX_POSTS) return []; const nowSeconds = Math.floor(currentTime);
const loadingStartTimestamps = useSubplebbitsLoadingStartTimestamps(subplebbitAddresses);
const nowSeconds = Math.floor(Date.now() / 1000); const candidates = useMemo<PopularPostCandidate[]>(() => {
if (committedRef.current.revealed || committedRef.current.posts.length >= MAX_POSTS) {
return [];
}
try { try {
const uniqueLinks = new Set<string>(); const selectedLinks = new Set<string>();
const allPosts: Comment[] = []; const allPosts: PopularPostCandidate[] = [];
for (const sub of subplebbits) { subplebbitAddresses.forEach((boardAddress, index) => {
if (!sub?.posts?.pages?.hot?.comments) continue; const subplebbit = subplebbits[index];
if (!boardAddress || !subplebbit?.posts?.pages?.hot?.comments) {
return;
}
const subPosts: Comment[] = []; const subPosts: Comment[] = [];
for (const post of Object.values(sub.posts.pages.hot.comments as Comment)) { for (const post of Object.values(subplebbit.posts.pages.hot.comments as Record<string, Comment>)) {
const { deleted, link, linkHeight, linkWidth, locked, pinned, removed, thumbnailUrl } = post; const { deleted, link, linkHeight, linkWidth, locked, pinned, removed, thumbnailUrl } = post;
try { try {
const commentMediaInfo = getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight); const commentMediaInfo = getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
const hasThumbnail = getHasThumbnail(commentMediaInfo, link); const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
if (hasThumbnail && !deleted && !removed && !locked && !pinned && !uniqueLinks.has(link)) { if (hasThumbnail && !deleted && !removed && !locked && !pinned) {
subPosts.push(post); subPosts.push(post);
uniqueLinks.add(link);
} }
} catch { } catch {
// skip posts with malformed media URLs // skip posts with malformed media URLs
@@ -75,32 +110,45 @@ const usePopularPosts = (subplebbits: Subplebbit[]) => {
} }
subPosts.sort((a, b) => popularityScore(b, nowSeconds) - popularityScore(a, nowSeconds)); subPosts.sort((a, b) => popularityScore(b, nowSeconds) - popularityScore(a, nowSeconds));
allPosts.push(...subPosts.slice(0, MAX_PER_SUB));
}
allPosts.sort((a, b) => popularityScore(b, nowSeconds) - popularityScore(a, nowSeconds)); const bestPost = subPosts.find((post) => !selectedLinks.has(post.link));
if (bestPost) {
allPosts.push({ boardAddress, post: bestPost });
selectedLinks.add(bestPost.link);
}
});
allPosts.sort((a, b) => popularityScore(b.post, nowSeconds) - popularityScore(a.post, nowSeconds));
return allPosts; return allPosts;
} catch (err) { } catch (err) {
console.error('Error in usePopularPosts:', err); console.error('Error in usePopularPosts:', err);
return []; return [];
} }
}, [subplebbits]); }, [nowSeconds, subplebbits, subplebbitAddresses]);
// Grow-only: committed posts keep their position, new ones fill empty slots const { boardAddresses, posts, cids } = committedRef.current;
const { posts, cids } = committedRef.current; for (const candidate of candidates) {
for (const post of candidates) { if (posts.length >= MAX_POSTS || committedRef.current.revealed) {
if (posts.length >= MAX_POSTS) break; break;
if (post.cid && !cids.has(post.cid)) { }
const { boardAddress, post } = candidate;
if (post.cid && !cids.has(post.cid) && !boardAddresses.has(boardAddress)) {
posts.push(post); posts.push(post);
cids.add(post.cid); cids.add(post.cid);
boardAddresses.add(boardAddress);
} }
} }
const hasLoadedData = subplebbits.some((sub) => sub?.posts?.pages?.hot?.comments); const hasPendingBoards = subplebbitAddresses.some((_, index) => isBoardStillLoading(subplebbits[index], loadingStartTimestamps[index], nowSeconds));
const isLoading = subplebbits.length > 0 && !hasLoadedData; if (!committedRef.current.revealed && (posts.length >= MAX_POSTS || (!hasPendingBoards && posts.length > 0))) {
committedRef.current.revealed = true;
}
return { popularPosts: posts, isLoading, error: null as string | null }; const isLoading = !committedRef.current.revealed;
return { popularPosts: committedRef.current.revealed ? posts : [], isLoading, error: null as string | null };
}; };
export default usePopularPosts; export default usePopularPosts;
+1 -1
View File
@@ -211,7 +211,7 @@ const Home = () => {
<SearchBar /> <SearchBar />
<InfoBox /> <InfoBox />
<BoardsList multisub={directories} /> <BoardsList multisub={directories} />
<PopularThreadsBox directories={directories} subplebbits={subplebbits} /> <PopularThreadsBox directories={directories} directoryAddresses={directoryAddresses} subplebbits={subplebbits} />
<Stats directoryAddresses={directoryAddresses} /> <Stats directoryAddresses={directoryAddresses} />
<Footer /> <Footer />
</div> </div>
@@ -19,7 +19,7 @@ interface PopularThreadProps {
boardPath: string; boardPath: string;
} }
export const ContentPreview = ({ content, maxLength = 99 }: { content: string; maxLength?: number }) => { const ContentPreview = ({ content, maxLength = 99 }: { content: string; maxLength?: number }) => {
const plainText = removeMarkdown(content).trim().replaceAll('&nbsp;', '').replace(/\n\n/g, '\n').replaceAll('\n\n', ''); const plainText = removeMarkdown(content).trim().replaceAll('&nbsp;', '').replace(/\n\n/g, '\n').replaceAll('\n\n', '');
const truncatedText = plainText.length > maxLength ? `${plainText.substring(0, maxLength).trim()}...` : plainText; const truncatedText = plainText.length > maxLength ? `${plainText.substring(0, maxLength).trim()}...` : plainText;