Files
frontend/app/_layout.tsx
lafay b7ce9e3b9a
Some checks failed
Frontend CI / ota-android (push) Successful in 1m27s
Frontend CI / build-and-push-web (push) Has been cancelled
Frontend CI / build-android-apk (push) Has been cancelled
feat(notification): integrate expo-notifications for permission handling and deep linking
- Add expo-notifications dependency and configure with JPush plugin
- Request notification permission on app launch for iOS/Android
- Handle notification taps to navigate to chat or notifications screen
- Add UI to check and request system notification permission in settings
- Refactor background sync: WebSocket disconnects in background, JPush handles push
- Add checkNotificationPermission() and requestNotificationPermission() to jpushService
2026-04-28 00:20:07 +08:00

323 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
const permissionRequested = useRef(false);
const router = useRouter();
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 — navigate to the relevant screen
jpushService.onNotification((message) => {
console.log('[NotificationBootstrap] JPush notification:', message.notificationEventType, message);
if (message.notificationEventType !== 'notificationOpened') return;
const extras = message.extras || {};
const notifType = extras.notification_type || message.notificationType;
if (notifType === 'chat_message' && extras.conversation_id) {
const isGroup = extras.conversation_type === 'group';
router.push(
`/chat/${encodeURIComponent(extras.conversation_id)}${
isGroup ? '?isGroupChat=1' : extras.sender_id ? `?userId=${encodeURIComponent(extras.sender_id)}` : ''
}`
);
} else {
router.push('/messages/notifications');
}
});
return () => {
subscription.remove();
};
};
const requestPermissionOnLaunch = async () => {
if (permissionRequested.current) return;
permissionRequested.current = true;
if (Platform.OS === 'web') return;
try {
const hasPermission = await jpushService.checkNotificationPermission();
if (!hasPermission) {
await jpushService.requestNotificationPermission();
}
} catch (error) {
console.warn('[NotificationBootstrap] request permission on launch failed:', error);
}
};
initNotifications();
requestPermissionOnLaunch();
}, []);
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 的 SafeAreaViewbackground.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>
);
}