diff --git a/src/hooks/__tests__/use-popular-posts.test.tsx b/src/hooks/__tests__/use-popular-posts.test.tsx index 01ecf71d..329b2ff1 100644 --- a/src/hooks/__tests__/use-popular-posts.test.tsx +++ b/src/hooks/__tests__/use-popular-posts.test.tsx @@ -69,6 +69,21 @@ const renderHook = async (addresses: string[], subplebbits: Array) => { }); }; +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', () => { beforeEach(() => { vi.clearAllMocks(); @@ -169,4 +184,28 @@ describe('usePopularPosts', () => { expect(latestValue.isLoading).toBe(true); 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(); + }); }); diff --git a/src/hooks/use-popular-posts.ts b/src/hooks/use-popular-posts.ts index e5133c39..29b0663f 100644 --- a/src/hooks/use-popular-posts.ts +++ b/src/hooks/use-popular-posts.ts @@ -16,8 +16,6 @@ type PopularPostCandidate = { }; type CommittedPopularPosts = { - boardAddresses: Set; - cids: Set; posts: Comment[]; revealed: boolean; }; @@ -46,9 +44,20 @@ function isBoardStillLoading(subplebbit: Subplebbit | undefined, loadingStartTim 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 - * recent engagement rather than stale all-time reply leaders. + * Each board contributes at most one time-decayed popular thread, but the + * 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 board filter, so threads never disappear during background loads. @@ -56,20 +65,18 @@ function isBoardStillLoading(subplebbit: Subplebbit | undefined, loadingStartTim const usePopularPosts = (subplebbits: Array, subplebbitAddresses: string[]) => { const inputKey = [...subplebbitAddresses].sort().join(','); const committedRef = useRef({ - boardAddresses: new Set(), posts: [], - cids: new Set(), revealed: false, }); const prevInputKeyRef = useRef(''); + const randomizedBoardAddressesRef = useRef([]); - // 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) { prevInputKeyRef.current = inputKey; + randomizedBoardAddressesRef.current = shuffleBoardAddresses(subplebbitAddresses); committedRef.current = { - boardAddresses: new Set(), posts: [], - cids: new Set(), revealed: false, }; } @@ -86,9 +93,10 @@ const usePopularPosts = (subplebbits: Array, subplebbitA try { const selectedLinks = new Set(); const allPosts: PopularPostCandidate[] = []; + const subplebbitsByAddress = new Map(subplebbitAddresses.map((boardAddress, index) => [boardAddress, subplebbits[index]])); - subplebbitAddresses.forEach((boardAddress, index) => { - const subplebbit = subplebbits[index]; + randomizedBoardAddressesRef.current.forEach((boardAddress) => { + const subplebbit = subplebbitsByAddress.get(boardAddress); if (!boardAddress || !subplebbit?.posts?.pages?.hot?.comments) { return; } @@ -118,8 +126,6 @@ const usePopularPosts = (subplebbits: Array, subplebbitA } }); - allPosts.sort((a, b) => popularityScore(b.post, nowSeconds) - popularityScore(a.post, nowSeconds)); - return allPosts; } catch (err) { console.error('Error in usePopularPosts:', err); @@ -127,28 +133,16 @@ const usePopularPosts = (subplebbits: Array, subplebbitA } }, [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)); - 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; } 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;