Skip to content
Clone
fe2613d
112 lines 4.3 KB
/**
* 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 { getRealtimeHub } from './hub'
import type { WsClientIssue } from './client'
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)
}