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; }; export function UserListPopover( { anchorRef, onClose, fetchUrl }: UserListPopoverProps, ) { const { token } = useAuth(); const [state, setState] = useState({ status: "loading" }); const [rect, setRect] = 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. useEffect(() => { setRect(anchorRef.current?.getBoundingClientRect() ?? null); }, [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); window.addEventListener("scroll", onScroll, true); return () => { document.removeEventListener("mousedown", onMouseDown); document.removeEventListener("keydown", onKeyDown); window.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 (!rect) 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, ); }