import { useContext, useEffect, useRef, useState, useSyncExternalStore } from "react"; import { streamReconnected } from "../lib/buildWatch"; import { AuthCtx } from "./context"; import { isScreen, SCREEN_LABELS } from "./roles"; 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 8: this browser's own session was locked or unlocked, here or in another tab. */ onLockChange?: (action: "locked" | "unlocked") => void; } export interface EventsOptions { /** The screen in view (the first path segment when it is a screen). The server shows it as the role. */ screen?: string | null; /** While locked the stream stays open but takes no new screen or packet. */ locked?: boolean; } /** 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 screen and the packet in view. */ export function eventsUrl(packetId: number | null, screen: string | null = null): string { const sp = new URLSearchParams(); if (screen) sp.set("screen", screen); if (packetId) sp.set("packet_id", String(packetId)); const q = sp.toString(); return q ? `/api/events?${q}` : "/api/events"; } /** The screen of a path: its first segment when that is a screen. */ export function screenOf(pathname: string): string | null { const seg = pathname.split("/")[1]; return isScreen(seg) ? seg : null; } /** * 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, opts: EventsOptions = {}): PresenceUser[] { const [presence, setPresence] = useState([]); const focus = useSyncExternalStore(subscribeFocus, getFocus, getFocus); const ref = useRef(handlers); useEffect(() => { ref.current = handlers; }); // While locked the stream keeps what it had: nothing new is sent about where this browser is. const [held, setHeld] = useState<{ packetId: number | null; screen: string | null }>({ packetId: focus, screen: opts.screen ?? null }); const wantScreen = opts.screen ?? null; if (!opts.locked && (held.packetId !== focus || held.screen !== wantScreen)) setHeld({ packetId: focus, screen: wantScreen }); const { packetId, screen } = held; useEffect(() => { if (!sessionId || typeof EventSource === "undefined") return; const es = new EventSource(eventsUrl(packetId, screen), { withCredentials: true }); // A second open on the same EventSource is a reconnect: the server may have restarted on a new build. let opened = false; es.addEventListener("open", () => { if (opened) streamReconnected(); opened = 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) return; if (o.action === "revoked") ref.current.onRevoked(); else if (o.action === "locked" || o.action === "unlocked") ref.current.onLockChange?.(o.action); } }); 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, screen]); return presence; } /** The other people on `packetId`, from the presence list. One entry per user (one with a role first), sorted by name. */ export function othersOn(users: readonly PacketPresenceUser[], packetId: number, myId: string | null | undefined): PacketPresenceUser[] { const byUser = new Map(); 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; const had = byUser.get(u.user_id); if (!had || (!presenceRole(had) && presenceRole(u))) byUser.set(u.user_id, u); } return [...byUser.values()].sort((a, b) => a.display_name.localeCompare(b.display_name)); } /** The role shown next to a name: the server's role label, else the label of the screen the person has open. */ export function presenceRole(u: Pick): string { if (u.role) return u.role; return u.screen && isScreen(u.screen) ? SCREEN_LABELS[u.screen] : ""; } /** "Also in this packet now: Andy Welder (Weld Stall) · Wes Super (Supervisor)". Empty when alone. */ export function presenceText(others: readonly PresenceUser[]): string { if (!others.length) return ""; const names = others.map((u) => { const r = presenceRole(u); return r ? `${u.display_name} (${r})` : u.display_name; }); return `Also in this packet now: ${names.join(" · ")}`; } /** * 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); }