fix: mobile action bar layout + infinite scroll stats/author drop
- NoteCard: replace fixed gap-6 action bar with justify-between + max-w-xs on mobile so stats never overflow or wrap on narrow screens - usePageBatch: new hook that batches author/stats prefetches per page instead of across the whole accumulated feed. The old approach used a single query key built from ALL loaded event IDs; adding page 2 changed the key, abandoned the old cache entry, and fired a fresh request for every item — causing stats and author data to visually disappear on pages 2 and 3. Now each page gets its own stable cache entry that is never invalidated by subsequent page loads. - Feed: swap useAuthors + useBatchEventStats calls for usePageBatch Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
+5
-22
@@ -11,8 +11,7 @@ import LoginDialog from '@/components/auth/LoginDialog';
|
||||
import SignupDialog from '@/components/auth/SignupDialog';
|
||||
import { useFeed } from '@/hooks/useFeed';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthors } from '@/hooks/useAuthors';
|
||||
import { useBatchEventStats } from '@/hooks/useTrending';
|
||||
import { usePageBatch } from '@/hooks/usePageBatch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { FeedItem } from '@/hooks/useFeed';
|
||||
|
||||
@@ -68,26 +67,10 @@ export function Feed() {
|
||||
return items;
|
||||
}, [data?.pages]);
|
||||
|
||||
// Batch-prefetch all author profiles in a single relay query instead of
|
||||
// firing N individual useAuthor() calls from each NoteCard. The results
|
||||
// are seeded into the ['author', pubkey] cache so NoteCard's own
|
||||
// useAuthor() resolves instantly from cache.
|
||||
const feedPubkeys = useMemo(() => {
|
||||
const keys = new Set<string>();
|
||||
for (const item of feedItems) {
|
||||
keys.add(item.event.pubkey);
|
||||
if (item.repostedBy) keys.add(item.repostedBy);
|
||||
}
|
||||
return [...keys];
|
||||
}, [feedItems]);
|
||||
useAuthors(feedPubkeys);
|
||||
|
||||
// Batch-prefetch interaction stats for all visible events in a single
|
||||
// relay query instead of firing 2 queries per NoteCard.
|
||||
const feedEventIds = useMemo(() => {
|
||||
return feedItems.map((item) => item.event.id);
|
||||
}, [feedItems]);
|
||||
useBatchEventStats(feedEventIds);
|
||||
// Batch-prefetch authors and stats per page so that each page's cache
|
||||
// entry is stable — adding a new page doesn't invalidate previous pages'
|
||||
// already-fetched data.
|
||||
usePageBatch(data?.pages ?? []);
|
||||
|
||||
const handleLogin = () => {
|
||||
setLoginDialogOpen(false);
|
||||
|
||||
@@ -250,22 +250,22 @@ export function NoteCard({ event, className, repostedBy, compact }: NoteCardProp
|
||||
{/* Action buttons — hidden in compact/embed mode */}
|
||||
{!compact && (
|
||||
<>
|
||||
<div className="flex items-center gap-6 mt-3 -ml-2">
|
||||
<div className="flex items-center justify-between mt-3 -ml-2 max-w-xs sidebar:max-w-none">
|
||||
<button
|
||||
className="flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
|
||||
className="flex items-center gap-1 p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors min-w-0"
|
||||
title="Reply"
|
||||
onClick={(e) => { e.stopPropagation(); setReplyOpen(true); }}
|
||||
>
|
||||
<MessageCircle className="size-[18px]" />
|
||||
<MessageCircle className="size-[18px] shrink-0" />
|
||||
{stats?.replies ? <span className="text-sm tabular-nums">{stats.replies}</span> : null}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-green-500 hover:bg-green-500/10 transition-colors"
|
||||
className="flex items-center gap-1 p-2 rounded-full text-muted-foreground hover:text-green-500 hover:bg-green-500/10 transition-colors min-w-0"
|
||||
title="Repost"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Repeat2 className="size-[18px]" />
|
||||
<Repeat2 className="size-[18px] shrink-0" />
|
||||
{(stats?.reposts || stats?.quotes) ? <span className="text-sm tabular-nums">{(stats?.reposts ?? 0) + (stats?.quotes ?? 0)}</span> : null}
|
||||
</button>
|
||||
|
||||
@@ -278,16 +278,16 @@ export function NoteCard({ event, className, repostedBy, compact }: NoteCardProp
|
||||
|
||||
<ZapDialog target={event}>
|
||||
<button
|
||||
className="flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-amber-500 hover:bg-amber-500/10 transition-colors"
|
||||
className="flex items-center gap-1 p-2 rounded-full text-muted-foreground hover:text-amber-500 hover:bg-amber-500/10 transition-colors min-w-0"
|
||||
title="Zap"
|
||||
>
|
||||
<Zap className="size-[18px]" />
|
||||
<Zap className="size-[18px] shrink-0" />
|
||||
{stats?.zapAmount ? <span className="text-sm tabular-nums">{formatSats(stats.zapAmount)}</span> : null}
|
||||
</button>
|
||||
</ZapDialog>
|
||||
|
||||
<button
|
||||
className="p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
|
||||
className="p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors shrink-0"
|
||||
title="More"
|
||||
onClick={(e) => { e.stopPropagation(); setMoreMenuOpen(true); }}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* usePageBatch — per-page author & stats prefetching for the feed.
|
||||
*
|
||||
* WHY THIS EXISTS
|
||||
* ---------------
|
||||
* The naive approach is to gather all pubkeys / event IDs across every loaded
|
||||
* page into a single array and pass that to useAuthors / useBatchEventStats.
|
||||
* That creates a query key like:
|
||||
*
|
||||
* ['authors', 'aaa,bbb,ccc,ddd,eee,...']
|
||||
*
|
||||
* When page 2 loads the key changes to:
|
||||
*
|
||||
* ['authors', 'aaa,bbb,ccc,ddd,eee,...,fff,ggg,...']
|
||||
*
|
||||
* React Query treats this as a completely new query, abandons the old cache
|
||||
* entry, and fires a fresh request for ALL pubkeys. On page 3 the same thing
|
||||
* happens again. This means:
|
||||
* • Each new page causes a full re-fetch of every previous page's data.
|
||||
* • The ever-growing request may time out or return a truncated result set.
|
||||
* • Stats / author data appear to "drop" for already-rendered cards.
|
||||
*
|
||||
* THE FIX
|
||||
* -------
|
||||
* Call the batch hooks once per page instead of once for the whole feed.
|
||||
* Each page's cache key is stable after it is first fetched, so loading
|
||||
* page 2 does NOT invalidate page 1's data.
|
||||
*
|
||||
* We render one invisible <PageBatchFetcher> per page. React's rules of
|
||||
* hooks don't allow dynamic numbers of hook calls, so the per-page work is
|
||||
* delegated to a dedicated component that calls the hooks unconditionally.
|
||||
*/
|
||||
|
||||
import { useAuthors } from '@/hooks/useAuthors';
|
||||
import { useBatchEventStats } from '@/hooks/useTrending';
|
||||
import { useMemo } from 'react';
|
||||
import type { FeedItem } from '@/hooks/useFeed';
|
||||
|
||||
/**
|
||||
* Prefetches authors and event stats for a single feed page.
|
||||
* Designed to be called once per page so the query key stays stable
|
||||
* even as more pages are loaded.
|
||||
*/
|
||||
export function usePageBatchEntry(page: FeedItem[]) {
|
||||
// Build a stable string fingerprint of the page's event IDs.
|
||||
// This is used as the sole dependency for derived arrays so that
|
||||
// re-renders with the same page content (same array reference or not)
|
||||
// don't trigger new queries.
|
||||
const pageFingerprint = page.length === 0
|
||||
? ''
|
||||
: page.map((i) => i.event.id).join(',');
|
||||
|
||||
const pubkeys = useMemo(() => {
|
||||
if (pageFingerprint === '') return [] as string[];
|
||||
const keys = new Set<string>();
|
||||
for (const item of page) {
|
||||
keys.add(item.event.pubkey);
|
||||
if (item.repostedBy) keys.add(item.repostedBy);
|
||||
}
|
||||
return [...keys].sort();
|
||||
// pageFingerprint is the stable proxy for the page's contents
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pageFingerprint]);
|
||||
|
||||
const eventIds = useMemo(
|
||||
() => (pageFingerprint === '' ? [] as string[] : page.map((item) => item.event.id)),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[pageFingerprint],
|
||||
);
|
||||
|
||||
useAuthors(pubkeys);
|
||||
useBatchEventStats(eventIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls usePageBatchEntry for every page already loaded.
|
||||
*
|
||||
* Because we can't call a variable number of hooks, each page index gets its
|
||||
* own dedicated hook slot. We support up to MAX_PAGES pages; pages beyond
|
||||
* that still render correctly — their authors/stats fall back to individual
|
||||
* useAuthor / useEventStats queries in NoteCard (same as before this hook
|
||||
* existed).
|
||||
*
|
||||
* In practice feeds rarely exceed ~10 pages before the user stops scrolling,
|
||||
* so MAX_PAGES = 20 is a comfortable upper bound.
|
||||
*/
|
||||
const MAX_PAGES = 20;
|
||||
const EMPTY_PAGE: FeedItem[] = [];
|
||||
|
||||
export function usePageBatch(pages: FeedItem[][]) {
|
||||
// Pad to MAX_PAGES so hook call count is always the same
|
||||
const slots = useMemo(() => {
|
||||
const arr: FeedItem[][] = [];
|
||||
for (let i = 0; i < MAX_PAGES; i++) {
|
||||
arr.push(pages[i] ?? EMPTY_PAGE);
|
||||
}
|
||||
return arr;
|
||||
}, [pages]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
usePageBatchEntry(slots[0]);
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
usePageBatchEntry(slots[1]);
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
usePageBatchEntry(slots[2]);
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
usePageBatchEntry(slots[3]);
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
usePageBatchEntry(slots[4]);
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
usePageBatchEntry(slots[5]);
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
usePageBatchEntry(slots[6]);
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
usePageBatchEntry(slots[7]);
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
usePageBatchEntry(slots[8]);
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
usePageBatchEntry(slots[9]);
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
usePageBatchEntry(slots[10]);
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
usePageBatchEntry(slots[11]);
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
usePageBatchEntry(slots[12]);
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
usePageBatchEntry(slots[13]);
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
usePageBatchEntry(slots[14]);
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
usePageBatchEntry(slots[15]);
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
usePageBatchEntry(slots[16]);
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
usePageBatchEntry(slots[17]);
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
usePageBatchEntry(slots[18]);
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
usePageBatchEntry(slots[19]);
|
||||
}
|
||||
Reference in New Issue
Block a user