import { useEffect, useRef, useState, useCallback } from 'react'; import { supabase } from '../lib/supabase'; import type { RealtimeChannel } from '@supabase/supabase-js'; /** * Typing-Indicator für eine DM-Konversation via Supabase-Broadcast (ephemer, * KEIN DB-Write — Tipp-Status muss nicht persistiert werden). * * Beide Peers joinen denselben deterministischen Channel (sortiertes ID-Paar), * damit `send()` von A bei B ankommt. `self:false` filtert die eigenen Events. * * STABILITÄT (gegen Flackern Online⇄tippt): * Der Sender feuert nicht nur bei Tastendruck, sondern hält einen HEARTBEAT * (alle HEARTBEAT_MS), solange aktiv komponiert wird (Feld fokussiert + Text * vorhanden). Sonst würde bei jeder Denkpause der Empfänger-Auto-Clear greifen * und der Status auf „Online" zurückspringen. Der Clear-Timeout liegt deutlich * über dem Heartbeat, damit ein einzelnes verlorenes Broadcast nicht flackert. * * - `setComposing(true|false)` → startet/stoppt Heartbeat (true = aktiv tippen) * - `sendStopTyping()` → sofortiger „Stop" (beim Senden / Blur / Leeren) * - `partnerTyping` → true solange Partner-Heartbeats reinkommen */ const HEARTBEAT_MS = 2000; const CLEAR_MS = 6000; // > HEARTBEAT_MS, toleriert ~2 verlorene Beats export function useDmTyping(myUserId: string | undefined, partnerId: string | undefined) { const [partnerTyping, setPartnerTyping] = useState(false); const channelRef = useRef(null); const clearTimer = useRef | null>(null); const heartbeat = useRef | null>(null); useEffect(() => { if (!myUserId || !partnerId) return; const pair = [myUserId, partnerId].sort().join('_'); const channel = supabase.channel(`dm-typing:${pair}`, { config: { broadcast: { self: false } }, }); channel .on('broadcast', { event: 'typing' }, (msg: any) => { if (msg?.payload?.userId !== partnerId) return; setPartnerTyping(true); if (clearTimer.current) clearTimeout(clearTimer.current); clearTimer.current = setTimeout(() => setPartnerTyping(false), CLEAR_MS); }) .on('broadcast', { event: 'stop_typing' }, (msg: any) => { if (msg?.payload?.userId !== partnerId) return; if (clearTimer.current) clearTimeout(clearTimer.current); setPartnerTyping(false); }) .subscribe(); channelRef.current = channel; return () => { if (clearTimer.current) clearTimeout(clearTimer.current); if (heartbeat.current) clearInterval(heartbeat.current); heartbeat.current = null; supabase.removeChannel(channel); channelRef.current = null; setPartnerTyping(false); }; }, [myUserId, partnerId]); const broadcast = useCallback( (event: 'typing' | 'stop_typing') => { channelRef.current?.send({ type: 'broadcast', event, payload: { userId: myUserId } }); }, [myUserId], ); const sendStopTyping = useCallback(() => { if (heartbeat.current) { clearInterval(heartbeat.current); heartbeat.current = null; } broadcast('stop_typing'); }, [broadcast]); /** * Aktiv-Status setzen. true = der lokale User komponiert gerade (Feld * fokussiert + Text vorhanden) → Heartbeat läuft. false = aufhören. */ const setComposing = useCallback( (active: boolean) => { if (active) { if (heartbeat.current) return; // Heartbeat läuft bereits broadcast('typing'); // sofort sichtbar, nicht erst nach 2s heartbeat.current = setInterval(() => broadcast('typing'), HEARTBEAT_MS); } else { if (heartbeat.current) { clearInterval(heartbeat.current); heartbeat.current = null; } broadcast('stop_typing'); } }, [broadcast], ); return { partnerTyping, setComposing, sendStopTyping }; }