import { useState, useCallback, useEffect } from 'react'; import { View, Text, FlatList, TouchableOpacity, TextInput, ActivityIndicator, RefreshControl, StyleSheet, } from 'react-native'; import { useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { apiFetch } from '../../lib/api'; import { supabase } from '../../lib/supabase'; import { useAuthStore } from '../../stores/auth'; import { AppHeader } from '../../components/AppHeader'; import { UserAvatar } from '../../components/UserAvatar'; import { useColors } from '../../lib/theme'; type DmConversation = { partnerId: string; partnerName: string; partnerAvatar: string | null; lastMessage: string; lastAttachmentType?: string | null; lastMessageAt: string; unreadCount: number; isOwn: boolean; }; function formatTime(ts: string, justNowLabel: string): string { const diff = Date.now() - new Date(ts).getTime(); const day = 86_400_000; if (diff < 60_000) return justNowLabel; if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m`; if (diff < day) return `${Math.floor(diff / 3_600_000)}h`; if (diff < 7 * day) return new Date(ts).toLocaleDateString('de-DE', { weekday: 'short' }); if (diff < 30 * day) return `${Math.floor(diff / day)}d`; if (diff < 60 * day) return `${Math.floor(diff / (7 * day))}w`; if (diff < 365 * day) return `${Math.floor(diff / (30 * day))}mo`; return `${Math.floor(diff / (365 * day))}y`; } function DmItem({ conv, onPress }: { conv: DmConversation; onPress: () => void }) { const { t } = useTranslation(); const colors = useColors(); const styles = makeStyles(colors); const hasUnread = conv.unreadCount > 0; return ( {conv.partnerName} {formatTime(conv.lastMessageAt, t('chat.just_now'))} {conv.isOwn ? `${t('chat.you')} ` : ''} {conv.lastMessage || (conv.lastAttachmentType === 'call' ? t('chat.call_audio') : conv.lastAttachmentType === 'audio' ? t('chat.voice_message') : conv.lastAttachmentType === 'image' ? t('chat.photo') : t('chat.media_sent'))} {hasUnread && ( {conv.unreadCount > 99 ? '99+' : conv.unreadCount} )} ); } export default function ChatScreen() { const { t } = useTranslation(); const router = useRouter(); const colors = useColors(); const styles = makeStyles(colors); const queryClient = useQueryClient(); const myUserId = useAuthStore((s) => s.user?.id); const [search, setSearch] = useState(''); const [userRefreshing, setUserRefreshing] = useState(false); const [debouncedSearch, setDebouncedSearch] = useState(''); // 300ms debounce für User-Suche useEffect(() => { const t = setTimeout(() => setDebouncedSearch(search.trim()), 300); return () => clearTimeout(t); }, [search]); const { data: convs = [], isLoading: loadingDms, refetch: refetchDms, } = useQuery({ queryKey: ['dm-conversations'], queryFn: () => apiFetch('/api/chat/dm-conversations'), staleTime: 30_000, }); // Realtime: bei jeder neuen DM/Anruf (eingehend ODER von mir, auch von einem // anderen Gerät) die Konversationsliste neu laden → sie re-sortiert sich live // (neueste zuerst). Anrufe sind Rows in direct_messages (attachment_type=call), // werden also vom selben Insert-Listener mitgefangen. useEffect(() => { if (!myUserId) return; let channel: ReturnType | null = null; let cancelled = false; let reconnectTimer: ReturnType | null = null; const bump = () => { queryClient.invalidateQueries({ queryKey: ['dm-conversations'] }); }; async function subscribe() { const { data } = await supabase.auth.getSession(); if (cancelled || !data.session?.access_token) return; channel = supabase .channel(`dm-list:${myUserId}:${Date.now()}`) .on('postgres_changes', { event: 'INSERT', schema: 'rebreak', table: 'direct_messages', filter: `receiver_id=eq.${myUserId}`, }, bump) .on('postgres_changes', { event: 'INSERT', schema: 'rebreak', table: 'direct_messages', filter: `sender_id=eq.${myUserId}`, }, bump) .subscribe((status: string) => { if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') { if (channel) { supabase.removeChannel(channel); channel = null; } if (reconnectTimer) clearTimeout(reconnectTimer); reconnectTimer = setTimeout(() => { if (!cancelled) subscribe(); }, 3000); } }); } subscribe(); return () => { cancelled = true; if (reconnectTimer) clearTimeout(reconnectTimer); if (channel) supabase.removeChannel(channel); }; }, [myUserId, queryClient]); const handleRefresh = useCallback(async () => { setUserRefreshing(true); try { await refetchDms(); } finally { setUserRefreshing(false); } }, [refetchDms]); // Newest-first: der Server liefert nach partner_id sortiert (DISTINCT ON), NICHT // nach Aktualität → hier client-seitig nach lastMessageAt absteigend sortieren. const sorted = [...convs].sort( (a, b) => new Date(b.lastMessageAt).getTime() - new Date(a.lastMessageAt).getTime(), ); const filtered = search.trim() ? sorted.filter((c) => c.partnerName.toLowerCase().includes(search.toLowerCase()) || c.lastMessage.toLowerCase().includes(search.toLowerCase()), ) : sorted; // Zweite Stufe: User-Suche (nur wenn Suchbegriff ≥ 2 Zeichen) const { data: userResults = [], isFetching: searchingUsers, } = useQuery<{ id: string; nickname: string; avatar: string | null }[]>({ queryKey: ['user-search', debouncedSearch], queryFn: () => apiFetch(`/api/users/search?q=${encodeURIComponent(debouncedSearch)}`), enabled: debouncedSearch.length >= 2, staleTime: 10_000, }); // Bereits gechattet? User-Resultate ohne aktive Conversations zeigen const existingPartnerIds = new Set(convs.map((c) => c.partnerId)); const newUsers = userResults.filter((u) => !existingPartnerIds.has(u.id)); const openDm = useCallback( (userId: string) => { router.push(`/dm?userId=${userId}`); }, [router], ); return ( {search.length > 0 && ( setSearch('')} activeOpacity={0.7} hitSlop={8}> )} item.partnerId} refreshControl={ } ListEmptyComponent={ loadingDms ? ( ) : ( {t('chat.no_chats')} ) } renderItem={({ item }) => openDm(item.partnerId)} />} ListFooterComponent={ debouncedSearch.length >= 2 ? ( {t('chat.new_conversation')} {searchingUsers && } {newUsers.length === 0 && !searchingUsers ? ( {t('chat.no_users_found')} ) : ( newUsers.map((u) => ( openDm(u.id)} activeOpacity={0.7} > {u.nickname} {t('chat.start_conversation')} )) )} ) : ( ) } contentContainerStyle={{ flexGrow: 1 }} /> ); } function makeStyles(colors: ReturnType) { return StyleSheet.create({ container: { flex: 1, backgroundColor: colors.bg }, headerSection: { paddingHorizontal: 16, paddingTop: 12, paddingBottom: 10, backgroundColor: colors.bg, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: colors.border, }, searchRow: { flexDirection: 'row', alignItems: 'center', backgroundColor: colors.surfaceElevated, borderRadius: 999, paddingHorizontal: 16, paddingVertical: 8, }, searchIcon: { marginRight: 8 }, searchInput: { flex: 1, fontSize: 14, fontFamily: 'Nunito_500Medium', color: colors.text, paddingVertical: 0, }, emptyBox: { alignItems: 'center', justifyContent: 'center', paddingVertical: 40, paddingHorizontal: 32, }, emptyText: { fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted, marginTop: 12, }, newConvHeader: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingHorizontal: 16, paddingTop: 20, paddingBottom: 8, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.border, }, newConvLabel: { fontSize: 12, fontFamily: 'Nunito_700Bold', textTransform: 'uppercase', letterSpacing: 0.5, flex: 1, }, dmRow: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 16, paddingVertical: 12, backgroundColor: colors.bg, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: colors.border, minHeight: 68, }, dmInfo: { flex: 1, minWidth: 0 }, dmHeaderRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }, dmName: { fontSize: 15, fontFamily: 'Nunito_700Bold', color: colors.text, flexShrink: 1, marginRight: 6, }, dmTime: { fontSize: 11, fontFamily: 'Nunito_500Medium' }, dmBottomRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginTop: 2, }, dmLast: { fontSize: 12, flex: 1 }, unreadBadge: { minWidth: 20, height: 20, paddingHorizontal: 6, borderRadius: 10, backgroundColor: colors.brandOrange, alignItems: 'center', justifyContent: 'center', marginLeft: 8, }, unreadBadgeText: { fontSize: 10, fontFamily: 'Nunito_700Bold', color: '#fff', }, }); }