import { useQueryClient } from "@tanstack/react-query"; import { useEffect, useRef, useState } from "react"; import { getHovercard } from "~/fns/hovercards"; import { isReservedName } from "~/lib/reserved-names"; const SHOW_DELAY_MS = 350; const HIDE_DELAY_MS = 200; const KINDS = ["issue", "user", "team", "commit"] as const; /** Cards describe immutable refs (a commit) or slow-moving ones (a user), so a page visit shares one fetch. */ const CARD_STALE_MS = 5 * 60 * 1000; type Kind = (typeof KINDS)[number]; type Card = NonNullable>>; export type HovercardProfile = NonNullable; export type HovercardState = { card: Card; top: number; left: number } | null; export const hovercardQueryKey = (kind: Kind, ref: string) => ["hovercard", kind, ref] as const; function isKind(value: string | undefined): value is Kind { return KINDS.some((kind) => kind === value); } /** A link straight to a profile page, e.g. author names, which gets a user card without opting in. */ const PROFILE_PATH = /^\/([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))$/; type Target = { link: HTMLAnchorElement; kind: Kind; ref: string }; function hoverTarget(event: Event): Target | null { if (!(event.target instanceof Element)) return null; const tagged = event.target.closest("a[data-hovercard]"); if (tagged) { const kind = tagged.dataset.hovercard; const ref = tagged.dataset.ref; return isKind(kind) && ref ? { link: tagged, kind, ref } : null; } const link = event.target.closest("a[href]"); if (!link || link.closest("header, nav, [data-no-hovercard]") || link.origin !== window.location.origin) return null; const name = PROFILE_PATH.exec(link.pathname)?.[1]; return name && !isReservedName(name) ? { link, kind: "user", ref: name } : null; } function popupOf(event: Event): Element | null { return event.target instanceof Element ? event.target.closest("[data-hovercard-popup]") : null; } /** Watches the document for hovers over `a[data-hovercard]` links (and plain profile links) and loads preview cards through the query cache. */ export function useHovercards() { const queryClient = useQueryClient(); const [state, setState] = useState(null); const timer = useRef | undefined>(undefined); useEffect(() => { let pending: HTMLAnchorElement | null = null; let shown: HTMLAnchorElement | null = null; const hide = () => { shown = null; setState(null); }; const dismiss = () => { clearTimeout(timer.current); pending = null; hide(); }; const over = (event: PointerEvent) => { if (event.pointerType !== "mouse") return; if (popupOf(event)) { clearTimeout(timer.current); return; } const hovered = hoverTarget(event); if (!hovered) return; const { link, kind, ref } = hovered; if (link === pending) return; clearTimeout(timer.current); if (link === shown) return; pending = link; timer.current = setTimeout(async () => { const card = await queryClient .fetchQuery({ queryKey: hovercardQueryKey(kind, ref), queryFn: () => getHovercard({ data: { kind, ref } }), staleTime: CARD_STALE_MS, }) .catch(() => null); if (pending !== link) return; pending = null; if (!card || !link.isConnected) return; const rect = link.getBoundingClientRect(); shown = link; setState({ card, top: rect.bottom + window.scrollY + 6, left: rect.left + window.scrollX }); }, SHOW_DELAY_MS); }; const out = (event: PointerEvent) => { if (event.pointerType !== "mouse") return; const source = hoverTarget(event)?.link ?? popupOf(event); if (!source) return; if (event.relatedTarget instanceof Node && source.contains(event.relatedTarget)) return; pending = null; clearTimeout(timer.current); timer.current = setTimeout(hide, HIDE_DELAY_MS); }; const press = (event: PointerEvent) => { if (!popupOf(event)) dismiss(); }; const key = (event: KeyboardEvent) => { if (event.key === "Escape") dismiss(); }; document.addEventListener("pointerover", over); document.addEventListener("pointerout", out); document.addEventListener("pointerdown", press, true); document.addEventListener("keydown", key); window.addEventListener("popstate", dismiss); return () => { clearTimeout(timer.current); document.removeEventListener("pointerover", over); document.removeEventListener("pointerout", out); document.removeEventListener("pointerdown", press, true); document.removeEventListener("keydown", key); window.removeEventListener("popstate", dismiss); }; }, [queryClient]); return state; }