import { useState, useRef, useEffect, useCallback } from 'react'; import { View, Text, TextInput, FlatList, TouchableOpacity, Platform, Alert, ActivityIndicator, StyleSheet, Keyboard, type FlatList as FlatListType, } from 'react-native'; 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'; // 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 } from '../components/chat/ChatBubble'; import { DmChatBackground } from '../components/chat/DmChatBackground'; import { useDmRealtime } from '../hooks/useChatRealtime'; import { useColors } from '../lib/theme'; import { useThemeStore } from '../stores/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; export default function DmScreen() { const { t } = useTranslation(); const router = useRouter(); const insets = useSafeAreaInsets(); const isAndroid = Platform.OS === 'android'; const colors = useColors(); const styles = makeStyles(colors); const queryClient = useQueryClient(); const myUserId = useAuthStore((s) => s.user?.id); const colorScheme = useThemeStore((s) => s.colorScheme); const chatBg = colorScheme === 'dark' ? '#1a1f1e' : '#EDE8E1'; const { userId } = useLocalSearchParams<{ userId: string }>(); const flatListRef = useRef>(null); const isNearBottomRef = useRef(true); const [messages, setMessages] = useState([]); const [partner, setPartner] = useState(null); const partnerRef = useRef(null); const [replyTo, setReplyTo] = useState<{ id: string; nickname: string; content: string } | null>( 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); // Reset aller conversation-spezifischen States wenn userId wechselt (Stack-Reuse) useEffect(() => { setMessages([]); setPartner(null); partnerRef.current = null; setReplyTo(null); }, [userId]); // 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(() => flatListRef.current?.scrollToEnd({ animated: false })); }); const hide = Keyboard.addListener(hideEvent, () => { setKeyboardHeight(0); setKeyboardVisible(false); setTimeout(() => flatListRef.current?.scrollToEnd({ animated: 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]), ); // Lade DM-History — staleTime:0 erzwingt immer frischen Fetch (kein Cache-Hit-Bug) const { isLoading, isFetching } = useQuery({ queryKey: ['dm-history', userId], queryFn: async () => { console.log('[dm] fetching history for partner', userId, 'me', myUserId); try { const data = await apiFetch(`/api/chat/dm/${userId}`); console.log('[dm] partner:', data.partner?.nickname, 'msgs:', data.messages?.length); setPartner(data.partner); partnerRef.current = data.partner; 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, })); setMessages(msgs); requestAnimationFrame(() => flatListRef.current?.scrollToEnd({ animated: false })); return data; } catch (err: any) { console.error('[dm] history fetch failed:', err?.message ?? err); throw err; } }, enabled: !!userId && !!myUserId, staleTime: 0, gcTime: 0, }); // Neue Nachricht (incoming Realtime oder outgoing send) — nur scrollen wenn nahe unten useEffect(() => { if (messages.length === 0) return; if (isNearBottomRef.current) { requestAnimationFrame(() => flatListRef.current?.scrollToEnd({ animated: 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, }, ]; }); }, [myUserId], ); useDmRealtime(userId, onDmInsert, !!myUserId); 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(): Promise<{ url: string; type: string; name: string } | null> { if (!attachment) return null; try { setUploading(true); const ext = attachment.name.split('.').pop() || 'jpg'; const path = `chat/${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${ext}`; const base64 = await FileSystem.readAsStringAsync(attachment.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: attachment.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; setSending(true); try { let attachmentMeta: { url: string; type: string; name: string } | null = null; if (attachment) { attachmentMeta = await uploadAttachment(); if (!attachmentMeta) { setSending(false); return; } } const newMsg = await apiFetch('/api/chat/dm', { method: 'POST', body: { receiverId: userId, content, replyToId: replyTo?.id, attachmentUrl: attachmentMeta?.url, attachmentType: attachmentMeta?.type, attachmentName: attachmentMeta?.name, }, }); setMessages((prev) => [ ...prev, { id: newMsg.id, userId: myUserId ?? '', nickname: 'Du', avatar: null, content: newMsg.content, replyTo: newMsg.replyTo ? { id: newMsg.replyTo.id, userId: newMsg.replyTo.senderId, nickname: newMsg.replyTo.senderId === myUserId ? 'Du' : partner?.nickname ?? '?', content: newMsg.replyTo.content?.slice(0, 100) ?? '', attachmentType: newMsg.replyTo.attachmentType ?? null, } : null, attachmentUrl: newMsg.attachmentUrl, attachmentType: newMsg.attachmentType, attachmentName: newMsg.attachmentName, likesCount: newMsg.likesCount ?? 0, likedByMe: false, createdAt: newMsg.createdAt, isOwn: true, readAt: null, }, ]); setInputText(''); setAttachment(null); setReplyTo(null); queryClient.invalidateQueries({ queryKey: ['dm-conversations'] }); } catch (err) { console.error('DM send failed:', err); } 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 {} } function startReply(msg: ChatMsg) { setReplyTo({ id: msg.id, nickname: msg.nickname ?? '?', content: msg.content?.slice(0, 100) || (msg.attachmentType === 'image' ? 'Bild' : ''), }); } 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 ( router.back()} hitSlop={8} activeOpacity={0.7}> {partner?.nickname ?? '…'} {userId && } {(isLoading || isFetching) && messages.length === 0 ? ( ) : messages.length === 0 ? ( {t('chat.no_chats')} ) : ( ( {}} /> )} keyExtractor={(m) => m.id} contentContainerStyle={{ paddingHorizontal: 0, paddingTop: 12, paddingBottom: 12 + insets.bottom + (keyboardVisible ? keyboardHeight : 0), }} showsVerticalScrollIndicator={false} keyboardDismissMode="interactive" keyboardShouldPersistTaps="handled" onScroll={(e) => { const { layoutMeasurement, contentOffset, contentSize } = e.nativeEvent; const distFromBottom = contentSize.height - contentOffset.y - layoutMeasurement.height; isNearBottomRef.current = distFromBottom < 80; }} scrollEventThrottle={100} onContentSizeChange={() => { if (isNearBottomRef.current) { flatListRef.current?.scrollToEnd({ animated: false }); } }} /> )} {replyTo && ( {t('chat.reply_to')} {replyTo.nickname} {replyTo.content || '…'} setReplyTo(null)} activeOpacity={0.7}> )} {attachment && ( {attachment.name} setAttachment(null)} activeOpacity={0.7}> )} {(inputText.trim().length > 0 || attachment) && ( {sending || uploading ? ( ) : ( )} )} ); } function makeStyles(colors: ReturnType) { return StyleSheet.create({ container: { flex: 1, backgroundColor: colors.bg }, header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingVertical: 10, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: colors.border, }, backBtn: { padding: 8, alignItems: 'center', justifyContent: 'center', }, headerCenter: { flex: 1, flexDirection: 'row', alignItems: 'center', marginLeft: 8, }, 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: 38, height: 38, borderRadius: 19, alignItems: 'center', justifyContent: 'center', }, textInput: { flex: 1, borderRadius: 22, paddingVertical: 9, paddingHorizontal: 16, fontSize: 15, fontFamily: 'Nunito_400Regular', maxHeight: 120, }, sendBtn: { width: 38, height: 38, borderRadius: 19, backgroundColor: '#007AFF', alignItems: 'center', justifyContent: 'center', }, sendBtnDisabled: { opacity: 0.4, }, }); }