2026-01-02 16:42:16 +01:00
|
|
|
import { useMemo, useRef } from 'react';
|
2026-03-05 17:14:23 +08:00
|
|
|
import { Comment, Subplebbit } from '@bitsocialhq/bitsocial-react-hooks';
|
2024-05-10 21:47:00 +02:00
|
|
|
import { getCommentMediaInfo, getHasThumbnail } from '../lib/utils/media-utils';
|
2026-03-08 16:48:38 +08:00
|
|
|
import useSubplebbitsLoadingStartTimestamps from '../stores/use-subplebbits-loading-start-timestamps-store';
|
|
|
|
|
import { useCurrentTime } from './use-current-time';
|
2024-05-10 21:47:00 +02:00
|
|
|
|
2026-02-25 16:49:43 +08:00
|
|
|
const MAX_POSTS = 8;
|
2026-03-08 16:48:38 +08:00
|
|
|
const BOARD_LOADING_TIMEOUT_SECONDS = 30;
|
2026-02-25 16:49:43 +08:00
|
|
|
|
2026-03-03 14:51:42 +08:00
|
|
|
// Activity relevance halves every 3 days
|
|
|
|
|
const HALF_LIFE_SECONDS = 72 * 3600;
|
|
|
|
|
|
2026-03-08 16:48:38 +08:00
|
|
|
type PopularPostCandidate = {
|
|
|
|
|
boardAddress: string;
|
|
|
|
|
post: Comment;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type CommittedPopularPosts = {
|
|
|
|
|
boardAddresses: Set<string>;
|
|
|
|
|
cids: Set<string>;
|
|
|
|
|
posts: Comment[];
|
|
|
|
|
revealed: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-02 16:42:16 +01:00
|
|
|
/**
|
2026-03-03 14:51:42 +08:00
|
|
|
* Time-decayed popularity: replyCount divided by age of latest
|
|
|
|
|
* activity so a stale post with many old replies loses to a newer
|
|
|
|
|
* post with a few recent replies.
|
|
|
|
|
*/
|
|
|
|
|
function popularityScore(post: Comment, nowSeconds: number): number {
|
|
|
|
|
const lastActivity = post.lastReplyTimestamp ?? post.timestamp ?? 0;
|
|
|
|
|
const ageSeconds = Math.max(0, nowSeconds - lastActivity);
|
|
|
|
|
const replies = post.replyCount ?? 0;
|
|
|
|
|
return Math.max(replies, 0.1) / (1 + ageSeconds / HALF_LIFE_SECONDS);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 16:48:38 +08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 14:51:42 +08:00
|
|
|
/**
|
|
|
|
|
* Ranked by time-decayed popularity so the box surfaces posts with
|
|
|
|
|
* recent engagement rather than stale all-time reply leaders.
|
2026-02-25 16:49:43 +08:00
|
|
|
*
|
2026-03-08 16:48:38 +08:00
|
|
|
* The first revealed set is frozen until the user refreshes or changes
|
|
|
|
|
* the board filter, so threads never disappear during background loads.
|
2026-01-02 16:42:16 +01:00
|
|
|
*/
|
2026-03-08 16:48:38 +08:00
|
|
|
const usePopularPosts = (subplebbits: Array<Subplebbit | undefined>, subplebbitAddresses: string[]) => {
|
|
|
|
|
const inputKey = [...subplebbitAddresses].sort().join(',');
|
|
|
|
|
const committedRef = useRef<CommittedPopularPosts>({
|
|
|
|
|
boardAddresses: new Set(),
|
2026-02-25 16:49:43 +08:00
|
|
|
posts: [],
|
|
|
|
|
cids: new Set(),
|
2026-03-08 16:48:38 +08:00
|
|
|
revealed: false,
|
2026-02-25 16:49:43 +08:00
|
|
|
});
|
|
|
|
|
const prevInputKeyRef = useRef('');
|
|
|
|
|
|
2026-03-08 16:48:38 +08:00
|
|
|
// Reset committed when the requested board set changes (e.g. NSFW filter toggle).
|
2026-02-25 16:49:43 +08:00
|
|
|
if (prevInputKeyRef.current !== inputKey) {
|
|
|
|
|
prevInputKeyRef.current = inputKey;
|
2026-03-08 16:48:38 +08:00
|
|
|
committedRef.current = {
|
|
|
|
|
boardAddresses: new Set(),
|
|
|
|
|
posts: [],
|
|
|
|
|
cids: new Set(),
|
|
|
|
|
revealed: false,
|
|
|
|
|
};
|
2026-02-25 16:49:43 +08:00
|
|
|
}
|
|
|
|
|
|
2026-03-08 16:48:38 +08:00
|
|
|
const currentTime = useCurrentTime(committedRef.current.revealed ? 300 : 5);
|
|
|
|
|
const nowSeconds = Math.floor(currentTime);
|
|
|
|
|
const loadingStartTimestamps = useSubplebbitsLoadingStartTimestamps(subplebbitAddresses);
|
2024-05-10 21:47:00 +02:00
|
|
|
|
2026-03-08 16:48:38 +08:00
|
|
|
const candidates = useMemo<PopularPostCandidate[]>(() => {
|
|
|
|
|
if (committedRef.current.revealed || committedRef.current.posts.length >= MAX_POSTS) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
2026-03-03 14:51:42 +08:00
|
|
|
|
2026-01-02 16:42:16 +01:00
|
|
|
try {
|
2026-03-08 16:48:38 +08:00
|
|
|
const selectedLinks = new Set<string>();
|
|
|
|
|
const allPosts: PopularPostCandidate[] = [];
|
2024-06-03 17:26:47 +02:00
|
|
|
|
2026-03-08 16:48:38 +08:00
|
|
|
subplebbitAddresses.forEach((boardAddress, index) => {
|
|
|
|
|
const subplebbit = subplebbits[index];
|
|
|
|
|
if (!boardAddress || !subplebbit?.posts?.pages?.hot?.comments) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2024-05-10 21:47:00 +02:00
|
|
|
|
2026-02-25 16:49:43 +08:00
|
|
|
const subPosts: Comment[] = [];
|
2026-03-08 16:48:38 +08:00
|
|
|
for (const post of Object.values(subplebbit.posts.pages.hot.comments as Record<string, Comment>)) {
|
2026-02-25 16:49:43 +08:00
|
|
|
const { deleted, link, linkHeight, linkWidth, locked, pinned, removed, thumbnailUrl } = post;
|
2024-05-10 21:47:00 +02:00
|
|
|
|
2026-02-25 16:49:43 +08:00
|
|
|
try {
|
|
|
|
|
const commentMediaInfo = getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
|
|
|
|
|
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
|
2024-05-10 21:47:00 +02:00
|
|
|
|
2026-03-08 16:48:38 +08:00
|
|
|
if (hasThumbnail && !deleted && !removed && !locked && !pinned) {
|
2026-02-25 16:49:43 +08:00
|
|
|
subPosts.push(post);
|
2024-10-12 17:09:23 +02:00
|
|
|
}
|
2026-02-25 16:49:43 +08:00
|
|
|
} catch {
|
|
|
|
|
// skip posts with malformed media URLs
|
2024-05-10 21:47:00 +02:00
|
|
|
}
|
2026-01-02 16:42:16 +01:00
|
|
|
}
|
2026-02-25 16:49:43 +08:00
|
|
|
|
2026-03-03 14:51:42 +08:00
|
|
|
subPosts.sort((a, b) => popularityScore(b, nowSeconds) - popularityScore(a, nowSeconds));
|
2026-02-25 16:49:43 +08:00
|
|
|
|
2026-03-08 16:48:38 +08:00
|
|
|
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));
|
2024-10-12 17:09:23 +02:00
|
|
|
|
2026-02-25 16:49:43 +08:00
|
|
|
return allPosts;
|
2026-01-02 16:42:16 +01:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Error in usePopularPosts:', err);
|
2026-02-25 16:49:43 +08:00
|
|
|
return [];
|
2026-01-02 16:42:16 +01:00
|
|
|
}
|
2026-03-08 16:48:38 +08:00
|
|
|
}, [nowSeconds, subplebbits, subplebbitAddresses]);
|
2024-05-10 21:47:00 +02:00
|
|
|
|
2026-03-08 16:48:38 +08:00
|
|
|
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)) {
|
2026-02-25 16:49:43 +08:00
|
|
|
posts.push(post);
|
|
|
|
|
cids.add(post.cid);
|
2026-03-08 16:48:38 +08:00
|
|
|
boardAddresses.add(boardAddress);
|
2026-02-25 16:49:43 +08:00
|
|
|
}
|
2026-01-02 16:42:16 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-08 16:48:38 +08:00
|
|
|
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;
|
|
|
|
|
}
|
2026-01-02 16:42:16 +01:00
|
|
|
|
2026-03-08 16:48:38 +08:00
|
|
|
const isLoading = !committedRef.current.revealed;
|
|
|
|
|
|
|
|
|
|
return { popularPosts: committedRef.current.revealed ? posts : [], isLoading, error: null as string | null };
|
2024-05-10 21:47:00 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default usePopularPosts;
|