// Singleton Tinode connection + a small pub-sub layer wrapping it for React. // // The SDK's onConnect/onDisconnect/onLogin etc. are plain single-slot properties (`tinode.onConnect // = fn`), not a real multi-listener event emitter -- assigning one overwrites any previous // assignment. If more than one React component tried to register its own callback directly (e.g. // each calling useSyncExternalStore with its own subscribe function touching these slots), they'd // silently stomp on each other. This module claims all the SDK's callback slots exactly once and // fans them out to any number of subscribers via its own Set, which is what hooks.ts's // useSyncExternalStore-based hooks actually subscribe to. import { Tinode } from 'tinode-sdk'; import { API_KEY, APP_NAME, LOGGING_ENABLED } from '@/lib/config'; import { useSessionStore } from '@/stores/session'; export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'authenticated'; let tinode: Tinode | null = null; let status: ConnectionStatus = 'disconnected'; const listeners = new Set<() => void>(); function setStatus(next: ConnectionStatus) { if (status === next) return; status = next; listeners.forEach((cb) => cb()); } /** * The server extends the stock {hi} packet with a `tenant` field (server/datamodel.go) that the * vendored JS SDK has no API for. `#initPacket`/the connection's packet builder is private, so the * only usable hook is wrapping the connection's raw sendText and injecting the field into the * already-serialized JSON. Ported as-is from the old app's tnSetup/installTenantHiPatch -- this is * a real backend protocol extension, not an old-codebase-specific workaround. */ function installTenantHiPatch(t: Tinode) { const conn = t._connection; if (!conn || conn.__tenantPatched) return; const original = conn.sendText.bind(conn); conn.sendText = (msg: unknown) => { if (typeof msg === 'string' && msg.indexOf('"hi":') >= 0) { try { const parsed = JSON.parse(msg); if (parsed.hi) { const tenant = useSessionStore.getState().tenant; if (tenant?.code) { parsed.hi.tenant = tenant.code; msg = JSON.stringify(parsed); } } } catch { // Not valid JSON -- send as is. } } return original(msg as string); }; conn.__tenantPatched = true; } function createTinode(): Tinode { const tenant = useSessionStore.getState().tenant; const host = tenant?.host || 'web.118im.com'; const secure = tenant?.secure ?? true; const t = new Tinode({ appName: APP_NAME, host, apiKey: API_KEY, transport: 'ws', secure, persist: false }); t.setHumanLanguage('zh'); t.enableLogging(LOGGING_ENABLED, true); installTenantHiPatch(t); t.onConnect = () => setStatus('connected'); t.onDisconnect = () => setStatus('disconnected'); t.onLogin = (code: number) => { if (code === 200) setStatus('authenticated'); }; return t; } /** Lazily creates the singleton connection. Must only be called once a tenant is resolved. */ export function getTinode(): Tinode { if (!tinode) { tinode = createTinode(); } return tinode; } /** Tears down the singleton so the next getTinode() rebuilds against a freshly-resolved tenant. */ export function resetTinode() { if (tinode?.isConnected()) { tinode.disconnect(); } tinode = null; setStatus('disconnected'); } export async function connect(): Promise { const t = getTinode(); if (t.isConnected()) return; setStatus('connecting'); await t.connect(); } export function getConnectionStatus(): ConnectionStatus { return status; } export function subscribeConnectionStatus(callback: () => void): () => void { listeners.add(callback); return () => listeners.delete(callback); }