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 { Comment, Subplebbit } from '@plebbit/plebbit-react-hooks';
|
||||||
import { getCommentMediaInfo, getHasThumbnail } from '../lib/utils/media-utils';
|
import { getCommentMediaInfo, getHasThumbnail } from '../lib/utils/media-utils';
|
||||||
|
|
||||||
|
const MAX_POSTS = 8;
|
||||||
|
const MAX_PER_SUB = 3;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts popular posts from subplebbits.
|
* Ranked by replyCount instead of a static threshold so the box
|
||||||
* Uses memoization to avoid recomputing when only updatingState changes.
|
* 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[]) => {
|
const usePopularPosts = (subplebbits: Subplebbit[]) => {
|
||||||
// Track the previous CID list to detect actual content changes vs transient state changes
|
const committedRef = useRef<{ posts: Comment[]; cids: Set<string> }>({
|
||||||
const prevCidsRef = useRef<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 {
|
try {
|
||||||
const uniqueLinks: Set<string> = new Set();
|
const uniqueLinks = new Set<string>();
|
||||||
const allPosts: Comment[] = [];
|
const allPosts: Comment[] = [];
|
||||||
|
|
||||||
// Base quota on boards that currently have loaded hot comments.
|
for (const sub of subplebbits) {
|
||||||
// Using total directory count can underfill the list when many boards are empty/unavailable.
|
if (!sub?.posts?.pages?.hot?.comments) continue;
|
||||||
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)];
|
|
||||||
|
|
||||||
subplebbits.forEach((subplebbit: any) => {
|
const subPosts: Comment[] = [];
|
||||||
let subplebbitPosts: 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) {
|
try {
|
||||||
const rawPosts = Object.values(subplebbit.posts.pages.hot.comments as Comment);
|
const commentMediaInfo = getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
|
||||||
for (const post of rawPosts) {
|
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
|
||||||
const { deleted, link, linkHeight, linkWidth, locked, pinned, removed, replyCount, thumbnailUrl } = post;
|
|
||||||
|
|
||||||
try {
|
if (hasThumbnail && !deleted && !removed && !locked && !pinned && !uniqueLinks.has(link)) {
|
||||||
const commentMediaInfo = getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
|
subPosts.push(post);
|
||||||
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
|
uniqueLinks.add(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);
|
|
||||||
}
|
}
|
||||||
|
} 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 allPosts;
|
||||||
|
|
||||||
return { popularPosts: sortedPosts, error: null };
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error in usePopularPosts:', err);
|
console.error('Error in usePopularPosts:', err);
|
||||||
return { popularPosts: [], error: 'Failed to fetch popular posts' };
|
return [];
|
||||||
}
|
}
|
||||||
}, [subplebbits]);
|
}, [subplebbits]);
|
||||||
|
|
||||||
// Create stable reference: only update if the post content actually changes
|
// Grow-only: committed posts keep their position, new ones fill empty slots
|
||||||
// Build a key from relevant mutable fields, not just CIDs
|
const { posts, cids } = committedRef.current;
|
||||||
const currentKey = popularPosts.map((p) => `${p.cid}:${p.replyCount}:${p.timestamp}:${p.locked}:${p.pinned}`).join(',');
|
for (const post of candidates) {
|
||||||
const stablePostsRef = useRef<Comment[]>(popularPosts);
|
if (posts.length >= MAX_POSTS) break;
|
||||||
const keyChanged = currentKey !== prevCidsRef.current;
|
if (post.cid && !cids.has(post.cid)) {
|
||||||
|
posts.push(post);
|
||||||
if (keyChanged) {
|
cids.add(post.cid);
|
||||||
prevCidsRef.current = currentKey;
|
}
|
||||||
stablePostsRef.current = popularPosts;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 hasLoadedData = subplebbits.some((sub) => sub?.posts?.pages?.hot?.comments);
|
||||||
const isLoading = subplebbits.length > 0 && !hasLoadedData;
|
const isLoading = subplebbits.length > 0 && !hasLoadedData;
|
||||||
|
|
||||||
return { popularPosts: stablePostsRef.current, isLoading, error };
|
return { popularPosts: posts, isLoading, error: null as string | null };
|
||||||
};
|
};
|
||||||
|
|
||||||
export default usePopularPosts;
|
export default usePopularPosts;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import useHomeFiltersStore from '../../../stores/use-popular-threads-options-store';
|
import useHomeFiltersStore from '../../../stores/use-popular-threads-options-store';
|
||||||
import styles from '../home.module.css';
|
import styles from '../home.module.css';
|
||||||
@@ -11,21 +11,37 @@ const BoxModal = () => {
|
|||||||
|
|
||||||
const { showWorksafeContentOnly, setShowWorksafeContentOnly, showNsfwContentOnly, setShowNsfwContentOnly } = useHomeFiltersStore();
|
const { showWorksafeContentOnly, setShowWorksafeContentOnly, showNsfwContentOnly, setShowNsfwContentOnly } = useHomeFiltersStore();
|
||||||
|
|
||||||
const handleClickOutside = useCallback(
|
useEffect(() => {
|
||||||
(event: MouseEvent) => {
|
if (!showFilterModal) return;
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (modalRef.current && !modalRef.current.contains(event.target as Node) && buttonRef.current && !buttonRef.current.contains(event.target as Node)) {
|
if (modalRef.current && !modalRef.current.contains(event.target as Node) && buttonRef.current && !buttonRef.current.contains(event.target as Node)) {
|
||||||
setShowFilterModal(false);
|
setShowFilterModal(false);
|
||||||
}
|
}
|
||||||
},
|
|
||||||
[modalRef, buttonRef, setShowFilterModal],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
};
|
};
|
||||||
}, [handleClickOutside]);
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, [showFilterModal]);
|
||||||
|
|
||||||
|
const selectFilter = (filter: 'worksafe' | 'nsfw' | 'all') => {
|
||||||
|
if (filter === 'worksafe') {
|
||||||
|
if (showNsfwContentOnly) setShowNsfwContentOnly(false);
|
||||||
|
setShowWorksafeContentOnly(!showWorksafeContentOnly);
|
||||||
|
} else if (filter === 'nsfw') {
|
||||||
|
if (showWorksafeContentOnly) setShowWorksafeContentOnly(false);
|
||||||
|
setShowNsfwContentOnly(!showNsfwContentOnly);
|
||||||
|
} else {
|
||||||
|
setShowWorksafeContentOnly(false);
|
||||||
|
setShowNsfwContentOnly(false);
|
||||||
|
}
|
||||||
|
setShowFilterModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKey = (e: React.KeyboardEvent, filter: 'worksafe' | 'nsfw' | 'all') => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
selectFilter(filter);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -46,70 +62,29 @@ const BoxModal = () => {
|
|||||||
{showFilterModal && (
|
{showFilterModal && (
|
||||||
<div ref={modalRef} className={styles.filterModal}>
|
<div ref={modalRef} className={styles.filterModal}>
|
||||||
<div
|
<div
|
||||||
className={`${styles.option} ${showWorksafeContentOnly && styles.selected}`}
|
className={`${styles.option} ${showWorksafeContentOnly ? styles.selected : ''}`}
|
||||||
role='button'
|
role='button'
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => handleKey(e, 'worksafe')}
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
onClick={() => selectFilter('worksafe')}
|
||||||
e.preventDefault();
|
|
||||||
if (showNsfwContentOnly) {
|
|
||||||
setShowNsfwContentOnly(false);
|
|
||||||
}
|
|
||||||
setShowWorksafeContentOnly(!showWorksafeContentOnly);
|
|
||||||
setShowFilterModal(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (showNsfwContentOnly) {
|
|
||||||
setShowNsfwContentOnly(false);
|
|
||||||
}
|
|
||||||
setShowWorksafeContentOnly(!showWorksafeContentOnly);
|
|
||||||
setShowFilterModal(false);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t('show_worksafe_content_only')}
|
{t('show_worksafe_content_only')}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`${styles.option} ${showNsfwContentOnly && styles.selected}`}
|
className={`${styles.option} ${showNsfwContentOnly ? styles.selected : ''}`}
|
||||||
role='button'
|
role='button'
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => handleKey(e, 'nsfw')}
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
onClick={() => selectFilter('nsfw')}
|
||||||
e.preventDefault();
|
|
||||||
if (showWorksafeContentOnly) {
|
|
||||||
setShowWorksafeContentOnly(false);
|
|
||||||
}
|
|
||||||
setShowNsfwContentOnly(!showNsfwContentOnly);
|
|
||||||
setShowFilterModal(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (showWorksafeContentOnly) {
|
|
||||||
setShowWorksafeContentOnly(false);
|
|
||||||
}
|
|
||||||
setShowNsfwContentOnly(!showNsfwContentOnly);
|
|
||||||
setShowFilterModal(false);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t('show_nsfw_content_only')}
|
{t('show_nsfw_content_only')}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`${styles.option} ${!showWorksafeContentOnly && !showNsfwContentOnly && styles.selected}`}
|
className={`${styles.option} ${!showWorksafeContentOnly && !showNsfwContentOnly ? styles.selected : ''}`}
|
||||||
role='button'
|
role='button'
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => handleKey(e, 'all')}
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
onClick={() => selectFilter('all')}
|
||||||
e.preventDefault();
|
|
||||||
setShowWorksafeContentOnly(false);
|
|
||||||
setShowNsfwContentOnly(false);
|
|
||||||
setShowFilterModal(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
setShowWorksafeContentOnly(false);
|
|
||||||
setShowNsfwContentOnly(false);
|
|
||||||
setShowFilterModal(false);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t('show_all_content')}
|
{t('show_all_content')}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import { removeMarkdown } from '../../../lib/utils/post-utils';
|
|||||||
|
|
||||||
interface PopularThreadProps {
|
interface PopularThreadProps {
|
||||||
post: Comment;
|
post: Comment;
|
||||||
directories: DirectoryCommunity[];
|
boardTitle: string;
|
||||||
|
boardPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ContentPreview = ({ content, maxLength = 99 }: { content: string; maxLength?: number }) => {
|
export const ContentPreview = ({ content, maxLength = 99 }: { content: string; maxLength?: number }) => {
|
||||||
@@ -25,17 +26,11 @@ export const ContentPreview = ({ content, maxLength = 99 }: { content: string; m
|
|||||||
return truncatedText;
|
return truncatedText;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Memoize to prevent rerenders when parent rerenders due to updatingState
|
|
||||||
const PopularThreadCard = memo(
|
const PopularThreadCard = memo(
|
||||||
({ post, directories }: PopularThreadProps) => {
|
({ post, boardTitle, boardPath }: PopularThreadProps) => {
|
||||||
const { cid, content, link, linkHeight, linkWidth, subplebbitAddress, thumbnailUrl, title } = post || {};
|
const { cid, content, link, linkHeight, linkWidth, thumbnailUrl, title } = post || {};
|
||||||
const commentMediaInfo = getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
|
const commentMediaInfo = getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
|
||||||
|
|
||||||
// Find the matching DirectoryCommunity entry and get its title
|
|
||||||
const directoriesEntry = directories.find((ms) => ms?.address === subplebbitAddress);
|
|
||||||
const boardTitle = directoriesEntry?.title?.replace(/^\/[^/]+\/\s*-\s*/, '') || '';
|
|
||||||
const boardPath = subplebbitAddress ? getBoardPath(subplebbitAddress, directories) : '';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.popularThread} key={cid}>
|
<div className={styles.popularThread} key={cid}>
|
||||||
<div className={styles.title}>{boardTitle}</div>
|
<div className={styles.title}>{boardTitle}</div>
|
||||||
@@ -56,35 +51,31 @@ const PopularThreadCard = memo(
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
// Custom equality: rerender if post.cid or directories entry title changes
|
(prevProps, nextProps) => prevProps.post?.cid === nextProps.post?.cid && prevProps.boardTitle === nextProps.boardTitle,
|
||||||
(prevProps, nextProps) => {
|
|
||||||
if (prevProps.post?.cid !== nextProps.post?.cid) return false;
|
|
||||||
// Compare the relevant directories entry for this post's subplebbitAddress
|
|
||||||
const prevEntry = prevProps.directories.find((ms) => ms?.address === prevProps.post?.subplebbitAddress);
|
|
||||||
const nextEntry = nextProps.directories.find((ms) => ms?.address === nextProps.post?.subplebbitAddress);
|
|
||||||
return prevEntry?.title === nextEntry?.title;
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const PopularThreadsBox = ({ directories, subplebbits }: { directories: DirectoryCommunity[]; subplebbits: any }) => {
|
const PopularThreadsBox = ({ directories, subplebbits }: { directories: DirectoryCommunity[]; subplebbits: any }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { showWorksafeContentOnly, showNsfwContentOnly } = usePopularThreadsOptionsStore();
|
const { showWorksafeContentOnly, showNsfwContentOnly } = usePopularThreadsOptionsStore();
|
||||||
|
|
||||||
|
const directoryByAddress = useMemo(() => new Map(directories.map((d) => [d.address, d])), [directories]);
|
||||||
|
|
||||||
const filteredSubplebbits = useMemo(() => {
|
const filteredSubplebbits = useMemo(() => {
|
||||||
if (showWorksafeContentOnly) {
|
if (showWorksafeContentOnly) {
|
||||||
return subplebbits.filter((sub: Subplebbit) => {
|
return subplebbits.filter((sub: Subplebbit) => {
|
||||||
const directoriesEntry = directories.find((ms) => ms?.address === sub?.address);
|
const entry = directoryByAddress.get(sub?.address);
|
||||||
return directoriesEntry ? !directoriesEntry.nsfw : true;
|
return entry ? !entry.nsfw : true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (showNsfwContentOnly) {
|
if (showNsfwContentOnly) {
|
||||||
return subplebbits.filter((sub: Subplebbit) => {
|
return subplebbits.filter((sub: Subplebbit) => {
|
||||||
const directoriesEntry = directories.find((ms) => ms?.address === sub?.address);
|
const entry = directoryByAddress.get(sub?.address);
|
||||||
return directoriesEntry ? directoriesEntry.nsfw : false;
|
return entry ? entry.nsfw : false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return subplebbits;
|
return subplebbits;
|
||||||
}, [subplebbits, showWorksafeContentOnly, showNsfwContentOnly, directories]);
|
}, [subplebbits, showWorksafeContentOnly, showNsfwContentOnly, directoryByAddress]);
|
||||||
|
|
||||||
const { popularPosts } = usePopularPosts(filteredSubplebbits);
|
const { popularPosts } = usePopularPosts(filteredSubplebbits);
|
||||||
const isLoading = popularPosts.length === 0;
|
const isLoading = popularPosts.length === 0;
|
||||||
|
|
||||||
@@ -98,7 +89,12 @@ const PopularThreadsBox = ({ directories, subplebbits }: { directories: Director
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<LoadingEllipsis string={t('loading')} />
|
<LoadingEllipsis string={t('loading')} />
|
||||||
) : (
|
) : (
|
||||||
popularPosts.map((post: any) => <PopularThreadCard key={post.cid} post={post} directories={directories} />)
|
popularPosts.map((post: Comment) => {
|
||||||
|
const entry = directoryByAddress.get(post.subplebbitAddress);
|
||||||
|
const boardTitle = entry?.title?.replace(/^\/[^/]+\/\s*-\s*/, '') || '';
|
||||||
|
const boardPath = post.subplebbitAddress ? getBoardPath(post.subplebbitAddress, directories) : '';
|
||||||
|
return <PopularThreadCard key={post.cid} post={post} boardTitle={boardTitle} boardPath={boardPath} />;
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user