Improve application stability and performance by optimizing Zustand store usage, implementing memoization patterns, and introducing persistent authentication. - **State Management**: - Refactor Zustand selectors to use `useShallow` and `getState()` to prevent unnecessary re-renders and infinite loops in hooks. - Implement `persist` middleware for `authStore` to maintain user sessions across restarts. - Introduce `buildStateCached` in `themeStore` to reduce redundant theme object computations. - **Performance & Rendering**: - Implement `useMemo` for stable object/array references in `ImageGallery` and list components to prevent expensive re-renders. - Replace inline arrow functions with `useCallback` in complex screens like `ChatScreen` and `MessageListScreen`. - Optimize `FlashList` usage by providing stable `key` and `extraData` props. - **Architecture**: - Decouple unread count fetching by introducing `useUnreadCountQuery` (React Query) for better caching and synchronization. - Add `useChannels` hook to centralize channel data fetching. - Refine `SessionGate` logic to allow immediate rendering of authenticated users while verifying in the background.
319 lines
10 KiB
TypeScript
319 lines
10 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 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 isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
|
const fetchCurrentUser = useAuthStore((s) => s.fetchCurrentUser);
|
|
const colors = useAppColors();
|
|
const [verified, setVerified] = useState(false);
|
|
|
|
useEffect(() => {
|
|
fetchCurrentUser().finally(() => setVerified(true));
|
|
}, [fetchCurrentUser]);
|
|
|
|
// If persisted state shows authenticated, render immediately (background verify)
|
|
// If not authenticated and not yet verified, show loading
|
|
if (!isAuthenticated && !verified) {
|
|
return (
|
|
<View
|
|
style={{
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
backgroundColor: colors.background.default,
|
|
}}
|
|
>
|
|
<ActivityIndicator size="large" color={colors.primary.main} />
|
|
</View>
|
|
);
|
|
}
|
|
// If verified and not authenticated, the Redirect in (app)/_layout.tsx handles it
|
|
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' || notifType === 'chat') {
|
|
const conversationId = extras.conversation_id || extras.conversationId;
|
|
const senderId = extras.sender_id || extras.senderId;
|
|
const conversationType = extras.conversation_type || extras.conversationType;
|
|
const groupId = extras.group_id || extras.groupId;
|
|
const groupName = extras.group_name || extras.groupName;
|
|
const groupAvatar = extras.group_avatar || extras.groupAvatar;
|
|
|
|
if (conversationId) {
|
|
const isGroup = conversationType === 'group';
|
|
const q = new URLSearchParams();
|
|
if (isGroup) {
|
|
q.set('isGroupChat', '1');
|
|
if (groupId) q.set('groupId', groupId);
|
|
if (groupName) q.set('groupName', groupName);
|
|
if (groupAvatar) q.set('groupAvatar', groupAvatar);
|
|
} else if (senderId) {
|
|
q.set('userId', senderId);
|
|
}
|
|
const qs = q.toString();
|
|
router.push(`/chat/${encodeURIComponent(conversationId)}${qs ? `?${qs}` : ''}`);
|
|
} else {
|
|
router.push('/messages/notifications');
|
|
}
|
|
} 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 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: false,
|
|
}}
|
|
/>
|
|
<Stack.Screen
|
|
name="privacy"
|
|
options={{
|
|
headerShown: false,
|
|
}}
|
|
/>
|
|
<Stack.Screen
|
|
name="post/[postId]"
|
|
options={{
|
|
headerShown: false,
|
|
}}
|
|
/>
|
|
<Stack.Screen
|
|
name="user/[userId]"
|
|
options={{
|
|
headerShown: false,
|
|
}}
|
|
/>
|
|
</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>
|
|
);
|
|
}
|