feat(mobile): conversation detail with decrypt + send
This commit is contained in:
@@ -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<ConversationSummary | null>(null);
|
||||||
|
const [messages, setMessages] = useState<DecryptedMessage[] | null>(null);
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const listRef = useRef<FlatList<DecryptedMessage>>(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 (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.container}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
keyboardVerticalOffset={Platform.OS === 'ios' ? 80 : 0}
|
||||||
|
>
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
title,
|
||||||
|
headerStyle: { backgroundColor: colors.bg },
|
||||||
|
headerTitleStyle: { color: colors.text },
|
||||||
|
headerBackTitle: 'Chats',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{messages === null && !error && (
|
||||||
|
<View style={styles.loading}>
|
||||||
|
<ActivityIndicator color={colors.accent} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <Text style={styles.error}>{error}</Text>}
|
||||||
|
|
||||||
|
{messages && (
|
||||||
|
<FlatList
|
||||||
|
ref={listRef}
|
||||||
|
data={messages}
|
||||||
|
keyExtractor={(m) => m.id}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<MessageRow
|
||||||
|
senderName={senderName(item.senderId)}
|
||||||
|
mine={item.senderId === user?.id}
|
||||||
|
body={item.plaintext ?? '🔒 [Entschlüsselung fehlgeschlagen]'}
|
||||||
|
time={new Date(item.createdAt).toLocaleTimeString('de-DE', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.inputRow}>
|
||||||
|
<TextInput
|
||||||
|
value={text}
|
||||||
|
onChangeText={setText}
|
||||||
|
placeholder="Nachricht schreiben…"
|
||||||
|
placeholderTextColor={colors.textDim}
|
||||||
|
style={styles.input}
|
||||||
|
multiline
|
||||||
|
editable={!sending}
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.sendBtn, (!text.trim() || sending) && styles.sendBtnDisabled]}
|
||||||
|
disabled={!text.trim() || sending}
|
||||||
|
onPress={handleSend}
|
||||||
|
>
|
||||||
|
{sending ? (
|
||||||
|
<ActivityIndicator color={colors.text} />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.sendBtnText}>Senden</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageRow({
|
||||||
|
senderName,
|
||||||
|
mine,
|
||||||
|
body,
|
||||||
|
time,
|
||||||
|
}: {
|
||||||
|
senderName: string;
|
||||||
|
mine: boolean;
|
||||||
|
body: string;
|
||||||
|
time: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<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>}
|
||||||
|
<Text style={styles.body}>{body}</Text>
|
||||||
|
<Text style={styles.time}>{time}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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' },
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user