From 92ad4c93b5754af528e4345334595fdfd50ca336 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Thu, 4 Jun 2026 10:48:00 +0200 Subject: [PATCH] fix(dm): smooth image lightbox + stable online/typing status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/rebreak-native/NEXT_RELEASE.md | 6 + apps/rebreak-native/app/dm.tsx | 169 +++++------------- .../components/chat/MediaLightbox.tsx | 137 ++++++++++++++ apps/rebreak-native/hooks/useDmTyping.ts | 74 +++++--- apps/rebreak-native/hooks/useOnlineUsers.ts | 50 +++++- 5 files changed, 283 insertions(+), 153 deletions(-) create mode 100644 apps/rebreak-native/NEXT_RELEASE.md create mode 100644 apps/rebreak-native/components/chat/MediaLightbox.tsx diff --git a/apps/rebreak-native/NEXT_RELEASE.md b/apps/rebreak-native/NEXT_RELEASE.md new file mode 100644 index 0000000..aec48ff --- /dev/null +++ b/apps/rebreak-native/NEXT_RELEASE.md @@ -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 diff --git a/apps/rebreak-native/app/dm.tsx b/apps/rebreak-native/app/dm.tsx index d3a6a5f..30d393b 100644 --- a/apps/rebreak-native/app/dm.tsx +++ b/apps/rebreak-native/app/dm.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect, useCallback } from 'react'; +import { useState, useRef, useEffect, useCallback, type ReactNode } from 'react'; import { View, Text, @@ -10,7 +10,6 @@ import { ActivityIndicator, StyleSheet, Keyboard, - Modal, ScrollView, Dimensions, type FlatList as FlatListType, @@ -35,6 +34,7 @@ import * as FileSystem from 'expo-file-system/legacy'; import { apiFetch } from '../lib/api'; import { ChatBubble, type ChatMsg, type MessageReaction } from '../components/chat/ChatBubble'; import { VoiceRecordingBar, formatVoiceDuration } from '../components/chat/VoiceRecordingBar'; +import { MediaLightbox } from '../components/chat/MediaLightbox'; import { FormSheet } from '../components/FormSheet'; import { useDmRealtime } from '../hooks/useChatRealtime'; import { useDmTyping } from '../hooks/useDmTyping'; @@ -143,9 +143,9 @@ export default function DmScreen() { // zwischen allen Bildern wischen. const [lightboxImages, setLightboxImages] = useState([]); const [lightboxIndex, setLightboxIndex] = useState(0); - // Index → echtes Seitenverhältnis (via onLoad), damit der Container exakt auf - // die Bildmaße passt und borderRadius die sichtbaren Foto-Ecken rundet. - const [lightboxRatios, setLightboxRatios] = useState>({}); + // Aus welchem Kontext die Lightbox geöffnet wurde. 'chat' = Root-Instanz, + // 'info' = genestet im Info-Sheet (liegt zuverlässig über dem FormSheet-Modal). + const [lightboxSource, setLightboxSource] = useState<'chat' | 'info'>('chat'); const [savingImage, setSavingImage] = useState(false); const lightboxOpen = lightboxImages.length > 0; @@ -154,18 +154,17 @@ export default function DmScreen() { const messagesRef = useRef(messages); messagesRef.current = messages; - const openLightbox = useCallback((uri: string) => { + const openLightbox = useCallback((uri: string, source: 'chat' | 'info' = 'chat') => { const urls = messagesRef.current .filter((m) => m.attachmentType === 'image' && m.attachmentUrl) .map((m) => m.attachmentUrl as string); const list = urls.length ? urls : [uri]; + setLightboxSource(source); setLightboxImages(list); setLightboxIndex(Math.max(0, list.indexOf(uri))); - setLightboxRatios({}); }, []); const closeLightbox = useCallback(() => { setLightboxImages([]); - setLightboxRatios({}); }, []); // Voice recording @@ -321,7 +320,7 @@ export default function DmScreen() { useDmRealtime(userId, onDmInsert, !!myUserId, refetchHistory, refetchHistory); // 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). // 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; } - // 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 ( @@ -945,9 +926,12 @@ export default function DmScreen() { value={inputText} onChangeText={(v) => { setInputText(v); - if (v.trim().length > 0) sendTyping(); - else sendStopTyping(); + // Heartbeat aktiv solange Text vorhanden (Feld ist beim + // onChangeText zwangsläufig fokussiert). Hält „tippt" stabil + // über Denkpausen — onBlur/Senden/Leeren stoppt ihn. + setComposing(v.trim().length > 0); }} + onBlur={() => sendStopTyping()} multiline maxLength={2000} returnKeyType="send" @@ -992,112 +976,38 @@ export default function DmScreen() { partner={partner} partnerUserId={userId ?? null} messages={messages} - onImagePress={(uri) => { - // 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); - }} + onImagePress={(uri) => openLightbox(uri, 'info')} onViewProfile={() => { setInfoSheetOpen(false); setTimeout(() => userId && router.push(`/profile/${userId}` as any), 250); }} colors={colors} t={t} + lightbox={ + + } /> - {/* ── Lightbox-Carousel ──────────────────────────────────────── */} - - - ({ length: lbWin.width, offset: lbWin.width * i, index: i })} - keyExtractor={(u, i) => `${i}-${u}`} - onMomentumScrollEnd={(e) => - setLightboxIndex(Math.round(e.nativeEvent.contentOffset.x / lbWin.width)) - } - renderItem={({ item, index }) => ( - - { - 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" - /> - - )} - /> - - {/* Zähler "2 / 6" — nur bei mehreren Bildern */} - {lightboxImages.length > 1 && ( - - - {lightboxIndex + 1} / {lightboxImages.length} - - - )} - - - - - - {/* Sichern (aktuelles Bild) */} - lightboxImages[lightboxIndex] && saveImage(lightboxImages[lightboxIndex])} - disabled={savingImage} - activeOpacity={0.7} - > - {savingImage ? ( - - ) : ( - - )} - - {t('chat.save')} - - - - + {/* ── Lightbox (Chat-Kontext) ────────────────────────────────── + Root-Instanz für Taps auf Bilder im Chatverlauf. Die Info-Sheet- + Instanz wird genested im DmInfoSheet gerendert (siehe oben). */} + ); } @@ -1118,6 +1028,7 @@ function DmInfoSheet({ onViewProfile, colors, t, + lightbox, }: { visible: boolean; onClose: () => void; @@ -1128,6 +1039,9 @@ function DmInfoSheet({ onViewProfile: () => void; colors: ReturnType; t: ReturnType['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( (m) => m.attachmentType === 'image' && m.attachmentUrl, @@ -1208,6 +1122,7 @@ function DmInfoSheet({ )} + {lightbox} ); } diff --git a/apps/rebreak-native/components/chat/MediaLightbox.tsx b/apps/rebreak-native/components/chat/MediaLightbox.tsx new file mode 100644 index 0000000..eb153eb --- /dev/null +++ b/apps/rebreak-native/components/chat/MediaLightbox.tsx @@ -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 Quadrat→Echtmaß-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 ( + + + ({ 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 }) => ( + + {/* Fixe Vollbild-Box + contain → stabil, kein Re-Layout-Sprung. */} + + + )} + /> + + {/* Zähler "2 / 6" — nur bei mehreren Bildern */} + {images.length > 1 && ( + + + {index + 1} / {images.length} + + + )} + + + + + + {/* Sichern (aktuelles Bild) */} + images[index] && onSave(images[index])} + disabled={saving} + activeOpacity={0.7} + > + {saving ? ( + + ) : ( + + )} + + {t('chat.save')} + + + + + ); +} diff --git a/apps/rebreak-native/hooks/useDmTyping.ts b/apps/rebreak-native/hooks/useDmTyping.ts index 64e603f..3352c86 100644 --- a/apps/rebreak-native/hooks/useDmTyping.ts +++ b/apps/rebreak-native/hooks/useDmTyping.ts @@ -9,15 +9,25 @@ import type { RealtimeChannel } from '@supabase/supabase-js'; * Beide Peers joinen denselben deterministischen Channel (sortiertes ID-Paar), * damit `send()` von A bei B ankommt. `self:false` filtert die eigenen Events. * - * - `sendTyping()` → throttled-Broadcast „ich tippe" (max 1×/1.5s) - * - `sendStopTyping()` → sofortiger „Stop" (beim Senden / Leeren des Inputs) - * - `partnerTyping` → true solange Partner-Events reinkommen (Auto-Clear 4s) + * 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 lastSent = useRef(0); + const heartbeat = useRef | null>(null); useEffect(() => { if (!myUserId || !partnerId) return; @@ -30,7 +40,7 @@ export function useDmTyping(myUserId: string | undefined, partnerId: string | un if (msg?.payload?.userId !== partnerId) return; setPartnerTyping(true); 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) => { if (msg?.payload?.userId !== partnerId) return; @@ -42,31 +52,49 @@ export function useDmTyping(myUserId: string | undefined, partnerId: string | un 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 sendTyping = useCallback(() => { - const now = Date.now(); - if (now - lastSent.current < 1500) return; // Throttle - lastSent.current = now; - channelRef.current?.send({ - type: 'broadcast', - event: 'typing', - payload: { userId: myUserId }, - }); - }, [myUserId]); + const broadcast = useCallback( + (event: 'typing' | 'stop_typing') => { + channelRef.current?.send({ type: 'broadcast', event, payload: { userId: myUserId } }); + }, + [myUserId], + ); const sendStopTyping = useCallback(() => { - lastSent.current = 0; - channelRef.current?.send({ - type: 'broadcast', - event: 'stop_typing', - payload: { userId: myUserId }, - }); - }, [myUserId]); + if (heartbeat.current) { + clearInterval(heartbeat.current); + heartbeat.current = null; + } + broadcast('stop_typing'); + }, [broadcast]); - 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 }; } diff --git a/apps/rebreak-native/hooks/useOnlineUsers.ts b/apps/rebreak-native/hooks/useOnlineUsers.ts index 53d5cd9..fd9e7a7 100644 --- a/apps/rebreak-native/hooks/useOnlineUsers.ts +++ b/apps/rebreak-native/hooks/useOnlineUsers.ts @@ -21,11 +21,56 @@ 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; @@ -37,9 +82,7 @@ function ensureChannel(currentUserId: string) { ch .on('presence', { event: 'sync' }, () => { const state = ch.presenceState(); - const keys = Object.keys(state); - onlineUserIds = new Set(keys); - notify(); + applyRawPresence(Object.keys(state)); }) .subscribe(async (status: string) => { if (status === 'SUBSCRIBED') { @@ -50,6 +93,7 @@ function ensureChannel(currentUserId: string) { function teardownChannel() { if (!sharedChannel) return; + clearPendingRemovals(); sharedChannel.untrack().catch(() => {}); supabase.removeChannel(sharedChannel); sharedChannel = null;