import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { Link } from "react-router"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { Avatar } from "./Avatar.tsx"; import { useAuth } from "../hooks/useAuth.ts"; import { DEFAULT_PAGE_SIZE } from "../config/api.ts"; import { deserializePublicUser, type PaginatedData, parseAPIResponse, type PublicUser, type RawPublicUser, } from "../model.ts"; interface UserListPopoverProps { anchorRef: React.RefObject; onClose: () => void; fetchUrl: string; } type State = | { status: "loading" } | { status: "error"; error: string } | { status: "loaded"; users: PublicUser[]; page: number; hasMore: boolean; loadingMore: boolean; }; interface Placement { left: number; top?: number; bottom?: number; maxHeight: number; } const PREFERRED_MAX_HEIGHT = 280; const VIEWPORT_MARGIN = 8; const MIN_USABLE_HEIGHT = 100; export function UserListPopover( { anchorRef, onClose, fetchUrl }: UserListPopoverProps, ) { const { token } = useAuth(); const [state, setState] = useState({ status: "loading" }); const [placement, setPlacement] = useState(null); const popoverRef = useRef(null); // Position via the anchor's viewport rect (mirrors Tooltip.tsx) — fixed + // portaled to so the popover can't be clipped by a card's overflow. // Flips above the anchor (anchored from the viewport bottom edge, so it // grows upward) when there isn't enough room below, so a long list never // overflows past the bottom of the viewport. useEffect(() => { const anchorRect = anchorRef.current?.getBoundingClientRect(); if (!anchorRect) { setPlacement(null); return; } const spaceBelow = globalThis.innerHeight - anchorRect.bottom - VIEWPORT_MARGIN; const spaceAbove = anchorRect.top - VIEWPORT_MARGIN; if (spaceBelow >= MIN_USABLE_HEIGHT || spaceBelow >= spaceAbove) { setPlacement({ left: anchorRect.left, top: anchorRect.bottom + 4, maxHeight: Math.max( MIN_USABLE_HEIGHT, Math.min(PREFERRED_MAX_HEIGHT, spaceBelow), ), }); } else { setPlacement({ left: anchorRect.left, bottom: globalThis.innerHeight - anchorRect.top + 4, maxHeight: Math.max( MIN_USABLE_HEIGHT, Math.min(PREFERRED_MAX_HEIGHT, spaceAbove), ), }); } }, [anchorRef]); useEffect(() => { function onMouseDown(e: MouseEvent) { const target = e.target as Node; if ( popoverRef.current?.contains(target) || anchorRef.current?.contains(target) ) { return; } onClose(); } function onKeyDown(e: KeyboardEvent) { if (e.key === "Escape") onClose(); } function onScroll() { onClose(); } document.addEventListener("mousedown", onMouseDown); document.addEventListener("keydown", onKeyDown); globalThis.addEventListener("scroll", onScroll, true); return () => { document.removeEventListener("mousedown", onMouseDown); document.removeEventListener("keydown", onKeyDown); globalThis.removeEventListener("scroll", onScroll, true); }; }, [onClose, anchorRef]); useEffect(() => { const controller = new AbortController(); fetch(`${fetchUrl}?page=1&limit=${DEFAULT_PAGE_SIZE}`, { signal: controller.signal, headers: token ? { Authorization: `Bearer ${token}` } : {}, }) .then((r) => r.json()) .then((body) => { const apiResponse = parseAPIResponse>( body, ); if (!apiResponse.success) { setState({ status: "error", error: apiResponse.error.message }); return; } setState({ status: "loaded", users: apiResponse.data.items.map(deserializePublicUser), page: 1, hasMore: apiResponse.data.hasMore, loadingMore: false, }); }) .catch(() => { if (controller.signal.aborted) return; setState({ status: "error", error: t`Could not load.` }); }); return () => controller.abort(); }, [fetchUrl, token]); const handleLoadMore = () => { if (state.status !== "loaded" || state.loadingMore) return; const nextPage = state.page + 1; setState({ ...state, loadingMore: true }); fetch(`${fetchUrl}?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`, { headers: token ? { Authorization: `Bearer ${token}` } : {}, }) .then((r) => r.json()) .then((body) => { const apiResponse = parseAPIResponse>( body, ); setState((prev) => { if (prev.status !== "loaded") return prev; if (!apiResponse.success) return { ...prev, loadingMore: false }; return { status: "loaded", users: [ ...prev.users, ...apiResponse.data.items.map(deserializePublicUser), ], page: nextPage, hasMore: apiResponse.data.hasMore, loadingMore: false, }; }); }); }; if (!placement) return null; return createPortal(
{state.status === "loading" && (

Loading…

)} {state.status === "error" && (

{state.error}

)} {state.status === "loaded" && state.users.length === 0 && (

No one yet.

)} {state.status === "loaded" && state.users.length > 0 && ( <> {state.users.map((u) => ( {u.username} ))} {state.hasMore && ( )} )}
, document.body, ); }