Caller/Callee UX: - lib/ringback.ts + assets/sounds/ringback_eu.mp3 (EU 425Hz Festnetz-Tone) - stores/call.ts: stopRingback bei connected, hangup-reasons, logCallToChat fix - locales: 'Wird angerufen…' statt 'Ruft an…' CallKit (iOS) + ConnectionService (Android): - lib/callkit.ts: setupCallKeep, displayIncomingCall, startOutgoingCall, reportConnected/Ended (appName 'ReBreak-Audio', includesCallsInRecents=false für DSGVO/DiGA) - hooks/useCallKeepEvents.ts: native answer/end/mute → useCallStore-Actions - stores/call.ts: CallKit-Aufrufe an allen lifecycle-Punkten - app.config.ts: @config-plugins/react-native-callkeep + UIBackgroundModes voip/audio + Android-Telecom-Perms VoIP-PushKit Backend: - services/voip-push.ts: @parse/node-apn Provider mit .p12 (Topic org.rebreak.app.voip) - services/push.ts sendCallRingPush: feuert beide Pfade (VoIP iOS + Expo Android/Fallback) - prisma: push_tokens.voip_token Column + Migration 20260604 - api/users/me/push-token: optional voipToken im Body - Env (Infisical): APNS_VOIP_P12_PATH/PASSWORD/TOPIC/PRODUCTION Push-tap routing + cold-start handling: - app/_layout.tsx: type:'call' Push → useCallStore.receiveIncoming + /call Docs: ops/CALLKIT_SETUP.md (Apple-Portal-Steps für VoIP-Cert)
312 lines
10 KiB
TypeScript
312 lines
10 KiB
TypeScript
import { useEffect } from 'react';
|
|
import { AppState, I18nManager } from 'react-native';
|
|
|
|
I18nManager.allowRTL(true);
|
|
import { Stack, router } from 'expo-router';
|
|
|
|
import { StatusBar } from 'expo-status-bar';
|
|
import * as Notifications from 'expo-notifications';
|
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
|
import { KeyboardProvider } from 'react-native-keyboard-controller';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
|
import { ActionSheetProvider } from '@expo/react-native-action-sheet';
|
|
import * as SplashScreen from 'expo-splash-screen';
|
|
import {
|
|
useFonts,
|
|
Nunito_400Regular,
|
|
Nunito_600SemiBold,
|
|
Nunito_700Bold,
|
|
Nunito_800ExtraBold,
|
|
} from '@expo-google-fonts/nunito';
|
|
import { supabase } from '../lib/supabase';
|
|
import { useAuthStore } from '../stores/auth';
|
|
import { useThemeStore } from '../stores/theme';
|
|
import { useRealtimeDebugStore } from '../stores/realtimeDebug';
|
|
import { useColors } from '../lib/theme';
|
|
import { useLanguageStore } from '../stores/language';
|
|
import { useAppLockStore } from '../stores/appLock';
|
|
import { useLyraVoiceStore } from '../stores/lyraVoice';
|
|
import { AppLockGate } from '../components/AppLockGate';
|
|
import { DeviceLimitReachedSheet } from '../components/DeviceLimitReachedSheet';
|
|
import { DeviceApprovalIncomingSheet } from '../components/DeviceApprovalIncomingSheet';
|
|
import { DeviceApprovalPendingSheet } from '../components/DeviceApprovalPendingSheet';
|
|
import { useDeviceApprovalRealtime } from '../hooks/useDeviceApprovalRealtime';
|
|
import { useDevicesStore } from '../stores/devices';
|
|
import { OnlinePresenceProvider } from '../components/OnlinePresenceProvider';
|
|
import { usePushTokenRegistration } from '../hooks/usePushTokenRegistration';
|
|
import { useIncomingCalls } from '../hooks/useIncomingCalls';
|
|
import { useCallStore } from '../stores/call';
|
|
import { useCallKeepEvents } from '../hooks/useCallKeepEvents';
|
|
import '../lib/i18n'; // i18next-Init via Side-Effect
|
|
import '../global.css';
|
|
|
|
SplashScreen.preventAutoHideAsync();
|
|
|
|
Notifications.setNotificationHandler({
|
|
handleNotification: async () => ({
|
|
shouldShowBanner: true,
|
|
shouldShowList: true,
|
|
shouldPlaySound: true,
|
|
shouldSetBadge: false,
|
|
}),
|
|
});
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
retry: 2,
|
|
staleTime: 1000 * 60,
|
|
},
|
|
},
|
|
});
|
|
|
|
function RootLayoutInner() {
|
|
const { loading, init, user } = useAuthStore();
|
|
const initTheme = useThemeStore((s) => s.init);
|
|
const colorScheme = useThemeStore((s) => s.colorScheme);
|
|
const initLanguage = useLanguageStore((s) => s.init);
|
|
const initAppLock = useAppLockStore((s) => s.init);
|
|
const initLyraVoice = useLyraVoiceStore((s) => s.init);
|
|
const appLockReady = useAppLockStore((s) => s.ready);
|
|
const initRealtimeDebug = useRealtimeDebugStore((s) => s.init);
|
|
const colors = useColors();
|
|
const [fontsLoaded] = useFonts({
|
|
Nunito_400Regular,
|
|
Nunito_600SemiBold,
|
|
Nunito_700Bold,
|
|
Nunito_800ExtraBold,
|
|
});
|
|
|
|
// Push-Token-Registration nach Login (idempotent)
|
|
usePushTokenRegistration(user?.id);
|
|
|
|
// Eingehende Voice-Calls (Ring-Channel) — zeigt den Call-Screen wenn jemand
|
|
// anruft. Foreground-only (Phase 1).
|
|
useIncomingCalls(user?.id);
|
|
|
|
// CallKit/ConnectionService Event-Bridge — mappt native UI-Actions
|
|
// (Accept/Decline/Hangup im Lockscreen-CallKit-UI) auf useCallStore.
|
|
useCallKeepEvents();
|
|
|
|
// Apple-Style Device-Approval Realtime — lauscht auf neue Approval-Requests
|
|
// für diesen User und zeigt das Incoming-Sheet wenn ein anderes Gerät
|
|
// sich anmelden möchte.
|
|
useDeviceApprovalRealtime(!!user?.id);
|
|
|
|
// Push-Tap-Deep-Link: User tippt Notification → navigate zu Chat / Call
|
|
useEffect(() => {
|
|
const handle = (response: Notifications.NotificationResponse | null | undefined) => {
|
|
if (!response) return;
|
|
const data = response.notification.request.content.data as
|
|
| {
|
|
type?: 'dm' | 'room' | 'call';
|
|
targetId?: string;
|
|
callId?: string;
|
|
from?: { id: string; nickname: string; avatar: string | null };
|
|
}
|
|
| undefined;
|
|
if (!data?.type) return;
|
|
if (data.type === 'dm' && data.targetId) {
|
|
router.push({ pathname: '/dm', params: { userId: data.targetId } });
|
|
} else if (data.type === 'room' && data.targetId) {
|
|
router.push({ pathname: '/room', params: { roomId: data.targetId } });
|
|
} else if (data.type === 'call' && data.callId && data.from) {
|
|
// Eingehender Anruf — Realtime hat (vermutlich) keine Subscription
|
|
// gehabt weil App im Background war. Wir simulieren receiveIncoming
|
|
// damit der Standard-Accept/Decline-Flow greift. Falls der Caller in
|
|
// der Zwischenzeit aufgelegt hat: ring-cancel kommt sobald Channel
|
|
// subscribed, dann teardown.
|
|
try {
|
|
useCallStore.getState().receiveIncoming(data.callId, data.from);
|
|
router.push('/call');
|
|
} catch {
|
|
// ignore — Call evtl. schon beendet
|
|
}
|
|
}
|
|
};
|
|
const sub = Notifications.addNotificationResponseReceivedListener(handle);
|
|
// Cold-Start: App wurde durch Notification-Tap geöffnet
|
|
Notifications.getLastNotificationResponseAsync().then(handle).catch(() => {});
|
|
return () => sub.remove();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
init();
|
|
initTheme();
|
|
initLanguage();
|
|
initAppLock();
|
|
initLyraVoice();
|
|
if (__DEV__) initRealtimeDebug();
|
|
}, []);
|
|
|
|
// Supabase-Doku-Pattern für RN: Token-Auto-Refresh nur wenn App aktiv ist.
|
|
// Plus Foreground-Reconnect via onAuthStateChange (TOKEN_REFRESHED →
|
|
// realtime.setAuth wird intern getriggert). Fixt den Realtime-Disconnect-Bug
|
|
// bei lange eingeloggten Usern (siehe `project_session_2026-05-15_push.md`).
|
|
useEffect(() => {
|
|
const sub = AppState.addEventListener('change', (state) => {
|
|
if (state === 'active') {
|
|
supabase.auth.startAutoRefresh();
|
|
} else {
|
|
supabase.auth.stopAutoRefresh();
|
|
}
|
|
});
|
|
return () => sub.remove();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (fontsLoaded && !loading && appLockReady) {
|
|
SplashScreen.hideAsync();
|
|
}
|
|
}, [fontsLoaded, loading, appLockReady]);
|
|
|
|
if (!fontsLoaded || loading || !appLockReady) {
|
|
// Nativer expo-splash-screen bleibt sichtbar bis SplashScreen.hideAsync()
|
|
// im Effect oben aufgerufen wird → kein Flicker durch zusätzlichen
|
|
// React-Splash mehr (User-Feedback: "geht sehr schnell vorbei und zuckt")
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<OnlinePresenceProvider>
|
|
<AppLockGate>
|
|
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
|
|
<DeviceLimitReachedSheet />
|
|
<DeviceApprovalIncomingSheet />
|
|
<DeviceApprovalPendingSheet
|
|
onApproved={() => {
|
|
// Slot wurde freigegeben — register retry
|
|
useDevicesStore.getState().ensureRegistered().catch(() => {});
|
|
}}
|
|
/>
|
|
<Stack
|
|
screenOptions={{
|
|
headerShown: false,
|
|
animation: 'slide_from_right',
|
|
contentStyle: { backgroundColor: colors.bg },
|
|
}}
|
|
>
|
|
<Stack.Screen name="index" />
|
|
<Stack.Screen name="(auth)" />
|
|
<Stack.Screen name="(app)" />
|
|
<Stack.Screen
|
|
name="lyra"
|
|
options={{
|
|
headerShown: false,
|
|
presentation: 'card',
|
|
animation: 'slide_from_right',
|
|
}}
|
|
/>
|
|
<Stack.Screen
|
|
name="urge"
|
|
options={{
|
|
headerShown: false,
|
|
presentation: 'card',
|
|
animation: 'slide_from_bottom',
|
|
}}
|
|
/>
|
|
<Stack.Screen
|
|
name="call"
|
|
options={{
|
|
headerShown: false,
|
|
presentation: 'fullScreenModal',
|
|
animation: 'slide_from_bottom',
|
|
gestureEnabled: false, // kein versehentliches Swipe-Dismiss im Call
|
|
}}
|
|
/>
|
|
<Stack.Screen
|
|
name="dm"
|
|
options={{
|
|
headerShown: false,
|
|
presentation: 'card',
|
|
animation: 'slide_from_right',
|
|
}}
|
|
/>
|
|
<Stack.Screen
|
|
name="settings"
|
|
options={{
|
|
headerShown: false,
|
|
presentation: 'card',
|
|
animation: 'slide_from_right',
|
|
}}
|
|
/>
|
|
<Stack.Screen
|
|
name="profile/index"
|
|
options={{
|
|
headerShown: false,
|
|
presentation: 'card',
|
|
animation: 'slide_from_right',
|
|
}}
|
|
/>
|
|
<Stack.Screen
|
|
name="profile/[userId]"
|
|
options={{
|
|
headerShown: false,
|
|
presentation: 'card',
|
|
animation: 'slide_from_right',
|
|
}}
|
|
/>
|
|
<Stack.Screen
|
|
name="games"
|
|
options={{
|
|
headerShown: false,
|
|
presentation: 'card',
|
|
animation: 'slide_from_right',
|
|
}}
|
|
/>
|
|
<Stack.Screen
|
|
name="debug"
|
|
options={{
|
|
headerShown: false,
|
|
presentation: 'card',
|
|
animation: 'slide_from_right',
|
|
}}
|
|
/>
|
|
<Stack.Screen
|
|
name="help"
|
|
options={{
|
|
headerShown: false,
|
|
presentation: 'card',
|
|
animation: 'slide_from_right',
|
|
}}
|
|
/>
|
|
<Stack.Screen
|
|
name="magic"
|
|
options={{
|
|
headerShown: false,
|
|
presentation: 'card',
|
|
animation: 'slide_from_right',
|
|
}}
|
|
/>
|
|
<Stack.Screen
|
|
name="onboarding/index"
|
|
options={{
|
|
headerShown: false,
|
|
presentation: 'card',
|
|
animation: 'fade',
|
|
gestureEnabled: false,
|
|
}}
|
|
/>
|
|
</Stack>
|
|
</AppLockGate>
|
|
</OnlinePresenceProvider>
|
|
);
|
|
}
|
|
|
|
export default function RootLayout() {
|
|
return (
|
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
|
<KeyboardProvider>
|
|
<QueryClientProvider client={queryClient}>
|
|
<ActionSheetProvider>
|
|
<SafeAreaProvider>
|
|
<RootLayoutInner />
|
|
</SafeAreaProvider>
|
|
</ActionSheetProvider>
|
|
</QueryClientProvider>
|
|
</KeyboardProvider>
|
|
</GestureHandlerRootView>
|
|
);
|
|
}
|