All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
197 lines
5.7 KiB
TypeScript
197 lines
5.7 KiB
TypeScript
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<HTMLElement | null>;
|
|
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<State>({ status: "loading" });
|
|
const [rect, setRect] = useState<DOMRect | null>(null);
|
|
const popoverRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Position via the anchor's viewport rect (mirrors Tooltip.tsx) — fixed +
|
|
// portaled to <body> 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<PaginatedData<RawPublicUser>>(
|
|
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<PaginatedData<RawPublicUser>>(
|
|
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(
|
|
<div
|
|
className="user-list-popover"
|
|
role="menu"
|
|
ref={popoverRef}
|
|
style={{
|
|
position: "fixed",
|
|
top: rect.bottom + 4,
|
|
left: rect.left,
|
|
}}
|
|
>
|
|
{state.status === "loading" && (
|
|
<p className="user-list-popover-status">
|
|
<Trans>Loading…</Trans>
|
|
</p>
|
|
)}
|
|
{state.status === "error" && (
|
|
<p className="user-list-popover-status">{state.error}</p>
|
|
)}
|
|
{state.status === "loaded" && state.users.length === 0 && (
|
|
<p className="user-list-popover-status">
|
|
<Trans>No one yet.</Trans>
|
|
</p>
|
|
)}
|
|
{state.status === "loaded" && state.users.length > 0 && (
|
|
<>
|
|
{state.users.map((u) => (
|
|
<Link
|
|
key={u.id}
|
|
to={`/users/${u.username}`}
|
|
className="user-list-popover-item"
|
|
role="menuitem"
|
|
onClick={onClose}
|
|
>
|
|
<Avatar
|
|
userId={u.id}
|
|
username={u.username}
|
|
hasAvatar={!!u.avatarMime}
|
|
size={24}
|
|
/>
|
|
<span>{u.username}</span>
|
|
</Link>
|
|
))}
|
|
{state.hasMore && (
|
|
<button
|
|
type="button"
|
|
className="user-list-popover-load-more"
|
|
onClick={handleLoadMore}
|
|
disabled={state.loadingMore}
|
|
>
|
|
{state.loadingMore
|
|
? <Trans>Loading…</Trans>
|
|
: <Trans>Load more</Trans>}
|
|
</button>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|