/** * React bindings for the realtime hub. They are deliberately thin: each one is * either a store subscription (`useSyncExternalStore`) or an effect that takes a * room intent / registers a listener for the lifetime of the component. * * Every hook is SSR-safe (`getServerSnapshot` + effect-only subscriptions) so a * chat column can be rendered to string in tests without a WebSocket global. */ import { useCallback, useEffect, useReducer, useRef, useSyncExternalStore } from 'react' import type { WsClientIssue } from './client' import { getRealtimeHub } from './hub' import type { ServerFrame, UserEvent, WsStatus, WsTypingIndicator } from './protocol' const IDLE: WsStatus = 'idle' /** Connection lifecycle of the shared socket. */ export function useWsStatus(): WsStatus { const hub = getRealtimeHub() const subscribe = useCallback((onChange: () => void) => hub.onStatus(onChange), [hub]) const getSnapshot = useCallback(() => hub.connectionStatus, [hub]) return useSyncExternalStore(subscribe, getSnapshot, () => IDLE) } /** `ready.user_id`, once the socket has handshaked. */ export function useWsUserId(): string | undefined { const hub = getRealtimeHub() const subscribe = useCallback((onChange: () => void) => hub.onStatus(onChange), [hub]) const getSnapshot = useCallback(() => hub.selfId, [hub]) return useSyncExternalStore(subscribe, getSnapshot, () => undefined) } /** * Keep a room joined for as long as the caller is mounted. Reference counted in * the hub, so a room opened in both the transcript and the thread panel is * subscribed once. */ export function useRoomWatch(roomId: string | null | undefined): WsStatus { const hub = getRealtimeHub() useEffect(() => { if (!roomId) return return hub.watchRoom(roomId) }, [hub, roomId]) return useWsStatus() } /** Observe every room/user frame; the handler is kept in a ref (stable id). */ export function useWsFrames(handler: (frame: ServerFrame) => void): void { const ref = useRef(handler) // The latest handler lives in a ref so the subscription is created once; the // write happens after paint, never during it. useEffect(() => { ref.current = handler }) const hub = getRealtimeHub() useEffect(() => hub.onFrame((frame) => ref.current(frame)), [hub]) } /** Observe protocol conditions (gap, resync, bye, connect failures). */ export function useWsIssues(handler: (issue: WsClientIssue) => void): void { const ref = useRef(handler) useEffect(() => { ref.current = handler }) const hub = getRealtimeHub() useEffect(() => hub.onIssue((issue) => ref.current(issue)), [hub]) } /** Observe per-user pushes: mention / presence / read_marker / room_added. */ export function useWsUserEvents(handler: (event: UserEvent) => void): void { const ref = useRef(handler) useEffect(() => { ref.current = handler }) const hub = getRealtimeHub() useEffect(() => hub.onUserEvent((event) => ref.current(event)), [hub]) } /** * Live typing indicators for one room/thread. The registry expires each indicator * after its own `ttl_secs`, and it notifies on expiry; the 1s sweep is a safety * net for the case where a broadcast was the only event in a quiet room. */ export function useTypingIndicators(roomId: string | null | undefined, threadId?: string | null): WsTypingIndicator[] { const hub = getRealtimeHub() // The indicator set is derived from the registry on every render: the listener // only says that something moved, so nothing keeps a second copy of that state. const [, bump] = useReducer((count: number) => count + 1, 0) useEffect(() => { if (!roomId) return const off = hub.onTypingChange((changed) => { if (changed === roomId) bump() }) // Indicators expire after their own ttl_secs and the registry notifies on the // sweep; this tick covers a room that went quiet mid-indicator. const timer = setInterval(bump, 1_000) return () => { off() clearInterval(timer) } }, [hub, roomId]) // Read straight from the registry: `bump` is what makes a render happen, so a // cached copy here would only be a second source of truth. return roomId ? hub.typingUsers(roomId, threadId ?? null) : [] } /** Tell the room you are typing; the client throttles to the server's 3s window. */ export function sendTypingSignal(roomId: string, threadId?: string | null): void { getRealtimeHub().sendTyping(roomId, threadId) }