mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(popular-threads): adaptive ranking with grow-only stability
This commit is contained in:
@@ -2,77 +2,92 @@ import { useMemo, useRef } from 'react';
|
||||
import { Comment, Subplebbit } from '@plebbit/plebbit-react-hooks';
|
||||
import { getCommentMediaInfo, getHasThumbnail } from '../lib/utils/media-utils';
|
||||
|
||||
const MAX_POSTS = 8;
|
||||
const MAX_PER_SUB = 3;
|
||||
|
||||
/**
|
||||
* Extracts popular posts from subplebbits.
|
||||
* Uses memoization to avoid recomputing when only updatingState changes.
|
||||
* Ranked by replyCount instead of a static threshold so the box
|
||||
* adapts to both low- and high-activity periods.
|
||||
*
|
||||
* Grow-only commit: once a post enters the grid it never shifts or
|
||||
* disappears — new posts fill remaining slots until the cap is reached.
|
||||
*/
|
||||
const usePopularPosts = (subplebbits: Subplebbit[]) => {
|
||||
// Track the previous CID list to detect actual content changes vs transient state changes
|
||||
const prevCidsRef = useRef<string>('');
|
||||
const committedRef = useRef<{ posts: Comment[]; cids: Set<string> }>({
|
||||
posts: [],
|
||||
cids: new Set(),
|
||||
});
|
||||
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(',');
|
||||
if (prevInputKeyRef.current !== inputKey) {
|
||||
prevInputKeyRef.current = inputKey;
|
||||
committedRef.current = { posts: [], cids: new Set() };
|
||||
}
|
||||
|
||||
const candidates = useMemo(() => {
|
||||
if (committedRef.current.posts.length >= MAX_POSTS) return [];
|
||||
|
||||
const { popularPosts, error } = useMemo(() => {
|
||||
try {
|
||||
const uniqueLinks: Set<string> = new Set();
|
||||
const uniqueLinks = new Set<string>();
|
||||
const allPosts: Comment[] = [];
|
||||
|
||||
// Base quota on boards that currently have loaded hot comments.
|
||||
// Using total directory count can underfill the list when many boards are empty/unavailable.
|
||||
const loadedSubplebbitsCount = subplebbits.filter((sub) => sub?.posts?.pages?.hot?.comments).length;
|
||||
const postsPerSub = [0, 8, 4, 3, 2, 2, 2, 2, 1][Math.min(loadedSubplebbitsCount, 8)];
|
||||
for (const sub of subplebbits) {
|
||||
if (!sub?.posts?.pages?.hot?.comments) continue;
|
||||
|
||||
subplebbits.forEach((subplebbit: any) => {
|
||||
let subplebbitPosts: Comment[] = [];
|
||||
const subPosts: Comment[] = [];
|
||||
for (const post of Object.values(sub.posts.pages.hot.comments as Comment)) {
|
||||
const { deleted, link, linkHeight, linkWidth, locked, pinned, removed, thumbnailUrl } = post;
|
||||
|
||||
if (subplebbit?.posts?.pages?.hot?.comments) {
|
||||
const rawPosts = Object.values(subplebbit.posts.pages.hot.comments as Comment);
|
||||
for (const post of rawPosts) {
|
||||
const { deleted, link, linkHeight, linkWidth, locked, pinned, removed, replyCount, thumbnailUrl } = post;
|
||||
try {
|
||||
const commentMediaInfo = getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
|
||||
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
|
||||
|
||||
try {
|
||||
const commentMediaInfo = getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
|
||||
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
|
||||
|
||||
if (hasThumbnail && replyCount > 1 && !deleted && !removed && !locked && !pinned && !uniqueLinks.has(link)) {
|
||||
subplebbitPosts.push(post);
|
||||
uniqueLinks.add(link);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error processing post:', err);
|
||||
if (hasThumbnail && !deleted && !removed && !locked && !pinned && !uniqueLinks.has(link)) {
|
||||
subPosts.push(post);
|
||||
uniqueLinks.add(link);
|
||||
}
|
||||
} catch {
|
||||
// skip posts with malformed media URLs
|
||||
}
|
||||
|
||||
subplebbitPosts.sort((a: any, b: any) => b.timestamp - a.timestamp);
|
||||
const selectedPosts = subplebbitPosts.slice(0, postsPerSub);
|
||||
allPosts.push(...selectedPosts);
|
||||
}
|
||||
|
||||
subPosts.sort((a, b) => (b.replyCount ?? 0) - (a.replyCount ?? 0));
|
||||
allPosts.push(...subPosts.slice(0, MAX_PER_SUB));
|
||||
}
|
||||
|
||||
// Primary: most replies first. Tiebreaker: newest first.
|
||||
allPosts.sort((a, b) => {
|
||||
const diff = (b.replyCount ?? 0) - (a.replyCount ?? 0);
|
||||
return diff !== 0 ? diff : (b.timestamp ?? 0) - (a.timestamp ?? 0);
|
||||
});
|
||||
|
||||
const sortedPosts = allPosts.sort((a: any, b: any) => b.timestamp - a.timestamp).slice(0, 8);
|
||||
|
||||
return { popularPosts: sortedPosts, error: null };
|
||||
return allPosts;
|
||||
} catch (err) {
|
||||
console.error('Error in usePopularPosts:', err);
|
||||
return { popularPosts: [], error: 'Failed to fetch popular posts' };
|
||||
return [];
|
||||
}
|
||||
}, [subplebbits]);
|
||||
|
||||
// Create stable reference: only update if the post content actually changes
|
||||
// Build a key from relevant mutable fields, not just CIDs
|
||||
const currentKey = popularPosts.map((p) => `${p.cid}:${p.replyCount}:${p.timestamp}:${p.locked}:${p.pinned}`).join(',');
|
||||
const stablePostsRef = useRef<Comment[]>(popularPosts);
|
||||
const keyChanged = currentKey !== prevCidsRef.current;
|
||||
|
||||
if (keyChanged) {
|
||||
prevCidsRef.current = currentKey;
|
||||
stablePostsRef.current = popularPosts;
|
||||
// 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)) {
|
||||
posts.push(post);
|
||||
cids.add(post.cid);
|
||||
}
|
||||
}
|
||||
|
||||
// Derive loading state from subplebbit states rather than post count
|
||||
// A subplebbit is still loading if it has no posts pages yet and isn't in a terminal state
|
||||
const hasLoadedData = subplebbits.some((sub) => sub?.posts?.pages?.hot?.comments);
|
||||
const isLoading = subplebbits.length > 0 && !hasLoadedData;
|
||||
|
||||
return { popularPosts: stablePostsRef.current, isLoading, error };
|
||||
return { popularPosts: posts, isLoading, error: null as string | null };
|
||||
};
|
||||
|
||||
export default usePopularPosts;
|
||||
|
||||
Reference in New Issue
Block a user