import { useEffect, useState } from 'react'; import { supabase } from './supabase'; // Watches the `call-presence:` realtime channel and returns // the set of userIds currently in that call. Independent of whether the // viewer is in the room โ€” used to show "Active call ยท Join" affordances. // // Why presence-callbacks + no-removeChannel: // - Supabase realtime 2.103+ dedupes channels by topic. CallContext owns // the same channel during a live call to track its own presence. // - If this hook removed the channel on unmount (e.g. when ConversationHeader // hides during an active call), the live tracking connection dies and the // banner never shows the rejoin option afterwards. // - Binding our own presence callbacks (sync/join/leave) ensures the local // presence state is updated even when CallContext also listens on the same // channel โ€” multiple callbacks can co-exist. export function useCallPresence(conversationId: string | undefined): string[] { const [activeUserIds, setActiveUserIds] = useState([]); useEffect(() => { if (!conversationId) { setActiveUserIds([]); return; } const channel = supabase.channel('call-presence:' + conversationId, { config: { presence: { key: 'observer-' + Math.random().toString(36).slice(2, 8), // Server only routes presence to channels that opt-in. Without this // flag we'd receive zero presence_state/presence_diff messages. enabled: true, }, }, }); const resync = () => { const state = channel.presenceState() as Record>>; const ids = new Set(); for (const list of Object.values(state)) { for (const entry of list) { const uid = entry?.userId; if (typeof uid === 'string') ids.add(uid); } } setActiveUserIds((prev) => { const next = Array.from(ids).sort(); if (prev.length === next.length && prev.every((v, i) => v === next[i])) return prev; return next; }); }; // Supabase realtime forbids `.on()` after `.subscribe()`. So we only bind // callbacks when we own the subscribe (channel was 'closed'). For deduped // shared channels, the polling fallback below covers state changes. if (channel.state === 'closed') { channel .on('presence', { event: 'sync' }, resync) .on('presence', { event: 'join' }, resync) .on('presence', { event: 'leave' }, resync); void channel.subscribe(); } // Initial poll covers the case where the channel is already 'joined' (we // dedupe'd onto an existing subscription) and won't fire a fresh sync. resync(); // Belt-and-suspenders: a slow poll catches any drift between callback and // server state (e.g. if a presence event was dropped during reconnect). const pollId = window.setInterval(resync, 3000); return () => { window.clearInterval(pollId); // Don't removeChannel โ€” the channel is shared with CallContext (and any // sibling observers) via Supabase's topic dedupe. Removing it would // tear down a live call's presence tracker. The channel naturally // outlives the hook; leaks are bounded by the small number of // conversations a user holds open in a session. }; }, [conversationId]); return activeUserIds; }