Files
frontend/app/_layout.tsx
lan cbe708b53b
All checks were successful
Frontend CI / ota-android (push) Successful in 1m50s
Frontend CI / ota-ios (push) Successful in 1m49s
Frontend CI / build-and-push-web (push) Successful in 2m55s
Frontend CI / build-android-apk (push) Successful in 1h10m58s
feat(message): support group avatar in navigation and chat screen
Pass group avatar through notification extras, navigation hrefs, and
route parameters. Update ChatScreen to resolve group information
(ID, name, and avatar) by prioritizing route parameters (e.g., from
push notifications) and falling back to conversation data from the
MessageManager.

- Update `NotificationBootstrap` to include `groupAvatar` in query params
- Update `hrefChat` to support `groupAvatar`
- Refactor `useChatScreen` to use `effectiveGroupId`, `effectiveGroupName`,
  and `effectiveGroupAvatar`
- Ensure `MessageManager` fetches conversation details if missing when
  activating a conversation
- Update `MessageListScreen` to pass `groupAvatar` during navigation
2026-05-13 00:31:44 +08:00

315 lines
9.7 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 [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' || 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>
);
}