chahinebrini 7fae4539ae diag(calls): add VoIP+push-token+ring-target logs; fix /call mount race
- AppDelegate: NSLog for didUpdate token, didInvalidate, didReceiveIncomingPush
- backend/push: log [push-token] register, [call-ring] receiver token-counts +
  expo-push-fanout for android-fallback
- app/call.tsx: 250ms grace window before closeScreen on initial idle (fixes
  'foreground call flashes briefly then disappears' race when dm.tsx
  startCall set() hasn't propagated through useCallStore selector yet)
2026-06-04 20:37:43 +02:00

204 lines
7.5 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { View, Text, TouchableOpacity, Alert, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useTranslation } from 'react-i18next';
import { useCallStore } from '../stores/call';
import { UserAvatar } from '../components/UserAvatar';
function fmtDuration(ms: number) {
const s = Math.floor(ms / 1000);
const m = Math.floor(s / 60);
return `${m}:${String(s % 60).padStart(2, '0')}`;
}
export default function CallScreen() {
const { t } = useTranslation();
const router = useRouter();
const status = useCallStore((s) => s.status);
const peer = useCallStore((s) => s.peer);
const muted = useCallStore((s) => s.muted);
const speaker = useCallStore((s) => s.speaker);
const startedAt = useCallStore((s) => s.startedAt);
const endReason = useCallStore((s) => s.endReason);
const acceptCall = useCallStore((s) => s.acceptCall);
const declineCall = useCallStore((s) => s.declineCall);
const hangup = useCallStore((s) => s.hangup);
const toggleMute = useCallStore((s) => s.toggleMute);
const toggleSpeaker = useCallStore((s) => s.toggleSpeaker);
const clear = useCallStore((s) => s._clear);
const [elapsed, setElapsed] = useState(0);
// Race-Guard: zustand-set in dm.tsx → router.push('/call') passiert manchmal
// bevor unser useCallStore-Selector den neuen status sieht. Beim allerersten
// Render kann status also kurz 'idle' sein, obwohl gerade ein Call gestartet
// wird. Wir geben dem Store 250ms Zeit bevor wir bei 'idle' den Screen
// schlie\u00dfen \u2014 sonst flickert die Call-UI sofort weg ("kurz und verschwindet").
const mountedAt = useRef(Date.now());
// Helper: zur\u00fcck oder Fallback zu Home, wenn kein Back-Stack vorhanden
// (z.B. wenn /call via VoIP-PushKit / Deep-Link als Initial-Route ge\u00f6ffnet wurde).
const closeScreen = () => {
if (router.canGoBack()) router.back();
else router.replace('/');
};
// Kein aktiver Call \u2192 Screen schlie\u00dfen.
useEffect(() => {
if (status !== 'idle') return;
const sinceMount = Date.now() - mountedAt.current;
if (sinceMount < 250) {
// Initial-Mount-Race: noch warten ob startCall/receiveIncoming den status
// gleich auf outgoing/incoming setzt.
const tm = setTimeout(() => {
if (useCallStore.getState().status === 'idle') closeScreen();
}, 250 - sinceMount);
return () => clearTimeout(tm);
}
closeScreen();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status]);
// Call beendet → kurz "beendet" zeigen, dann schließen + aufräumen.
useEffect(() => {
if (status !== 'ended') return;
const tm = setTimeout(() => {
clear();
closeScreen();
}, 1300);
return () => clearTimeout(tm);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status, clear]);
// Gesprächsdauer-Timer.
useEffect(() => {
if (status !== 'connected' || !startedAt) return;
const id = setInterval(() => setElapsed(Date.now() - startedAt), 1000);
return () => clearInterval(id);
}, [status, startedAt]);
async function onAccept() {
try {
await acceptCall();
} catch (e: any) {
if (e?.message === 'webrtc_unavailable') {
Alert.alert(t('call.title'), t('call.needs_rebuild'));
}
hangup('failed');
}
}
const subtitle =
status === 'outgoing'
? t('call.calling')
: status === 'incoming'
? t('call.incoming')
: status === 'connecting'
? t('call.connecting')
: status === 'connected'
? fmtDuration(elapsed)
: status === 'ended'
? endReason === 'declined'
? t('call.declined')
: endReason === 'unanswered'
? t('call.no_answer')
: endReason === 'failed'
? t('call.failed')
: t('call.ended')
: '';
return (
<View style={{ flex: 1 }}>
<LinearGradient colors={['#0f172a', '#1e3a34', '#0f172a']} style={StyleSheet.absoluteFill} />
<SafeAreaView style={{ flex: 1, justifyContent: 'space-between', alignItems: 'center' }}>
{/* Oben: Avatar + Name + Status */}
<View style={{ alignItems: 'center', marginTop: 72, gap: 18 }}>
<UserAvatar
userId={peer?.id ?? null}
avatar={peer?.avatar ?? null}
nickname={peer?.nickname ?? '?'}
size="xl"
/>
<Text style={{ color: '#fff', fontSize: 26, fontFamily: 'Nunito_700Bold' }} numberOfLines={1}>
{peer?.nickname ?? '…'}
</Text>
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 16, fontFamily: 'Nunito_600SemiBold', fontVariant: ['tabular-nums'] }}>
{subtitle}
</Text>
</View>
{/* Unten: Aktions-Buttons */}
<View style={{ marginBottom: 56, width: '100%', alignItems: 'center' }}>
{status === 'incoming' ? (
<View style={{ flexDirection: 'row', justifyContent: 'space-evenly', width: '100%' }}>
<CircleBtn color="#ef4444" icon="call" rotate onPress={declineCall} label={t('call.decline')} />
<CircleBtn color="#22c55e" icon="call" onPress={onAccept} label={t('call.accept')} />
</View>
) : status === 'ended' ? null : (
<View style={{ flexDirection: 'row', justifyContent: 'center', alignItems: 'center', gap: 28 }}>
{(status === 'connected' || status === 'connecting') && (
<CircleBtn
color={muted ? '#fff' : 'rgba(255,255,255,0.18)'}
iconColor={muted ? '#0f172a' : '#fff'}
icon={muted ? 'mic-off' : 'mic'}
onPress={toggleMute}
label={muted ? t('call.unmute') : t('call.mute')}
/>
)}
{(status === 'connected' || status === 'connecting' || status === 'outgoing') && (
<CircleBtn
color={speaker ? '#fff' : 'rgba(255,255,255,0.18)'}
iconColor={speaker ? '#0f172a' : '#fff'}
icon={speaker ? 'volume-high' : 'volume-medium'}
onPress={toggleSpeaker}
label={speaker ? t('call.speaker_on') : t('call.speaker_off')}
/>
)}
<CircleBtn color="#ef4444" icon="call" rotate onPress={() => hangup('ended')} label={t('call.hang_up')} />
</View>
)}
</View>
</SafeAreaView>
</View>
);
}
function CircleBtn({
color,
iconColor = '#fff',
icon,
rotate,
onPress,
label,
}: {
color: string;
iconColor?: string;
icon: keyof typeof Ionicons.glyphMap;
rotate?: boolean;
onPress: () => void;
label: string;
}) {
return (
<View style={{ alignItems: 'center', gap: 8 }}>
<TouchableOpacity
activeOpacity={0.8}
onPress={onPress}
style={{
width: 68,
height: 68,
borderRadius: 34,
backgroundColor: color,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name={icon} size={28} color={iconColor} style={rotate ? { transform: [{ rotate: '135deg' }] } : undefined} />
</TouchableOpacity>
<Text style={{ color: 'rgba(255,255,255,0.8)', fontSize: 12, fontFamily: 'Nunito_600SemiBold' }}>{label}</Text>
</View>
);
}