diff --git a/components/profile/follow-list-drawer.tsx b/components/profile/follow-list-drawer.tsx new file mode 100644 index 00000000..ca390067 --- /dev/null +++ b/components/profile/follow-list-drawer.tsx @@ -0,0 +1,272 @@ +"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} +

+ ); +} diff --git a/components/profile/profile-header.tsx b/components/profile/profile-header.tsx index 8208db71..9d536d75 100644 --- a/components/profile/profile-header.tsx +++ b/components/profile/profile-header.tsx @@ -34,6 +34,10 @@ import { Settings, X } from "lucide-react"; import { cn } from "@/lib/utils"; import type { RelationshipResult, RelationshipState } from "@/lib/social"; import { ProfileActions } from "@/components/profile/profile-actions"; +import { + FollowListDrawer, + type FollowMode, +} from "@/components/profile/follow-list-drawer"; export function ProfileHeader({ avatarURL, @@ -85,6 +89,9 @@ export function ProfileHeader({ const [following, setFollowing] = useState(followingCount ?? 0); const [rel, setRel] = useState(relationship ?? null); + // Which follow list (if any) is open in the drawer. + const [followList, setFollowList] = useState(null); + const handleResult = (r: RelationshipResult) => { setRel(r.relationship); setFollowers(r.follower_count); @@ -116,8 +123,16 @@ export function ProfileHeader({ ) : null} {showCounts ? ( <> - - + setFollowList("followers")} + /> + setFollowList("following")} + /> ) : null} @@ -164,17 +179,51 @@ export function ProfileHeader({ onClose={() => setZoomOpen(false)} /> ) : null} + {showCounts ? ( + setFollowList(null)} + /> + ) : null} ); } -/** One inline profile stat — bold count over a muted label. */ -function Stat({ n, label }: { n: number; label: string }) { - return ( -
- {n} +/** One inline profile stat — bold count over a muted label. Renders as a + * button (opening the follow list) when `onClick` is provided, else a plain + * div (e.g. the posts count). */ +function Stat({ + n, + label, + onClick, +}: { + n: number; + label: string; + onClick?: () => void; +}) { + const inner = ( + <> + + {n} + {label} -
+ + ); + if (!onClick) { + return ( +
{inner}
+ ); + } + return ( + ); } diff --git a/lib/social.ts b/lib/social.ts index b9b55878..1f51b1e7 100644 --- a/lib/social.ts +++ b/lib/social.ts @@ -493,4 +493,42 @@ export const relationships = { body: JSON.stringify({ category, reason }), }); }, + + /** One page of the profile's accepted followers, optionally filtered by a + * name/handle search. `total` is the full matching count for pagination. */ + followers(handle: string, opts: FollowListParams = {}) { + return call( + `/u/${encodeURIComponent(handle)}/followers?${followListQuery(opts)}`, + ); + }, + /** One page of the users this profile follows (accepted), same shape. */ + following(handle: string, opts: FollowListParams = {}) { + return call( + `/u/${encodeURIComponent(handle)}/following?${followListQuery(opts)}`, + ); + }, }; + +/** Lightweight user row in a followers / following list. */ +export type UserSummary = { + handle: string; + name: string; + avatar_url: string | null; +}; + +/** A paginated followers/following page: the rows + total matching count. */ +export type FollowList = { + users: UserSummary[]; + total: number; +}; + +export type FollowListParams = { q?: string; limit?: number; offset?: number }; + +function followListQuery({ q, limit = 30, offset = 0 }: FollowListParams) { + const params = new URLSearchParams({ + limit: String(limit), + offset: String(offset), + }); + if (q && q.trim()) params.set("q", q.trim()); + return params.toString(); +} diff --git a/package.json b/package.json index d7598a3d..43b84d1e 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@base-ui/react": "^1.4.0", + "@tanstack/react-virtual": "^3.14.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "framer-motion": "^12.38.0", diff --git a/yarn.lock b/yarn.lock index 5c73e40b..bb8f3d6b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1211,6 +1211,18 @@ postcss "^8.5.6" tailwindcss "4.2.2" +"@tanstack/react-virtual@^3.14.2": + version "3.14.2" + resolved "https://registry.yarnpkg.com/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz#ff1f97cd52f2bf4bda7f6d141bc9af80010daf45" + integrity sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ== + dependencies: + "@tanstack/virtual-core" "3.17.0" + +"@tanstack/virtual-core@3.17.0": + version "3.17.0" + resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.17.0.tgz#2949d4aaeaf73f89bab10bf86e22905f5a668acc" + integrity sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ== + "@ts-morph/common@~0.27.0": version "0.27.0" resolved "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz"