Root cause: iOS CallKit auto-dismisses incoming-call UI after ~5s when the
app is in foreground (because AppDelegate.didReceiveIncomingPush MUST call
reportNewIncomingCall — Apple requirement). That CallKit dismiss fires an
endCall event which our useCallKeepEvents.onEnd translated to declineCall,
unmounting the in-app /call screen before the user could tap accept/decline.
Fixes:
- useCallKeepEvents.onEnd: ignore CallKit endCall when iOS app is foreground
AND status==='incoming' (in-app UI is authoritative there). Comment with
big warning not to remove this again.
- call.tsx closeScreen: replace('/') instead of router.back() to avoid
GO_BACK action errors when navigation stack is inconsistent after long
calls (manifested as wrap-jsx.js crash in react-native-css-interop).
- useIncomingCalls: log CANCEL receive events for future diagnostics.
- call.ts: clog hangup/declineCall/closeScreen with reason+status for trace.
Verified: foreground call screen stays up the full UNANSWERED_MS (35s) and
caller-side hangup('unanswered') correctly triggers iPhone closeScreen via
cancel-broadcast.
217 lines
8.2 KiB
TypeScript
217 lines
8.2 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: Call-Screen schließen. Wir nutzen IMMER replace('/') statt back().
|
|
//
|
|
// Warum nicht router.back()?
|
|
// React-Navigation dispatched GO_BACK asynchron über seinen Reducer. Wenn
|
|
// canGoBack() zwar true zurückgibt aber der Stack-Zustand zwischenzeitlich
|
|
// (z.B. durch AppState-Change oder Tab-Switch während des Calls) inkonsistent
|
|
// geworden ist, wirft der Reducer einen GO_BACK-Action-Error — und der landet
|
|
// NICHT im try/catch um back(), sondern crasht beim nächsten Render in
|
|
// wrap-jsx.js. replace('/') ist deterministisch.
|
|
const closeScreen = (why: string) => {
|
|
const sinceMount = Date.now() - mountedAt.current;
|
|
console.log('[call-screen] closeScreen why=', why, 'sinceMount=', sinceMount, 'ms');
|
|
try {
|
|
router.replace('/');
|
|
} catch (err) {
|
|
console.warn('[call-screen] router.replace(/) threw', err);
|
|
}
|
|
};
|
|
|
|
// 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('idle-after-grace');
|
|
}, 250 - sinceMount);
|
|
return () => clearTimeout(tm);
|
|
}
|
|
closeScreen('idle');
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [status]);
|
|
|
|
// Call beendet → kurz "beendet" zeigen, dann schließen + aufräumen.
|
|
useEffect(() => {
|
|
if (status !== 'ended') return;
|
|
console.log('[call-screen] status=ended -> will close in 1300ms; endReason=', endReason);
|
|
const tm = setTimeout(() => {
|
|
clear();
|
|
closeScreen('ended-timeout');
|
|
}, 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>
|
|
);
|
|
}
|