mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
166 lines
6.0 KiB
TypeScript
166 lines
6.0 KiB
TypeScript
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<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;
|