Files
5chan/src/hooks/use-popular-posts.ts
T

166 lines
6.0 KiB
TypeScript

import { useMemo } from 'react';
import { Comment, type Community } from '@bitsocialnet/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<string, PopularPostsCacheEntry>();
/**
* 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<Community | undefined>, 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<PopularPostCandidate[]>(() => {
if (cacheEntry.revealed || cacheEntry.posts.length >= MAX_POSTS) {
return [];
}
try {
const selectedLinks = new Set<string>();
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<string, Comment>)) {
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;