"use client"; import { useEffect, useEffectEvent, useRef, useState } from "react"; import { PRESENCE_HEARTBEAT_MS } from "@/lib/presence-constants"; type VisitorCounts = { global: number; guide: number | null; byGuide: Record; }; export type EditorsByGuide = Record< string, Array<{ userId: string; name: string; sections: string[] }> >; const visitorIdStorageKey = "buzz-sheet:visitor-id"; let currentTabId: string | undefined; let fallbackVisitorId: string | undefined; function tabId(): string { currentTabId ??= crypto.randomUUID(); return currentTabId; } function visitorId(): string { try { const existing = window.localStorage.getItem(visitorIdStorageKey); if (existing) return existing; const created = crypto.randomUUID(); window.localStorage.setItem(visitorIdStorageKey, created); return created; } catch { fallbackVisitorId ??= crypto.randomUUID(); return fallbackVisitorId; } } function useHeartbeat({ endpoint, guideId, section, identifyBy, }: { endpoint: string; guideId: string | null; section?: string | null; identifyBy: "browser" | "tab"; }) { const [value, setValue] = useState(null); const pending = useRef(false); const queuedActive = useRef(null); const send = useEffectEvent(async (active = true) => { if (active && document.visibilityState !== "visible") return; if (pending.current) { queuedActive.current = active; return; } pending.current = true; let nextActive: boolean | null = active; try { while (nextActive !== null) { const requestActive = nextActive; queuedActive.current = null; const response = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tabId: tabId(), ...(identifyBy === "browser" ? { browserId: visitorId() } : {}), guideId, ...(section !== undefined ? { section } : {}), active: requestActive, }), cache: "no-store", keepalive: !requestActive, }); if (!response.ok) throw new Error("presence unavailable"); if (requestActive) setValue(await response.json() as T); nextActive = queuedActive.current; } } catch { if (active) setValue(null); } finally { pending.current = false; if (queuedActive.current !== null) { const queued = queuedActive.current; queuedActive.current = null; void send(queued); } } }); useEffect(() => { const immediate = window.setTimeout(() => void send(), 0); const interval = window.setInterval(() => void send(), PRESENCE_HEARTBEAT_MS); const refresh = () => void send(); const visibilityChange = () => void send(document.visibilityState === "visible"); const leave = () => { void send(false); }; window.addEventListener("focus", refresh); window.addEventListener("pagehide", leave); document.addEventListener("visibilitychange", visibilityChange); return () => { window.clearInterval(interval); window.clearTimeout(immediate); window.removeEventListener("focus", refresh); window.removeEventListener("pagehide", leave); document.removeEventListener("visibilitychange", visibilityChange); }; }, [endpoint, guideId, identifyBy, section]); return value; } export function useVisitorPresence(guideId: string | null) { return useHeartbeat({ endpoint: "/api/presence/visitors", guideId, identifyBy: "browser", }); } export function useAdminPresence(guideId: string | null, section: string | null = null) { return useHeartbeat<{ editorsByGuide: EditorsByGuide }>({ endpoint: "/api/presence/admin", guideId, section, identifyBy: "tab", }); }