import { createContext, useContext, useEffect, useRef, useState } from 'react'; import type { RealtimeChannel } from '@supabase/supabase-js'; import { supabase } from '../lib/supabase'; type OnlinePresenceContext = { onlineUserIds: Set; isOnline: (userId: string) => boolean; }; export const OnlinePresenceContext = createContext({ onlineUserIds: new Set(), isOnline: () => false, }); export function useOnlineUsers(): OnlinePresenceContext { return useContext(OnlinePresenceContext); } let sharedChannel: RealtimeChannel | null = null; let subscriberCount = 0; let onlineUserIds: Set = new Set(); const listeners = new Set<(ids: Set) => void>(); // Offline-Debounce: Presence-Sync kann kurz aussetzen (Reconnect, Sync-Timing). // Ohne Grace-Period springt der DM-Header dann sofort von „Online" auf „zuletzt // online vor X" und zurück → sichtbares Flackern. Online-Werden ist sofort, // Offline-Werden erst nach OFFLINE_GRACE_MS, falls der User bis dahin nicht // wieder in der Presence auftaucht. const OFFLINE_GRACE_MS = 12_000; const pendingRemoval = new Map>(); function clearPendingRemovals() { pendingRemoval.forEach((timer) => clearTimeout(timer)); pendingRemoval.clear(); } function notify() { const snapshot = new Set(onlineUserIds); listeners.forEach((fn) => fn(snapshot)); } function applyRawPresence(rawKeys: string[]) { const raw = new Set(rawKeys); let changed = false; // Anwesende: sofort online + jede geplante Entfernung abbrechen. for (const id of raw) { const pending = pendingRemoval.get(id); if (pending) { clearTimeout(pending); pendingRemoval.delete(id); } if (!onlineUserIds.has(id)) { onlineUserIds.add(id); changed = true; } } // Verschwundene: erst nach Grace-Period entfernen (nicht sofort). for (const id of onlineUserIds) { if (!raw.has(id) && !pendingRemoval.has(id)) { const timer = setTimeout(() => { onlineUserIds.delete(id); pendingRemoval.delete(id); notify(); }, OFFLINE_GRACE_MS); pendingRemoval.set(id, timer); } } if (changed) notify(); } function ensureChannel(currentUserId: string) { if (sharedChannel) return; const ch = supabase.channel('presence:online', { config: { presence: { key: currentUserId } }, }); sharedChannel = ch; ch .on('presence', { event: 'sync' }, () => { const state = ch.presenceState(); applyRawPresence(Object.keys(state)); }) .subscribe(async (status: string) => { if (status === 'SUBSCRIBED') { await ch.track({ userId: currentUserId, online_at: new Date().toISOString() }); } }); } function teardownChannel() { if (!sharedChannel) return; clearPendingRemovals(); sharedChannel.untrack().catch(() => {}); supabase.removeChannel(sharedChannel); sharedChannel = null; onlineUserIds = new Set(); notify(); } export function untrackSelf() { sharedChannel?.untrack().catch(() => {}); } export function retrackSelf(currentUserId: string) { sharedChannel ?.track({ userId: currentUserId, online_at: new Date().toISOString() }) .catch(() => {}); } export function useOnlinePresenceNode(currentUserId: string | null | undefined) { const [ids, setIds] = useState>(new Set(onlineUserIds)); useEffect(() => { if (!currentUserId) return; subscriberCount++; ensureChannel(currentUserId); const listener = (next: Set) => setIds(next); listeners.add(listener); return () => { listeners.delete(listener); subscriberCount--; if (subscriberCount <= 0) { subscriberCount = 0; teardownChannel(); } }; }, [currentUserId]); return ids; }