From 263d2455a84b9b45fb3fb1e9b62d8e63349c1701 Mon Sep 17 00:00:00 2001 From: byGalax Date: Thu, 14 May 2026 00:01:48 +0200 Subject: [PATCH] feat(mobile): conversation detail with decrypt + send --- apps/mobile/app/(app)/conversations/[id].tsx | 235 +++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 apps/mobile/app/(app)/conversations/[id].tsx diff --git a/apps/mobile/app/(app)/conversations/[id].tsx b/apps/mobile/app/(app)/conversations/[id].tsx new file mode 100644 index 0000000..d23f71a --- /dev/null +++ b/apps/mobile/app/(app)/conversations/[id].tsx @@ -0,0 +1,235 @@ +import { chat } from '@chat-app/shared'; +import type { ConversationSummary, DecryptedMessage } from '@chat-app/shared/chat'; +import { Stack, useLocalSearchParams } from 'expo-router'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + ActivityIndicator, + FlatList, + KeyboardAvoidingView, + Platform, + Pressable, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; + +import { useAuth } from '../../../lib/authContext'; +import { supabase } from '../../../lib/supabase'; +import { colors } from '../../../theme/colors'; + +// Phase 1 conversation detail: fetch + decrypt last 50 messages, render +// them newest-at-bottom, and let the user send a text message. No +// realtime subscription, no attachments, no edit/delete — those are +// later phases. Pull-to-refresh re-fetches. +export default function ConversationDetail() { + const { id } = useLocalSearchParams<{ id: string }>(); + const { user, device, ownPrivateKey } = useAuth(); + const [conversation, setConversation] = useState(null); + const [messages, setMessages] = useState(null); + const [text, setText] = useState(''); + const [sending, setSending] = useState(false); + const [error, setError] = useState(null); + const listRef = useRef>(null); + + const load = useCallback(async () => { + if (!id || !device || !ownPrivateKey) return; + setError(null); + try { + const all = await chat.listConversations(supabase); + const conv = all.find((c) => c.id === id) ?? null; + setConversation(conv); + + const ciphers = await chat.fetchConversationMessages(supabase, id, 50); + const decrypted = await chat.decryptMessages({ + client: supabase, + messages: ciphers, + ownDeviceId: device.id, + ownPrivateKey, + }); + setMessages(decrypted); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Nachrichten konnten nicht geladen werden'); + } + }, [id, device, 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 handleSend() { + if (!text.trim() || !user || !device || !ownPrivateKey || !id || sending) return; + setSending(true); + setError(null); + try { + await chat.sendEncryptedMessage({ + client: supabase, + conversationId: id, + plaintext: text.trim(), + senderUserId: user.id, + senderDeviceId: device.id, + senderPrivateKey: ownPrivateKey, + }); + setText(''); + await load(); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Senden fehlgeschlagen'); + } finally { + setSending(false); + } + } + + return ( + + + + {messages === null && !error && ( + + + + )} + + {error && {error}} + + {messages && ( + m.id} + contentContainerStyle={styles.listContent} + renderItem={({ item }) => ( + + )} + onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })} + /> + )} + + + + + {sending ? ( + + ) : ( + Senden + )} + + + + ); +} + +function MessageRow({ + senderName, + mine, + body, + time, +}: { + senderName: string; + mine: boolean; + body: string; + time: string; +}) { + return ( + + + {!mine && {senderName}} + {body} + {time} + + + ); +} + +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, gap: 6 }, + bubbleWrap: { flexDirection: 'row', marginVertical: 2 }, + bubbleWrapMine: { justifyContent: 'flex-end' }, + bubbleWrapOther: { justifyContent: 'flex-start' }, + bubble: { maxWidth: '78%', padding: 10, borderRadius: 14 }, + bubbleMine: { backgroundColor: colors.accent }, + bubbleOther: { backgroundColor: colors.surface, borderColor: colors.border, borderWidth: 1 }, + sender: { color: colors.textMuted, fontSize: 11, fontWeight: '600', marginBottom: 4 }, + body: { color: colors.text, fontSize: 15, lineHeight: 20 }, + time: { color: colors.textDim, fontSize: 10, marginTop: 4, textAlign: 'right' }, + inputRow: { + flexDirection: 'row', + alignItems: 'flex-end', + padding: 8, + gap: 8, + borderTopWidth: 1, + borderTopColor: colors.border, + backgroundColor: colors.surface, + }, + 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' }, +});