Files
PacketStation/frontend/src/auth/useEvents.ts
T

129 lines
4.6 KiB
TypeScript
Raw Normal View History

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<PresenceUser[]>([]);
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<string>();
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);
}