Files
PacketStation/frontend/src/auth/useEvents.ts
T
spencermandClaude Opus 5.5 6e820ba0a5 Version banner, File locations section, final CSP (no unsafe-inline styles, frame-src none, object-src none)
The MTR viewer draws PDFs with pdf.js instead of an iframe the server's frame-ancestors refused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 17:50:05 -06:00

178 lines
7.0 KiB
TypeScript

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<PresenceUser[]>([]);
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<string, 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;
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<PresenceUser, "role" | "screen">): 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);
}