fix(popular-posts): randomize popular thread board selection on mount

This commit is contained in:
plebeius
2026-03-08 16:58:55 +08:00
parent 04b98b84a4
commit c27590372c
2 changed files with 62 additions and 29 deletions
@@ -69,6 +69,21 @@ const renderHook = async (addresses: string[], subplebbits: Array<unknown>) => {
}); });
}; };
const resetHookRoot = () => {
act(() => root.unmount());
container.remove();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
};
const mockRandomSequence = (values: number[]) => {
let index = 0;
return vi.spyOn(Math, 'random').mockImplementation(() => values[index++] ?? values.at(-1) ?? 0);
};
describe('usePopularPosts', () => { describe('usePopularPosts', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -169,4 +184,28 @@ describe('usePopularPosts', () => {
expect(latestValue.isLoading).toBe(true); expect(latestValue.isLoading).toBe(true);
expect(latestValue.popularPosts).toEqual([]); expect(latestValue.popularPosts).toEqual([]);
}); });
it('reshuffles the selected boards on each mount while keeping one top thread per board', async () => {
const addresses = Array.from({ length: 10 }, (_, index) => `board-${index}.eth`);
const subplebbits = addresses.map((address, index) => createSubplebbit(address, [createPost(address, 'top', 30 - index), createPost(address, 'backup', 10 - index)]));
const keepOrderRandom = mockRandomSequence(Array.from({ length: addresses.length - 1 }, () => 0.999_999));
await renderHook(addresses, subplebbits);
expect(latestValue.isLoading).toBe(false);
expect(latestValue.popularPosts.map((post) => post.subplebbitAddress)).toEqual(addresses.slice(0, 8));
expect(latestValue.popularPosts.every((post) => post.cid.endsWith('-top'))).toBe(true);
keepOrderRandom.mockRestore();
resetHookRoot();
const rotateOrderRandom = mockRandomSequence(Array.from({ length: addresses.length - 1 }, () => 0));
await renderHook(addresses, subplebbits);
expect(latestValue.isLoading).toBe(false);
expect(latestValue.popularPosts.map((post) => post.subplebbitAddress)).toEqual(addresses.slice(1, 9));
expect(latestValue.popularPosts.every((post) => post.cid.endsWith('-top'))).toBe(true);
rotateOrderRandom.mockRestore();
});
}); });
+23 -29
View File
@@ -16,8 +16,6 @@ type PopularPostCandidate = {
}; };
type CommittedPopularPosts = { type CommittedPopularPosts = {
boardAddresses: Set<string>;
cids: Set<string>;
posts: Comment[]; posts: Comment[];
revealed: boolean; revealed: boolean;
}; };
@@ -46,9 +44,20 @@ function isBoardStillLoading(subplebbit: Subplebbit | undefined, loadingStartTim
return nowSeconds - loadingStartTimestamp < BOARD_LOADING_TIMEOUT_SECONDS; 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;
}
/** /**
* Ranked by time-decayed popularity so the box surfaces posts with * Each board contributes at most one time-decayed popular thread, but the
* recent engagement rather than stale all-time reply leaders. * board order is shuffled on mount so repeat visits surface different boards.
* *
* The first revealed set is frozen until the user refreshes or changes * The first revealed set is frozen until the user refreshes or changes
* the board filter, so threads never disappear during background loads. * the board filter, so threads never disappear during background loads.
@@ -56,20 +65,18 @@ function isBoardStillLoading(subplebbit: Subplebbit | undefined, loadingStartTim
const usePopularPosts = (subplebbits: Array<Subplebbit | undefined>, subplebbitAddresses: string[]) => { const usePopularPosts = (subplebbits: Array<Subplebbit | undefined>, subplebbitAddresses: string[]) => {
const inputKey = [...subplebbitAddresses].sort().join(','); const inputKey = [...subplebbitAddresses].sort().join(',');
const committedRef = useRef<CommittedPopularPosts>({ const committedRef = useRef<CommittedPopularPosts>({
boardAddresses: new Set(),
posts: [], posts: [],
cids: new Set(),
revealed: false, revealed: false,
}); });
const prevInputKeyRef = useRef(''); const prevInputKeyRef = useRef('');
const randomizedBoardAddressesRef = useRef<string[]>([]);
// Reset committed when the requested board set changes (e.g. NSFW filter toggle). // Reset committed and reshuffle when the requested board set changes (e.g. NSFW filter toggle).
if (prevInputKeyRef.current !== inputKey) { if (prevInputKeyRef.current !== inputKey) {
prevInputKeyRef.current = inputKey; prevInputKeyRef.current = inputKey;
randomizedBoardAddressesRef.current = shuffleBoardAddresses(subplebbitAddresses);
committedRef.current = { committedRef.current = {
boardAddresses: new Set(),
posts: [], posts: [],
cids: new Set(),
revealed: false, revealed: false,
}; };
} }
@@ -86,9 +93,10 @@ const usePopularPosts = (subplebbits: Array<Subplebbit | undefined>, subplebbitA
try { try {
const selectedLinks = new Set<string>(); const selectedLinks = new Set<string>();
const allPosts: PopularPostCandidate[] = []; const allPosts: PopularPostCandidate[] = [];
const subplebbitsByAddress = new Map(subplebbitAddresses.map((boardAddress, index) => [boardAddress, subplebbits[index]]));
subplebbitAddresses.forEach((boardAddress, index) => { randomizedBoardAddressesRef.current.forEach((boardAddress) => {
const subplebbit = subplebbits[index]; const subplebbit = subplebbitsByAddress.get(boardAddress);
if (!boardAddress || !subplebbit?.posts?.pages?.hot?.comments) { if (!boardAddress || !subplebbit?.posts?.pages?.hot?.comments) {
return; return;
} }
@@ -118,8 +126,6 @@ const usePopularPosts = (subplebbits: Array<Subplebbit | undefined>, subplebbitA
} }
}); });
allPosts.sort((a, b) => popularityScore(b.post, nowSeconds) - popularityScore(a.post, nowSeconds));
return allPosts; return allPosts;
} catch (err) { } catch (err) {
console.error('Error in usePopularPosts:', err); console.error('Error in usePopularPosts:', err);
@@ -127,28 +133,16 @@ const usePopularPosts = (subplebbits: Array<Subplebbit | undefined>, subplebbitA
} }
}, [nowSeconds, subplebbits, subplebbitAddresses]); }, [nowSeconds, subplebbits, subplebbitAddresses]);
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 hasPendingBoards = subplebbitAddresses.some((_, index) => isBoardStillLoading(subplebbits[index], loadingStartTimestamps[index], nowSeconds)); const hasPendingBoards = subplebbitAddresses.some((_, index) => isBoardStillLoading(subplebbits[index], loadingStartTimestamps[index], nowSeconds));
if (!committedRef.current.revealed && (posts.length >= MAX_POSTS || (!hasPendingBoards && posts.length > 0))) {
if (!committedRef.current.revealed && (candidates.length >= MAX_POSTS || (!hasPendingBoards && candidates.length > 0))) {
committedRef.current.posts = candidates.slice(0, MAX_POSTS).map(({ post }) => post);
committedRef.current.revealed = true; committedRef.current.revealed = true;
} }
const isLoading = !committedRef.current.revealed; const isLoading = !committedRef.current.revealed;
return { popularPosts: committedRef.current.revealed ? posts : [], isLoading, error: null as string | null }; return { popularPosts: committedRef.current.revealed ? committedRef.current.posts : [], isLoading, error: null as string | null };
}; };
export default usePopularPosts; export default usePopularPosts;