gitdataai/src/ws/hooks.ts

248 lines
7.6 KiB
TypeScript

/**
* 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<N extends WsOutEventName>(
eventName: N,
callback: (data: Extract<WsOutEvent, { type: N }>) => void,
): void {
const cbRef = useRef(callback);
useEffect(() => {
cbRef.current = callback;
});
useEffect(() => {
const client = getWsClient();
if (!client) return;
const handler = (data: Extract<WsOutEvent, { type: N }>) => 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<RoomId> {
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<Map<string, TypingUser>>(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 };
}