2026-10-16 22:46:56 -06:00
|
|
|
import { useContext, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
|
|
|
import { AuthCtx } from "./context";
|
2026-10-16 07:40:29 -06:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-16 22:46:56 -06:00
|
|
|
/** Phase 5: presence carries the packet each user has open. */
|
|
|
|
|
export interface PacketPresenceUser extends PresenceUser {
|
|
|
|
|
packet_id?: number | string | null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-16 07:40:29 -06:00
|
|
|
function parse(data: unknown): unknown {
|
|
|
|
|
if (typeof data !== "string") return null;
|
|
|
|
|
try {
|
|
|
|
|
return JSON.parse(data) as unknown;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-16 22:46:56 -06:00
|
|
|
/*
|
|
|
|
|
* 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";
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-16 07:40:29 -06:00
|
|
|
/**
|
|
|
|
|
* Opens /api/events with EventSource while `sessionId` is set.
|
|
|
|
|
* A `session` event with this session id and action "revoked" calls onRevoked.
|
2026-10-16 22:46:56 -06:00
|
|
|
* `presence` is kept in state. The stream reopens when the packet in view changes.
|
2026-10-16 07:40:29 -06:00
|
|
|
*/
|
|
|
|
|
export function useEvents(sessionId: string | null, handlers: EventHandlers): PresenceUser[] {
|
|
|
|
|
const [presence, setPresence] = useState<PresenceUser[]>([]);
|
2026-10-16 22:46:56 -06:00
|
|
|
const packetId = useSyncExternalStore(subscribeFocus, getFocus, getFocus);
|
2026-10-16 07:40:29 -06:00
|
|
|
const ref = useRef(handlers);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
ref.current = handlers;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!sessionId || typeof EventSource === "undefined") return;
|
2026-10-16 22:46:56 -06:00
|
|
|
const es = new EventSource(eventsUrl(packetId), { withCredentials: true });
|
2026-10-16 07:40:29 -06:00
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
});
|
2026-10-16 14:45:38 -06:00
|
|
|
// 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" });
|
|
|
|
|
});
|
2026-10-16 07:40:29 -06:00
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
es.close();
|
|
|
|
|
setPresence([]);
|
|
|
|
|
};
|
2026-10-16 22:46:56 -06:00
|
|
|
}, [sessionId, packetId]);
|
2026-10-16 07:40:29 -06:00
|
|
|
|
|
|
|
|
return presence;
|
|
|
|
|
}
|
2026-10-16 22:46:56 -06:00
|
|
|
|
|
|
|
|
/** 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);
|
|
|
|
|
}
|