chahinebrini 92ad4c93b5 fix(dm): smooth image lightbox + stable online/typing status
- MediaLightbox component extracted from dm.tsx. Image now fills a fixed
  full-screen box with contentFit=contain instead of an onLoad-computed
  aspect ratio, removing the square->real-size jump ("jitter") on open.
- Info-sheet images: render a nested MediaLightbox inside the FormSheet
  (stacks above the sheet modal) and track lightboxSource. Removes the
  close-sheet-then-reopen workaround that switched context back to the DM.
- Typing indicator: heartbeat (every 2s while focused + non-empty) instead
  of keystroke-only sends, so "typing…" holds through thinking pauses;
  receiver clear raised to 6s. stop on blur/send/empty.
- Presence: debounce going offline by 12s (online immediate) so brief
  presence-sync gaps no longer flicker "Online" <-> "last seen".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 10:48:00 +02:00

101 lines
3.8 KiB
TypeScript

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<RealtimeChannel | null>(null);
const clearTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const heartbeat = useRef<ReturnType<typeof setInterval> | 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 };
}