2026-01-02 16:42:16 +01:00
|
|
|
import { useMemo, useRef } from 'react';
|
2026-03-09 17:01:35 +08:00
|
|
|
import { Comment, Subplebbit } from '@bitsocialnet/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 = {
|
|
|
|
|
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-08 16:58:55 +08:00
|
|
|
function shuffleBoardAddresses(boardAddresses: string[]): string[] {
|
|
|
|
|
const shuffledBoardAddresses = [...boardAddresses];
|
|
|
|
|
|
|
|
|
|
for (let index = shuffledBoardAddresses.length - 1; index > 0; index -= 1) {
|
|
|
|
|
const randomIndex = Math.floor(Math.random() * (index + 1));
|
|
|
|
|
[shuffledBoardAddresses[index], shuffledBoardAddresses[randomIndex]] = [shuffledBoardAddresses[randomIndex], shuffledBoardAddresses[index]];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return shuffledBoardAddresses;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 14:51:42 +08:00
|
|
|
/**
|
2026-03-08 16:58:55 +08:00
|
|
|
* Each board contributes at most one time-decayed popular thread, but the
|
|
|
|
|
* board order is shuffled on mount so repeat visits surface different boards.
|
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>({
|
2026-02-25 16:49:43 +08:00
|
|
|
posts: [],
|
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:58:55 +08:00
|
|
|
const randomizedBoardAddressesRef = useRef<string[]>([]);
|
2026-02-25 16:49:43 +08:00
|
|
|
|
2026-03-08 16:58:55 +08:00
|
|
|
// Reset committed and reshuffle 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:58:55 +08:00
|
|
|
randomizedBoardAddressesRef.current = shuffleBoardAddresses(subplebbitAddresses);
|
2026-03-08 16:48:38 +08:00
|
|
|
committedRef.current = {
|
|
|
|
|
posts: [],
|
|
|
|
|
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[] = [];
|
2026-03-08 16:58:55 +08:00
|
|
|
const subplebbitsByAddress = new Map(subplebbitAddresses.map((boardAddress, index) => [boardAddress, subplebbits[index]]));
|
2024-06-03 17:26:47 +02:00
|
|
|
|
2026-03-08 16:58:55 +08:00
|
|
|
randomizedBoardAddressesRef.current.forEach((boardAddress) => {
|
|
|
|
|
const subplebbit = subplebbitsByAddress.get(boardAddress);
|
2026-03-08 16:48:38 +08:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
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 hasPendingBoards = subplebbitAddresses.some((_, index) => isBoardStillLoading(subplebbits[index], loadingStartTimestamps[index], nowSeconds));
|
2026-03-08 16:58:55 +08:00
|
|
|
|
|
|
|
|
if (!committedRef.current.revealed && (candidates.length >= MAX_POSTS || (!hasPendingBoards && candidates.length > 0))) {
|
|
|
|
|
committedRef.current.posts = candidates.slice(0, MAX_POSTS).map(({ post }) => post);
|
2026-03-08 16:48:38 +08:00
|
|
|
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;
|
|
|
|
|
|
2026-03-08 16:58:55 +08:00
|
|
|
return { popularPosts: committedRef.current.revealed ? committedRef.current.posts : [], isLoading, error: null as string | null };
|
2024-05-10 21:47:00 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default usePopularPosts;
|