71 lines
2.3 KiB
TypeScript
71 lines
2.3 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useEffectEvent, useState } from "react";
|
|
|
|
import { PRESENCE_HEARTBEAT_MS } from "@/lib/presence-constants";
|
|
|
|
type VisitorCounts = { global: number; guide: number | null };
|
|
export type EditorsByGuide = Record<string, Array<{ userId: string; name: string }>>;
|
|
|
|
function tabId(storageKey: string): string {
|
|
const existing = window.sessionStorage.getItem(storageKey);
|
|
if (existing) return existing;
|
|
const created = crypto.randomUUID();
|
|
window.sessionStorage.setItem(storageKey, created);
|
|
return created;
|
|
}
|
|
|
|
function useHeartbeat<T>({
|
|
endpoint,
|
|
guideId,
|
|
}: {
|
|
endpoint: string;
|
|
guideId: string | null;
|
|
}) {
|
|
const [value, setValue] = useState<T | null>(null);
|
|
const send = useEffectEvent(async (active = true) => {
|
|
try {
|
|
const response = await fetch(endpoint, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ tabId: tabId(`buzz-sheet:${endpoint}:tab`), guideId, active }),
|
|
cache: "no-store",
|
|
keepalive: !active,
|
|
});
|
|
if (!response.ok) throw new Error("presence unavailable");
|
|
if (active) setValue(await response.json() as T);
|
|
} catch {
|
|
if (active) setValue(null);
|
|
}
|
|
});
|
|
|
|
useEffect(() => {
|
|
const immediate = window.setTimeout(() => void send(), 0);
|
|
const interval = window.setInterval(() => void send(), PRESENCE_HEARTBEAT_MS);
|
|
const refresh = () => {
|
|
if (document.visibilityState === "visible") void send();
|
|
};
|
|
const leave = () => void send(false);
|
|
window.addEventListener("focus", refresh);
|
|
window.addEventListener("pagehide", leave);
|
|
document.addEventListener("visibilitychange", refresh);
|
|
return () => {
|
|
window.clearInterval(interval);
|
|
window.clearTimeout(immediate);
|
|
window.removeEventListener("focus", refresh);
|
|
window.removeEventListener("pagehide", leave);
|
|
document.removeEventListener("visibilitychange", refresh);
|
|
};
|
|
}, [endpoint, guideId]);
|
|
|
|
return value;
|
|
}
|
|
|
|
export function useVisitorPresence(guideId: string | null) {
|
|
return useHeartbeat<VisitorCounts>({ endpoint: "/api/presence/visitors", guideId });
|
|
}
|
|
|
|
export function useAdminPresence(guideId: string | null) {
|
|
return useHeartbeat<{ editorsByGuide: EditorsByGuide }>({ endpoint: "/api/presence/admin", guideId });
|
|
}
|