Files
buzz-sheet/hooks/use-presence.ts
T
gunshiz 3fb34ffde2
CI / Verify (push) Successful in 2m2s
CI / Build immutable images and deploy (push) Successful in 3m42s
fix(presence) : count visible browsers accurately
2026-10-12 00:57:56 +07:00

134 lines
3.8 KiB
TypeScript

"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<string, number>;
};
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<T>({
endpoint,
guideId,
section,
identifyBy,
}: {
endpoint: string;
guideId: string | null;
section?: string | null;
identifyBy: "browser" | "tab";
}) {
const [value, setValue] = useState<T | null>(null);
const pending = useRef(false);
const queuedActive = useRef<boolean | null>(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<VisitorCounts>({
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",
});
}