Replace expo-notifications with JPush for push notifications across the app. This includes adding JPush native module dependencies, configuring the JPush plugin in app.json, implementing jpushService wrapper, and updating notification services to use JPush APIs. Also adds device registration on token refresh for both mobile and web platforms. BREAKING CHANGE: expo-notifications removed in favor of JPush for push notifications
289 lines
9.1 KiB
TypeScript
289 lines
9.1 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 SystemUI from 'expo-system-ui';
|
||
import { useFonts } from 'expo-font';
|
||
|
||
import { registerNotificationPresentationHandler } from '@/services/notification';
|
||
import { api } from '@/services/core';
|
||
import { wsService } from '@/services/core';
|
||
import { EventSubscriber } from '../src/infrastructure/EventSubscriber';
|
||
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 '@/services/ui';
|
||
import { useAuthStore } from '../src/stores';
|
||
import { checkForAPKUpdate } from '@/services/platform';
|
||
import { CallScreen, IncomingCallModal, FloatingCallWindow } from '../src/components/call';
|
||
import { jpushService } from '@/services/notification/jpushService';
|
||
|
||
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; }
|
||
/* 修复移动端滑动问题 - 仅对根容器限制 */
|
||
html, body {
|
||
overscroll-behavior: none;
|
||
touch-action: manipulation;
|
||
-webkit-overflow-scrolling: touch;
|
||
}
|
||
/* React Native Web 生成的滚动容器 - 允许双向滑动 */
|
||
[class*="css-view"] {
|
||
touch-action: auto;
|
||
-webkit-overflow-scrolling: touch;
|
||
}
|
||
/* 水平滚动容器 */
|
||
[data-horizontal-scroll="true"],
|
||
div[style*="overflow-x"],
|
||
div[style*="overflow: scroll"],
|
||
div[style*="overflow: auto"] {
|
||
touch-action: pan-x pan-y;
|
||
-webkit-overflow-scrolling: touch;
|
||
}
|
||
/* 手势区域 - 允许滑动手势 */
|
||
[data-gesture-area="true"] {
|
||
touch-action: pan-x pan-y;
|
||
}
|
||
/* 图片查看器手势区域 */
|
||
.react-native-image-viewer,
|
||
[data-image-viewer="true"] {
|
||
touch-action: pinch-zoom pan-x pan-y;
|
||
}
|
||
`;
|
||
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);
|
||
|
||
useEffect(() => {
|
||
const initNotifications = async () => {
|
||
const { loadNotificationPreferences } = await import('@/services/notification');
|
||
await loadNotificationPreferences();
|
||
const { systemNotificationService } = await import('@/services/notification');
|
||
await systemNotificationService.initialize();
|
||
const { initBackgroundService } = await import('@/services/background');
|
||
await initBackgroundService();
|
||
|
||
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
||
if (appState.current.match(/inactive|background/) && nextAppState === 'active') {
|
||
systemNotificationService.clearBadge();
|
||
}
|
||
appState.current = nextAppState;
|
||
});
|
||
|
||
// Listen for JPush notification taps
|
||
jpushService.onNotification((message) => {
|
||
console.log('[NotificationBootstrap] JPush notification tapped:', message);
|
||
});
|
||
|
||
return () => {
|
||
subscription.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();
|
||
|
||
return (
|
||
<>
|
||
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
|
||
<SystemChrome />
|
||
<EventSubscriber />
|
||
<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() {
|
||
const [fontsLoaded] = useFonts({});
|
||
|
||
if (!fontsLoaded) {
|
||
return (
|
||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||
<ActivityIndicator />
|
||
</View>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||
<SafeAreaProvider>
|
||
<ThemedProviders>
|
||
<QueryClientProvider client={queryClient}>
|
||
<ThemeBootstrap />
|
||
<ThemedStack />
|
||
<AppPromptBar />
|
||
<AppDialogHost />
|
||
<IncomingCallModal />
|
||
<CallScreen />
|
||
<FloatingCallWindow />
|
||
</QueryClientProvider>
|
||
</ThemedProviders>
|
||
</SafeAreaProvider>
|
||
</GestureHandlerRootView>
|
||
);
|
||
}
|