"use client"; /** * FollowListDrawer — the followers / following list for a profile. * * Opens as a right-side drawer on desktop and a bottom sheet on mobile. The * list is virtualized (fixed-height rows) and paginated: it fetches a page at * a time from the BFF and loads the next page as you scroll near the bottom. * The search field queries the backend across the profile's followers / * following (debounced), so it works against the full set, not just the * loaded page. * * State-update notes (React 19 / set-state-in-effect rule): the (re)load runs * inside a debounce timer callback and pagination runs from the scroll * handler — never synchronously inside an effect body. */ import { useEffect, useRef, useState } from "react"; import Image from "next/image"; import Link from "next/link"; import { Loader2, Search } from "lucide-react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, } from "@/components/ui/drawer"; import { useMediaQuery } from "@/components/selection-toolbar/shared"; import { relationships, type UserSummary } from "@/lib/social"; import { cn } from "@/lib/utils"; export type FollowMode = "followers" | "following"; const PAGE = 30; const ROW = 64; // px — fixed row height for the virtualizer export function FollowListDrawer({ open, mode, handle, onClose, }: { open: boolean; mode: FollowMode; handle: string; onClose: () => void; }) { const isMobile = useMediaQuery("(max-width: 767px)"); const direction = isMobile ? "bottom" : "right"; const [q, setQ] = useState(""); const [items, setItems] = useState([]); const [total, setTotal] = useState(0); const [status, setStatus] = useState<"idle" | "loading" | "error">("idle"); // reqRef tags the current query "session"; a newer reload bumps it so any // in-flight reload/append for an older query is ignored on arrival. const reqRef = useRef(0); const busyRef = useRef(false); const scrollRef = useRef(null); const fetchPage = mode === "followers" ? relationships.followers : relationships.following; // (Re)load page 0 whenever the drawer opens or the mode/handle/query // changes. Debounced so typing in search doesn't spam the BFF. useEffect(() => { if (!open) return; const t = window.setTimeout(() => { const token = ++reqRef.current; busyRef.current = true; setItems([]); setStatus("loading"); fetchPage(handle, { q, limit: PAGE, offset: 0 }) .then((res) => { if (reqRef.current !== token) return; setItems(res.users); setTotal(res.total); setStatus("idle"); }) .catch(() => { if (reqRef.current === token) setStatus("error"); }) .finally(() => { if (reqRef.current === token) busyRef.current = false; }); }, 250); return () => window.clearTimeout(t); }, [open, mode, handle, q, fetchPage]); function loadMore() { if (busyRef.current || items.length >= total) return; const token = reqRef.current; // same session — don't bump const offset = items.length; busyRef.current = true; setStatus("loading"); fetchPage(handle, { q, limit: PAGE, offset }) .then((res) => { if (reqRef.current !== token) return; setItems((prev) => [...prev, ...res.users]); setTotal(res.total); setStatus("idle"); }) .catch(() => { if (reqRef.current === token) setStatus("error"); }) .finally(() => { if (reqRef.current === token) busyRef.current = false; }); } // TanStack Virtual returns non-memoizable functions; the React Compiler // simply skips memoizing this component, which is fine here. // eslint-disable-next-line react-hooks/incompatible-library const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => scrollRef.current, estimateSize: () => ROW, overscan: 8, }); const empty = status !== "loading" && items.length === 0; return ( { if (!o) onClose(); }} > {mode}
setQ(e.target.value)} placeholder={`Search ${mode}`} autoComplete="off" className="w-full rounded-lg border border-border bg-background py-2 pl-9 pr-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring" />
{ const el = e.currentTarget; if (el.scrollHeight - el.scrollTop - el.clientHeight < 240) { loadMore(); } }} className="flex-1 min-h-0 overflow-y-auto overscroll-contain" > {status === "error" ? ( Couldn't load. Pull down to retry. ) : empty ? ( {q.trim() ? "No matches." : mode === "followers" ? "No followers yet." : "Not following anyone yet."} ) : (
{virtualizer.getVirtualItems().map((vi) => { const u = items[vi.index]; if (!u) return null; return (
); })}
)} {status === "loading" ? (
) : null}
); } function Row({ user, onNavigate, }: { user: UserSummary; onNavigate: () => void; }) { return (

{user.name}

@{user.handle}

); } function RowAvatar({ src, name }: { src: string | null; name: string }) { if (src) { return ( {name} ); } const initials = name .split(/\s+/) .map((p) => p[0]) .slice(0, 2) .join("") .toUpperCase() || "?"; return (
{initials}
); } function Message({ children }: { children: React.ReactNode }) { return (

{children}

); }