chahinebrini 92ad4c93b5 fix(dm): smooth image lightbox + stable online/typing status
- MediaLightbox component extracted from dm.tsx. Image now fills a fixed
  full-screen box with contentFit=contain instead of an onLoad-computed
  aspect ratio, removing the square->real-size jump ("jitter") on open.
- Info-sheet images: render a nested MediaLightbox inside the FormSheet
  (stacks above the sheet modal) and track lightboxSource. Removes the
  close-sheet-then-reopen workaround that switched context back to the DM.
- Typing indicator: heartbeat (every 2s while focused + non-empty) instead
  of keystroke-only sends, so "typing…" holds through thinking pauses;
  receiver clear raised to 6s. stop on blur/send/empty.
- Presence: debounce going offline by 12s (online immediate) so brief
  presence-sync gaps no longer flicker "Online" <-> "last seen".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 10:48:00 +02:00

1258 lines
46 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, 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 { 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);
// 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"
/>
</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);
// 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,
},
});
}