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:
shakespeare.diy
2026-02-18 09:40:18 -06:00
parent e3b8a65fce
commit f1778babb6
3 changed files with 153 additions and 30 deletions
+5 -22
View File
@@ -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);
+8 -8
View File
@@ -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); }}
>
+140
View File
@@ -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]);
}