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>
This commit is contained in:
chahinebrini 2026-06-04 10:48:00 +02:00
parent ba200d54f4
commit 92ad4c93b5
5 changed files with 283 additions and 153 deletions

View File

@ -0,0 +1,6 @@
### Fixes
- DM image viewer: opening a shared photo no longer jitters — the image now fills the screen smoothly instead of snapping from a square placeholder to its real size on load
- DM info sheet: tapping a shared image now opens the full-screen viewer on top of the sheet and returns to the sheet when you close it, instead of kicking you back to the DM
- DM header status: the online / typing indicator no longer flickers. "Online" is now held steady through brief presence hiccups (no more rapid switching to "last seen"), and "typing…" stays stable while the other person is composing instead of dropping out on every thinking pause
- Lyra Coach chat: Lyra no longer pulls in leftover content from your last SOS crisis session — the SOS flow and the casual Coach chat are now fully separated. Stray internal prompts and raw text that could surface as chat bubbles are filtered out, and any already-affected chat history cleans itself up the next time you open the Coach

View File

@ -1,4 +1,4 @@
import { useState, useRef, useEffect, useCallback } from 'react'; import { useState, useRef, useEffect, useCallback, type ReactNode } from 'react';
import { import {
View, View,
Text, Text,
@ -10,7 +10,6 @@ import {
ActivityIndicator, ActivityIndicator,
StyleSheet, StyleSheet,
Keyboard, Keyboard,
Modal,
ScrollView, ScrollView,
Dimensions, Dimensions,
type FlatList as FlatListType, type FlatList as FlatListType,
@ -35,6 +34,7 @@ import * as FileSystem from 'expo-file-system/legacy';
import { apiFetch } from '../lib/api'; import { apiFetch } from '../lib/api';
import { ChatBubble, type ChatMsg, type MessageReaction } from '../components/chat/ChatBubble'; import { ChatBubble, type ChatMsg, type MessageReaction } from '../components/chat/ChatBubble';
import { VoiceRecordingBar, formatVoiceDuration } from '../components/chat/VoiceRecordingBar'; import { VoiceRecordingBar, formatVoiceDuration } from '../components/chat/VoiceRecordingBar';
import { MediaLightbox } from '../components/chat/MediaLightbox';
import { FormSheet } from '../components/FormSheet'; import { FormSheet } from '../components/FormSheet';
import { useDmRealtime } from '../hooks/useChatRealtime'; import { useDmRealtime } from '../hooks/useChatRealtime';
import { useDmTyping } from '../hooks/useDmTyping'; import { useDmTyping } from '../hooks/useDmTyping';
@ -143,9 +143,9 @@ export default function DmScreen() {
// zwischen allen Bildern wischen. // zwischen allen Bildern wischen.
const [lightboxImages, setLightboxImages] = useState<string[]>([]); const [lightboxImages, setLightboxImages] = useState<string[]>([]);
const [lightboxIndex, setLightboxIndex] = useState(0); const [lightboxIndex, setLightboxIndex] = useState(0);
// Index → echtes Seitenverhältnis (via onLoad), damit der Container exakt auf // Aus welchem Kontext die Lightbox geöffnet wurde. 'chat' = Root-Instanz,
// die Bildmaße passt und borderRadius die sichtbaren Foto-Ecken rundet. // 'info' = genestet im Info-Sheet (liegt zuverlässig über dem FormSheet-Modal).
const [lightboxRatios, setLightboxRatios] = useState<Record<number, number>>({}); const [lightboxSource, setLightboxSource] = useState<'chat' | 'info'>('chat');
const [savingImage, setSavingImage] = useState(false); const [savingImage, setSavingImage] = useState(false);
const lightboxOpen = lightboxImages.length > 0; const lightboxOpen = lightboxImages.length > 0;
@ -154,18 +154,17 @@ export default function DmScreen() {
const messagesRef = useRef(messages); const messagesRef = useRef(messages);
messagesRef.current = messages; messagesRef.current = messages;
const openLightbox = useCallback((uri: string) => { const openLightbox = useCallback((uri: string, source: 'chat' | 'info' = 'chat') => {
const urls = messagesRef.current const urls = messagesRef.current
.filter((m) => m.attachmentType === 'image' && m.attachmentUrl) .filter((m) => m.attachmentType === 'image' && m.attachmentUrl)
.map((m) => m.attachmentUrl as string); .map((m) => m.attachmentUrl as string);
const list = urls.length ? urls : [uri]; const list = urls.length ? urls : [uri];
setLightboxSource(source);
setLightboxImages(list); setLightboxImages(list);
setLightboxIndex(Math.max(0, list.indexOf(uri))); setLightboxIndex(Math.max(0, list.indexOf(uri)));
setLightboxRatios({});
}, []); }, []);
const closeLightbox = useCallback(() => { const closeLightbox = useCallback(() => {
setLightboxImages([]); setLightboxImages([]);
setLightboxRatios({});
}, []); }, []);
// Voice recording // Voice recording
@ -321,7 +320,7 @@ export default function DmScreen() {
useDmRealtime(userId, onDmInsert, !!myUserId, refetchHistory, refetchHistory); useDmRealtime(userId, onDmInsert, !!myUserId, refetchHistory, refetchHistory);
// Typing-Indicator (ephemerer Broadcast, kein DB-Write) // Typing-Indicator (ephemerer Broadcast, kein DB-Write)
const { partnerTyping, sendTyping, sendStopTyping } = useDmTyping(myUserId, userId); const { partnerTyping, setComposing, sendStopTyping } = useDmTyping(myUserId, userId);
// Darf der User den Partner anrufen? (gegenseitiger Follow + callsEnabled). // Darf der User den Partner anrufen? (gegenseitiger Follow + callsEnabled).
// Steuert Sichtbarkeit des Call-Buttons im Header. // Steuert Sichtbarkeit des Call-Buttons im Header.
@ -757,24 +756,6 @@ export default function DmScreen() {
return Math.abs(new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) <= GROUP_GAP_MS; return Math.abs(new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) <= GROUP_GAP_MS;
} }
// Lightbox-Bildmaße: in die Bildschirmfläche einpassen, Seitenverhältnis wahren.
const lbWin = Dimensions.get('window');
const lbMaxW = lbWin.width - 24;
const lbMaxH = lbWin.height * 0.78;
function lbFitDims(ratio?: number) {
let w = lbMaxW;
let h = lbMaxW; // Fallback (Ratio noch unbekannt): Quadrat
if (ratio) {
w = lbMaxW;
h = lbMaxW / ratio;
if (h > lbMaxH) {
h = lbMaxH;
w = lbMaxH * ratio;
}
}
return { width: w, height: h };
}
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
<View style={[styles.header, { backgroundColor: colors.bg }]}> <View style={[styles.header, { backgroundColor: colors.bg }]}>
@ -945,9 +926,12 @@ export default function DmScreen() {
value={inputText} value={inputText}
onChangeText={(v) => { onChangeText={(v) => {
setInputText(v); setInputText(v);
if (v.trim().length > 0) sendTyping(); // Heartbeat aktiv solange Text vorhanden (Feld ist beim
else sendStopTyping(); // onChangeText zwangsläufig fokussiert). Hält „tippt" stabil
// über Denkpausen — onBlur/Senden/Leeren stoppt ihn.
setComposing(v.trim().length > 0);
}} }}
onBlur={() => sendStopTyping()}
multiline multiline
maxLength={2000} maxLength={2000}
returnKeyType="send" returnKeyType="send"
@ -992,112 +976,38 @@ export default function DmScreen() {
partner={partner} partner={partner}
partnerUserId={userId ?? null} partnerUserId={userId ?? null}
messages={messages} messages={messages}
onImagePress={(uri) => { onImagePress={(uri) => openLightbox(uri, 'info')}
// Sheet erst schließen, dann Lightbox — sonst läge die Lightbox hinter
// dem FormSheet-Modal und wäre nicht sichtbar.
setInfoSheetOpen(false);
setTimeout(() => openLightbox(uri), 250);
}}
onViewProfile={() => { onViewProfile={() => {
setInfoSheetOpen(false); setInfoSheetOpen(false);
setTimeout(() => userId && router.push(`/profile/${userId}` as any), 250); setTimeout(() => userId && router.push(`/profile/${userId}` as any), 250);
}} }}
colors={colors} colors={colors}
t={t} t={t}
lightbox={
<MediaLightbox
visible={lightboxOpen && lightboxSource === 'info'}
images={lightboxImages}
index={lightboxIndex}
onIndexChange={setLightboxIndex}
onClose={closeLightbox}
onSave={saveImage}
saving={savingImage}
/>
}
/> />
{/* ── Lightbox-Carousel ──────────────────────────────────────── */} {/* Lightbox (Chat-Kontext)
<Modal visible={lightboxOpen} transparent animationType="fade" onRequestClose={closeLightbox}> Root-Instanz für Taps auf Bilder im Chatverlauf. Die Info-Sheet-
<View style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.92)' }}> Instanz wird genested im DmInfoSheet gerendert (siehe oben). */}
<FlatList <MediaLightbox
data={lightboxImages} visible={lightboxOpen && lightboxSource === 'chat'}
horizontal images={lightboxImages}
pagingEnabled index={lightboxIndex}
showsHorizontalScrollIndicator={false} onIndexChange={setLightboxIndex}
initialScrollIndex={lightboxIndex} onClose={closeLightbox}
getItemLayout={(_, i) => ({ length: lbWin.width, offset: lbWin.width * i, index: i })} onSave={saveImage}
keyExtractor={(u, i) => `${i}-${u}`} saving={savingImage}
onMomentumScrollEnd={(e) => />
setLightboxIndex(Math.round(e.nativeEvent.contentOffset.x / lbWin.width))
}
renderItem={({ item, index }) => (
<TouchableOpacity
activeOpacity={1}
onPress={closeLightbox}
style={{ width: lbWin.width, height: lbWin.height, alignItems: 'center', justifyContent: 'center' }}
>
<Image
source={{ uri: item }}
onLoad={(e) => {
const s = e.source;
if (s?.width && s?.height)
setLightboxRatios((r) => ({ ...r, [index]: s.width / s.height }));
}}
style={{ ...lbFitDims(lightboxRatios[index]), borderRadius: 16 }}
contentFit="contain"
cachePolicy="memory-disk"
/>
</TouchableOpacity>
)}
/>
{/* Zähler "2 / 6" — nur bei mehreren Bildern */}
{lightboxImages.length > 1 && (
<View
style={{
position: 'absolute',
top: 56,
alignSelf: 'center',
paddingHorizontal: 12,
paddingVertical: 5,
borderRadius: 14,
backgroundColor: 'rgba(0,0,0,0.45)',
}}
pointerEvents="none"
>
<Text style={{ color: '#fff', fontSize: 13, fontFamily: 'Nunito_700Bold', fontVariant: ['tabular-nums'] }}>
{lightboxIndex + 1} / {lightboxImages.length}
</Text>
</View>
)}
<TouchableOpacity
style={{ position: 'absolute', top: 50, right: 20, padding: 8 }}
onPress={closeLightbox}
activeOpacity={0.7}
>
<Ionicons name="close-circle" size={32} color="#fff" />
</TouchableOpacity>
{/* Sichern (aktuelles Bild) */}
<TouchableOpacity
style={{
position: 'absolute',
bottom: 54,
alignSelf: 'center',
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 24,
backgroundColor: 'rgba(255,255,255,0.16)',
}}
onPress={() => lightboxImages[lightboxIndex] && saveImage(lightboxImages[lightboxIndex])}
disabled={savingImage}
activeOpacity={0.7}
>
{savingImage ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Ionicons name="download-outline" size={20} color="#fff" />
)}
<Text style={{ color: '#fff', fontSize: 15, fontFamily: 'Nunito_600SemiBold' }}>
{t('chat.save')}
</Text>
</TouchableOpacity>
</View>
</Modal>
</SafeAreaView> </SafeAreaView>
); );
} }
@ -1118,6 +1028,7 @@ function DmInfoSheet({
onViewProfile, onViewProfile,
colors, colors,
t, t,
lightbox,
}: { }: {
visible: boolean; visible: boolean;
onClose: () => void; onClose: () => void;
@ -1128,6 +1039,9 @@ function DmInfoSheet({
onViewProfile: () => void; onViewProfile: () => void;
colors: ReturnType<typeof useColors>; colors: ReturnType<typeof useColors>;
t: ReturnType<typeof import('react-i18next').useTranslation>['t']; t: ReturnType<typeof import('react-i18next').useTranslation>['t'];
// Genestete Lightbox — liegt über dem FormSheet-Modal, damit das Sheet beim
// Bild-Öffnen erhalten bleibt (kein Kontextwechsel zurück zur DM).
lightbox?: ReactNode;
}) { }) {
const sharedMedia = messages.filter( const sharedMedia = messages.filter(
(m) => m.attachmentType === 'image' && m.attachmentUrl, (m) => m.attachmentType === 'image' && m.attachmentUrl,
@ -1208,6 +1122,7 @@ function DmInfoSheet({
</View> </View>
)} )}
</ScrollView> </ScrollView>
{lightbox}
</FormSheet> </FormSheet>
); );
} }

View File

@ -0,0 +1,137 @@
import {
Modal,
View,
Text,
FlatList,
TouchableOpacity,
ActivityIndicator,
Dimensions,
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
/**
* MediaLightbox Vollbild-Carousel über geteilte Chat-Bilder.
*
* Wird an ZWEI Stellen gerendert (siehe dm.tsx): einmal am Screen-Root für Taps
* auf Bilder im Chatverlauf, einmal genested INNERHALB des Info-Sheets
* (FormSheet ist ein RN-Modal). Nur die genestete Instanz liegt zuverlässig
* ÜBER dem Sheet deshalb steuert `visible` pro Kontext, welche aktiv ist.
* So bleibt das Info-Sheet beim Bild-Öffnen erhalten (kein Kontextwechsel
* zurück zur DM).
*
* Kein async Seitenverhältnis: das Bild füllt eine FIXE Vollbild-Box mit
* contentFit="contain". Dadurch entfällt der frühere QuadratEchtmaß-Sprung
* ("Zucken"), der durch onLoad-gesetzte Ratios entstand.
*/
export function MediaLightbox({
visible,
images,
index,
onIndexChange,
onClose,
onSave,
saving,
}: {
visible: boolean;
images: string[];
index: number;
onIndexChange: (i: number) => void;
onClose: () => void;
onSave: (uri: string) => void;
saving: boolean;
}) {
const { t } = useTranslation();
const win = Dimensions.get('window');
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.92)' }}>
<FlatList
data={images}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
initialScrollIndex={index}
getItemLayout={(_, i) => ({ length: win.width, offset: win.width * i, index: i })}
keyExtractor={(u, i) => `${i}-${u}`}
onMomentumScrollEnd={(e) =>
onIndexChange(Math.round(e.nativeEvent.contentOffset.x / win.width))
}
renderItem={({ item }) => (
<TouchableOpacity
activeOpacity={1}
onPress={onClose}
style={{ width: win.width, height: win.height, alignItems: 'center', justifyContent: 'center' }}
>
{/* Fixe Vollbild-Box + contain → stabil, kein Re-Layout-Sprung. */}
<Image
source={{ uri: item }}
style={{ width: win.width, height: win.height }}
contentFit="contain"
cachePolicy="memory-disk"
/>
</TouchableOpacity>
)}
/>
{/* Zähler "2 / 6" — nur bei mehreren Bildern */}
{images.length > 1 && (
<View
style={{
position: 'absolute',
top: 56,
alignSelf: 'center',
paddingHorizontal: 12,
paddingVertical: 5,
borderRadius: 14,
backgroundColor: 'rgba(0,0,0,0.45)',
}}
pointerEvents="none"
>
<Text style={{ color: '#fff', fontSize: 13, fontFamily: 'Nunito_700Bold', fontVariant: ['tabular-nums'] }}>
{index + 1} / {images.length}
</Text>
</View>
)}
<TouchableOpacity
style={{ position: 'absolute', top: 50, right: 20, padding: 8 }}
onPress={onClose}
activeOpacity={0.7}
>
<Ionicons name="close-circle" size={32} color="#fff" />
</TouchableOpacity>
{/* Sichern (aktuelles Bild) */}
<TouchableOpacity
style={{
position: 'absolute',
bottom: 54,
alignSelf: 'center',
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 24,
backgroundColor: 'rgba(255,255,255,0.16)',
}}
onPress={() => images[index] && onSave(images[index])}
disabled={saving}
activeOpacity={0.7}
>
{saving ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Ionicons name="download-outline" size={20} color="#fff" />
)}
<Text style={{ color: '#fff', fontSize: 15, fontFamily: 'Nunito_600SemiBold' }}>
{t('chat.save')}
</Text>
</TouchableOpacity>
</View>
</Modal>
);
}

View File

@ -9,15 +9,25 @@ import type { RealtimeChannel } from '@supabase/supabase-js';
* Beide Peers joinen denselben deterministischen Channel (sortiertes ID-Paar), * Beide Peers joinen denselben deterministischen Channel (sortiertes ID-Paar),
* damit `send()` von A bei B ankommt. `self:false` filtert die eigenen Events. * damit `send()` von A bei B ankommt. `self:false` filtert die eigenen Events.
* *
* - `sendTyping()` throttled-Broadcast ich tippe" (max 1×/1.5s) * STABILITÄT (gegen Flackern Onlinetippt):
* - `sendStopTyping()` sofortiger Stop" (beim Senden / Leeren des Inputs) * Der Sender feuert nicht nur bei Tastendruck, sondern hält einen HEARTBEAT
* - `partnerTyping` true solange Partner-Events reinkommen (Auto-Clear 4s) * (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) { export function useDmTyping(myUserId: string | undefined, partnerId: string | undefined) {
const [partnerTyping, setPartnerTyping] = useState(false); const [partnerTyping, setPartnerTyping] = useState(false);
const channelRef = useRef<RealtimeChannel | null>(null); const channelRef = useRef<RealtimeChannel | null>(null);
const clearTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const clearTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastSent = useRef(0); const heartbeat = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => { useEffect(() => {
if (!myUserId || !partnerId) return; if (!myUserId || !partnerId) return;
@ -30,7 +40,7 @@ export function useDmTyping(myUserId: string | undefined, partnerId: string | un
if (msg?.payload?.userId !== partnerId) return; if (msg?.payload?.userId !== partnerId) return;
setPartnerTyping(true); setPartnerTyping(true);
if (clearTimer.current) clearTimeout(clearTimer.current); if (clearTimer.current) clearTimeout(clearTimer.current);
clearTimer.current = setTimeout(() => setPartnerTyping(false), 4000); clearTimer.current = setTimeout(() => setPartnerTyping(false), CLEAR_MS);
}) })
.on('broadcast', { event: 'stop_typing' }, (msg: any) => { .on('broadcast', { event: 'stop_typing' }, (msg: any) => {
if (msg?.payload?.userId !== partnerId) return; if (msg?.payload?.userId !== partnerId) return;
@ -42,31 +52,49 @@ export function useDmTyping(myUserId: string | undefined, partnerId: string | un
return () => { return () => {
if (clearTimer.current) clearTimeout(clearTimer.current); if (clearTimer.current) clearTimeout(clearTimer.current);
if (heartbeat.current) clearInterval(heartbeat.current);
heartbeat.current = null;
supabase.removeChannel(channel); supabase.removeChannel(channel);
channelRef.current = null; channelRef.current = null;
setPartnerTyping(false); setPartnerTyping(false);
}; };
}, [myUserId, partnerId]); }, [myUserId, partnerId]);
const sendTyping = useCallback(() => { const broadcast = useCallback(
const now = Date.now(); (event: 'typing' | 'stop_typing') => {
if (now - lastSent.current < 1500) return; // Throttle channelRef.current?.send({ type: 'broadcast', event, payload: { userId: myUserId } });
lastSent.current = now; },
channelRef.current?.send({ [myUserId],
type: 'broadcast', );
event: 'typing',
payload: { userId: myUserId },
});
}, [myUserId]);
const sendStopTyping = useCallback(() => { const sendStopTyping = useCallback(() => {
lastSent.current = 0; if (heartbeat.current) {
channelRef.current?.send({ clearInterval(heartbeat.current);
type: 'broadcast', heartbeat.current = null;
event: 'stop_typing', }
payload: { userId: myUserId }, broadcast('stop_typing');
}); }, [broadcast]);
}, [myUserId]);
return { partnerTyping, sendTyping, sendStopTyping }; /**
* 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 };
} }

View File

@ -21,11 +21,56 @@ let subscriberCount = 0;
let onlineUserIds: Set<string> = new Set(); let onlineUserIds: Set<string> = new Set();
const listeners = new Set<(ids: Set<string>) => void>(); const listeners = new Set<(ids: Set<string>) => 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<string, ReturnType<typeof setTimeout>>();
function clearPendingRemovals() {
pendingRemoval.forEach((timer) => clearTimeout(timer));
pendingRemoval.clear();
}
function notify() { function notify() {
const snapshot = new Set(onlineUserIds); const snapshot = new Set(onlineUserIds);
listeners.forEach((fn) => fn(snapshot)); 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) { function ensureChannel(currentUserId: string) {
if (sharedChannel) return; if (sharedChannel) return;
@ -37,9 +82,7 @@ function ensureChannel(currentUserId: string) {
ch ch
.on('presence', { event: 'sync' }, () => { .on('presence', { event: 'sync' }, () => {
const state = ch.presenceState(); const state = ch.presenceState();
const keys = Object.keys(state); applyRawPresence(Object.keys(state));
onlineUserIds = new Set(keys);
notify();
}) })
.subscribe(async (status: string) => { .subscribe(async (status: string) => {
if (status === 'SUBSCRIBED') { if (status === 'SUBSCRIBED') {
@ -50,6 +93,7 @@ function ensureChannel(currentUserId: string) {
function teardownChannel() { function teardownChannel() {
if (!sharedChannel) return; if (!sharedChannel) return;
clearPendingRemovals();
sharedChannel.untrack().catch(() => {}); sharedChannel.untrack().catch(() => {});
supabase.removeChannel(sharedChannel); supabase.removeChannel(sharedChannel);
sharedChannel = null; sharedChannel = null;