Replace custom gesture handling with PagerView component for smoother tab switching on home screen. Add verification required redirects in API and WebSocket services that automatically navigate users to the verification guide when receiving VERIFICATION_REQUIRED error codes. BREAKING CHANGE: API and WS services now require router to be set via setExpoRouter/setRouter for navigation to work properly
263 lines
8.4 KiB
TypeScript
263 lines
8.4 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
||
import { AppState, AppStateStatus, Platform, View, ActivityIndicator } from 'react-native';
|
||
import { Stack, useRouter } from 'expo-router';
|
||
import { StatusBar } from 'expo-status-bar';
|
||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||
import { PaperProvider } from 'react-native-paper';
|
||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||
import * as Notifications from 'expo-notifications';
|
||
import * as SystemUI from 'expo-system-ui';
|
||
|
||
import { registerNotificationPresentationHandler } from '../src/services/notificationPreferences';
|
||
import { api } from '../src/services/api';
|
||
import { wsService } from '../src/services/wsService';
|
||
import {
|
||
ThemeBootstrap,
|
||
useAppColors,
|
||
usePaperThemeFromStore,
|
||
useResolvedColorScheme,
|
||
} from '../src/theme';
|
||
import { AppBackButton } from '../src/components/common';
|
||
import AppPromptBar from '../src/components/common/AppPromptBar';
|
||
import AppDialogHost from '../src/components/common/AppDialogHost';
|
||
import { installAlertOverride } from '../src/services/alertOverride';
|
||
import { useAuthStore } from '../src/stores';
|
||
import { checkForAPKUpdate } from '../src/services/apkUpdateService';
|
||
import { CallScreen, IncomingCallModal, FloatingCallWindow } from '../src/components/call';
|
||
|
||
registerNotificationPresentationHandler();
|
||
|
||
const queryClient = new QueryClient({
|
||
defaultOptions: {
|
||
queries: {
|
||
retry: 2,
|
||
staleTime: 5 * 60 * 1000,
|
||
},
|
||
},
|
||
});
|
||
|
||
installAlertOverride();
|
||
|
||
if (Platform.OS === 'web' && typeof document !== 'undefined') {
|
||
const style = document.createElement('style');
|
||
style.textContent = `
|
||
input:focus, textarea:focus, select:focus {
|
||
outline: none !important;
|
||
outline-width: 0 !important;
|
||
box-shadow: none !important;
|
||
}
|
||
input:focus-visible, textarea:focus-visible {
|
||
outline: none !important;
|
||
}
|
||
*:focus { outline: none !important; }
|
||
*:focus-visible { outline: none !important; }
|
||
`;
|
||
document.head.appendChild(style);
|
||
}
|
||
|
||
function SystemChrome() {
|
||
const colors = useAppColors();
|
||
useEffect(() => {
|
||
void SystemUI.setBackgroundColorAsync(colors.background.default);
|
||
if (Platform.OS === 'web' && typeof document !== 'undefined') {
|
||
const meta = document.querySelector('meta[name="theme-color"]');
|
||
if (meta) {
|
||
meta.setAttribute('content', colors.background.default);
|
||
}
|
||
}
|
||
}, [colors.background.default]);
|
||
return null;
|
||
}
|
||
|
||
function SessionGate({ children }: { children: React.ReactNode }) {
|
||
const [ready, setReady] = useState(false);
|
||
const fetchCurrentUser = useAuthStore((s) => s.fetchCurrentUser);
|
||
const colors = useAppColors();
|
||
|
||
useEffect(() => {
|
||
fetchCurrentUser().finally(() => setReady(true));
|
||
}, [fetchCurrentUser]);
|
||
|
||
if (!ready) {
|
||
return (
|
||
<View
|
||
style={{
|
||
flex: 1,
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
backgroundColor: colors.background.default,
|
||
}}
|
||
>
|
||
<ActivityIndicator size="large" color={colors.primary.main} />
|
||
</View>
|
||
);
|
||
}
|
||
return <>{children}</>;
|
||
}
|
||
|
||
function NotificationBootstrap() {
|
||
const appState = useRef<AppStateStatus>(AppState.currentState);
|
||
const notificationResponseListener = useRef<Notifications.EventSubscription | null>(null);
|
||
|
||
useEffect(() => {
|
||
const initNotifications = async () => {
|
||
const { loadNotificationPreferences } = await import('../src/services/notificationPreferences');
|
||
await loadNotificationPreferences();
|
||
const { systemNotificationService } = await import('../src/services/systemNotificationService');
|
||
await systemNotificationService.initialize();
|
||
const { initBackgroundService } = await import('../src/services/backgroundService');
|
||
await initBackgroundService();
|
||
|
||
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
||
if (appState.current.match(/inactive|background/) && nextAppState === 'active') {
|
||
systemNotificationService.clearBadge();
|
||
}
|
||
appState.current = nextAppState;
|
||
});
|
||
|
||
notificationResponseListener.current = Notifications.addNotificationResponseReceivedListener(
|
||
(response) => {
|
||
void response.notification.request.content.data;
|
||
}
|
||
);
|
||
|
||
const notificationReceivedSubscription = Notifications.addNotificationReceivedListener(
|
||
(notification) => {
|
||
void notification;
|
||
}
|
||
);
|
||
|
||
return () => {
|
||
subscription.remove();
|
||
notificationResponseListener.current?.remove();
|
||
notificationReceivedSubscription.remove();
|
||
};
|
||
};
|
||
|
||
initNotifications();
|
||
}, []);
|
||
|
||
return null;
|
||
}
|
||
|
||
function APKUpdateBootstrap() {
|
||
const hasChecked = useRef(false);
|
||
|
||
useEffect(() => {
|
||
if (hasChecked.current) return;
|
||
hasChecked.current = true;
|
||
|
||
// 延迟执行,避免与启动流程冲突
|
||
const timer = setTimeout(() => {
|
||
checkForAPKUpdate().catch((error) => {
|
||
console.error('APK update check failed:', error);
|
||
});
|
||
}, 3000);
|
||
|
||
return () => clearTimeout(timer);
|
||
}, []);
|
||
|
||
return null;
|
||
}
|
||
|
||
function ThemedStack() {
|
||
const router = useRouter();
|
||
const colors = useAppColors();
|
||
const resolved = useResolvedColorScheme();
|
||
|
||
useEffect(() => {
|
||
api.setExpoRouter(router);
|
||
wsService.setRouter(router);
|
||
}, [router]);
|
||
|
||
return (
|
||
<>
|
||
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
|
||
<SystemChrome />
|
||
<SessionGate>
|
||
<NotificationBootstrap />
|
||
<APKUpdateBootstrap />
|
||
<Stack screenOptions={{ headerShown: false }}>
|
||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
|
||
<Stack.Screen name="(app)" options={{ headerShown: false }} />
|
||
<Stack.Screen
|
||
name="terms"
|
||
options={{
|
||
headerShown: true,
|
||
title: '用户协议',
|
||
headerStyle: { backgroundColor: colors.background.paper },
|
||
headerTintColor: colors.text.primary,
|
||
headerShadowVisible: false,
|
||
headerBackVisible: false,
|
||
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
|
||
}}
|
||
/>
|
||
<Stack.Screen
|
||
name="privacy"
|
||
options={{
|
||
headerShown: true,
|
||
title: '隐私政策',
|
||
headerStyle: { backgroundColor: colors.background.paper },
|
||
headerTintColor: colors.text.primary,
|
||
headerShadowVisible: false,
|
||
headerBackVisible: false,
|
||
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
|
||
}}
|
||
/>
|
||
<Stack.Screen
|
||
name="post/[postId]"
|
||
options={{
|
||
headerShown: true,
|
||
title: '',
|
||
// 与 PostDetailScreen 的 SafeAreaView(background.default)同色,避免出现 header/内容之间的色差线
|
||
headerStyle: { backgroundColor: colors.background.default },
|
||
headerTintColor: colors.text.primary,
|
||
headerShadowVisible: false,
|
||
headerBackVisible: false,
|
||
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
|
||
}}
|
||
/>
|
||
<Stack.Screen
|
||
name="user/[userId]"
|
||
options={{
|
||
headerShown: true,
|
||
title: '用户主页',
|
||
headerStyle: { backgroundColor: colors.background.paper },
|
||
headerTintColor: colors.text.primary,
|
||
headerBackVisible: false,
|
||
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
|
||
}}
|
||
/>
|
||
</Stack>
|
||
</SessionGate>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function ThemedProviders({ children }: { children: React.ReactNode }) {
|
||
const theme = usePaperThemeFromStore();
|
||
return <PaperProvider theme={theme}>{children}</PaperProvider>;
|
||
}
|
||
|
||
export default function RootLayout() {
|
||
return (
|
||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||
<SafeAreaProvider>
|
||
<ThemedProviders>
|
||
<QueryClientProvider client={queryClient}>
|
||
<ThemeBootstrap />
|
||
<ThemedStack />
|
||
<AppPromptBar />
|
||
<AppDialogHost />
|
||
<IncomingCallModal />
|
||
<CallScreen />
|
||
<FloatingCallWindow />
|
||
</QueryClientProvider>
|
||
</ThemedProviders>
|
||
</SafeAreaProvider>
|
||
</GestureHandlerRootView>
|
||
);
|
||
}
|