chahinebrini fb2d90b947 fix(calls): no duplicate incoming-call notifications
- backend: skip Expo alert push to iOS devices that already received VoIP push
  (CallKit + banner = double ring)
- native: receiveIncoming no longer triggers InCallManager.startRingtone —
  CallKit/ConnectionService play their own ring. Dedup if same callId
  arrives twice (Realtime + VoIP-Push race).
2026-06-04 18:28:00 +02:00

414 lines
14 KiB
TypeScript

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 (
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
<View style={styles.dmRow}>
<UserAvatar
userId={conv.partnerId}
avatar={conv.partnerAvatar}
nickname={conv.partnerName}
size="md"
/>
<View style={styles.dmInfo}>
<View style={styles.dmHeaderRow}>
<Text style={styles.dmName} numberOfLines={1}>
{conv.partnerName}
</Text>
<Text style={[styles.dmTime, { color: hasUnread ? colors.brandOrange : colors.textMuted }]}>
{formatTime(conv.lastMessageAt, t('chat.just_now'))}
</Text>
</View>
<View style={styles.dmBottomRow}>
<Text
numberOfLines={1}
style={[
styles.dmLast,
{
fontFamily: hasUnread ? 'Nunito_600SemiBold' : 'Nunito_400Regular',
color: hasUnread ? colors.text : colors.textMuted,
},
]}
>
{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'))}
</Text>
{hasUnread && (
<View style={styles.unreadBadge}>
<Text style={styles.unreadBadgeText}>
{conv.unreadCount > 99 ? '99+' : conv.unreadCount}
</Text>
</View>
)}
</View>
</View>
</View>
</TouchableOpacity>
);
}
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<DmConversation[]>({
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<typeof supabase.channel> | null = null;
let cancelled = false;
let reconnectTimer: ReturnType<typeof setTimeout> | 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 (
<View style={styles.container}>
<AppHeader />
<View style={styles.headerSection}>
<View style={styles.searchRow}>
<Ionicons name="search-outline" size={16} color={colors.textMuted} style={styles.searchIcon} />
<TextInput
style={styles.searchInput}
value={search}
onChangeText={setSearch}
placeholder={t('chat.search_placeholder')}
placeholderTextColor={colors.textMuted}
returnKeyType="search"
autoCorrect={false}
autoCapitalize="none"
/>
{search.length > 0 && (
<TouchableOpacity onPress={() => setSearch('')} activeOpacity={0.7} hitSlop={8}>
<Ionicons name="close-circle" size={16} color={colors.textMuted} />
</TouchableOpacity>
)}
</View>
</View>
<FlatList
data={loadingDms ? [] : filtered}
keyExtractor={(item) => item.partnerId}
refreshControl={
<RefreshControl
refreshing={userRefreshing}
onRefresh={handleRefresh}
tintColor={colors.brandOrange}
/>
}
ListEmptyComponent={
loadingDms ? (
<View style={styles.emptyBox}>
<ActivityIndicator color={colors.brandOrange} />
</View>
) : (
<View style={styles.emptyBox}>
<Ionicons name="chatbubble-ellipses-outline" size={42} color="#d4d4d4" />
<Text style={styles.emptyText}>{t('chat.no_chats')}</Text>
</View>
)
}
renderItem={({ item }) => <DmItem conv={item} onPress={() => openDm(item.partnerId)} />}
ListFooterComponent={
debouncedSearch.length >= 2 ? (
<View style={{ paddingBottom: 100 }}>
<View style={styles.newConvHeader}>
<Text style={[styles.newConvLabel, { color: colors.textMuted }]}>
{t('chat.new_conversation')}
</Text>
{searchingUsers && <ActivityIndicator size="small" color={colors.brandOrange} />}
</View>
{newUsers.length === 0 && !searchingUsers ? (
<View style={styles.emptyBox}>
<Text style={styles.emptyText}>{t('chat.no_users_found')}</Text>
</View>
) : (
newUsers.map((u) => (
<TouchableOpacity
key={u.id}
onPress={() => openDm(u.id)}
activeOpacity={0.7}
>
<View style={styles.dmRow}>
<UserAvatar userId={u.id} avatar={u.avatar} nickname={u.nickname} size="md" />
<View style={styles.dmInfo}>
<Text style={styles.dmName} numberOfLines={1}>{u.nickname}</Text>
<Text style={[styles.dmLast, { fontFamily: 'Nunito_400Regular', color: colors.textMuted }]}>
{t('chat.start_conversation')}
</Text>
</View>
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
</View>
</TouchableOpacity>
))
)}
</View>
) : (
<View style={{ paddingBottom: 100 }} />
)
}
contentContainerStyle={{ flexGrow: 1 }}
/>
</View>
);
}
function makeStyles(colors: ReturnType<typeof useColors>) {
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',
},
});
}