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

138 lines
4.3 KiB
TypeScript

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 (
<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>
);
}