- 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)
315 lines
10 KiB
TypeScript
315 lines
10 KiB
TypeScript
import { useEffect } from 'react';
|
|
import { AppState, I18nManager, Platform } 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 via regulärem Push (Android-Pfad / iOS-Fallback wenn
|
|
// kein voipToken). Auf iOS gilt: VoIP-PushKit ist der einzige Pfad
|
|
// der die App im Background wachrüttelt — und der landet NICHT hier
|
|
// (geht via AppDelegate → useCallKeepEvents). Wenn der User stattdessen
|
|
// einen verpassten-Anruf-Push tappt, ist der Call längst beendet —
|
|
// wir würden ihn künstlich auferwecken und die App zeigt einen Geist-
|
|
// /call-Screen. Deshalb auf iOS hier nichts tun.
|
|
if (Platform.OS === 'ios') return;
|
|
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>
|
|
);
|
|
}
|