chahinebrini 822053e11e feat(calls): CallKit/ConnectionService + VoIP-PushKit + EU-Ringback
Caller/Callee UX:
- lib/ringback.ts + assets/sounds/ringback_eu.mp3 (EU 425Hz Festnetz-Tone)
- stores/call.ts: stopRingback bei connected, hangup-reasons, logCallToChat fix
- locales: 'Wird angerufen…' statt 'Ruft an…'

CallKit (iOS) + ConnectionService (Android):
- lib/callkit.ts: setupCallKeep, displayIncomingCall, startOutgoingCall, reportConnected/Ended (appName 'ReBreak-Audio', includesCallsInRecents=false für DSGVO/DiGA)
- hooks/useCallKeepEvents.ts: native answer/end/mute → useCallStore-Actions
- stores/call.ts: CallKit-Aufrufe an allen lifecycle-Punkten
- app.config.ts: @config-plugins/react-native-callkeep + UIBackgroundModes voip/audio + Android-Telecom-Perms

VoIP-PushKit Backend:
- services/voip-push.ts: @parse/node-apn Provider mit .p12 (Topic org.rebreak.app.voip)
- services/push.ts sendCallRingPush: feuert beide Pfade (VoIP iOS + Expo Android/Fallback)
- prisma: push_tokens.voip_token Column + Migration 20260604
- api/users/me/push-token: optional voipToken im Body
- Env (Infisical): APNS_VOIP_P12_PATH/PASSWORD/TOPIC/PRODUCTION

Push-tap routing + cold-start handling:
- app/_layout.tsx: type:'call' Push → useCallStore.receiveIncoming + /call

Docs: ops/CALLKIT_SETUP.md (Apple-Portal-Steps für VoIP-Cert)
2026-06-04 09:27:13 +02:00

1343 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { 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);
// 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);
// 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;
}
// 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 + 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"
/>
</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} typing={partnerTyping} />}
</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}
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 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) => {
// 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,
},
});
}