feat(mobile): wire image attachments, reactions, reply, delete into conversation detail
This commit is contained in:
@@ -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 (
|
||||||
|
<MessageBubble
|
||||||
|
message={item}
|
||||||
mine={item.senderId === user?.id}
|
mine={item.senderId === user?.id}
|
||||||
body={item.plaintext ?? '🔒 [Entschlüsselung fehlgeschlagen]'}
|
senderName={senderName(item.senderId)}
|
||||||
|
parent={parent}
|
||||||
|
parentSenderName={parent ? senderName(parent.senderId) : ''}
|
||||||
time={new Date(item.createdAt).toLocaleTimeString('de-DE', {
|
time={new Date(item.createdAt).toLocaleTimeString('de-DE', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '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,
|
||||||
|
|||||||
Reference in New Issue
Block a user