diff --git a/apps/mobile/app/(app)/conversations/[id].tsx b/apps/mobile/app/(app)/conversations/[id].tsx index d23f71a..1adec33 100644 --- a/apps/mobile/app/(app)/conversations/[id].tsx +++ b/apps/mobile/app/(app)/conversations/[id].tsx @@ -1,9 +1,15 @@ import { chat } from '@chat-app/shared'; -import type { ConversationSummary, DecryptedMessage } from '@chat-app/shared/chat'; +import type { + ConversationSummary, + DecryptedMessage, + MessageReaction, +} from '@chat-app/shared/chat'; import { Stack, useLocalSearchParams } from 'expo-router'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { + ActionSheetIOS, ActivityIndicator, + Alert, FlatList, KeyboardAvoidingView, Platform, @@ -14,22 +20,24 @@ import { View, } from 'react-native'; +import { MessageActionsSheet } from '../../../components/MessageActionsSheet'; +import { MessageBubble } from '../../../components/MessageBubble'; import { useAuth } from '../../../lib/authContext'; +import { captureFromCamera, pickFromLibrary, type PickedImage } from '../../../lib/imagePicker'; 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 [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 () => { @@ -37,9 +45,7 @@ export default function ConversationDetail() { setError(null); try { const all = await chat.listConversations(supabase); - const conv = all.find((c) => c.id === id) ?? null; - setConversation(conv); - + setConversation(all.find((c) => c.id === id) ?? null); const ciphers = await chat.fetchConversationMessages(supabase, id, 50); const decrypted = await chat.decryptMessages({ client: supabase, @@ -48,6 +54,17 @@ export default function ConversationDetail() { 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'); } @@ -72,10 +89,11 @@ export default function ConversationDetail() { ? (conversation.name ?? 'Gruppe') : (conversation?.peer?.displayName ?? '…'); - async function handleSend() { + async function handleSendText() { if (!text.trim() || !user || !device || !ownPrivateKey || !id || sending) return; setSending(true); setError(null); + const replyToId = replyTo?.id; try { await chat.sendEncryptedMessage({ client: supabase, @@ -84,8 +102,10 @@ export default function ConversationDetail() { senderUserId: user.id, senderDeviceId: device.id, senderPrivateKey: ownPrivateKey, + ...(replyToId ? { replyToId } : {}), }); setText(''); + setReplyTo(null); await load(); } catch (err: unknown) { setError(err instanceof Error ? err.message : 'Senden fehlgeschlagen'); @@ -94,6 +114,123 @@ export default function ConversationDetail() { } } + async function sendImage(pick: PickedImage) { + if (!user || !device || !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, + senderDeviceId: device.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 ( m.id} contentContainerStyle={styles.listContent} - renderItem={({ item }) => ( - - )} + 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 ? ( @@ -160,29 +334,22 @@ export default function ConversationDetail() { )} - - ); -} -function MessageRow({ - senderName, - mine, - body, - time, -}: { - senderName: string; - mine: boolean; - body: string; - time: string; -}) { - return ( - - - {!mine && {senderName}} - {body} - {time} - - + setActiveMessage(null)} + onReact={(emoji) => { + if (activeMessage) void handleReact(emoji, activeMessage.id); + }} + onReply={() => { + if (activeMessage) setReplyTo(activeMessage); + }} + onDelete={() => { + if (activeMessage) handleDelete(activeMessage.id); + }} + /> + ); } @@ -190,16 +357,21 @@ 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' }, + 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', @@ -209,6 +381,17 @@ const styles = StyleSheet.create({ 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,