import { useMemo } from 'react'; import { Comment, type Community } from '@bitsocial/bitsocial-react-hooks'; import { getCommentMediaInfo, getHasThumbnail } from '../lib/utils/media-utils'; import useCommunitiesLoadingStartTimestamps from '../stores/use-communities-loading-start-timestamps-store'; import { useCurrentTime } from './use-current-time'; const MAX_POSTS = 8; 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 PopularPostsCacheEntry = { randomizedBoardAddresses: string[]; posts: Comment[]; revealed: boolean; }; const popularPostsCacheByInputKey = new Map(); /** * 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); } function isBoardStillLoading(community: Community | undefined, loadingStartTimestamp: number | undefined, nowSeconds: number): boolean { if (community?.updatedAt) { return false; } if (!loadingStartTimestamp) { return true; } return nowSeconds - loadingStartTimestamp < BOARD_LOADING_TIMEOUT_SECONDS; } 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; } function getPopularPostsInputKey(communityAddresses: string[]): string { return [...communityAddresses].sort().join(','); } function getPopularPostsCacheEntry(inputKey: string, communityAddresses: string[]): PopularPostsCacheEntry { const cachedEntry = popularPostsCacheByInputKey.get(inputKey); if (cachedEntry) { return cachedEntry; } const cacheEntry = { randomizedBoardAddresses: shuffleBoardAddresses(communityAddresses), posts: [], revealed: false, }; popularPostsCacheByInputKey.set(inputKey, cacheEntry); return cacheEntry; } export function getRevealedPopularPosts(communityAddresses: string[]): Comment[] | undefined { const cacheEntry = popularPostsCacheByInputKey.get(getPopularPostsInputKey(communityAddresses)); return cacheEntry?.revealed ? cacheEntry.posts : undefined; } export function clearPopularPostsCacheForTest() { popularPostsCacheByInputKey.clear(); } /** * Each board contributes at most one time-decayed popular thread, but the * board order is shuffled once per page load so repeat navigation keeps the * same threads until a real browser refresh creates a fresh module instance. * * The first revealed set is frozen until the user refreshes or changes * the board filter, so threads never disappear during background loads. */ const usePopularPosts = (communities: Array, communityAddresses: string[]) => { const inputKey = getPopularPostsInputKey(communityAddresses); const cacheEntry = getPopularPostsCacheEntry(inputKey, communityAddresses); const currentTime = useCurrentTime(cacheEntry.revealed ? false : 5); const nowSeconds = Math.floor(currentTime); const loadingStartTimestamps = useCommunitiesLoadingStartTimestamps(communityAddresses); const candidates = useMemo(() => { if (cacheEntry.revealed || cacheEntry.posts.length >= MAX_POSTS) { return []; } try { const selectedLinks = new Set(); const allPosts: PopularPostCandidate[] = []; const communitiesByAddress = new Map(communityAddresses.map((boardAddress, index) => [boardAddress, communities[index]])); cacheEntry.randomizedBoardAddresses.forEach((boardAddress) => { const community = communitiesByAddress.get(boardAddress); if (!boardAddress || !community?.posts?.pages?.hot?.comments) { return; } const subPosts: Comment[] = []; for (const post of Object.values(community.posts.pages.hot.comments as Record)) { 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) { subPosts.push(post); } } catch { // skip posts with malformed media URLs } } subPosts.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); } }); return allPosts; } catch (err) { console.error('Error in usePopularPosts:', err); return []; } }, [nowSeconds, communities, communityAddresses, cacheEntry]); const hasPendingBoards = communityAddresses.some((_, index) => isBoardStillLoading(communities[index], loadingStartTimestamps[index], nowSeconds)); if (!cacheEntry.revealed && (candidates.length >= MAX_POSTS || (!hasPendingBoards && candidates.length > 0))) { cacheEntry.posts = candidates.slice(0, MAX_POSTS).map(({ post }) => post); cacheEntry.revealed = true; } const isLoading = !cacheEntry.revealed; return { popularPosts: cacheEntry.revealed ? cacheEntry.posts : [], isLoading, error: null as string | null }; }; export default usePopularPosts;