- 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>
138 lines
4.3 KiB
TypeScript
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>
|
|
);
|
|
}
|