/** * React hooks for WebSocket — typed event subscription, * room management, typing indicators, and connection status. */ import { useEffect, useRef, useCallback, useState } from 'react'; import { WsClient } from './client'; import type { ConnectionStatus, WsClientConfig } from './client'; import { useWsStore } from './store'; import type { WsOutEventName, WsOutEvent } from './types/outbound'; import type { RoomId } from './types/core'; /** Global singleton WsClient instance. */ let globalClient: WsClient | null = null; /** Monotonically increasing ID — incremented each time initWsClient creates a new client. * Used as a React dependency so hooks re-register listeners after client re-init. */ let clientId = 0; /** Get the global WsClient instance, or null if not initialized. */ export function getWsClient(): WsClient | null { return globalClient; } /** Get the current client identity counter — changes when client is re-initialized. */ export function getWsClientId(): number { return clientId; } /** Initialize the global WsClient with config. */ export function initWsClient(config: WsClientConfig): WsClient { if (globalClient) globalClient.destroy(); globalClient = new WsClient(config); clientId++; // Bridge status changes to Zustand store globalClient.onStatusChange((status) => { useWsStore.getState().setStatus(status); if (status === 'reconnecting') { useWsStore.getState().setReconnecting(true); } else { useWsStore.getState().setReconnecting(false); } if (status === 'connected') { useWsStore.getState().setLastError(null); } }); // Bridge error events to store globalClient.on('error', (event) => { useWsStore.getState().setLastError(event.message); }); return globalClient; } // ── Initialization hook ───────────────────────────────── export interface UseWsInitOptions { url: string; backendUrl?: string; mode?: 'socketio' | 'raw-ws'; autoReconnect?: boolean; token?: string; connectOnMount?: boolean; } /** Initialize and optionally auto-connect the WsClient on mount. */ export function useWsInit(options: UseWsInitOptions): WsClient { const [client] = useState(() => initWsClient(options)); useEffect(() => { if (options.connectOnMount !== false) { client.connect(); } return () => { client.disconnect(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return client; } // ── Connection status hook ────────────────────────────── /** Reactive connection status — updates via Zustand. */ export function useWsStatus(): ConnectionStatus { return useWsStore((s) => s.status); } /** Whether the client is connected. */ export function useWsConnected(): boolean { return useWsStore((s) => s.status === 'connected'); } /** Last error from the server. */ export function useWsError(): string | null { return useWsStore((s) => s.lastError); } // ── Typed event subscription hook ─────────────────────── /** Subscribe to a typed outbound event. Auto-cleanup on unmount. Re-registers on client re-init. */ export function useWsEvent( eventName: N, callback: (data: Extract) => void, ): void { const cbRef = useRef(callback); useEffect(() => { cbRef.current = callback; }); useEffect(() => { const client = getWsClient(); if (!client) return; const handler = (data: Extract) => cbRef.current(data); client.on(eventName, handler); return () => { client.off(eventName, handler); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [eventName, clientId]); } // ── Room subscription hook ────────────────────────────── /** Subscribe to a room on mount, keep subscription alive on unmount/room-change. */ export function useRoomSubscription(roomId: RoomId | null): void { useEffect(() => { if (!roomId) return; const client = getWsClient(); if (!client) return; client.joinRoom(roomId); useWsStore.getState().addSubscribedRoom(roomId); return () => { // Cleanup: unsubscribe and sync Zustand store on unmount client.leaveRoom(roomId); useWsStore.getState().removeSubscribedRoom(roomId); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [roomId, clientId]); } /** Get the set of currently subscribed rooms (reactive). */ export function useSubscribedRooms(): Set { return useWsStore((s) => s.subscribedRooms); } // ── Typing indicator hook ─────────────────────────────── export interface TypingUser { user_id: string; username: string; avatar_url: string | null; } /** Track typing users in a room. Clears on unmount. */ export function useTypingIndicator(roomId: RoomId | null): TypingUser[] { const [typingUsers, setTypingUsers] = useState>(new Map()); useWsEvent('typing_start', (event) => { if (event.room_id === roomId && roomId) { const data = event.data; setTypingUsers((prev) => { const next = new Map(prev); next.set(data.user, { user_id: data.user, username: data.username, avatar_url: data.avatar_url ?? null }); return next; }); } }); useWsEvent('typing_stop', (event) => { if (event.room_id === roomId && roomId) { setTypingUsers((prev) => { const next = new Map(prev); next.delete(event.data.user); return next; }); } }); return [...typingUsers.values()]; } // ── Notification count hook ───────────────────────────── /** Track unread notification count via notify_created events. */ export function useNotificationCount(): number { const [count, setCount] = useState(0); useWsEvent('notify_created', () => { setCount((c) => c + 1); }); useWsEvent('notify_read', () => { setCount((c) => Math.max(0, c - 1)); }); return count; } // ── Convenience message sender hook ───────────────────── /** Returns stable send functions bound to a room. */ export function useRoomSender(roomId: RoomId | null) { const client = getWsClient(); const sendMessage = useCallback( (content: string, opts?: { content_type?: string; thread?: string; in_reply_to?: string }) => { if (!roomId || !client) return; client.sendMessage(roomId, content, opts); }, [roomId, client], ); const sendTypingStart = useCallback(() => { if (!roomId || !client) return; client.sendTypingStart(roomId); }, [roomId, client]); const sendTypingStop = useCallback(() => { if (!roomId || !client) return; client.sendTypingStop(roomId); }, [roomId, client]); const sendReadReceipt = useCallback( (lastReadSeq: number) => { if (!roomId || !client) return; client.sendReadReceipt(roomId, lastReadSeq); }, [roomId, client], ); const addReaction = useCallback( (messageId: string, emoji: string) => { if (!roomId || !client) return; client.addReaction(roomId, messageId, emoji); }, [roomId, client], ); const removeReaction = useCallback( (messageId: string, emoji: string) => { if (!roomId || !client) return; client.removeReaction(roomId, messageId, emoji); }, [roomId, client], ); return { sendMessage, sendTypingStart, sendTypingStop, sendReadReceipt, addReaction, removeReaction }; }