mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(home): stabilize initial popular threads box
Keep usePopularPosts() keyed to the requested board list, wait to reveal the box until the first result set is stable, and limit the grid to one thread per board. Freeze the first revealed threads until refresh or filter changes so later board loads cannot displace visible cards.
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
import * as React from 'react';
|
||||
import { createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import usePopularPosts from '../use-popular-posts';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
currentTime: 1_704_067_200,
|
||||
loadingTimestamps: [] as number[],
|
||||
requestedAddresses: undefined as string[] | undefined,
|
||||
}));
|
||||
|
||||
vi.mock('../use-current-time', () => ({
|
||||
useCurrentTime: () => testState.currentTime,
|
||||
}));
|
||||
|
||||
vi.mock('../../stores/use-subplebbits-loading-start-timestamps-store', () => ({
|
||||
default: (addresses?: string[]) => {
|
||||
testState.requestedAddresses = addresses;
|
||||
return testState.loadingTimestamps;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/utils/media-utils', () => ({
|
||||
getCommentMediaInfo: (link: string) => ({ link }),
|
||||
getHasThumbnail: (_commentMediaInfo: unknown, link?: string) => Boolean(link),
|
||||
}));
|
||||
|
||||
let latestValue: ReturnType<typeof usePopularPosts>;
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
const createPost = (boardAddress: string, suffix: string, replyCount: number, timestamp = 1_704_067_000) =>
|
||||
({
|
||||
cid: `${boardAddress}-${suffix}`,
|
||||
content: `${suffix} content`,
|
||||
link: `https://cdn.example/${boardAddress}/${suffix}.jpg`,
|
||||
replyCount,
|
||||
subplebbitAddress: boardAddress,
|
||||
thumbnailUrl: `https://cdn.example/${boardAddress}/${suffix}.thumb.jpg`,
|
||||
timestamp,
|
||||
title: `${suffix} title`,
|
||||
}) as never;
|
||||
|
||||
const createSubplebbit = (boardAddress: string, posts: Array<{ cid: string }>, updatedAt = 1_704_067_150) =>
|
||||
({
|
||||
address: boardAddress,
|
||||
updatedAt,
|
||||
posts: {
|
||||
pages: {
|
||||
hot: {
|
||||
comments: Object.fromEntries(posts.map((post: { cid: string }) => [post.cid, post])),
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as never;
|
||||
|
||||
const HookHarness = ({ addresses, subplebbits }: { addresses: string[]; subplebbits: Array<unknown> }) => {
|
||||
latestValue = usePopularPosts(subplebbits as never, addresses);
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderHook = async (addresses: string[], subplebbits: Array<unknown>) => {
|
||||
await act(async () => {
|
||||
root.render(createElement(HookHarness, { addresses, subplebbits }));
|
||||
});
|
||||
};
|
||||
|
||||
describe('usePopularPosts', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testState.currentTime = 1_704_067_200;
|
||||
testState.loadingTimestamps = [];
|
||||
testState.requestedAddresses = undefined;
|
||||
|
||||
latestValue = {
|
||||
error: null,
|
||||
isLoading: true,
|
||||
popularPosts: [],
|
||||
};
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('keeps the box loading until eight unique boards can supply eight posts', async () => {
|
||||
const addresses = Array.from({ length: 8 }, (_, index) => `board-${index}.eth`);
|
||||
testState.loadingTimestamps = addresses.map(() => 1_704_067_180);
|
||||
|
||||
await renderHook(addresses, [
|
||||
createSubplebbit(addresses[0], [createPost(addresses[0], 'top', 20), createPost(addresses[0], 'backup', 10)]),
|
||||
createSubplebbit(addresses[1], [createPost(addresses[1], 'top', 18), createPost(addresses[1], 'backup', 9)]),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
]);
|
||||
|
||||
expect(testState.requestedAddresses).toEqual(addresses);
|
||||
expect(latestValue.isLoading).toBe(true);
|
||||
expect(latestValue.popularPosts).toEqual([]);
|
||||
|
||||
await renderHook(
|
||||
addresses,
|
||||
addresses.map((address, index) => createSubplebbit(address, [createPost(address, 'top', 20 - index), createPost(address, 'backup', 5 - index)])),
|
||||
);
|
||||
|
||||
expect(latestValue.isLoading).toBe(false);
|
||||
expect(latestValue.popularPosts).toHaveLength(8);
|
||||
expect(new Set(latestValue.popularPosts.map((post) => post.subplebbitAddress)).size).toBe(8);
|
||||
expect(latestValue.popularPosts.every((post) => post.cid.endsWith('-top'))).toBe(true);
|
||||
});
|
||||
|
||||
it('freezes the revealed set until the input board list changes', async () => {
|
||||
const addresses = Array.from({ length: 8 }, (_, index) => `board-${index}.eth`);
|
||||
testState.loadingTimestamps = addresses.map(() => 1_704_067_180);
|
||||
|
||||
await renderHook(
|
||||
addresses,
|
||||
addresses.map((address, index) => createSubplebbit(address, [createPost(address, 'initial', 30 - index)])),
|
||||
);
|
||||
|
||||
const initialCids = latestValue.popularPosts.map((post) => post.cid);
|
||||
expect(latestValue.isLoading).toBe(false);
|
||||
|
||||
await renderHook(
|
||||
addresses,
|
||||
addresses.map((address, index) => createSubplebbit(address, [createPost(address, 'replacement', 100 - index), createPost(address, 'initial', 30 - index)])),
|
||||
);
|
||||
|
||||
expect(latestValue.isLoading).toBe(false);
|
||||
expect(latestValue.popularPosts.map((post) => post.cid)).toEqual(initialCids);
|
||||
});
|
||||
|
||||
it('reveals the committed posts once the remaining boards time out', async () => {
|
||||
const addresses = ['board-0.eth', 'board-1.eth'];
|
||||
testState.loadingTimestamps = [1_704_067_180, 1_704_067_180];
|
||||
|
||||
await renderHook(addresses, [createSubplebbit(addresses[0], [createPost(addresses[0], 'only', 12)]), undefined]);
|
||||
|
||||
expect(latestValue.isLoading).toBe(true);
|
||||
expect(latestValue.popularPosts).toEqual([]);
|
||||
|
||||
testState.currentTime = 1_704_067_211;
|
||||
await renderHook(addresses, [createSubplebbit(addresses[0], [createPost(addresses[0], 'only', 12)]), undefined]);
|
||||
|
||||
expect(latestValue.isLoading).toBe(false);
|
||||
expect(latestValue.popularPosts.map((post) => post.cid)).toEqual([`${addresses[0]}-only`]);
|
||||
});
|
||||
|
||||
it('keeps loading forever when no board ever produces a thread', async () => {
|
||||
const addresses = ['board-0.eth'];
|
||||
testState.currentTime = 1_704_067_240;
|
||||
testState.loadingTimestamps = [1_704_067_180];
|
||||
|
||||
await renderHook(addresses, [undefined]);
|
||||
|
||||
expect(latestValue.isLoading).toBe(true);
|
||||
expect(latestValue.popularPosts).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,27 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { Comment, Subplebbit } from '@bitsocialhq/bitsocial-react-hooks';
|
||||
import { getCommentMediaInfo, getHasThumbnail } from '../lib/utils/media-utils';
|
||||
import useSubplebbitsLoadingStartTimestamps from '../stores/use-subplebbits-loading-start-timestamps-store';
|
||||
import { useCurrentTime } from './use-current-time';
|
||||
|
||||
const MAX_POSTS = 8;
|
||||
const MAX_PER_SUB = 3;
|
||||
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 CommittedPopularPosts = {
|
||||
boardAddresses: Set<string>;
|
||||
cids: Set<string>;
|
||||
posts: Comment[];
|
||||
revealed: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Time-decayed popularity: replyCount divided by age of latest
|
||||
* activity so a stale post with many old replies loses to a newer
|
||||
@@ -20,54 +34,75 @@ function popularityScore(post: Comment, nowSeconds: number): number {
|
||||
return Math.max(replies, 0.1) / (1 + ageSeconds / HALF_LIFE_SECONDS);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ranked by time-decayed popularity so the box surfaces posts with
|
||||
* recent engagement rather than stale all-time reply leaders.
|
||||
*
|
||||
* Grow-only commit: once a post enters the grid it never shifts or
|
||||
* disappears — new posts fill remaining slots until the cap is reached.
|
||||
* The first revealed set is frozen until the user refreshes or changes
|
||||
* the board filter, so threads never disappear during background loads.
|
||||
*/
|
||||
const usePopularPosts = (subplebbits: Subplebbit[]) => {
|
||||
const committedRef = useRef<{ posts: Comment[]; cids: Set<string> }>({
|
||||
const usePopularPosts = (subplebbits: Array<Subplebbit | undefined>, subplebbitAddresses: string[]) => {
|
||||
const inputKey = [...subplebbitAddresses].sort().join(',');
|
||||
const committedRef = useRef<CommittedPopularPosts>({
|
||||
boardAddresses: new Set(),
|
||||
posts: [],
|
||||
cids: new Set(),
|
||||
revealed: false,
|
||||
});
|
||||
const prevInputKeyRef = useRef('');
|
||||
|
||||
// Reset committed when the board set changes (e.g. NSFW filter toggle)
|
||||
const inputKey = subplebbits
|
||||
.map((s) => s?.address)
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.join(',');
|
||||
// Reset committed when the requested board set changes (e.g. NSFW filter toggle).
|
||||
if (prevInputKeyRef.current !== inputKey) {
|
||||
prevInputKeyRef.current = inputKey;
|
||||
committedRef.current = { posts: [], cids: new Set() };
|
||||
committedRef.current = {
|
||||
boardAddresses: new Set(),
|
||||
posts: [],
|
||||
cids: new Set(),
|
||||
revealed: false,
|
||||
};
|
||||
}
|
||||
|
||||
const candidates = useMemo(() => {
|
||||
if (committedRef.current.posts.length >= MAX_POSTS) return [];
|
||||
const currentTime = useCurrentTime(committedRef.current.revealed ? 300 : 5);
|
||||
const nowSeconds = Math.floor(currentTime);
|
||||
const loadingStartTimestamps = useSubplebbitsLoadingStartTimestamps(subplebbitAddresses);
|
||||
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const candidates = useMemo<PopularPostCandidate[]>(() => {
|
||||
if (committedRef.current.revealed || committedRef.current.posts.length >= MAX_POSTS) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const uniqueLinks = new Set<string>();
|
||||
const allPosts: Comment[] = [];
|
||||
const selectedLinks = new Set<string>();
|
||||
const allPosts: PopularPostCandidate[] = [];
|
||||
|
||||
for (const sub of subplebbits) {
|
||||
if (!sub?.posts?.pages?.hot?.comments) continue;
|
||||
subplebbitAddresses.forEach((boardAddress, index) => {
|
||||
const subplebbit = subplebbits[index];
|
||||
if (!boardAddress || !subplebbit?.posts?.pages?.hot?.comments) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subPosts: Comment[] = [];
|
||||
for (const post of Object.values(sub.posts.pages.hot.comments as Comment)) {
|
||||
for (const post of Object.values(subplebbit.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 && !uniqueLinks.has(link)) {
|
||||
if (hasThumbnail && !deleted && !removed && !locked && !pinned) {
|
||||
subPosts.push(post);
|
||||
uniqueLinks.add(link);
|
||||
}
|
||||
} catch {
|
||||
// skip posts with malformed media URLs
|
||||
@@ -75,32 +110,45 @@ const usePopularPosts = (subplebbits: Subplebbit[]) => {
|
||||
}
|
||||
|
||||
subPosts.sort((a, b) => popularityScore(b, nowSeconds) - popularityScore(a, nowSeconds));
|
||||
allPosts.push(...subPosts.slice(0, MAX_PER_SUB));
|
||||
}
|
||||
|
||||
allPosts.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);
|
||||
}
|
||||
});
|
||||
|
||||
allPosts.sort((a, b) => popularityScore(b.post, nowSeconds) - popularityScore(a.post, nowSeconds));
|
||||
|
||||
return allPosts;
|
||||
} catch (err) {
|
||||
console.error('Error in usePopularPosts:', err);
|
||||
return [];
|
||||
}
|
||||
}, [subplebbits]);
|
||||
}, [nowSeconds, subplebbits, subplebbitAddresses]);
|
||||
|
||||
// Grow-only: committed posts keep their position, new ones fill empty slots
|
||||
const { posts, cids } = committedRef.current;
|
||||
for (const post of candidates) {
|
||||
if (posts.length >= MAX_POSTS) break;
|
||||
if (post.cid && !cids.has(post.cid)) {
|
||||
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)) {
|
||||
posts.push(post);
|
||||
cids.add(post.cid);
|
||||
boardAddresses.add(boardAddress);
|
||||
}
|
||||
}
|
||||
|
||||
const hasLoadedData = subplebbits.some((sub) => sub?.posts?.pages?.hot?.comments);
|
||||
const isLoading = subplebbits.length > 0 && !hasLoadedData;
|
||||
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;
|
||||
}
|
||||
|
||||
return { popularPosts: posts, isLoading, error: null as string | null };
|
||||
const isLoading = !committedRef.current.revealed;
|
||||
|
||||
return { popularPosts: committedRef.current.revealed ? posts : [], isLoading, error: null as string | null };
|
||||
};
|
||||
|
||||
export default usePopularPosts;
|
||||
|
||||
@@ -211,7 +211,7 @@ const Home = () => {
|
||||
<SearchBar />
|
||||
<InfoBox />
|
||||
<BoardsList multisub={directories} />
|
||||
<PopularThreadsBox directories={directories} subplebbits={subplebbits} />
|
||||
<PopularThreadsBox directories={directories} directoryAddresses={directoryAddresses} subplebbits={subplebbits} />
|
||||
<Stats directoryAddresses={directoryAddresses} />
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@ interface PopularThreadProps {
|
||||
boardPath: string;
|
||||
}
|
||||
|
||||
export const ContentPreview = ({ content, maxLength = 99 }: { content: string; maxLength?: number }) => {
|
||||
const ContentPreview = ({ content, maxLength = 99 }: { content: string; maxLength?: number }) => {
|
||||
const plainText = removeMarkdown(content).trim().replaceAll(' ', '').replace(/\n\n/g, '\n').replaceAll('\n\n', '');
|
||||
const truncatedText = plainText.length > maxLength ? `${plainText.substring(0, maxLength).trim()}...` : plainText;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user