import { useState, useRef, useEffect, useCallback } from 'react'; import { View, Text, TextInput, FlatList, TouchableOpacity, Platform, Alert, ActivityIndicator, StyleSheet, Keyboard, Modal, ScrollView, Dimensions, type FlatList as FlatListType, } from 'react-native'; import { Audio } from 'expo-av'; import { KeyboardStickyView } from 'react-native-keyboard-controller'; import { Image } from 'expo-image'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useRouter, useLocalSearchParams, useFocusEffect } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import * as ImagePicker from 'expo-image-picker'; import * as MediaLibrary from 'expo-media-library'; // TODO(sdk54): migrate to new expo-file-system class-based API (File/Directory/Paths) — see Task #14 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 { FormSheet } from '../components/FormSheet'; import { useDmRealtime } from '../hooks/useChatRealtime'; import { useDmTyping } from '../hooks/useDmTyping'; import { useColors } from '../lib/theme'; import { useAuthStore } from '../stores/auth'; import { supabase } from '../lib/supabase'; import { UserAvatar } from '../components/UserAvatar'; import { ChatHeaderStatus } from '../components/chat/ChatHeaderStatus'; type DmHistoryResponse = { partner: { id: string; nickname: string; username?: string; avatar?: string | null; }; messages: Array<{ id: string; content: string; createdAt: string; isOwn: boolean; readAt: string | null; senderId?: string; receiverId?: string; likesCount?: number; likedByMe?: boolean; attachmentUrl?: string | null; attachmentType?: string | null; attachmentName?: string | null; replyTo?: any; }>; }; const GROUP_GAP_MS = 5 * 60 * 1000; type DmData = { partner: DmHistoryResponse['partner']; messages: ChatMsg[]; }; // Merge bei Background-Refetch: Server-Daten sind autoritativ; lokale Extras // (optimistische temp-* Sends + Realtime-Inserts, die der letzte Fetch noch // nicht kannte) bleiben erhalten und werden nach createdAt einsortiert. function mergeMessages(server: ChatMsg[], local: ChatMsg[]): ChatMsg[] { const serverIds = new Set(server.map((m) => m.id)); const extras = local.filter((m) => !serverIds.has(m.id)); if (extras.length === 0) return server; return [...server, ...extras].sort( (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), ); } export default function DmScreen() { const { t } = useTranslation(); const router = useRouter(); const insets = useSafeAreaInsets(); const colors = useColors(); const styles = makeStyles(colors); const queryClient = useQueryClient(); const myUserId = useAuthStore((s) => s.user?.id); const { userId } = useLocalSearchParams<{ userId: string }>(); // Chat-Hintergrund: immer clean (solider Theme-BG, weiß / schwarz). Insta-Style. const chatBg = colors.bg; const flatListRef = useRef>(null); // scrollToEnd() unterschätzt auf Android UND iOS die Content-Höhe und // stoppt 1 Item zu früh (besonders nach Bild-Load + InputBar-Padding). // scrollToOffset(999999) wird intern auf den echten Max-Wert geclampt. const scrollToBottom = useCallback((animated = false) => { flatListRef.current?.scrollToOffset({ offset: 999999, animated }); }, []); // Seed beide aus dem React-Query-Cache → Reopen einer bereits geladenen // Konversation ist sofort sichtbar (kein Spinner, kein Flash). const [messages, setMessages] = useState( () => queryClient.getQueryData(['dm-history', userId])?.messages ?? [], ); const [partner, setPartner] = useState( () => queryClient.getQueryData(['dm-history', userId])?.partner ?? null, ); const partnerRef = useRef(partner); // userId, zu dem die aktuellen `messages` gehören (Stack-Reuse-Guard). const messagesUserId = useRef(userId); const [replyTo, setReplyTo] = useState<{ id: string; nickname: string; content: string } | null>( null, ); const [sending, setSending] = useState(false); const [inputText, setInputText] = useState(''); const [attachment, setAttachment] = useState<{ uri: string; name: string } | null>(null); const [uploading, setUploading] = useState(false); const [keyboardVisible, setKeyboardVisible] = useState(false); const [keyboardHeight, setKeyboardHeight] = useState(0); const [inputBarHeight, setInputBarHeight] = useState(60); const [infoSheetOpen, setInfoSheetOpen] = useState(false); const [lightboxUri, setLightboxUri] = useState(null); // Echtes Seitenverhältnis des Lightbox-Bilds (via onLoad). Wird gebraucht, um // den Container exakt auf die Bild-Maße zu sizen → borderRadius rundet dann die // sichtbaren Foto-Ecken statt der leeren Letterbox-Ränder eines Quadrats. const [lightboxRatio, setLightboxRatio] = useState(null); const [savingImage, setSavingImage] = useState(false); const openLightbox = useCallback((uri: string) => { setLightboxRatio(null); setLightboxUri(uri); }, []); const closeLightbox = useCallback(() => { setLightboxUri(null); setLightboxRatio(null); }, []); // Voice recording const [isVoiceRecording, setIsVoiceRecording] = useState(false); const [voiceDuration, setVoiceDuration] = useState(0); const [voiceLevel, setVoiceLevel] = useState(0); const [voiceTrashFlash, setVoiceTrashFlash] = useState(false); const voiceRecordingRef = useRef(null); const voiceTimerRef = useRef | null>(null); const voiceStartTime = useRef(0); // Konversation gewechselt (expo-router reused den DM-Screen). Reply-Draft // wegräumen und sofort auf den Cache der neuen Konversation umschalten: // vorhanden → instant sichtbar, sonst leeren (Spinner via isLoading). useEffect(() => { if (messagesUserId.current === userId) return; setReplyTo(null); const cached = queryClient.getQueryData(['dm-history', userId]); setMessages(cached?.messages ?? []); setPartner(cached?.partner ?? null); partnerRef.current = cached?.partner ?? null; messagesUserId.current = userId; }, [userId, queryClient]); // Keyboard-Sichtbarkeit tracken + scroll to end beim Schließen useEffect(() => { const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; const show = Keyboard.addListener(showEvent, (e) => { setKeyboardHeight(e.endCoordinates.height); setKeyboardVisible(true); requestAnimationFrame(() => scrollToBottom(false)); }); const hide = Keyboard.addListener(hideEvent, () => { setKeyboardHeight(0); setKeyboardVisible(false); setTimeout(() => scrollToBottom(false), 50); }); return () => { show.remove(); hide.remove(); }; }, []); // Wenn User zurücknavigiert, soll die Conversation-Liste sofort neu laden // (unread-Badge soll verschwinden — Backend hat bereits markDmsAsRead beim GET aufgerufen) useFocusEffect( useCallback(() => { return () => { queryClient.invalidateQueries({ queryKey: ['dm-conversations'] }); }; }, [queryClient]), ); // DM-History laden — stale-while-revalidate: gecachte Messages werden sofort // gezeigt (useState-Seed oben + Sync-Effekt unten), im Hintergrund frisch // gefetcht und gemerged. gcTime hält den Cache über Navigation hinweg, sodass // ein Reopen instant ist statt jedes Mal die ganze History neu zu ziehen. const { data: historyData, isLoading, isFetching } = useQuery({ queryKey: ['dm-history', userId], queryFn: async () => { const data = await apiFetch(`/api/chat/dm/${userId}`); const msgs: ChatMsg[] = data.messages.map((m: any) => ({ id: m.id, userId: m.senderId ?? (m.isOwn ? myUserId ?? '' : userId), nickname: m.isOwn ? 'Du' : data.partner?.nickname ?? '?', avatar: m.isOwn ? null : data.partner?.avatar ?? null, content: m.content, replyTo: m.replyTo ? { id: m.replyTo.id, userId: m.replyTo.senderId, nickname: m.replyTo.senderId === myUserId ? 'Du' : data.partner?.nickname ?? '?', content: m.replyTo.content?.slice(0, 100) ?? '', attachmentType: m.replyTo.attachmentType ?? null, } : null, attachmentUrl: m.attachmentUrl ?? null, attachmentType: m.attachmentType ?? null, attachmentName: m.attachmentName ?? null, likesCount: m.likesCount ?? 0, likedByMe: m.likedByMe ?? false, createdAt: m.createdAt, isOwn: m.isOwn, readAt: m.readAt, reactions: m.reactions ?? [], deleted: m.deleted ?? false, })); return { partner: data.partner, messages: msgs }; }, enabled: !!userId && !!myUserId, staleTime: 30_000, gcTime: 30 * 60_000, }); // Cache → lokaler State. Lokaler State bleibt Render-Source-of-Truth, damit // Realtime-Inserts & optimistische Sends ihn direkt mutieren können; der // Merge bewahrt lokale Extras (temp-* / noch-nicht-gefetchte Realtime-Msgs). useEffect(() => { if (!historyData) return; setPartner(historyData.partner); partnerRef.current = historyData.partner; setMessages((prev) => { const base = messagesUserId.current === userId ? prev : []; messagesUserId.current = userId; return mergeMessages(historyData.messages, base); }); }, [historyData, userId]); // Neue Nachricht (incoming Realtime oder outgoing send) → immer scrollen useEffect(() => { if (messages.length === 0) return; requestAnimationFrame(() => scrollToBottom(true)); }, [messages.length]); // Realtime: neue DMs vom Partner const onDmInsert = useCallback( (row: any) => { if (row.receiver_id !== myUserId) return; setMessages((prev) => { if (prev.some((m) => m.id === row.id)) return prev; const p = partnerRef.current; return [ ...prev, { id: row.id, userId: row.sender_id, nickname: p?.nickname ?? '?', avatar: p?.avatar ?? null, content: row.content ?? '', replyTo: null, attachmentUrl: row.attachment_url ?? null, attachmentType: row.attachment_type ?? null, attachmentName: row.attachment_name ?? null, likesCount: row.likes_count ?? 0, likedByMe: false, createdAt: row.created_at, isOwn: false, readAt: null, }, ]; }); // Nachricht kam live rein WÄHREND der Chat offen ist → serverseitig als // gelesen markieren. markDmsAsRead läuft nur im History-GET, also den // invalidieren (refetch markiert read). Sonst bleibt der Tab-Bar-Badge // hängen, weil dm-conversations die Live-Message als unread zählt. queryClient.invalidateQueries({ queryKey: ['dm-history', userId] }); }, [myUserId, queryClient, userId], ); // Realtime: Partner-Soft-Delete (Tombstone) + Reaktions-Änderungen → refetch. const refetchHistory = useCallback(() => { queryClient.invalidateQueries({ queryKey: ['dm-history', userId] }); }, [queryClient, userId]); useDmRealtime(userId, onDmInsert, !!myUserId, refetchHistory, refetchHistory); // Typing-Indicator (ephemerer Broadcast, kein DB-Write) const { partnerTyping, sendTyping, sendStopTyping } = useDmTyping(myUserId, userId); async function pickImage() { const perm = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (!perm.granted) { Alert.alert(t('chat.photo_access_title'), t('chat.photo_access_body')); return; } const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, quality: 0.8, }); if (!result.canceled && result.assets[0]?.uri) { const a = result.assets[0]; setAttachment({ uri: a.uri, name: a.fileName ?? `image-${Date.now()}.jpg` }); } } async function uploadAttachment(file: { uri: string; name: string }): Promise<{ url: string; type: string; name: string } | null> { try { setUploading(true); const ext = file.name.split('.').pop() || 'jpg'; const path = `chat/${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${ext}`; const base64 = await FileSystem.readAsStringAsync(file.uri, { encoding: FileSystem.EncodingType.Base64, }); const binary = typeof atob === 'function' ? atob(base64) : Buffer.from(base64, 'base64').toString('binary'); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); const { error } = await supabase.storage.from('chat-attachments').upload(path, bytes, { cacheControl: '3600', upsert: false, contentType: 'image/jpeg', }); if (error) throw error; const { data } = supabase.storage.from('chat-attachments').getPublicUrl(path); return { url: data.publicUrl, type: 'image', name: file.name }; } catch (err: any) { Alert.alert(t('chat.upload_failed'), err?.message ?? ''); return null; } finally { setUploading(false); } } async function handleSend() { const content = inputText.trim(); if (!content && !attachment) return; if (sending || uploading) return; const tempId = `temp-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; const currentReplyTo = replyTo; const currentAttachment = attachment; const optimisticMsg: ChatMsg = { id: tempId, userId: myUserId ?? '', nickname: 'Du', avatar: null, content, replyTo: currentReplyTo ? { id: currentReplyTo.id, userId: myUserId ?? '', nickname: currentReplyTo.nickname, content: currentReplyTo.content, attachmentType: null, } : null, attachmentUrl: currentAttachment?.uri ?? null, attachmentType: currentAttachment ? 'image' : null, attachmentName: currentAttachment?.name ?? null, likesCount: 0, likedByMe: false, createdAt: new Date().toISOString(), isOwn: true, readAt: null, status: 'pending', }; setMessages((prev) => [...prev, optimisticMsg]); setInputText(''); setAttachment(null); setReplyTo(null); setSending(true); sendStopTyping(); try { let attachmentMeta: { url: string; type: string; name: string } | null = null; if (currentAttachment) { attachmentMeta = await uploadAttachment(currentAttachment); if (!attachmentMeta) { setMessages((prev) => prev.map((m) => (m.id === tempId ? { ...m, status: 'failed' as const } : m)), ); setSending(false); return; } } const newMsg = await apiFetch('/api/chat/dm', { method: 'POST', body: { receiverId: userId, content, replyToId: currentReplyTo?.id, attachmentUrl: attachmentMeta?.url, attachmentType: attachmentMeta?.type, attachmentName: attachmentMeta?.name, }, }); setMessages((prev) => prev.map((m) => m.id === tempId ? { ...m, id: newMsg.id, content: newMsg.content, attachmentUrl: newMsg.attachmentUrl ?? null, attachmentType: newMsg.attachmentType ?? null, attachmentName: newMsg.attachmentName ?? null, createdAt: newMsg.createdAt, status: 'sent' as const, } : m, ), ); queryClient.invalidateQueries({ queryKey: ['dm-conversations'] }); } catch (err) { console.error('DM send failed:', err); setMessages((prev) => prev.map((m) => (m.id === tempId ? { ...m, status: 'failed' as const } : m)), ); } finally { setSending(false); } } async function toggleLike(msg: ChatMsg) { try { const { liked } = await apiFetch<{ liked: boolean }>('/api/chat/like', { method: 'POST', body: { messageId: msg.id, type: 'dm' }, }); setMessages((prev) => prev.map((m) => m.id === msg.id ? { ...m, likedByMe: liked, likesCount: m.likesCount + (liked ? 1 : -1) } : m, ), ); } catch {} } // Optimistisches Anwenden einer Reaktion auf die aggregierte Pills-Liste // (WhatsApp-Toggle: gleiches Emoji entfernt, anderes ersetzt meine Reaktion). function applyReactionOptimistic( reactions: MessageReaction[], emoji: string, ): MessageReaction[] { const list = reactions.map((r) => ({ ...r })); const mine = list.find((r) => r.mine); const toggledOff = mine?.emoji === emoji; if (mine) { mine.count -= 1; mine.mine = false; } const cleaned = list.filter((r) => r.count > 0); if (toggledOff) return cleaned; const existing = cleaned.find((r) => r.emoji === emoji); if (existing) { existing.count += 1; existing.mine = true; } else { cleaned.push({ emoji, count: 1, mine: true }); } return cleaned; } async function toggleReaction(msg: ChatMsg, emoji: string) { setMessages((prev) => prev.map((m) => m.id === msg.id ? { ...m, reactions: applyReactionOptimistic(m.reactions ?? [], emoji) } : m, ), ); try { await apiFetch('/api/chat/reaction', { method: 'POST', body: { messageId: msg.id, emoji }, }); } catch { refetchHistory(); // Rollback via Server-State } } function deleteMessage(msg: ChatMsg) { Alert.alert( t('chat.delete_confirm_title'), t('chat.delete_confirm_msg'), [ { text: t('common.cancel'), style: 'cancel' }, { text: t('chat.delete'), style: 'destructive', onPress: async () => { setMessages((prev) => prev.map((m) => m.id === msg.id ? { ...m, deleted: true, content: '', attachmentUrl: null, attachmentType: null, reactions: [] } : m, ), ); try { await apiFetch('/api/chat/delete-message', { method: 'POST', body: { messageId: msg.id }, }); } catch { refetchHistory(); } }, }, ], ); } function startReply(msg: ChatMsg) { setReplyTo({ id: msg.id, nickname: msg.nickname ?? '?', content: msg.content?.slice(0, 100) || (msg.attachmentType === 'image' ? `📷 ${t('chat.photo')}` : msg.attachmentType === 'audio' ? `🎤 ${t('chat.voice_message')}` : ''), }); } async function startVoiceRecording() { if (isVoiceRecording || sending) return; const { status } = await Audio.requestPermissionsAsync(); if (status !== 'granted') { Alert.alert(t('chat.mic_access_title'), t('chat.mic_access_body')); return; } try { await Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true }); const rec = new Audio.Recording(); await rec.prepareToRecordAsync({ ...Audio.RecordingOptionsPresets.HIGH_QUALITY, isMeteringEnabled: true }); await rec.startAsync(); voiceRecordingRef.current = rec; voiceStartTime.current = Date.now(); setVoiceDuration(0); setIsVoiceRecording(true); voiceTimerRef.current = setInterval(async () => { setVoiceDuration(Math.floor((Date.now() - voiceStartTime.current) / 1000)); try { const s = await voiceRecordingRef.current?.getStatusAsync(); if (s?.isRecording && s.metering !== undefined) { setVoiceLevel(Math.max(0, Math.min(1, (s.metering + 60) / 60))); } } catch {} }, 200); } catch { await Audio.setAudioModeAsync({ allowsRecordingIOS: false }).catch(() => {}); } } async function cancelVoiceRecording() { const rec = voiceRecordingRef.current; voiceRecordingRef.current = null; if (voiceTimerRef.current) { clearInterval(voiceTimerRef.current); voiceTimerRef.current = null; } setVoiceDuration(0); setVoiceLevel(0); setVoiceTrashFlash(true); setTimeout(() => { setVoiceTrashFlash(false); setIsVoiceRecording(false); }, 350); try { await rec?.stopAndUnloadAsync(); } catch {} await Audio.setAudioModeAsync({ allowsRecordingIOS: false }); } async function stopAndSendVoice() { if (!isVoiceRecording) return; if (voiceTimerRef.current) { clearInterval(voiceTimerRef.current); voiceTimerRef.current = null; } const duration = voiceDuration; setIsVoiceRecording(false); setVoiceDuration(0); setVoiceLevel(0); const rec = voiceRecordingRef.current; voiceRecordingRef.current = null; if (!rec) return; try { await rec.stopAndUnloadAsync(); } catch {} await Audio.setAudioModeAsync({ allowsRecordingIOS: false }); const uri = rec.getURI(); if (!uri) return; await sendVoiceMessage(uri, formatVoiceDuration(duration)); } async function sendVoiceMessage(uri: string, durationStr: string) { if (sending) return; const tempId = `temp-voice-${Date.now()}`; const optimisticMsg: ChatMsg = { id: tempId, userId: myUserId ?? '', nickname: 'Du', avatar: null, content: '', replyTo: null, attachmentUrl: uri, attachmentType: 'audio', attachmentName: durationStr, likesCount: 0, likedByMe: false, createdAt: new Date().toISOString(), isOwn: true, readAt: null, status: 'pending', }; setMessages((prev) => [...prev, optimisticMsg]); setSending(true); try { setUploading(true); const ext = uri.split('.').pop() ?? 'm4a'; const path = `chat/${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${ext}`; const base64 = await FileSystem.readAsStringAsync(uri, { encoding: FileSystem.EncodingType.Base64 }); const binary = typeof atob === 'function' ? atob(base64) : Buffer.from(base64, 'base64').toString('binary'); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); const { error } = await supabase.storage.from('chat-attachments').upload(path, bytes, { cacheControl: '3600', upsert: false, contentType: 'audio/m4a', }); setUploading(false); if (error) throw error; const { data } = supabase.storage.from('chat-attachments').getPublicUrl(path); const newMsg = await apiFetch('/api/chat/dm', { method: 'POST', body: { receiverId: userId, content: '', attachmentUrl: data.publicUrl, attachmentType: 'audio', attachmentName: durationStr }, }); setMessages((prev) => prev.map((m) => m.id === tempId ? { ...m, id: newMsg.id, attachmentUrl: newMsg.attachmentUrl, createdAt: newMsg.createdAt, status: 'sent' as const } : m ) ); queryClient.invalidateQueries({ queryKey: ['dm-conversations'] }); } catch (err) { console.error('Voice send failed:', err); setMessages((prev) => prev.map((m) => m.id === tempId ? { ...m, status: 'failed' as const } : m)); setUploading(false); } finally { setSending(false); } } // Bild aus der Lightbox in die Fotos-App sichern. Remote-URLs müssen erst // lokal heruntergeladen werden, da saveToLibraryAsync eine file:// URI braucht. async function saveImage(uri: string) { if (savingImage) return; try { setSavingImage(true); const perm = await MediaLibrary.requestPermissionsAsync(); if (!perm.granted) { Alert.alert(t('chat.photo_access_title'), t('chat.photo_access_body')); return; } let localUri = uri; if (!uri.startsWith('file://')) { const ext = uri.split('?')[0].split('.').pop() || 'jpg'; const target = `${FileSystem.cacheDirectory}save-${Date.now()}.${ext}`; const res = await FileSystem.downloadAsync(uri, target); localUri = res.uri; } await MediaLibrary.saveToLibraryAsync(localUri); Alert.alert(t('chat.image_saved')); } catch (err: any) { Alert.alert(t('chat.save_failed'), err?.message ?? ''); } finally { setSavingImage(false); } } function sameAuthor(a: ChatMsg | undefined, b: ChatMsg | undefined): boolean { if (!a || !b) return false; if (a.userId !== b.userId) return false; 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; let lbW = lbMaxW; let lbH = lbMaxW; // Fallback (Ratio noch unbekannt): Quadrat if (lightboxRatio) { lbW = lbMaxW; lbH = lbMaxW / lightboxRatio; if (lbH > lbMaxH) { lbH = lbMaxH; lbW = lbMaxH * lightboxRatio; } } return ( router.back()} hitSlop={8} activeOpacity={0.7}> {/* Avatar + Name — tap → Profil */} userId && router.push(`/profile/${userId}` as any)} > {partner?.nickname ?? '…'} {userId && } {/* Info-Button */} setInfoSheetOpen(true)} > {(isLoading || isFetching) && messages.length === 0 ? ( ) : messages.length === 0 ? ( {t('chat.no_chats')} ) : ( ( )} keyExtractor={(m) => m.id} contentContainerStyle={{ paddingHorizontal: 0, paddingTop: 12, // Tastatur offen: Input-Bar floatet (per transform) über der Tastatur, // der Viewport schrumpft NICHT → Clearance = keyboardHeight + 4 (Gap). // Tastatur zu: die KeyboardStickyView hat offset.closed = -insets.bottom, // schiebt die Bar um insets.bottom NACH OBEN über den Content → diese // Überlappung als Clearance abziehen. +16 = mittlerer Gap (nicht so eng // wie +4, nicht so hoch wie die alte inputBarHeight-Variante). paddingBottom: keyboardVisible ? keyboardHeight + 4 : insets.bottom + 16, }} showsVerticalScrollIndicator={false} keyboardDismissMode="interactive" keyboardShouldPersistTaps="handled" onContentSizeChange={() => scrollToBottom(false)} onLayout={() => scrollToBottom(false)} /> )} { const h = e.nativeEvent.layout.height; if (Math.abs(h - inputBarHeight) > 1) setInputBarHeight(h); }} style={[ styles.inputBar, { paddingBottom: 8, backgroundColor: colors.bg, borderTopColor: colors.border, }, ]} > {replyTo && ( {t('chat.reply_to')} {replyTo.nickname} {replyTo.content || '…'} setReplyTo(null)} activeOpacity={0.7}> )} {attachment && ( {attachment.name} setAttachment(null)} activeOpacity={0.7}> )} {isVoiceRecording ? ( ) : ( { setInputText(v); if (v.trim().length > 0) sendTyping(); else sendStopTyping(); }} multiline maxLength={2000} returnKeyType="send" onSubmitEditing={handleSend} // Insta/WA-Style: nach dem Senden bleibt die Tastatur offen // (Fokus bleibt am Input), bis der User woanders hin tippt. blurOnSubmit={false} editable={!sending && !uploading} /> {(inputText.trim().length > 0 || attachment) ? ( {sending || uploading ? ( ) : ( )} ) : ( )} )} {/* ── Info-Sheet ─────────────────────────────────────────────── */} setInfoSheetOpen(false)} 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); }} onViewProfile={() => { setInfoSheetOpen(false); setTimeout(() => userId && router.push(`/profile/${userId}` as any), 250); }} colors={colors} t={t} /> {/* ── Lightbox ───────────────────────────────────────────────── */} {lightboxUri && ( { const s = e.source; if (s?.width && s?.height) setLightboxRatio(s.width / s.height); }} style={{ width: lbW, height: lbH, borderRadius: 16 }} contentFit="contain" cachePolicy="memory-disk" /> )} {/* Sichern */} lightboxUri && saveImage(lightboxUri)} disabled={savingImage} activeOpacity={0.7} > {savingImage ? ( ) : ( )} {t('chat.save')} ); } // ─── DmInfoSheet ───────────────────────────────────────────────────────────── const MEDIA_COL = 3; const MEDIA_GAP = 2; const MEDIA_SIZE = (Dimensions.get('window').width - MEDIA_GAP * (MEDIA_COL + 1)) / MEDIA_COL; function DmInfoSheet({ visible, onClose, partner, partnerUserId, messages, onImagePress, onViewProfile, colors, t, }: { visible: boolean; onClose: () => void; partner: { id: string; nickname: string; avatar?: string | null } | null; partnerUserId: string | null; messages: ChatMsg[]; onImagePress: (uri: string) => void; onViewProfile: () => void; colors: ReturnType; t: ReturnType['t']; }) { const sharedMedia = messages.filter( (m) => m.attachmentType === 'image' && m.attachmentUrl, ); return ( {/* Partner-Karte — Avatar via UserAvatar (rendert auch Listen-/Default- Avatare, nicht nur eigene Foto-URLs), Pfeil direkt neben dem Namen. */} {partner?.nickname ?? '…'} {t('dm.view_profile')} {/* Geteilte Medien */} {t('dm.shared_media')} {sharedMedia.length > 0 && ( {sharedMedia.length} )} {sharedMedia.length === 0 ? ( {t('dm.no_shared_media')} ) : ( {[...sharedMedia].reverse().map((m) => ( onImagePress(m.attachmentUrl!)} > ))} )} ); } function makeStyles(colors: ReturnType) { return StyleSheet.create({ container: { flex: 1, backgroundColor: colors.bg }, header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingVertical: 10, }, backBtn: { padding: 8, alignItems: 'center', justifyContent: 'center', }, headerCenter: { flex: 1, flexDirection: 'row', alignItems: 'center', marginLeft: 8, }, infoBtn: { padding: 8, alignItems: 'center', justifyContent: 'center', }, headerName: { fontSize: 15, fontFamily: 'Nunito_700Bold', color: colors.text, flexShrink: 1, }, loadingBox: { flex: 1, alignItems: 'center', justifyContent: 'center', }, emptyText: { fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted, marginTop: 12, }, inputBar: { borderTopWidth: StyleSheet.hairlineWidth, }, replyBar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingVertical: 8, borderLeftWidth: 3, borderLeftColor: '#007AFF', marginHorizontal: 8, marginTop: 6, borderRadius: 8, }, replyName: { fontSize: 11, fontFamily: 'Nunito_700Bold', color: '#007AFF', }, replyContent: { fontSize: 11, fontFamily: 'Nunito_400Regular', marginTop: 1, }, attachBar: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingVertical: 6, marginHorizontal: 8, marginTop: 6, borderRadius: 8, }, attachImg: { width: 36, height: 36, borderRadius: 6, marginRight: 8, }, attachName: { flex: 1, fontSize: 12, fontFamily: 'Nunito_600SemiBold', }, inputRow: { flexDirection: 'row', alignItems: 'flex-end', gap: 8, paddingHorizontal: 12, paddingTop: 8, }, addBtn: { width: 44, height: 44, borderRadius: 22, alignItems: 'center', justifyContent: 'center', }, textInput: { flex: 1, borderRadius: 22, paddingVertical: 9, paddingHorizontal: 16, fontSize: 15, fontFamily: 'Nunito_400Regular', maxHeight: 120, }, sendBtn: { width: 44, height: 44, borderRadius: 22, backgroundColor: '#007AFF', alignItems: 'center', justifyContent: 'center', }, sendBtnDisabled: { opacity: 0.4, }, voiceRecBarWrap: { flexDirection: 'row', marginHorizontal: 12, marginTop: 8, marginBottom: 4, }, }); }