- 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)
204 lines
7.5 KiB
TypeScript
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>
|
|
);
|
|
}
|