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
+82 -34
View File
@@ -1,13 +1,27 @@
import { useMemo, useRef } from 'react';
import { Comment, Subplebbit } from '@bitsocialhq/bitsocial-react-hooks';
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_PER_SUB = 3;
const BOARD_LOADING_TIMEOUT_SECONDS = 30;
// Activity relevance halves every 3 days
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
* 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);
}
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
* recent engagement rather than stale all-time reply leaders.
*
* Grow-only commit: once a post enters the grid it never shifts or
* disappears — new posts fill remaining slots until the cap is reached.
* The first revealed set is frozen until the user refreshes or changes
* the board filter, so threads never disappear during background loads.
*/
const usePopularPosts = (subplebbits: Subplebbit[]) => {
const committedRef = useRef<{ posts: Comment[]; cids: Set<string> }>({
const usePopularPosts = (subplebbits: Array<Subplebbit | undefined>, subplebbitAddresses: string[]) => {
const inputKey = [...subplebbitAddresses].sort().join(',');
const committedRef = useRef<CommittedPopularPosts>({
boardAddresses: new Set(),
posts: [],
cids: new Set(),
revealed: false,
});
const prevInputKeyRef = useRef('');
// Reset committed when the board set changes (e.g. NSFW filter toggle)
const inputKey = subplebbits
.map((s) => s?.address)
.filter(Boolean)
.sort()
.join(',');
// Reset committed when the requested board set changes (e.g. NSFW filter toggle).
if (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(() => {
if (committedRef.current.posts.length >= MAX_POSTS) return [];
const currentTime = useCurrentTime(committedRef.current.revealed ? 300 : 5);
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 {
const uniqueLinks = new Set<string>();
const allPosts: Comment[] = [];
const selectedLinks = new Set<string>();
const allPosts: PopularPostCandidate[] = [];
for (const sub of subplebbits) {
if (!sub?.posts?.pages?.hot?.comments) continue;
subplebbitAddresses.forEach((boardAddress, index) => {
const subplebbit = subplebbits[index];
if (!boardAddress || !subplebbit?.posts?.pages?.hot?.comments) {
return;
}
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;
try {
const commentMediaInfo = getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
if (hasThumbnail && !deleted && !removed && !locked && !pinned && !uniqueLinks.has(link)) {
if (hasThumbnail && !deleted && !removed && !locked && !pinned) {
subPosts.push(post);
uniqueLinks.add(link);
}
} catch {
// skip posts with malformed media URLs
@@ -75,32 +110,45 @@ const usePopularPosts = (subplebbits: Subplebbit[]) => {
}
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;
} catch (err) {
console.error('Error in usePopularPosts:', err);
return [];
}
}, [subplebbits]);
}, [nowSeconds, subplebbits, subplebbitAddresses]);
// Grow-only: committed posts keep their position, new ones fill empty slots
const { posts, cids } = committedRef.current;
for (const post of candidates) {
if (posts.length >= MAX_POSTS) break;
if (post.cid && !cids.has(post.cid)) {
const { boardAddresses, posts, cids } = committedRef.current;
for (const candidate of candidates) {
if (posts.length >= MAX_POSTS || committedRef.current.revealed) {
break;
}
const { boardAddress, post } = candidate;
if (post.cid && !cids.has(post.cid) && !boardAddresses.has(boardAddress)) {
posts.push(post);
cids.add(post.cid);
boardAddresses.add(boardAddress);
}
}
const hasLoadedData = subplebbits.some((sub) => sub?.posts?.pages?.hot?.comments);
const isLoading = subplebbits.length > 0 && !hasLoadedData;
const hasPendingBoards = subplebbitAddresses.some((_, index) => isBoardStillLoading(subplebbits[index], loadingStartTimestamps[index], nowSeconds));
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;