Calls: an incoming call that ended without the in-app /call screen ever mounting (iOS shows the native CallKit banner, not our screen) left the call store stuck in 'ended' forever — the ended→idle reset only lived in the /call screen. A stuck 'ended' then blocked every subsequent incoming call (RING + VoIP push were received but dropped by the status!=='idle' guard), so accepting from the banner produced a phantom CallKit call that ticked as active with no connection, and the caller saw a missed call. - store self-heals back to 'idle' after a call ends (teardown fallback) - receiveIncoming + ring handler tolerate a stale 'ended' state - onAnswer ends the native CallKit call when store has no incoming call - RNCallKeep.endAllCalls() on launch clears leftover CallKit zombies DM online dot: the green avatar dot used follow-gated presence while the "online" text used raw presence → dot hidden for non-followed partners even when online. DM header avatar now uses raw presence (rawPresence prop) → consistent with the text on both platforms. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1275 lines
46 KiB
TypeScript
1275 lines
46 KiB
TypeScript
import { useState, useRef, useEffect, useCallback, type ReactNode } from 'react';
|
||
import {
|
||
View,
|
||
Text,
|
||
TextInput,
|
||
FlatList,
|
||
TouchableOpacity,
|
||
Platform,
|
||
Alert,
|
||
ActivityIndicator,
|
||
StyleSheet,
|
||
Keyboard,
|
||
ScrollView,
|
||
Dimensions,
|
||
type FlatList as FlatListType,
|
||
} from 'react-native';
|
||
import { Audio } from 'expo-av';
|
||
import { KeyboardStickyView } from 'react-native-keyboard-controller';
|
||
import { Image } from 'expo-image';
|
||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||
import { useRouter, useLocalSearchParams, useFocusEffect } from 'expo-router';
|
||
import { Ionicons } from '@expo/vector-icons';
|
||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||
import { useTranslation } from 'react-i18next';
|
||
import * as ImagePicker from 'expo-image-picker';
|
||
import { requireOptionalNativeModule } from 'expo-modules-core';
|
||
// expo-media-library wird LAZY in saveImage() geladen (require statt top-level
|
||
// import): ist das native Modul in einem älteren Build nicht eincompiliert,
|
||
// würde ein top-level import den GANZEN DM-Screen crashen. Lazy → nur das
|
||
// Speichern schlägt fehl, der Screen lädt normal.
|
||
type MediaLibraryModule = typeof import('expo-media-library');
|
||
// TODO(sdk54): migrate to new expo-file-system class-based API (File/Directory/Paths) — see Task #14
|
||
import * as FileSystem from 'expo-file-system/legacy';
|
||
import { apiFetch } from '../lib/api';
|
||
import { ChatBubble, type ChatMsg, type MessageReaction } from '../components/chat/ChatBubble';
|
||
import { VoiceRecordingBar, formatVoiceDuration } from '../components/chat/VoiceRecordingBar';
|
||
import { MediaLightbox } from '../components/chat/MediaLightbox';
|
||
import { TypingBubble } from '../components/chat/TypingBubble';
|
||
import { FormSheet } from '../components/FormSheet';
|
||
import { useDmRealtime } from '../hooks/useChatRealtime';
|
||
import { useDmTyping } from '../hooks/useDmTyping';
|
||
import { useColors } from '../lib/theme';
|
||
import { useAuthStore } from '../stores/auth';
|
||
import { useCallStore, isWebRTCAvailable } from '../stores/call';
|
||
import { useMe } from '../hooks/useMe';
|
||
import { supabase } from '../lib/supabase';
|
||
import { UserAvatar } from '../components/UserAvatar';
|
||
import { ChatHeaderStatus } from '../components/chat/ChatHeaderStatus';
|
||
|
||
type DmHistoryResponse = {
|
||
partner: {
|
||
id: string;
|
||
nickname: string;
|
||
username?: string;
|
||
avatar?: string | null;
|
||
};
|
||
messages: Array<{
|
||
id: string;
|
||
content: string;
|
||
createdAt: string;
|
||
isOwn: boolean;
|
||
readAt: string | null;
|
||
senderId?: string;
|
||
receiverId?: string;
|
||
likesCount?: number;
|
||
likedByMe?: boolean;
|
||
attachmentUrl?: string | null;
|
||
attachmentType?: string | null;
|
||
attachmentName?: string | null;
|
||
replyTo?: any;
|
||
}>;
|
||
};
|
||
|
||
const GROUP_GAP_MS = 5 * 60 * 1000;
|
||
|
||
type DmData = {
|
||
partner: DmHistoryResponse['partner'];
|
||
messages: ChatMsg[];
|
||
};
|
||
|
||
// Merge bei Background-Refetch: Server-Daten sind autoritativ; lokale Extras
|
||
// (optimistische temp-* Sends + Realtime-Inserts, die der letzte Fetch noch
|
||
// nicht kannte) bleiben erhalten und werden nach createdAt einsortiert.
|
||
function mergeMessages(server: ChatMsg[], local: ChatMsg[]): ChatMsg[] {
|
||
const serverIds = new Set(server.map((m) => m.id));
|
||
const extras = local.filter((m) => !serverIds.has(m.id));
|
||
if (extras.length === 0) return server;
|
||
return [...server, ...extras].sort(
|
||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||
);
|
||
}
|
||
|
||
export default function DmScreen() {
|
||
const { t } = useTranslation();
|
||
const router = useRouter();
|
||
const insets = useSafeAreaInsets();
|
||
const colors = useColors();
|
||
const styles = makeStyles(colors);
|
||
const queryClient = useQueryClient();
|
||
const myUserId = useAuthStore((s) => s.user?.id);
|
||
const { me } = useMe();
|
||
|
||
const { userId } = useLocalSearchParams<{ userId: string }>();
|
||
|
||
// Chat-Hintergrund: immer clean (solider Theme-BG, weiß / schwarz). Insta-Style.
|
||
const chatBg = colors.bg;
|
||
|
||
const flatListRef = useRef<FlatListType<ChatMsg>>(null);
|
||
|
||
// scrollToEnd() unterschätzt auf Android UND iOS die Content-Höhe und
|
||
// stoppt 1 Item zu früh (besonders nach Bild-Load + InputBar-Padding).
|
||
// scrollToOffset(999999) wird intern auf den echten Max-Wert geclampt.
|
||
const scrollToBottom = useCallback((animated = false) => {
|
||
flatListRef.current?.scrollToOffset({ offset: 999999, animated });
|
||
}, []);
|
||
// Seed beide aus dem React-Query-Cache → Reopen einer bereits geladenen
|
||
// Konversation ist sofort sichtbar (kein Spinner, kein Flash).
|
||
const [messages, setMessages] = useState<ChatMsg[]>(
|
||
() => queryClient.getQueryData<DmData>(['dm-history', userId])?.messages ?? [],
|
||
);
|
||
const [partner, setPartner] = useState<DmHistoryResponse['partner'] | null>(
|
||
() => queryClient.getQueryData<DmData>(['dm-history', userId])?.partner ?? null,
|
||
);
|
||
const partnerRef = useRef<DmHistoryResponse['partner'] | null>(partner);
|
||
// userId, zu dem die aktuellen `messages` gehören (Stack-Reuse-Guard).
|
||
const messagesUserId = useRef<string | undefined>(userId);
|
||
const [replyTo, setReplyTo] = useState<{ id: string; nickname: string; content: string } | null>(
|
||
null,
|
||
);
|
||
// Ref auf das Text-Eingabefeld → nach dem Senden Fokus re-asserten, damit die
|
||
// Tastatur offen bleibt (Insta/WA-Style), auch wenn das Leeren des Inputs den
|
||
// Send-Button gegen den Mic-Button austauscht.
|
||
const inputRef = useRef<TextInput>(null);
|
||
const [sending, setSending] = useState(false);
|
||
const [inputText, setInputText] = useState('');
|
||
const [attachment, setAttachment] = useState<{ uri: string; name: string } | null>(null);
|
||
const [uploading, setUploading] = useState(false);
|
||
const [keyboardVisible, setKeyboardVisible] = useState(false);
|
||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||
const [inputBarHeight, setInputBarHeight] = useState(60);
|
||
const [infoSheetOpen, setInfoSheetOpen] = useState(false);
|
||
// Lightbox = Carousel über ALLE geteilten Bilder der Konversation. Tippt man
|
||
// ein Bild an, öffnet die Galerie bei dessen Index und man kann horizontal
|
||
// zwischen allen Bildern wischen.
|
||
const [lightboxImages, setLightboxImages] = useState<string[]>([]);
|
||
const [lightboxIndex, setLightboxIndex] = useState(0);
|
||
// Aus welchem Kontext die Lightbox geöffnet wurde. 'chat' = Root-Instanz,
|
||
// 'info' = genestet im Info-Sheet (liegt zuverlässig über dem FormSheet-Modal).
|
||
const [lightboxSource, setLightboxSource] = useState<'chat' | 'info'>('chat');
|
||
const [savingImage, setSavingImage] = useState(false);
|
||
const lightboxOpen = lightboxImages.length > 0;
|
||
|
||
// messagesRef, damit openLightbox (useCallback []) immer die aktuelle Bildliste
|
||
// sieht, ohne bei jeder neuen Nachricht neu erzeugt zu werden.
|
||
const messagesRef = useRef(messages);
|
||
messagesRef.current = messages;
|
||
|
||
const openLightbox = useCallback((uri: string, source: 'chat' | 'info' = 'chat') => {
|
||
const urls = messagesRef.current
|
||
.filter((m) => m.attachmentType === 'image' && m.attachmentUrl)
|
||
.map((m) => m.attachmentUrl as string);
|
||
const list = urls.length ? urls : [uri];
|
||
setLightboxSource(source);
|
||
setLightboxImages(list);
|
||
setLightboxIndex(Math.max(0, list.indexOf(uri)));
|
||
}, []);
|
||
const closeLightbox = useCallback(() => {
|
||
setLightboxImages([]);
|
||
}, []);
|
||
|
||
// Voice recording
|
||
const [isVoiceRecording, setIsVoiceRecording] = useState(false);
|
||
const [voiceDuration, setVoiceDuration] = useState(0);
|
||
const [voiceLevel, setVoiceLevel] = useState(0);
|
||
const [voiceTrashFlash, setVoiceTrashFlash] = useState(false);
|
||
const voiceRecordingRef = useRef<Audio.Recording | null>(null);
|
||
const voiceTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||
const voiceStartTime = useRef(0);
|
||
|
||
// Konversation gewechselt (expo-router reused den DM-Screen). Reply-Draft
|
||
// wegräumen und sofort auf den Cache der neuen Konversation umschalten:
|
||
// vorhanden → instant sichtbar, sonst leeren (Spinner via isLoading).
|
||
useEffect(() => {
|
||
if (messagesUserId.current === userId) return;
|
||
setReplyTo(null);
|
||
const cached = queryClient.getQueryData<DmData>(['dm-history', userId]);
|
||
setMessages(cached?.messages ?? []);
|
||
setPartner(cached?.partner ?? null);
|
||
partnerRef.current = cached?.partner ?? null;
|
||
messagesUserId.current = userId;
|
||
}, [userId, queryClient]);
|
||
|
||
// Keyboard-Sichtbarkeit tracken + scroll to end beim Schließen
|
||
useEffect(() => {
|
||
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
||
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
||
const show = Keyboard.addListener(showEvent, (e) => {
|
||
setKeyboardHeight(e.endCoordinates.height);
|
||
setKeyboardVisible(true);
|
||
requestAnimationFrame(() => scrollToBottom(false));
|
||
});
|
||
const hide = Keyboard.addListener(hideEvent, () => {
|
||
setKeyboardHeight(0);
|
||
setKeyboardVisible(false);
|
||
setTimeout(() => scrollToBottom(false), 50);
|
||
});
|
||
return () => { show.remove(); hide.remove(); };
|
||
}, []);
|
||
|
||
// Wenn User zurücknavigiert, soll die Conversation-Liste sofort neu laden
|
||
// (unread-Badge soll verschwinden — Backend hat bereits markDmsAsRead beim GET aufgerufen)
|
||
useFocusEffect(
|
||
useCallback(() => {
|
||
return () => {
|
||
queryClient.invalidateQueries({ queryKey: ['dm-conversations'] });
|
||
};
|
||
}, [queryClient]),
|
||
);
|
||
|
||
// DM-History laden — stale-while-revalidate: gecachte Messages werden sofort
|
||
// gezeigt (useState-Seed oben + Sync-Effekt unten), im Hintergrund frisch
|
||
// gefetcht und gemerged. gcTime hält den Cache über Navigation hinweg, sodass
|
||
// ein Reopen instant ist statt jedes Mal die ganze History neu zu ziehen.
|
||
const { data: historyData, isLoading, isFetching } = useQuery<DmData>({
|
||
queryKey: ['dm-history', userId],
|
||
queryFn: async () => {
|
||
const data = await apiFetch<DmHistoryResponse>(`/api/chat/dm/${userId}`);
|
||
const msgs: ChatMsg[] = data.messages.map((m: any) => ({
|
||
id: m.id,
|
||
userId: m.senderId ?? (m.isOwn ? myUserId ?? '' : userId),
|
||
nickname: m.isOwn ? 'Du' : data.partner?.nickname ?? '?',
|
||
avatar: m.isOwn ? null : data.partner?.avatar ?? null,
|
||
content: m.content,
|
||
replyTo: m.replyTo
|
||
? {
|
||
id: m.replyTo.id,
|
||
userId: m.replyTo.senderId,
|
||
nickname:
|
||
m.replyTo.senderId === myUserId ? 'Du' : data.partner?.nickname ?? '?',
|
||
content: m.replyTo.content?.slice(0, 100) ?? '',
|
||
attachmentType: m.replyTo.attachmentType ?? null,
|
||
}
|
||
: null,
|
||
attachmentUrl: m.attachmentUrl ?? null,
|
||
attachmentType: m.attachmentType ?? null,
|
||
attachmentName: m.attachmentName ?? null,
|
||
likesCount: m.likesCount ?? 0,
|
||
likedByMe: m.likedByMe ?? false,
|
||
createdAt: m.createdAt,
|
||
isOwn: m.isOwn,
|
||
readAt: m.readAt,
|
||
reactions: m.reactions ?? [],
|
||
deleted: m.deleted ?? false,
|
||
}));
|
||
return { partner: data.partner, messages: msgs };
|
||
},
|
||
enabled: !!userId && !!myUserId,
|
||
staleTime: 30_000,
|
||
gcTime: 30 * 60_000,
|
||
});
|
||
|
||
// Cache → lokaler State. Lokaler State bleibt Render-Source-of-Truth, damit
|
||
// Realtime-Inserts & optimistische Sends ihn direkt mutieren können; der
|
||
// Merge bewahrt lokale Extras (temp-* / noch-nicht-gefetchte Realtime-Msgs).
|
||
useEffect(() => {
|
||
if (!historyData) return;
|
||
setPartner(historyData.partner);
|
||
partnerRef.current = historyData.partner;
|
||
setMessages((prev) => {
|
||
const base = messagesUserId.current === userId ? prev : [];
|
||
messagesUserId.current = userId;
|
||
return mergeMessages(historyData.messages, base);
|
||
});
|
||
}, [historyData, userId]);
|
||
|
||
// Neue Nachricht (incoming Realtime oder outgoing send) → immer scrollen
|
||
useEffect(() => {
|
||
if (messages.length === 0) return;
|
||
requestAnimationFrame(() => scrollToBottom(true));
|
||
}, [messages.length]);
|
||
|
||
// Realtime: neue DMs vom Partner
|
||
const onDmInsert = useCallback(
|
||
(row: any) => {
|
||
if (row.receiver_id !== myUserId) return;
|
||
setMessages((prev) => {
|
||
if (prev.some((m) => m.id === row.id)) return prev;
|
||
const p = partnerRef.current;
|
||
return [
|
||
...prev,
|
||
{
|
||
id: row.id,
|
||
userId: row.sender_id,
|
||
nickname: p?.nickname ?? '?',
|
||
avatar: p?.avatar ?? null,
|
||
content: row.content ?? '',
|
||
replyTo: null,
|
||
attachmentUrl: row.attachment_url ?? null,
|
||
attachmentType: row.attachment_type ?? null,
|
||
attachmentName: row.attachment_name ?? null,
|
||
likesCount: row.likes_count ?? 0,
|
||
likedByMe: false,
|
||
createdAt: row.created_at,
|
||
isOwn: false,
|
||
readAt: null,
|
||
},
|
||
];
|
||
});
|
||
// Nachricht kam live rein WÄHREND der Chat offen ist → serverseitig als
|
||
// gelesen markieren. markDmsAsRead läuft nur im History-GET, also den
|
||
// invalidieren (refetch markiert read). Sonst bleibt der Tab-Bar-Badge
|
||
// hängen, weil dm-conversations die Live-Message als unread zählt.
|
||
queryClient.invalidateQueries({ queryKey: ['dm-history', userId] });
|
||
},
|
||
[myUserId, queryClient, userId],
|
||
);
|
||
// Realtime: Partner-Soft-Delete (Tombstone) + Reaktions-Änderungen → refetch.
|
||
const refetchHistory = useCallback(() => {
|
||
queryClient.invalidateQueries({ queryKey: ['dm-history', userId] });
|
||
}, [queryClient, userId]);
|
||
useDmRealtime(userId, onDmInsert, !!myUserId, refetchHistory, refetchHistory);
|
||
|
||
// Typing-Indicator (ephemerer Broadcast, kein DB-Write)
|
||
const { partnerTyping, setComposing, sendStopTyping } = useDmTyping(myUserId, userId);
|
||
|
||
// Erscheint der In-Thread-Typing-Bubble (ListFooter), ans Ende scrollen damit
|
||
// er sichtbar wird — wie Instagram/WhatsApp.
|
||
useEffect(() => {
|
||
if (partnerTyping) scrollToBottom(true);
|
||
}, [partnerTyping, scrollToBottom]);
|
||
|
||
// Darf der User den Partner anrufen? (gegenseitiger Follow + callsEnabled).
|
||
// Steuert Sichtbarkeit des Call-Buttons im Header.
|
||
const { data: canCallData } = useQuery({
|
||
queryKey: ['can-call', userId],
|
||
queryFn: () => apiFetch<{ canCall: boolean }>(`/api/chat/can-call/${userId}`),
|
||
enabled: !!userId && !!myUserId,
|
||
staleTime: 60_000,
|
||
});
|
||
const canCall = canCallData?.canCall ?? false;
|
||
|
||
function startCall() {
|
||
if (!userId || !partner) return;
|
||
// Native WebRTC fehlt im aktuellen Build → ehrlicher Hinweis statt Crash.
|
||
if (!isWebRTCAvailable()) {
|
||
Alert.alert(t('chat.call'), t('call.needs_rebuild'));
|
||
return;
|
||
}
|
||
useCallStore
|
||
.getState()
|
||
.startCall(
|
||
{ id: userId, nickname: partner.nickname ?? '?', avatar: partner.avatar ?? null },
|
||
{ id: me?.id ?? myUserId ?? '', nickname: me?.nickname ?? 'Du', avatar: me?.avatar ?? null },
|
||
)
|
||
.catch((e: any) => console.log('[CALL] startCall error:', e?.message ?? e));
|
||
router.push('/call' as any);
|
||
}
|
||
|
||
async function pickImage() {
|
||
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||
if (!perm.granted) {
|
||
Alert.alert(t('chat.photo_access_title'), t('chat.photo_access_body'));
|
||
return;
|
||
}
|
||
const result = await ImagePicker.launchImageLibraryAsync({
|
||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||
quality: 0.8,
|
||
});
|
||
if (!result.canceled && result.assets[0]?.uri) {
|
||
const a = result.assets[0];
|
||
setAttachment({ uri: a.uri, name: a.fileName ?? `image-${Date.now()}.jpg` });
|
||
}
|
||
}
|
||
|
||
async function uploadAttachment(file: { uri: string; name: string }): Promise<{ url: string; type: string; name: string } | null> {
|
||
try {
|
||
setUploading(true);
|
||
const ext = file.name.split('.').pop() || 'jpg';
|
||
const path = `chat/${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||
const base64 = await FileSystem.readAsStringAsync(file.uri, {
|
||
encoding: FileSystem.EncodingType.Base64,
|
||
});
|
||
const binary = typeof atob === 'function' ? atob(base64) : Buffer.from(base64, 'base64').toString('binary');
|
||
const bytes = new Uint8Array(binary.length);
|
||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||
const { error } = await supabase.storage.from('chat-attachments').upload(path, bytes, {
|
||
cacheControl: '3600',
|
||
upsert: false,
|
||
contentType: 'image/jpeg',
|
||
});
|
||
if (error) throw error;
|
||
const { data } = supabase.storage.from('chat-attachments').getPublicUrl(path);
|
||
return { url: data.publicUrl, type: 'image', name: file.name };
|
||
} catch (err: any) {
|
||
Alert.alert(t('chat.upload_failed'), err?.message ?? '');
|
||
return null;
|
||
} finally {
|
||
setUploading(false);
|
||
}
|
||
}
|
||
|
||
async function handleSend() {
|
||
const content = inputText.trim();
|
||
if (!content && !attachment) return;
|
||
if (sending || uploading) return;
|
||
|
||
const tempId = `temp-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||
const currentReplyTo = replyTo;
|
||
const currentAttachment = attachment;
|
||
|
||
const optimisticMsg: ChatMsg = {
|
||
id: tempId,
|
||
userId: myUserId ?? '',
|
||
nickname: 'Du',
|
||
avatar: null,
|
||
content,
|
||
replyTo: currentReplyTo
|
||
? {
|
||
id: currentReplyTo.id,
|
||
userId: myUserId ?? '',
|
||
nickname: currentReplyTo.nickname,
|
||
content: currentReplyTo.content,
|
||
attachmentType: null,
|
||
}
|
||
: null,
|
||
attachmentUrl: currentAttachment?.uri ?? null,
|
||
attachmentType: currentAttachment ? 'image' : null,
|
||
attachmentName: currentAttachment?.name ?? null,
|
||
likesCount: 0,
|
||
likedByMe: false,
|
||
createdAt: new Date().toISOString(),
|
||
isOwn: true,
|
||
readAt: null,
|
||
status: 'pending',
|
||
};
|
||
|
||
setMessages((prev) => [...prev, optimisticMsg]);
|
||
setInputText('');
|
||
setAttachment(null);
|
||
setReplyTo(null);
|
||
setSending(true);
|
||
sendStopTyping();
|
||
// Fokus 1× re-assertieren reicht — die mehrfach-focus-Aufrufe waren cargo-cult.
|
||
// Wichtiger: Send-Button bleibt mounted solange `sending` true ist (siehe
|
||
// Render-Bedingung unten), dadurch fällt das Touch-Target nicht weg und
|
||
// die Tastatur bleibt stehen.
|
||
inputRef.current?.focus();
|
||
|
||
try {
|
||
let attachmentMeta: { url: string; type: string; name: string } | null = null;
|
||
if (currentAttachment) {
|
||
attachmentMeta = await uploadAttachment(currentAttachment);
|
||
if (!attachmentMeta) {
|
||
setMessages((prev) =>
|
||
prev.map((m) => (m.id === tempId ? { ...m, status: 'failed' as const } : m)),
|
||
);
|
||
setSending(false);
|
||
return;
|
||
}
|
||
}
|
||
|
||
const newMsg = await apiFetch<any>('/api/chat/dm', {
|
||
method: 'POST',
|
||
body: {
|
||
receiverId: userId,
|
||
content,
|
||
replyToId: currentReplyTo?.id,
|
||
attachmentUrl: attachmentMeta?.url,
|
||
attachmentType: attachmentMeta?.type,
|
||
attachmentName: attachmentMeta?.name,
|
||
},
|
||
});
|
||
|
||
setMessages((prev) =>
|
||
prev.map((m) =>
|
||
m.id === tempId
|
||
? {
|
||
...m,
|
||
id: newMsg.id,
|
||
content: newMsg.content,
|
||
attachmentUrl: newMsg.attachmentUrl ?? null,
|
||
attachmentType: newMsg.attachmentType ?? null,
|
||
attachmentName: newMsg.attachmentName ?? null,
|
||
createdAt: newMsg.createdAt,
|
||
status: 'sent' as const,
|
||
}
|
||
: m,
|
||
),
|
||
);
|
||
|
||
queryClient.invalidateQueries({ queryKey: ['dm-conversations'] });
|
||
} catch (err) {
|
||
console.error('DM send failed:', err);
|
||
setMessages((prev) =>
|
||
prev.map((m) => (m.id === tempId ? { ...m, status: 'failed' as const } : m)),
|
||
);
|
||
} finally {
|
||
setSending(false);
|
||
}
|
||
}
|
||
|
||
async function toggleLike(msg: ChatMsg) {
|
||
try {
|
||
const { liked } = await apiFetch<{ liked: boolean }>('/api/chat/like', {
|
||
method: 'POST',
|
||
body: { messageId: msg.id, type: 'dm' },
|
||
});
|
||
setMessages((prev) =>
|
||
prev.map((m) =>
|
||
m.id === msg.id
|
||
? { ...m, likedByMe: liked, likesCount: m.likesCount + (liked ? 1 : -1) }
|
||
: m,
|
||
),
|
||
);
|
||
} catch {}
|
||
}
|
||
|
||
// Optimistisches Anwenden einer Reaktion auf die aggregierte Pills-Liste
|
||
// (WhatsApp-Toggle: gleiches Emoji entfernt, anderes ersetzt meine Reaktion).
|
||
function applyReactionOptimistic(
|
||
reactions: MessageReaction[],
|
||
emoji: string,
|
||
): MessageReaction[] {
|
||
const list = reactions.map((r) => ({ ...r }));
|
||
const mine = list.find((r) => r.mine);
|
||
const toggledOff = mine?.emoji === emoji;
|
||
if (mine) {
|
||
mine.count -= 1;
|
||
mine.mine = false;
|
||
}
|
||
const cleaned = list.filter((r) => r.count > 0);
|
||
if (toggledOff) return cleaned;
|
||
const existing = cleaned.find((r) => r.emoji === emoji);
|
||
if (existing) {
|
||
existing.count += 1;
|
||
existing.mine = true;
|
||
} else {
|
||
cleaned.push({ emoji, count: 1, mine: true });
|
||
}
|
||
return cleaned;
|
||
}
|
||
|
||
async function toggleReaction(msg: ChatMsg, emoji: string) {
|
||
setMessages((prev) =>
|
||
prev.map((m) =>
|
||
m.id === msg.id
|
||
? { ...m, reactions: applyReactionOptimistic(m.reactions ?? [], emoji) }
|
||
: m,
|
||
),
|
||
);
|
||
try {
|
||
await apiFetch('/api/chat/reaction', {
|
||
method: 'POST',
|
||
body: { messageId: msg.id, emoji },
|
||
});
|
||
} catch {
|
||
refetchHistory(); // Rollback via Server-State
|
||
}
|
||
}
|
||
|
||
function deleteMessage(msg: ChatMsg) {
|
||
Alert.alert(
|
||
t('chat.delete_confirm_title'),
|
||
t('chat.delete_confirm_msg'),
|
||
[
|
||
{ text: t('common.cancel'), style: 'cancel' },
|
||
{
|
||
text: t('chat.delete'),
|
||
style: 'destructive',
|
||
onPress: async () => {
|
||
setMessages((prev) =>
|
||
prev.map((m) =>
|
||
m.id === msg.id
|
||
? { ...m, deleted: true, content: '', attachmentUrl: null, attachmentType: null, reactions: [] }
|
||
: m,
|
||
),
|
||
);
|
||
try {
|
||
await apiFetch('/api/chat/delete-message', {
|
||
method: 'POST',
|
||
body: { messageId: msg.id },
|
||
});
|
||
} catch {
|
||
refetchHistory();
|
||
}
|
||
},
|
||
},
|
||
],
|
||
);
|
||
}
|
||
|
||
function startReply(msg: ChatMsg) {
|
||
setReplyTo({
|
||
id: msg.id,
|
||
nickname: msg.nickname ?? '?',
|
||
content: msg.content?.slice(0, 100) ||
|
||
(msg.attachmentType === 'image' ? `📷 ${t('chat.photo')}` :
|
||
msg.attachmentType === 'audio' ? `🎤 ${t('chat.voice_message')}` : ''),
|
||
});
|
||
}
|
||
|
||
async function startVoiceRecording() {
|
||
if (isVoiceRecording || sending) return;
|
||
const { status } = await Audio.requestPermissionsAsync();
|
||
if (status !== 'granted') {
|
||
Alert.alert(t('chat.mic_access_title'), t('chat.mic_access_body'));
|
||
return;
|
||
}
|
||
try {
|
||
await Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true });
|
||
const rec = new Audio.Recording();
|
||
await rec.prepareToRecordAsync({ ...Audio.RecordingOptionsPresets.HIGH_QUALITY, isMeteringEnabled: true });
|
||
await rec.startAsync();
|
||
voiceRecordingRef.current = rec;
|
||
voiceStartTime.current = Date.now();
|
||
setVoiceDuration(0);
|
||
setIsVoiceRecording(true);
|
||
voiceTimerRef.current = setInterval(async () => {
|
||
setVoiceDuration(Math.floor((Date.now() - voiceStartTime.current) / 1000));
|
||
try {
|
||
const s = await voiceRecordingRef.current?.getStatusAsync();
|
||
if (s?.isRecording && s.metering !== undefined) {
|
||
setVoiceLevel(Math.max(0, Math.min(1, (s.metering + 60) / 60)));
|
||
}
|
||
} catch {}
|
||
}, 200);
|
||
} catch {
|
||
await Audio.setAudioModeAsync({ allowsRecordingIOS: false }).catch(() => {});
|
||
}
|
||
}
|
||
|
||
async function cancelVoiceRecording() {
|
||
const rec = voiceRecordingRef.current;
|
||
voiceRecordingRef.current = null;
|
||
if (voiceTimerRef.current) { clearInterval(voiceTimerRef.current); voiceTimerRef.current = null; }
|
||
setVoiceDuration(0);
|
||
setVoiceLevel(0);
|
||
setVoiceTrashFlash(true);
|
||
setTimeout(() => {
|
||
setVoiceTrashFlash(false);
|
||
setIsVoiceRecording(false);
|
||
}, 350);
|
||
try { await rec?.stopAndUnloadAsync(); } catch {}
|
||
await Audio.setAudioModeAsync({ allowsRecordingIOS: false });
|
||
}
|
||
|
||
async function stopAndSendVoice() {
|
||
if (!isVoiceRecording) return;
|
||
if (voiceTimerRef.current) { clearInterval(voiceTimerRef.current); voiceTimerRef.current = null; }
|
||
const duration = voiceDuration;
|
||
setIsVoiceRecording(false);
|
||
setVoiceDuration(0);
|
||
setVoiceLevel(0);
|
||
const rec = voiceRecordingRef.current;
|
||
voiceRecordingRef.current = null;
|
||
if (!rec) return;
|
||
try { await rec.stopAndUnloadAsync(); } catch {}
|
||
await Audio.setAudioModeAsync({ allowsRecordingIOS: false });
|
||
const uri = rec.getURI();
|
||
if (!uri) return;
|
||
await sendVoiceMessage(uri, formatVoiceDuration(duration));
|
||
}
|
||
|
||
async function sendVoiceMessage(uri: string, durationStr: string) {
|
||
if (sending) return;
|
||
const tempId = `temp-voice-${Date.now()}`;
|
||
const optimisticMsg: ChatMsg = {
|
||
id: tempId,
|
||
userId: myUserId ?? '',
|
||
nickname: 'Du',
|
||
avatar: null,
|
||
content: '',
|
||
replyTo: null,
|
||
attachmentUrl: uri,
|
||
attachmentType: 'audio',
|
||
attachmentName: durationStr,
|
||
likesCount: 0,
|
||
likedByMe: false,
|
||
createdAt: new Date().toISOString(),
|
||
isOwn: true,
|
||
readAt: null,
|
||
status: 'pending',
|
||
};
|
||
setMessages((prev) => [...prev, optimisticMsg]);
|
||
setSending(true);
|
||
try {
|
||
setUploading(true);
|
||
const ext = uri.split('.').pop() ?? 'm4a';
|
||
const path = `chat/${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||
const base64 = await FileSystem.readAsStringAsync(uri, { encoding: FileSystem.EncodingType.Base64 });
|
||
const binary = typeof atob === 'function' ? atob(base64) : Buffer.from(base64, 'base64').toString('binary');
|
||
const bytes = new Uint8Array(binary.length);
|
||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||
const { error } = await supabase.storage.from('chat-attachments').upload(path, bytes, {
|
||
cacheControl: '3600',
|
||
upsert: false,
|
||
contentType: 'audio/m4a',
|
||
});
|
||
setUploading(false);
|
||
if (error) throw error;
|
||
const { data } = supabase.storage.from('chat-attachments').getPublicUrl(path);
|
||
const newMsg = await apiFetch<any>('/api/chat/dm', {
|
||
method: 'POST',
|
||
body: { receiverId: userId, content: '', attachmentUrl: data.publicUrl, attachmentType: 'audio', attachmentName: durationStr },
|
||
});
|
||
setMessages((prev) =>
|
||
prev.map((m) =>
|
||
m.id === tempId
|
||
? { ...m, id: newMsg.id, attachmentUrl: newMsg.attachmentUrl, createdAt: newMsg.createdAt, status: 'sent' as const }
|
||
: m
|
||
)
|
||
);
|
||
queryClient.invalidateQueries({ queryKey: ['dm-conversations'] });
|
||
} catch (err) {
|
||
console.error('Voice send failed:', err);
|
||
setMessages((prev) => prev.map((m) => m.id === tempId ? { ...m, status: 'failed' as const } : m));
|
||
setUploading(false);
|
||
} finally {
|
||
setSending(false);
|
||
}
|
||
}
|
||
|
||
// Bild aus der Lightbox in die Fotos-App sichern. Remote-URLs müssen erst
|
||
// lokal heruntergeladen werden, da saveToLibraryAsync eine file:// URI braucht.
|
||
async function saveImage(uri: string) {
|
||
if (savingImage) return;
|
||
// In einem älteren Build ohne eincompiliertes expo-media-library existieren
|
||
// die JS-Wrapper zwar, der native Teil aber nicht → der Call würde tief drin
|
||
// mit "Cannot read property ... of undefined" failen. requireOptionalNative-
|
||
// Module gibt null zurück (statt zu werfen), wenn das native Modul fehlt →
|
||
// saubere Vorab-Prüfung mit klarer Meldung.
|
||
if (!requireOptionalNativeModule('ExpoMediaLibrary')) {
|
||
Alert.alert(t('chat.save_failed'), t('chat.save_needs_rebuild'));
|
||
return;
|
||
}
|
||
const MediaLibrary: MediaLibraryModule = require('expo-media-library');
|
||
try {
|
||
setSavingImage(true);
|
||
const perm = await MediaLibrary.requestPermissionsAsync();
|
||
if (!perm.granted) {
|
||
Alert.alert(t('chat.photo_access_title'), t('chat.photo_access_body'));
|
||
return;
|
||
}
|
||
let localUri = uri;
|
||
if (!uri.startsWith('file://')) {
|
||
const ext = uri.split('?')[0].split('.').pop() || 'jpg';
|
||
const target = `${FileSystem.cacheDirectory}save-${Date.now()}.${ext}`;
|
||
const res = await FileSystem.downloadAsync(uri, target);
|
||
localUri = res.uri;
|
||
}
|
||
await MediaLibrary.saveToLibraryAsync(localUri);
|
||
Alert.alert(t('chat.image_saved'));
|
||
} catch (err: any) {
|
||
Alert.alert(t('chat.save_failed'), err?.message ?? '');
|
||
} finally {
|
||
setSavingImage(false);
|
||
}
|
||
}
|
||
|
||
function sameAuthor(a: ChatMsg | undefined, b: ChatMsg | undefined): boolean {
|
||
if (!a || !b) return false;
|
||
if (a.userId !== b.userId) return false;
|
||
return Math.abs(new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) <= GROUP_GAP_MS;
|
||
}
|
||
|
||
return (
|
||
<SafeAreaView style={styles.container} edges={['top']}>
|
||
<View style={[styles.header, { backgroundColor: colors.bg }]}>
|
||
<TouchableOpacity style={styles.backBtn} onPress={() => router.back()} hitSlop={8} activeOpacity={0.7}>
|
||
<Ionicons name="chevron-back" size={22} color={colors.text} />
|
||
</TouchableOpacity>
|
||
|
||
{/* Avatar + Name + Chevron — tap → Info-Sheet (ersetzt den alten i-Button) */}
|
||
<TouchableOpacity
|
||
style={styles.headerCenter}
|
||
activeOpacity={0.7}
|
||
onPress={() => setInfoSheetOpen(true)}
|
||
>
|
||
<View style={{ marginRight: 8 }}>
|
||
<UserAvatar
|
||
userId={userId ?? null}
|
||
avatar={partner?.avatar ?? null}
|
||
nickname={partner?.nickname ?? '?'}
|
||
size="md"
|
||
rawPresence
|
||
/>
|
||
</View>
|
||
<View style={{ flexShrink: 1 }}>
|
||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 3 }}>
|
||
<Text style={styles.headerName} numberOfLines={1}>
|
||
{partner?.nickname ?? '…'}
|
||
</Text>
|
||
<Ionicons name="chevron-forward" size={15} color={colors.textMuted} />
|
||
</View>
|
||
{userId && <ChatHeaderStatus userId={userId} />}
|
||
</View>
|
||
</TouchableOpacity>
|
||
|
||
{/* Call-Button — nur wenn erlaubt (gegenseitiger Follow + callsEnabled) */}
|
||
{canCall && (
|
||
<TouchableOpacity
|
||
style={styles.infoBtn}
|
||
hitSlop={8}
|
||
activeOpacity={0.7}
|
||
onPress={startCall}
|
||
>
|
||
<Ionicons name="call-outline" size={23} color={colors.text} />
|
||
</TouchableOpacity>
|
||
)}
|
||
</View>
|
||
|
||
<View style={{ flex: 1, backgroundColor: chatBg }}>
|
||
{(isLoading || isFetching) && messages.length === 0 ? (
|
||
<View style={styles.loadingBox}>
|
||
<ActivityIndicator color={colors.brandOrange} />
|
||
</View>
|
||
) : messages.length === 0 ? (
|
||
<View style={styles.loadingBox}>
|
||
<Ionicons name="chatbubble-outline" size={42} color="#d4d4d4" />
|
||
<Text style={styles.emptyText}>{t('chat.no_chats')}</Text>
|
||
</View>
|
||
) : (
|
||
<FlatList
|
||
ref={flatListRef}
|
||
data={messages}
|
||
style={{ flex: 1 }}
|
||
renderItem={({ item, index }) => (
|
||
<ChatBubble
|
||
msg={item}
|
||
isDM
|
||
isFirstInGroup={!sameAuthor(messages[index - 1], item)}
|
||
isLastInGroup={!sameAuthor(item, messages[index + 1])}
|
||
onReply={startReply}
|
||
onLike={toggleLike}
|
||
onReact={toggleReaction}
|
||
onDelete={deleteMessage}
|
||
onOpenImage={openLightbox}
|
||
cleanBg
|
||
/>
|
||
)}
|
||
keyExtractor={(m) => m.id}
|
||
ListFooterComponent={
|
||
partnerTyping ? (
|
||
<TypingBubble
|
||
userId={userId ?? null}
|
||
avatar={partner?.avatar ?? null}
|
||
nickname={partner?.nickname ?? '?'}
|
||
/>
|
||
) : null
|
||
}
|
||
contentContainerStyle={{
|
||
paddingHorizontal: 0,
|
||
paddingTop: 12,
|
||
// Tastatur offen: Input-Bar floatet (per transform) über der Tastatur,
|
||
// der Viewport schrumpft NICHT → Clearance = keyboardHeight + 4 (Gap).
|
||
// Tastatur zu: die KeyboardStickyView hat offset.closed = -insets.bottom,
|
||
// schiebt die Bar um insets.bottom NACH OBEN über den Content → diese
|
||
// Überlappung als Clearance abziehen. +16 = mittlerer Gap (nicht so eng
|
||
// wie +4, nicht so hoch wie die alte inputBarHeight-Variante).
|
||
paddingBottom: keyboardVisible ? keyboardHeight + 4 : insets.bottom + 16,
|
||
}}
|
||
showsVerticalScrollIndicator={false}
|
||
keyboardDismissMode="interactive"
|
||
keyboardShouldPersistTaps="handled"
|
||
onContentSizeChange={() => scrollToBottom(false)}
|
||
onLayout={() => scrollToBottom(false)}
|
||
/>
|
||
)}
|
||
</View>
|
||
|
||
<KeyboardStickyView
|
||
offset={{ closed: -insets.bottom, opened: 0 }}
|
||
style={{ backgroundColor: colors.bg }}
|
||
>
|
||
<View
|
||
onLayout={(e) => {
|
||
const h = e.nativeEvent.layout.height;
|
||
if (Math.abs(h - inputBarHeight) > 1) setInputBarHeight(h);
|
||
}}
|
||
style={[
|
||
styles.inputBar,
|
||
{
|
||
paddingBottom: 8,
|
||
backgroundColor: colors.bg,
|
||
borderTopColor: colors.border,
|
||
},
|
||
]}
|
||
>
|
||
{replyTo && (
|
||
<View style={[styles.replyBar, { backgroundColor: colors.surface }]}>
|
||
<Ionicons name="arrow-undo" size={14} color="#007AFF" style={{ marginRight: 6 }} />
|
||
<View style={{ flex: 1 }}>
|
||
<Text style={styles.replyName} numberOfLines={1}>
|
||
{t('chat.reply_to')} {replyTo.nickname}
|
||
</Text>
|
||
<Text style={[styles.replyContent, { color: colors.textMuted }]} numberOfLines={1}>
|
||
{replyTo.content || '…'}
|
||
</Text>
|
||
</View>
|
||
<TouchableOpacity hitSlop={10} onPress={() => setReplyTo(null)} activeOpacity={0.7}>
|
||
<Ionicons name="close" size={16} color={colors.textMuted} />
|
||
</TouchableOpacity>
|
||
</View>
|
||
)}
|
||
{attachment && (
|
||
<View style={[styles.attachBar, { backgroundColor: colors.surface }]}>
|
||
<Image source={{ uri: attachment.uri }} style={styles.attachImg} contentFit="cover" />
|
||
<Text style={[styles.attachName, { color: colors.text }]} numberOfLines={1}>
|
||
{attachment.name}
|
||
</Text>
|
||
<TouchableOpacity hitSlop={10} onPress={() => setAttachment(null)} activeOpacity={0.7}>
|
||
<Ionicons name="close" size={16} color={colors.textMuted} />
|
||
</TouchableOpacity>
|
||
</View>
|
||
)}
|
||
{isVoiceRecording ? (
|
||
<View style={styles.voiceRecBarWrap}>
|
||
<VoiceRecordingBar
|
||
duration={voiceDuration}
|
||
level={voiceLevel}
|
||
trashFlash={voiceTrashFlash}
|
||
onCancel={cancelVoiceRecording}
|
||
onSend={stopAndSendVoice}
|
||
sendIcon="arrow-up"
|
||
accentColor="#007AFF"
|
||
/>
|
||
</View>
|
||
) : (
|
||
<View style={styles.inputRow}>
|
||
<TouchableOpacity
|
||
activeOpacity={0.7}
|
||
style={[styles.addBtn, { backgroundColor: colors.surfaceElevated }]}
|
||
onPress={pickImage}
|
||
disabled={uploading || sending}
|
||
>
|
||
<Ionicons name="add" size={22} color={colors.textMuted} />
|
||
</TouchableOpacity>
|
||
<TextInput
|
||
ref={inputRef}
|
||
style={[styles.textInput, { backgroundColor: colors.surfaceElevated, color: colors.text }]}
|
||
placeholder={t('chat.placeholder')}
|
||
placeholderTextColor={colors.textMuted}
|
||
value={inputText}
|
||
onChangeText={(v) => {
|
||
setInputText(v);
|
||
// Heartbeat aktiv solange Text vorhanden (Feld ist beim
|
||
// onChangeText zwangsläufig fokussiert). Hält „tippt" stabil
|
||
// über Denkpausen — onBlur/Senden/Leeren stoppt ihn.
|
||
setComposing(v.trim().length > 0);
|
||
}}
|
||
onBlur={() => sendStopTyping()}
|
||
multiline
|
||
maxLength={2000}
|
||
returnKeyType="send"
|
||
onSubmitEditing={handleSend}
|
||
// Insta/WA-Style: nach dem Senden bleibt die Tastatur offen
|
||
// (Fokus bleibt am Input), bis der User woanders hin tippt.
|
||
blurOnSubmit={false}
|
||
// editable NICHT auf !sending setzen — das wäre der Grund warum
|
||
// die Tastatur nach Send dismisst (non-editable TextInput → iOS
|
||
// forciert Blur → Keyboard weg). User darf während des Sendens
|
||
// schon die nächste Nachricht tippen (wie WhatsApp/Insta).
|
||
editable={!uploading}
|
||
/>
|
||
{(inputText.trim().length > 0 || attachment) ? (
|
||
<TouchableOpacity
|
||
style={styles.sendBtn}
|
||
onPress={handleSend}
|
||
disabled={uploading}
|
||
activeOpacity={0.7}
|
||
>
|
||
<Ionicons name="send" size={16} color="#fff" />
|
||
</TouchableOpacity>
|
||
) : (
|
||
<TouchableOpacity
|
||
style={[styles.addBtn, { backgroundColor: colors.surfaceElevated }]}
|
||
onPress={startVoiceRecording}
|
||
disabled={sending}
|
||
activeOpacity={0.7}
|
||
>
|
||
<Ionicons name="mic" size={20} color={colors.textMuted} />
|
||
</TouchableOpacity>
|
||
)}
|
||
</View>
|
||
)}
|
||
</View>
|
||
</KeyboardStickyView>
|
||
|
||
{/* ── Info-Sheet ─────────────────────────────────────────────── */}
|
||
<DmInfoSheet
|
||
visible={infoSheetOpen}
|
||
onClose={() => setInfoSheetOpen(false)}
|
||
partner={partner}
|
||
partnerUserId={userId ?? null}
|
||
messages={messages}
|
||
onImagePress={(uri) => openLightbox(uri, 'info')}
|
||
onViewProfile={() => {
|
||
setInfoSheetOpen(false);
|
||
setTimeout(() => userId && router.push(`/profile/${userId}` as any), 250);
|
||
}}
|
||
colors={colors}
|
||
t={t}
|
||
lightbox={
|
||
<MediaLightbox
|
||
visible={lightboxOpen && lightboxSource === 'info'}
|
||
images={lightboxImages}
|
||
index={lightboxIndex}
|
||
onIndexChange={setLightboxIndex}
|
||
onClose={closeLightbox}
|
||
onSave={saveImage}
|
||
saving={savingImage}
|
||
/>
|
||
}
|
||
/>
|
||
|
||
{/* ── Lightbox (Chat-Kontext) ──────────────────────────────────
|
||
Root-Instanz für Taps auf Bilder im Chatverlauf. Die Info-Sheet-
|
||
Instanz wird genested im DmInfoSheet gerendert (siehe oben). */}
|
||
<MediaLightbox
|
||
visible={lightboxOpen && lightboxSource === 'chat'}
|
||
images={lightboxImages}
|
||
index={lightboxIndex}
|
||
onIndexChange={setLightboxIndex}
|
||
onClose={closeLightbox}
|
||
onSave={saveImage}
|
||
saving={savingImage}
|
||
/>
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
// ─── DmInfoSheet ─────────────────────────────────────────────────────────────
|
||
|
||
const MEDIA_COL = 3;
|
||
const MEDIA_GAP = 2;
|
||
const MEDIA_SIZE = (Dimensions.get('window').width - MEDIA_GAP * (MEDIA_COL + 1)) / MEDIA_COL;
|
||
|
||
function DmInfoSheet({
|
||
visible,
|
||
onClose,
|
||
partner,
|
||
partnerUserId,
|
||
messages,
|
||
onImagePress,
|
||
onViewProfile,
|
||
colors,
|
||
t,
|
||
lightbox,
|
||
}: {
|
||
visible: boolean;
|
||
onClose: () => void;
|
||
partner: { id: string; nickname: string; avatar?: string | null } | null;
|
||
partnerUserId: string | null;
|
||
messages: ChatMsg[];
|
||
onImagePress: (uri: string) => void;
|
||
onViewProfile: () => void;
|
||
colors: ReturnType<typeof useColors>;
|
||
t: ReturnType<typeof import('react-i18next').useTranslation>['t'];
|
||
// Genestete Lightbox — liegt über dem FormSheet-Modal, damit das Sheet beim
|
||
// Bild-Öffnen erhalten bleibt (kein Kontextwechsel zurück zur DM).
|
||
lightbox?: ReactNode;
|
||
}) {
|
||
const sharedMedia = messages.filter(
|
||
(m) => m.attachmentType === 'image' && m.attachmentUrl,
|
||
);
|
||
|
||
return (
|
||
<FormSheet
|
||
visible={visible}
|
||
onClose={onClose}
|
||
title={t('chat.info')}
|
||
initialHeightPct={0.85}
|
||
dismissOnBackdrop
|
||
>
|
||
<ScrollView contentContainerStyle={{ paddingBottom: 40 }}>
|
||
{/* Partner-Karte — Avatar via UserAvatar (rendert auch Listen-/Default-
|
||
Avatare, nicht nur eigene Foto-URLs), Pfeil direkt neben dem Namen. */}
|
||
<TouchableOpacity
|
||
activeOpacity={0.7}
|
||
onPress={onViewProfile}
|
||
style={{ flexDirection: 'row', alignItems: 'center', padding: 16, gap: 14 }}
|
||
>
|
||
<UserAvatar
|
||
userId={partnerUserId}
|
||
avatar={partner?.avatar ?? null}
|
||
nickname={partner?.nickname ?? '?'}
|
||
size="lg"
|
||
/>
|
||
<View style={{ flex: 1 }}>
|
||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
|
||
<Text style={{ fontSize: 17, fontFamily: 'Nunito_700Bold', color: colors.text }} numberOfLines={1}>
|
||
{partner?.nickname ?? '…'}
|
||
</Text>
|
||
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
|
||
</View>
|
||
<Text style={{ fontSize: 13, fontFamily: 'Nunito_400Regular', color: colors.textMuted, marginTop: 2 }}>
|
||
{t('dm.view_profile')}
|
||
</Text>
|
||
</View>
|
||
</TouchableOpacity>
|
||
|
||
<View style={{ height: StyleSheet.hairlineWidth, backgroundColor: colors.border, marginHorizontal: 16 }} />
|
||
|
||
{/* Geteilte Medien */}
|
||
<View style={{ paddingHorizontal: 16, paddingTop: 20, paddingBottom: 12, flexDirection: 'row', alignItems: 'baseline', gap: 6 }}>
|
||
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
||
{t('dm.shared_media')}
|
||
</Text>
|
||
{sharedMedia.length > 0 && (
|
||
<Text style={{ fontSize: 13, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
|
||
{sharedMedia.length}
|
||
</Text>
|
||
)}
|
||
</View>
|
||
|
||
{sharedMedia.length === 0 ? (
|
||
<View style={{ alignItems: 'center', paddingVertical: 32 }}>
|
||
<Ionicons name="images-outline" size={40} color={colors.textMuted} />
|
||
<Text style={{ marginTop: 10, fontSize: 13, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
|
||
{t('dm.no_shared_media')}
|
||
</Text>
|
||
</View>
|
||
) : (
|
||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: MEDIA_GAP, paddingHorizontal: MEDIA_GAP }}>
|
||
{[...sharedMedia].reverse().map((m) => (
|
||
<TouchableOpacity
|
||
key={m.id}
|
||
activeOpacity={0.8}
|
||
onPress={() => onImagePress(m.attachmentUrl!)}
|
||
>
|
||
<Image
|
||
source={{ uri: m.attachmentUrl! }}
|
||
style={{ width: MEDIA_SIZE, height: MEDIA_SIZE }}
|
||
contentFit="cover"
|
||
cachePolicy="memory-disk"
|
||
/>
|
||
</TouchableOpacity>
|
||
))}
|
||
</View>
|
||
)}
|
||
</ScrollView>
|
||
{lightbox}
|
||
</FormSheet>
|
||
);
|
||
}
|
||
|
||
function makeStyles(colors: ReturnType<typeof useColors>) {
|
||
return StyleSheet.create({
|
||
container: { flex: 1, backgroundColor: colors.bg },
|
||
header: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
paddingHorizontal: 12,
|
||
paddingVertical: 10,
|
||
},
|
||
backBtn: {
|
||
padding: 8,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
headerCenter: {
|
||
flex: 1,
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
marginLeft: 8,
|
||
},
|
||
infoBtn: {
|
||
padding: 8,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
headerName: {
|
||
fontSize: 15,
|
||
fontFamily: 'Nunito_700Bold',
|
||
color: colors.text,
|
||
flexShrink: 1,
|
||
},
|
||
loadingBox: {
|
||
flex: 1,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
emptyText: {
|
||
fontSize: 13,
|
||
fontFamily: 'Nunito_600SemiBold',
|
||
color: colors.textMuted,
|
||
marginTop: 12,
|
||
},
|
||
inputBar: {
|
||
borderTopWidth: StyleSheet.hairlineWidth,
|
||
},
|
||
replyBar: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
paddingHorizontal: 12,
|
||
paddingVertical: 8,
|
||
borderLeftWidth: 3,
|
||
borderLeftColor: '#007AFF',
|
||
marginHorizontal: 8,
|
||
marginTop: 6,
|
||
borderRadius: 8,
|
||
},
|
||
replyName: {
|
||
fontSize: 11,
|
||
fontFamily: 'Nunito_700Bold',
|
||
color: '#007AFF',
|
||
},
|
||
replyContent: {
|
||
fontSize: 11,
|
||
fontFamily: 'Nunito_400Regular',
|
||
marginTop: 1,
|
||
},
|
||
attachBar: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
paddingHorizontal: 12,
|
||
paddingVertical: 6,
|
||
marginHorizontal: 8,
|
||
marginTop: 6,
|
||
borderRadius: 8,
|
||
},
|
||
attachImg: {
|
||
width: 36,
|
||
height: 36,
|
||
borderRadius: 6,
|
||
marginRight: 8,
|
||
},
|
||
attachName: {
|
||
flex: 1,
|
||
fontSize: 12,
|
||
fontFamily: 'Nunito_600SemiBold',
|
||
},
|
||
inputRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'flex-end',
|
||
gap: 8,
|
||
paddingHorizontal: 12,
|
||
paddingTop: 8,
|
||
},
|
||
addBtn: {
|
||
width: 44,
|
||
height: 44,
|
||
borderRadius: 22,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
textInput: {
|
||
flex: 1,
|
||
borderRadius: 22,
|
||
paddingVertical: 9,
|
||
paddingHorizontal: 16,
|
||
fontSize: 15,
|
||
fontFamily: 'Nunito_400Regular',
|
||
maxHeight: 120,
|
||
},
|
||
sendBtn: {
|
||
width: 44,
|
||
height: 44,
|
||
borderRadius: 22,
|
||
backgroundColor: '#007AFF',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
sendBtnDisabled: {
|
||
opacity: 0.4,
|
||
},
|
||
voiceRecBarWrap: {
|
||
flexDirection: 'row',
|
||
marginHorizontal: 12,
|
||
marginTop: 8,
|
||
marginBottom: 4,
|
||
},
|
||
});
|
||
}
|