449 lines
14 KiB
TypeScript
449 lines
14 KiB
TypeScript
import { chat } from '@chat-app/shared';
|
||
import type {
|
||
ConversationSummary,
|
||
DecryptedMessage,
|
||
MessageReaction,
|
||
} from '@chat-app/shared/chat';
|
||
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import {
|
||
ActionSheetIOS,
|
||
ActivityIndicator,
|
||
Alert,
|
||
FlatList,
|
||
KeyboardAvoidingView,
|
||
Platform,
|
||
Pressable,
|
||
StyleSheet,
|
||
Text,
|
||
TextInput,
|
||
View,
|
||
} from 'react-native';
|
||
|
||
import { MessageActionsSheet } from '../../../components/MessageActionsSheet';
|
||
import { MessageBubble } from '../../../components/MessageBubble';
|
||
import { useAuth } from '../../../lib/authContext';
|
||
import { useCall } from '../../../lib/callContext';
|
||
import { captureFromCamera, pickFromLibrary, type PickedImage } from '../../../lib/imagePicker';
|
||
import { supabase } from '../../../lib/supabase';
|
||
import { colors } from '../../../theme/colors';
|
||
|
||
export default function ConversationDetail() {
|
||
const { id } = useLocalSearchParams<{ id: string }>();
|
||
const { user, device, ownPrivateKey } = useAuth();
|
||
const { state: callState, startCall } = useCall();
|
||
const router = useRouter();
|
||
|
||
// When a call moves into `connected`, jump to the in-call screen so
|
||
// the user can see participants + controls. The /call screen pops
|
||
// itself when callState returns to `idle`.
|
||
useEffect(() => {
|
||
if (callState.kind === 'connected') {
|
||
router.push('/(app)/call');
|
||
}
|
||
}, [callState.kind, router]);
|
||
|
||
const [conversation, setConversation] = useState<ConversationSummary | null>(null);
|
||
const [messages, setMessages] = useState<DecryptedMessage[] | null>(null);
|
||
const [reactions, setReactions] = useState<Map<string, MessageReaction[]>>(new Map());
|
||
const [text, setText] = useState('');
|
||
const [sending, setSending] = useState(false);
|
||
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 load = useCallback(async () => {
|
||
if (!id || !device || !ownPrivateKey) return;
|
||
setError(null);
|
||
try {
|
||
const all = await chat.listConversations(supabase);
|
||
setConversation(all.find((c) => c.id === id) ?? null);
|
||
const ciphers = await chat.fetchConversationMessages(supabase, id, 50);
|
||
const decrypted = await chat.decryptMessages({
|
||
client: supabase,
|
||
messages: ciphers,
|
||
ownDeviceId: device.id,
|
||
ownPrivateKey,
|
||
});
|
||
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) {
|
||
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 handleSendText() {
|
||
if (!text.trim() || !user || !device || !ownPrivateKey || !id || sending) return;
|
||
setSending(true);
|
||
setError(null);
|
||
const replyToId = replyTo?.id;
|
||
try {
|
||
await chat.sendEncryptedMessage({
|
||
client: supabase,
|
||
conversationId: id,
|
||
plaintext: text.trim(),
|
||
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');
|
||
} finally {
|
||
setSending(false);
|
||
}
|
||
}
|
||
|
||
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 (
|
||
<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',
|
||
headerRight: () => (
|
||
<Pressable
|
||
onPress={() => {
|
||
if (!id) return;
|
||
void startCall(id);
|
||
}}
|
||
hitSlop={10}
|
||
style={styles.callBtn}
|
||
>
|
||
<Text style={styles.callBtnText}>📞</Text>
|
||
</Pressable>
|
||
),
|
||
}}
|
||
/>
|
||
|
||
{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 }) => {
|
||
const parent = item.replyToId ? (parentLookup.get(item.replyToId) ?? null) : null;
|
||
return (
|
||
<MessageBubble
|
||
message={item}
|
||
mine={item.senderId === user?.id}
|
||
senderName={senderName(item.senderId)}
|
||
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 })}
|
||
/>
|
||
)}
|
||
|
||
{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}>
|
||
<Pressable
|
||
style={styles.plusBtn}
|
||
onPress={openImageMenu}
|
||
disabled={sending}
|
||
hitSlop={6}
|
||
>
|
||
<Text style={styles.plusText}>+</Text>
|
||
</Pressable>
|
||
<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={handleSendText}
|
||
>
|
||
{sending ? (
|
||
<ActivityIndicator color={colors.text} />
|
||
) : (
|
||
<Text style={styles.sendBtnText}>Senden</Text>
|
||
)}
|
||
</Pressable>
|
||
</View>
|
||
|
||
<MessageActionsSheet
|
||
visible={activeMessage !== null}
|
||
mine={activeMessage?.senderId === user?.id}
|
||
onClose={() => setActiveMessage(null)}
|
||
onReact={(emoji) => {
|
||
if (activeMessage) void handleReact(emoji, activeMessage.id);
|
||
}}
|
||
onReply={() => {
|
||
if (activeMessage) setReplyTo(activeMessage);
|
||
}}
|
||
onDelete={() => {
|
||
if (activeMessage) handleDelete(activeMessage.id);
|
||
}}
|
||
/>
|
||
</KeyboardAvoidingView>
|
||
);
|
||
}
|
||
|
||
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 },
|
||
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',
|
||
padding: 8,
|
||
gap: 8,
|
||
borderTopWidth: 1,
|
||
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,
|
||
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' },
|
||
callBtn: {
|
||
paddingHorizontal: 10,
|
||
paddingVertical: 4,
|
||
},
|
||
callBtnText: { fontSize: 18 },
|
||
});
|