feat(mobile): wire image attachments, reactions, reply, delete into conversation detail

This commit is contained in:
byGalax
2026-05-14 00:22:18 +02:00
parent 286836f539
commit 1440b11740
+236 -53
View File
@@ -1,9 +1,15 @@
import { chat } from '@chat-app/shared'; 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 { Stack, useLocalSearchParams } from 'expo-router';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
ActionSheetIOS,
ActivityIndicator, ActivityIndicator,
Alert,
FlatList, FlatList,
KeyboardAvoidingView, KeyboardAvoidingView,
Platform, Platform,
@@ -14,22 +20,24 @@ import {
View, View,
} from 'react-native'; } from 'react-native';
import { MessageActionsSheet } from '../../../components/MessageActionsSheet';
import { MessageBubble } from '../../../components/MessageBubble';
import { useAuth } from '../../../lib/authContext'; import { useAuth } from '../../../lib/authContext';
import { captureFromCamera, pickFromLibrary, type PickedImage } from '../../../lib/imagePicker';
import { supabase } from '../../../lib/supabase'; import { supabase } from '../../../lib/supabase';
import { colors } from '../../../theme/colors'; 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() { export default function ConversationDetail() {
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
const { user, device, ownPrivateKey } = useAuth(); const { user, device, ownPrivateKey } = useAuth();
const [conversation, setConversation] = useState<ConversationSummary | null>(null); const [conversation, setConversation] = useState<ConversationSummary | null>(null);
const [messages, setMessages] = useState<DecryptedMessage[] | null>(null); const [messages, setMessages] = useState<DecryptedMessage[] | null>(null);
const [reactions, setReactions] = useState<Map<string, MessageReaction[]>>(new Map());
const [text, setText] = useState(''); const [text, setText] = useState('');
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [replyTo, setReplyTo] = useState<DecryptedMessage | null>(null);
const [activeMessage, setActiveMessage] = useState<DecryptedMessage | null>(null);
const listRef = useRef<FlatList<DecryptedMessage>>(null); const listRef = useRef<FlatList<DecryptedMessage>>(null);
const load = useCallback(async () => { const load = useCallback(async () => {
@@ -37,9 +45,7 @@ export default function ConversationDetail() {
setError(null); setError(null);
try { try {
const all = await chat.listConversations(supabase); const all = await chat.listConversations(supabase);
const conv = all.find((c) => c.id === id) ?? null; setConversation(all.find((c) => c.id === id) ?? null);
setConversation(conv);
const ciphers = await chat.fetchConversationMessages(supabase, id, 50); const ciphers = await chat.fetchConversationMessages(supabase, id, 50);
const decrypted = await chat.decryptMessages({ const decrypted = await chat.decryptMessages({
client: supabase, client: supabase,
@@ -48,6 +54,17 @@ export default function ConversationDetail() {
ownPrivateKey, ownPrivateKey,
}); });
setMessages(decrypted); setMessages(decrypted);
const rows = await chat.listReactionsForMessages(
supabase,
decrypted.map((m) => m.id),
);
const map = new Map<string, MessageReaction[]>();
for (const r of rows) {
const arr = map.get(r.messageId) ?? [];
arr.push(r);
map.set(r.messageId, arr);
}
setReactions(map);
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Nachrichten konnten nicht geladen werden'); setError(err instanceof Error ? err.message : 'Nachrichten konnten nicht geladen werden');
} }
@@ -72,10 +89,11 @@ export default function ConversationDetail() {
? (conversation.name ?? 'Gruppe') ? (conversation.name ?? 'Gruppe')
: (conversation?.peer?.displayName ?? '…'); : (conversation?.peer?.displayName ?? '…');
async function handleSend() { async function handleSendText() {
if (!text.trim() || !user || !device || !ownPrivateKey || !id || sending) return; if (!text.trim() || !user || !device || !ownPrivateKey || !id || sending) return;
setSending(true); setSending(true);
setError(null); setError(null);
const replyToId = replyTo?.id;
try { try {
await chat.sendEncryptedMessage({ await chat.sendEncryptedMessage({
client: supabase, client: supabase,
@@ -84,8 +102,10 @@ export default function ConversationDetail() {
senderUserId: user.id, senderUserId: user.id,
senderDeviceId: device.id, senderDeviceId: device.id,
senderPrivateKey: ownPrivateKey, senderPrivateKey: ownPrivateKey,
...(replyToId ? { replyToId } : {}),
}); });
setText(''); setText('');
setReplyTo(null);
await load(); await load();
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Senden fehlgeschlagen'); 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<string, MessageReaction[]>();
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<string, DecryptedMessage>();
for (const msg of messages ?? []) m.set(msg.id, msg);
return m;
}, [messages]);
return ( return (
<KeyboardAvoidingView <KeyboardAvoidingView
style={styles.container} style={styles.container}
@@ -123,22 +260,59 @@ export default function ConversationDetail() {
data={messages} data={messages}
keyExtractor={(m) => m.id} keyExtractor={(m) => m.id}
contentContainerStyle={styles.listContent} contentContainerStyle={styles.listContent}
renderItem={({ item }) => ( renderItem={({ item }) => {
<MessageRow const parent = item.replyToId ? (parentLookup.get(item.replyToId) ?? null) : null;
senderName={senderName(item.senderId)} return (
mine={item.senderId === user?.id} <MessageBubble
body={item.plaintext ?? '🔒 [Entschlüsselung fehlgeschlagen]'} message={item}
time={new Date(item.createdAt).toLocaleTimeString('de-DE', { mine={item.senderId === user?.id}
hour: '2-digit', senderName={senderName(item.senderId)}
minute: '2-digit', parent={parent}
})} parentSenderName={parent ? senderName(parent.senderId) : ''}
/> time={new Date(item.createdAt).toLocaleTimeString('de-DE', {
)} hour: '2-digit',
minute: '2-digit',
})}
reactions={reactions.get(item.id) ?? []}
myUserId={user?.id ?? null}
ownDeviceId={device?.id ?? null}
ownPrivateKey={ownPrivateKey}
onLongPress={() => setActiveMessage(item)}
onToggleReaction={(emoji) => {
void handleReact(emoji, item.id);
}}
/>
);
}}
onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })} onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })}
/> />
)} )}
{replyTo && (
<View style={styles.replyBanner}>
<View style={styles.replyBannerLeft}>
<Text style={styles.replyBannerLabel}>
Antwort an {senderName(replyTo.senderId)}
</Text>
<Text style={styles.replyBannerBody} numberOfLines={1}>
{replyTo.plaintext ?? '…'}
</Text>
</View>
<Pressable onPress={() => setReplyTo(null)} hitSlop={10}>
<Text style={styles.replyBannerClose}>×</Text>
</Pressable>
</View>
)}
<View style={styles.inputRow}> <View style={styles.inputRow}>
<Pressable
style={styles.plusBtn}
onPress={openImageMenu}
disabled={sending}
hitSlop={6}
>
<Text style={styles.plusText}></Text>
</Pressable>
<TextInput <TextInput
value={text} value={text}
onChangeText={setText} onChangeText={setText}
@@ -151,7 +325,7 @@ export default function ConversationDetail() {
<Pressable <Pressable
style={[styles.sendBtn, (!text.trim() || sending) && styles.sendBtnDisabled]} style={[styles.sendBtn, (!text.trim() || sending) && styles.sendBtnDisabled]}
disabled={!text.trim() || sending} disabled={!text.trim() || sending}
onPress={handleSend} onPress={handleSendText}
> >
{sending ? ( {sending ? (
<ActivityIndicator color={colors.text} /> <ActivityIndicator color={colors.text} />
@@ -160,29 +334,22 @@ export default function ConversationDetail() {
)} )}
</Pressable> </Pressable>
</View> </View>
</KeyboardAvoidingView>
);
}
function MessageRow({ <MessageActionsSheet
senderName, visible={activeMessage !== null}
mine, mine={activeMessage?.senderId === user?.id}
body, onClose={() => setActiveMessage(null)}
time, onReact={(emoji) => {
}: { if (activeMessage) void handleReact(emoji, activeMessage.id);
senderName: string; }}
mine: boolean; onReply={() => {
body: string; if (activeMessage) setReplyTo(activeMessage);
time: string; }}
}) { onDelete={() => {
return ( if (activeMessage) handleDelete(activeMessage.id);
<View style={[styles.bubbleWrap, mine ? styles.bubbleWrapMine : styles.bubbleWrapOther]}> }}
<View style={[styles.bubble, mine ? styles.bubbleMine : styles.bubbleOther]}> />
{!mine && <Text style={styles.sender}>{senderName}</Text>} </KeyboardAvoidingView>
<Text style={styles.body}>{body}</Text>
<Text style={styles.time}>{time}</Text>
</View>
</View>
); );
} }
@@ -190,16 +357,21 @@ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bg }, container: { flex: 1, backgroundColor: colors.bg },
loading: { flex: 1, alignItems: 'center', justifyContent: 'center' }, loading: { flex: 1, alignItems: 'center', justifyContent: 'center' },
error: { color: colors.danger, padding: 12, textAlign: 'center' }, error: { color: colors.danger, padding: 12, textAlign: 'center' },
listContent: { padding: 12, gap: 6 }, listContent: { padding: 12 },
bubbleWrap: { flexDirection: 'row', marginVertical: 2 }, replyBanner: {
bubbleWrapMine: { justifyContent: 'flex-end' }, flexDirection: 'row',
bubbleWrapOther: { justifyContent: 'flex-start' }, alignItems: 'center',
bubble: { maxWidth: '78%', padding: 10, borderRadius: 14 }, paddingHorizontal: 12,
bubbleMine: { backgroundColor: colors.accent }, paddingVertical: 8,
bubbleOther: { backgroundColor: colors.surface, borderColor: colors.border, borderWidth: 1 }, borderTopWidth: 1,
sender: { color: colors.textMuted, fontSize: 11, fontWeight: '600', marginBottom: 4 }, borderTopColor: colors.border,
body: { color: colors.text, fontSize: 15, lineHeight: 20 }, backgroundColor: colors.surface,
time: { color: colors.textDim, fontSize: 10, marginTop: 4, textAlign: 'right' }, 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: { inputRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'flex-end', alignItems: 'flex-end',
@@ -209,6 +381,17 @@ const styles = StyleSheet.create({
borderTopColor: colors.border, borderTopColor: colors.border,
backgroundColor: colors.surface, 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: { input: {
flex: 1, flex: 1,
minHeight: 40, minHeight: 40,