import { chat } from '@chat-app/shared'; import type { ConversationSummary, DecryptedMessage, MessageReaction, } from '@chat-app/shared/chat'; import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ActionSheetIOS, ActivityIndicator, Alert, FlatList, KeyboardAvoidingView, Platform, Pressable, StyleSheet, Text, TextInput, View, } from 'react-native'; import { MessageActionsSheet } from '../../../components/MessageActionsSheet'; import { MessageBubble } from '../../../components/MessageBubble'; import { useAuth } from '../../../lib/authContext'; import { useCall } from '../../../lib/callContext'; import { captureFromCamera, pickFromLibrary, type PickedImage } from '../../../lib/imagePicker'; import { supabase } from '../../../lib/supabase'; import { colors } from '../../../theme/colors'; export default function ConversationDetail() { const { id } = useLocalSearchParams<{ id: string }>(); const { user, userId, ownPrivateKey } = useAuth(); const { state: callState, startCall } = useCall(); const router = useRouter(); // When a call moves into `connected`, jump to the in-call screen so // the user can see participants + controls. The /call screen pops // itself when callState returns to `idle`. useEffect(() => { if (callState.kind === 'connected') { router.push('/(app)/call'); } }, [callState.kind, router]); const [conversation, setConversation] = useState(null); const [messages, setMessages] = useState(null); const [reactions, setReactions] = useState>(new Map()); const [text, setText] = useState(''); const [sending, setSending] = useState(false); const [error, setError] = useState(null); const [replyTo, setReplyTo] = useState(null); const [activeMessage, setActiveMessage] = useState(null); const listRef = useRef>(null); const load = useCallback(async () => { if (!id || !userId || !ownPrivateKey) return; setError(null); try { const all = await chat.listConversations(supabase); setConversation(all.find((c) => c.id === id) ?? null); const ciphers = await chat.fetchConversationMessages(supabase, id, 50); const decrypted = await chat.decryptMessages({ client: supabase, messages: ciphers, ownUserId: userId, ownPrivateKey, }); setMessages(decrypted); const rows = await chat.listReactionsForMessages( supabase, decrypted.map((m) => m.id), ); const map = new Map(); for (const r of rows) { const arr = map.get(r.messageId) ?? []; arr.push(r); map.set(r.messageId, arr); } setReactions(map); } catch (err: unknown) { setError(err instanceof Error ? err.message : 'Nachrichten konnten nicht geladen werden'); } }, [id, userId, ownPrivateKey]); useEffect(() => { void load(); }, [load]); const members = useMemo(() => conversation?.members ?? [], [conversation]); const senderName = useCallback( (senderId: string) => { if (senderId === user?.id) return 'Du'; const m = members.find((mm) => mm.userId === senderId); return m?.profile?.displayName ?? m?.profile?.username ?? 'Unbekannt'; }, [members, user], ); const title = conversation?.type === 'group' ? (conversation.name ?? 'Gruppe') : (conversation?.peer?.displayName ?? '…'); async function handleSendText() { if (!text.trim() || !user || !ownPrivateKey || !id || sending) return; setSending(true); setError(null); const replyToId = replyTo?.id; try { await chat.sendEncryptedMessage({ client: supabase, conversationId: id, plaintext: text.trim(), senderUserId: user.id, senderPrivateKey: ownPrivateKey, ...(replyToId ? { replyToId } : {}), }); setText(''); setReplyTo(null); await load(); } catch (err: unknown) { setError(err instanceof Error ? err.message : 'Senden fehlgeschlagen'); } finally { setSending(false); } } async function sendImage(pick: PickedImage) { if (!user || !ownPrivateKey || !id) return; setSending(true); setError(null); try { const resp = await fetch(pick.uri); const blob = await resp.blob(); const result = await chat.encryptAndUploadAttachment({ client: supabase, conversationId: id, file: blob, mimeType: pick.mimeType, sizeBytes: pick.sizeBytes, width: pick.width, height: pick.height, }); await chat.sendEncryptedMessage({ client: supabase, conversationId: id, plaintext: '', senderUserId: user.id, senderPrivateKey: ownPrivateKey, attachmentHandles: [result.handle], }); await load(); } catch (err: unknown) { setError(err instanceof Error ? err.message : 'Bild senden fehlgeschlagen'); } finally { setSending(false); } } function openImageMenu() { if (Platform.OS === 'ios') { ActionSheetIOS.showActionSheetWithOptions( { options: ['Foto aufnehmen', 'Aus Galerie wählen', 'Abbrechen'], cancelButtonIndex: 2 }, async (idx) => { if (idx === 0) { const p = await captureFromCamera(); if (p) await sendImage(p); } else if (idx === 1) { const p = await pickFromLibrary(); if (p) await sendImage(p); } }, ); } else { Alert.alert('Bild senden', undefined, [ { text: 'Foto aufnehmen', onPress: async () => { const p = await captureFromCamera(); if (p) await sendImage(p); }, }, { text: 'Aus Galerie wählen', onPress: async () => { const p = await pickFromLibrary(); if (p) await sendImage(p); }, }, { text: 'Abbrechen', style: 'cancel' }, ]); } } async function handleReact(emoji: string, messageId: string) { if (!user) return; try { const mine = reactions.get(messageId)?.some((r) => r.userId === user.id && r.emoji === emoji); if (mine) { await chat.removeReaction(supabase, messageId, emoji); } else { await chat.addReaction(supabase, messageId, emoji); } const rows = await chat.listReactionsForMessages( supabase, (messages ?? []).map((m) => m.id), ); const map = new Map(); for (const r of rows) { const arr = map.get(r.messageId) ?? []; arr.push(r); map.set(r.messageId, arr); } setReactions(map); } catch (err: unknown) { setError(err instanceof Error ? err.message : 'Reaktion fehlgeschlagen'); } } function handleDelete(messageId: string) { Alert.alert('Nachricht löschen', 'Diese Nachricht für alle löschen?', [ { text: 'Abbrechen', style: 'cancel' }, { text: 'Löschen', style: 'destructive', onPress: async () => { try { await chat.softDeleteMessage(supabase, messageId); await load(); } catch (err: unknown) { setError(err instanceof Error ? err.message : 'Löschen fehlgeschlagen'); } }, }, ]); } const parentLookup = useMemo(() => { const m = new Map(); for (const msg of messages ?? []) m.set(msg.id, msg); return m; }, [messages]); return ( ( { if (!id) return; void startCall(id); }} hitSlop={10} style={styles.callBtn} > 📞 ), }} /> {messages === null && !error && ( )} {error && {error}} {messages && ( m.id} contentContainerStyle={styles.listContent} renderItem={({ item }) => { const parent = item.replyToId ? (parentLookup.get(item.replyToId) ?? null) : null; return ( setActiveMessage(item)} onToggleReaction={(emoji) => { void handleReact(emoji, item.id); }} /> ); }} onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })} /> )} {replyTo && ( Antwort an {senderName(replyTo.senderId)} {replyTo.plaintext ?? '…'} setReplyTo(null)} hitSlop={10}> × )} {sending ? ( ) : ( Senden )} setActiveMessage(null)} onReact={(emoji) => { if (activeMessage) void handleReact(emoji, activeMessage.id); }} onReply={() => { if (activeMessage) setReplyTo(activeMessage); }} onDelete={() => { if (activeMessage) handleDelete(activeMessage.id); }} /> ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: colors.bg }, loading: { flex: 1, alignItems: 'center', justifyContent: 'center' }, error: { color: colors.danger, padding: 12, textAlign: 'center' }, listContent: { padding: 12 }, replyBanner: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingVertical: 8, borderTopWidth: 1, borderTopColor: colors.border, backgroundColor: colors.surface, gap: 12, }, replyBannerLeft: { flex: 1 }, replyBannerLabel: { color: colors.accent, fontSize: 12, fontWeight: '700' }, replyBannerBody: { color: colors.textMuted, fontSize: 13, marginTop: 2 }, replyBannerClose: { color: colors.textMuted, fontSize: 22, paddingHorizontal: 6 }, inputRow: { flexDirection: 'row', alignItems: 'flex-end', padding: 8, gap: 8, borderTopWidth: 1, borderTopColor: colors.border, backgroundColor: colors.surface, }, plusBtn: { width: 40, height: 40, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.bg, borderRadius: 10, borderColor: colors.border, borderWidth: 1, }, plusText: { color: colors.text, fontSize: 20, lineHeight: 22 }, input: { flex: 1, minHeight: 40, maxHeight: 120, color: colors.text, backgroundColor: colors.bg, borderColor: colors.border, borderWidth: 1, borderRadius: 10, paddingHorizontal: 12, paddingVertical: 10, fontSize: 15, }, sendBtn: { backgroundColor: colors.accent, borderRadius: 10, paddingHorizontal: 16, height: 40, alignItems: 'center', justifyContent: 'center', }, sendBtnDisabled: { opacity: 0.5 }, sendBtnText: { color: colors.text, fontWeight: '600' }, callBtn: { paddingHorizontal: 10, paddingVertical: 4, }, callBtnText: { fontSize: 18 }, });