import { useContext, useEffect, useRef, useState, useSyncExternalStore } from "react"; import { AuthCtx } from "./context"; import type { PresenceUser } from "./types"; interface EventHandlers { /** Called when the server revokes this browser's own session. */ onRevoked: () => void; /** Called for every `changed` event. Later phases refresh their data from it. */ onChanged?: (payload: unknown) => void; } /** Phase 5: presence carries the packet each user has open. */ export interface PacketPresenceUser extends PresenceUser { packet_id?: number | string | null; } function parse(data: unknown): unknown { if (typeof data !== "string") return null; try { return JSON.parse(data) as unknown; } catch { return null; } } /* * The packet this browser is looking at. The packet page sets it (usePacketPresence) and the one EventSource * reopens with ?packet_id= so the server's presence knows it. A module store keeps AuthProvider unchanged. */ let focusPacket: number | null = null; const focusListeners = new Set<() => void>(); export function setPresencePacket(id: number | null): void { if (focusPacket === id) return; focusPacket = id; for (const fn of focusListeners) fn(); } function subscribeFocus(fn: () => void): () => void { focusListeners.add(fn); return () => { focusListeners.delete(fn); }; } const getFocus = () => focusPacket; /** The stream URL for the packet in view. */ export function eventsUrl(packetId: number | null): string { return packetId ? `/api/events?packet_id=${encodeURIComponent(String(packetId))}` : "/api/events"; } /** * Opens /api/events with EventSource while `sessionId` is set. * A `session` event with this session id and action "revoked" calls onRevoked. * `presence` is kept in state. The stream reopens when the packet in view changes. */ export function useEvents(sessionId: string | null, handlers: EventHandlers): PresenceUser[] { const [presence, setPresence] = useState([]); const packetId = useSyncExternalStore(subscribeFocus, getFocus, getFocus); const ref = useRef(handlers); useEffect(() => { ref.current = handlers; }); useEffect(() => { if (!sessionId || typeof EventSource === "undefined") return; const es = new EventSource(eventsUrl(packetId), { withCredentials: true }); es.addEventListener("session", (ev) => { const p = parse((ev as MessageEvent).data); if (p && typeof p === "object") { const o = p as { session_id?: unknown; action?: unknown }; if (o.session_id === sessionId && o.action === "revoked") ref.current.onRevoked(); } }); es.addEventListener("presence", (ev) => { const p = parse((ev as MessageEvent).data); if (p && typeof p === "object" && Array.isArray((p as { users?: unknown }).users)) { setPresence((p as { users: PresenceUser[] }).users); } }); es.addEventListener("changed", (ev) => { ref.current.onChanged?.(parse((ev as MessageEvent).data)); }); // Phase 3: the worker finished a feed run. Fanned out with the changed events, marked event "feed". es.addEventListener("feed", (ev) => { const p = parse((ev as MessageEvent).data); ref.current.onChanged?.({ ...(p && typeof p === "object" ? p : {}), event: "feed" }); }); return () => { es.close(); setPresence([]); }; }, [sessionId, packetId]); return presence; } /** The other people on `packetId`, from the presence list. One entry per user, sorted by name. */ export function othersOn(users: readonly PacketPresenceUser[], packetId: number, myId: string | null | undefined): PacketPresenceUser[] { const seen = new Set(); const out: PacketPresenceUser[] = []; for (const u of users) { if (u.packet_id === null || u.packet_id === undefined || Number(u.packet_id) !== packetId) continue; if (myId && u.user_id === myId) continue; if (seen.has(u.user_id)) continue; seen.add(u.user_id); out.push(u); } return out.sort((a, b) => a.display_name.localeCompare(b.display_name)); } /** * Marks `packetId` as the packet in view while mounted, and returns the other people looking at it. * Works under AuthProvider; outside it (a bare test render) it returns an empty list. */ export function usePacketPresence(packetId: number | null): PacketPresenceUser[] { const ctx = useContext(AuthCtx); useEffect(() => { if (!packetId) return; setPresencePacket(packetId); return () => setPresencePacket(null); }, [packetId]); if (!ctx || !packetId) return []; return othersOn(ctx.presence as PacketPresenceUser[], packetId, ctx.me?.user.id); }