chahinebrini 89e4e3481b feat(calls): Phase 0 — calls_enabled opt-out + canCall guard (mutual-follow); DM UI batch
Backend (voice-call groundwork, no call engine yet):
- Profile.callsEnabled (Boolean default true) + migration
- canCall(caller,callee): mutual-follow AND callee.callsEnabled — server-side hard guard
- POST /api/me/calls-enabled (opt-out toggle), GET /api/chat/can-call/:userId
- expose callsEnabled in /api/auth/me

Frontend:
- "Allow calls" toggle in Profile privacy section (default on, optimistic+rollback)
- Me.callsEnabled + i18n DE/EN/FR/AR

Bundled DM UI work from this session:
- image lightbox is now a swipeable carousel over all shared images (+ counter)
- keyboard stays open after sending (input ref refocus)
- voice notes: Instagram-style waveforms (own=white/mint, other=black/grey),
  removed the blue progress dot; lazy-load expo-media-library with clean fallback
- expo-linear-gradient + expo-media-library deps

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:14:31 +02:00

1306 lines
47 KiB
TypeScript

import { useState, useRef, useEffect, useCallback } from 'react';
import {
View,
Text,
TextInput,
FlatList,
TouchableOpacity,
Platform,
Alert,
ActivityIndicator,
StyleSheet,
Keyboard,
Modal,
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 { 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 { 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 { 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);
// Index → echtes Seitenverhältnis (via onLoad), damit der Container exakt auf
// die Bildmaße passt und borderRadius die sichtbaren Foto-Ecken rundet.
const [lightboxRatios, setLightboxRatios] = useState<Record<number, number>>({});
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) => {
const urls = messagesRef.current
.filter((m) => m.attachmentType === 'image' && m.attachmentUrl)
.map((m) => m.attachmentUrl as string);
const list = urls.length ? urls : [uri];
setLightboxImages(list);
setLightboxIndex(Math.max(0, list.indexOf(uri)));
setLightboxRatios({});
}, []);
const closeLightbox = useCallback(() => {
setLightboxImages([]);
setLightboxRatios({});
}, []);
// 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, sendTyping, sendStopTyping } = useDmTyping(myUserId, userId);
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 halten: das Leeren des Inputs tauscht Send→Mic-Button und kann den
// Fokus verlieren. Re-assert nach dem Re-Render → Tastatur bleibt offen.
requestAnimationFrame(() => 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;
}
// Lightbox-Bildmaße: in die Bildschirmfläche einpassen, Seitenverhältnis wahren.
const lbWin = Dimensions.get('window');
const lbMaxW = lbWin.width - 24;
const lbMaxH = lbWin.height * 0.78;
function lbFitDims(ratio?: number) {
let w = lbMaxW;
let h = lbMaxW; // Fallback (Ratio noch unbekannt): Quadrat
if (ratio) {
w = lbMaxW;
h = lbMaxW / ratio;
if (h > lbMaxH) {
h = lbMaxH;
w = lbMaxH * ratio;
}
}
return { width: w, height: h };
}
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 — tap → Profil */}
<TouchableOpacity
style={styles.headerCenter}
activeOpacity={0.7}
onPress={() => userId && router.push(`/profile/${userId}` as any)}
>
<View style={{ marginRight: 8 }}>
<UserAvatar
userId={userId ?? null}
avatar={partner?.avatar ?? null}
nickname={partner?.nickname ?? '?'}
size="md"
/>
</View>
<View style={{ flexShrink: 1 }}>
<Text style={styles.headerName} numberOfLines={1}>
{partner?.nickname ?? '…'}
</Text>
{userId && <ChatHeaderStatus userId={userId} typing={partnerTyping} />}
</View>
</TouchableOpacity>
{/* Info-Button */}
<TouchableOpacity
style={styles.infoBtn}
hitSlop={8}
activeOpacity={0.7}
onPress={() => setInfoSheetOpen(true)}
>
<Ionicons name="information-circle-outline" size={24} 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}
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);
if (v.trim().length > 0) sendTyping();
else 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={!sending && !uploading}
/>
{(inputText.trim().length > 0 || attachment) ? (
<TouchableOpacity
style={[styles.sendBtn, (sending || uploading) && styles.sendBtnDisabled]}
onPress={handleSend}
disabled={sending || uploading}
activeOpacity={0.7}
>
{sending || uploading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<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) => {
// Sheet erst schließen, dann Lightbox — sonst läge die Lightbox hinter
// dem FormSheet-Modal und wäre nicht sichtbar.
setInfoSheetOpen(false);
setTimeout(() => openLightbox(uri), 250);
}}
onViewProfile={() => {
setInfoSheetOpen(false);
setTimeout(() => userId && router.push(`/profile/${userId}` as any), 250);
}}
colors={colors}
t={t}
/>
{/* ── Lightbox-Carousel ──────────────────────────────────────── */}
<Modal visible={lightboxOpen} transparent animationType="fade" onRequestClose={closeLightbox}>
<View style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.92)' }}>
<FlatList
data={lightboxImages}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
initialScrollIndex={lightboxIndex}
getItemLayout={(_, i) => ({ length: lbWin.width, offset: lbWin.width * i, index: i })}
keyExtractor={(u, i) => `${i}-${u}`}
onMomentumScrollEnd={(e) =>
setLightboxIndex(Math.round(e.nativeEvent.contentOffset.x / lbWin.width))
}
renderItem={({ item, index }) => (
<TouchableOpacity
activeOpacity={1}
onPress={closeLightbox}
style={{ width: lbWin.width, height: lbWin.height, alignItems: 'center', justifyContent: 'center' }}
>
<Image
source={{ uri: item }}
onLoad={(e) => {
const s = e.source;
if (s?.width && s?.height)
setLightboxRatios((r) => ({ ...r, [index]: s.width / s.height }));
}}
style={{ ...lbFitDims(lightboxRatios[index]), borderRadius: 16 }}
contentFit="contain"
cachePolicy="memory-disk"
/>
</TouchableOpacity>
)}
/>
{/* Zähler "2 / 6" — nur bei mehreren Bildern */}
{lightboxImages.length > 1 && (
<View
style={{
position: 'absolute',
top: 56,
alignSelf: 'center',
paddingHorizontal: 12,
paddingVertical: 5,
borderRadius: 14,
backgroundColor: 'rgba(0,0,0,0.45)',
}}
pointerEvents="none"
>
<Text style={{ color: '#fff', fontSize: 13, fontFamily: 'Nunito_700Bold', fontVariant: ['tabular-nums'] }}>
{lightboxIndex + 1} / {lightboxImages.length}
</Text>
</View>
)}
<TouchableOpacity
style={{ position: 'absolute', top: 50, right: 20, padding: 8 }}
onPress={closeLightbox}
activeOpacity={0.7}
>
<Ionicons name="close-circle" size={32} color="#fff" />
</TouchableOpacity>
{/* Sichern (aktuelles Bild) */}
<TouchableOpacity
style={{
position: 'absolute',
bottom: 54,
alignSelf: 'center',
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 24,
backgroundColor: 'rgba(255,255,255,0.16)',
}}
onPress={() => lightboxImages[lightboxIndex] && saveImage(lightboxImages[lightboxIndex])}
disabled={savingImage}
activeOpacity={0.7}
>
{savingImage ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Ionicons name="download-outline" size={20} color="#fff" />
)}
<Text style={{ color: '#fff', fontSize: 15, fontFamily: 'Nunito_600SemiBold' }}>
{t('chat.save')}
</Text>
</TouchableOpacity>
</View>
</Modal>
</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,
}: {
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'];
}) {
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>
</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,
},
});
}