mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(popular-posts): randomize popular thread board selection on mount
This commit is contained in:
@@ -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', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,8 +16,6 @@ type PopularPostCandidate = {
|
||||
};
|
||||
|
||||
type CommittedPopularPosts = {
|
||||
boardAddresses: Set<string>;
|
||||
cids: Set<string>;
|
||||
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<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('');
|
||||
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) {
|
||||
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<Subplebbit | undefined>, subplebbitA
|
||||
try {
|
||||
const selectedLinks = new Set<string>();
|
||||
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<Subplebbit | undefined>, 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<Subplebbit | undefined>, 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;
|
||||
|
||||
Reference in New Issue
Block a user