- 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>
101 lines
3.8 KiB
TypeScript
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 };
|
|
}
|