248 lines
7.6 KiB
TypeScript
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 };
|
|
} |