"use client"; /** * UserListDrawer — a generic, virtualized, paginated, searchable list of * users in a drawer (right on desktop, bottom sheet on mobile). Powers both * the followers/following lists and the post "liked by" list. * * The caller supplies `fetchPage(params)` returning `{ users, total }` and a * `depsKey` that changes when the data source changes (so the list reloads). * Search is debounced and queries the backend across the full set. The list * loads the next page as you scroll near the bottom. * * State-update notes (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 type { FollowList, FollowListParams, UserSummary } from "@/lib/social"; import { cn } from "@/lib/utils"; const PAGE = 30; const ROW = 64; // px — fixed row height for the virtualizer export type FetchUserPage = (params: FollowListParams) => Promise; export function UserListDrawer({ open, onClose, title, depsKey, fetchPage, }: { open: boolean; onClose: () => void; title: string; /** Changes when the data source changes (e.g. mode/handle or post id), to * trigger a fresh reload. */ depsKey: string; fetchPage: FetchUserPage; }) { 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); // Latest fetchPage, read via ref so its (unstable) identity isn't an effect // dep — the reload keys on `depsKey` instead. const fetchRef = useRef(fetchPage); fetchRef.current = fetchPage; // (Re)load page 0 when the drawer opens or the source/query changes. // Debounced so typing in search doesn't spam the backend. useEffect(() => { if (!open) return; const t = window.setTimeout(() => { const token = ++reqRef.current; busyRef.current = true; setItems([]); setStatus("loading"); fetchRef .current({ 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, depsKey, q]); 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"); fetchRef .current({ 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(); }} > {title}
setQ(e.target.value)} placeholder="Search" 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. Please try again. ) : empty ? ( {q.trim() ? "No matches." : "Nothing here 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}

); }