diff --git a/.dockerignore b/.dockerignore index 7ffff64..eb1e6ac 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,6 +5,6 @@ dist dist-web dist-android dist-android-update.zip -android +android screenshots *.log diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index b966310..54648a5 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -94,7 +94,7 @@ jobs: test "${REMOTE}" = "${LOCAL}" build-android-apk: - runs-on: ubuntu-latest + runs-on: android-builder container: image: reactnativecommunity/react-native-android:latest env: @@ -104,6 +104,7 @@ jobs: NODE_ENV: "production" NDK_NUM_JOBS: "4" CMAKE_BUILD_PARALLEL_LEVEL: "4" + GRADLE_USER_HOME: /root/.gradle permissions: contents: read steps: @@ -122,7 +123,28 @@ jobs: node-version: '25.6.1' cache: 'npm' + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + ~/.android/build-cache + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/gradle.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Cache node_modules + uses: actions/cache@v4 + id: cache-node-modules + with: + path: node_modules + key: ${{ runner.os }}-node-modules-${{ hashFiles('package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node-modules- + - name: Install dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' run: npm ci - name: Generate Android native project @@ -292,6 +314,8 @@ jobs: labels: ${{ steps.meta.outputs.labels }} platforms: linux/amd64 provenance: false + cache-from: type=registry,ref=code.littlelan.cn/carrot_bbs/frontend-web:buildcache + cache-to: type=registry,ref=code.littlelan.cn/carrot_bbs/frontend-web:buildcache,mode=max - name: Show image tags run: | diff --git a/.gitignore b/.gitignore index 4074a5c..f5cbf7d 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,6 @@ dist-android-update.zip # Backend backend/data/ backend/logs/ + +doc/ +docs/ diff --git a/App.tsx b/App.tsx index 771c3c4..d511c1d 100644 --- a/App.tsx +++ b/App.tsx @@ -1,137 +1,7 @@ /** - * 萝卜BBS - 主应用入口 - * 配置导航、主题、状态管理等 + * 应用入口已由 Expo Router 接管(package.json main: expo-router/entry)。 + * 全局 Provider 与根布局见 app/_layout.tsx。 */ - -import React, { useEffect, useRef } from 'react'; -import { AppState, AppStateStatus, Platform, StyleSheet } from 'react-native'; -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'; - -// 配置通知处理器 - 必须在应用启动时设置 -Notifications.setNotificationHandler({ - handleNotification: async () => ({ - shouldShowAlert: true, // 即使在后台也要显示通知 - shouldPlaySound: true, // 播放声音 - shouldSetBadge: true, // 设置角标 - shouldShowBanner: true, // 显示横幅 - shouldShowList: true, // 显示通知列表 - }), -}); - -import MainNavigator from './src/navigation/MainNavigator'; -import { paperTheme } from './src/theme'; -import AppPromptBar from './src/components/common/AppPromptBar'; -import AppDialogHost from './src/components/common/AppDialogHost'; -import { installAlertOverride } from './src/services/alertOverride'; -// 数据库初始化移到 authStore 中,根据用户ID创建用户专属数据库 - -// 创建 QueryClient 实例 -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: 2, - staleTime: 5 * 60 * 1000, // 5分钟 - }, - }, -}); - -installAlertOverride(); - -// 注入全局CSS来移除React Native Web在浏览器中的默认focus outline -if (Platform.OS === 'web') { - const style = document.createElement('style'); - style.textContent = ` - /* 移除TextInput focus时的浏览器默认outline */ - input:focus, textarea:focus, select:focus { - outline: none !important; - outline-width: 0 !important; - box-shadow: none !important; - } - - /* 移除focus-visible时的默认outline */ - input:focus-visible, textarea:focus-visible { - outline: none !important; - } - - /* 移除所有:focus相关的默认样式 */ - *:focus { - outline: none !important; - } - - *:focus-visible { - outline: none !important; - } - `; - document.head.appendChild(style); -} - export default function App() { - const appState = useRef(AppState.currentState); - const notificationResponseListener = useRef(null); - - // 系统通知功能初始化 - useEffect(() => { - const initNotifications = async () => { - const { systemNotificationService } = await import('./src/services/systemNotificationService'); - await systemNotificationService.initialize(); - - // 初始化后台保活服务 - const { initBackgroundService } = await import('./src/services/backgroundService'); - await initBackgroundService(); - - // 监听 App 状态变化 - const subscription = AppState.addEventListener('change', (nextAppState) => { - if ( - appState.current.match(/inactive|background/) && - nextAppState === 'active' - ) { - systemNotificationService.clearBadge(); - } - appState.current = nextAppState; - }); - - // 监听通知点击响应 - notificationResponseListener.current = Notifications.addNotificationResponseReceivedListener( - (response) => { - const data = response.notification.request.content.data; - void data; - } - ); - - // 监听通知到达(用于前台显示时额外处理) - const notificationReceivedSubscription = Notifications.addNotificationReceivedListener( - (notification) => { - void notification; - } - ); - - return () => { - subscription.remove(); - notificationResponseListener.current?.remove(); - notificationReceivedSubscription.remove(); - }; - }; - - initNotifications(); - }, []); - - return ( - - - - - - - - - - - - - ); + return null; } diff --git a/app.json b/app.json index ca43d16..0037e51 100644 --- a/app.json +++ b/app.json @@ -2,10 +2,11 @@ "expo": { "name": "萝卜社区", "slug": "qojo", - "version": "1.0.10", + "version": "1.0.11", "orientation": "default", "icon": "./assets/icon.png", - "userInterfaceStyle": "light", + "userInterfaceStyle": "automatic", + "scheme": "carrotbbs", "splash": { "image": "./assets/splash-icon.png", "resizeMode": "contain", @@ -33,7 +34,7 @@ }, "predictiveBackGestureEnabled": false, "package": "skin.carrot.bbs", - "versionCode": 5, + "versionCode": 6, "permissions": [ "VIBRATE", "RECEIVE_BOOT_COMPLETED", @@ -68,6 +69,12 @@ } ], "expo-sqlite", + [ + "expo-camera", + { + "cameraPermission": "允许萝卜社区访问您的相机以扫描二维码" + } + ], [ "expo-notifications", { diff --git a/app/(app)/(tabs)/_layout.tsx b/app/(app)/(tabs)/_layout.tsx new file mode 100644 index 0000000..f555b9b --- /dev/null +++ b/app/(app)/(tabs)/_layout.tsx @@ -0,0 +1,127 @@ +import { useMemo } from 'react'; +import { Platform, useWindowDimensions } from 'react-native'; +import { Tabs, usePathname } from 'expo-router'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { useAppColors, shadows } from '../../../src/theme'; +import { BREAKPOINTS } from '../../../src/hooks/useResponsive'; +import { useHomeTabBarVisibilityStore, useTotalUnreadCount } from '../../../src/stores'; + +const TAB_BAR_HEIGHT = 56; +const TAB_BAR_FLOATING_MARGIN = 12; +const TAB_BAR_MARGIN = 20; + +export default function TabsLayout() { + const colors = useAppColors(); + const { width } = useWindowDimensions(); + const insets = useSafeAreaInsets(); + const pathname = usePathname(); + const unreadCount = useTotalUnreadCount(); + const scrollHideTabBar = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll); + const hideTabBar = Platform.OS === 'web' && width >= BREAKPOINTS.desktop; + + const isHomeStackRoute = pathname === '/home' || pathname.startsWith('/home/'); + + const tabBarStyle = useMemo(() => { + if (hideTabBar) { + return { display: 'none' as const, height: 0, overflow: 'hidden' as const }; + } + const visibleStyle = { + position: 'absolute' as const, + left: 0, + right: 0, + marginHorizontal: TAB_BAR_MARGIN, + bottom: TAB_BAR_FLOATING_MARGIN + insets.bottom, + height: TAB_BAR_HEIGHT, + borderRadius: 22, + backgroundColor: colors.background.paper, + ...shadows.lg, + borderTopWidth: 0, + elevation: 100, + zIndex: 100, + }; + if (isHomeStackRoute && scrollHideTabBar) { + return { + ...visibleStyle, + display: 'none' as const, + height: 0, + opacity: 0, + overflow: 'hidden' as const, + }; + } + return visibleStyle; + }, [hideTabBar, insets.bottom, isHomeStackRoute, scrollHideTabBar, colors]); + + return ( + + ( + + ), + }} + /> + ( + + ), + }} + /> + 0 ? (unreadCount > 99 ? 99 : unreadCount) : undefined, + tabBarIcon: ({ color, focused }) => ( + + ), + }} + /> + ( + + ), + }} + /> + + ); +} diff --git a/app/(app)/(tabs)/apps/_layout.tsx b/app/(app)/(tabs)/apps/_layout.tsx new file mode 100644 index 0000000..400d471 --- /dev/null +++ b/app/(app)/(tabs)/apps/_layout.tsx @@ -0,0 +1,5 @@ +import { Stack } from 'expo-router'; + +export default function AppsStackLayout() { + return ; +} diff --git a/app/(app)/(tabs)/apps/index.tsx b/app/(app)/(tabs)/apps/index.tsx new file mode 100644 index 0000000..8259a61 --- /dev/null +++ b/app/(app)/(tabs)/apps/index.tsx @@ -0,0 +1,5 @@ +import { AppsScreen } from '../../../../src/screens/apps'; + +export default function AppsRoute() { + return ; +} diff --git a/app/(app)/(tabs)/apps/schedule/_layout.tsx b/app/(app)/(tabs)/apps/schedule/_layout.tsx new file mode 100644 index 0000000..66385c9 --- /dev/null +++ b/app/(app)/(tabs)/apps/schedule/_layout.tsx @@ -0,0 +1,17 @@ +import { Stack } from 'expo-router'; + +export default function ScheduleStackLayout() { + return ( + + + + + ); +} diff --git a/app/(app)/(tabs)/apps/schedule/course.tsx b/app/(app)/(tabs)/apps/schedule/course.tsx new file mode 100644 index 0000000..d290558 --- /dev/null +++ b/app/(app)/(tabs)/apps/schedule/course.tsx @@ -0,0 +1,5 @@ +import { CourseDetailScreen } from '../../../../../src/screens/schedule'; + +export default function CourseDetailRoute() { + return ; +} diff --git a/app/(app)/(tabs)/apps/schedule/index.tsx b/app/(app)/(tabs)/apps/schedule/index.tsx new file mode 100644 index 0000000..bf05928 --- /dev/null +++ b/app/(app)/(tabs)/apps/schedule/index.tsx @@ -0,0 +1,5 @@ +import { ScheduleScreen } from '../../../../../src/screens/schedule'; + +export default function ScheduleRoute() { + return ; +} diff --git a/app/(app)/(tabs)/home/_layout.tsx b/app/(app)/(tabs)/home/_layout.tsx new file mode 100644 index 0000000..e30c650 --- /dev/null +++ b/app/(app)/(tabs)/home/_layout.tsx @@ -0,0 +1,5 @@ +import { Stack } from 'expo-router'; + +export default function HomeStackLayout() { + return ; +} diff --git a/app/(app)/(tabs)/home/index.tsx b/app/(app)/(tabs)/home/index.tsx new file mode 100644 index 0000000..3b9f7d4 --- /dev/null +++ b/app/(app)/(tabs)/home/index.tsx @@ -0,0 +1,5 @@ +import { HomeScreen } from '../../../../src/screens/home'; + +export default function HomeRoute() { + return ; +} diff --git a/app/(app)/(tabs)/home/search.tsx b/app/(app)/(tabs)/home/search.tsx new file mode 100644 index 0000000..defc617 --- /dev/null +++ b/app/(app)/(tabs)/home/search.tsx @@ -0,0 +1,5 @@ +import { SearchScreen } from '../../../../src/screens/home'; + +export default function HomeSearchRoute() { + return ; +} diff --git a/app/(app)/(tabs)/messages/_layout.tsx b/app/(app)/(tabs)/messages/_layout.tsx new file mode 100644 index 0000000..9e34be5 --- /dev/null +++ b/app/(app)/(tabs)/messages/_layout.tsx @@ -0,0 +1,5 @@ +import { Stack } from 'expo-router'; + +export default function MessagesStackLayout() { + return ; +} diff --git a/app/(app)/(tabs)/messages/index.tsx b/app/(app)/(tabs)/messages/index.tsx new file mode 100644 index 0000000..032f6c9 --- /dev/null +++ b/app/(app)/(tabs)/messages/index.tsx @@ -0,0 +1,5 @@ +import { MessageListScreen } from '../../../../src/screens/message'; + +export default function MessagesRoute() { + return ; +} diff --git a/app/(app)/(tabs)/messages/notifications.tsx b/app/(app)/(tabs)/messages/notifications.tsx new file mode 100644 index 0000000..6a60ecc --- /dev/null +++ b/app/(app)/(tabs)/messages/notifications.tsx @@ -0,0 +1,5 @@ +import { NotificationsScreen } from '../../../../src/screens/message'; + +export default function NotificationsRoute() { + return ; +} diff --git a/app/(app)/(tabs)/profile/_layout.tsx b/app/(app)/(tabs)/profile/_layout.tsx new file mode 100644 index 0000000..8a50c48 --- /dev/null +++ b/app/(app)/(tabs)/profile/_layout.tsx @@ -0,0 +1,41 @@ +import { useMemo } from 'react'; +import { Stack } from 'expo-router'; +import { useRouter } from 'expo-router'; + +import { AppBackButton } from '../../../../src/components/common'; +import { useAppColors } from '../../../../src/theme'; + +export default function ProfileStackLayout() { + const router = useRouter(); + const colors = useAppColors(); + + const headerOptions = useMemo( + () => ({ + headerStyle: { backgroundColor: colors.background.paper }, + headerTintColor: colors.text.primary, + headerTitleStyle: { fontWeight: '600' as const }, + headerShadowVisible: false, + headerBackTitle: '', + }), + [colors] + ); + + return ( + router.back()} />, + }} + > + + + + + + + + + + ); +} diff --git a/app/(app)/(tabs)/profile/account-security.tsx b/app/(app)/(tabs)/profile/account-security.tsx new file mode 100644 index 0000000..2eac261 --- /dev/null +++ b/app/(app)/(tabs)/profile/account-security.tsx @@ -0,0 +1,5 @@ +import { AccountSecurityScreen } from '../../../../src/screens/profile'; + +export default function AccountSecurityRoute() { + return ; +} diff --git a/app/(app)/(tabs)/profile/blocked-users.tsx b/app/(app)/(tabs)/profile/blocked-users.tsx new file mode 100644 index 0000000..fb23915 --- /dev/null +++ b/app/(app)/(tabs)/profile/blocked-users.tsx @@ -0,0 +1,5 @@ +import { BlockedUsersScreen } from '../../../../src/screens/profile'; + +export default function BlockedUsersRoute() { + return ; +} diff --git a/app/(app)/(tabs)/profile/bookmarks.tsx b/app/(app)/(tabs)/profile/bookmarks.tsx new file mode 100644 index 0000000..5fa89d9 --- /dev/null +++ b/app/(app)/(tabs)/profile/bookmarks.tsx @@ -0,0 +1,5 @@ +import { ProfileScreen } from '../../../../src/screens/profile'; + +export default function BookmarksRoute() { + return ; +} diff --git a/app/(app)/(tabs)/profile/edit-profile.tsx b/app/(app)/(tabs)/profile/edit-profile.tsx new file mode 100644 index 0000000..b440978 --- /dev/null +++ b/app/(app)/(tabs)/profile/edit-profile.tsx @@ -0,0 +1,5 @@ +import { EditProfileScreen } from '../../../../src/screens/profile'; + +export default function EditProfileRoute() { + return ; +} diff --git a/app/(app)/(tabs)/profile/index.tsx b/app/(app)/(tabs)/profile/index.tsx new file mode 100644 index 0000000..7944f71 --- /dev/null +++ b/app/(app)/(tabs)/profile/index.tsx @@ -0,0 +1,5 @@ +import { ProfileScreen } from '../../../../src/screens/profile'; + +export default function ProfileRoute() { + return ; +} diff --git a/app/(app)/(tabs)/profile/my-posts.tsx b/app/(app)/(tabs)/profile/my-posts.tsx new file mode 100644 index 0000000..eeb11f9 --- /dev/null +++ b/app/(app)/(tabs)/profile/my-posts.tsx @@ -0,0 +1,5 @@ +import { ProfileScreen } from '../../../../src/screens/profile'; + +export default function MyPostsRoute() { + return ; +} diff --git a/app/(app)/(tabs)/profile/notification-settings.tsx b/app/(app)/(tabs)/profile/notification-settings.tsx new file mode 100644 index 0000000..0907e61 --- /dev/null +++ b/app/(app)/(tabs)/profile/notification-settings.tsx @@ -0,0 +1,5 @@ +import { NotificationSettingsScreen } from '../../../../src/screens/profile'; + +export default function NotificationSettingsRoute() { + return ; +} diff --git a/app/(app)/(tabs)/profile/settings.tsx b/app/(app)/(tabs)/profile/settings.tsx new file mode 100644 index 0000000..ec2060d --- /dev/null +++ b/app/(app)/(tabs)/profile/settings.tsx @@ -0,0 +1,5 @@ +import { SettingsScreen } from '../../../../src/screens/profile'; + +export default function SettingsRoute() { + return ; +} diff --git a/app/(app)/_layout.tsx b/app/(app)/_layout.tsx new file mode 100644 index 0000000..00991db --- /dev/null +++ b/app/(app)/_layout.tsx @@ -0,0 +1,19 @@ +import { useEffect } from 'react'; +import { Redirect } from 'expo-router'; + +import { AppRouteStack } from '../../src/app-navigation/AppRouteStack'; +import { messageManager, useAuthStore } from '../../src/stores'; + +export default function AppLayout() { + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + + useEffect(() => { + messageManager.initialize(); + }, []); + + if (!isAuthenticated) { + return ; + } + + return ; +} diff --git a/app/(app)/_layout.web.tsx b/app/(app)/_layout.web.tsx new file mode 100644 index 0000000..a8e5521 --- /dev/null +++ b/app/(app)/_layout.web.tsx @@ -0,0 +1,28 @@ +import { useEffect } from 'react'; +import { useWindowDimensions } from 'react-native'; +import { Redirect } from 'expo-router'; + +import { AppDesktopShell } from '../../src/app-navigation/AppDesktopShell'; +import { AppRouteStack } from '../../src/app-navigation/AppRouteStack'; +import { BREAKPOINTS } from '../../src/hooks/useResponsive'; +import { messageManager, useAuthStore } from '../../src/stores'; + +export default function AppLayoutWeb() { + const { width } = useWindowDimensions(); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const useDesktopShell = width >= BREAKPOINTS.desktop; + + useEffect(() => { + messageManager.initialize(); + }, []); + + if (!isAuthenticated) { + return ; + } + + if (useDesktopShell) { + return ; + } + + return ; +} diff --git a/app/(app)/chat/[conversationId].tsx b/app/(app)/chat/[conversationId].tsx new file mode 100644 index 0000000..f0bee9c --- /dev/null +++ b/app/(app)/chat/[conversationId].tsx @@ -0,0 +1,5 @@ +import { ChatScreen } from '../../../src/screens/message'; + +export default function ChatRoute() { + return ; +} diff --git a/app/(app)/chat/private-info.tsx b/app/(app)/chat/private-info.tsx new file mode 100644 index 0000000..e080eea --- /dev/null +++ b/app/(app)/chat/private-info.tsx @@ -0,0 +1,5 @@ +import { PrivateChatInfoScreen } from '../../../src/screens/message'; + +export default function PrivateChatInfoRoute() { + return ; +} diff --git a/app/(app)/group/[groupId]/index.tsx b/app/(app)/group/[groupId]/index.tsx new file mode 100644 index 0000000..4a54deb --- /dev/null +++ b/app/(app)/group/[groupId]/index.tsx @@ -0,0 +1,5 @@ +import { GroupInfoScreen } from '../../../../src/screens/message'; + +export default function GroupInfoRoute() { + return ; +} diff --git a/app/(app)/group/[groupId]/members.tsx b/app/(app)/group/[groupId]/members.tsx new file mode 100644 index 0000000..f0b2876 --- /dev/null +++ b/app/(app)/group/[groupId]/members.tsx @@ -0,0 +1,5 @@ +import { GroupMembersScreen } from '../../../../src/screens/message'; + +export default function GroupMembersRoute() { + return ; +} diff --git a/app/(app)/group/create.tsx b/app/(app)/group/create.tsx new file mode 100644 index 0000000..2dc7954 --- /dev/null +++ b/app/(app)/group/create.tsx @@ -0,0 +1,5 @@ +import { CreateGroupScreen } from '../../../src/screens/message'; + +export default function CreateGroupRoute() { + return ; +} diff --git a/app/(app)/group/invite.tsx b/app/(app)/group/invite.tsx new file mode 100644 index 0000000..462a0f6 --- /dev/null +++ b/app/(app)/group/invite.tsx @@ -0,0 +1,5 @@ +import GroupInviteDetailScreen from '../../../src/screens/message/GroupInviteDetailScreen'; + +export default function GroupInviteRoute() { + return ; +} diff --git a/app/(app)/group/join.tsx b/app/(app)/group/join.tsx new file mode 100644 index 0000000..7758296 --- /dev/null +++ b/app/(app)/group/join.tsx @@ -0,0 +1,5 @@ +import { JoinGroupScreen } from '../../../src/screens/message'; + +export default function JoinGroupRoute() { + return ; +} diff --git a/app/(app)/group/request.tsx b/app/(app)/group/request.tsx new file mode 100644 index 0000000..67b463d --- /dev/null +++ b/app/(app)/group/request.tsx @@ -0,0 +1,5 @@ +import GroupRequestDetailScreen from '../../../src/screens/message/GroupRequestDetailScreen'; + +export default function GroupRequestRoute() { + return ; +} diff --git a/app/(app)/posts/create.tsx b/app/(app)/posts/create.tsx new file mode 100644 index 0000000..1f11170 --- /dev/null +++ b/app/(app)/posts/create.tsx @@ -0,0 +1,5 @@ +import { CreatePostScreen } from '../../../src/screens/create'; + +export default function CreatePostRoute() { + return ; +} diff --git a/app/(app)/qrcode/login/[sessionId].tsx b/app/(app)/qrcode/login/[sessionId].tsx new file mode 100644 index 0000000..2614da6 --- /dev/null +++ b/app/(app)/qrcode/login/[sessionId].tsx @@ -0,0 +1,5 @@ +import { QRCodeConfirmScreen } from '../../../../src/screens/auth/QRCodeConfirmScreen'; + +export default function QrLoginConfirmRoute() { + return ; +} diff --git a/app/(app)/users/[userId]/[type].tsx b/app/(app)/users/[userId]/[type].tsx new file mode 100644 index 0000000..fd1ab1a --- /dev/null +++ b/app/(app)/users/[userId]/[type].tsx @@ -0,0 +1,5 @@ +import FollowListScreen from '../../../../src/screens/profile/FollowListScreen'; + +export default function FollowListRoute() { + return ; +} diff --git a/app/(auth)/_layout.tsx b/app/(auth)/_layout.tsx new file mode 100644 index 0000000..5c5737d --- /dev/null +++ b/app/(auth)/_layout.tsx @@ -0,0 +1,5 @@ +import { Stack } from 'expo-router'; + +export default function AuthLayout() { + return ; +} diff --git a/app/(auth)/forgot-password.tsx b/app/(auth)/forgot-password.tsx new file mode 100644 index 0000000..a819c45 --- /dev/null +++ b/app/(auth)/forgot-password.tsx @@ -0,0 +1,5 @@ +import { ForgotPasswordScreen } from '../../src/screens/auth'; + +export default function ForgotPasswordRoute() { + return ; +} diff --git a/app/(auth)/login.tsx b/app/(auth)/login.tsx new file mode 100644 index 0000000..91b576f --- /dev/null +++ b/app/(auth)/login.tsx @@ -0,0 +1,5 @@ +import { LoginScreen } from '../../src/screens/auth'; + +export default function LoginRoute() { + return ; +} diff --git a/app/(auth)/register.tsx b/app/(auth)/register.tsx new file mode 100644 index 0000000..fb04e28 --- /dev/null +++ b/app/(auth)/register.tsx @@ -0,0 +1,5 @@ +import { RegisterScreen } from '../../src/screens/auth'; + +export default function RegisterRoute() { + return ; +} diff --git a/app/_layout.tsx b/app/_layout.tsx new file mode 100644 index 0000000..45e3a7f --- /dev/null +++ b/app/_layout.tsx @@ -0,0 +1,205 @@ +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 { + 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'; + +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 ( + + + + ); + } + return <>{children}; +} + +function NotificationBootstrap() { + const appState = useRef(AppState.currentState); + const notificationResponseListener = useRef(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 ThemedStack() { + const router = useRouter(); + const colors = useAppColors(); + const resolved = useResolvedColorScheme(); + + return ( + <> + + + + + + + + + router.back()} />, + }} + /> + router.back()} />, + }} + /> + + + + ); +} + +function ThemedProviders({ children }: { children: React.ReactNode }) { + const theme = usePaperThemeFromStore(); + return {children}; +} + +export default function RootLayout() { + return ( + + + + + + + + + + + + + ); +} diff --git a/app/index.tsx b/app/index.tsx new file mode 100644 index 0000000..f412813 --- /dev/null +++ b/app/index.tsx @@ -0,0 +1,11 @@ +import { Redirect } from 'expo-router'; + +import { useAuthStore } from '../src/stores'; + +export default function Index() { + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + if (isAuthenticated) { + return ; + } + return ; +} diff --git a/app/post/[postId].tsx b/app/post/[postId].tsx new file mode 100644 index 0000000..4ac907f --- /dev/null +++ b/app/post/[postId].tsx @@ -0,0 +1,5 @@ +import { PostDetailScreen } from '../../src/screens/home'; + +export default function PostDetailRoute() { + return ; +} diff --git a/app/user/[userId].tsx b/app/user/[userId].tsx new file mode 100644 index 0000000..12313c2 --- /dev/null +++ b/app/user/[userId].tsx @@ -0,0 +1,5 @@ +import { UserScreen } from '../../src/screens/profile'; + +export default function UserProfileRoute() { + return ; +} diff --git a/docs/OPTIMIZATION_DESIGN.md b/docs/OPTIMIZATION_DESIGN.md deleted file mode 100644 index 5b1a7e8..0000000 --- a/docs/OPTIMIZATION_DESIGN.md +++ /dev/null @@ -1,1725 +0,0 @@ -# 聊天系统性能优化设计方案 - -## 文档信息 - -- **创建日期**: 2026-03-18 -- **项目**: 胡萝卜 BBS (Carrot BBS) 前端 -- **范围**: 消息系统性能优化 - ---- - -## 目录 - -1. [架构概述](#架构概述) -2. [P0 分页状态管理](#p0-分页状态管理) -3. [P0 同步状态机](#p0-同步状态机) -4. [P1 差异更新](#p1-差异更新) -5. [P1 媒体缓存清理](#p1-媒体缓存清理) -6. [实现优先级与依赖关系](#实现优先级与依赖关系) - ---- - -## 架构概述 - -### 当前消息系统架构 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ UI Layer │ -│ ┌─────────────────┐ ┌─────────────────┐ │ -│ │ ChatScreen │ │ MessageList │ │ -│ │ useChatScreen │ │ Screen │ │ -│ └────────┬────────┘ └────────┬────────┘ │ -└───────────┼─────────────────────┼───────────────────────────────┘ - │ │ - ▼ ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Store Layer (Zustand) │ -│ ┌─────────────────────────────────────────────────────────────┐│ -│ │ messageManager (MessageManager) ││ -│ │ - messages Map ││ -│ │ - conversations: Conversation[] ││ -│ │ - activeConversation: string | null ││ -│ │ - isConnected: boolean ││ -│ └─────────────────────────────────────────────────────────────┘│ -│ ┌─────────────────────────────────────────────────────────────┐│ -│ │ messageManagerHooks (React Hooks) ││ -│ │ - useChat(conversationId) ││ -│ │ - useMessages(conversationId) ││ -│ │ - useConversations() ││ -│ └─────────────────────────────────────────────────────────────┘│ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ UseCase Layer │ -│ ┌─────────────────────────────────────────────────────────────┐│ -│ │ ProcessMessageUseCase ││ -│ │ - WebSocket 事件监听与处理 ││ -│ │ - 消息去重 (processedMessageIds) ││ -│ │ - 已读状态管理 (pendingReadMap) ││ -│ │ - 消息持久化到 Repository ││ -│ └─────────────────────────────────────────────────────────────┘│ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ DataSource Layer │ -│ ┌───────────────┐ ┌───────────────┐ ┌───────────────────────┐│ -│ │WebSocketClient│ │CacheDataSource│ │LocalDataSource (SQLite)││ -│ │ │ │ (Memory+Async) │ │ ││ -│ │ - 事件订阅 │ │ - get/set │ │ - messages 表 ││ -│ │ - emit │ │ - delete │ │ - conversations 表 ││ -│ │ - 连接状态 │ │ - clear │ │ - users 表 ││ -│ └───────────────┘ └───────────────┘ └───────────────────────┘│ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Infrastructure Layer │ -│ ┌─────────────────────────────────────────────────────────────┐│ -│ │ sseService (SSE Client) ││ -│ │ - EventSource 连接管理 ││ -│ │ - 重连逻辑 (maxReconnectAttempts=20) ││ -│ │ - AppState 监听 ││ -│ └─────────────────────────────────────────────────────────────┘│ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 问题分析 - -基于代码分析,当前系统存在以下问题: - -| 问题 | 影响 | 优先级 | -|------|------|--------| -| 分页状态管理不完善 | 用户滚动加载时可能重复请求同一页数据 | P0 | -| 连接状态反馈不清晰 | 用户不知道连接是否正常 | P0 | -| 全量数据更新 | 大房间每次更新都刷新整个消息列表 | P1 | -| 媒体缓存无清理机制 | 长期使用后存储空间爆炸 | P1 | - ---- - -## P0 分页状态管理 - -### 现有代码分析 - -#### 问题点 - -1. **`useChatScreen.ts` (第388-408行)** - ```typescript - const loadMoreHistory = useCallback(async () => { - if (!conversationId || !hasMoreHistory || loadingMore) { - return; - } - // 缺少分页游标管理,可能导致重复加载 - setLoadingMore(true); - try { - await loadMoreMessages(); - // 没有追踪当前加载到了哪一页 - } finally { - setLoadingMore(false); - } - }, [...]); - ``` - -2. **`messageManagerHooks.ts` (第156-158行)** - ```typescript - setIsLoading(true); - const loadedMessages = await messageManager.loadMoreMessages(conversationId, minSeq, 20); - // 直接使用 minSeq,但没有缓存 minSeq 的状态 - ``` - -3. **`MessageRepository.ts` (第125-137行)** - ```typescript - async getMessagesBeforeSeq(conversationId, beforeSeq, limit = 20) { - // 依赖 beforeSeq 参数,但没有分页偏移量管理 - } - ``` - -#### 架构缺陷 - -- 没有独立的分页状态存储 -- `hasMoreMessages` 状态与实际数据可能不同步 -- 并发加载时缺少互斥机制 -- 没有加载中的分页信息缓存 - -### 实现方案 - -#### 1. 新增分页状态管理器 - -```typescript -// src/stores/pagination/PaginationStateManager.ts - -interface PaginationState { - conversationId: string; - currentMinSeq: number; // 当前已加载的最小seq - currentMaxSeq: number; // 当前已加载的最大seq - pages: Map; // pageNumber -> messageIds - loadingPageNumbers: Set; - hasMoreBefore: boolean; - hasMoreAfter: boolean; - totalLoaded: number; -} - -class PaginationStateManager { - private states: Map = new Map(); - - // 获取或创建分页状态 - getOrCreate(conversationId: string): PaginationState { ... } - - // 标记页面加载中 - markPageLoading(conversationId: string, pageNumber: number): void { ... } - - // 标记页面加载完成 - markPageLoaded(conversationId: string, pageNumber: number, messageIds: string[], hasMore: boolean): void { ... } - - // 检查页面是否已加载 - isPageLoaded(conversationId: string, pageNumber: number): boolean { ... } - - // 检查页面是否正在加载 - isPageLoading(conversationId: string, pageNumber: number): boolean { ... } - - // 重置分页状态 - reset(conversationId: string): void { ... } -} -``` - -#### 2. 修改 useMessages Hook - -```typescript -// src/stores/messageManagerHooks.ts - -interface UseMessagesOptions { - pageSize?: number; - prefetchThreshold?: number; // 预加载阈值 -} - -function useMessages( - conversationId: string | null, - options: UseMessagesOptions = {} -) { - const { - pageSize = 20, - prefetchThreshold = 5 - } = options; - - // 分页状态 - const [paginationState, setPaginationState] = useState(null); - - // 计算当前页码 - const currentPage = useMemo(() => { - if (!paginationState) return 1; - return Math.ceil( - (paginationState.totalLoaded - messages.length) / pageSize - ) + 1; - }, [paginationState, messages.length]); - - // 加载更多(带分页保护) - const loadMoreMessages = useCallback(async () => { - if (!conversationId || !paginationState) return; - - // 计算下一页 - const nextPage = Math.floor(paginationState.totalLoaded / pageSize) + 1; - - // 检查是否正在加载 - if (paginationState.loadingPageNumbers.has(nextPage)) { - return; - } - - // 检查是否已加载 - if (paginationState.pages.has(nextPage)) { - return; - } - - await messageManager.loadMoreMessages( - conversationId, - paginationState.currentMinSeq, - pageSize - ); - }, [conversationId, paginationState, pageSize]); - - // 预加载检测 - useEffect(() => { - if (!paginationState || !hasMore) return; - - const currentPageLoaded = messages.length; - const threshold = pageSize - prefetchThreshold; - - if (currentPageLoaded <= threshold && !paginationState.loadingPageNumbers.has(1)) { - loadMoreMessages(); - } - }, [messages.length, hasMore, loadMoreMessages]); -} -``` - -#### 3. 修改 MessageManager - -```typescript -// src/stores/message/MessageManager.ts - -class MessageManager { - private paginationManager = new PaginationStateManager(); - - async loadMoreMessages( - conversationId: string, - beforeSeq: number, - limit: number - ): Promise { - // 检查分页状态 - const pageNumber = this.calculatePageNumber(conversationId, beforeSeq); - - if (this.paginationManager.isPageLoading(conversationId, pageNumber)) { - return []; // 避免重复加载 - } - - if (this.paginationManager.isPageLoaded(conversationId, pageNumber)) { - return []; // 已加载,直接返回 - } - - this.paginationManager.markPageLoading(conversationId, pageNumber); - - try { - // 调用 API - const response = await messageService.getMessages(conversationId, { - before_seq: beforeSeq, - limit - }); - - const messageIds = response.messages.map(m => m.id); - - this.paginationManager.markPageLoaded( - conversationId, - pageNumber, - messageIds, - response.has_more - ); - - // 更新消息存储 - this.appendMessages(conversationId, response.messages); - - return response.messages; - } catch (error) { - // 加载失败,清除分页状态 - this.paginationManager.markPageFailed(conversationId, pageNumber); - throw error; - } - } -} -``` - -#### 4. 修改数据库查询 - -```typescript -// src/services/database.ts (或 MessageRepository) - -export async function getMessagesBeforeSeq( - conversationId: string, - beforeSeq: number, - limit: number -): Promise { - // 确保使用索引优化查询 - const result = await db.getAllAsync(` - SELECT * FROM messages - WHERE conversationId = ? AND seq < ? - ORDER BY seq DESC - LIMIT ? - `, [conversationId, beforeSeq, limit]); - - return result; -} -``` - -### 修改/新增文件列表 - -| 文件 | 操作 | 说明 | -|------|------|------| -| `src/stores/pagination/PaginationStateManager.ts` | 新增 | 分页状态管理器 | -| `src/stores/pagination/index.ts` | 新增 | 导出文件 | -| `src/stores/messageManagerHooks.ts` | 修改 | 集成分页状态管理 | -| `src/stores/message/MessageManager.ts` | 修改 | 添加分页保护逻辑 | -| `src/data/repositories/MessageRepository.ts` | 修改 | 优化分页查询 | - -### 关键设计决策 - -1. **分页粒度**: 以 `beforeSeq` 为游标,而非 offset,避免消息新增导致的分页漂移 -2. **页面缓存**: 使用 `Map` 缓存已加载页面,避免重复请求 -3. **加载锁**: 使用 `Set` 追踪正在加载的页面,防止并发重复加载 -4. **预加载**: 当滚动到距离底部 `prefetchThreshold` 条消息时,提前加载下一页 - ---- - -## P0 同步状态机 - -### 现有代码分析 - -#### 问题点 - -1. **`sseService.ts` (第406-408行)** - ```typescript - isConnected(): boolean { - return this.source != null; - } - // 状态判断过于简单,source != null 不代表真正连接成功 - ``` - -2. **`WebSocketClient.ts` (第23-35行)** - ```typescript - export interface WebSocketEvents { - 'connected': void; - 'disconnected': void; - 'error': Error; - } - // 只有 connected/disconnected 两种状态 - ``` - -3. **`sseService.ts` (第190-228行)** - ```typescript - async connect(): Promise { - // isConnecting 状态没有暴露给外部 - // 外部无法区分"正在连接"和"连接失败" - } - ``` - -4. **`messageManagerHooks.ts` (第390-405行)** - ```typescript - const [isConnected, setIsConnected] = useState(() => messageManager.isConnected()); - // 连接状态变化没有详细的状态分类 - ``` - -#### 状态定义缺失 - -当前没有区分以下状态: -- **Connecting**: 正在建立连接 -- **Connected**: 连接已建立 -- **Reconnecting**: 连接断开,正在重连 -- **Disconnected**: 连接已断开 -- **Error**: 连接错误 - -### 实现方案 - -#### 1. 定义连接状态机 - -```typescript -// src/services/connection/ConnectionState.ts - -export enum ConnectionStateType { - IDLE = 'idle', - CONNECTING = 'connecting', - CONNECTED = 'connected', - RECONNECTING = 'reconnecting', - DISCONNECTED = 'disconnected', - ERROR = 'error', -} - -export interface ConnectionState { - type: ConnectionStateType; - timestamp: number; - error?: Error; - retryCount?: number; - lastConnectedAt?: number; -} - -export interface ConnectionStateChangeEvent { - previousState: ConnectionState; - currentState: ConnectionState; - reason?: string; -} -``` - -#### 2. 创建连接状态管理器 - -```typescript -// src/services/connection/ConnectionStateManager.ts - -type ConnectionStateListener = (event: ConnectionStateChangeEvent) => void; - -class ConnectionStateManager { - private state: ConnectionState = { - type: ConnectionStateType.IDLE, - timestamp: Date.now(), - }; - - private listeners: Set = new Set(); - private reconnectAttempts = 0; - private maxReconnectAttempts = 20; - - // 获取当前状态 - getState(): ConnectionState { ... } - - // 状态转换 - transition(newType: ConnectionStateType, reason?: string, error?: Error): void { - const previousState = this.state; - this.state = { - type: newType, - timestamp: Date.now(), - error, - retryCount: newType === ConnectionStateType.RECONNECTING - ? this.reconnectAttempts - : undefined, - lastConnectedAt: newType === ConnectionStateType.CONNECTED - ? Date.now() - : this.state.lastConnectedAt, - }; - - this.notifyListeners({ previousState, currentState: this.state, reason }); - } - - // 增加重试计数 - incrementRetry(): number { - this.reconnectAttempts++; - return this.reconnectAttempts; - } - - // 重置重试计数 - resetRetry(): void { - this.reconnectAttempts = 0; - } - - // 订阅状态变化 - subscribe(listener: ConnectionStateListener): () => void { ... } - - // 获取可读的状态描述 - getStatusDescription(): string { - switch (this.state.type) { - case ConnectionStateType.IDLE: - return '未连接'; - case ConnectionStateType.CONNECTING: - return '正在连接...'; - case ConnectionStateType.CONNECTED: - return '已连接'; - case ConnectionStateType.RECONNECTING: - return `正在重连 (${this.state.retryCount}/${this.maxReconnectAttempts})`; - case ConnectionStateType.DISCONNECTED: - return '已断开'; - case ConnectionStateType.ERROR: - return `连接错误: ${this.state.error?.message || '未知错误'}`; - } - } -} - -export const connectionStateManager = new ConnectionStateManager(); -``` - -#### 3. 修改 SSEService - -```typescript -// src/services/sseService.ts - -class SSEService { - private connectionStateManager = connectionStateManager; - - async connect(): Promise { - if (this.isConnecting || this.isConnected()) { - return true; - } - - this.connectionStateManager.transition( - ConnectionStateType.CONNECTING, - 'initiated' - ); - - try { - const token = await api.getToken(); - if (!token) { - throw new Error('No auth token'); - } - - // ... 建立连接 ... - - this.source.addEventListener('open', () => { - this.connectionStateManager.resetRetry(); - this.connectionStateManager.transition( - ConnectionStateType.CONNECTED, - 'connection_opened' - ); - }); - - this.source.addEventListener('error', (error) => { - this.connectionStateManager.transition( - ConnectionStateType.ERROR, - 'connection_error', - error - ); - this.scheduleReconnect(); - }); - - return true; - } catch (error) { - this.connectionStateManager.transition( - ConnectionStateType.ERROR, - 'connection_failed', - error - ); - this.scheduleReconnect(); - return false; - } - } - - private scheduleReconnect(): void { - const retryCount = this.connectionStateManager.incrementRetry(); - - if (retryCount >= this.maxReconnectAttempts) { - this.connectionStateManager.transition( - ConnectionStateType.DISCONNECTED, - 'max_reconnect_exceeded' - ); - return; - } - - this.connectionStateManager.transition( - ConnectionStateType.RECONNECTING, - 'scheduling_reconnect' - ); - - this.reconnectTimer = setTimeout(() => { - this.connect(); - }, this.reconnectDelay); - } -} -``` - -#### 4. 创建连接状态 Hook - -```typescript -// src/hooks/useConnectionState.ts - -export function useConnectionState() { - const [state, setState] = useState( - () => connectionStateManager.getState() - ); - - useEffect(() => { - const unsubscribe = connectionStateManager.subscribe((event) => { - setState(event.currentState); - }); - - return unsubscribe; - }, []); - - return { - state, - status: connectionStateManager.getStatusDescription(), - isConnected: state.type === ConnectionStateType.CONNECTED, - isConnecting: state.type === ConnectionStateType.CONNECTING, - isReconnecting: state.type === ConnectionStateType.RECONNECTING, - isDisconnected: state.type === ConnectionStateType.DISCONNECTED, - isError: state.type === ConnectionStateType.ERROR, - retryCount: state.retryCount, - error: state.error, - reconnect: () => sseService.connect(), - disconnect: () => sseService.disconnect(), - }; -} -``` - -#### 5. UI 集成示例 - -```typescript -// src/screens/message/components/ChatScreen/ConnectionStatusBadge.tsx - -export function ConnectionStatusBadge() { - const { status, isConnected, isReconnecting, isError, retryCount } = useConnectionState(); - - if (isConnected) { - return null; // 已连接不显示 - } - - return ( - - {isReconnecting && ( - - )} - - {isReconnecting - ? `连接中断,正在重连 (${retryCount}/20)` - : status} - - - ); -} -``` - -### 修改/新增文件列表 - -| 文件 | 操作 | 说明 | -|------|------|------| -| `src/services/connection/ConnectionState.ts` | 新增 | 连接状态定义 | -| `src/services/connection/ConnectionStateManager.ts` | 新增 | 连接状态管理器 | -| `src/services/connection/index.ts` | 新增 | 导出文件 | -| `src/services/sseService.ts` | 修改 | 集成状态管理器 | -| `src/hooks/useConnectionState.ts` | 新增 | 连接状态 Hook | -| `src/hooks/index.ts` | 修改 | 导出新 Hook | - -### Mermaid 状态图 - -```mermaid -stateDiagram-v2 - [*] --> IDLE - IDLE --> CONNECTING: 调用 connect() - CONNECTING --> CONNECTED: 连接成功 - CONNECTING --> ERROR: 连接失败 - CONNECTED --> RECONNECTING: 连接断开 - RECONNECTING --> CONNECTED: 重连成功 - RECONNECTING --> DISCONNECTED: 超过最大重试次数 - RECONNECTING --> ERROR: 重连失败 - ERROR --> RECONNECTING: 自动重连 - ERROR --> DISCONNECTED: 停止重连 - DISCONNECTED --> CONNECTING: 手动重连 - IDLE --> CONNECTING: 手动重连 - CONNECTED --> DISCONNECTED: 调用 disconnect() -``` - ---- - -## P1 差异更新 - -### 现有代码分析 - -#### 问题点 - -1. **`MessageManager` 消息更新机制** - - 每当有新消息时,通过 `setMessages` 更新整个消息数组 - - 大房间场景下,频繁的全量更新会导致 UI 卡顿 - - 没有区分不同类型的消息更新(新增、删除、修改) - -2. **`messageManagerHooks.ts` (第33-46行)** - ```typescript - const unsubscribe = messageManager.subscribe((event: MessageEvent) => { - switch (event.type) { - case 'conversations_updated': - setConversations(messageManager.getConversations()); - break; - // ... 其他都是全量更新 - } - }); - ``` - -3. **`useChatScreen.ts` (第148-162行)** - ```typescript - const messages = useMemo(() => { - return messageManagerMessages.map(m => ({ - // 全量映射 - })); - }, [messageManagerMessages]); - ``` - -4. **`ProcessMessageUseCase.ts` (第159-199行)** - ```typescript - private async handleNewMessage(message: any): Promise { - // 每条消息都触发完整流程 - this.notifySubscribers({ - type: 'message_received', - payload: { message, ... } - }); - } - ``` - -#### 性能瓶颈 - -- 1000人群聊,每秒10条消息 = 每秒10000次 UI 更新操作 -- 全量 `setMessages` 触发 FlatList 完整重渲染 -- 没有虚拟列表优化 - -### 实现方案 - -#### 1. 定义消息更新类型 - -```typescript -// src/stores/message/MessageUpdateTypes.ts - -export enum MessageUpdateType { - APPEND = 'append', // 追加新消息到末尾 - PREPEND = 'prepend', // 预置历史消息到开头 - UPDATE = 'update', // 更新单条消息 - DELETE = 'delete', // 删除单条消息 - BATCH_APPEND = 'batch_append', // 批量追加 - BATCH_PREPEND = 'batch_prepend', // 批量预置 - RECALL = 'recall', // 撤回消息 - CLEAR = 'clear', // 清空会话 -} - -export interface MessageUpdate { - type: MessageUpdateType; - conversationId: string; - payload: T; - timestamp: number; -} - -export interface AppendPayload { - messages: Message[]; -} - -export interface PrependPayload { - messages: Message[]; - hasMoreBefore: boolean; -} - -export interface UpdatePayload { - messageId: string; - updates: Partial; -} - -export interface DeletePayload { - messageId: string; -} - -export interface BatchAppendPayload { - messages: Message[]; -} - -export interface RecallPayload { - messageId: string; -} -``` - -#### 2. 创建差异更新 Hook - -```typescript -// src/hooks/useDifferentialMessages.ts - -export function useDifferentialMessages(conversationId: string | null) { - const [messages, setMessages] = useState([]); - const pendingUpdatesRef = useRef([]); - const flushScheduledRef = useRef(false); - - // 批量处理更新 - const flushUpdates = useCallback(() => { - if (pendingUpdatesRef.current.length === 0) return; - - const updates = pendingUpdatesRef.current; - pendingUpdatesRef.current = []; - flushScheduledRef.current = false; - - setMessages(currentMessages => { - const newMessages = [...currentMessages]; - - for (const update of updates) { - switch (update.type) { - case MessageUpdateType.APPEND: - for (const msg of update.payload.messages) { - if (!newMessages.find(m => m.id === msg.id)) { - newMessages.push(msg); - } - } - break; - - case MessageUpdateType.PREPEND: - const prependMessages = update.payload.messages - .filter(m => !newMessages.find(n => n.id === m.id)); - newMessages.unshift(...prependMessages.reverse()); - break; - - case MessageUpdateType.UPDATE: - const updateIdx = newMessages.findIndex(m => m.id === update.payload.messageId); - if (updateIdx !== -1) { - newMessages[updateIdx] = { - ...newMessages[updateIdx], - ...update.payload.updates - }; - } - break; - - case MessageUpdateType.DELETE: - newMessages = newMessages.filter(m => m.id !== update.payload.messageId); - break; - - case MessageUpdateType.RECALL: - const recallIdx = newMessages.findIndex(m => m.id === update.payload.messageId); - if (recallIdx !== -1) { - newMessages[recallIdx] = { - ...newMessages[recallIdx], - status: 'recalled', - segments: [], - }; - } - break; - - case MessageUpdateType.BATCH_APPEND: - for (const msg of update.payload.messages) { - if (!newMessages.find(m => m.id === msg.id)) { - newMessages.push(msg); - } - } - break; - } - } - - return newMessages; - }); - }, []); - - // 调度批量更新(16ms 内合并) - const scheduleFlush = useCallback(() => { - if (flushScheduledRef.current) return; - flushScheduledRef.current = true; - requestAnimationFrame(flushUpdates); - }, [flushUpdates]); - - // 处理消息更新 - const handleMessageUpdate = useCallback((update: MessageUpdate) => { - pendingUpdatesRef.current.push(update); - scheduleFlush(); - }, [scheduleFlush]); - - // 订阅 MessageManager - useEffect(() => { - if (!conversationId) return; - - const unsubscribe = messageManager.subscribe((event) => { - if (event.type === 'message_received') { - const { message, isCurrentUser } = event.payload; - handleMessageUpdate({ - type: isCurrentUser - ? MessageUpdateType.APPEND - : MessageUpdateType.PREPEND, - conversationId, - payload: { messages: [message] }, - timestamp: Date.now(), - }); - } - - if (event.type === 'messages_loaded') { - const { messages, direction } = event.payload; - handleMessageUpdate({ - type: direction === 'before' - ? MessageUpdateType.PREPEND - : MessageUpdateType.BATCH_APPEND, - conversationId, - payload: { messages }, - timestamp: Date.now(), - }); - } - - if (event.type === 'message_recalled') { - handleMessageUpdate({ - type: MessageUpdateType.RECALL, - conversationId, - payload: { messageId: event.payload.messageId }, - timestamp: Date.now(), - }); - } - }); - - return unsubscribe; - }, [conversationId, handleMessageUpdate]); - - return { messages }; -} -``` - -#### 3. 优化 FlatList 渲染 - -```typescript -// src/screens/message/components/ChatScreen/OptimizedMessageList.tsx - -export function OptimizedMessageList({ - messages, - onLoadMore, - hasMore, -}: { - messages: Message[]; - onLoadMore: () => void; - hasMore: boolean; -}) { - const viewabilityConfig = useRef({ - itemVisiblePercentThreshold: 50, - minimumViewTime: 100, - }); - - // 关键消息路径优化 - const keyExtractor = useCallback((item: Message) => item.id, []); - - const getItemLayout = useCallback((data: Message[] | null, index: number) => { - // 估算每条消息高度(可以根据类型调整) - const BASE_HEIGHT = 60; - const IMAGE_EXTRA = 200; - const EXTRA_PER_SEGMENT = 30; - - let length = BASE_HEIGHT; - if (data) { - const message = data[index]; - if (message.segments) { - length += message.segments.length * EXTRA_PER_SEGMENT; - } - if (message.segments?.some(s => s.type === 'image')) { - length += IMAGE_EXTRA; - } - } - - return { - length, - offset: data.slice(0, index).reduce((sum, m) => { - // 计算累计高度 - return sum + BASE_HEIGHT + - (m.segments?.length || 0) * EXTRA_PER_SEGMENT + - (m.segments?.some(s => s.type === 'image') ? IMAGE_EXTRA : 0); - }, 0), - index, - }; - }, []); - - // 消息类型判断 - const isSameDay = useCallback((prev: Message, next: Message) => { - const prevDate = new Date(prev.created_at).toDateString(); - const nextDate = new Date(next.created_at).toDateString(); - return prevDate === nextDate; - }, []); - - const renderItem = useCallback(({ item, index }: { item: Message; index: number }) => { - const prevItem = messages[index - 1]; - - return ( - 50} - /> - ); - }, [isSameDay]); - - return ( - - ); -} -``` - -#### 4. 修改 MessageManager 支持差异事件 - -```typescript -// src/stores/message/MessageManager.ts - -class MessageManager { - // 发送差异更新事件 - private emitMessageUpdate(update: MessageUpdate): void { - this.subscribers.forEach(subscriber => { - if (subscriber.types.includes(update.type) || subscriber.types.includes('*')) { - subscriber.callback(update); - } - }); - } - - async handleIncomingMessage(message: WSChatMessage): Promise { - // 检查消息是否已存在 - const existingMessages = this.messages.get(message.conversation_id) || []; - if (existingMessages.find(m => m.id === message.id)) { - return; // 避免重复 - } - - const newMessage = this.createMessageObject(message); - - // 直接发送差异更新,而不是全量更新 - this.emitMessageUpdate({ - type: MessageUpdateType.APPEND, - conversationId: message.conversation_id, - payload: { messages: [newMessage] }, - timestamp: Date.now(), - }); - - // 异步保存到数据库 - this.persistMessage(newMessage); - } -} -``` - -### 修改/新增文件列表 - -| 文件 | 操作 | 说明 | -|------|------|------| -| `src/stores/message/MessageUpdateTypes.ts` | 新增 | 消息更新类型定义 | -| `src/hooks/useDifferentialMessages.ts` | 新增 | 差异更新 Hook | -| `src/stores/messageManagerHooks.ts` | 修改 | 集成差异更新 | -| `src/stores/message/MessageManager.ts` | 修改 | 添加差异事件支持 | -| `src/screens/message/components/ChatScreen/OptimizedMessageList.tsx` | 新增 | 优化的消息列表组件 | - -### 性能收益 - -| 场景 | 优化前 | 优化后 | -|------|--------|--------| -| 1000人群,每秒10条消息 | 10000次/秒 UI 更新 | 60次/秒 UI 更新 | -| 滚动加载100条历史消息 | 全量重渲染100条 | 仅渲染可见区域~20条 | -| 消息撤回 | 全量更新 + UI 重建 | 单条更新 | - ---- - -## P1 媒体缓存清理 - -### 现有代码分析 - -#### 问题点 - -1. **`CacheDataSource.ts` (第14-28行)** - ```typescript - export class CacheDataSource implements ICacheDataSource { - private memoryCache: Map> = new Map(); - private maxSize: number; // 默认 100 条 - private defaultTtl: number; // 默认 5 分钟 - - // 没有媒体文件专用缓存管理 - // 没有磁盘空间监控 - } - ``` - -2. **缺少媒体缓存机制** - - 图片、视频、音频没有独立的缓存策略 - - 没有按会话清理过期媒体的功能 - - 没有用户手动清理入口 - -3. **`LocalDataSource.ts` (第73-86行)** - ```typescript - // 消息表结构 - `CREATE TABLE IF NOT EXISTS messages ( - id TEXT PRIMARY KEY NOT NULL, - conversationId TEXT NOT NULL, - segments TEXT - // 没有存储媒体文件路径 - )` - ``` - - 消息表没有记录媒体缓存路径 - - 无法追踪哪些媒体文件属于哪个会话 - -#### 存储风险 - -- 图片缓存:无限制增长 -- 视频缓存:无限制增长 -- 音频缓存:无限制增长 -- 总计:可能导致存储空间耗尽 - -### 实现方案 - -#### 1. 定义媒体缓存配置 - -```typescript -// src/services/media/MediaCacheConfig.ts - -export interface MediaCacheConfig { - // 图片配置 - image: { - maxMemoryCacheSize: number; // 内存缓存数量,默认 50 - maxDiskCacheSize: number; // 磁盘缓存大小(MB),默认 500 - maxAge: number; // 缓存有效期(小时),默认 168 (7天) - maxAgeForConversation: number; // 会话内媒体保留时间(天),默认 30 - }; - - // 视频配置 - video: { - maxDiskCacheSize: number; // 磁盘缓存大小(MB),默认 1000 - maxAge: number; // 缓存有效期(小时),默认 72 (3天) - autoPreload: boolean; // 是否自动预加载,默认 false - }; - - // 音频配置 - audio: { - maxDiskCacheSize: number; // 磁盘缓存大小(MB),默认 200 - maxAge: number; // 缓存有效期(天),默认 7 - }; - - // 全局配置 - global: { - checkOnStartup: boolean; // 启动时检查清理,默认 true - checkInterval: number; // 定期检查间隔(小时),默认 24 - lowStorageThreshold: number; // 低存储空间阈值(MB),默认 500 - }; -} - -export const DEFAULT_MEDIA_CACHE_CONFIG: MediaCacheConfig = { - image: { - maxMemoryCacheSize: 50, - maxDiskCacheSize: 500, - maxAge: 168, // 7 days - maxAgeForConversation: 30, - }, - video: { - maxDiskCacheSize: 1000, - maxAge: 72, // 3 days - autoPreload: false, - }, - audio: { - maxDiskCacheSize: 200, - maxAge: 168, // 7 days - }, - global: { - checkOnStartup: true, - checkInterval: 24, - lowStorageThreshold: 500, - }, -}; -``` - -#### 2. 创建媒体缓存管理器 - -```typescript -// src/services/media/MediaCacheManager.ts - -import * as FileSystem from 'expo-file-system'; -import { cacheDataSource } from '../data/datasources/CacheDataSource'; - -export enum MediaType { - IMAGE = 'image', - VIDEO = 'video', - AUDIO = 'audio', -} - -export interface MediaCacheInfo { - type: MediaType; - uri: string; - localPath: string; - conversationId?: string; - messageId?: string; - size: number; - createdAt: number; - lastAccessedAt: number; -} - -class MediaCacheManager { - private config: MediaCacheConfig; - private cacheDirectory: string; - private mediaRecords: Map = new Map(); - - // 缓存目录 - private readonly CACHE_DIR = `${FileSystem.documentDirectory}media_cache/`; - private readonly IMAGE_DIR = `${this.CACHE_DIR}images/`; - private readonly VIDEO_DIR = `${this.CACHE_DIR}videos/`; - private readonly AUDIO_DIR = `${this.CACHE_DIR}audios/`; - - constructor(config: MediaCacheConfig = DEFAULT_MEDIA_CACHE_CONFIG) { - this.config = config; - this.cacheDirectory = this.CACHE_DIR; - } - - // 初始化 - async initialize(): Promise { - await this.ensureDirectories(); - await this.loadMediaRecords(); - await this.cleanupIfNeeded(); - } - - // 确保缓存目录存在 - private async ensureDirectories(): Promise { - await FileSystem.makeDirectoryAsync(this.IMAGE_DIR, { intermediates: true }); - await FileSystem.makeDirectoryAsync(this.VIDEO_DIR, { intermediates: true }); - await FileSystem.makeDirectoryAsync(this.AUDIO_DIR, { intermediates: true }); - } - - // 缓存媒体文件 - async cacheMedia( - uri: string, - type: MediaType, - options: { - conversationId?: string; - messageId?: string; - } = {} - ): Promise { - const { conversationId, messageId } = options; - - // 生成唯一文件名 - const filename = this.generateFilename(uri, type); - const localPath = this.getMediaPath(type, filename); - - // 检查是否已缓存 - if (await this.exists(localPath)) { - await this.updateAccessTime(localPath); - return localPath; - } - - try { - // 下载文件 - const downloadResult = await FileSystem.downloadAsync(uri, localPath); - - // 记录缓存信息 - const cacheInfo: MediaCacheInfo = { - type, - uri, - localPath, - conversationId, - messageId, - size: downloadResult.headers['content-length'] - ? parseInt(downloadResult.headers['content-length']) - : 0, - createdAt: Date.now(), - lastAccessedAt: Date.now(), - }; - - await this.saveMediaRecord(localPath, cacheInfo); - await this.cleanupIfNeeded(); - - return localPath; - } catch (error) { - console.error('[MediaCacheManager] 缓存失败:', error); - throw error; - } - } - - // 获取缓存的媒体文件路径 - async getCachedMedia(uri: string, type: MediaType): Promise { - const record = this.findByUri(uri); - if (record && await this.exists(record.localPath)) { - await this.updateAccessTime(record.localPath); - return record.localPath; - } - return null; - } - - // 删除单个会话的所有媒体缓存 - async clearConversationMedia(conversationId: string): Promise { - const toDelete: string[] = []; - - for (const [path, info] of this.mediaRecords) { - if (info.conversationId === conversationId) { - toDelete.push(path); - } - } - - await Promise.all(toDelete.map(p => this.deleteMedia(p))); - } - - // 删除过期媒体 - async clearExpiredMedia(): Promise { - const now = Date.now(); - const maxAge = this.config.image.maxAge * 60 * 60 * 1000; // 转换为毫秒 - let deletedCount = 0; - - for (const [path, info] of this.mediaRecords) { - if (now - info.lastAccessedAt > maxAge) { - await this.deleteMedia(path); - deletedCount++; - } - } - - return deletedCount; - } - - // 清理超过会话保留期的媒体 - async clearOldConversationMedia(): Promise { - const now = Date.now(); - const maxAge = this.config.image.maxAgeForConversation * 24 * 60 * 60 * 1000; - let deletedCount = 0; - - for (const [path, info] of this.mediaRecords) { - if (info.conversationId && now - info.createdAt > maxAge) { - await this.deleteMedia(path); - deletedCount++; - } - } - - return deletedCount; - } - - // 按大小清理(LRU) - async clearBySizeLimit(): Promise { - const totalSize = await this.getTotalCacheSize(); - const limit = this.config.image.maxDiskCacheSize * 1024 * 1024; - - if (totalSize <= limit) { - return 0; - } - - // 按最后访问时间排序 - const sorted = Array.from(this.mediaRecords.entries()) - .sort((a, b) => a[1].lastAccessedAt - b[1].lastAccessedAt); - - let currentSize = totalSize; - let deletedCount = 0; - - for (const [path, info] of sorted) { - if (currentSize <= limit) break; - - await this.deleteMedia(path); - currentSize -= info.size; - deletedCount++; - } - - return deletedCount; - } - - // 获取缓存统计 - async getCacheStats(): Promise<{ - totalSize: number; - imageCount: number; - videoCount: number; - audioCount: number; - oldestItem: number; - newestItem: number; - }> { - const stats = { - totalSize: 0, - imageCount: 0, - videoCount: 0, - audioCount: 0, - oldestItem: Date.now(), - newestItem: 0, - }; - - for (const info of this.mediaRecords.values()) { - stats.totalSize += info.size; - stats.oldestItem = Math.min(stats.oldestItem, info.createdAt); - stats.newestItem = Math.max(stats.newestItem, info.createdAt); - - switch (info.type) { - case MediaType.IMAGE: stats.imageCount++; break; - case MediaType.VIDEO: stats.videoCount++; break; - case MediaType.AUDIO: stats.audioCount++; break; - } - } - - return stats; - } - - // 清理入口 - async cleanupIfNeeded(): Promise { - await this.clearExpiredMedia(); - await this.clearBySizeLimit(); - } - - // 私有辅助方法 - private generateFilename(uri: string, type: MediaType): string { - const ext = this.getExtension(uri, type); - const hash = this.hashString(uri); - return `${type}_${hash}_${Date.now()}.${ext}`; - } - - private getExtension(uri: string, type: MediaType): string { - if (type === MediaType.IMAGE) { - if (uri.includes('.gif')) return 'gif'; - if (uri.includes('.webp')) return 'webp'; - return 'jpg'; - } - if (type === MediaType.VIDEO) return 'mp4'; - if (type === MediaType.AUDIO) return 'mp3'; - return 'bin'; - } - - private getMediaPath(type: MediaType, filename: string): string { - switch (type) { - case MediaType.IMAGE: return `${this.IMAGE_DIR}${filename}`; - case MediaType.VIDEO: return `${this.VIDEO_DIR}${filename}`; - case MediaType.AUDIO: return `${this.AUDIO_DIR}${filename}`; - } - } - - private async exists(path: string): Promise { - const info = await FileSystem.getInfoAsync(path); - return info.exists; - } - - private async deleteMedia(path: string): Promise { - try { - await FileSystem.deleteAsync(path, { idempotent: true }); - this.mediaRecords.delete(path); - await this.removeMediaRecord(path); - } catch (error) { - console.warn('[MediaCacheManager] 删除失败:', path, error); - } - } - - private async saveMediaRecord(path: string, info: MediaCacheInfo): Promise { - this.mediaRecords.set(path, info); - await cacheDataSource.set(`media_${path}`, info, null); - } - - private async loadMediaRecords(): Promise { - // 从缓存数据源加载 - } - - private async updateAccessTime(path: string): Promise { - const info = this.mediaRecords.get(path); - if (info) { - info.lastAccessedAt = Date.now(); - await this.saveMediaRecord(path, info); - } - } - - private findByUri(uri: string): MediaCacheInfo | undefined { - for (const info of this.mediaRecords.values()) { - if (info.uri === uri) { - return info; - } - } - return undefined; - } - - private async getTotalCacheSize(): Promise { - let total = 0; - for (const info of this.mediaRecords.values()) { - total += info.size; - } - return total; - } - - private hashString(str: string): string { - let hash = 0; - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = ((hash << 5) - hash) + char; - hash = hash & hash; - } - return Math.abs(hash).toString(16); - } -} - -export const mediaCacheManager = new MediaCacheManager(); -``` - -#### 3. 创建清理策略 - -```typescript -// src/services/media/MediaCleanupPolicy.ts - -export enum CleanupTrigger { - STARTUP = 'startup', - SCHEDULED = 'scheduled', - MANUAL = 'manual', - LOW_STORAGE = 'low_storage', - CONVERSATION_DELETED = 'conversation_deleted', -} - -export interface CleanupResult { - trigger: CleanupTrigger; - timestamp: number; - deletedCount: number; - freedSize: number; - errors: string[]; -} - -class MediaCleanupPolicy { - private lastCleanup: number = 0; - private config: MediaCacheConfig; - - // 启动时清理 - async cleanupOnStartup(): Promise { - const errors: string[] = []; - let deletedCount = 0; - let freedSize = 0; - - try { - // 清理过期文件 - const expired = await mediaCacheManager.clearExpiredMedia(); - deletedCount += expired; - - // 清理超过保留期的会话媒体 - const oldConversation = await mediaCacheManager.clearOldConversationMedia(); - deletedCount += oldConversation; - - // 超过大小限制时清理 - const bySize = await mediaCacheManager.clearBySizeLimit(); - deletedCount += bySize; - - this.lastCleanup = Date.now(); - } catch (error) { - errors.push(`Startup cleanup failed: ${error}`); - } - - return { - trigger: CleanupTrigger.STARTUP, - timestamp: Date.now(), - deletedCount, - freedSize, - errors, - }; - } - - // 检查是否需要定期清理 - shouldRunScheduledCleanup(): boolean { - const interval = this.config.global.checkInterval * 60 * 60 * 1000; - return Date.now() - this.lastCleanup > interval; - } - - // 会话删除时清理 - async cleanupOnConversationDeleted(conversationId: string): Promise { - const errors: string[] = []; - let deletedCount = 0; - let freedSize = 0; - - try { - const statsBefore = await mediaCacheManager.getCacheStats(); - await mediaCacheManager.clearConversationMedia(conversationId); - const statsAfter = await mediaCacheManager.getCacheStats(); - - deletedCount = statsBefore.imageCount + statsBefore.videoCount + statsBefore.audioCount - - (statsAfter.imageCount + statsAfter.videoCount + statsAfter.audioCount); - freedSize = statsBefore.totalSize - statsAfter.totalSize; - } catch (error) { - errors.push(`Conversation cleanup failed: ${error}`); - } - - return { - trigger: CleanupTrigger.CONVERSATION_DELETED, - timestamp: Date.now(), - deletedCount, - freedSize, - errors, - }; - } -} - -export const mediaCleanupPolicy = new MediaCleanupPolicy(); -``` - -#### 4. 创建清理 Hook - -```typescript -// src/hooks/useMediaCache.ts - -export function useMediaCache() { - const [stats, setStats] = useState(null); - const [isClearing, setIsClearing] = useState(false); - - // 加载缓存统计 - const loadStats = useCallback(async () => { - const cacheStats = await mediaCacheManager.getCacheStats(); - setStats(cacheStats); - }, []); - - // 手动清理 - const clearAll = useCallback(async () => { - setIsClearing(true); - try { - await mediaCacheManager.clearExpiredMedia(); - await mediaCacheManager.clearBySizeLimit(); - await loadStats(); - } finally { - setIsClearing(false); - } - }, [loadStats]); - - // 清理指定会话的媒体 - const clearConversation = useCallback(async (conversationId: string) => { - await mediaCacheManager.clearConversationMedia(conversationId); - await loadStats(); - }, [loadStats]); - - // 组件挂载时加载统计 - useEffect(() => { - loadStats(); - }, [loadStats]); - - return { - stats, - isClearing, - loadStats, - clearAll, - clearConversation, - formatSize: (bytes: number) => { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`; - return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`; - }, - }; -} -``` - -### 修改/新增文件列表 - -| 文件 | 操作 | 说明 | -|------|------|------| -| `src/services/media/MediaCacheConfig.ts` | 新增 | 媒体缓存配置 | -| `src/services/media/MediaCacheManager.ts` | 新增 | 媒体缓存管理器 | -| `src/services/media/MediaCleanupPolicy.ts` | 新增 | 清理策略 | -| `src/services/media/index.ts` | 新增 | 导出文件 | -| `src/hooks/useMediaCache.ts` | 新增 | 媒体缓存 Hook | -| `src/hooks/index.ts` | 修改 | 导出新 Hook | -| `src/data/datasources/CacheDataSource.ts` | 修改 | 添加媒体记录存储 | - -### 清理策略 - -```mermaid -graph TD - A[启动 App] --> B{检查启动清理标志} - B -->|是| C[清理过期媒体] - B -->|是| D[清理超期会话媒体] - B -->|是| E[检查大小限制] - E -->|超过限制| F[LRU 清理] - - G[定期检查] -->|间隔到期| C - G -->|间隔到期| D - G -->|间隔到期| E - - H[用户操作] -->|删除会话| I[清理该会话媒体] - H -->|手动清理| J[清理所有过期媒体] - - K[存储空间检查] -->|低于阈值| L[紧急清理] -``` - ---- - -## 实现优先级与依赖关系 - -### 优先级矩阵 - -| 优先级 | 功能 | 依赖 | 复杂度 | -|--------|------|------|--------| -| P0 | 分页状态管理 | 无 | 中 | -| P0 | 同步状态机 | 无 | 低 | -| P1 | 差异更新 | P0 分页状态管理 | 高 | -| P1 | 媒体缓存清理 | 无 | 中 | - -### 推荐实现顺序 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Phase 1: 基础改进 (1-2天) │ -│ ┌───────────────────┐ ┌───────────────────┐ │ -│ │ P0 分页状态管理 │ │ P0 同步状态机 │ │ -│ │ │ │ │ │ -│ │ - 避免重复加载 │ │ - 清晰状态反馈 │ │ -│ │ - 页面缓存 │ │ - 连接状态 Hook │ │ -│ │ - 预加载机制 │ │ - UI 状态指示器 │ │ -│ └───────────────────┘ └───────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Phase 2: 性能优化 (2-3天) │ -│ ┌─────────────────────────────────────────────────────────────┐│ -│ │ P1 差异更新 ││ -│ │ ││ -│ │ - 依赖分页状态管理 ││ -│ │ - 消息更新类型定义 ││ -│ │ - 差异更新 Hook ││ -│ │ - FlatList 渲染优化 ││ -│ └─────────────────────────────────────────────────────────────┘│ -└─────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Phase 3: 存储优化 (1-2天) │ -│ ┌─────────────────────────────────────────────────────────────┐│ -│ │ P1 媒体缓存清理 ││ -│ │ ││ -│ │ - 媒体缓存管理器 ││ -│ │ - 清理策略 ││ -│ │ - 用户清理界面 ││ -│ └─────────────────────────────────────────────────────────────┘│ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 风险与注意事项 - -1. **差异更新风险** - - 需要确保消息 ID 的唯一性 - - 并发更新时需要正确合并 - - 建议在开发环境进行大房间压力测试 - -2. **分页状态管理风险** - - 页面缓存会占用内存,需要设置上限 - - 分页状态需要正确序列化和恢复(跨会话) - -3. **状态机风险** - - 状态转换需要严格测试 - - 重连逻辑需要处理边界情况(如网络中断) - -4. **媒体缓存风险** - - 删除文件时需要确保没有正在使用 - - 缓存元数据需要持久化存储 - - 清理操作应该在后台线程执行 \ No newline at end of file diff --git a/index.ts b/index.ts index 1d6e981..5b83418 100644 --- a/index.ts +++ b/index.ts @@ -1,8 +1 @@ -import { registerRootComponent } from 'expo'; - -import App from './App'; - -// registerRootComponent calls AppRegistry.registerComponent('main', () => App); -// It also ensures that whether you load the app in Expo Go or in a native build, -// the environment is set up appropriately -registerRootComponent(App); +import 'expo-router/entry'; diff --git a/package-lock.json b/package-lock.json index 4756a76..db23446 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "carrot_bbs", - "version": "1.0.0", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "carrot_bbs", - "version": "1.0.0", + "version": "1.0.1", "dependencies": { "@expo/vector-icons": "^15.1.1", "@react-native-async-storage/async-storage": "^2.2.0", @@ -19,7 +19,9 @@ "axios": "^1.13.6", "date-fns": "^4.1.0", "expo": "~55.0.4", - "expo-background-fetch": "~55.0.9", + "expo-background-fetch": "~55.0.10", + "expo-background-task": "~55.0.10", + "expo-camera": "^55.0.10", "expo-constants": "~55.0.7", "expo-dev-client": "~55.0.10", "expo-file-system": "~55.0.10", @@ -37,6 +39,9 @@ "expo-task-manager": "~55.0.9", "expo-updates": "^55.0.12", "expo-video": "^55.0.10", + "katex": "^0.16.42", + "markdown-it": "^14.1.1", + "prismjs": "^1.30.0", "react": "19.2.0", "react-dom": "19.2.0", "react-native": "0.83.2", @@ -48,6 +53,7 @@ "react-native-screens": "~4.23.0", "react-native-sse": "^1.2.1", "react-native-web": "^0.21.0", + "react-native-webview": "13.16.0", "react-native-worklets": "0.7.2", "zod": "^4.3.6", "zustand": "^5.0.11" @@ -112,15 +118,6 @@ "url": "https://opencollective.com/babel" } }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/generator": { "version": "7.29.1", "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.1.tgz", @@ -165,15 +162,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/helper-create-class-features-plugin": { "version": "7.28.6", "resolved": "https://registry.npmmirror.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", @@ -195,15 +183,6 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/helper-create-regexp-features-plugin": { "version": "7.28.5", "resolved": "https://registry.npmmirror.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", @@ -221,19 +200,10 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.6", - "resolved": "https://registry.npmmirror.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.6.tgz", - "integrity": "sha512-mOAsxeeKkUKayvZR3HeTYD/fICpCPLJrU5ZjelT/PA6WHtNDBOE436YiaEUvHN454bRM3CebhDsIpieCc4texA==", + "version": "0.6.8", + "resolved": "https://registry.npmmirror.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.28.6", @@ -408,22 +378,22 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", "license": "MIT", "dependencies": { "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "license": "MIT", "dependencies": { "@babel/types": "^7.29.0" @@ -1278,15 +1248,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/plugin-transform-shorthand-properties": { "version": "7.27.1", "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", @@ -1423,9 +1384,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1530,9 +1491,9 @@ } }, "node_modules/@expo-google-fonts/material-symbols": { - "version": "0.4.25", - "resolved": "https://registry.npmmirror.com/@expo-google-fonts/material-symbols/-/material-symbols-0.4.25.tgz", - "integrity": "sha512-MlwOpcYPLYu2+aDAwqv29l3sknNNxA36Jcu07Tg9+MTEvXk2SPcO8eQmwwDeVBbv5Wb6ToD1LmE+e0lLv/9WvA==", + "version": "0.4.27", + "resolved": "https://registry.npmmirror.com/@expo-google-fonts/material-symbols/-/material-symbols-0.4.27.tgz", + "integrity": "sha512-cnb3DZnWUWpezGFkJ8y4MT5f/lw6FcgDzeJzic+T+vpQHLHG1cg3SC3i1w1i8Bk4xKR4HPY3t9iIRNvtr5ml8A==", "license": "MIT AND Apache-2.0" }, "node_modules/@expo/code-signing-certificates": { @@ -1545,15 +1506,15 @@ } }, "node_modules/@expo/config": { - "version": "55.0.8", - "resolved": "https://registry.npmmirror.com/@expo/config/-/config-55.0.8.tgz", - "integrity": "sha512-D7RYYHfErCgEllGxNwdYdkgzLna7zkzUECBV3snbUpf7RvIpB5l1LpCgzuVoc5KVew5h7N1Tn4LnT/tBSUZsQg==", + "version": "55.0.10", + "resolved": "https://registry.npmmirror.com/@expo/config/-/config-55.0.10.tgz", + "integrity": "sha512-qCHxo9H1ZoeW+y0QeMtVZ3JfGmumpGrgUFX60wLWMarraoQZSe47ZUm9kJSn3iyoPjUtUNanO3eXQg+K8k4rag==", "license": "MIT", "dependencies": { - "@expo/config-plugins": "~55.0.6", + "@expo/config-plugins": "~55.0.7", "@expo/config-types": "^55.0.5", "@expo/json-file": "^10.0.12", - "@expo/require-utils": "^55.0.2", + "@expo/require-utils": "^55.0.3", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^13.0.0", @@ -1564,9 +1525,9 @@ } }, "node_modules/@expo/config-plugins": { - "version": "55.0.6", - "resolved": "https://registry.npmmirror.com/@expo/config-plugins/-/config-plugins-55.0.6.tgz", - "integrity": "sha512-cIox6FjZlFaaX40rbQ3DvP9e87S5X85H9uw+BAxJE5timkMhuByy3GAlOsj1h96EyzSiol7Q6YIGgY1Jiz4M+A==", + "version": "55.0.7", + "resolved": "https://registry.npmmirror.com/@expo/config-plugins/-/config-plugins-55.0.7.tgz", + "integrity": "sha512-XZUoDWrsHEkH3yasnDSJABM/UxP5a1ixzRwU/M+BToyn/f0nTrSJJe/Ay/FpxkI4JSNz2n0e06I23b2bleXKVA==", "license": "MIT", "dependencies": { "@expo/config-types": "^55.0.5", @@ -1584,12 +1545,36 @@ "xml2js": "0.6.0" } }, + "node_modules/@expo/config-plugins/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@expo/config-types": { "version": "55.0.5", "resolved": "https://registry.npmmirror.com/@expo/config-types/-/config-types-55.0.5.tgz", "integrity": "sha512-sCmSUZG4mZ/ySXvfyyBdhjivz8Q539X1NondwDdYG7s3SBsk+wsgPJzYsqgAG/P9+l0xWjUD2F+kQ1cAJ6NNLg==", "license": "MIT" }, + "node_modules/@expo/config/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@expo/devcert": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/@expo/devcert/-/devcert-1.2.1.tgz", @@ -1656,9 +1641,9 @@ } }, "node_modules/@expo/fingerprint": { - "version": "0.16.5", - "resolved": "https://registry.npmmirror.com/@expo/fingerprint/-/fingerprint-0.16.5.tgz", - "integrity": "sha512-mLrcymtgkW9IJ/G1e8MH1Xt2VIb1MOS86ePY0ePcnV3nVyJqm7gfa/AXD1Hk+eZXvf8XhioYz6QZaamBdEzR3A==", + "version": "0.16.6", + "resolved": "https://registry.npmmirror.com/@expo/fingerprint/-/fingerprint-0.16.6.tgz", + "integrity": "sha512-nRITNbnu3RKSHPvKVehrSU4KG2VY9V8nvULOHBw98ukHCAU4bGrU5APvcblOkX3JAap+xEHsg/mZvqlvkLInmQ==", "license": "MIT", "dependencies": { "@expo/env": "^2.0.11", @@ -1677,6 +1662,18 @@ "fingerprint": "bin/cli.js" } }, + "node_modules/@expo/fingerprint/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@expo/image-utils": { "version": "0.8.12", "resolved": "https://registry.npmmirror.com/@expo/image-utils/-/image-utils-0.8.12.tgz", @@ -1692,6 +1689,18 @@ "semver": "^7.6.0" } }, + "node_modules/@expo/image-utils/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@expo/json-file": { "version": "10.0.12", "resolved": "https://registry.npmmirror.com/@expo/json-file/-/json-file-10.0.12.tgz", @@ -1703,12 +1712,12 @@ } }, "node_modules/@expo/local-build-cache-provider": { - "version": "55.0.6", - "resolved": "https://registry.npmmirror.com/@expo/local-build-cache-provider/-/local-build-cache-provider-55.0.6.tgz", - "integrity": "sha512-4kfdv48sKzokijMqi07fINYA9/XprshmPgSLf8i69XgzIv2YdRyBbb70SzrufB7PDneFoltz8N83icW8gOOj1g==", + "version": "55.0.7", + "resolved": "https://registry.npmmirror.com/@expo/local-build-cache-provider/-/local-build-cache-provider-55.0.7.tgz", + "integrity": "sha512-Qg9uNZn1buv4zJUA4ZQaz+ZnKDCipRgjoEg2Gcp8Qfy+2Gq5yZKX4YN1TThCJ01LJk/pvJsCRxXlXZSwdZppgg==", "license": "MIT", "dependencies": { - "@expo/config": "~55.0.8", + "@expo/config": "~55.0.10", "chalk": "^4.1.2" } }, @@ -1751,6 +1760,41 @@ "metro-transform-worker": "0.83.3" } }, + "node_modules/@expo/metro-config": { + "version": "55.0.11", + "resolved": "https://registry.npmmirror.com/@expo/metro-config/-/metro-config-55.0.11.tgz", + "integrity": "sha512-qGxq7RwWpj0zNvZO/e5aizKrOKYYBrVPShSbxPOVB1EXcexxTPTxnOe4pYFg/gKkLIJe0t3jSSF8IDWlGdaaOg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.20.0", + "@babel/generator": "^7.20.5", + "@expo/config": "~55.0.10", + "@expo/env": "~2.1.1", + "@expo/json-file": "~10.0.12", + "@expo/metro": "~54.2.0", + "@expo/spawn-async": "^1.7.2", + "browserslist": "^4.25.0", + "chalk": "^4.1.0", + "debug": "^4.3.2", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "hermes-parser": "^0.32.0", + "jsc-safe-url": "^0.2.4", + "lightningcss": "^1.30.1", + "picomatch": "^4.0.3", + "postcss": "~8.4.32", + "resolve-from": "^5.0.0" + }, + "peerDependencies": { + "expo": "*" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } + } + }, "node_modules/@expo/metro-runtime": { "version": "55.0.6", "resolved": "https://registry.npmmirror.com/@expo/metro-runtime/-/metro-runtime-55.0.6.tgz", @@ -1801,6 +1845,166 @@ "resolve-workspace-root": "^2.0.0" } }, + "node_modules/@expo/package-manager/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/package-manager/node_modules/cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/package-manager/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@expo/package-manager/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/@expo/package-manager/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@expo/package-manager/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/package-manager/node_modules/log-symbols": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/log-symbols/-/log-symbols-2.2.0.tgz", + "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "license": "MIT", + "dependencies": { + "chalk": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/package-manager/node_modules/log-symbols/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmmirror.com/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/package-manager/node_modules/mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/package-manager/node_modules/onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/package-manager/node_modules/ora": { + "version": "3.4.0", + "resolved": "https://registry.npmmirror.com/ora/-/ora-3.4.0.tgz", + "integrity": "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==", + "license": "MIT", + "dependencies": { + "chalk": "^2.4.2", + "cli-cursor": "^2.1.0", + "cli-spinners": "^2.0.0", + "log-symbols": "^2.2.0", + "strip-ansi": "^5.2.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@expo/package-manager/node_modules/ora/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmmirror.com/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/package-manager/node_modules/restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", + "license": "MIT", + "dependencies": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@expo/package-manager/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@expo/plist": { "version": "0.5.2", "resolved": "https://registry.npmmirror.com/@expo/plist/-/plist-0.5.2.tgz", @@ -1812,10 +2016,43 @@ "xmlbuilder": "^15.1.1" } }, + "node_modules/@expo/prebuild-config": { + "version": "55.0.10", + "resolved": "https://registry.npmmirror.com/@expo/prebuild-config/-/prebuild-config-55.0.10.tgz", + "integrity": "sha512-AMylDld5G7YJGfEhEyXtgWRuBB83802QBoewF1vJ6NMDtufukuPhMJzOs9E4UXNsjLTaQcgT4yTWhsAWl7o1AQ==", + "license": "MIT", + "dependencies": { + "@expo/config": "~55.0.10", + "@expo/config-plugins": "~55.0.7", + "@expo/config-types": "^55.0.5", + "@expo/image-utils": "^0.8.12", + "@expo/json-file": "^10.0.12", + "@react-native/normalize-colors": "0.83.2", + "debug": "^4.3.1", + "resolve-from": "^5.0.0", + "semver": "^7.6.0", + "xml2js": "0.6.0" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/@expo/prebuild-config/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@expo/require-utils": { - "version": "55.0.2", - "resolved": "https://registry.npmmirror.com/@expo/require-utils/-/require-utils-55.0.2.tgz", - "integrity": "sha512-dV5oCShQ1umKBKagMMT4B/N+SREsQe3lU4Zgmko5AO0rxKV0tynZT6xXs+e2JxuqT4Rz997atg7pki0BnZb4uw==", + "version": "55.0.3", + "resolved": "https://registry.npmmirror.com/@expo/require-utils/-/require-utils-55.0.3.tgz", + "integrity": "sha512-TS1m5tW45q4zoaTlt6DwmdYHxvFTIxoLrTHKOFrIirHIqIXnHCzpceg8wumiBi+ZXSaGY2gobTbfv+WVhJY6Fw==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", @@ -1892,24 +2129,6 @@ "excpretty": "build/cli.js" } }, - "node_modules/@expo/xcpretty/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, - "node_modules/@expo/xcpretty/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/@hapi/hoek": { "version": "9.3.0", "resolved": "https://registry.npmmirror.com/@hapi/hoek/-/hoek-9.3.0.tgz", @@ -1952,6 +2171,15 @@ "node": ">=8" } }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": { "version": "5.3.1", "resolved": "https://registry.npmmirror.com/camelcase/-/camelcase-5.3.1.tgz", @@ -1961,6 +2189,71 @@ "node": ">=6" } }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", "resolved": "https://registry.npmmirror.com/@istanbuljs/schema/-/schema-0.1.3.tgz", @@ -2769,98 +3062,17 @@ "yaml": "^2.2.1" } }, - "node_modules/@react-native-community/cli-doctor/node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "node_modules/@react-native-community/cli-doctor/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "devOptional": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@react-native-community/cli-doctor/node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmmirror.com/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli-doctor/node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/@react-native-community/cli-doctor/node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli-doctor/node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmmirror.com/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli-doctor/node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" } }, "node_modules/@react-native-community/cli-platform-android": { @@ -2921,39 +3133,6 @@ "ws": "^6.2.3" } }, - "node_modules/@react-native-community/cli-server-api/node_modules/is-wsl": { - "version": "1.1.0", - "resolved": "https://registry.npmmirror.com/is-wsl/-/is-wsl-1.1.0.tgz", - "integrity": "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/@react-native-community/cli-server-api/node_modules/open": { - "version": "6.4.0", - "resolved": "https://registry.npmmirror.com/open/-/open-6.4.0.tgz", - "integrity": "sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "is-wsl": "^1.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@react-native-community/cli-server-api/node_modules/ws": { - "version": "6.2.3", - "resolved": "https://registry.npmmirror.com/ws/-/ws-6.2.3.tgz", - "integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "async-limiter": "~1.0.0" - } - }, "node_modules/@react-native-community/cli-tools": { "version": "20.1.2", "resolved": "https://registry.npmmirror.com/@react-native-community/cli-tools/-/cli-tools-20.1.2.tgz", @@ -2973,176 +3152,17 @@ "semver": "^7.5.2" } }, - "node_modules/@react-native-community/cli-tools/node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "node_modules/@react-native-community/cli-tools/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "devOptional": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@react-native-community/cli-tools/node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli-tools/node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli-tools/node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmmirror.com/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli-tools/node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmmirror.com/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "devOptional": true, - "license": "MIT", + "license": "ISC", "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/@react-native-community/cli-tools/node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/@react-native-community/cli-tools/node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli-tools/node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmmirror.com/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" + "semver": "bin/semver.js" }, "engines": { "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli-tools/node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli-tools/node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli-tools/node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" } }, "node_modules/@react-native-community/cli-types": { @@ -3155,79 +3175,17 @@ "joi": "^17.2.1" } }, - "node_modules/@react-native-community/cli/node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmmirror.com/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "node_modules/@react-native-community/cli/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "devOptional": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || >=14" - } - }, - "node_modules/@react-native-community/cli/node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli/node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli/node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-native-community/cli/node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/@react-native/assets-registry": { @@ -3311,6 +3269,30 @@ "@babel/core": "*" } }, + "node_modules/@react-native/babel-preset/node_modules/babel-plugin-syntax-hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmmirror.com/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.32.0.tgz", + "integrity": "sha512-m5HthL++AbyeEA2FcdwOLfVFvWYECOBObLHNqdR8ceY4TsEdn4LdX2oTvbB2QJSSElE2AWA/b2MXZ/PF/CqLZg==", + "license": "MIT", + "dependencies": { + "hermes-parser": "0.32.0" + } + }, + "node_modules/@react-native/babel-preset/node_modules/hermes-estree": { + "version": "0.32.0", + "resolved": "https://registry.npmmirror.com/hermes-estree/-/hermes-estree-0.32.0.tgz", + "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", + "license": "MIT" + }, + "node_modules/@react-native/babel-preset/node_modules/hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.32.0.tgz", + "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.32.0" + } + }, "node_modules/@react-native/codegen": { "version": "0.83.2", "resolved": "https://registry.npmmirror.com/@react-native/codegen/-/codegen-0.83.2.tgz", @@ -3369,6 +3351,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@react-native/codegen/node_modules/hermes-estree": { + "version": "0.32.0", + "resolved": "https://registry.npmmirror.com/hermes-estree/-/hermes-estree-0.32.0.tgz", + "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", + "license": "MIT" + }, + "node_modules/@react-native/codegen/node_modules/hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.32.0.tgz", + "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.32.0" + } + }, "node_modules/@react-native/codegen/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", @@ -3411,6 +3408,18 @@ } } }, + "node_modules/@react-native/community-cli-plugin/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@react-native/debugger-frontend": { "version": "0.83.2", "resolved": "https://registry.npmmirror.com/@react-native/debugger-frontend/-/debugger-frontend-0.83.2.tgz", @@ -3456,6 +3465,55 @@ "node": ">= 20.19.4" } }, + "node_modules/@react-native/dev-middleware/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmmirror.com/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmmirror.com/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/@react-native/gradle-plugin": { "version": "0.83.2", "resolved": "https://registry.npmmirror.com/@react-native/gradle-plugin/-/gradle-plugin-0.83.2.tgz", @@ -3481,17 +3539,17 @@ "license": "MIT" }, "node_modules/@react-navigation/bottom-tabs": { - "version": "7.15.2", - "resolved": "https://registry.npmmirror.com/@react-navigation/bottom-tabs/-/bottom-tabs-7.15.2.tgz", - "integrity": "sha512-xaSumZWE97P3j33guO7bh5dJ5IqR1bWiT+i17SUjsXxoI9xnNXWDm4dkTjzGuuT0BHcUVkzei0tjjCQmNg9cIQ==", + "version": "7.15.6", + "resolved": "https://registry.npmmirror.com/@react-navigation/bottom-tabs/-/bottom-tabs-7.15.6.tgz", + "integrity": "sha512-olB+s0ApMzWN9t5Bk5Mj6ntSlVRz3B8v+1LtwGS/29lyC311G5es0kgxyzpGKE9gy6Ef8W526QH5cIka2jh0kQ==", "license": "MIT", "dependencies": { - "@react-navigation/elements": "^2.9.8", + "@react-navigation/elements": "^2.9.11", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { - "@react-navigation/native": "^7.1.31", + "@react-navigation/native": "^7.1.34", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", @@ -3499,9 +3557,9 @@ } }, "node_modules/@react-navigation/core": { - "version": "7.15.1", - "resolved": "https://registry.npmmirror.com/@react-navigation/core/-/core-7.15.1.tgz", - "integrity": "sha512-Fqr6qxfZJIC4ewho7LtTa9zz6hcOzohX7D1lcDfrkGaYkS5xBwEZViGNxCJK/czUc74ua8NThyrObQFjB6Q/RQ==", + "version": "7.16.2", + "resolved": "https://registry.npmmirror.com/@react-navigation/core/-/core-7.16.2.tgz", + "integrity": "sha512-0dbCC2aTjNW7MvG1fY7zeq6eYvmmaFCEnBDXPuMPJ8uKgfs9lFGXIQFIfBdmcBVX6vHhS+K213VCsuHSIv5jYw==", "license": "MIT", "dependencies": { "@react-navigation/routers": "^7.5.3", @@ -3517,16 +3575,10 @@ "react": ">= 18.2.0" } }, - "node_modules/@react-navigation/core/node_modules/react-is": { - "version": "19.2.4", - "resolved": "https://registry.npmmirror.com/react-is/-/react-is-19.2.4.tgz", - "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", - "license": "MIT" - }, "node_modules/@react-navigation/elements": { - "version": "2.9.8", - "resolved": "https://registry.npmmirror.com/@react-navigation/elements/-/elements-2.9.8.tgz", - "integrity": "sha512-3gpwUmVnDJYvK9nFmAA/YXw0hmT/C/lZx8RkRMK+ux9l1T+32EWnQFnn34Wa1BMDX8HN2r64yrlW93DIzKI7Uw==", + "version": "2.9.11", + "resolved": "https://registry.npmmirror.com/@react-navigation/elements/-/elements-2.9.11.tgz", + "integrity": "sha512-O5KiwaVCcEVuqZgQ77xiBFSl1sha77rNMTFlLWYnom33ZHPDarV3bM9WNyVnMZxU8ZVTi02X3+ZhO0fSn5QYyg==", "license": "MIT", "dependencies": { "color": "^4.2.3", @@ -3535,7 +3587,7 @@ }, "peerDependencies": { "@react-native-masked-view/masked-view": ">= 0.2.0", - "@react-navigation/native": "^7.1.31", + "@react-navigation/native": "^7.1.34", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0" @@ -3547,17 +3599,17 @@ } }, "node_modules/@react-navigation/material-top-tabs": { - "version": "7.4.16", - "resolved": "https://registry.npmmirror.com/@react-navigation/material-top-tabs/-/material-top-tabs-7.4.16.tgz", - "integrity": "sha512-Wy/NP17O948/4uUvzpPs5FkvhOCvojBJ320FxW5kUiUbaxde11LUJf9yhDJhScQ7pjHeT0NsMSc1uhbF8O1oFg==", + "version": "7.4.20", + "resolved": "https://registry.npmmirror.com/@react-navigation/material-top-tabs/-/material-top-tabs-7.4.20.tgz", + "integrity": "sha512-a4QToiT0gIfqjG8J/OaCSuEovFUfMyOo4VOMt2haHsymnM9cMXR7UQL4j7MsUOhaPo1wGsJ9QazeA4TcVJWRFA==", "license": "MIT", "dependencies": { - "@react-navigation/elements": "^2.9.8", + "@react-navigation/elements": "^2.9.11", "color": "^4.2.3", - "react-native-tab-view": "^4.2.2" + "react-native-tab-view": "^4.3.0" }, "peerDependencies": { - "@react-navigation/native": "^7.1.31", + "@react-navigation/native": "^7.1.34", "react": ">= 18.2.0", "react-native": "*", "react-native-pager-view": ">= 6.0.0", @@ -3565,12 +3617,12 @@ } }, "node_modules/@react-navigation/native": { - "version": "7.1.31", - "resolved": "https://registry.npmmirror.com/@react-navigation/native/-/native-7.1.31.tgz", - "integrity": "sha512-+YCUwtfDgsux59Q0LDHc3Zid9ih93ecUCFWZOH6/+eNoUGnWx77wjS6ZfvBO/7E+EiIup11IVShDzCHR4of8hw==", + "version": "7.1.34", + "resolved": "https://registry.npmmirror.com/@react-navigation/native/-/native-7.1.34.tgz", + "integrity": "sha512-zzQ0mKAhLsjTIsaoLfILKZVMObJzE0F+bOi0hl2Glt+1Rd2GtaWJ1Z024c3yLmX+Oc79pqoCQLBXpyxtrZu9NQ==", "license": "MIT", "dependencies": { - "@react-navigation/core": "^7.15.1", + "@react-navigation/core": "^7.16.2", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", @@ -3582,18 +3634,18 @@ } }, "node_modules/@react-navigation/native-stack": { - "version": "7.14.2", - "resolved": "https://registry.npmmirror.com/@react-navigation/native-stack/-/native-stack-7.14.2.tgz", - "integrity": "sha512-/nKxFAFSUSGV+NSXrXXcWEcGAHdyp8RyWjoGMDzVPdBhjCLblVSgHWx5y4mm+k0de9V1pkjsftUaroP7rQckzw==", + "version": "7.14.6", + "resolved": "https://registry.npmmirror.com/@react-navigation/native-stack/-/native-stack-7.14.6.tgz", + "integrity": "sha512-VRlC5mLanRPHK0E15Cild6U01Z5TDPBlmt5YcXRBc+hQTAMbMT9XcSTobf3sJXNY0zzDD1IpSs3Ynex/GU225g==", "license": "MIT", "dependencies": { - "@react-navigation/elements": "^2.9.8", + "@react-navigation/elements": "^2.9.11", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0", "warn-once": "^0.1.1" }, "peerDependencies": { - "@react-navigation/native": "^7.1.31", + "@react-navigation/native": "^7.1.34", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", @@ -3672,9 +3724,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.90.20", - "resolved": "https://registry.npmmirror.com/@tanstack/query-core/-/query-core-5.90.20.tgz", - "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", + "version": "5.91.2", + "resolved": "https://registry.npmmirror.com/@tanstack/query-core/-/query-core-5.91.2.tgz", + "integrity": "sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw==", "license": "MIT", "funding": { "type": "github", @@ -3682,12 +3734,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.90.21", - "resolved": "https://registry.npmmirror.com/@tanstack/react-query/-/react-query-5.90.21.tgz", - "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", + "version": "5.91.2", + "resolved": "https://registry.npmmirror.com/@tanstack/react-query/-/react-query-5.91.2.tgz", + "integrity": "sha512-GClLPzbM57iFXv+FlvOUL56XVe00PxuTaVEyj1zAObhRiKF008J5vedmaq7O6ehs+VmPHe8+PUQhMuEyv8d9wQ==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.90.20" + "@tanstack/query-core": "5.91.2" }, "funding": { "type": "github", @@ -3738,6 +3790,12 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/emscripten": { + "version": "1.41.5", + "resolved": "https://registry.npmmirror.com/@types/emscripten/-/emscripten-1.41.5.tgz", + "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", + "license": "MIT" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmmirror.com/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -3778,9 +3836,9 @@ } }, "node_modules/@types/node": { - "version": "25.3.2", - "resolved": "https://registry.npmmirror.com/@types/node/-/node-25.3.2.tgz", - "integrity": "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q==", + "version": "25.5.0", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -3885,6 +3943,15 @@ "node": ">= 0.6" } }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", @@ -3951,29 +4018,6 @@ "strip-ansi": "^5.0.0" } }, - "node_modules/ansi-fragments/node_modules/ansi-regex": { - "version": "4.1.1", - "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-4.1.1.tgz", - "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/ansi-fragments/node_modules/strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^4.1.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -4011,6 +4055,18 @@ "node": ">= 8" } }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/appdirsjs": { "version": "1.2.7", "resolved": "https://registry.npmmirror.com/appdirsjs/-/appdirsjs-1.2.7.tgz", @@ -4025,13 +4081,10 @@ "license": "MIT" }, "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" }, "node_modules/aria-hidden": { "version": "1.2.6", @@ -4144,28 +4197,19 @@ } }, "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.15", - "resolved": "https://registry.npmmirror.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.15.tgz", - "integrity": "sha512-hR3GwrRwHUfYwGfrisXPIDP3JcYfBrW7wKE7+Au6wDYl7fm/ka1NEII6kORzxNU556JjfidZeBsO10kYvtV1aw==", + "version": "0.4.17", + "resolved": "https://registry.npmmirror.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", "license": "MIT", "dependencies": { "@babel/compat-data": "^7.28.6", - "@babel/helper-define-polyfill-provider": "^0.6.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, - "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/babel-plugin-polyfill-corejs3": { "version": "0.13.0", "resolved": "https://registry.npmmirror.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", @@ -4180,12 +4224,12 @@ } }, "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.6", - "resolved": "https://registry.npmmirror.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.6.tgz", - "integrity": "sha512-hYm+XLYRMvupxiQzrvXUj7YyvFFVfv5gI0R71AJzudg1g2AI2vyCPPIFEBjk162/wFzti3inBHo7isWFuEVS/A==", + "version": "0.6.8", + "resolved": "https://registry.npmmirror.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.6" + "@babel/helper-define-polyfill-provider": "^0.6.8" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -4207,12 +4251,12 @@ "license": "MIT" }, "node_modules/babel-plugin-syntax-hermes-parser": { - "version": "0.32.0", - "resolved": "https://registry.npmmirror.com/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.32.0.tgz", - "integrity": "sha512-m5HthL++AbyeEA2FcdwOLfVFvWYECOBObLHNqdR8ceY4TsEdn4LdX2oTvbB2QJSSElE2AWA/b2MXZ/PF/CqLZg==", + "version": "0.32.1", + "resolved": "https://registry.npmmirror.com/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.32.1.tgz", + "integrity": "sha512-HgErPZTghW76Rkq9uqn5ESeiD97FbqpZ1V170T1RG2RDp+7pJVQV2pQJs7y5YzN0/gcT6GM5ci9apRnIwuyPdQ==", "license": "MIT", "dependencies": { - "hermes-parser": "0.32.0" + "hermes-parser": "0.32.1" } }, "node_modules/babel-plugin-transform-flow-enums": { @@ -4250,6 +4294,54 @@ "@babel/core": "^7.0.0 || ^8.0.0-0" } }, + "node_modules/babel-preset-expo": { + "version": "55.0.12", + "resolved": "https://registry.npmmirror.com/babel-preset-expo/-/babel-preset-expo-55.0.12.tgz", + "integrity": "sha512-oR46ExGZpRijmPUsr0rFH5X4lR/mvwqJAFXJRLpynZcvyv2pHPTeGMNfd/p5oPMbdbaeMS6G+3k18p48u2Qjbw==", + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.20.5", + "@babel/helper-module-imports": "^7.25.9", + "@babel/plugin-proposal-decorators": "^7.12.9", + "@babel/plugin-proposal-export-default-from": "^7.24.7", + "@babel/plugin-syntax-export-default-from": "^7.24.7", + "@babel/plugin-transform-class-static-block": "^7.27.1", + "@babel/plugin-transform-export-namespace-from": "^7.25.9", + "@babel/plugin-transform-flow-strip-types": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-object-rest-spread": "^7.24.7", + "@babel/plugin-transform-parameters": "^7.24.7", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-runtime": "^7.24.7", + "@babel/preset-react": "^7.22.15", + "@babel/preset-typescript": "^7.23.0", + "@react-native/babel-preset": "0.83.2", + "babel-plugin-react-compiler": "^1.0.0", + "babel-plugin-react-native-web": "~0.21.0", + "babel-plugin-syntax-hermes-parser": "^0.32.0", + "babel-plugin-transform-flow-enums": "^0.0.2", + "debug": "^4.3.4", + "resolve-from": "^5.0.0" + }, + "peerDependencies": { + "@babel/runtime": "^7.20.0", + "expo": "*", + "expo-widgets": "^55.0.6", + "react-refresh": ">=0.14.0 <1.0.0" + }, + "peerDependenciesMeta": { + "@babel/runtime": { + "optional": true + }, + "expo": { + "optional": true + }, + "expo-widgets": { + "optional": true + } + } + }, "node_modules/babel-preset-jest": { "version": "29.6.3", "resolved": "https://registry.npmmirror.com/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", @@ -4281,6 +4373,15 @@ "node": "18 || 20 || >=22" } }, + "node_modules/barcode-detector": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/barcode-detector/-/barcode-detector-3.1.1.tgz", + "integrity": "sha512-ghWlEAV93ZCUniO7Co3ih/01XPm+U30CV+NoPbO6Chj5lZzHydDAqKlrBEd+37TkoR+QTH3tnnwd8k8epGTfIg==", + "license": "MIT", + "dependencies": { + "zxing-wasm": "3.0.1" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", @@ -4302,9 +4403,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.0", - "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", - "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "version": "2.10.9", + "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.9.tgz", + "integrity": "sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -4325,6 +4426,18 @@ "node": ">=12.0.0" } }, + "node_modules/better-opn/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/better-opn/node_modules/open": { "version": "8.4.2", "resolved": "https://registry.npmmirror.com/open/-/open-8.4.2.tgz", @@ -4388,19 +4501,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/body-parser/node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/bplist-creator": { "version": "0.1.0", "resolved": "https://registry.npmmirror.com/bplist-creator/-/bplist-creator-0.1.0.tgz", @@ -4581,9 +4681,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001774", - "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", - "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", + "version": "1.0.30001780", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", + "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", "funding": [ { "type": "opencollective", @@ -4634,6 +4734,18 @@ "node": ">=12.13.0" } }, + "node_modules/chrome-launcher/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/chromium-edge-launcher": { "version": "0.2.0", "resolved": "https://registry.npmmirror.com/chromium-edge-launcher/-/chromium-edge-launcher-0.2.0.tgz", @@ -4648,6 +4760,18 @@ "rimraf": "^3.0.2" } }, + "node_modules/chromium-edge-launcher/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ci-info": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/ci-info/-/ci-info-2.0.0.tgz", @@ -4655,15 +4779,16 @@ "license": "MIT" }, "node_modules/cli-cursor": { - "version": "2.1.0", - "resolved": "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-2.1.0.tgz", - "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "devOptional": true, "license": "MIT", "dependencies": { - "restore-cursor": "^2.0.0" + "restore-cursor": "^3.1.0" }, "engines": { - "node": ">=4" + "node": ">=8" } }, "node_modules/cli-spinners": { @@ -4698,6 +4823,18 @@ "node": ">=12" } }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/clone": { "version": "1.0.4", "resolved": "https://registry.npmmirror.com/clone/-/clone-1.0.4.tgz", @@ -4775,12 +4912,13 @@ "license": "MIT" }, "node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmmirror.com/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "version": "9.5.0", + "resolved": "https://registry.npmmirror.com/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "devOptional": true, "license": "MIT", "engines": { - "node": ">= 10" + "node": "^12.20.0 || >=14" } }, "node_modules/compressible": { @@ -4828,15 +4966,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/compression/node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", @@ -4890,9 +5019,9 @@ "license": "MIT" }, "node_modules/core-js-compat": { - "version": "3.48.0", - "resolved": "https://registry.npmmirror.com/core-js-compat/-/core-js-compat-3.48.0.tgz", - "integrity": "sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==", + "version": "3.49.0", + "resolved": "https://registry.npmmirror.com/core-js-compat/-/core-js-compat-3.49.0.tgz", + "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", "license": "MIT", "dependencies": { "browserslist": "^4.28.1" @@ -4929,26 +5058,6 @@ } } }, - "node_modules/cosmiconfig/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "devOptional": true, - "license": "Python-2.0" - }, - "node_modules/cosmiconfig/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/cross-fetch": { "version": "3.2.0", "resolved": "https://registry.npmmirror.com/cross-fetch/-/cross-fetch-3.2.0.tgz", @@ -4999,9 +5108,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.19", - "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz", - "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "version": "1.11.20", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", "devOptional": true, "license": "MIT" }, @@ -5141,9 +5250,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.302", - "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", - "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "version": "1.5.321", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", + "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -5161,6 +5270,18 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz", @@ -5194,13 +5315,6 @@ "is-arrayish": "^0.2.1" } }, - "node_modules/error-ex/node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "devOptional": true, - "license": "MIT" - }, "node_modules/error-stack-parser": { "version": "2.1.4", "resolved": "https://registry.npmmirror.com/error-stack-parser/-/error-stack-parser-2.1.4.tgz", @@ -5355,58 +5469,32 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/execa/node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/execa/node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/expo": { - "version": "55.0.4", - "resolved": "https://registry.npmmirror.com/expo/-/expo-55.0.4.tgz", - "integrity": "sha512-cbQBPYwmH6FRvh942KR8mSdEcrVdsIMkjdHthtf59zlpzgrk28FabhOdL/Pc9WuS+CsIP3EIQbZqmLkTjv6qPg==", + "version": "55.0.8", + "resolved": "https://registry.npmmirror.com/expo/-/expo-55.0.8.tgz", + "integrity": "sha512-sziDGiDmeRmaSpFwMuSxFhr4vfWrQS1UgVXSTovsUDY0ximABzYdnF5L2OwtD8zjtIww8x2oJGmD6mKS+AoVsw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.0", - "@expo/cli": "55.0.14", - "@expo/config": "~55.0.8", - "@expo/config-plugins": "~55.0.6", + "@expo/cli": "55.0.18", + "@expo/config": "~55.0.10", + "@expo/config-plugins": "~55.0.7", "@expo/devtools": "55.0.2", - "@expo/fingerprint": "0.16.5", - "@expo/local-build-cache-provider": "55.0.6", + "@expo/fingerprint": "0.16.6", + "@expo/local-build-cache-provider": "55.0.7", "@expo/log-box": "55.0.7", "@expo/metro": "~54.2.0", - "@expo/metro-config": "55.0.9", + "@expo/metro-config": "55.0.11", "@expo/vector-icons": "^15.0.2", "@ungap/structured-clone": "^1.3.0", - "babel-preset-expo": "~55.0.10", - "expo-asset": "~55.0.8", - "expo-constants": "~55.0.7", - "expo-file-system": "~55.0.10", + "babel-preset-expo": "~55.0.12", + "expo-asset": "~55.0.10", + "expo-constants": "~55.0.9", + "expo-file-system": "~55.0.11", "expo-font": "~55.0.4", "expo-keep-awake": "~55.0.4", - "expo-modules-autolinking": "55.0.8", - "expo-modules-core": "55.0.13", + "expo-modules-autolinking": "55.0.11", + "expo-modules-core": "55.0.17", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.1" @@ -5436,33 +5524,80 @@ } }, "node_modules/expo-application": { - "version": "55.0.8", - "resolved": "https://registry.npmmirror.com/expo-application/-/expo-application-55.0.8.tgz", - "integrity": "sha512-PeZk4Zj8LlzRcRtK3J4ouSPBoi9lroYsRbbz/0HEvx+uB6HIaM1qfzgpcctvjkdJJfnidBQNyieW5BVO/qUQ6w==", + "version": "55.0.10", + "resolved": "https://registry.npmmirror.com/expo-application/-/expo-application-55.0.10.tgz", + "integrity": "sha512-5ccf+S6hsQz+doi907TOJxKzV5AKgAgw004z4FoDWSoGhfab0LUPg6uyvOspuU4cbNvqw8EAy08hZbVO8nKc9Q==", "license": "MIT", "peerDependencies": { "expo": "*" } }, - "node_modules/expo-background-fetch": { - "version": "55.0.9", - "resolved": "https://registry.npmmirror.com/expo-background-fetch/-/expo-background-fetch-55.0.9.tgz", - "integrity": "sha512-SIXlLyUsEgqZZ9RZBUAHMGudMYUco2LWbWJkC79PzkedSTRuhQUergFhVR+n+OFe/YYfw2l9Rk2KSwtN+1WcYA==", + "node_modules/expo-asset": { + "version": "55.0.10", + "resolved": "https://registry.npmmirror.com/expo-asset/-/expo-asset-55.0.10.tgz", + "integrity": "sha512-wxjNBKIaDyachq7oJgVlWVFzZ6SnNpJFJhkkcymXoTPt5O3XmDM+a6fT91xQQawCXTyZuCc1sNxKMetEofeYkg==", "license": "MIT", "dependencies": { - "expo-task-manager": "~55.0.9" + "@expo/image-utils": "^0.8.12", + "expo-constants": "~55.0.9" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-background-fetch": { + "version": "55.0.10", + "resolved": "https://registry.npmmirror.com/expo-background-fetch/-/expo-background-fetch-55.0.10.tgz", + "integrity": "sha512-JRSlQ2GEigUxKqHi11xLPz5/T02O7YzsV1xNKDOxr2SXSERmAN9WFD9l2F9SDw5tUz53ewI1kndHQ6FmSu+7Tw==", + "license": "MIT", + "dependencies": { + "expo-task-manager": "~55.0.10" }, "peerDependencies": { "expo": "*" } }, - "node_modules/expo-constants": { - "version": "55.0.7", - "resolved": "https://registry.npmmirror.com/expo-constants/-/expo-constants-55.0.7.tgz", - "integrity": "sha512-kdcO4TsQRRqt0USvjaY5vgQMO9H52K3kBZ/ejC7F6rz70mv08GoowrZ1CYOr5O4JpPDRlIpQfZJUucaS/c+KWQ==", + "node_modules/expo-background-task": { + "version": "55.0.10", + "resolved": "https://registry.npmmirror.com/expo-background-task/-/expo-background-task-55.0.10.tgz", + "integrity": "sha512-mkwdS8T34P0pSphr08yv9lsQ6pd1pMgFdVq0wd3Ane3h5heZj/il6mes/b8OQDoxtD3mLFj7yKDYOU9dAwSrJA==", "license": "MIT", "dependencies": { - "@expo/config": "~55.0.8", + "expo-task-manager": "~55.0.10" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-camera": { + "version": "55.0.10", + "resolved": "https://registry.npmmirror.com/expo-camera/-/expo-camera-55.0.10.tgz", + "integrity": "sha512-ftDNJbGsAPNJ/QrM3j6g8/rQAOqTwZpqtvmzF7V9VX0movaCznZFdYsLi/Fff9WeEk1KzcnLIlmSz4Tj+BCrJA==", + "license": "MIT", + "dependencies": { + "barcode-detector": "^3.0.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, + "node_modules/expo-constants": { + "version": "55.0.9", + "resolved": "https://registry.npmmirror.com/expo-constants/-/expo-constants-55.0.9.tgz", + "integrity": "sha512-iBiXjZeuU5S/8docQeNzsVvtDy4w0zlmXBpFEi1ypwugceEpdQQab65TVRbusXAcwpNVxCPMpNlDssYp0Pli2g==", + "license": "MIT", + "dependencies": { + "@expo/config": "~55.0.10", "@expo/env": "~2.1.1" }, "peerDependencies": { @@ -5471,15 +5606,15 @@ } }, "node_modules/expo-dev-client": { - "version": "55.0.10", - "resolved": "https://registry.npmmirror.com/expo-dev-client/-/expo-dev-client-55.0.10.tgz", - "integrity": "sha512-qclT+uDp5VjdHDrXkMus0d8ZpNq41CzOXWJq4UmlfsuFeY4b7v/vAI0OJTtScx/FSTkSkggRzjqm+EwnmIFRCg==", + "version": "55.0.18", + "resolved": "https://registry.npmmirror.com/expo-dev-client/-/expo-dev-client-55.0.18.tgz", + "integrity": "sha512-zQeCGk+doTMIKwPe9lNlGcUiBlMpJQNyrrsYc5eRdxuMwBrDVAU7zshARmamSw8zlAIHCsThJH2zeJg/lgQrKA==", "license": "MIT", "dependencies": { - "expo-dev-launcher": "55.0.11", - "expo-dev-menu": "55.0.10", + "expo-dev-launcher": "55.0.19", + "expo-dev-menu": "55.0.16", "expo-dev-menu-interface": "55.0.1", - "expo-manifests": "~55.0.9", + "expo-manifests": "~55.0.11", "expo-updates-interface": "~55.1.3" }, "peerDependencies": { @@ -5487,23 +5622,23 @@ } }, "node_modules/expo-dev-launcher": { - "version": "55.0.11", - "resolved": "https://registry.npmmirror.com/expo-dev-launcher/-/expo-dev-launcher-55.0.11.tgz", - "integrity": "sha512-u/8iVwD4VU2N5R5Tr32+yqT7Llm8K7VyfYB76Fe5apK97Ocpf2PR4Oqf8RbKc9DmqcbHEWfNriMGEW8U6FsaKA==", + "version": "55.0.19", + "resolved": "https://registry.npmmirror.com/expo-dev-launcher/-/expo-dev-launcher-55.0.19.tgz", + "integrity": "sha512-RtiC/K7Cg7RafNlq32GtilMEO5cy61HPBKASQHwLK6Gz5+FYepx0KSHIXIhWqekZMfpcj1w80tvEjMNFjUJ9Dg==", "license": "MIT", "dependencies": { "@expo/schema-utils": "^55.0.2", - "expo-dev-menu": "55.0.10", - "expo-manifests": "~55.0.9" + "expo-dev-menu": "55.0.16", + "expo-manifests": "~55.0.11" }, "peerDependencies": { "expo": "*" } }, "node_modules/expo-dev-menu": { - "version": "55.0.10", - "resolved": "https://registry.npmmirror.com/expo-dev-menu/-/expo-dev-menu-55.0.10.tgz", - "integrity": "sha512-gad31DFkRmEC6pj6sZLIv3HY14PR3X6SUwUTtilArF5eQK/Nr3dWhYFL/QHlrBkwTEDYeKKgcw3V6sZDfE6hcg==", + "version": "55.0.16", + "resolved": "https://registry.npmmirror.com/expo-dev-menu/-/expo-dev-menu-55.0.16.tgz", + "integrity": "sha512-UhduIh/6wQmFLIy1EeQJBCN09irOrC1kf/UMQ9CxXCkXit9SOtJx+26YfCmbrIRV/lYK2qZK8Y178/HNkt7yeA==", "license": "MIT", "dependencies": { "expo-dev-menu-interface": "55.0.1" @@ -5528,9 +5663,9 @@ "license": "MIT" }, "node_modules/expo-file-system": { - "version": "55.0.10", - "resolved": "https://registry.npmmirror.com/expo-file-system/-/expo-file-system-55.0.10.tgz", - "integrity": "sha512-ysFdVdUgtfj2ApY0Cn+pBg+yK4xp+SNwcaH8j2B91JJQ4OXJmnyCSmrNZYz7J4mdYVuv2GzxIP+N/IGlHQG3Yw==", + "version": "55.0.11", + "resolved": "https://registry.npmmirror.com/expo-file-system/-/expo-file-system-55.0.11.tgz", + "integrity": "sha512-KMUd6OY375J9WD79ZvjvCDZMveT7YfgiGWdi58/gfuTBsr14TRuoPk8RRQHAtc4UquzWViKcHwna9aPY7/XPpw==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -5552,9 +5687,9 @@ } }, "node_modules/expo-glass-effect": { - "version": "55.0.7", - "resolved": "https://registry.npmmirror.com/expo-glass-effect/-/expo-glass-effect-55.0.7.tgz", - "integrity": "sha512-G7Q9rUaEY0YC36fGE6irDljfsfvzz/y49zagARAKvSJSyQMUSrhR25WOr5LK5Cw7gQNNBEy9U1ctlr7yCay/fQ==", + "version": "55.0.8", + "resolved": "https://registry.npmmirror.com/expo-glass-effect/-/expo-glass-effect-55.0.8.tgz", + "integrity": "sha512-IvUjHb/4t6r2H/LXDjcQ4uDoHrmO2cLOvEb9leLavQ4HX5+P4LRtQrMDMlkWAn5Wo5DkLcG8+1CrQU2nqgogTA==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -5563,9 +5698,9 @@ } }, "node_modules/expo-haptics": { - "version": "55.0.8", - "resolved": "https://registry.npmmirror.com/expo-haptics/-/expo-haptics-55.0.8.tgz", - "integrity": "sha512-yVR6EsQwl1WuhFITc0PpfI/7dsBdjK/F2YA8xB80UUW9iTa+Tqz21FpH4n/vtbargpzFxkhl5WNYMa419+QWFQ==", + "version": "55.0.9", + "resolved": "https://registry.npmmirror.com/expo-haptics/-/expo-haptics-55.0.9.tgz", + "integrity": "sha512-KCRyHr/uu4syXmoq3aIQ6ahuaX6FGtlPkWGlLlHJ836WF3nG+5+oCaCQiI7qMTpml+Tp/V/zP4ZaowM2KHgLNA==", "license": "MIT", "peerDependencies": { "expo": "*" @@ -5601,9 +5736,9 @@ } }, "node_modules/expo-image-picker": { - "version": "55.0.10", - "resolved": "https://registry.npmmirror.com/expo-image-picker/-/expo-image-picker-55.0.10.tgz", - "integrity": "sha512-uspDWjNBNjUD//MLzu7oEtT9cuNgW5pd6HxPfAJffRIdkdCUCYxLYXRp5pfB6JtW640eCdvm2QEQrQC172Y62g==", + "version": "55.0.13", + "resolved": "https://registry.npmmirror.com/expo-image-picker/-/expo-image-picker-55.0.13.tgz", + "integrity": "sha512-G+W11rcoUi3rK+6cnKWkTfZilMkGVZnYe90TiM3R98nPSlzGBoto3a/TkGGTJXedz/dmMzr49L+STlWhuKKIFw==", "license": "MIT", "dependencies": { "expo-image-loader": "~55.0.0" @@ -5618,10 +5753,20 @@ "integrity": "sha512-aupt/o5PDAb8dXDCb0JcRdkqnTLxe/F+La7jrnyd/sXlYFfRgBJLFOa1SqVFXm1E/Xam1SE/yw6eAb+DGY7Arg==", "license": "MIT" }, + "node_modules/expo-keep-awake": { + "version": "55.0.4", + "resolved": "https://registry.npmmirror.com/expo-keep-awake/-/expo-keep-awake-55.0.4.tgz", + "integrity": "sha512-vwfdMtMS5Fxaon8gC0AiE70SpxTsHJ+rjeoVJl8kdfdbxczF7OIaVmfjFJ5Gfigd/WZiLqxhfZk34VAkXF4PNg==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*" + } + }, "node_modules/expo-linear-gradient": { - "version": "55.0.8", - "resolved": "https://registry.npmmirror.com/expo-linear-gradient/-/expo-linear-gradient-55.0.8.tgz", - "integrity": "sha512-nCMgZXcHesnFslH1TUFMdqlDiE4jYTTTSHb97g1jq1gyGX2xDEOyqBxQJmiIVGrZJ+kTWH6RljJ5tYyva9mrAg==", + "version": "55.0.9", + "resolved": "https://registry.npmmirror.com/expo-linear-gradient/-/expo-linear-gradient-55.0.9.tgz", + "integrity": "sha512-S82iF+CVoSBVHdwusLQGh6Th/kcWLHU47jZhBPwyTrYWnsHZtb0oCqU96YvhDYvhbTdsuOaKEi+Xu+r/I2R8ow==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -5630,13 +5775,13 @@ } }, "node_modules/expo-linking": { - "version": "55.0.7", - "resolved": "https://registry.npmmirror.com/expo-linking/-/expo-linking-55.0.7.tgz", - "integrity": "sha512-MiGCedere1vzQTEi2aGrkzd7eh/rPSz4w6F3GMBuAJzYl+/0VhIuyhozpEGrueyDIXWfzaUVOcn3SfxVi+kwQQ==", + "version": "55.0.8", + "resolved": "https://registry.npmmirror.com/expo-linking/-/expo-linking-55.0.8.tgz", + "integrity": "sha512-O9QgKAfEqKfsjL6IKs5p7pFAjo/3/TQwjMzzNPl8BCndbxWMPQfMeViXPYYNS9bA2ujUqrtF1OYhO6woI7GNQQ==", "license": "MIT", "peer": true, "dependencies": { - "expo-constants": "~55.0.7", + "expo-constants": "~55.0.8", "invariant": "^2.2.4" }, "peerDependencies": { @@ -5645,12 +5790,12 @@ } }, "node_modules/expo-manifests": { - "version": "55.0.9", - "resolved": "https://registry.npmmirror.com/expo-manifests/-/expo-manifests-55.0.9.tgz", - "integrity": "sha512-i82j3X4hbxYDe6kxUw4u8WfvbvTj2w+9BD9WKuL0mFRy+MjvdzdyaqAjEViWCKo/alquP/hTApDTQBb3UmWhkg==", + "version": "55.0.11", + "resolved": "https://registry.npmmirror.com/expo-manifests/-/expo-manifests-55.0.11.tgz", + "integrity": "sha512-3+pFun4C9F/eFMVpwZgOBrBWq5sfu7rS1uxTrcg9G7jUFatNe5W6hr+M7z7aQPDf0J1afaSudUZPawx1LLf15w==", "license": "MIT", "dependencies": { - "@expo/config": "~55.0.8", + "@expo/config": "~55.0.10", "expo-json-utils": "~55.0.0" }, "peerDependencies": { @@ -5658,9 +5803,9 @@ } }, "node_modules/expo-media-library": { - "version": "55.0.9", - "resolved": "https://registry.npmmirror.com/expo-media-library/-/expo-media-library-55.0.9.tgz", - "integrity": "sha512-E12e4gjQEZNdAa7MHDLOAiOMQmhmOGHFMMU5DpiK6I01hPXyRcaxgAQQLpibIXNP4O5LjGi2psa3NBacjyjxkw==", + "version": "55.0.10", + "resolved": "https://registry.npmmirror.com/expo-media-library/-/expo-media-library-55.0.10.tgz", + "integrity": "sha512-xXmz8Do9BJSt1LrkC6r8l2HAXNdaAr2TdzCuiVo9u47Vuo54twlOgzLpdx4rIzODy/cclqbYb2tae4nCKHJLbw==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -5668,12 +5813,12 @@ } }, "node_modules/expo-modules-autolinking": { - "version": "55.0.8", - "resolved": "https://registry.npmmirror.com/expo-modules-autolinking/-/expo-modules-autolinking-55.0.8.tgz", - "integrity": "sha512-nrWB1pkNp7bR8ECUTgYUiJ2Pyh6AvxCBXZ+lyPlfl1TzEIGhwU1Yqr+d78eJDueXaW+9zKeE0HqrTZoLS3ve4A==", + "version": "55.0.11", + "resolved": "https://registry.npmmirror.com/expo-modules-autolinking/-/expo-modules-autolinking-55.0.11.tgz", + "integrity": "sha512-9dqnPzQoIl1dIvEctMWpQ8eaiXDeBTgAwebCc1WF0BbEo+pcdKjZWoCSqlLj+d7IX+OnTgM+k6cY2kPDGIu4sg==", "license": "MIT", "dependencies": { - "@expo/require-utils": "^55.0.2", + "@expo/require-utils": "^55.0.3", "@expo/spawn-async": "^1.7.2", "chalk": "^4.1.0", "commander": "^7.2.0" @@ -5682,10 +5827,19 @@ "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, + "node_modules/expo-modules-autolinking/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/expo-modules-core": { - "version": "55.0.13", - "resolved": "https://registry.npmmirror.com/expo-modules-core/-/expo-modules-core-55.0.13.tgz", - "integrity": "sha512-DYLQTOJAR7jD3M9S0sH9myZaPEtShdicHrPiWcupIXMeMkQxFzErx+adUI8gZPy4AU45BgeGgtaogRfT25iLfw==", + "version": "55.0.17", + "resolved": "https://registry.npmmirror.com/expo-modules-core/-/expo-modules-core-55.0.17.tgz", + "integrity": "sha512-pw3cZiaSlBrqRJUD/pHuMnKGsRTW6XJ255FrjDd3HC4QrqErCnfSQPmz+Sv4Qkelcvd9UGdAewyTqZdFwjLwOw==", "license": "MIT", "dependencies": { "invariant": "^2.2.4" @@ -5696,16 +5850,16 @@ } }, "node_modules/expo-notifications": { - "version": "55.0.10", - "resolved": "https://registry.npmmirror.com/expo-notifications/-/expo-notifications-55.0.10.tgz", - "integrity": "sha512-F+ozrVFthKCwfqz2cXmcqrqwzBMTAwoNBqTZERuFtgc+6I++mweVzLLTtbAy8kBDZ33MA13GKyeq7mw13/rDgw==", + "version": "55.0.13", + "resolved": "https://registry.npmmirror.com/expo-notifications/-/expo-notifications-55.0.13.tgz", + "integrity": "sha512-vbtSBcMkYtNTO+6WKdeOzysOqvtmiq/sQrUKJpYcB75m9hBFcAfI2klpXdUiGg5kMr/ygBmFENSolQt1B9QY8A==", "license": "MIT", "dependencies": { "@expo/image-utils": "^0.8.12", "abort-controller": "^3.0.0", "badgin": "^1.1.5", - "expo-application": "~55.0.8", - "expo-constants": "~55.0.7" + "expo-application": "~55.0.10", + "expo-constants": "~55.0.8" }, "peerDependencies": { "expo": "*", @@ -5714,22 +5868,22 @@ } }, "node_modules/expo-router": { - "version": "55.0.4", - "resolved": "https://registry.npmmirror.com/expo-router/-/expo-router-55.0.4.tgz", - "integrity": "sha512-wLKxc9l3IaE96UJFvwXKi2YYYjYK/VUttwAwcnljaUA2dLgDruNGmjsBS9A+g3aK3lt2/JJRu+cec7ZLJ9r6Wg==", + "version": "55.0.7", + "resolved": "https://registry.npmmirror.com/expo-router/-/expo-router-55.0.7.tgz", + "integrity": "sha512-UdraTi8/1LGCCEnq/3+wEVnM11b4ezFEIvMsWP9ajFvEhFGkcXlQitvSehT2yI5cbBrBaIMP2p/2naBiPyYVyw==", "license": "MIT", "dependencies": { "@expo/metro-runtime": "^55.0.6", "@expo/schema-utils": "^55.0.2", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", - "@react-navigation/bottom-tabs": "^7.10.1", - "@react-navigation/native": "^7.1.28", - "@react-navigation/native-stack": "^7.10.1", + "@react-navigation/bottom-tabs": "^7.15.5", + "@react-navigation/native": "^7.1.33", + "@react-navigation/native-stack": "^7.14.5", "client-only": "^0.0.1", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", - "expo-glass-effect": "^55.0.7", + "expo-glass-effect": "^55.0.8", "expo-image": "^55.0.6", "expo-server": "^55.0.6", "expo-symbols": "^55.0.5", @@ -5749,11 +5903,11 @@ "peerDependencies": { "@expo/log-box": "55.0.7", "@expo/metro-runtime": "^55.0.6", - "@react-navigation/drawer": "^7.7.2", + "@react-navigation/drawer": "^7.9.4", "@testing-library/react-native": ">= 13.2.0", "expo": "*", - "expo-constants": "^55.0.7", - "expo-linking": "^55.0.7", + "expo-constants": "^55.0.8", + "expo-linking": "^55.0.8", "react": "*", "react-dom": "*", "react-native": "*", @@ -5810,9 +5964,9 @@ } }, "node_modules/expo-sqlite": { - "version": "55.0.10", - "resolved": "https://registry.npmmirror.com/expo-sqlite/-/expo-sqlite-55.0.10.tgz", - "integrity": "sha512-yLQXkwcA0OVSKuL4t+a6vv+H/Klh8147n7hH75AN0MkC48p3Go7+6GM+3SFENeaBUsmOnOS3XSFxMxxj6PIg4g==", + "version": "55.0.11", + "resolved": "https://registry.npmmirror.com/expo-sqlite/-/expo-sqlite-55.0.11.tgz", + "integrity": "sha512-lDGJrE0m1lw/3y1ZSsER2kfXnS+9WzgaKcIFp/RKbTfyhs0v8l86Ulqdr+6peRFOfzi0kdj4Ty0LzE2Adx93tg==", "license": "MIT", "dependencies": { "await-lock": "^2.2.2" @@ -5859,9 +6013,9 @@ } }, "node_modules/expo-system-ui": { - "version": "55.0.9", - "resolved": "https://registry.npmmirror.com/expo-system-ui/-/expo-system-ui-55.0.9.tgz", - "integrity": "sha512-8ygP1B0uFAFI8s7eHY2IcGnE83GhFeZYwHBr/fQ4dSXnc7iVT9zp2PvyTyiDiibQ69dBG+fauMQ4KlPcOO51kQ==", + "version": "55.0.10", + "resolved": "https://registry.npmmirror.com/expo-system-ui/-/expo-system-ui-55.0.10.tgz", + "integrity": "sha512-s0Fyf37ma3TaWhh18uocg914Uz0tKPaYBf+mEME7Tp88d3FxOTg+tTv1S9mUe4lJNXY+0KlWFz9J4SMNyjGRWQ==", "license": "MIT", "dependencies": { "@react-native/normalize-colors": "0.83.2", @@ -5879,9 +6033,9 @@ } }, "node_modules/expo-task-manager": { - "version": "55.0.9", - "resolved": "https://registry.npmmirror.com/expo-task-manager/-/expo-task-manager-55.0.9.tgz", - "integrity": "sha512-ABqEua5FCjXmzRB+OGKD2KcQL2gkh57nCwGnFGe5Km+/b+0uxIs0lq0L3PPyoYPs1tAWp5FKfn2jcnz1nbBCVA==", + "version": "55.0.10", + "resolved": "https://registry.npmmirror.com/expo-task-manager/-/expo-task-manager-55.0.10.tgz", + "integrity": "sha512-QjlVPiBqiDaALssZAQ9w14quELKQ9DBdV4sfmH6rICUbDvSBFiH91HbGxROXcQEDrU8lrVDObJQBxrJK9ucaaw==", "license": "MIT", "dependencies": { "unimodules-app-loader": "~55.0.2" @@ -5892,9 +6046,9 @@ } }, "node_modules/expo-updates": { - "version": "55.0.12", - "resolved": "https://registry.npmmirror.com/expo-updates/-/expo-updates-55.0.12.tgz", - "integrity": "sha512-20YTlmivT7pU8+jYMQHqHQmFTdNHHfIMXXVuFjQA31qCyAOwR32AvDn30IBpgn1Z7XJTX+Sr6cEoMhqz5IJfww==", + "version": "55.0.15", + "resolved": "https://registry.npmmirror.com/expo-updates/-/expo-updates-55.0.15.tgz", + "integrity": "sha512-UE9Ik56trq//kNeJ/BlC5vOTYdNTvsHwhfWFYMazP1UOQK4lnX59/t0qz8Ut+3aPXZZT7+B6mnbWtic0QqN1wA==", "license": "MIT", "dependencies": { "@expo/code-signing-certificates": "^0.0.6", @@ -5904,7 +6058,7 @@ "chalk": "^4.1.2", "debug": "^4.3.4", "expo-eas-client": "~55.0.2", - "expo-manifests": "~55.0.9", + "expo-manifests": "~55.0.11", "expo-structured-headers": "~55.0.0", "expo-updates-interface": "~55.1.3", "getenv": "^2.0.0", @@ -5937,9 +6091,9 @@ "license": "MIT" }, "node_modules/expo-video": { - "version": "55.0.10", - "resolved": "https://registry.npmmirror.com/expo-video/-/expo-video-55.0.10.tgz", - "integrity": "sha512-L3UXgvGjrJv4ym3PnIGPPQi4LlVHQSh89eYm2Q7Pn9iUy5ce98sAdqPIKV88bfdLAIWfmPecYUoOzozXBPmenw==", + "version": "55.0.11", + "resolved": "https://registry.npmmirror.com/expo-video/-/expo-video-55.0.11.tgz", + "integrity": "sha512-XCXcPfzVYx6CgXlbRPH2sgacsNT82T6+euyyFlZsG+iuF5tUOWnMgToTuCmktAkwKDe70pNonoQI9HtZjSfNXw==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -5948,27 +6102,27 @@ } }, "node_modules/expo/node_modules/@expo/cli": { - "version": "55.0.14", - "resolved": "https://registry.npmmirror.com/@expo/cli/-/cli-55.0.14.tgz", - "integrity": "sha512-glXPSjjLCIz+KX/ezqLTGIF9eTE1lexiCxunvB3loRZNnGeBDGW3eF++cuPKudW26jeC6bqZkcqBG7Lp0Sp9qg==", + "version": "55.0.18", + "resolved": "https://registry.npmmirror.com/@expo/cli/-/cli-55.0.18.tgz", + "integrity": "sha512-3sJwu8KvCvQIXBnhUlHgLBZBe+ZK4Da9R5rgI4znaowJavYWMqzRClLzyE6Kri66WVoMX7Q4HUVIh8prRlO0XA==", "license": "MIT", "dependencies": { "@expo/code-signing-certificates": "^0.0.6", - "@expo/config": "~55.0.8", - "@expo/config-plugins": "~55.0.6", + "@expo/config": "~55.0.10", + "@expo/config-plugins": "~55.0.7", "@expo/devcert": "^1.2.1", "@expo/env": "~2.1.1", "@expo/image-utils": "^0.8.12", "@expo/json-file": "^10.0.12", "@expo/log-box": "55.0.7", "@expo/metro": "~54.2.0", - "@expo/metro-config": "~55.0.9", + "@expo/metro-config": "~55.0.11", "@expo/osascript": "^2.4.2", "@expo/package-manager": "^1.10.3", "@expo/plist": "^0.5.2", - "@expo/prebuild-config": "^55.0.8", - "@expo/require-utils": "^55.0.2", - "@expo/router-server": "^55.0.9", + "@expo/prebuild-config": "^55.0.10", + "@expo/require-utils": "^55.0.3", + "@expo/router-server": "^55.0.11", "@expo/schema-utils": "^55.0.2", "@expo/spawn-async": "^1.7.2", "@expo/ws-tunnel": "^1.0.1", @@ -6028,31 +6182,10 @@ } } }, - "node_modules/expo/node_modules/@expo/cli/node_modules/@expo/prebuild-config": { - "version": "55.0.8", - "resolved": "https://registry.npmmirror.com/@expo/prebuild-config/-/prebuild-config-55.0.8.tgz", - "integrity": "sha512-VJNJiOmmZgyDnR7JMmc3B8Z0ZepZ17I8Wtw+wAH/2+UCUsFg588XU+bwgYcFGw+is28kwGjY46z43kfufpxOnA==", - "license": "MIT", - "dependencies": { - "@expo/config": "~55.0.8", - "@expo/config-plugins": "~55.0.6", - "@expo/config-types": "^55.0.5", - "@expo/image-utils": "^0.8.12", - "@expo/json-file": "^10.0.12", - "@react-native/normalize-colors": "0.83.2", - "debug": "^4.3.1", - "resolve-from": "^5.0.0", - "semver": "^7.6.0", - "xml2js": "0.6.0" - }, - "peerDependencies": { - "expo": "*" - } - }, "node_modules/expo/node_modules/@expo/cli/node_modules/@expo/router-server": { - "version": "55.0.9", - "resolved": "https://registry.npmmirror.com/@expo/router-server/-/router-server-55.0.9.tgz", - "integrity": "sha512-LcCFi+P1qfZOsw0DO4JwNKRxtWt4u2bjTYj0PUe4WVf9NVG/NfUetAXYRbBS6P+gupfM6SC+/bdzdqCWQh7j8g==", + "version": "55.0.11", + "resolved": "https://registry.npmmirror.com/@expo/router-server/-/router-server-55.0.11.tgz", + "integrity": "sha512-Kd8J1OOlFR00DZxn+1KfiQiXZtRut6cj8+ynqHJa7dtt/lTL4tGkYistqmVhpKJ6w886eRY5WivKy7o0ZBFkJA==", "license": "MIT", "dependencies": { "debug": "^4.3.4" @@ -6060,7 +6193,7 @@ "peerDependencies": { "@expo/metro-runtime": "^55.0.6", "expo": "*", - "expo-constants": "^55.0.7", + "expo-constants": "^55.0.9", "expo-font": "^55.0.4", "expo-router": "*", "expo-server": "^55.0.6", @@ -6083,87 +6216,16 @@ } } }, - "node_modules/expo/node_modules/@expo/metro-config": { - "version": "55.0.9", - "resolved": "https://registry.npmmirror.com/@expo/metro-config/-/metro-config-55.0.9.tgz", - "integrity": "sha512-ZJFEfat/+dLUhFyFFWrzMjAqAwwUaJ3RD42QNqR7jh+RVYkAf6XYLynb5qrKJTHI1EcOx4KoO1717yXYYRFDBA==", + "node_modules/expo/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.20.0", - "@babel/core": "^7.20.0", - "@babel/generator": "^7.20.5", - "@expo/config": "~55.0.8", - "@expo/env": "~2.1.1", - "@expo/json-file": "~10.0.12", - "@expo/metro": "~54.2.0", - "@expo/spawn-async": "^1.7.2", - "browserslist": "^4.25.0", - "chalk": "^4.1.0", - "debug": "^4.3.2", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "hermes-parser": "^0.32.0", - "jsc-safe-url": "^0.2.4", - "lightningcss": "^1.30.1", - "picomatch": "^4.0.3", - "postcss": "~8.4.32", - "resolve-from": "^5.0.0" + "color-convert": "^1.9.0" }, - "peerDependencies": { - "expo": "*" - }, - "peerDependenciesMeta": { - "expo": { - "optional": true - } - } - }, - "node_modules/expo/node_modules/babel-preset-expo": { - "version": "55.0.10", - "resolved": "https://registry.npmmirror.com/babel-preset-expo/-/babel-preset-expo-55.0.10.tgz", - "integrity": "sha512-aRtW7qJKohGU2V0LUJ6IeP7py3+kVUo9zcc8+v1Kix8jGGuIvqvpo9S6W1Fmn9VFP2DBwkFDLiyzkCZS85urVA==", - "license": "MIT", - "dependencies": { - "@babel/generator": "^7.20.5", - "@babel/helper-module-imports": "^7.25.9", - "@babel/plugin-proposal-decorators": "^7.12.9", - "@babel/plugin-proposal-export-default-from": "^7.24.7", - "@babel/plugin-syntax-export-default-from": "^7.24.7", - "@babel/plugin-transform-class-static-block": "^7.27.1", - "@babel/plugin-transform-export-namespace-from": "^7.25.9", - "@babel/plugin-transform-flow-strip-types": "^7.25.2", - "@babel/plugin-transform-modules-commonjs": "^7.24.8", - "@babel/plugin-transform-object-rest-spread": "^7.24.7", - "@babel/plugin-transform-parameters": "^7.24.7", - "@babel/plugin-transform-private-methods": "^7.24.7", - "@babel/plugin-transform-private-property-in-object": "^7.24.7", - "@babel/plugin-transform-runtime": "^7.24.7", - "@babel/preset-react": "^7.22.15", - "@babel/preset-typescript": "^7.23.0", - "@react-native/babel-preset": "0.83.2", - "babel-plugin-react-compiler": "^1.0.0", - "babel-plugin-react-native-web": "~0.21.0", - "babel-plugin-syntax-hermes-parser": "^0.32.0", - "babel-plugin-transform-flow-enums": "^0.0.2", - "debug": "^4.3.4", - "resolve-from": "^5.0.0" - }, - "peerDependencies": { - "@babel/runtime": "^7.20.0", - "expo": "*", - "expo-widgets": "^55.0.2", - "react-refresh": ">=0.14.0 <1.0.0" - }, - "peerDependenciesMeta": { - "@babel/runtime": { - "optional": true - }, - "expo": { - "optional": true - }, - "expo-widgets": { - "optional": true - } + "engines": { + "node": ">=4" } }, "node_modules/expo/node_modules/ci-info": { @@ -6181,41 +6243,164 @@ "node": ">=8" } }, - "node_modules/expo/node_modules/expo-asset": { - "version": "55.0.8", - "resolved": "https://registry.npmmirror.com/expo-asset/-/expo-asset-55.0.8.tgz", - "integrity": "sha512-yEz2svDX67R0yiW2skx6dJmcE0q7sj9ECpGMcxBExMCbctc+nMoZCnjUuhzPl5vhClUsO5HFFXS5vIGmf1bgHQ==", + "node_modules/expo/node_modules/cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", "license": "MIT", "dependencies": { - "@expo/image-utils": "^0.8.12", - "expo-constants": "~55.0.7" + "restore-cursor": "^2.0.0" }, - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" + "engines": { + "node": ">=4" } }, - "node_modules/expo/node_modules/expo-keep-awake": { - "version": "55.0.4", - "resolved": "https://registry.npmmirror.com/expo-keep-awake/-/expo-keep-awake-55.0.4.tgz", - "integrity": "sha512-vwfdMtMS5Fxaon8gC0AiE70SpxTsHJ+rjeoVJl8kdfdbxczF7OIaVmfjFJ5Gfigd/WZiLqxhfZk34VAkXF4PNg==", + "node_modules/expo/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "license": "MIT", - "peerDependencies": { - "expo": "*", - "react": "*" + "dependencies": { + "color-name": "1.1.3" } }, - "node_modules/expo/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "node_modules/expo/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/expo/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "license": "MIT", "engines": { - "node": ">=12" + "node": ">=0.8.0" + } + }, + "node_modules/expo/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/expo/node_modules/log-symbols": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/log-symbols/-/log-symbols-2.2.0.tgz", + "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "license": "MIT", + "dependencies": { + "chalk": "^2.0.1" }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "engines": { + "node": ">=4" + } + }, + "node_modules/expo/node_modules/log-symbols/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmmirror.com/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/expo/node_modules/mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/expo/node_modules/onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/expo/node_modules/ora": { + "version": "3.4.0", + "resolved": "https://registry.npmmirror.com/ora/-/ora-3.4.0.tgz", + "integrity": "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==", + "license": "MIT", + "dependencies": { + "chalk": "^2.4.2", + "cli-cursor": "^2.1.0", + "cli-spinners": "^2.0.0", + "log-symbols": "^2.2.0", + "strip-ansi": "^5.2.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/expo/node_modules/ora/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmmirror.com/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/expo/node_modules/restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", + "license": "MIT", + "dependencies": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/expo/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/expo/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" } }, "node_modules/expo/node_modules/ws": { @@ -6284,22 +6469,9 @@ "license": "MIT" }, "node_modules/fast-xml-builder": { - "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", - "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", - "devOptional": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, - "node_modules/fast-xml-parser": { - "version": "5.4.2", - "resolved": "https://registry.npmmirror.com/fast-xml-parser/-/fast-xml-parser-5.4.2.tgz", - "integrity": "sha512-pw/6pIl4k0CSpElPEJhDppLzaixDEuWui2CUQQBH/ECDf7+y6YwA4Gf7Tyb0Rfe4DIMuZipYj4AEL0nACKglvQ==", + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", "devOptional": true, "funding": [ { @@ -6309,8 +6481,25 @@ ], "license": "MIT", "dependencies": { - "fast-xml-builder": "^1.0.0", - "strnum": "^2.1.2" + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.7", + "resolved": "https://registry.npmmirror.com/fast-xml-parser/-/fast-xml-parser-5.5.7.tgz", + "integrity": "sha512-LteOsISQ2GEiDHZch6L9hB0+MLoYVLToR7xotrzU0opCICBkxOPgHAy1HxAvtxfJNXDJpgAsQN30mkrfpO2Prg==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.1.3", + "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" @@ -6378,9 +6567,9 @@ } }, "node_modules/fetch-nodeshim": { - "version": "0.4.8", - "resolved": "https://registry.npmmirror.com/fetch-nodeshim/-/fetch-nodeshim-0.4.8.tgz", - "integrity": "sha512-YW5vG33rabBq6JpYosLNoXoaMN69/WH26MeeX2hkDVjN6UlvRGq3Wkazl9H0kisH95aMu/HtHL64JUvv/+Nv/g==", + "version": "0.4.9", + "resolved": "https://registry.npmmirror.com/fetch-nodeshim/-/fetch-nodeshim-0.4.9.tgz", + "integrity": "sha512-XIQWlB2A4RZ7NebXWGxS0uDMdvRHkiUDTghBVJKFg9yEOd45w/PP8cZANuPf2H08W6Cor3+2n7Q6TTZgAS3Fkw==", "license": "MIT" }, "node_modules/fill-range": { @@ -6437,17 +6626,33 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "node_modules/finalhandler/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", "license": "MIT", "dependencies": { - "locate-path": "^5.0.0", + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", "path-exists": "^4.0.0" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/flow-enums-runtime": { @@ -6749,18 +6954,18 @@ "license": "MIT" }, "node_modules/hermes-estree": { - "version": "0.32.0", - "resolved": "https://registry.npmmirror.com/hermes-estree/-/hermes-estree-0.32.0.tgz", - "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", + "version": "0.32.1", + "resolved": "https://registry.npmmirror.com/hermes-estree/-/hermes-estree-0.32.1.tgz", + "integrity": "sha512-ne5hkuDxheNBAikDjqvCZCwihnz0vVu9YsBzAEO1puiyFR4F1+PAz/SiPHSsNTuOveCYGRMX8Xbx4LOubeC0Qg==", "license": "MIT" }, "node_modules/hermes-parser": { - "version": "0.32.0", - "resolved": "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.32.0.tgz", - "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", + "version": "0.32.1", + "resolved": "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.32.1.tgz", + "integrity": "sha512-175dz634X/W5AiwrpLdoMl/MOb17poLHyIqgyExlE8D9zQ1OPnoORnGMB5ltRKnpvQzBjMYvT2rN/sHeIfZW5Q==", "license": "MIT", "dependencies": { - "hermes-estree": "0.32.0" + "hermes-estree": "0.32.1" } }, "node_modules/hoist-non-react-statics": { @@ -6988,9 +7193,10 @@ } }, "node_modules/is-arrayish": { - "version": "0.3.4", - "resolved": "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.3.4.tgz", - "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "version": "0.2.1", + "resolved": "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "devOptional": true, "license": "MIT" }, "node_modules/is-core-module": { @@ -7034,12 +7240,13 @@ } }, "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "devOptional": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=4" } }, "node_modules/is-glob": { @@ -7110,15 +7317,13 @@ } }, "node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmmirror.com/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/is-wsl/-/is-wsl-1.1.0.tgz", + "integrity": "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==", + "devOptional": true, "license": "MIT", - "dependencies": { - "is-docker": "^2.0.0" - }, "engines": { - "node": ">=8" + "node": ">=4" } }, "node_modules/isexe": { @@ -7152,15 +7357,6 @@ "node": ">=8" } }, - "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/jest-environment-node": { "version": "29.7.0", "resolved": "https://registry.npmmirror.com/jest-environment-node/-/jest-environment-node-29.7.0.tgz", @@ -7287,6 +7483,18 @@ "node": ">=8" } }, + "node_modules/jest-util/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/jest-validate": { "version": "29.7.0", "resolved": "https://registry.npmmirror.com/jest-validate/-/jest-validate-29.7.0.tgz", @@ -7361,13 +7569,12 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "license": "MIT", "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" @@ -7420,6 +7627,31 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/katex": { + "version": "0.16.42", + "resolved": "https://registry.npmmirror.com/katex/-/katex-0.16.42.tgz", + "integrity": "sha512-sZ4jqyEXfHTLEFK+qsFYToa3UZ0rtFcPGwKpyiRYh2NJn8obPWOQ+/u7ux0F6CAU/y78+Mksh1YkxTPXTh47TQ==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmmirror.com/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmmirror.com/kleur/-/kleur-3.0.3.tgz", @@ -7484,9 +7716,9 @@ "license": "MIT" }, "node_modules/lightningcss": { - "version": "1.31.1", - "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.31.1.tgz", - "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -7499,23 +7731,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.31.1", - "lightningcss-darwin-arm64": "1.31.1", - "lightningcss-darwin-x64": "1.31.1", - "lightningcss-freebsd-x64": "1.31.1", - "lightningcss-linux-arm-gnueabihf": "1.31.1", - "lightningcss-linux-arm64-gnu": "1.31.1", - "lightningcss-linux-arm64-musl": "1.31.1", - "lightningcss-linux-x64-gnu": "1.31.1", - "lightningcss-linux-x64-musl": "1.31.1", - "lightningcss-win32-arm64-msvc": "1.31.1", - "lightningcss-win32-x64-msvc": "1.31.1" + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", - "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "cpu": [ "arm64" ], @@ -7533,9 +7765,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", - "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ "arm64" ], @@ -7553,9 +7785,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", - "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "cpu": [ "x64" ], @@ -7573,9 +7805,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", - "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "cpu": [ "x64" ], @@ -7593,9 +7825,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.31.1", - "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", - "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "cpu": [ "arm" ], @@ -7613,9 +7845,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", - "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "cpu": [ "arm64" ], @@ -7633,9 +7865,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", - "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "cpu": [ "arm64" ], @@ -7653,9 +7885,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", - "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], @@ -7673,9 +7905,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", - "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], @@ -7693,9 +7925,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", - "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "cpu": [ "arm64" ], @@ -7713,9 +7945,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", - "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" ], @@ -7739,16 +7971,29 @@ "devOptional": true, "license": "MIT" }, - "node_modules/locate-path": { + "node_modules/linkify-it": { "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "resolved": "https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", "license": "MIT", "dependencies": { - "p-locate": "^4.1.0" + "uc.micro": "^2.0.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/lodash.debounce": { @@ -7764,86 +8009,20 @@ "license": "MIT" }, "node_modules/log-symbols": { - "version": "2.2.0", - "resolved": "https://registry.npmmirror.com/log-symbols/-/log-symbols-2.2.0.tgz", - "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "devOptional": true, "license": "MIT", "dependencies": { - "chalk": "^2.0.1" + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" }, "engines": { - "node": ">=4" - } - }, - "node_modules/log-symbols/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "license": "MIT", - "dependencies": { - "color-convert": "^1.9.0" + "node": ">=10" }, - "engines": { - "node": ">=4" - } - }, - "node_modules/log-symbols/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmmirror.com/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/log-symbols/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "license": "MIT", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/log-symbols/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "license": "MIT" - }, - "node_modules/log-symbols/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/log-symbols/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/log-symbols/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/logkitty": { @@ -7883,6 +8062,75 @@ "wrap-ansi": "^6.2.0" } }, + "node_modules/logkitty/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/logkitty/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/logkitty/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/logkitty/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/logkitty/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/logkitty/node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -7972,6 +8220,23 @@ "tmpl": "1.0.5" } }, + "node_modules/markdown-it": { + "version": "14.1.1", + "resolved": "https://registry.npmmirror.com/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, "node_modules/marky": { "version": "1.3.0", "resolved": "https://registry.npmmirror.com/marky/-/marky-1.3.0.tgz", @@ -7987,6 +8252,12 @@ "node": ">= 0.4" } }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-1.1.0.tgz", @@ -8100,6 +8371,21 @@ "node": ">=20.19.4" } }, + "node_modules/metro-babel-transformer/node_modules/hermes-estree": { + "version": "0.32.0", + "resolved": "https://registry.npmmirror.com/hermes-estree/-/hermes-estree-0.32.0.tgz", + "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", + "license": "MIT" + }, + "node_modules/metro-babel-transformer/node_modules/hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.32.0.tgz", + "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.32.0" + } + }, "node_modules/metro-cache": { "version": "0.83.3", "resolved": "https://registry.npmmirror.com/metro-cache/-/metro-cache-0.83.3.tgz", @@ -8300,6 +8586,42 @@ "node": ">=20.19.4" } }, + "node_modules/metro/node_modules/hermes-estree": { + "version": "0.32.0", + "resolved": "https://registry.npmmirror.com/hermes-estree/-/hermes-estree-0.32.0.tgz", + "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", + "license": "MIT" + }, + "node_modules/metro/node_modules/hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.32.0.tgz", + "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.32.0" + } + }, + "node_modules/metro/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmmirror.com/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz", @@ -8313,22 +8635,35 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmmirror.com/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "devOptional": true, "license": "MIT", "bin": { "mime": "cli.js" }, "engines": { - "node": ">=4" + "node": ">=4.0.0" } }, "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "version": "1.54.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -8346,13 +8681,23 @@ "node": ">= 0.6" } }, - "node_modules/mimic-fn": { - "version": "1.2.0", - "resolved": "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-1.2.0.tgz", - "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", "engines": { - "node": ">=4" + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" } }, "node_modules/minimatch": { @@ -8422,9 +8767,9 @@ } }, "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "version": "0.6.4", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -8476,9 +8821,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.36", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", "license": "MIT" }, "node_modules/node-stream-zip": { @@ -8519,6 +8864,18 @@ "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/npm-package-arg/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmmirror.com/npm-run-path/-/npm-run-path-4.0.1.tgz", @@ -8573,9 +8930,9 @@ } }, "node_modules/on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "version": "2.4.1", + "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "license": "MIT", "dependencies": { "ee-first": "1.1.1" @@ -8603,167 +8960,101 @@ } }, "node_modules/onetime": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/onetime/-/onetime-2.0.1.tgz", - "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "devOptional": true, "license": "MIT", "dependencies": { - "mimic-fn": "^1.0.0" + "mimic-fn": "^2.1.0" }, "engines": { - "node": ">=4" - } - }, - "node_modules/open": { - "version": "7.4.2", - "resolved": "https://registry.npmmirror.com/open/-/open-7.4.2.tgz", - "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", - "license": "MIT", - "dependencies": { - "is-docker": "^2.0.0", - "is-wsl": "^2.1.1" - }, - "engines": { - "node": ">=8" + "node": ">=6" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ora": { - "version": "3.4.0", - "resolved": "https://registry.npmmirror.com/ora/-/ora-3.4.0.tgz", - "integrity": "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==", + "node_modules/open": { + "version": "6.4.0", + "resolved": "https://registry.npmmirror.com/open/-/open-6.4.0.tgz", + "integrity": "sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg==", + "devOptional": true, "license": "MIT", "dependencies": { - "chalk": "^2.4.2", - "cli-cursor": "^2.1.0", - "cli-spinners": "^2.0.0", - "log-symbols": "^2.2.0", - "strip-ansi": "^5.2.0", + "is-wsl": "^1.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmmirror.com/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" }, "engines": { - "node": ">=6" - } - }, - "node_modules/ora/node_modules/ansi-regex": { - "version": "4.1.1", - "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-4.1.1.tgz", - "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/ora/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "license": "MIT", - "dependencies": { - "color-convert": "^1.9.0" + "node": ">=10" }, - "engines": { - "node": ">=4" - } - }, - "node_modules/ora/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmmirror.com/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/ora/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "license": "MIT", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/ora/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "license": "MIT" - }, - "node_modules/ora/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/ora/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "license": "MIT", - "engines": { - "node": ">=4" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/ora/node_modules/strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "devOptional": true, "license": "MIT", "dependencies": { - "ansi-regex": "^4.1.0" + "ansi-regex": "^5.0.1" }, "engines": { - "node": ">=6" - } - }, - "node_modules/ora/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" + "node": ">=8" } }, "node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "devOptional": true, "license": "MIT", "dependencies": { - "p-try": "^2.0.0" + "yocto-queue": "^0.1.0" }, "engines": { - "node": ">=6" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "devOptional": true, "license": "MIT", "dependencies": { - "p-limit": "^2.2.0" + "p-limit": "^3.0.2" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/p-try": { @@ -8837,6 +9128,22 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", + "integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -8878,9 +9185,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "version": "11.2.7", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -8893,12 +9200,12 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -8996,6 +9303,21 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmmirror.com/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/proc-log": { "version": "4.2.0", "resolved": "https://registry.npmmirror.com/proc-log/-/proc-log-4.2.0.tgz", @@ -9042,6 +9364,15 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.0", "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.0.tgz", @@ -9150,6 +9481,27 @@ "ws": "^7" } }, + "node_modules/react-devtools-core/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmmirror.com/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/react-dom": { "version": "19.2.0", "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.0.tgz", @@ -9181,9 +9533,9 @@ } }, "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "version": "19.2.4", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", "license": "MIT" }, "node_modules/react-native": { @@ -9260,9 +9612,9 @@ } }, "node_modules/react-native-is-edge-to-edge": { - "version": "1.2.1", - "resolved": "https://registry.npmmirror.com/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz", - "integrity": "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q==", + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz", + "integrity": "sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==", "license": "MIT", "peerDependencies": { "react": "*", @@ -9325,9 +9677,9 @@ "license": "MIT" }, "node_modules/react-native-reanimated": { - "version": "4.2.1", - "resolved": "https://registry.npmmirror.com/react-native-reanimated/-/react-native-reanimated-4.2.1.tgz", - "integrity": "sha512-/NcHnZMyOvsD/wYXug/YqSKw90P9edN0kEPL5lP4PFf1aQ4F1V7MKe/E0tvfkXKIajy3Qocp5EiEnlcrK/+BZg==", + "version": "4.2.2", + "resolved": "https://registry.npmmirror.com/react-native-reanimated/-/react-native-reanimated-4.2.2.tgz", + "integrity": "sha512-o3kKvdD8cVlg12Z4u3jv0MFAt53QV4k7gD9OLwQqU8eZLyd8QvaOjVZIghMZhC2pjP93uUU44PlO5JgF8S4Vxw==", "license": "MIT", "dependencies": { "react-native-is-edge-to-edge": "1.2.1", @@ -9339,6 +9691,16 @@ "react-native-worklets": ">=0.7.0" } }, + "node_modules/react-native-reanimated/node_modules/react-native-is-edge-to-edge": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz", + "integrity": "sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-reanimated/node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.3.tgz", @@ -9382,9 +9744,9 @@ "license": "MIT" }, "node_modules/react-native-tab-view": { - "version": "4.2.2", - "resolved": "https://registry.npmmirror.com/react-native-tab-view/-/react-native-tab-view-4.2.2.tgz", - "integrity": "sha512-NXtrG6OchvbGjsvbySJGVocXxo4Y2vA17ph4rAaWtA2jh+AasD8OyikKBRg2SmllEfeQ+GEhcKe8kulHv8BhTg==", + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/react-native-tab-view/-/react-native-tab-view-4.3.0.tgz", + "integrity": "sha512-qPMF75uz/7+MuVG2g+YETdGMzlWZnhC6iI4h/7EBbwIBwNBIBi2z4OA6KhY3IOOBwGHXEIz5IyA6doDqifYBHg==", "license": "MIT", "dependencies": { "use-latest-callback": "^0.2.4" @@ -9427,6 +9789,20 @@ "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", "license": "MIT" }, + "node_modules/react-native-webview": { + "version": "13.16.0", + "resolved": "https://registry.npmmirror.com/react-native-webview/-/react-native-webview-13.16.0.tgz", + "integrity": "sha512-Nh13xKZWW35C0dbOskD7OX01nQQavOzHbCw9XoZmar4eXCo7AvrYJ0jlUfRVVIJzqINxHlpECYLdmAdFsl9xDA==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "invariant": "2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-worklets": { "version": "0.7.2", "resolved": "https://registry.npmmirror.com/react-native-worklets/-/react-native-worklets-0.7.2.tgz", @@ -9572,6 +9948,15 @@ } } }, + "node_modules/react-native/node_modules/babel-plugin-syntax-hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmmirror.com/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.32.0.tgz", + "integrity": "sha512-m5HthL++AbyeEA2FcdwOLfVFvWYECOBObLHNqdR8ceY4TsEdn4LdX2oTvbB2QJSSElE2AWA/b2MXZ/PF/CqLZg==", + "license": "MIT", + "dependencies": { + "hermes-parser": "0.32.0" + } + }, "node_modules/react-native/node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", @@ -9618,6 +10003,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/react-native/node_modules/hermes-estree": { + "version": "0.32.0", + "resolved": "https://registry.npmmirror.com/hermes-estree/-/hermes-estree-0.32.0.tgz", + "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", + "license": "MIT" + }, + "node_modules/react-native/node_modules/hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.32.0.tgz", + "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.32.0" + } + }, "node_modules/react-native/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", @@ -9630,6 +10030,39 @@ "node": "*" } }, + "node_modules/react-native/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/react-native/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmmirror.com/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.14.2.tgz", @@ -9834,16 +10267,17 @@ "license": "MIT" }, "node_modules/restore-cursor": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-2.0.0.tgz", - "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "devOptional": true, "license": "MIT", "dependencies": { - "onetime": "^2.0.0", + "onetime": "^5.1.0", "signal-exit": "^3.0.2" }, "engines": { - "node": ">=4" + "node": ">=8" } }, "node_modules/reusify": { @@ -9974,9 +10408,9 @@ "license": "MIT" }, "node_modules/sax": { - "version": "1.4.4", - "resolved": "https://registry.npmmirror.com/sax/-/sax-1.4.4.tgz", - "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -9989,15 +10423,12 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "license": "ISC", "bin": { "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" } }, "node_modules/send": { @@ -10048,16 +10479,16 @@ "node": ">= 0.8" } }, - "node_modules/send/node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "node_modules/send/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" + "bin": { + "mime": "cli.js" }, "engines": { - "node": ">= 0.8" + "node": ">=4" } }, "node_modules/send/node_modules/statuses": { @@ -10277,6 +10708,12 @@ "is-arrayish": "^0.3.1" } }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmmirror.com/sisteransi/-/sisteransi-1.0.5.tgz", @@ -10337,20 +10774,10 @@ "devOptional": true, "license": "MIT" }, - "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/slugify": { - "version": "1.6.6", - "resolved": "https://registry.npmmirror.com/slugify/-/slugify-1.6.6.tgz", - "integrity": "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==", + "version": "1.6.8", + "resolved": "https://registry.npmmirror.com/slugify/-/slugify-1.6.8.tgz", + "integrity": "sha512-HVk9X1E0gz3mSpoi60h/saazLKXKaZThMLU3u/aNwoYn8/xQyX2MGxL0ui2eaokkD7tF+Zo+cKTHUbe1mmmGzA==", "license": "MIT", "engines": { "node": ">=8.0.0" @@ -10505,7 +10932,16 @@ "node": ">=8" } }, - "node_modules/strip-ansi": { + "node_modules/string-width/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", @@ -10517,6 +10953,27 @@ "node": ">=8" } }, + "node_modules/strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/strip-final-newline": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz", @@ -10528,9 +10985,9 @@ } }, "node_modules/strnum": { - "version": "2.2.0", - "resolved": "https://registry.npmmirror.com/strnum/-/strnum-2.2.0.tgz", - "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/strnum/-/strnum-2.2.1.tgz", + "integrity": "sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==", "devOptional": true, "funding": [ { @@ -10589,6 +11046,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/terminal-link": { "version": "2.1.1", "resolved": "https://registry.npmmirror.com/terminal-link/-/terminal-link-2.1.1.tgz", @@ -10606,9 +11075,9 @@ } }, "node_modules/terser": { - "version": "5.46.0", - "resolved": "https://registry.npmmirror.com/terser/-/terser-5.46.0.tgz", - "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", + "version": "5.46.1", + "resolved": "https://registry.npmmirror.com/terser/-/terser-5.46.1.tgz", + "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", @@ -10776,16 +11245,6 @@ "node": ">= 0.6" } }, - "node_modules/type-is/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/type-is/node_modules/mime-types": { "version": "3.0.2", "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz", @@ -10843,6 +11302,12 @@ "node": "*" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.18.2.tgz", @@ -11158,6 +11623,18 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", @@ -11178,24 +11655,13 @@ } }, "node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmmirror.com/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "version": "6.2.3", + "resolved": "https://registry.npmmirror.com/ws/-/ws-6.2.3.tgz", + "integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==", + "devOptional": true, "license": "MIT", - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } + "dependencies": { + "async-limiter": "~1.0.0" } }, "node_modules/xcode": { @@ -11322,9 +11788,9 @@ } }, "node_modules/zustand": { - "version": "5.0.11", - "resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.11.tgz", - "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==", + "version": "5.0.12", + "resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", "license": "MIT", "engines": { "node": ">=12.20.0" @@ -11349,6 +11815,34 @@ "optional": true } } + }, + "node_modules/zxing-wasm": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/zxing-wasm/-/zxing-wasm-3.0.1.tgz", + "integrity": "sha512-3CLj6iaGkpqPWXAB4pIWkFOR63MwqGekpMzaROFKto4dFowiPmLlC56KoMoOSXzqOCOpI5DAvMdB8ku2va6fUg==", + "license": "MIT", + "dependencies": { + "@types/emscripten": "^1.41.5", + "type-fest": "^5.4.4" + }, + "peerDependencies": { + "@types/emscripten": ">=1.39.6" + } + }, + "node_modules/zxing-wasm/node_modules/type-fest": { + "version": "5.5.0", + "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-5.5.0.tgz", + "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index 49baba2..aa21d88 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "carrot_bbs", - "version": "1.0.0", - "main": "index.ts", + "version": "1.0.1", + "main": "expo-router/entry", "scripts": { "start": "expo start", "android": "expo run:android", @@ -25,7 +25,9 @@ "axios": "^1.13.6", "date-fns": "^4.1.0", "expo": "~55.0.4", - "expo-background-fetch": "~55.0.9", + "expo-background-fetch": "~55.0.10", + "expo-background-task": "~55.0.10", + "expo-camera": "^55.0.10", "expo-constants": "~55.0.7", "expo-dev-client": "~55.0.10", "expo-file-system": "~55.0.10", @@ -43,6 +45,9 @@ "expo-task-manager": "~55.0.9", "expo-updates": "^55.0.12", "expo-video": "^55.0.10", + "katex": "^0.16.42", + "markdown-it": "^14.1.1", + "prismjs": "^1.30.0", "react": "19.2.0", "react-dom": "19.2.0", "react-native": "0.83.2", @@ -54,6 +59,7 @@ "react-native-screens": "~4.23.0", "react-native-sse": "^1.2.1", "react-native-web": "^0.21.0", + "react-native-webview": "13.16.0", "react-native-worklets": "0.7.2", "zod": "^4.3.6", "zustand": "^5.0.11" diff --git a/plans/architecture-comparison-report.md b/plans/architecture-comparison-report.md new file mode 100644 index 0000000..16db566 --- /dev/null +++ b/plans/architecture-comparison-report.md @@ -0,0 +1,394 @@ +# Messages模块与PostService/Manager架构对比分析报告 + +## 一、Messages模块架构分析 + +### 1.1 架构层次结构 + +Messages模块采用了**清晰的分层架构**,各层职责明确: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 表现层 (Screens) │ +│ ChatScreen, MessageListScreen, NotificationsScreen │ +├─────────────────────────────────────────────────────────────┤ +│ Hooks层 │ +│ useDifferentialMessages, useChatScreen │ +├─────────────────────────────────────────────────────────────┤ +│ 用例层 (UseCases) │ +│ ProcessMessageUseCase │ +├─────────────────────────────────────────────────────────────┤ +│ 领域层 (Entities) │ +│ Message, Conversation, GroupNotice │ +├─────────────────────────────────────────────────────────────┤ +│ 仓库层 (Repositories) │ +│ MessageRepository, IMessageRepository │ +├─────────────────────────────────────────────────────────────┤ +│ 数据源层 (DataSources) │ +│ SSEClient, LocalDataSource, ApiDataSource │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 1.2 核心组件详解 + +#### 1.2.1 领域实体层 (Core/Entities) +- **Message.ts**: 定义消息、会话、群通知等核心领域模型 +- 包含工厂函数:`createMessage`, `createConversation` +- 包含业务逻辑函数:`isMessageFromCurrentUser`, `shouldIncrementUnread`, `buildTextContent` + +#### 1.2.2 用例层 (Core/UseCases) +- **ProcessMessageUseCase**: 核心业务逻辑编排器 + - 订阅SSE事件(chat, group_message, read, recall, typing, group_notice等) + - 消息去重处理(processedMessageIds Set) + - 已读状态保护(pendingReadMap + 版本号机制) + - 用户信息缓存与去重请求(pendingUserRequests Map) + - 事件发布订阅模式(subscribers Set) + +#### 1.2.3 仓库层 (Data/Repositories) +- **IMessageRepository**: 定义数据访问接口 + - 消息操作:getMessages, saveMessage, updateMessageStatus, markAsRead + - 会话操作:getConversations, saveConversation, updateUnreadCount + - 同步操作:syncMessages, getServerLastSeq + +- **MessageRepository**: 实现接口 + - 封装SQLite数据库操作 + - 消息与CachedMessage的转换 + +#### 1.2.4 映射器层 (Data/Mappers) +- **MessageMapper**: 数据转换 + - `fromApiResponse`: API响应 → 应用模型 + - `fromDbRecord`: 数据库记录 → 应用模型 + - `toDbRecord`: 应用模型 → 数据库记录 + - `toApiRequest`: 应用模型 → API请求 + - 创建消息片段:`createTextSegment`, `createImageSegment` + +#### 1.2.5 差异计算基础设施 (Infrastructure/Diff) +- **MessageDiffCalculator**: 消息列表差异计算 + - 计算added, updated, deleted, moved, unchanged + - 变化比例检测(changeRatio > 0.5 时触发重置) + - 增量差异计算(基于缓存) + +- **MessageUpdateBatcher**: 批量更新处理器 + - 16ms批量间隔(约60fps) + - 去重和合并更新 + - 100ms最大等待时间 + - 统计信息:totalBatches, totalUpdates, averageBatchSize + +- **types.ts**: 完整的类型定义 + - MessageUpdateType枚举:ADD, UPDATE, DELETE, MOVE, BATCH_* + - DiffResult, DiffConfig等接口 + +#### 1.2.6 Hook层 +- **useDifferentialMessages**: 差异更新Hook + - 集成DiffCalculator和Batcher + - 自动处理批量更新 + - 提供flush, reset, forceUpdate方法 + - 支持配置:enableDiff, enableBatching, maxMessageCount, changeRatioThreshold + +### 1.3 数据流 + +``` +SSE事件 → ProcessMessageUseCase → 事件发布 → useChatScreen → useDifferentialMessages + ↓ ↓ + MessageRepository 差异计算 + 批量处理 + ↓ ↓ + SQLite数据库 优化后的消息列表 +``` + +### 1.4 状态管理方式 + +- **发布-订阅模式**:ProcessMessageUseCase作为事件中心 +- **本地SQLite缓存**:消息持久化存储 +- **内存状态**:通过Hook返回,组件直接消费 +- **差异更新**:通过MessageDiffCalculator + MessageUpdateBatcher减少重渲染 + +--- + +## 二、PostService/Manager架构分析 + +### 2.1 架构层次结构 + +Post模块采用了**扁平化架构**,Service和Manager层职责混合: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 表现层 (Screens) │ +│ HomeScreen, PostDetailScreen, CreatePostScreen │ +├─────────────────────────────────────────────────────────────┤ +│ Hooks层 │ +│ useCursorPagination (通用), usePrefetch │ +├─────────────────────────────────────────────────────────────┤ +│ Store层 (Zustand) │ +│ useUserStore (直接操作状态), postManager (缓存管理) │ +├─────────────────────────────────────────────────────────────┤ +│ Service层 │ +│ postService (API调用 + 状态更新) │ +├─────────────────────────────────────────────────────────────┤ +│ 数据源 │ +│ API (直接调用) │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 2.2 核心组件详解 + +#### 2.2.1 Service层 (Services) +- **postService.ts**: 帖子服务 + - CRUD操作:getPosts, getPost, createPost, updatePost, deletePost + - 互动操作:likePost, unlikePost, favoritePost, unfavoritePost + - 分页支持:传统分页 + 游标分页 + - **问题**:直接操作useUserStore(违反分层原则) + +#### 2.2.2 Manager层 (Stores) +- **postManager.ts**: 帖子缓存管理器 + - 继承CacheBus(发布订阅基类) + - 内存缓存:listCache, detailCache + - 请求去重:pendingRequests Map + - TTL管理:LIST_TTL=30s, DETAIL_TTL=60s + - 后台刷新机制 + +#### 2.2.3 Store层 (Zustand) +- **useUserStore**: 用户状态存储 + - 直接存储posts数组 + - 帖子操作副作用直接修改状态 + - 混合了用户数据和帖子数据 + +#### 2.2.4 映射器层 (Data/Mappers) +- **PostMapper**: 数据转换 + - `fromApiResponse`: API响应 → 应用模型 + - `fromApiResponseList`: 批量转换 + - `toApiRequest`: 应用模型 → API请求 + - 相比MessageMapper缺少数据库记录转换方法 + +### 2.3 数据流 + +``` +HomeScreen → useCursorPagination → postService.getPostsCursor + ↓ + useUserStore.setState(posts) + ↓ + PostCard组件渲染 +``` + +### 2.4 状态管理方式 + +- **Zustand Store**:集中式状态管理 +- **内存缓存**:postManager提供应用级缓存 +- **请求去重**:dedupe方法防止重复请求 +- **直接修改**:postService直接调用useUserStore.setState + +--- + +## 三、主要架构差异 + +### 3.1 分层复杂度 + +| 维度 | Messages模块 | Post模块 | +|------|-------------|---------| +| 层次深度 | 6层(Entity→UseCase→Repository→Mapper→Hook→Screen) | 4层(Service→Store→Hook→Screen) | +| 用例层 | 独立ProcessMessageUseCase | 无 | +| 仓库接口 | IMessageRepository抽象接口 | 无 | +| 基础设施 | Diff模块(Calculator + Batcher) | 无 | + +### 3.2 数据持久化 + +| 维度 | Messages模块 | Post模块 | +|------|-------------|---------| +| 本地存储 | SQLite数据库 | 无 | +| 缓存抽象 | MessageRepository封装 | postManager内存缓存 | +| 离线支持 | 完整 | 不支持 | + +### 3.3 实时更新 + +| 维度 | Messages模块 | Post模块 | +|------|-------------|---------| +| 推送机制 | SSE实时推送 | 轮询/下拉刷新 | +| 事件订阅 | ProcessMessageUseCase发布订阅 | 无 | +| 增量更新 | MessageDiffCalculator | 无 | + +### 3.4 状态管理 + +| 维度 | Messages模块 | Post模块 | +|------|-------------|---------| +| 管理方式 | 发布订阅 + Hook局部状态 | Zustand全局Store | +| 状态来源 | useChatScreen, useDifferentialMessages | useUserStore | +| 批量更新 | MessageUpdateBatcher | 无 | +| 差异检测 | MessageDiffCalculator | 无 | + +### 3.5 依赖方向 + +**Messages模块(正确)**: +``` +Hook → UseCase → Repository → DataSource + ↓ + Entity(不依赖外部) +``` + +**Post模块(问题)**: +``` +Service → Store(循环依赖风险) + ↓ +API直接调用 +``` + +### 3.6 核心问题总结 + +| # | 问题 | Messages | Post | +|---|------|---------|------| +| 1 | 违反分层原则 | 无 | postService直接操作useUserStore | +| 2 | 缺少UseCase层 | ProcessMessageUseCase | 无业务逻辑编排 | +| 3 | 缺少Repository抽象 | IMessageRepository | 无数据访问接口 | +| 4 | 无差异更新 | useDifferentialMessages | 无 | +| 5 | 无批量处理 | MessageUpdateBatcher | 无 | +| 6 | 无本地持久化 | SQLite | 无 | +| 7 | 无实时推送 | SSE | 无 | + +--- + +## 四、对齐建议 + +### 4.1 架构重构目标 + +将Post模块对齐到Messages模块的架构模式: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 表现层 (Screens) │ +│ HomeScreen, PostDetailScreen │ +├─────────────────────────────────────────────────────────────┤ +│ Hooks层 │ +│ useDifferentialPosts (新增) │ +├─────────────────────────────────────────────────────────────┤ +│ 用例层 (UseCases) │ +│ ProcessPostUseCase (新增) │ +├─────────────────────────────────────────────────────────────┤ +│ 领域层 (Entities) │ +│ Post, PostComment (新增) │ +├─────────────────────────────────────────────────────────────┤ +│ 仓库层 (Repositories) │ +│ PostRepository, IPostRepository (新增) │ +├─────────────────────────────────────────────────────────────┤ +│ Service层 │ +│ postService (仅保留API调用,移除状态操作) │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 4.2 具体改造项 + +#### 4.2.1 创建Post领域实体 +```typescript +// src/core/entities/Post.ts +export interface Post { + id: string; + authorId: string; + title: string; + content: string; + images: string[]; + likeCount: number; + commentCount: number; + // ... 其他字段 +} +``` + +#### 4.2.2 创建ProcessPostUseCase +```typescript +// src/core/usecases/ProcessPostUseCase.ts +class ProcessPostUseCase { + // 帖子增删改查 + // 点赞/收藏逻辑 + // 事件发布订阅 +} +``` + +#### 4.2.3 创建IPostRepository接口 +```typescript +// src/data/repositories/interfaces/IPostRepository.ts +interface IPostRepository { + getPosts(type: string, page: number, pageSize: number): Promise; + savePost(post: Post): Promise; + updatePost(postId: string, updates: Partial): Promise; + // ... +} +``` + +#### 4.2.4 创建PostRepository实现 +```typescript +// src/data/repositories/PostRepository.ts +// 封装SQLite操作,实现IPostRepository接口 +``` + +#### 4.2.5 创建useDifferentialPosts Hook +```typescript +// src/hooks/useDifferentialPosts.ts +// 类似useDifferentialMessages,处理帖子列表差异更新 +``` + +#### 4.2.6 改造postService +- 移除对useUserStore的直接依赖 +- 仅保留API调用职责 + +--- + +## 五、架构对比图 + +```mermaid +graph TB + subgraph "Messages模块 [理想架构]" + E1[Entity
Message.ts] + U1[UseCase
ProcessMessageUseCase] + R1[Repository
MessageRepository] + M1[Mapper
MessageMapper] + H1[Hook
useDifferentialMessages] + D1[Diff基础设施
DiffCalculator + Batcher] + S1[(SQLite)] + + E1 --> U1 + U1 --> R1 + U1 --> D1 + R1 --> S1 + M1 --> R1 + D1 --> H1 + H1 --> S1 + end + + subgraph "Post模块 [当前架构]" + E2[PostMapper] + SV2[postService
直接操作Store] + ST2[useUserStore
Zustand] + H2[Hook
useCursorPagination] + PM2[postManager
内存缓存] + + E2 --> SV2 + SV2 --> ST2 + H2 --> SV2 + PM2 --> ST2 + end + + style E1 fill:#90EE90 + style U1 fill:#90EE90 + style R1 fill:#90EE90 + style D1 fill:#90EE90 + style H1 fill:#90EE90 + style S1 fill:#90EE90 + + style SV2 fill:#FFB6C1 + style ST2 fill:#FFB6C1 +``` + +--- + +## 六、结论 + +Messages模块是一个**架构完善**的模块,具备: +1. 清晰的分层架构 +2. 独立的业务逻辑编排层(UseCase) +3. 完整的数据持久化(SQLite) +4. 高效的差异更新机制 +5. 实时事件推送能力 + +Post模块当前存在以下问题: +1. **违反分层原则**:Service直接操作Store +2. **缺少UseCase层**:业务逻辑分散 +3. **无数据抽象**:缺少Repository接口 +4. **无差异更新**:每次全量更新导致性能问题 +5. **无本地持久化**:无法离线使用 + +建议按照对齐建议逐步重构Post模块,使其架构与Messages模块对齐。 diff --git a/screenshots/after-fix.png b/screenshots/after-fix.png deleted file mode 100644 index 2c35236..0000000 Binary files a/screenshots/after-fix.png and /dev/null differ diff --git a/screenshots/test-navigation-fix.png b/screenshots/test-navigation-fix.png deleted file mode 100644 index 060a680..0000000 Binary files a/screenshots/test-navigation-fix.png and /dev/null differ diff --git a/src/app-navigation/AppDesktopShell.tsx b/src/app-navigation/AppDesktopShell.tsx new file mode 100644 index 0000000..0d79850 --- /dev/null +++ b/src/app-navigation/AppDesktopShell.tsx @@ -0,0 +1,243 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + View, + StyleSheet, + ScrollView, + TouchableOpacity, + Text, + Platform, +} from 'react-native'; +import { useSafeAreaInsets, SafeAreaView } from 'react-native-safe-area-context'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { usePathname, useRouter } from 'expo-router'; + +import { useAppColors, shadows, type AppColors } from '../theme'; +import { useTotalUnreadCount } from '../stores'; +import { AppRouteStack } from './AppRouteStack'; + +type TabName = 'HomeTab' | 'MessageTab' | 'AppsTab' | 'ProfileTab'; + +const SIDEBAR_WIDTH_DESKTOP = 240; +const SIDEBAR_WIDTH_TABLET = 200; +const SIDEBAR_COLLAPSED_WIDTH = 72; + +const NAV_ITEMS: { name: TabName; label: string; href: string; icon: string; iconOutline: string }[] = [ + { name: 'HomeTab', label: '首页', href: '/home', icon: 'home', iconOutline: 'home-outline' }, + { name: 'AppsTab', label: '应用', href: '/apps', icon: 'view-grid', iconOutline: 'view-grid-outline' }, + { name: 'MessageTab', label: '消息', href: '/messages', icon: 'message-text', iconOutline: 'message-text-outline' }, + { name: 'ProfileTab', label: '我的', href: '/profile', icon: 'account', iconOutline: 'account-outline' }, +]; + +function pathToTab(pathname: string): TabName { + if (pathname.startsWith('/messages')) return 'MessageTab'; + if (pathname.startsWith('/apps')) return 'AppsTab'; + if (pathname.startsWith('/profile')) return 'ProfileTab'; + if (pathname.startsWith('/home')) return 'HomeTab'; + return 'HomeTab'; +} + +function createDesktopShellStyles(colors: AppColors) { + return StyleSheet.create({ + container: { + flex: 1, + flexDirection: 'row', + backgroundColor: colors.background.default, + }, + sidebar: { + backgroundColor: colors.background.paper, + borderRightWidth: 1, + borderRightColor: colors.divider, + flexDirection: 'column', + ...shadows.md, + }, + sidebarHeader: { + paddingHorizontal: 16, + paddingVertical: 20, + borderBottomWidth: 1, + borderBottomColor: colors.divider, + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + }, + logoText: { + fontSize: 18, + fontWeight: '700', + color: colors.primary.main, + marginLeft: 8, + }, + sidebarContent: { + flex: 1, + paddingTop: 8, + }, + sidebarItem: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + marginHorizontal: 8, + marginVertical: 4, + borderRadius: 12, + }, + sidebarItemActive: { + backgroundColor: `${colors.primary.main}15`, + }, + sidebarItemCollapsed: { + justifyContent: 'center', + paddingHorizontal: 0, + }, + sidebarIconContainer: { + position: 'relative', + width: 40, + height: 40, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 12, + }, + sidebarLabel: { + fontSize: 15, + fontWeight: '500', + color: colors.text.secondary, + marginLeft: 12, + flex: 1, + }, + sidebarLabelActive: { + color: colors.primary.main, + fontWeight: '600', + }, + collapseButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-end', + paddingHorizontal: 16, + paddingVertical: 12, + borderTopWidth: 1, + borderTopColor: colors.divider, + }, + collapseButtonCollapsed: { + justifyContent: 'center', + paddingHorizontal: 0, + }, + badge: { + position: 'absolute', + top: 2, + right: 2, + backgroundColor: colors.error.main, + borderRadius: 10, + minWidth: 18, + height: 18, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 4, + }, + badgeText: { + color: colors.primary.contrast, + fontSize: 10, + fontWeight: '600', + }, + mainContent: { + flex: 1, + backgroundColor: colors.background.default, + }, + }); +} + +export function AppDesktopShell() { + const colors = useAppColors(); + const styles = useMemo(() => createDesktopShellStyles(colors), [colors]); + const insets = useSafeAreaInsets(); + const router = useRouter(); + const pathname = usePathname(); + const unreadCount = useTotalUnreadCount(); + const [isCollapsed, setIsCollapsed] = useState(false); + const [isDesktop, setIsDesktop] = useState(false); + + useEffect(() => { + if (Platform.OS !== 'web') return; + const check = () => { + const w = window.innerWidth || document.documentElement.clientWidth; + setIsDesktop(w >= SIDEBAR_WIDTH_DESKTOP); + }; + check(); + window.addEventListener('resize', check); + return () => window.removeEventListener('resize', check); + }, []); + + const sidebarWidth = isCollapsed + ? SIDEBAR_COLLAPSED_WIDTH + : isDesktop + ? SIDEBAR_WIDTH_DESKTOP + : SIDEBAR_WIDTH_TABLET; + + const currentTab = pathToTab(pathname || '/home'); + + const handleTabChange = useCallback( + (href: string) => { + router.replace(href); + }, + [router] + ); + + return ( + + + + + {!isCollapsed && 胡萝卜BBS} + + + {NAV_ITEMS.map((item) => { + const isActive = currentTab === item.name; + const showBadge = item.name === 'MessageTab' && unreadCount > 0; + return ( + handleTabChange(item.href)} + activeOpacity={0.7} + > + + + {showBadge ? ( + + {unreadCount > 99 ? '99+' : unreadCount} + + ) : null} + + {!isCollapsed ? ( + {item.label} + ) : null} + + ); + })} + + setIsCollapsed((c) => !c)} + activeOpacity={0.7} + > + + + + + + + + ); +} diff --git a/src/app-navigation/AppRouteStack.tsx b/src/app-navigation/AppRouteStack.tsx new file mode 100644 index 0000000..5ea6fed --- /dev/null +++ b/src/app-navigation/AppRouteStack.tsx @@ -0,0 +1,8 @@ +import { Stack } from 'expo-router'; + +/** + * 已登录区内栈:由 app/(app) 下文件系统自动注册子路由 + */ +export function AppRouteStack() { + return ; +} diff --git a/src/components/business/CommentItem.tsx b/src/components/business/CommentItem.tsx index a0c5b0d..7e53802 100644 --- a/src/components/business/CommentItem.tsx +++ b/src/components/business/CommentItem.tsx @@ -1,21 +1,19 @@ /** - * CommentItem 评论项组件 - QQ频道风格 + * CommentItem 评论项组件 - 小红书 / 贴吧式平铺列表 * 支持嵌套回复显示、楼层号、身份标识、删除评论、图片显示 */ -import React, { useState } from 'react'; -import { View, TouchableOpacity, StyleSheet, Alert, Dimensions } from 'react-native'; +import React, { useMemo, useState } from 'react'; +import { View, TouchableOpacity, StyleSheet, Alert } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { formatDistanceToNow } from 'date-fns'; import { zhCN } from 'date-fns/locale'; -import { colors, spacing, borderRadius, fontSizes } from '../../theme'; +import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme'; import { Comment, CommentImage } from '../../types'; import Text from '../common/Text'; import Avatar from '../common/Avatar'; import { CompactImageGrid, ImageGridItem } from '../common'; -const { width: screenWidth } = Dimensions.get('window'); - interface CommentItemProps { comment: Comment; onUserPress: () => void; @@ -33,6 +31,174 @@ interface CommentItemProps { currentUserId?: string; // 当前用户ID,用于判断子评论作者 } +function createCommentItemStyles(colors: AppColors) { + return StyleSheet.create({ + container: { + flexDirection: 'row', + paddingTop: spacing.md, + paddingBottom: spacing.md, + paddingHorizontal: spacing.md, + backgroundColor: colors.background.paper, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.divider, + }, + content: { + flex: 1, + marginLeft: spacing.sm, + minWidth: 0, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: spacing.xs, + gap: spacing.xs, + }, + userInfo: { + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + flex: 1, + minWidth: 0, + }, + metaDot: { + fontSize: fontSizes.xs, + color: colors.text.hint, + marginHorizontal: 2, + }, + username: { + fontWeight: '600', + fontSize: fontSizes.sm, + color: colors.text.primary, + marginRight: spacing.xs, + }, + badge: { + paddingHorizontal: 4, + paddingVertical: 1, + borderRadius: 2, + marginRight: spacing.xs, + }, + smallBadge: { + paddingHorizontal: 2, + paddingVertical: 0, + }, + authorBadge: { + backgroundColor: colors.primary.main, + }, + adminBadge: { + backgroundColor: colors.error.main, + }, + badgeText: { + color: colors.text.inverse, + fontSize: fontSizes.xs, + fontWeight: '600', + }, + smallBadgeText: { + color: colors.text.inverse, + fontSize: 9, + fontWeight: '600', + }, + timeText: { + fontSize: fontSizes.xs, + flexShrink: 0, + }, + floorPlain: { + fontSize: fontSizes.xs, + flexShrink: 0, + }, + replyReference: { + marginBottom: spacing.xs, + }, + commentContent: { + marginBottom: spacing.xs, + }, + text: { + lineHeight: 22, + fontSize: fontSizes.md, + color: colors.text.primary, + }, + actions: { + flexDirection: 'row', + alignItems: 'center', + marginTop: spacing.xs, + flexWrap: 'wrap', + }, + actionButton: { + flexDirection: 'row', + alignItems: 'center', + marginRight: spacing.lg, + paddingVertical: 4, + paddingRight: spacing.xs, + }, + actionText: { + marginLeft: 4, + fontSize: fontSizes.xs, + }, + subRepliesContainer: { + marginTop: spacing.sm, + marginLeft: 0, + paddingLeft: spacing.sm, + paddingVertical: spacing.xs, + borderLeftWidth: 2, + borderLeftColor: colors.divider, + backgroundColor: colors.background.default, + }, + subReplyItem: { + flexDirection: 'row', + alignItems: 'flex-start', + marginBottom: spacing.md, + }, + subReplyItemLast: { + marginBottom: 0, + }, + subReplyBody: { + fontSize: fontSizes.sm, + lineHeight: 20, + marginTop: 2, + }, + subReplyContent: { + flex: 1, + marginLeft: spacing.xs, + }, + subReplyHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 2, + }, + subReplyAuthor: { + fontWeight: '600', + color: colors.text.primary, + marginRight: spacing.xs, + }, + moreRepliesButton: { + marginTop: spacing.xs, + paddingVertical: spacing.xs, + }, + replyToText: { + fontSize: fontSizes.xs, + }, + replyToName: { + fontSize: fontSizes.xs, + fontWeight: '500', + }, + subReplyActions: { + flexDirection: 'row', + alignItems: 'center', + marginTop: spacing.xs, + }, + subActionButton: { + flexDirection: 'row', + alignItems: 'center', + marginRight: spacing.sm, + paddingVertical: 2, + }, + subActionText: { + marginLeft: 2, + fontSize: fontSizes.xs, + }, + }); +} + const CommentItem: React.FC = ({ comment, onUserPress, @@ -49,6 +215,8 @@ const CommentItem: React.FC = ({ onImagePress, currentUserId, }) => { + const colors = useAppColors(); + const styles = useMemo(() => createCommentItemStyles(colors), [colors]); const [isDeleting, setIsDeleting] = useState(false); // 格式化时间 const formatTime = (dateString: string): string => { @@ -181,11 +349,9 @@ const CommentItem: React.FC = ({ }; return ( - - - {getFloorText(floorNumber)} - - + + {getFloorText(floorNumber)} + ); }; @@ -276,8 +442,9 @@ const CommentItem: React.FC = ({ return ( - {comment.replies.map((reply) => { + {comment.replies.map((reply, replyIndex) => { const replyAuthorId = reply.author?.id || ''; + const isLastReply = replyIndex === comment.replies!.length - 1; // 根据 target_id 获取被回复的用户昵称 const targetId = reply.target_id; const targetNickname = targetId ? getTargetUserNickname(targetId) : null; @@ -287,7 +454,7 @@ const CommentItem: React.FC = ({ return ( onReplyPress?.(reply)} activeOpacity={0.7} > @@ -320,7 +487,7 @@ const CommentItem: React.FC = ({ {/* 显示回复内容(如果有文字) */} {reply.content ? ( - + {reply.content} ) : null} @@ -329,18 +496,18 @@ const CommentItem: React.FC = ({ {/* 子评论操作按钮 */} onReplyPress?.(reply)} > - + 回复 {/* 删除按钮 - 子评论作者可见 */} {isSubReplyAuthor && ( handleSubReplyDelete(reply)} disabled={isDeleting} > @@ -349,7 +516,7 @@ const CommentItem: React.FC = ({ size={12} color={colors.text.hint} /> - + {isDeleting ? '删除中' : '删除'} @@ -375,11 +542,11 @@ const CommentItem: React.FC = ({ return ( - {/* 用户头像 */} - + {/* 用户头像 - 略大便于点击,与正文左对齐 */} + @@ -389,12 +556,13 @@ const CommentItem: React.FC = ({ {/* 用户信息行 - QQ频道风格 */} - - - {comment.author?.nickname} + + + {comment.author?.nickname || '用户'} {renderBadges()} + · {formatTime(comment.created_at || '')} @@ -473,140 +641,4 @@ const CommentItem: React.FC = ({ ); }; -const styles = StyleSheet.create({ - container: { - flexDirection: 'row', - paddingVertical: spacing.sm, - paddingHorizontal: spacing.lg, - backgroundColor: colors.background.paper, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: colors.divider, - }, - content: { - flex: 1, - marginLeft: spacing.sm, - }, - header: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'flex-start', - marginBottom: spacing.xs, - }, - userInfo: { - flexDirection: 'row', - alignItems: 'center', - flexWrap: 'wrap', - flex: 1, - }, - username: { - fontWeight: '600', - fontSize: fontSizes.sm, - color: colors.text.primary, - marginRight: spacing.xs, - }, - badge: { - paddingHorizontal: 4, - paddingVertical: 1, - borderRadius: 2, - marginRight: spacing.xs, - }, - smallBadge: { - paddingHorizontal: 2, - paddingVertical: 0, - }, - authorBadge: { - backgroundColor: colors.primary.main, - }, - adminBadge: { - backgroundColor: colors.error.main, - }, - badgeText: { - color: colors.text.inverse, - fontSize: fontSizes.xs, - fontWeight: '600', - }, - smallBadgeText: { - color: colors.text.inverse, - fontSize: 9, - fontWeight: '600', - }, - timeText: { - fontSize: fontSizes.xs, - }, - floorTag: { - backgroundColor: colors.background.default, - paddingHorizontal: spacing.xs, - paddingVertical: 2, - borderRadius: borderRadius.sm, - }, - floorText: { - fontSize: fontSizes.xs, - }, - replyReference: { - marginBottom: spacing.xs, - }, - commentContent: { - marginBottom: spacing.xs, - }, - text: { - lineHeight: 20, - fontSize: fontSizes.md, - }, - actions: { - flexDirection: 'row', - alignItems: 'center', - }, - actionButton: { - flexDirection: 'row', - alignItems: 'center', - marginRight: spacing.md, - paddingVertical: spacing.xs, - }, - actionText: { - marginLeft: 2, - fontSize: fontSizes.xs, - }, - subRepliesContainer: { - marginTop: spacing.sm, - backgroundColor: colors.background.default, - borderRadius: borderRadius.md, - padding: spacing.sm, - }, - subReplyItem: { - flexDirection: 'row', - alignItems: 'flex-start', - marginBottom: spacing.sm, - }, - subReplyContent: { - flex: 1, - marginLeft: spacing.xs, - }, - subReplyHeader: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 2, - }, - subReplyAuthor: { - fontWeight: '600', - color: colors.text.primary, - marginRight: spacing.xs, - }, - moreRepliesButton: { - marginTop: spacing.xs, - paddingVertical: spacing.xs, - }, - replyToText: { - fontSize: fontSizes.xs, - }, - replyToName: { - fontSize: fontSizes.xs, - fontWeight: '500', - }, - subReplyActions: { - flexDirection: 'row', - alignItems: 'center', - marginTop: spacing.xs, - }, -}); - export default CommentItem; diff --git a/src/components/business/NotificationItem.tsx b/src/components/business/NotificationItem.tsx deleted file mode 100644 index b89322a..0000000 --- a/src/components/business/NotificationItem.tsx +++ /dev/null @@ -1,147 +0,0 @@ -/** - * NotificationItem 通知项组件 - * 根据通知类型显示不同图标和内容 - * - * @deprecated 请使用 SystemMessageItem 组件代替 - * 该组件使用旧的 Notification 类型,新功能请使用 SystemMessageItem - */ - -import React from 'react'; -import { View, TouchableOpacity, StyleSheet } from 'react-native'; -import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { formatDistanceToNow } from 'date-fns'; -import { zhCN } from 'date-fns/locale'; -import { colors, spacing, borderRadius } from '../../theme'; -import { Notification, NotificationType } from '../../types'; -import Text from '../common/Text'; -import Avatar from '../common/Avatar'; - -interface NotificationItemProps { - notification: Notification; - onPress: () => void; -} - -// 通知类型到图标和颜色的映射 -const getNotificationIcon = (type: NotificationType): { icon: string; color: string } => { - switch (type) { - case 'like_post': - return { icon: 'heart', color: colors.error.main }; - case 'like_comment': - return { icon: 'heart', color: colors.error.main }; - case 'comment': - return { icon: 'comment', color: colors.info.main }; - case 'reply': - return { icon: 'reply', color: colors.info.main }; - case 'follow': - return { icon: 'account-plus', color: colors.primary.main }; - case 'mention': - return { icon: 'at', color: colors.warning.main }; - case 'system': - return { icon: 'information', color: colors.text.secondary }; - default: - return { icon: 'bell', color: colors.text.secondary }; - } -}; - -const NotificationItem: React.FC = ({ - notification, - onPress, -}) => { - // 格式化时间 - const formatTime = (dateString: string): string => { - try { - return formatDistanceToNow(new Date(dateString), { - addSuffix: true, - locale: zhCN, - }); - } catch { - return ''; - } - }; - - const { icon, color } = getNotificationIcon(notification.type); - - return ( - - {/* 通知图标/头像 */} - - {notification.type === 'follow' && notification.data.userId ? ( - - ) : ( - - - - )} - - - {/* 通知内容 */} - - - - {notification.content} - - - - {formatTime(notification.createdAt)} - - - - {/* 未读标记 */} - {!notification.isRead && } - - ); -}; - -const styles = StyleSheet.create({ - container: { - flexDirection: 'row', - alignItems: 'center', - padding: spacing.lg, - backgroundColor: colors.background.paper, - borderBottomWidth: 1, - borderBottomColor: colors.divider, - }, - unread: { - backgroundColor: colors.primary.light + '10', - }, - iconContainer: { - marginRight: spacing.md, - }, - iconWrapper: { - width: 40, - height: 40, - borderRadius: 20, - justifyContent: 'center', - alignItems: 'center', - }, - content: { - flex: 1, - }, - textContainer: { - marginBottom: spacing.xs, - }, - unreadText: { - fontWeight: '600', - }, - unreadDot: { - width: 8, - height: 8, - borderRadius: 4, - backgroundColor: colors.primary.main, - marginLeft: spacing.sm, - }, -}); - -export default NotificationItem; diff --git a/src/components/business/PostCard.tsx b/src/components/business/PostCard.tsx deleted file mode 100644 index fb5fabd..0000000 --- a/src/components/business/PostCard.tsx +++ /dev/null @@ -1,977 +0,0 @@ -/** - * PostCard 帖子卡片组件 - QQ频道风格(响应式版本) - * 简洁的帖子展示,无卡片边框 - * 支持响应式布局,宽屏下优化显示 - */ - -import React, { useState, useCallback, useMemo } from 'react'; -import { - View, - TouchableOpacity, - StyleSheet, - Alert, - useWindowDimensions, -} from 'react-native'; -import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { colors, spacing, fontSizes, borderRadius } from '../../theme'; -import { Post } from '../../types'; -import Text from '../common/Text'; -import Avatar from '../common/Avatar'; -import { ImageGrid, ImageGridItem, SmartImage } from '../common'; -import VotePreview from './VotePreview'; -import { useResponsive, useResponsiveValue } from '../../hooks/useResponsive'; -import { getPreviewImageUrl, ImageDisplayMode } from '../../utils/imageHelper'; - -interface PostCardProps { - post: Post; - onPress: () => void; - onUserPress: () => void; - onLike: () => void; - onComment: () => void; - onBookmark: () => void; - onShare: () => void; - onImagePress?: (images: ImageGridItem[], index: number) => void; - onDelete?: () => void; - compact?: boolean; - index?: number; - isAuthor?: boolean; - isPostAuthor?: boolean; // 当前用户是否为帖子作者 - variant?: 'default' | 'grid'; -} - -const PostCard: React.FC = ({ - post, - onPress, - onUserPress, - onLike, - onComment, - onBookmark, - onShare, - onImagePress, - onDelete, - compact = false, - index, - isAuthor = false, - isPostAuthor = false, - variant = 'default', -}) => { - const [isExpanded, setIsExpanded] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); - - // 使用响应式 hook - const { - width, - isMobile, - isTablet, - isDesktop, - isWideScreen, - isLandscape, - orientation - } = useResponsive(); - - const { width: windowWidth } = useWindowDimensions(); - - // 响应式字体大小 - const responsiveFontSize = useResponsiveValue({ - xs: fontSizes.md, - sm: fontSizes.md, - md: fontSizes.lg, - lg: isLandscape ? fontSizes.lg : fontSizes.xl, - xl: fontSizes.xl, - '2xl': fontSizes.xl, - '3xl': fontSizes['2xl'], - '4xl': fontSizes['2xl'], - }); - - // 响应式标题字体大小 - const responsiveTitleFontSize = useResponsiveValue({ - xs: fontSizes.lg, - sm: fontSizes.lg, - md: fontSizes.xl, - lg: isLandscape ? fontSizes.xl : fontSizes['2xl'], - xl: fontSizes['2xl'], - '2xl': fontSizes['2xl'], - '3xl': fontSizes['3xl'], - '4xl': fontSizes['3xl'], - }); - - // 响应式头像大小 - const avatarSize = useResponsiveValue({ - xs: 36, - sm: 38, - md: 40, - lg: 44, - xl: 48, - '2xl': 48, - '3xl': 52, - '4xl': 56, - }); - - // 响应式内边距 - 宽屏下增加更多内边距 - const responsivePadding = useResponsiveValue({ - xs: spacing.md, - sm: spacing.md, - md: spacing.lg, - lg: spacing.xl, - xl: spacing.xl * 1.2, - '2xl': spacing.xl * 1.5, - '3xl': spacing.xl * 2, - '4xl': spacing.xl * 2.5, - }); - - // 宽屏下额外的卡片内边距 - const cardPadding = useMemo(() => { - if (isWideScreen) return spacing.xl; - if (isDesktop) return spacing.lg; - if (isTablet) return spacing.md; - return 0; // 移动端无额外内边距 - }, [isWideScreen, isDesktop, isTablet]); - - const formatDateTime = (dateString?: string | null): string => { - if (!dateString) return ''; - const date = new Date(dateString); - if (Number.isNaN(date.getTime())) return ''; - const pad = (num: number) => String(num).padStart(2, '0'); - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; - }; - - const isPostEdited = (createdAt?: string, updatedAt?: string): boolean => { - if (!createdAt || !updatedAt) return false; - const created = new Date(createdAt).getTime(); - const updated = new Date(updatedAt).getTime(); - if (Number.isNaN(created) || Number.isNaN(updated)) return false; - return updated - created > 1000; - }; - - const getTruncatedContent = (content: string | undefined | null, maxLength: number = 100): string => { - if (!content) return ''; - if (content.length <= maxLength || isExpanded) return content; - return content.substring(0, maxLength) + '...'; - }; - - // 宽屏下显示更多内容 - const getResponsiveMaxLength = () => { - if (isWideScreen) return 300; - if (isDesktop) return 250; - if (isTablet) return 200; - return 100; - }; - - const formatNumber = (num: number): string => { - if (num >= 10000) { - return (num / 10000).toFixed(1) + 'w'; - } - if (num >= 1000) { - return (num / 1000).toFixed(1) + 'k'; - } - return num.toString(); - }; - - // 处理删除帖子 - const handleDelete = () => { - if (!onDelete || isDeleting) return; - - Alert.alert( - '删除帖子', - '确定要删除这篇帖子吗?删除后将无法恢复。', - [ - { - text: '取消', - style: 'cancel', - }, - { - text: '删除', - style: 'destructive', - onPress: async () => { - setIsDeleting(true); - try { - await onDelete(); - } catch (error) { - console.error('删除帖子失败:', error); - Alert.alert('删除失败', '删除帖子时发生错误,请稍后重试'); - } finally { - setIsDeleting(false); - } - }, - }, - ], - ); - }; - - // 渲染图片 - 使用新的 ImageGrid 组件 - const renderImages = () => { - if (!post.images || !Array.isArray(post.images) || post.images.length === 0) return null; - - // 转换图片数据格式 - const gridImages: ImageGridItem[] = post.images.map(img => ({ - id: img.id, - url: img.url, - thumbnail_url: img.thumbnail_url, - preview_url: img.preview_url, - preview_url_large: img.preview_url_large, - width: img.width, - height: img.height, - })); - - // 宽屏下显示更大的图片 - const imageGap = isDesktop ? 12 : isTablet ? 8 : 4; - const imageBorderRadius = isDesktop ? borderRadius.xl : borderRadius.md; - - return ( - - - - ); - }; - - const renderBadges = () => { - const badges = []; - - if (isAuthor) { - badges.push( - - 楼主 - - ); - } - - if (post.author?.id === '1') { - badges.push( - - 管理员 - - ); - } - - return badges; - }; - - const renderTopComment = () => { - if (!post.top_comment) return null; - - const { top_comment } = post; - - const topCommentContainerStyles = [ - styles.topCommentContainer, - ...(isDesktop ? [styles.topCommentContainerWide] : []) - ]; - - const topCommentAuthorStyles = [ - styles.topCommentAuthor, - ...(isDesktop ? [styles.topCommentAuthorWide] : []) - ]; - - const topCommentTextStyles = [ - styles.topCommentText, - ...(isDesktop ? [styles.topCommentTextWide] : []) - ]; - - const moreCommentsStyles = [ - styles.moreComments, - ...(isDesktop ? [styles.moreCommentsWide] : []) - ]; - - return ( - - - - {top_comment.author?.nickname || '匿名用户'}: - - - {top_comment.content} - - - {post.comments_count > 1 && ( - - 查看全部{formatNumber(post.comments_count)}条评论 - - )} - - ); - }; - - // 渲染投票预览 - const renderVotePreview = () => { - if (!post.is_vote) return null; - - return ( - - ); - }; - - // 渲染小红书风格的两栏卡片 - const renderGridVariant = () => { - // 获取封面图(第一张图) - const coverImage = post.images && Array.isArray(post.images) && post.images.length > 0 ? post.images[0] : null; - const hasImage = !!coverImage; - - // 防御性检查:确保 author 存在 - const author = post.author || { id: '', nickname: '匿名用户', avatar: null, username: '' }; - - // 根据图片实际宽高比或使用随机值创建错落感 - // 使用帖子 ID 生成一个伪随机值,确保每次渲染一致但不同帖子有差异 - const postId = post.id || ''; - const hash = postId.split('').reduce((a, b) => a + b.charCodeAt(0), 0); - const aspectRatios = [0.7, 0.75, 0.8, 0.85, 0.9, 1, 1.1, 1.2]; - const aspectRatio = aspectRatios[hash % aspectRatios.length]; - - // 获取封面图预览 URL - const coverUrl = hasImage - ? getPreviewImageUrl(coverImage, 'grid') - : ''; - - // 获取正文预览(无图时显示) - const getContentPreview = (content: string | undefined | null): string => { - if (!content) return ''; - // 移除 Markdown 标记和多余空白 - return content - .replace(/[#*_~`\[\]()]/g, '') - .replace(/\n+/g, ' ') - .trim(); - }; - - const contentPreview = getContentPreview(post.content); - - // 响应式字体大小(网格模式) - const gridTitleFontSize = isDesktop ? 16 : isTablet ? 15 : 14; - const gridUsernameFontSize = isDesktop ? 14 : 12; - const gridLikeFontSize = isDesktop ? 14 : 12; - - const handleContainerPress = () => { - onPress(); - }; - - const handleImagePress = (e: any) => { - e.stopPropagation(); - onImagePress?.(post.images || [], 0); - }; - - const handleUserPress = (e: any) => { - e.stopPropagation(); - onUserPress(); - }; - - return ( - - {/* 封面图 - 只有有图片时才渲染,无图时不显示占位区域 */} - {hasImage && ( - - - - )} - - {/* 无图时的正文预览区域 */} - {!hasImage && contentPreview && ( - - - {contentPreview} - - - )} - - {/* 投票标识 */} - {post.is_vote && ( - - - 投票 - - )} - - {/* 标题 - 无图时显示更多行 */} - {post.title && ( - - {post.title} - - )} - - {/* 底部信息 */} - - - - - {author.nickname} - - - - - - - {formatNumber(post.likes_count)} - - - - - ); - }; - - // 根据 variant 渲染不同样式 - if (variant === 'grid') { - return renderGridVariant(); - } - - // 防御性检查:确保 author 存在 - const author = post.author || { id: '', nickname: '匿名用户', avatar: null, username: '' }; - - // 计算响应式内容最大行数 - const contentNumberOfLines = useMemo(() => { - if (isWideScreen) return 8; - if (isDesktop) return 6; - if (isTablet) return 5; - return 3; - }, [isWideScreen, isDesktop, isTablet]); - - const handleContainerPress = () => { - onPress(); - }; - - const handleUserPress = (e: any) => { - e.stopPropagation(); - onUserPress(); - }; - - const handleLikePress = (e: any) => { - e.stopPropagation(); - onLike(); - }; - - const handleCommentPress = (e: any) => { - e.stopPropagation(); - onComment(); - }; - - const handleSharePress = (e: any) => { - e.stopPropagation(); - onShare(); - }; - - const handleBookmarkPress = (e: any) => { - e.stopPropagation(); - onBookmark(); - }; - - const handleDeletePress = (e: any) => { - e.stopPropagation(); - handleDelete(); - }; - - return ( - - {/* 用户信息 */} - - - - - - - - - {author.nickname} - - - {renderBadges()} - - - - 发布 {formatDateTime(post.created_at)} - - {isPostEdited(post.created_at, post.updated_at) && ( - - {' · 修改 '}{formatDateTime(post.updated_at)} - - )} - - - {post.is_pinned && ( - - - 置顶 - - )} - {/* 删除按钮 - 只对帖子作者显示 */} - {isPostAuthor && onDelete && ( - - - - )} - - - {/* 标题 */} - {post.title && ( - - {post.title} - - )} - - {/* 内容 */} - {!compact && ( - <> - - {getTruncatedContent(post.content, getResponsiveMaxLength())} - - {post.content && post.content.length > getResponsiveMaxLength() && ( - setIsExpanded(!isExpanded)} - style={styles.expandButton} - > - - {isExpanded ? '收起' : '展开全文'} - - - )} - - )} - - {/* 图片 */} - {renderImages()} - - {/* 投票预览 */} - {renderVotePreview()} - - {/* 热门评论预览 */} - {renderTopComment()} - - {/* 交互按钮 */} - - - - - {formatNumber(post.views_count || 0)} - - - - - - - - {post.likes_count > 0 ? formatNumber(post.likes_count) : '赞'} - - - - - - - {post.comments_count > 0 ? formatNumber(post.comments_count) : '评论'} - - - - - - - - - - - - - - ); -}; - -const styles = StyleSheet.create({ - container: { - backgroundColor: colors.background.paper, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: colors.divider, - }, - // 宽屏卡片样式 - wideCard: { - backgroundColor: colors.background.paper, - borderRadius: borderRadius.lg, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.08, - shadowRadius: 8, - elevation: 3, - }, - userSection: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: spacing.sm, - }, - userInfo: { - flex: 1, - marginLeft: spacing.sm, - }, - userNameRow: { - flexDirection: 'row', - alignItems: 'center', - flexWrap: 'wrap', - }, - username: { - fontWeight: '600', - color: colors.text.primary, - }, - badge: { - paddingHorizontal: 4, - paddingVertical: 1, - borderRadius: 2, - marginLeft: spacing.xs, - }, - authorBadge: { - backgroundColor: colors.primary.main, - }, - adminBadge: { - backgroundColor: colors.error.main, - }, - badgeText: { - color: colors.text.inverse, - fontSize: fontSizes.xs, - fontWeight: '600', - }, - postMeta: { - flexDirection: 'row', - alignItems: 'center', - marginTop: 2, - }, - timeText: { - fontSize: fontSizes.sm, - color: colors.text.hint, - }, - pinnedTag: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.warning.light + '20', - paddingHorizontal: spacing.xs, - paddingVertical: 2, - borderRadius: borderRadius.sm, - }, - pinnedText: { - marginLeft: 2, - fontSize: fontSizes.xs, - fontWeight: '500', - }, - deleteButton: { - padding: spacing.xs, - marginLeft: spacing.sm, - }, - title: { - marginBottom: spacing.xs, - fontWeight: '600', - color: colors.text.primary, - }, - content: { - marginBottom: spacing.xs, - color: colors.text.secondary, - }, - expandButton: { - marginBottom: spacing.sm, - }, - imagesContainer: { - marginBottom: spacing.md, - }, - topCommentContainer: { - backgroundColor: colors.background.default, - borderRadius: borderRadius.sm, - padding: spacing.sm, - marginBottom: spacing.sm, - }, - topCommentContainerWide: { - padding: spacing.md, - borderRadius: borderRadius.md, - }, - topCommentContent: { - flexDirection: 'row', - alignItems: 'center', - }, - topCommentAuthor: { - fontWeight: '600', - marginRight: spacing.xs, - }, - topCommentAuthorWide: { - fontSize: fontSizes.sm, - }, - topCommentText: { - flex: 1, - }, - topCommentTextWide: { - fontSize: fontSizes.sm, - }, - moreComments: { - marginTop: spacing.xs, - fontWeight: '500', - }, - moreCommentsWide: { - fontSize: fontSizes.sm, - marginTop: spacing.sm, - }, - actionBar: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - }, - actionBarWide: { - marginTop: spacing.sm, - paddingTop: spacing.sm, - }, - viewCount: { - flexDirection: 'row', - alignItems: 'center', - }, - actionButtons: { - flexDirection: 'row', - alignItems: 'center', - }, - actionButton: { - flexDirection: 'row', - alignItems: 'center', - marginLeft: spacing.md, - paddingVertical: spacing.xs, - }, - actionButtonWide: { - marginLeft: spacing.lg, - paddingVertical: spacing.sm, - }, - actionText: { - marginLeft: 6, - }, - actionTextWide: { - fontSize: fontSizes.md, - }, - - // ========== 小红书风格两栏样式 ========== - gridContainer: { - backgroundColor: '#FFF', - overflow: 'hidden', - borderRadius: 8, - }, - gridContainerDesktop: { - borderRadius: 12, - }, - gridContainerNoImage: { - minHeight: 200, - justifyContent: 'space-between', - }, - gridCoverImage: { - width: '100%', - aspectRatio: 0.7, - backgroundColor: '#F5F5F5', - }, - gridCoverPlaceholder: { - width: '100%', - aspectRatio: 0.7, - backgroundColor: '#F5F5F5', - alignItems: 'center', - justifyContent: 'center', - }, - // 无图时的正文预览区域 - gridContentPreview: { - backgroundColor: '#F8F8F8', - margin: 4, - padding: 8, - borderRadius: 8, - minHeight: 100, - }, - gridContentPreviewDesktop: { - margin: 8, - padding: 12, - borderRadius: 12, - minHeight: 150, - }, - gridContentText: { - fontSize: 13, - color: '#666', - lineHeight: 20, - }, - gridContentTextDesktop: { - fontSize: 15, - lineHeight: 24, - }, - gridTitle: { - color: '#333', - lineHeight: 20, - paddingHorizontal: 4, - paddingTop: 8, - minHeight: 44, - }, - gridTitleNoImage: { - minHeight: 0, - paddingTop: 4, - }, - gridTitleDesktop: { - paddingHorizontal: 8, - paddingTop: 12, - lineHeight: 24, - minHeight: 56, - }, - gridFooter: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 4, - paddingVertical: 8, - }, - gridFooterDesktop: { - paddingHorizontal: 8, - paddingVertical: 12, - }, - gridUserInfo: { - flexDirection: 'row', - alignItems: 'center', - flex: 1, - }, - gridUsername: { - color: '#666', - marginLeft: 6, - flex: 1, - }, - gridLikeInfo: { - flexDirection: 'row', - alignItems: 'center', - }, - gridLikeCount: { - color: '#666', - marginLeft: 4, - }, - gridVoteBadge: { - position: 'absolute', - top: 8, - right: 8, - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.primary.main + 'CC', - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: borderRadius.full, - gap: 4, - }, - gridVoteBadgeText: { - fontSize: 10, - color: colors.primary.contrast, - fontWeight: '600', - }, -}); - -export default PostCard; diff --git a/src/components/business/PostCard/PostCard.tsx b/src/components/business/PostCard/PostCard.tsx new file mode 100644 index 0000000..e4ecaf1 --- /dev/null +++ b/src/components/business/PostCard/PostCard.tsx @@ -0,0 +1,713 @@ +/** + * PostCard 主组件 + * 帖子卡片组件(新实现) + * + * @example + * // 新 API 使用方式 + * handleAction(post, action)} + * variant="list" + * features="full" + * /> + */ + +import React, { useMemo, useState, memo, useEffect } from 'react'; +import { View, TouchableOpacity, StyleSheet, Alert, TextStyle } from 'react-native'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { PostCardProps, PostCardAction } from './types'; +import { ImageGridItem, SmartImage } from '../../common'; +import Text from '../../common/Text'; +import Avatar from '../../common/Avatar'; +import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../../theme'; +import { getPreviewImageUrl } from '../../../utils/imageHelper'; +import PostImages from './components/PostImages'; +import { useResponsive } from '../../../hooks/useResponsive'; +import { formatPostCreatedAtString } from '../../../core/entities/Post'; + +/** 列表相对时间:独立定时刷新,避免外层 PostCard.memo 挡住「分钟前」更新 */ +const PostCardRelativeTime: React.FC<{ createdAt?: string | null; style?: TextStyle | TextStyle[] }> = ({ + createdAt, + style: timeStyle, +}) => { + const [label, setLabel] = useState(() => formatPostCreatedAtString(createdAt)); + + useEffect(() => { + setLabel(formatPostCreatedAtString(createdAt)); + const tick = () => setLabel(formatPostCreatedAtString(createdAt)); + const id = setInterval(tick, 60_000); + return () => clearInterval(id); + }, [createdAt]); + + return ( + + {label} + + ); +}; + +function imagesSignature( + images: PostCardProps['post']['images'] | undefined +): string { + if (!images?.length) return ''; + return images.map((i) => i.id).join('\u001f'); +} + +function authorSignature(author: PostCardProps['post']['author']): string { + if (!author) return ''; + return [author.id, author.nickname ?? '', author.avatar ?? ''].join('\u001f'); +} + +function topCommentSignature(tc: PostCardProps['post']['top_comment']): string { + if (!tc) return ''; + return [tc.id, tc.content ?? '', tc.author?.id ?? ''].join('\u001f'); +} + +function channelSignature(ch: PostCardProps['post']['channel']): string { + if (!ch) return ''; + return [ch.id, ch.name ?? ''].join('\u001f'); +} + +function featuresComparable(f: PostCardProps['features']): string { + if (f == null) return ''; + if (typeof f === 'string') return f; + return JSON.stringify(f); +} + +/** + * 自定义比较:忽略 onAction 引用(父组件应用 postId + ref 解析最新 post,避免 Tab 切换时全列表因回调重建而重绘)。 + * index 未参与 UI,不比较以免列表重排时多余更新。 + */ +function arePostCardPropsEqual(prev: PostCardProps, next: PostCardProps): boolean { + if (prev.variant !== next.variant) return false; + if (prev.isPostAuthor !== next.isPostAuthor) return false; + if (prev.isAuthor !== next.isAuthor) return false; + if (prev.style !== next.style) return false; + if (featuresComparable(prev.features) !== featuresComparable(next.features)) return false; + + const a = prev.post; + const b = next.post; + if (a === b) return true; + + if (a.id !== b.id) return false; + if (a.title !== b.title) return false; + if (a.content !== b.content) return false; + if ((a.likes_count ?? 0) !== (b.likes_count ?? 0)) return false; + if ((a.comments_count ?? 0) !== (b.comments_count ?? 0)) return false; + if ((a.favorites_count ?? 0) !== (b.favorites_count ?? 0)) return false; + if ((a.shares_count ?? 0) !== (b.shares_count ?? 0)) return false; + if ((a.views_count ?? 0) !== (b.views_count ?? 0)) return false; + if (!!a.is_pinned !== !!b.is_pinned) return false; + if (!!a.is_locked !== !!b.is_locked) return false; + if (!!a.is_vote !== !!b.is_vote) return false; + if (a.created_at !== b.created_at) return false; + if (a.updated_at !== b.updated_at) return false; + if (a.content_edited_at !== b.content_edited_at) return false; + if (!!a.is_liked !== !!b.is_liked) return false; + if (!!a.is_favorited !== !!b.is_favorited) return false; + if (authorSignature(a.author) !== authorSignature(b.author)) return false; + if (imagesSignature(a.images) !== imagesSignature(b.images)) return false; + if (topCommentSignature(a.top_comment) !== topCommentSignature(b.top_comment)) return false; + if (channelSignature(a.channel) !== channelSignature(b.channel)) return false; + + return true; +} + +function createPostCardStyles(colors: AppColors) { + return StyleSheet.create({ + card: { + backgroundColor: colors.background.paper, + borderRadius: borderRadius.lg, + padding: spacing.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.divider, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: spacing.sm, + }, + authorWrap: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + minWidth: 0, + }, + authorMeta: { + marginLeft: spacing.sm, + flex: 1, + minWidth: 0, + }, + authorName: { + color: colors.text.primary, + fontWeight: '600', + fontSize: fontSizes.md, + }, + timeText: { + color: colors.text.hint, + fontSize: fontSizes.xs, + marginTop: 2, + }, + metaRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + marginTop: 2, + flexWrap: 'wrap', + }, + channelTag: { + flexDirection: 'row', + alignItems: 'center', + maxWidth: 140, + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: borderRadius.sm, + backgroundColor: `${colors.primary.main}12`, + gap: 4, + }, + channelTagText: { + fontSize: fontSizes.xs, + color: colors.primary.main, + fontWeight: '600', + flexShrink: 1, + }, + pinnedTag: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: `${colors.warning.light}22`, + borderRadius: borderRadius.sm, + paddingHorizontal: 4, + paddingVertical: 1, + gap: 2, + }, + pinnedText: { + color: colors.warning.main, + fontSize: fontSizes.xs, + }, + badgeText: { + color: colors.primary.main, + fontSize: fontSizes.xs, + fontWeight: '600', + }, + deleteButton: { + padding: spacing.xs, + marginLeft: spacing.sm, + }, + title: { + color: colors.text.primary, + fontSize: fontSizes.lg, + fontWeight: '600', + marginBottom: spacing.xs, + }, + content: { + color: colors.text.secondary, + fontSize: fontSizes.md, + marginBottom: spacing.xs, + lineHeight: fontSizes.md * 1.45, + }, + expandBtn: { + marginBottom: spacing.sm, + }, + expandText: { + color: colors.primary.main, + fontSize: fontSizes.sm, + }, + topCommentBox: { + backgroundColor: colors.background.default, + borderRadius: borderRadius.sm, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + marginBottom: spacing.sm, + flexDirection: 'row', + alignItems: 'center', + }, + topCommentAuthor: { + color: colors.text.secondary, + fontSize: fontSizes.sm, + fontWeight: '600', + marginRight: 4, + }, + topCommentText: { + color: colors.text.secondary, + fontSize: fontSizes.sm, + flex: 1, + }, + gridCover: { + width: '100%', + aspectRatio: 0.78, + backgroundColor: colors.background.default, + }, + actions: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + actionsLeading: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + minWidth: 0, + marginRight: spacing.sm, + gap: spacing.sm, + }, + channelTagAfterViews: { + maxWidth: 130, + flexShrink: 1, + }, + viewsWrap: { + flexDirection: 'row', + alignItems: 'center', + flexShrink: 0, + }, + viewsText: { + color: colors.text.hint, + fontSize: fontSizes.sm, + marginLeft: 4, + }, + actionButtons: { + flexDirection: 'row', + alignItems: 'center', + }, + actionBtn: { + flexDirection: 'row', + alignItems: 'center', + marginLeft: spacing.md, + }, + actionText: { + color: colors.text.secondary, + fontSize: fontSizes.sm, + marginLeft: 4, + }, + activeActionText: { + color: colors.error.main, + }, + activeActionTextMerged: { + color: colors.error.main, + fontSize: fontSizes.sm, + marginLeft: 4, + }, + gridRootCard: { + backgroundColor: colors.background.paper, + borderRadius: borderRadius.lg, + overflow: 'hidden', + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.divider, + }, + gridNoImagePreview: { + backgroundColor: colors.background.default, + margin: 6, + borderRadius: borderRadius.md, + minHeight: 120, + padding: 8, + }, + gridNoImageText: { + color: colors.text.secondary, + fontSize: fontSizes.sm, + lineHeight: 20, + }, + gridVoteTag: { + position: 'absolute', + top: 8, + right: 8, + backgroundColor: `${colors.primary.main}CC`, + borderRadius: borderRadius.full, + paddingHorizontal: 8, + paddingVertical: 4, + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + gridVoteTagText: { + color: colors.primary.contrast, + fontSize: 10, + fontWeight: '600', + }, + gridTitleMain: { + color: colors.text.primary, + fontSize: fontSizes.md, + lineHeight: 20, + paddingHorizontal: 8, + paddingTop: 8, + minHeight: 44, + }, + gridChannelRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingHorizontal: 8, + paddingTop: 4, + paddingBottom: 2, + }, + gridChannelText: { + fontSize: fontSizes.xs, + color: colors.text.secondary, + fontWeight: '600', + flex: 1, + minWidth: 0, + }, + gridFooter: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 8, + paddingVertical: 10, + }, + gridUserArea: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + minWidth: 0, + }, + gridUsername: { + color: colors.text.secondary, + fontSize: fontSizes.sm, + marginLeft: 6, + flex: 1, + }, + gridLikeArea: { + flexDirection: 'row', + alignItems: 'center', + }, + gridLikeCount: { + color: colors.text.secondary, + fontSize: fontSizes.sm, + marginLeft: 4, + }, + }); +} + +/** + * PostCard 主组件 + * 仅支持新 API + */ +const PostCardInner: React.FC = (normalizedProps) => { + const { + post, + onAction, + variant = 'list', + features, + isPostAuthor = false, + isAuthor = false, + index, + style, + } = normalizedProps; + + const colors = useAppColors(); + const styles = useMemo(() => createPostCardStyles(colors), [colors]); + const [isExpanded, setIsExpanded] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + const isCompact = + features === 'compact' || + (typeof features === 'object' && features?.showContent === false); + + const emit = (action: PostCardAction) => { + onAction(action); + }; + + const handleImagePress = (images: ImageGridItem[], imageIndex: number) => { + emit({ type: 'imagePress', payload: { images, imageIndex } }); + }; + + const showGrid = variant === 'grid'; + const author = post.author; + const rawContent = post.content ?? ''; + const { isDesktop, isTablet, isWideScreen, isLandscape } = useResponsive(); + + const handleCardPress = () => emit({ type: 'press' }); + const handleUserPress = () => emit({ type: 'userPress' }); + const handleLike = () => emit({ type: post.is_liked ? 'unlike' : 'like' }); + const handleComment = () => emit({ type: 'comment' }); + const handleBookmark = () => emit({ type: post.is_favorited ? 'unbookmark' : 'bookmark' }); + const handleShare = () => emit({ type: 'share' }); + const handleDelete = () => { + if (isDeleting) return; + Alert.alert( + '删除帖子', + '确定要删除这篇帖子吗?删除后将无法恢复。', + [ + { text: '取消', style: 'cancel' }, + { + text: '删除', + style: 'destructive', + onPress: async () => { + setIsDeleting(true); + try { + emit({ type: 'delete' }); + } finally { + setIsDeleting(false); + } + }, + }, + ], + ); + }; + + const images: ImageGridItem[] = Array.isArray(post.images) + ? post.images.map((img) => ({ + id: img.id, + url: img.url, + thumbnail_url: img.thumbnail_url, + preview_url: img.preview_url, + preview_url_large: img.preview_url_large, + width: img.width, + height: img.height, + })) + : []; + + const formatNumber = (num: number): string => { + if (num >= 10000) return `${(num / 10000).toFixed(1)}w`; + if (num >= 1000) return `${(num / 1000).toFixed(1)}k`; + return String(num); + }; + + const getResponsiveMaxLength = () => { + if (isWideScreen) return 300; + if (isDesktop) return 250; + if (isTablet) return 200; + return 100; + }; + + const contentNumberOfLines = useMemo(() => { + if (isWideScreen) return 8; + if (isDesktop) return 6; + if (isTablet) return 5; + return 3; + }, [isWideScreen, isDesktop, isTablet]); + + const getTruncatedContent = (value: string): string => { + const maxLength = getResponsiveMaxLength(); + if (value.length <= maxLength || isExpanded) return value; + return `${value.substring(0, maxLength)}...`; + }; + + const shouldTruncate = !showGrid && !isCompact && rawContent.length > getResponsiveMaxLength(); + const content = useMemo( + () => (showGrid || isCompact ? rawContent : getTruncatedContent(rawContent)), + [rawContent, showGrid, isCompact, isExpanded, isWideScreen, isDesktop, isTablet] + ); + + const renderImages = () => { + if (images.length === 0) return null; + + if (showGrid) { + const cover = images[0]; + const coverUrl = getPreviewImageUrl(cover as any, 'grid'); + return ( + handleImagePress(images, 0)}> + + + ); + } + + return ( + + ); + }; + + const renderTopComment = () => { + if (showGrid || !post.top_comment) return null; + return ( + + + {post.top_comment.author?.nickname || '匿名用户'}: + + + {post.top_comment.content} + + + ); + }; + + const renderGridCard = () => { + const cover = images[0]; + const hasImage = !!cover; + const coverUrl = hasImage ? getPreviewImageUrl(cover as any, 'grid') : ''; + const contentPreview = rawContent + .replace(/[#*_~`\[\]()]/g, '') + .replace(/\n+/g, ' ') + .trim(); + + return ( + + {hasImage ? ( + handleImagePress(images, 0)}> + + + ) : ( + + + {contentPreview} + + + )} + + {post.is_vote && ( + + + 投票 + + )} + + {!!post.title && ( + + {post.title} + + )} + + {!!post.channel?.name && ( + + + + {post.channel.name} + + + )} + + + + + {author?.nickname || '匿名用户'} + + + + {formatNumber(post.likes_count || 0)} + + + + ); + }; + + return ( + showGrid ? renderGridCard() : ( + + + + + + {author?.nickname || '匿名用户'} + {!showGrid && ( + + + {post.is_pinned && ( + + + 置顶 + + )} + {isAuthor && 楼主} + + )} + + + {isPostAuthor && ( + + + + )} + + + {!!post.title && ( + + {post.title} + + )} + + {!isCompact && !!content && ( + <> + + {content} + + {shouldTruncate && ( + setIsExpanded((prev) => !prev)} style={styles.expandBtn}> + {isExpanded ? '收起' : '展开全文'} + + )} + + )} + + {renderImages()} + {renderTopComment()} + + + + + + {post.views_count || 0} + + {!!post.channel?.name && ( + + + + {post.channel.name} + + + )} + + + + + + {post.likes_count > 0 ? formatNumber(post.likes_count) : '赞'} + + + + + {post.comments_count > 0 ? formatNumber(post.comments_count) : '评论'} + + + + + + + + + + + ) + ); +}; + +PostCardInner.displayName = 'PostCard'; + +const PostCard = memo(PostCardInner, arePostCardPropsEqual); + +export default PostCard; \ No newline at end of file diff --git a/src/components/business/PostCard/components/PostImages.tsx b/src/components/business/PostCard/components/PostImages.tsx new file mode 100644 index 0000000..75e7161 --- /dev/null +++ b/src/components/business/PostCard/components/PostImages.tsx @@ -0,0 +1,53 @@ +/** + * PostImages 子组件 + * 显示帖子图片网格 + */ + +import React from 'react'; +import { View, StyleSheet } from 'react-native'; +import { spacing, borderRadius } from '../../../../theme'; +import { useResponsive } from '../../../../hooks/useResponsive'; +import { ImageGrid, ImageGridItem } from '../../../common'; +import { PostImagesProps } from '../types'; + +const PostImages: React.FC = ({ images, displayMode, onImagePress }) => { + const { isDesktop, isWideScreen } = useResponsive(); + + if (!images || images.length === 0) return null; + + // 计算图片间距和圆角 + const imageGap = isDesktop ? 4 : 2; + const imageBorderRadius = isDesktop ? borderRadius.xl : borderRadius.md; + + return ( + + ({ + id: img.id, + url: img.url, + thumbnail_url: img.thumbnail_url, + preview_url: img.preview_url, + preview_url_large: img.preview_url_large, + width: img.width, + height: img.height, + }))} + maxDisplayCount={isWideScreen ? 12 : 9} + mode="auto" + gap={imageGap} + borderRadius={imageBorderRadius} + showMoreOverlay={true} + onImagePress={onImagePress} + displayMode={displayMode} + usePreview={true} + /> + + ); +}; + +const styles = StyleSheet.create({ + container: { + marginBottom: spacing.md, + }, +}); + +export default PostImages; \ No newline at end of file diff --git a/src/components/business/PostCard/components/index.ts b/src/components/business/PostCard/components/index.ts new file mode 100644 index 0000000..68a7cd6 --- /dev/null +++ b/src/components/business/PostCard/components/index.ts @@ -0,0 +1,5 @@ +/** + * PostCard 子组件导出(仅导出仓库内已存在的实现) + */ + +export { default as PostImages } from './PostImages'; diff --git a/src/components/business/PostCard/hooks/index.ts b/src/components/business/PostCard/hooks/index.ts new file mode 100644 index 0000000..55cf8d1 --- /dev/null +++ b/src/components/business/PostCard/hooks/index.ts @@ -0,0 +1,5 @@ +/** + * PostCard Hooks(预留入口;实现文件尚未加入仓库时勿导出,避免 TS 无法解析) + */ + +export {}; diff --git a/src/components/business/PostCard/index.ts b/src/components/business/PostCard/index.ts new file mode 100644 index 0000000..0e8c2b6 --- /dev/null +++ b/src/components/business/PostCard/index.ts @@ -0,0 +1,30 @@ +/** + * PostCard 组件模块导出 + */ + +// 主组件(默认导出) +export { default } from './PostCard'; + +// 类型导出 +export type { + PostCardActionType, + PostCardActionPayload, + PostCardAction, + PostCardOnAction, + PostCardFeatures, + PostCardFeaturesPreset, + PostCardFeaturesConfig, + PostCardProps, + PostHeaderProps, + PostTitleProps, + PostContentProps, + PostImagesProps, + PostActionsProps, + PostVotePreviewProps, + PostTopCommentProps, + PostGridCoverProps, + PostGridInfoProps, + PostCardListProps, + PostCardGridProps, + Badge, +} from './types'; \ No newline at end of file diff --git a/src/components/business/PostCard/types.ts b/src/components/business/PostCard/types.ts new file mode 100644 index 0000000..5585ced --- /dev/null +++ b/src/components/business/PostCard/types.ts @@ -0,0 +1,262 @@ +/** + * PostCard 组件类型定义 + * 统一的帖子卡片组件类型系统 + */ + +import { StyleProp, ViewStyle } from 'react-native'; +import { Post, PostImageDTO, UserDTO, CommentDTO } from '../../../types'; +import { ImageGridItem } from '../../common'; + +// ==================== Action 类型定义 ==================== + +/** + * PostCard 支持的操作类型 + */ +export type PostCardActionType = + | 'press' // 点击帖子主体 + | 'userPress' // 点击用户头像/名称 + | 'like' // 点赞 + | 'unlike' // 取消点赞 + | 'comment' // 评论 + | 'bookmark' // 收藏 + | 'unbookmark' // 取消收藏 + | 'share' // 分享 + | 'imagePress' // 点击图片 + | 'delete'; // 删除 + +/** + * Action payload 类型 + */ +export interface PostCardActionPayload { + images?: ImageGridItem[]; + imageIndex?: number; + [key: string]: unknown; +} + +/** + * 统一的 Action 对象 + */ +export interface PostCardAction { + type: PostCardActionType; + payload?: PostCardActionPayload; +} + +/** + * 统一的回调函数类型 + */ +export type PostCardOnAction = (action: PostCardAction) => void; + +// ==================== Features 配置类型 ==================== + +/** + * 显示特性配置 + */ +export interface PostCardFeatures { + /** 显示用户信息 */ + showAuthor?: boolean; + /** 显示标题 */ + showTitle?: boolean; + /** 显示内容 */ + showContent?: boolean; + /** 显示图片 */ + showImages?: boolean; + /** 显示投票预览 */ + showVote?: boolean; + /** 显示热门评论 */ + showTopComment?: boolean; + /** 显示操作栏 */ + showActions?: boolean; + /** 显示浏览数 */ + showViews?: boolean; + /** 显示分享按钮 */ + showShare?: boolean; + /** 显示收藏按钮 */ + showBookmark?: boolean; + /** 显示删除按钮(需配合权限判断) */ + showDelete?: boolean; + /** 显示置顶标签 */ + showPinned?: boolean; + /** 显示徽章(楼主/管理员) */ + showBadges?: boolean; +} + +/** + * Features 预设名称 + */ +export type PostCardFeaturesPreset = 'full' | 'compact' | 'grid'; + +/** + * Features 配置(可以是预设名称或自定义对象) + */ +export type PostCardFeaturesConfig = PostCardFeaturesPreset | PostCardFeatures; + +// ==================== 主组件 Props ==================== + +/** + * PostCard 主组件 Props + */ +export interface PostCardProps { + /** 帖子数据 */ + post: Post; + + /** 统一事件回调 */ + onAction: PostCardOnAction; + + /** 显示模式:列表模式 | 网格模式 */ + variant?: 'list' | 'grid'; + + /** 预设配置名称,或自定义配置对象 */ + features?: PostCardFeaturesConfig; + + /** 当前用户是否为帖子作者(用于显示删除按钮) */ + isPostAuthor?: boolean; + + /** 当前用户是否为楼主(在帖子详情页显示"楼主"徽章) */ + isAuthor?: boolean; + + /** 索引(用于虚拟化列表优化) */ + index?: number; + + /** 自定义样式 */ + style?: StyleProp; +} + +// ==================== 子组件 Props ==================== + +/** + * PostHeader 子组件 Props + */ +export interface PostHeaderProps { + author: UserDTO; + createdAt: string; + editedAt?: string; + isPinned?: boolean; + badges?: Array<{ type: 'author' | 'admin'; label: string }>; + onDelete?: () => void; + onUserPress: () => void; + showDelete?: boolean; + showPinned?: boolean; + showBadges?: boolean; + isDeleting?: boolean; +} + +/** + * PostTitle 子组件 Props + */ +export interface PostTitleProps { + title?: string; + numberOfLines?: number; +} + +/** + * PostContent 子组件 Props + */ +export interface PostContentProps { + content?: string; + compact?: boolean; + maxLines?: number; + isExpanded?: boolean; + onToggleExpand?: () => void; +} + +/** + * PostImages 子组件 Props + */ +export interface PostImagesProps { + images: PostImageDTO[]; + displayMode: 'list' | 'grid'; + onImagePress: (images: ImageGridItem[], index: number) => void; +} + +/** + * PostActions 子组件 Props + */ +export interface PostActionsProps { + viewsCount: number; + likesCount: number; + commentsCount: number; + isLiked: boolean; + isFavorited: boolean; + onLike: () => void; + onComment: () => void; + onShare: () => void; + onBookmark: () => void; + showViews?: boolean; + showShare?: boolean; + showBookmark?: boolean; +} + +/** + * PostVotePreview 子组件 Props + */ +export interface PostVotePreviewProps { + isVote: boolean; + totalVotes?: number; + optionsCount?: number; + onPress: () => void; +} + +/** + * PostTopComment 子组件 Props + */ +export interface PostTopCommentProps { + comment: CommentDTO | null; + totalComments: number; + onPress: () => void; +} + +/** + * PostGridCover 子组件 Props + */ +export interface PostGridCoverProps { + image: PostImageDTO | null; + content?: string; + isVote?: boolean; + aspectRatio: number; + onImagePress: () => void; +} + +/** + * PostGridInfo 子组件 Props + */ +export interface PostGridInfoProps { + title?: string; + author: UserDTO; + likesCount: number; + onUserPress: () => void; +} + +// ==================== 容器组件 Props ==================== + +/** + * PostCardList 容器组件 Props + */ +export interface PostCardListProps { + post: Post; + onAction: PostCardOnAction; + features: PostCardFeatures; + isPostAuthor?: boolean; + isAuthor?: boolean; + index?: number; + style?: StyleProp; +} + +/** + * PostCardGrid 容器组件 Props + */ +export interface PostCardGridProps { + post: Post; + onAction: PostCardOnAction; + features: PostCardFeatures; + style?: StyleProp; +} + +// ==================== 工具类型 ==================== + +/** + * 徽章类型 + */ +export interface Badge { + type: 'author' | 'admin'; + label: string; +} diff --git a/src/components/business/QRCodeScanner.tsx b/src/components/business/QRCodeScanner.tsx new file mode 100644 index 0000000..401d683 --- /dev/null +++ b/src/components/business/QRCodeScanner.tsx @@ -0,0 +1,246 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + Alert, + Modal, + Dimensions, +} from 'react-native'; +import { CameraView, useCameraPermissions } from 'expo-camera'; +import { useRouter } from 'expo-router'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import * as hrefs from '../../navigation/hrefs'; +import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme'; + +const { width, height } = Dimensions.get('window'); + +interface QRCodeScannerProps { + visible: boolean; + onClose: () => void; +} + +function createQrScannerStyles(colors: AppColors) { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#000', + }, + camera: { + flex: 1, + }, + overlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.5)', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.md, + paddingTop: spacing.xl, + paddingBottom: spacing.md, + }, + closeButton: { + padding: spacing.sm, + }, + headerTitle: { + fontSize: fontSizes.lg, + fontWeight: '600', + color: '#fff', + }, + placeholder: { + width: 40, + }, + scanArea: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + scanFrame: { + width: width * 0.7, + height: width * 0.7, + position: 'relative', + }, + corner: { + position: 'absolute', + width: 20, + height: 20, + borderColor: colors.primary.main, + borderWidth: 3, + }, + topLeft: { + top: 0, + left: 0, + borderRightWidth: 0, + borderBottomWidth: 0, + }, + topRight: { + top: 0, + right: 0, + borderLeftWidth: 0, + borderBottomWidth: 0, + }, + bottomLeft: { + bottom: 0, + left: 0, + borderRightWidth: 0, + borderTopWidth: 0, + }, + bottomRight: { + bottom: 0, + right: 0, + borderLeftWidth: 0, + borderTopWidth: 0, + }, + scanText: { + marginTop: spacing.lg, + fontSize: fontSizes.md, + color: '#fff', + textAlign: 'center', + }, + footer: { + padding: spacing.xl, + alignItems: 'center', + }, + flashButton: { + padding: spacing.md, + backgroundColor: 'rgba(255,255,255,0.2)', + borderRadius: borderRadius.full, + }, + permissionContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: spacing.xl, + }, + permissionText: { + marginTop: spacing.lg, + fontSize: fontSizes.md, + color: 'rgba(255,255,255,0.72)', + textAlign: 'center', + }, + permissionButton: { + marginTop: spacing.xl, + paddingVertical: spacing.md, + paddingHorizontal: spacing.xl, + backgroundColor: colors.primary.main, + borderRadius: borderRadius.md, + }, + permissionButtonText: { + color: '#fff', + fontSize: fontSizes.md, + fontWeight: '600', + }, + }); +} + +export const QRCodeScanner: React.FC = ({ visible, onClose }) => { + const router = useRouter(); + const themeColors = useAppColors(); + const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]); + const [permission, requestPermission] = useCameraPermissions(); + const [scanned, setScanned] = useState(false); + + useEffect(() => { + if (visible) { + setScanned(false); + if (!permission?.granted) { + requestPermission(); + } + } + }, [visible]); + + const handleBarCodeScanned = ({ type, data }: { type: string; data: string }) => { + if (scanned) return; + setScanned(true); + onClose(); + + if (data.startsWith('carrotbbs://qrcode/login')) { + const sessionId = extractSessionId(data); + if (sessionId) { + router.push(hrefs.hrefQrLoginConfirm(sessionId)); + } else { + Alert.alert('无效的二维码', '无法识别该二维码'); + } + } else { + Alert.alert('无效的二维码', '请扫描网页端的登录二维码'); + } + }; + + const extractSessionId = (url: string): string | null => { + try { + const sessionIdMatch = url.match(/session_id=([^&]+)/); + return sessionIdMatch ? sessionIdMatch[1] : null; + } catch { + return null; + } + }; + + if (!permission?.granted) { + return ( + + + + + + + 扫码登录 + + + + + 需要相机权限才能扫码 + + 授予权限 + + + + + ); + } + + return ( + + + + + + + + + 扫码登录 + + + + + + + + + + + 将二维码放入框内即可扫描 + + + + {}}> + + + + + + + + ); +}; + +export default QRCodeScanner; diff --git a/src/components/business/SearchBar.tsx b/src/components/business/SearchBar.tsx index c1bf4b4..0d66df7 100644 --- a/src/components/business/SearchBar.tsx +++ b/src/components/business/SearchBar.tsx @@ -3,10 +3,10 @@ * 用于搜索内容 */ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { View, TextInput, TouchableOpacity, StyleSheet } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { colors, spacing, fontSizes, borderRadius } from '../../theme'; +import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme'; interface SearchBarProps { value: string; @@ -18,6 +18,63 @@ interface SearchBarProps { autoFocus?: boolean; } +function createSearchBarStyles(colors: AppColors) { + return StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.background.paper, + borderRadius: borderRadius.full, + paddingHorizontal: spacing.xs, + height: 46, + borderWidth: 1, + borderColor: colors.divider, + shadowColor: 'transparent', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0, + shadowRadius: 0, + elevation: 0, + }, + containerFocused: { + borderColor: colors.primary.main, + shadowColor: 'transparent', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0, + shadowRadius: 0, + elevation: 0, + }, + searchIconWrap: { + width: 30, + height: 30, + marginLeft: spacing.xs, + marginRight: spacing.xs, + borderRadius: borderRadius.full, + backgroundColor: `${colors.text.secondary}12`, + alignItems: 'center', + justifyContent: 'center', + }, + searchIconWrapFocused: { + backgroundColor: `${colors.primary.main}1A`, + }, + input: { + flex: 1, + fontSize: fontSizes.md, + color: colors.text.primary, + paddingVertical: spacing.sm + 1, + paddingHorizontal: spacing.xs, + }, + clearButton: { + width: 24, + height: 24, + marginHorizontal: spacing.xs, + borderRadius: borderRadius.full, + backgroundColor: `${colors.text.secondary}14`, + alignItems: 'center', + justifyContent: 'center', + }, + }); +} + const SearchBar: React.FC = ({ value, onChangeText, @@ -27,6 +84,8 @@ const SearchBar: React.FC = ({ onBlur, autoFocus = false, }) => { + const colors = useAppColors(); + const styles = useMemo(() => createSearchBarStyles(colors), [colors]); const [isFocused, setIsFocused] = useState(false); const handleFocus = () => { @@ -59,7 +118,6 @@ const SearchBar: React.FC = ({ onFocus={handleFocus} onBlur={handleBlur} autoFocus={autoFocus} - // 确保光标可见 cursorColor={colors.primary.main} selectionColor={`${colors.primary.main}40`} /> @@ -69,73 +127,11 @@ const SearchBar: React.FC = ({ style={styles.clearButton} activeOpacity={0.7} > - + )} ); }; -const styles = StyleSheet.create({ - container: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.background.paper, - borderRadius: borderRadius.full, - paddingHorizontal: spacing.xs, - height: 46, - borderWidth: 1, - borderColor: '#E7E7E7', - // 减少常态阴影,避免看起来像"黑框" - shadowColor: 'transparent', - shadowOffset: { width: 0, height: 0 }, - shadowOpacity: 0, - shadowRadius: 0, - elevation: 0, - }, - containerFocused: { - // 焦点时使用更柔和的边框颜色 - borderColor: colors.primary.main, - // 不添加额外的阴影,保持简洁 - shadowColor: 'transparent', - shadowOffset: { width: 0, height: 0 }, - shadowOpacity: 0, - shadowRadius: 0, - elevation: 0, - }, - searchIconWrap: { - width: 30, - height: 30, - marginLeft: spacing.xs, - marginRight: spacing.xs, - borderRadius: borderRadius.full, - backgroundColor: `${colors.text.secondary}12`, - alignItems: 'center', - justifyContent: 'center', - }, - searchIconWrapFocused: { - backgroundColor: `${colors.primary.main}1A`, - }, - input: { - flex: 1, - fontSize: fontSizes.md, - color: colors.text.primary, - paddingVertical: spacing.sm + 1, - paddingHorizontal: spacing.xs, - }, - clearButton: { - width: 24, - height: 24, - marginHorizontal: spacing.xs, - borderRadius: borderRadius.full, - backgroundColor: `${colors.text.secondary}14`, - alignItems: 'center', - justifyContent: 'center', - }, -}); - export default SearchBar; diff --git a/src/components/business/SystemMessageItem.tsx b/src/components/business/SystemMessageItem.tsx index 64894ec..45ccf14 100644 --- a/src/components/business/SystemMessageItem.tsx +++ b/src/components/business/SystemMessageItem.tsx @@ -1,15 +1,15 @@ /** - * SystemMessageItem 系统消息项组件 + * SystemMessageItem 系统消息项组件 - 美化版 * 根据系统消息类型显示不同图标和内容 - * 参考 QQ 10000 系统消息和微信服务通知样式 + * 采用卡片式设计,符合胡萝卜BBS整体风格 */ -import React from 'react'; +import React, { useMemo } from 'react'; import { View, TouchableOpacity, StyleSheet } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { formatDistanceToNow } from 'date-fns'; import { zhCN } from 'date-fns/locale'; -import { colors, spacing, borderRadius } from '../../theme'; +import { spacing, borderRadius, shadows, fontSizes, useAppColors, type AppColors } from '../../theme'; import { SystemMessageResponse, SystemMessageType } from '../../types/dto'; import Text from '../common/Text'; import Avatar from '../common/Avatar'; @@ -17,15 +17,16 @@ import Avatar from '../common/Avatar'; interface SystemMessageItemProps { message: SystemMessageResponse; onPress?: () => void; - onAvatarPress?: () => void; // 头像点击回调 + onAvatarPress?: () => void; onRequestAction?: (approve: boolean) => void; requestActionLoading?: boolean; + index?: number; // 用于交错动画 } -// 系统消息类型到图标和颜色的映射 const getSystemMessageIcon = ( - systemType: SystemMessageType -): { icon: keyof typeof MaterialCommunityIcons.glyphMap; color: string; bgColor: string } => { + systemType: SystemMessageType, + colors: AppColors +): { icon: keyof typeof MaterialCommunityIcons.glyphMap; color: string; bgColor: string; gradient: string[] } => { switch (systemType) { case 'like_post': case 'like_comment': @@ -33,83 +34,243 @@ const getSystemMessageIcon = ( case 'favorite_post': return { icon: 'heart', - color: colors.error.main, - bgColor: colors.error.light + '20', + color: '#FF4757', + bgColor: '#FFE4E6', + gradient: ['#FF4757', '#FF6B7A'], }; case 'comment': return { - icon: 'comment', - color: colors.info.main, - bgColor: colors.info.light + '20', + icon: 'comment-text', + color: '#2196F3', + bgColor: '#E3F2FD', + gradient: ['#2196F3', '#64B5F6'], }; case 'reply': return { icon: 'reply', - color: colors.success.main, - bgColor: colors.success.light + '20', + color: '#4CAF50', + bgColor: '#E8F5E9', + gradient: ['#4CAF50', '#81C784'], }; case 'follow': return { icon: 'account-plus', - color: '#9C27B0', // 紫色 - bgColor: '#9C27B020', + color: '#9C27B0', + bgColor: '#F3E5F5', + gradient: ['#9C27B0', '#BA68C8'], }; case 'mention': return { icon: 'at', - color: colors.warning.main, - bgColor: colors.warning.light + '20', + color: '#FF9800', + bgColor: '#FFF3E0', + gradient: ['#FF9800', '#FFB74D'], }; case 'system': return { icon: 'cog', - color: colors.text.secondary, - bgColor: colors.background.disabled, + color: '#607D8B', + bgColor: '#ECEFF1', + gradient: ['#607D8B', '#90A4AE'], }; case 'announcement': case 'announce': return { icon: 'bullhorn', color: colors.primary.main, - bgColor: colors.primary.light + '20', + bgColor: colors.primary.light + '30', + gradient: [colors.primary.main, colors.primary.light], }; case 'group_invite': return { icon: 'account-multiple-plus', - color: colors.primary.main, - bgColor: colors.primary.light + '20', + color: '#00BCD4', + bgColor: '#E0F7FA', + gradient: ['#00BCD4', '#4DD0E1'], }; case 'group_join_apply': return { icon: 'account-clock', - color: colors.warning.main, - bgColor: colors.warning.light + '20', + color: '#FF9800', + bgColor: '#FFF3E0', + gradient: ['#FF9800', '#FFB74D'], }; case 'group_join_approved': return { icon: 'check-circle', - color: colors.success.main, - bgColor: colors.success.light + '20', + color: '#4CAF50', + bgColor: '#E8F5E9', + gradient: ['#4CAF50', '#81C784'], }; case 'group_join_rejected': return { icon: 'close-circle', - color: colors.error.main, - bgColor: colors.error.light + '20', + color: '#F44336', + bgColor: '#FFEBEE', + gradient: ['#F44336', '#E57373'], }; default: return { icon: 'bell', - color: colors.text.secondary, - bgColor: colors.background.disabled, + color: '#757575', + bgColor: '#F5F5F5', + gradient: ['#757575', '#9E9E9E'], }; } }; +function createSystemMessageStyles(colors: AppColors) { + return StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'flex-start', + padding: spacing.md, + backgroundColor: colors.background.paper, + borderRadius: borderRadius.lg, + marginHorizontal: spacing.md, + marginBottom: spacing.sm, + ...shadows.sm, + }, + unreadContainer: { + backgroundColor: colors.primary.light + '08', + borderLeftWidth: 3, + borderLeftColor: colors.primary.main, + }, + unreadIndicator: { + position: 'absolute', + left: 6, + top: '50%', + marginTop: -4, + width: 8, + height: 8, + borderRadius: borderRadius.full, + backgroundColor: colors.primary.main, + }, + iconContainer: { + marginRight: spacing.md, + }, + avatarWrapper: { + position: 'relative', + }, + iconWrapper: { + width: 48, + height: 48, + borderRadius: borderRadius.lg, + justifyContent: 'center', + alignItems: 'center', + }, + typeIconBadge: { + position: 'absolute', + bottom: -2, + right: -2, + width: 20, + height: 20, + borderRadius: borderRadius.full, + justifyContent: 'center', + alignItems: 'center', + borderWidth: 2, + borderColor: colors.background.paper, + }, + content: { + flex: 1, + justifyContent: 'center', + minWidth: 0, + }, + titleRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: spacing.xs, + }, + titleLeft: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + marginRight: spacing.sm, + }, + title: { + fontWeight: '500', + fontSize: fontSizes.md, + color: colors.text.primary, + }, + unreadTitle: { + fontWeight: '600', + color: colors.text.primary, + }, + statusBadge: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.xs, + paddingVertical: 2, + borderRadius: borderRadius.sm, + marginLeft: spacing.xs, + }, + statusText: { + fontSize: 10, + fontWeight: '500', + marginLeft: 2, + }, + time: { + fontSize: fontSizes.sm, + flexShrink: 0, + }, + messageContent: { + lineHeight: 20, + fontSize: fontSizes.sm, + }, + extraInfo: { + flexDirection: 'row', + alignItems: 'center', + marginTop: spacing.xs, + backgroundColor: colors.primary.light + '12', + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: borderRadius.md, + alignSelf: 'flex-start', + }, + extraText: { + marginLeft: spacing.xs, + flex: 1, + fontWeight: '500', + }, + actionsRow: { + flexDirection: 'row', + marginTop: spacing.sm, + gap: spacing.sm, + }, + actionBtn: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: spacing.xs, + paddingHorizontal: spacing.md, + borderRadius: borderRadius.md, + borderWidth: 1, + gap: spacing.xs, + }, + rejectBtn: { + borderColor: `${colors.error.main}55`, + backgroundColor: `${colors.error.main}18`, + }, + approveBtn: { + borderColor: `${colors.success.main}55`, + backgroundColor: `${colors.success.main}18`, + }, + actionBtnText: { + fontWeight: '500', + }, + arrowContainer: { + marginLeft: spacing.sm, + justifyContent: 'center', + alignItems: 'center', + height: 20, + width: 20, + }, + }); +} + // 获取系统消息标题 const getSystemMessageTitle = (message: SystemMessageResponse): string => { const { system_type, extra_data } = message; - // 兼容后端返回的 actor_name 和 operator_name const operatorName = extra_data?.actor_name || extra_data?.operator_name; const groupName = extra_data?.group_name || extra_data?.target_title; @@ -163,7 +324,11 @@ const SystemMessageItem: React.FC = ({ onAvatarPress, onRequestAction, requestActionLoading = false, + index = 0, }) => { + const colors = useAppColors(); + const styles = useMemo(() => createSystemMessageStyles(colors), [colors]); + // 格式化时间 const formatTime = (dateString: string): string => { try { @@ -176,10 +341,9 @@ const SystemMessageItem: React.FC = ({ } }; - const { icon, color, bgColor } = getSystemMessageIcon(message.system_type); + const { icon, color, bgColor } = getSystemMessageIcon(message.system_type, colors); const title = getSystemMessageTitle(message); const { extra_data } = message; - // 兼容后端返回的 actor_name 和 operator_name const operatorName = extra_data?.actor_name || extra_data?.operator_name; const operatorAvatar = extra_data?.avatar_url || extra_data?.operator_avatar; const groupAvatar = extra_data?.group_avatar; @@ -188,99 +352,7 @@ const SystemMessageItem: React.FC = ({ (message.system_type === 'group_invite' || message.system_type === 'group_join_apply') && requestStatus === 'pending' && !!onRequestAction; - - // 使用固定的响应式值,兼容移动端和Web端 - const containerPadding = spacing.md; - const avatarSize = 44; - const iconSize = 22; - const contentFontSize = 14; - const titleFontSize = 14; - const timeFontSize = 12; - - const styles = StyleSheet.create({ - container: { - flexDirection: 'row', - alignItems: 'flex-start', - padding: containerPadding, - backgroundColor: colors.background.paper, - borderBottomWidth: 1, - borderBottomColor: colors.divider, - }, - iconContainer: { - marginRight: spacing.md, - }, - iconWrapper: { - width: avatarSize, - height: avatarSize, - borderRadius: avatarSize / 2, - justifyContent: 'center', - alignItems: 'center', - }, - content: { - flex: 1, - justifyContent: 'center', - minWidth: 0, - }, - titleRow: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: spacing.xs, - }, - title: { - fontWeight: '600', - flex: 1, - marginRight: spacing.sm, - fontSize: titleFontSize, - }, - time: { - fontSize: timeFontSize, - }, - messageContent: { - lineHeight: 20, - fontSize: contentFontSize, - }, - extraInfo: { - flexDirection: 'row', - alignItems: 'center', - marginTop: spacing.xs, - backgroundColor: colors.background.default, - paddingHorizontal: spacing.sm, - paddingVertical: spacing.xs, - borderRadius: borderRadius.sm, - alignSelf: 'flex-start', - }, - extraText: { - marginLeft: spacing.xs, - flex: 1, - }, - actionsRow: { - flexDirection: 'row', - marginTop: spacing.sm, - }, - actionBtn: { - paddingVertical: spacing.xs, - paddingHorizontal: spacing.md, - borderRadius: borderRadius.sm, - borderWidth: 1, - marginRight: spacing.sm, - }, - rejectBtn: { - borderColor: colors.error.light, - backgroundColor: colors.error.light + '18', - }, - approveBtn: { - borderColor: colors.success.light, - backgroundColor: colors.success.light + '18', - }, - arrowContainer: { - marginLeft: spacing.sm, - justifyContent: 'center', - alignItems: 'center', - height: 20, - width: 24, - }, - }); + const isUnread = message.is_read !== true; // 判断是否显示操作者头像 const showOperatorAvatar = ['follow', 'like_post', 'like_comment', 'like_reply', 'favorite_post', 'comment', 'reply', 'mention', 'group_join_apply'].includes( @@ -288,31 +360,68 @@ const SystemMessageItem: React.FC = ({ ); const showGroupAvatar = message.system_type === 'group_invite'; + // 获取状态标签 + const getStatusBadge = () => { + if (requestStatus === 'accepted') { + return ( + + + 已同意 + + ); + } + if (requestStatus === 'rejected') { + return ( + + + 已拒绝 + + ); + } + return null; + }; + return ( + {/* 左侧未读指示器 */} + {isUnread && } + {/* 图标/头像区域 */} {showGroupAvatar ? ( - + + + + + + ) : showOperatorAvatar ? ( - + + + + + + ) : ( - + )} @@ -321,36 +430,49 @@ const SystemMessageItem: React.FC = ({ {/* 标题行 */} - - {title} - - + + + {title} + + {getStatusBadge()} + + {formatTime(message.created_at)} {/* 消息内容 */} - + {message.content} - {/* 附加信息 - 优先显示 target_title,图标根据 target_type 区分 */} + {/* 附加信息 */} {(extra_data?.target_title || extra_data?.post_title || extra_data?.comment_preview) && !['group_invite', 'group_join_apply', 'group_join_approved', 'group_join_rejected'].includes(message.system_type) && (() => { - const isCommentType = ['comment', 'reply'].includes(extra_data?.target_type ?? ''); - const previewText = extra_data?.target_title || extra_data?.post_title || extra_data?.comment_preview; - const iconName = isCommentType ? 'comment-outline' : 'file-document-outline'; - return ( - - - - {previewText} - - - ); - })()} + const isCommentType = ['comment', 'reply'].includes(extra_data?.target_type ?? ''); + const previewText = extra_data?.target_title || extra_data?.post_title || extra_data?.comment_preview; + const iconName = isCommentType ? 'comment-text-outline' : 'file-document-outline'; + return ( + + + + {previewText} + + + ); + })()} + {/* 操作按钮 */} {isActionable && ( = ({ onPress={() => onRequestAction?.(false)} disabled={requestActionLoading} > - 拒绝 + + 拒绝 onRequestAction?.(true)} disabled={requestActionLoading} > - 同意 + + 同意 )} - {/* 右侧箭头(如果有跳转) */} + {/* 右侧箭头 */} {onPress && ( diff --git a/src/components/business/TabBar.tsx b/src/components/business/TabBar.tsx index bcca877..ee3ce6d 100644 --- a/src/components/business/TabBar.tsx +++ b/src/components/business/TabBar.tsx @@ -4,10 +4,10 @@ * 新增胶囊式、分段式等现代设计风格 */ -import React, { ReactNode } from 'react'; -import { View, TouchableOpacity, StyleSheet, ScrollView, Animated } from 'react-native'; +import React, { ReactNode, useMemo } from 'react'; +import { View, TouchableOpacity, StyleSheet, ScrollView, ViewStyle, StyleProp } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { colors, spacing, fontSizes, borderRadius } from '../../theme'; +import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme'; import Text from '../common/Text'; type TabBarVariant = 'default' | 'pill' | 'segmented' | 'modern'; @@ -20,6 +20,166 @@ interface TabBarProps { rightContent?: ReactNode; variant?: TabBarVariant; icons?: string[]; + style?: StyleProp; +} + +function createTabBarStyles(colors: AppColors) { + return StyleSheet.create({ + container: { + flexDirection: 'row', + backgroundColor: colors.background.paper, + borderBottomWidth: 1, + borderBottomColor: colors.divider, + alignItems: 'center', + paddingRight: spacing.xs, + }, + scrollableContainer: { + flexDirection: 'row', + paddingHorizontal: spacing.md, + backgroundColor: colors.background.paper, + borderBottomWidth: 1, + borderBottomColor: colors.divider, + flex: 1, + }, + tab: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: spacing.md, + position: 'relative', + }, + activeTab: {}, + tabText: { + fontWeight: '500', + }, + activeTabText: { + fontWeight: '600', + }, + activeIndicator: { + position: 'absolute', + bottom: 0, + left: '25%', + right: '25%', + height: 3, + backgroundColor: colors.primary.main, + borderTopLeftRadius: borderRadius.sm, + borderTopRightRadius: borderRadius.sm, + }, + rightContent: { + paddingLeft: spacing.sm, + }, + pillContainer: { + flexDirection: 'row', + backgroundColor: colors.background.default, + padding: spacing.sm, + gap: spacing.sm, + }, + pillTab: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + borderRadius: borderRadius.lg, + backgroundColor: colors.background.paper, + }, + pillTabActive: { + backgroundColor: colors.primary.main, + }, + pillTabText: { + fontWeight: '600', + }, + segmentedContainer: { + flexDirection: 'row', + backgroundColor: colors.background.default, + padding: spacing.xs, + marginHorizontal: spacing.md, + marginVertical: spacing.sm, + borderRadius: borderRadius.lg, + }, + segmentedTab: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: spacing.sm, + backgroundColor: 'transparent', + }, + segmentedTabActive: { + backgroundColor: colors.background.paper, + borderRadius: borderRadius.md, + shadowColor: colors.chat.shadow, + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.1, + shadowRadius: 2, + elevation: 2, + }, + segmentedTabFirst: { + borderTopLeftRadius: borderRadius.md, + borderBottomLeftRadius: borderRadius.md, + }, + segmentedTabLast: { + borderTopRightRadius: borderRadius.md, + borderBottomRightRadius: borderRadius.md, + }, + segmentedTabText: { + fontWeight: '600', + }, + segmentedTabContent: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + segmentedTabIcon: { + marginRight: spacing.xs, + }, + modernContainer: { + flexDirection: 'row', + backgroundColor: colors.background.paper, + borderRadius: borderRadius.xl, + marginHorizontal: spacing.lg, + marginVertical: spacing.md, + padding: spacing.xs, + shadowColor: colors.chat.shadow, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.08, + shadowRadius: 8, + elevation: 3, + }, + modernTab: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: spacing.sm, + borderRadius: borderRadius.lg, + position: 'relative', + }, + modernTabActive: { + backgroundColor: colors.primary.main + '15', + }, + modernTabContent: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + modernTabIcon: { + marginRight: spacing.xs, + }, + modernTabText: { + fontWeight: '500', + fontSize: fontSizes.md, + }, + modernTabTextActive: { + fontWeight: '700', + }, + modernTabIndicator: { + position: 'absolute', + bottom: 4, + width: 20, + height: 3, + backgroundColor: colors.primary.main, + borderRadius: borderRadius.full, + }, + }); } const TabBar: React.FC = ({ @@ -30,7 +190,11 @@ const TabBar: React.FC = ({ rightContent, variant = 'default', icons, + style, }) => { + const colors = useAppColors(); + const styles = useMemo(() => createTabBarStyles(colors), [colors]); + const renderTabs = () => { return tabs.map((tab, index) => { const isActive = index === activeIndex; @@ -109,11 +273,7 @@ const TabBar: React.FC = ({ style={styles.segmentedTabIcon} /> )} - + {tab} @@ -121,7 +281,6 @@ const TabBar: React.FC = ({ ); } - // default variant return ( = ({ if (scrollable) { return ( - - + + {renderTabs()} {rightContent && {rightContent}} @@ -171,177 +326,11 @@ const TabBar: React.FC = ({ } return ( - + {renderTabs()} {rightContent && {rightContent}} ); }; -const styles = StyleSheet.create({ - // Default variant - container: { - flexDirection: 'row', - backgroundColor: colors.background.paper, - borderBottomWidth: 1, - borderBottomColor: colors.divider, - alignItems: 'center', - paddingRight: spacing.xs, - }, - scrollableContainer: { - flexDirection: 'row', - paddingHorizontal: spacing.md, - backgroundColor: colors.background.paper, - borderBottomWidth: 1, - borderBottomColor: colors.divider, - flex: 1, - }, - tab: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - paddingVertical: spacing.md, - position: 'relative', - }, - activeTab: { - // 激活状态样式 - }, - tabText: { - fontWeight: '500', - }, - activeTabText: { - fontWeight: '600', - }, - activeIndicator: { - position: 'absolute', - bottom: 0, - left: '25%', - right: '25%', - height: 3, - backgroundColor: colors.primary.main, - borderTopLeftRadius: borderRadius.sm, - borderTopRightRadius: borderRadius.sm, - }, - rightContent: { - paddingLeft: spacing.sm, - }, - - // Pill variant - pillContainer: { - flexDirection: 'row', - backgroundColor: colors.background.default, - padding: spacing.sm, - gap: spacing.sm, - }, - pillTab: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - paddingVertical: spacing.sm, - paddingHorizontal: spacing.md, - borderRadius: borderRadius.lg, - backgroundColor: colors.background.paper, - }, - pillTabActive: { - backgroundColor: colors.primary.main, - }, - pillTabText: { - fontWeight: '600', - }, - - // Segmented variant - segmentedContainer: { - flexDirection: 'row', - backgroundColor: colors.background.default, - padding: spacing.xs, - marginHorizontal: spacing.md, - marginVertical: spacing.sm, - borderRadius: borderRadius.lg, - }, - segmentedTab: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - paddingVertical: spacing.sm, - backgroundColor: 'transparent', - }, - segmentedTabActive: { - backgroundColor: colors.background.paper, - borderRadius: borderRadius.md, - shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.1, - shadowRadius: 2, - elevation: 2, - }, - segmentedTabFirst: { - borderTopLeftRadius: borderRadius.md, - borderBottomLeftRadius: borderRadius.md, - }, - segmentedTabLast: { - borderTopRightRadius: borderRadius.md, - borderBottomRightRadius: borderRadius.md, - }, - segmentedTabText: { - fontWeight: '600', - }, - segmentedTabContent: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - }, - segmentedTabIcon: { - marginRight: spacing.xs, - }, - - // Modern variant - 现代化标签栏 - modernContainer: { - flexDirection: 'row', - backgroundColor: colors.background.paper, - borderRadius: borderRadius.xl, - marginHorizontal: spacing.lg, - marginVertical: spacing.md, - padding: spacing.xs, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.08, - shadowRadius: 8, - elevation: 3, - }, - modernTab: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - paddingVertical: spacing.sm, - borderRadius: borderRadius.lg, - position: 'relative', - }, - modernTabActive: { - backgroundColor: colors.primary.main + '15', // 10% opacity - }, - modernTabContent: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - }, - modernTabIcon: { - marginRight: spacing.xs, - }, - modernTabText: { - fontWeight: '500', - fontSize: fontSizes.md, - }, - modernTabTextActive: { - fontWeight: '700', - }, - modernTabIndicator: { - position: 'absolute', - bottom: 4, - width: 20, - height: 3, - backgroundColor: colors.primary.main, - borderRadius: borderRadius.full, - }, -}); - export default TabBar; diff --git a/src/components/business/UserProfileHeader.tsx b/src/components/business/UserProfileHeader.tsx index d680ae0..e685e70 100644 --- a/src/components/business/UserProfileHeader.tsx +++ b/src/components/business/UserProfileHeader.tsx @@ -6,7 +6,7 @@ * 在宽屏下显示更大的头像和封面 */ -import React from 'react'; +import React, { useMemo } from 'react'; import { View, Image, @@ -15,7 +15,7 @@ import { } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; -import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme'; +import { spacing, fontSizes, borderRadius, shadows, useAppColors, type AppColors } from '../../theme'; import { User } from '../../types'; import Text from '../common/Text'; import Button from '../common/Button'; @@ -49,7 +49,8 @@ const UserProfileHeader: React.FC = ({ onFollowersPress, onAvatarPress, }) => { - // 响应式布局 + const colors = useAppColors(); + const styles = useMemo(() => createUserProfileHeaderStyles(colors), [colors]); const { isWideScreen, isDesktop, width } = useResponsive(); // 格式化数字 @@ -236,22 +237,22 @@ const UserProfileHeader: React.FC = ({ {/* 个人信息标签 */} - {user.location && ( + {user.location?.trim() ? ( {user.location} - )} - {user.website && ( + ) : null} + {user.website?.trim() ? ( {user.website.replace(/^https?:\/\//, '')} - )} + ) : null} @@ -317,7 +318,8 @@ const UserProfileHeader: React.FC = ({ ); }; -const styles = StyleSheet.create({ +function createUserProfileHeaderStyles(colors: AppColors) { + return StyleSheet.create({ container: { backgroundColor: colors.background.default, }, @@ -533,7 +535,8 @@ const styles = StyleSheet.create({ alignSelf: 'center', padding: spacing.sm, }, -}); + }); +} // 使用 React.memo 避免不必要的重新渲染 const MemoizedUserProfileHeader = React.memo(UserProfileHeader); diff --git a/src/components/business/VoteCard.tsx b/src/components/business/VoteCard.tsx index ec555ef..9705da1 100644 --- a/src/components/business/VoteCard.tsx +++ b/src/components/business/VoteCard.tsx @@ -4,7 +4,7 @@ * 风格与现代整体UI保持一致 */ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { View, TouchableOpacity, @@ -12,7 +12,7 @@ import { Animated, } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { colors, spacing, fontSizes, borderRadius } from '../../theme'; +import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme'; import { VoteOptionDTO } from '../../types'; import Text from '../common/Text'; @@ -28,6 +28,146 @@ interface VoteCardProps { compact?: boolean; } +function createVoteCardStyles(colors: AppColors) { + return StyleSheet.create({ + container: { + backgroundColor: colors.background.paper, + borderRadius: borderRadius.lg, + padding: spacing.md, + marginVertical: spacing.sm, + borderWidth: 1, + borderColor: colors.divider, + }, + containerCompact: { + padding: spacing.sm, + marginVertical: spacing.xs, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: spacing.sm, + }, + headerIcon: { + width: 24, + height: 24, + borderRadius: borderRadius.sm, + backgroundColor: colors.primary.light + '20', + justifyContent: 'center', + alignItems: 'center', + marginRight: spacing.sm, + }, + headerTitle: { + fontWeight: '600', + color: colors.text.primary, + }, + optionsList: { + gap: spacing.sm, + }, + optionContainer: { + position: 'relative', + borderRadius: borderRadius.md, + overflow: 'hidden', + }, + progressBar: { + position: 'absolute', + top: 0, + left: 0, + bottom: 0, + borderRadius: borderRadius.md, + }, + optionButton: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + borderRadius: borderRadius.md, + backgroundColor: colors.background.default, + minHeight: 44, + borderWidth: 1, + borderColor: 'transparent', + }, + optionButtonVoted: { + borderColor: colors.primary.main, + backgroundColor: 'transparent', + }, + optionIndicator: { + width: 18, + height: 18, + borderRadius: borderRadius.full, + borderWidth: 2, + borderColor: colors.divider, + marginRight: spacing.sm, + justifyContent: 'center', + alignItems: 'center', + }, + optionIndicatorVoted: { + backgroundColor: colors.primary.main, + borderColor: colors.primary.main, + }, + optionText: { + flex: 1, + fontSize: fontSizes.md, + color: colors.text.primary, + lineHeight: 20, + }, + optionTextCompact: { + fontSize: fontSizes.sm, + lineHeight: 18, + }, + resultContainer: { + flexDirection: 'row', + alignItems: 'center', + marginLeft: spacing.sm, + minWidth: 40, + justifyContent: 'flex-end', + }, + percentage: { + color: colors.text.secondary, + fontWeight: '500', + }, + percentageVoted: { + color: colors.primary.main, + fontWeight: '700', + }, + footer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginTop: spacing.md, + paddingTop: spacing.sm, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.divider, + }, + footerLeft: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + }, + footerText: { + fontSize: fontSizes.sm, + }, + unvoteButton: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingVertical: spacing.xs, + paddingHorizontal: spacing.sm, + borderRadius: borderRadius.sm, + backgroundColor: colors.background.default, + }, + unvoteText: { + fontSize: fontSizes.sm, + }, + loadingOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: colors.background.paper + 'CC', + justifyContent: 'center', + alignItems: 'center', + borderRadius: borderRadius.lg, + }, + }); +} + const VoteCard: React.FC = ({ options, totalVotes, @@ -38,7 +178,8 @@ const VoteCard: React.FC = ({ isLoading = false, compact = false, }) => { - // 动画值 + const colors = useAppColors(); + const styles = useMemo(() => createVoteCardStyles(colors), [colors]); const progressAnim = React.useRef(new Animated.Value(0)).current; React.useEffect(() => { @@ -151,7 +292,7 @@ const VoteCard: React.FC = ({ ); - }, [hasVoted, votedOptionId, calculatePercentage, handleVote, isLoading, progressAnim, compact]); + }, [hasVoted, votedOptionId, calculatePercentage, handleVote, isLoading, progressAnim, compact, colors, styles]); // 排序后的选项(已投票的排在前面) const sortedOptions = React.useMemo(() => { @@ -229,142 +370,4 @@ const VoteCard: React.FC = ({ ); }; -const styles = StyleSheet.create({ - container: { - backgroundColor: colors.background.paper, - borderRadius: borderRadius.lg, - padding: spacing.md, - marginVertical: spacing.sm, - borderWidth: 1, - borderColor: colors.divider, - }, - containerCompact: { - padding: spacing.sm, - marginVertical: spacing.xs, - }, - header: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: spacing.sm, - }, - headerIcon: { - width: 24, - height: 24, - borderRadius: borderRadius.sm, - backgroundColor: colors.primary.light + '20', - justifyContent: 'center', - alignItems: 'center', - marginRight: spacing.sm, - }, - headerTitle: { - fontWeight: '600', - color: colors.text.primary, - }, - optionsList: { - gap: spacing.sm, - }, - optionContainer: { - position: 'relative', - borderRadius: borderRadius.md, - overflow: 'hidden', - }, - progressBar: { - position: 'absolute', - top: 0, - left: 0, - bottom: 0, - borderRadius: borderRadius.md, - }, - optionButton: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: spacing.sm, - paddingHorizontal: spacing.md, - borderRadius: borderRadius.md, - backgroundColor: colors.background.default, - minHeight: 44, - borderWidth: 1, - borderColor: 'transparent', - }, - optionButtonVoted: { - borderColor: colors.primary.main, - backgroundColor: 'transparent', - }, - optionIndicator: { - width: 18, - height: 18, - borderRadius: borderRadius.full, - borderWidth: 2, - borderColor: colors.divider, - marginRight: spacing.sm, - justifyContent: 'center', - alignItems: 'center', - }, - optionIndicatorVoted: { - backgroundColor: colors.primary.main, - borderColor: colors.primary.main, - }, - optionText: { - flex: 1, - fontSize: fontSizes.md, - color: colors.text.primary, - lineHeight: 20, - }, - optionTextCompact: { - fontSize: fontSizes.sm, - lineHeight: 18, - }, - resultContainer: { - flexDirection: 'row', - alignItems: 'center', - marginLeft: spacing.sm, - minWidth: 40, - justifyContent: 'flex-end', - }, - percentage: { - color: colors.text.secondary, - fontWeight: '500', - }, - percentageVoted: { - color: colors.primary.main, - fontWeight: '700', - }, - footer: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginTop: spacing.md, - paddingTop: spacing.sm, - borderTopWidth: StyleSheet.hairlineWidth, - borderTopColor: colors.divider, - }, - footerLeft: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.xs, - }, - footerText: { - fontSize: fontSizes.sm, - }, - unvoteButton: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.xs, - paddingVertical: spacing.xs, - paddingHorizontal: spacing.sm, - borderRadius: borderRadius.sm, - backgroundColor: colors.background.default, - }, - unvoteText: { - fontSize: fontSizes.sm, - }, - loadingOverlay: { - ...StyleSheet.absoluteFillObject, - backgroundColor: colors.background.paper + 'CC', - justifyContent: 'center', - alignItems: 'center', - borderRadius: borderRadius.lg, - }, -}); - export default VoteCard; diff --git a/src/components/business/VoteEditor.tsx b/src/components/business/VoteEditor.tsx index 19fde40..952a19c 100644 --- a/src/components/business/VoteEditor.tsx +++ b/src/components/business/VoteEditor.tsx @@ -3,7 +3,7 @@ * 用于创建帖子时编辑投票选项 */ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { View, TouchableOpacity, @@ -11,7 +11,7 @@ import { TextInput, } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { colors, spacing, fontSizes, borderRadius } from '../../theme'; +import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme'; import Text from '../common/Text'; interface VoteEditorProps { @@ -24,6 +24,86 @@ interface VoteEditorProps { maxLength?: number; } +function createVoteEditorStyles(colors: AppColors) { + return StyleSheet.create({ + container: { + backgroundColor: colors.background.default, + borderRadius: borderRadius.lg, + marginHorizontal: spacing.lg, + marginTop: spacing.md, + marginBottom: spacing.md, + padding: spacing.md, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: spacing.md, + }, + headerLeft: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + }, + headerTitle: { + fontWeight: '600', + color: colors.text.primary, + }, + optionsContainer: { + gap: spacing.sm, + }, + optionRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + }, + optionIndex: { + width: 20, + height: 20, + borderRadius: borderRadius.full, + backgroundColor: colors.background.disabled, + justifyContent: 'center', + alignItems: 'center', + }, + optionInput: { + flex: 1, + fontSize: fontSizes.md, + color: colors.text.primary, + backgroundColor: colors.background.paper, + borderRadius: borderRadius.md, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderWidth: 1, + borderColor: colors.divider, + height: 44, + }, + removeButton: { + padding: spacing.xs, + }, + removeButtonPlaceholder: { + width: 28, + }, + addOptionButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + paddingVertical: spacing.sm, + marginTop: spacing.xs, + }, + addOptionText: { + fontWeight: '500', + }, + hintContainer: { + marginTop: spacing.md, + paddingTop: spacing.sm, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.divider, + alignItems: 'center', + }, + }); +} + const VoteEditor: React.FC = ({ options, onAddOption, @@ -33,6 +113,8 @@ const VoteEditor: React.FC = ({ minOptions = 2, maxLength = 50, }) => { + const colors = useAppColors(); + const styles = useMemo(() => createVoteEditorStyles(colors), [colors]); const validOptionsCount = options.filter(opt => opt.trim() !== '').length; const canAddOption = options.length < maxOptions; const canRemoveOption = options.length > minOptions; @@ -122,82 +204,4 @@ const VoteEditor: React.FC = ({ ); }; -const styles = StyleSheet.create({ - container: { - backgroundColor: colors.background.default, - borderRadius: borderRadius.lg, - marginHorizontal: spacing.lg, - marginTop: spacing.md, - marginBottom: spacing.md, - padding: spacing.md, - }, - header: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - marginBottom: spacing.md, - }, - headerLeft: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm, - }, - headerTitle: { - fontWeight: '600', - color: colors.text.primary, - }, - optionsContainer: { - gap: spacing.sm, - }, - optionRow: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm, - }, - optionIndex: { - width: 20, - height: 20, - borderRadius: borderRadius.full, - backgroundColor: colors.background.disabled, - justifyContent: 'center', - alignItems: 'center', - }, - optionInput: { - flex: 1, - fontSize: fontSizes.md, - color: colors.text.primary, - backgroundColor: colors.background.paper, - borderRadius: borderRadius.md, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - borderWidth: 1, - borderColor: colors.divider, - height: 44, - }, - removeButton: { - padding: spacing.xs, - }, - removeButtonPlaceholder: { - width: 28, - }, - addOptionButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: spacing.sm, - paddingVertical: spacing.sm, - marginTop: spacing.xs, - }, - addOptionText: { - fontWeight: '500', - }, - hintContainer: { - marginTop: spacing.md, - paddingTop: spacing.sm, - borderTopWidth: StyleSheet.hairlineWidth, - borderTopColor: colors.divider, - alignItems: 'center', - }, -}); - export default VoteEditor; diff --git a/src/components/business/VotePreview.tsx b/src/components/business/VotePreview.tsx index a430e95..1cbfad5 100644 --- a/src/components/business/VotePreview.tsx +++ b/src/components/business/VotePreview.tsx @@ -3,14 +3,14 @@ * 用于帖子列表中显示投票预览,类似微博风格 */ -import React from 'react'; +import React, { useMemo } from 'react'; import { View, TouchableOpacity, StyleSheet, } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { colors, spacing, fontSizes, borderRadius } from '../../theme'; +import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme'; import Text from '../common/Text'; interface VotePreviewProps { @@ -19,11 +19,51 @@ interface VotePreviewProps { onPress?: () => void; } +function createVotePreviewStyles(colors: AppColors) { + return StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.primary.light + '08', + borderRadius: borderRadius.md, + padding: spacing.md, + marginTop: spacing.sm, + borderWidth: 1, + borderColor: colors.primary.light + '30', + }, + iconContainer: { + width: 36, + height: 36, + borderRadius: borderRadius.md, + backgroundColor: colors.primary.light + '20', + justifyContent: 'center', + alignItems: 'center', + marginRight: spacing.sm, + }, + content: { + flex: 1, + }, + title: { + fontSize: fontSizes.md, + fontWeight: '600', + color: colors.text.primary, + marginBottom: 2, + }, + subtitle: { + fontSize: fontSizes.sm, + color: colors.text.secondary, + }, + }); +} + const VotePreview: React.FC = ({ totalVotes = 0, optionsCount = 0, onPress, }) => { + const colors = useAppColors(); + const styles = useMemo(() => createVotePreviewStyles(colors), [colors]); + // 格式化票数 const formatVoteCount = (count: number): string => { if (count >= 10000) { @@ -71,39 +111,4 @@ const VotePreview: React.FC = ({ ); }; -const styles = StyleSheet.create({ - container: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.primary.light + '08', - borderRadius: borderRadius.md, - padding: spacing.md, - marginTop: spacing.sm, - borderWidth: 1, - borderColor: colors.primary.light + '30', - }, - iconContainer: { - width: 36, - height: 36, - borderRadius: borderRadius.md, - backgroundColor: colors.primary.light + '20', - justifyContent: 'center', - alignItems: 'center', - marginRight: spacing.sm, - }, - content: { - flex: 1, - }, - title: { - fontSize: fontSizes.md, - fontWeight: '600', - color: colors.text.primary, - marginBottom: 2, - }, - subtitle: { - fontSize: fontSizes.sm, - color: colors.text.secondary, - }, -}); - export default VotePreview; diff --git a/src/components/business/index.ts b/src/components/business/index.ts index dad9de0..91395e8 100644 --- a/src/components/business/index.ts +++ b/src/components/business/index.ts @@ -2,10 +2,20 @@ * 业务组件导出 */ +// PostCard 新架构(从 PostCard 目录导出) export { default as PostCard } from './PostCard'; +// 导出 PostCard 相关类型和工具 +export type { + PostCardActionType, + PostCardAction, + PostCardOnAction, + PostCardFeatures, + PostCardFeaturesPreset, + PostCardFeaturesConfig, + PostCardProps, +} from './PostCard'; export { default as CommentItem } from './CommentItem'; export { default as UserProfileHeader } from './UserProfileHeader'; -export { default as NotificationItem } from './NotificationItem'; export { default as SystemMessageItem } from './SystemMessageItem'; export { default as SearchBar } from './SearchBar'; export { default as TabBar } from './TabBar'; diff --git a/src/components/common/AppBackButton.tsx b/src/components/common/AppBackButton.tsx new file mode 100644 index 0000000..687d936 --- /dev/null +++ b/src/components/common/AppBackButton.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { TouchableOpacity, StyleProp, ViewStyle, StyleSheet } from 'react-native'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; + +import { borderRadius, spacing, useAppColors } from '../../theme'; + +type AppBackButtonIcon = 'arrow-left' | 'close'; + +interface AppBackButtonProps { + onPress: () => void; + icon?: AppBackButtonIcon; + style?: StyleProp; + iconColor?: string; + backgroundColor?: string; + hitSlop?: number; + testID?: string; +} + +const AppBackButton: React.FC = ({ + onPress, + icon = 'arrow-left', + style, + iconColor: iconColorProp, + backgroundColor: backgroundColorProp, + hitSlop = 8, + testID, +}) => { + const colors = useAppColors(); + const iconColor = iconColorProp ?? colors.text.primary; + const backgroundColor = backgroundColorProp ?? colors.background.paper; + + return ( + + + + ); +}; + +const styles = StyleSheet.create({ + button: { + width: 36, + height: 36, + borderRadius: borderRadius.full, + alignItems: 'center', + justifyContent: 'center', + marginLeft: spacing.xs, + }, +}); + +export default AppBackButton; diff --git a/src/components/common/AppDialogHost.tsx b/src/components/common/AppDialogHost.tsx index baa7cd0..6be4aa9 100644 --- a/src/components/common/AppDialogHost.tsx +++ b/src/components/common/AppDialogHost.tsx @@ -5,10 +5,98 @@ import { MaterialCommunityIcons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; import { bindDialogListener, DialogPayload } from '../../services/dialogService'; -import { borderRadius, colors, shadows, spacing } from '../../theme'; +import { borderRadius, shadows, spacing, useAppColors, type AppColors } from '../../theme'; + +function createDialogHostStyles(colors: AppColors) { + return StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.36)', + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: spacing.xl, + }, + container: { + width: '100%', + maxWidth: 380, + backgroundColor: colors.background.paper, + borderRadius: borderRadius['2xl'], + paddingHorizontal: spacing.xl, + paddingTop: spacing.lg, + paddingBottom: spacing.lg, + ...shadows.lg, + }, + iconHeader: { + alignItems: 'center', + marginBottom: spacing.md, + }, + iconBadge: { + width: 42, + height: 42, + borderRadius: borderRadius.full, + justifyContent: 'center', + alignItems: 'center', + }, + title: { + color: colors.text.primary, + fontSize: 18, + fontWeight: '700', + textAlign: 'center', + }, + message: { + marginTop: spacing.md, + color: colors.text.secondary, + fontSize: 14, + lineHeight: 21, + textAlign: 'center', + }, + actions: { + marginTop: spacing.xl, + gap: spacing.sm, + }, + actionButton: { + height: 46, + borderRadius: borderRadius.lg, + borderWidth: 1, + borderColor: `${colors.primary.main}28`, + backgroundColor: colors.background.paper, + justifyContent: 'center', + alignItems: 'center', + }, + primaryButton: { + backgroundColor: colors.primary.main, + borderColor: colors.primary.main, + }, + cancelButton: { + backgroundColor: colors.background.paper, + borderColor: colors.divider, + }, + destructiveButton: { + backgroundColor: `${colors.error.main}18`, + borderColor: `${colors.error.main}40`, + }, + actionText: { + color: colors.text.primary, + fontSize: 15, + fontWeight: '700', + }, + primaryText: { + color: colors.text.inverse, + }, + cancelText: { + color: colors.text.secondary, + fontWeight: '600', + }, + destructiveText: { + color: colors.error.main, + }, + }); +} const AppDialogHost: React.FC = () => { const [dialog, setDialog] = useState(null); + const colors = useAppColors(); + const styles = useMemo(() => createDialogHostStyles(colors), [colors]); useEffect(() => { const unbind = bindDialogListener((payload) => { @@ -19,7 +107,7 @@ const AppDialogHost: React.FC = () => { const actions = useMemo(() => { if (!dialog?.actions?.length) return [{ text: '确定' }]; - return dialog.actions.slice(0, 3); + return dialog.actions; }, [dialog]); const onClose = () => { @@ -101,88 +189,4 @@ const AppDialogHost: React.FC = () => { ); }; -const styles = StyleSheet.create({ - backdrop: { - flex: 1, - backgroundColor: 'rgba(0, 0, 0, 0.36)', - justifyContent: 'center', - alignItems: 'center', - paddingHorizontal: spacing.xl, - }, - container: { - width: '100%', - maxWidth: 380, - backgroundColor: colors.background.paper, - borderRadius: borderRadius['2xl'], - paddingHorizontal: spacing.xl, - paddingTop: spacing.lg, - paddingBottom: spacing.lg, - ...shadows.lg, - }, - iconHeader: { - alignItems: 'center', - marginBottom: spacing.md, - }, - iconBadge: { - width: 42, - height: 42, - borderRadius: borderRadius.full, - justifyContent: 'center', - alignItems: 'center', - }, - title: { - color: colors.text.primary, - fontSize: 18, - fontWeight: '700', - textAlign: 'center', - }, - message: { - marginTop: spacing.md, - color: colors.text.secondary, - fontSize: 14, - lineHeight: 21, - textAlign: 'center', - }, - actions: { - marginTop: spacing.xl, - gap: spacing.sm, - }, - actionButton: { - height: 46, - borderRadius: borderRadius.lg, - borderWidth: 1, - borderColor: `${colors.primary.main}28`, - backgroundColor: '#FFFFFF', - justifyContent: 'center', - alignItems: 'center', - }, - primaryButton: { - backgroundColor: colors.primary.main, - borderColor: colors.primary.main, - }, - cancelButton: { - backgroundColor: colors.background.paper, - borderColor: colors.divider, - }, - destructiveButton: { - backgroundColor: '#FEECEC', - borderColor: '#FCD4D1', - }, - actionText: { - color: colors.text.primary, - fontSize: 15, - fontWeight: '700', - }, - primaryText: { - color: '#FFFFFF', - }, - cancelText: { - color: colors.text.secondary, - fontWeight: '600', - }, - destructiveText: { - color: colors.error.main, - }, -}); - export default AppDialogHost; diff --git a/src/components/common/AppPromptBar.tsx b/src/components/common/AppPromptBar.tsx index d0e8e84..66a94d0 100644 --- a/src/components/common/AppPromptBar.tsx +++ b/src/components/common/AppPromptBar.tsx @@ -1,10 +1,10 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Animated, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { bindPromptListener, PromptPayload, PromptType } from '../../services/promptService'; -import { borderRadius, colors, shadows, spacing } from '../../theme'; +import { borderRadius, shadows, spacing, useAppColors, type AppColors } from '../../theme'; interface PromptState extends PromptPayload { id: number; @@ -12,29 +12,96 @@ interface PromptState extends PromptPayload { const DEFAULT_DURATION = 2200; -const styleMap: Record['name'] }> = { - info: { backgroundColor: '#FFFFFF', icon: 'information-outline' }, - success: { backgroundColor: '#FFFFFF', icon: 'check-circle-outline' }, - warning: { backgroundColor: '#FFFFFF', icon: 'alert-outline' }, - error: { backgroundColor: '#FFFFFF', icon: 'alert-circle-outline' }, -}; - -const iconColorMap: Record = { - info: colors.primary.main, - success: colors.success.main, - warning: colors.warning.dark, - error: colors.error.main, -}; - -const accentColorMap: Record = { - info: colors.primary.main, - success: colors.success.main, - warning: colors.warning.main, - error: colors.error.main, -}; +function createPromptBarStyles(colors: AppColors) { + return StyleSheet.create({ + wrapper: { + position: 'absolute', + left: spacing.md, + right: spacing.md, + zIndex: 9999, + }, + card: { + borderRadius: borderRadius.xl, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md, + borderWidth: 1, + borderColor: `${colors.primary.main}22`, + flexDirection: 'row', + alignItems: 'center', + overflow: 'hidden', + ...shadows.lg, + }, + accentBar: { + position: 'absolute', + left: 0, + top: 0, + bottom: 0, + width: 4, + }, + iconWrap: { + width: 32, + height: 32, + borderRadius: borderRadius.full, + backgroundColor: `${colors.primary.main}12`, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.md, + }, + textWrap: { + flex: 1, + paddingRight: spacing.sm, + }, + title: { + color: colors.text.primary, + fontWeight: '700', + fontSize: 14, + marginBottom: 2, + }, + message: { + color: colors.text.primary, + fontSize: 13, + lineHeight: 18, + }, + }); +} const AppPromptBar: React.FC = () => { const insets = useSafeAreaInsets(); + const colors = useAppColors(); + const styles = useMemo(() => createPromptBarStyles(colors), [colors]); + + const styleMap = useMemo< + Record['name'] }> + >( + () => ({ + info: { backgroundColor: colors.background.paper, icon: 'information-outline' }, + success: { backgroundColor: colors.background.paper, icon: 'check-circle-outline' }, + warning: { backgroundColor: colors.background.paper, icon: 'alert-outline' }, + error: { backgroundColor: colors.background.paper, icon: 'alert-circle-outline' }, + }), + [colors.background.paper], + ); + + const iconColorMap = useMemo( + () => ({ + info: colors.primary.main, + success: colors.success.main, + warning: colors.warning.dark, + error: colors.error.main, + }), + [colors.primary.main, colors.success.main, colors.warning.dark, colors.error.main], + ); + + const accentColorMap = useMemo( + () => ({ + info: colors.primary.main, + success: colors.success.main, + warning: colors.warning.main, + error: colors.error.main, + }), + [colors.primary.main, colors.success.main, colors.warning.main, colors.error.main], + ); + const [prompt, setPrompt] = useState(null); const hideTimerRef = useRef | null>(null); const animation = useRef(new Animated.Value(0)).current; @@ -131,55 +198,4 @@ const AppPromptBar: React.FC = () => { ); }; -const styles = StyleSheet.create({ - wrapper: { - position: 'absolute', - left: spacing.md, - right: spacing.md, - zIndex: 9999, - }, - card: { - borderRadius: borderRadius.xl, - paddingVertical: spacing.md, - paddingHorizontal: spacing.md, - borderWidth: 1, - borderColor: `${colors.primary.main}22`, - flexDirection: 'row', - alignItems: 'center', - overflow: 'hidden', - ...shadows.lg, - }, - accentBar: { - position: 'absolute', - left: 0, - top: 0, - bottom: 0, - width: 4, - }, - iconWrap: { - width: 32, - height: 32, - borderRadius: borderRadius.full, - backgroundColor: `${colors.primary.main}12`, - alignItems: 'center', - justifyContent: 'center', - marginRight: spacing.md, - }, - textWrap: { - flex: 1, - paddingRight: spacing.sm, - }, - title: { - color: colors.text.primary, - fontWeight: '700', - fontSize: 14, - marginBottom: 2, - }, - message: { - color: colors.text.primary, - fontSize: 13, - lineHeight: 18, - }, -}); - export default AppPromptBar; diff --git a/src/components/common/Avatar.tsx b/src/components/common/Avatar.tsx index 51090b8..808e88e 100644 --- a/src/components/common/Avatar.tsx +++ b/src/components/common/Avatar.tsx @@ -4,7 +4,7 @@ * 使用 expo-image 实现内存+磁盘双级缓存,头像秒加载 */ -import React from 'react'; +import React, { useMemo } from 'react'; import { View, TouchableOpacity, @@ -12,7 +12,7 @@ import { ViewStyle, } from 'react-native'; import { Image as ExpoImage } from 'expo-image'; -import { colors, borderRadius } from '../../theme'; +import { borderRadius, useAppColors, type AppColors } from '../../theme'; import Text from './Text'; type AvatarSource = string | { uri: string } | number | null; @@ -27,29 +27,50 @@ interface AvatarProps { style?: ViewStyle; } +function createAvatarStyles(colors: AppColors) { + return StyleSheet.create({ + container: { + position: 'relative', + }, + image: { + backgroundColor: colors.background.disabled, + }, + placeholder: { + backgroundColor: colors.primary.main, + justifyContent: 'center', + alignItems: 'center', + }, + badge: { + position: 'absolute', + borderWidth: 2, + borderColor: colors.background.paper, + }, + }); +} + const Avatar: React.FC = ({ source, size = 40, name, onPress, showBadge = false, - badgeColor = colors.success.main, + badgeColor, style, }) => { - // 获取首字母 + const colors = useAppColors(); + const styles = useMemo(() => createAvatarStyles(colors), [colors]); + const resolvedBadgeColor = badgeColor ?? colors.success.main; + const getInitial = (): string => { if (!name) return '?'; const firstChar = name.charAt(0).toUpperCase(); - // 中文字符 if (/[\u4e00-\u9fa5]/.test(firstChar)) { return firstChar; } return firstChar; }; - // 渲染头像内容 const renderAvatarContent = () => { - // 如果有图片源 if (source) { const imageSource = typeof source === 'string' ? { uri: source } : source; @@ -72,7 +93,6 @@ const Avatar: React.FC = ({ ); } - // 显示首字母 const fontSize = size / 2; return ( = ({ style={[ styles.badge, { - backgroundColor: badgeColor, + backgroundColor: resolvedBadgeColor, width: size * 0.3, height: size * 0.3, borderRadius: (size * 0.3) / 2, @@ -124,23 +144,4 @@ const Avatar: React.FC = ({ return avatarContainer; }; -const styles = StyleSheet.create({ - container: { - position: 'relative', - }, - image: { - backgroundColor: colors.background.disabled, - }, - placeholder: { - backgroundColor: colors.primary.main, - justifyContent: 'center', - alignItems: 'center', - }, - badge: { - position: 'absolute', - borderWidth: 2, - borderColor: colors.background.paper, - }, -}); - export default Avatar; diff --git a/src/components/common/Button.tsx b/src/components/common/Button.tsx index 68bc883..ffab0d6 100644 --- a/src/components/common/Button.tsx +++ b/src/components/common/Button.tsx @@ -3,7 +3,7 @@ * 支持多种变体、尺寸、加载状态和图标 */ -import React from 'react'; +import React, { useMemo } from 'react'; import { TouchableOpacity, ActivityIndicator, @@ -13,7 +13,7 @@ import { TextStyle, } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { colors, borderRadius, spacing, fontSizes, shadows } from '../../theme'; +import { borderRadius, spacing, fontSizes, useAppColors, type AppColors } from '../../theme'; import Text from './Text'; type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'text' | 'danger'; @@ -27,12 +27,86 @@ interface ButtonProps { size?: ButtonSize; disabled?: boolean; loading?: boolean; - icon?: string; // MaterialCommunityIcons name + icon?: string; iconPosition?: IconPosition; fullWidth?: boolean; style?: ViewStyle; } +function createButtonStyles(colors: AppColors) { + return StyleSheet.create({ + base: { + justifyContent: 'center', + alignItems: 'center', + borderRadius: borderRadius.md, + }, + content: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + size_sm: { + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + minHeight: 32, + }, + size_md: { + paddingVertical: spacing.md, + paddingHorizontal: spacing.lg, + minHeight: 40, + }, + size_lg: { + paddingVertical: spacing.lg, + paddingHorizontal: spacing.xl, + minHeight: 48, + }, + primary: { + backgroundColor: colors.primary.main, + }, + secondary: { + backgroundColor: colors.secondary.main, + }, + outline: { + backgroundColor: 'transparent', + borderWidth: 1, + borderColor: colors.primary.main, + }, + text: { + backgroundColor: 'transparent', + shadowColor: 'transparent', + elevation: 0, + }, + danger: { + backgroundColor: colors.error.main, + }, + disabled: { + backgroundColor: colors.background.disabled, + borderColor: colors.background.disabled, + }, + fullWidth: { + width: '100%', + }, + textBase: { + fontWeight: '600', + }, + textSize_sm: { + fontSize: fontSizes.sm, + }, + textSize_md: { + fontSize: fontSizes.md, + }, + textSize_lg: { + fontSize: fontSizes.lg, + }, + iconLeft: { + marginRight: spacing.sm, + }, + iconRight: { + marginLeft: spacing.sm, + }, + }); +} + const Button: React.FC = ({ title, onPress, @@ -45,11 +119,12 @@ const Button: React.FC = ({ fullWidth = false, style, }) => { - // 获取按钮样式 + const colors = useAppColors(); + const styles = useMemo(() => createButtonStyles(colors), [colors]); + const getButtonStyle = (): ViewStyle[] => { const baseStyle: ViewStyle[] = [styles.base, styles[`size_${size}`]]; - // 变体样式 switch (variant) { case 'primary': baseStyle.push(styles.primary); @@ -68,12 +143,10 @@ const Button: React.FC = ({ break; } - // 禁用状态 if (disabled || loading) { baseStyle.push(styles.disabled); } - // 全宽度 if (fullWidth) { baseStyle.push(styles.fullWidth); } @@ -81,7 +154,6 @@ const Button: React.FC = ({ return baseStyle; }; - // 获取文本样式 const getTextStyle = (): TextStyle => { const baseStyle: TextStyle = { ...styles.textBase, @@ -109,7 +181,6 @@ const Button: React.FC = ({ return baseStyle; }; - // 获取图标大小 const getIconSize = (): number => { switch (size) { case 'sm': @@ -121,7 +192,6 @@ const Button: React.FC = ({ } }; - // 获取图标颜色 const getIconColor = (): string => { if (disabled || loading) { return colors.text.disabled; @@ -150,9 +220,9 @@ const Button: React.FC = ({ {loading ? ( ) : ( @@ -179,82 +249,4 @@ const Button: React.FC = ({ ); }; -const styles = StyleSheet.create({ - base: { - justifyContent: 'center', - alignItems: 'center', - borderRadius: borderRadius.md, - }, - content: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - }, - // 尺寸样式 - size_sm: { - paddingVertical: spacing.sm, - paddingHorizontal: spacing.md, - minHeight: 32, - }, - size_md: { - paddingVertical: spacing.md, - paddingHorizontal: spacing.lg, - minHeight: 40, - }, - size_lg: { - paddingVertical: spacing.lg, - paddingHorizontal: spacing.xl, - minHeight: 48, - }, - // 变体样式 - primary: { - backgroundColor: colors.primary.main, - }, - secondary: { - backgroundColor: colors.secondary.main, - }, - outline: { - backgroundColor: 'transparent', - borderWidth: 1, - borderColor: colors.primary.main, - }, - text: { - backgroundColor: 'transparent', - shadowColor: 'transparent', - elevation: 0, - }, - danger: { - backgroundColor: colors.error.main, - }, - // 禁用状态 - disabled: { - backgroundColor: colors.background.disabled, - borderColor: colors.background.disabled, - }, - // 全宽度 - fullWidth: { - width: '100%', - }, - // 文本样式 - textBase: { - fontWeight: '600', - }, - textSize_sm: { - fontSize: fontSizes.sm, - }, - textSize_md: { - fontSize: fontSizes.md, - }, - textSize_lg: { - fontSize: fontSizes.lg, - }, - // 图标样式 - iconLeft: { - marginRight: spacing.sm, - }, - iconRight: { - marginLeft: spacing.sm, - }, -}); - export default Button; diff --git a/src/components/common/Card.tsx b/src/components/common/Card.tsx index d3f1ca9..42f9573 100644 --- a/src/components/common/Card.tsx +++ b/src/components/common/Card.tsx @@ -3,9 +3,9 @@ * 白色背景、圆角、阴影,支持点击 */ -import React from 'react'; +import React, { useMemo } from 'react'; import { View, TouchableOpacity, StyleSheet, ViewStyle } from 'react-native'; -import { colors, borderRadius, spacing, shadows } from '../../theme'; +import { borderRadius, spacing, shadows, useAppColors, type AppColors } from '../../theme'; type ShadowSize = 'sm' | 'md' | 'lg'; @@ -13,10 +13,20 @@ interface CardProps { children: React.ReactNode; onPress?: () => void; padding?: number; - shadow?: ShadowSize; + shadow?: ShadowSize | 'none'; style?: ViewStyle; } +function createCardStyles(colors: AppColors) { + return StyleSheet.create({ + card: { + backgroundColor: colors.background.paper, + borderRadius: borderRadius.lg, + overflow: 'hidden', + }, + }); +} + const Card: React.FC = ({ children, onPress, @@ -24,13 +34,11 @@ const Card: React.FC = ({ shadow = 'none', style, }) => { + const colors = useAppColors(); + const styles = useMemo(() => createCardStyles(colors), [colors]); const shadowStyle = shadow !== 'none' ? shadows[shadow as keyof typeof shadows] : undefined; - - const cardStyle = [ - styles.card, - { padding }, - shadowStyle, - ].filter(Boolean); + + const cardStyle = [styles.card, { padding }, shadowStyle].filter(Boolean); if (onPress) { return ( @@ -47,12 +55,4 @@ const Card: React.FC = ({ return {children}; }; -const styles = StyleSheet.create({ - card: { - backgroundColor: colors.background.paper, - borderRadius: borderRadius.lg, - overflow: 'hidden', - }, -}); - export default Card; diff --git a/src/components/common/Divider.tsx b/src/components/common/Divider.tsx index ee2ffbe..f60cdd1 100644 --- a/src/components/common/Divider.tsx +++ b/src/components/common/Divider.tsx @@ -5,7 +5,7 @@ import React from 'react'; import { View, StyleSheet, ViewStyle } from 'react-native'; -import { colors, spacing } from '../../theme'; +import { spacing, useAppColors } from '../../theme'; interface DividerProps { margin?: number; @@ -13,16 +13,14 @@ interface DividerProps { style?: ViewStyle; } -const Divider: React.FC = ({ - margin = spacing.lg, - color = colors.divider, - style, -}) => { +const Divider: React.FC = ({ margin = spacing.lg, color, style }) => { + const colors = useAppColors(); + const lineColor = color ?? colors.divider; return ( diff --git a/src/components/common/EmptyState.tsx b/src/components/common/EmptyState.tsx index c1e0dfb..3112d94 100644 --- a/src/components/common/EmptyState.tsx +++ b/src/components/common/EmptyState.tsx @@ -3,10 +3,10 @@ * 显示空数据时的占位界面,采用现代插图风格设计 */ -import React from 'react'; -import { View, StyleSheet, ViewStyle, Dimensions } from 'react-native'; +import React, { useMemo } from 'react'; +import { View, StyleSheet, ViewStyle } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { colors, spacing, fontSizes, borderRadius } from '../../theme'; +import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme'; import Text from './Text'; import Button from './Button'; @@ -20,6 +20,109 @@ interface EmptyStateProps { variant?: 'default' | 'modern' | 'compact'; } +function createEmptyStateStyles(colors: AppColors) { + return StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: spacing.xl, + }, + icon: { + marginBottom: spacing.lg, + }, + title: { + textAlign: 'center', + marginBottom: spacing.sm, + }, + description: { + textAlign: 'center', + marginBottom: spacing.lg, + }, + button: { + minWidth: 120, + }, + modernContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: spacing.xl, + minHeight: 280, + }, + illustrationContainer: { + position: 'relative', + alignItems: 'center', + justifyContent: 'center', + marginBottom: spacing.xl, + width: 120, + height: 120, + }, + iconBackground: { + width: 100, + height: 100, + borderRadius: borderRadius['2xl'], + backgroundColor: colors.primary.main + '15', + alignItems: 'center', + justifyContent: 'center', + shadowColor: colors.primary.main, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.2, + shadowRadius: 12, + elevation: 4, + }, + modernIcon: { + opacity: 0.9, + }, + decorativeCircle1: { + position: 'absolute', + width: 24, + height: 24, + borderRadius: borderRadius.full, + backgroundColor: colors.primary.light + '30', + top: 5, + right: 10, + }, + decorativeCircle2: { + position: 'absolute', + width: 16, + height: 16, + borderRadius: borderRadius.full, + backgroundColor: colors.primary.main + '20', + bottom: 15, + left: 5, + }, + modernTitle: { + textAlign: 'center', + marginBottom: spacing.sm, + fontWeight: '700', + fontSize: fontSizes.xl, + }, + modernDescription: { + textAlign: 'center', + marginBottom: spacing.lg, + fontSize: fontSizes.md, + lineHeight: 22, + maxWidth: 280, + }, + modernButton: { + minWidth: 140, + borderRadius: borderRadius.lg, + }, + compactContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + padding: spacing.lg, + }, + compactIcon: { + marginRight: spacing.sm, + }, + compactTitle: { + fontSize: fontSizes.md, + }, + }); +} + const EmptyState: React.FC = ({ icon = 'folder-open-outline', title, @@ -29,7 +132,9 @@ const EmptyState: React.FC = ({ style, variant = 'modern', }) => { - // 现代风格空状态 + const colors = useAppColors(); + const styles = useMemo(() => createEmptyStateStyles(colors), [colors]); + if (variant === 'modern') { return ( @@ -45,19 +150,11 @@ const EmptyState: React.FC = ({ - + {title} {description && ( - + {description} )} @@ -74,7 +171,6 @@ const EmptyState: React.FC = ({ ); } - // 紧凑风格 if (variant === 'compact') { return ( @@ -84,18 +180,13 @@ const EmptyState: React.FC = ({ color={colors.text.disabled} style={styles.compactIcon} /> - + {title} ); } - // 默认风格 return ( = ({ color={colors.text.disabled} style={styles.icon} /> - + {title} {description && ( - + {description} )} @@ -133,110 +216,4 @@ const EmptyState: React.FC = ({ ); }; -const styles = StyleSheet.create({ - // 默认风格 - container: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - padding: spacing.xl, - }, - icon: { - marginBottom: spacing.lg, - }, - title: { - textAlign: 'center', - marginBottom: spacing.sm, - }, - description: { - textAlign: 'center', - marginBottom: spacing.lg, - }, - button: { - minWidth: 120, - }, - - // 现代风格 - modernContainer: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - padding: spacing.xl, - minHeight: 280, - }, - illustrationContainer: { - position: 'relative', - alignItems: 'center', - justifyContent: 'center', - marginBottom: spacing.xl, - width: 120, - height: 120, - }, - iconBackground: { - width: 100, - height: 100, - borderRadius: borderRadius['2xl'], - backgroundColor: colors.primary.main + '15', - alignItems: 'center', - justifyContent: 'center', - shadowColor: colors.primary.main, - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.2, - shadowRadius: 12, - elevation: 4, - }, - modernIcon: { - opacity: 0.9, - }, - decorativeCircle1: { - position: 'absolute', - width: 24, - height: 24, - borderRadius: borderRadius.full, - backgroundColor: colors.primary.light + '30', - top: 5, - right: 10, - }, - decorativeCircle2: { - position: 'absolute', - width: 16, - height: 16, - borderRadius: borderRadius.full, - backgroundColor: colors.primary.main + '20', - bottom: 15, - left: 5, - }, - modernTitle: { - textAlign: 'center', - marginBottom: spacing.sm, - fontWeight: '700', - fontSize: fontSizes.xl, - }, - modernDescription: { - textAlign: 'center', - marginBottom: spacing.lg, - fontSize: fontSizes.md, - lineHeight: 22, - maxWidth: 280, - }, - modernButton: { - minWidth: 140, - borderRadius: borderRadius.lg, - }, - - // 紧凑风格 - compactContainer: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - padding: spacing.lg, - }, - compactIcon: { - marginRight: spacing.sm, - }, - compactTitle: { - fontSize: fontSizes.md, - }, -}); - export default EmptyState; diff --git a/src/components/common/ImageGallery.tsx b/src/components/common/ImageGallery.tsx index 8cc0dc0..a4a14ee 100644 --- a/src/components/common/ImageGallery.tsx +++ b/src/components/common/ImageGallery.tsx @@ -4,7 +4,7 @@ * 使用 expo-image,原生支持 GIF/WebP 动图 */ -import React, { useState, useCallback, useEffect, useMemo } from 'react'; +import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'; import { Modal, View, @@ -13,7 +13,6 @@ import { TouchableOpacity, Text, StatusBar, - ActivityIndicator, Alert, } from 'react-native'; import { Image as ExpoImage } from 'expo-image'; @@ -25,16 +24,14 @@ import { import Animated, { useSharedValue, useAnimatedStyle, - useDerivedValue, runOnJS, withTiming, - withSpring, } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import * as MediaLibrary from 'expo-media-library'; import { File, Paths } from 'expo-file-system'; -import { colors, spacing, borderRadius, fontSizes } from '../../theme'; +import { spacing, borderRadius, fontSizes } from '../../theme'; const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window'); @@ -84,9 +81,13 @@ export const ImageGallery: React.FC = ({ onSave, backgroundOpacity = 1, }) => { + const closeTimerRef = useRef | null>(null); + const isClosingRef = useRef(false); + const currentIndexRef = useRef(initialIndex); + const pendingSwipeDirectionRef = useRef<-1 | 1 | null>(null); const [currentIndex, setCurrentIndex] = useState(initialIndex); + currentIndexRef.current = currentIndex; const [showControls, setShowControls] = useState(true); - const [loading, setLoading] = useState(true); const [error, setError] = useState(false); const [saving, setSaving] = useState(false); const [saveToast, setSaveToast] = useState<'success' | 'error' | null>(null); @@ -127,7 +128,6 @@ export const ImageGallery: React.FC = ({ if (visible) { setCurrentIndex(initialIndex); setShowControls(true); - setLoading(true); setError(false); resetZoom(); StatusBar.setHidden(true, 'fade'); @@ -136,37 +136,61 @@ export const ImageGallery: React.FC = ({ } }, [visible, initialIndex, resetZoom]); - // 图片变化时重置加载状态和缩放 useEffect(() => { - setLoading(true); - setError(false); + if (visible) { + isClosingRef.current = false; + } + }, [visible]); + + useEffect(() => { + return () => { + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current); + } + }; + }, []); + + // 图片变化时重置缩放(加载态在 updateIndex / 打开弹窗时同步设置,避免晚一帧仍显示上一张) + useEffect(() => { resetZoom(); }, [currentImage?.id, resetZoom]); const updateIndex = useCallback( (newIndex: number) => { const clampedIndex = Math.max(0, Math.min(validImages.length - 1, newIndex)); + if (clampedIndex === currentIndexRef.current) { + return; + } + setError(false); setCurrentIndex(clampedIndex); onIndexChange?.(clampedIndex); }, [validImages.length, onIndexChange] ); + const updateIndexFromSwipe = useCallback( + (newIndex: number, direction: -1 | 1) => { + pendingSwipeDirectionRef.current = direction; + updateIndex(newIndex); + }, + [updateIndex] + ); + const toggleControls = useCallback(() => { setShowControls(prev => !prev); }, []); - const goToPrev = useCallback(() => { - if (currentIndex > 0) { - updateIndex(currentIndex - 1); + const requestClose = useCallback(() => { + if (isClosingRef.current) { + return; } - }, [currentIndex, updateIndex]); - const goToNext = useCallback(() => { - if (currentIndex < validImages.length - 1) { - updateIndex(currentIndex + 1); - } - }, [currentIndex, validImages.length, updateIndex]); + isClosingRef.current = true; + // 延迟一个短时间窗口,避免关闭同一触摸触发到底层组件 + closeTimerRef.current = setTimeout(() => { + onClose(); + }, 120); + }, [onClose]); // 显示短暂提示 const showToast = useCallback((type: 'success' | 'error') => { @@ -236,6 +260,30 @@ export const ImageGallery: React.FC = ({ // 滑动切换图片相关状态 const swipeTranslateX = useSharedValue(0); + useEffect(() => { + pendingSwipeDirectionRef.current = null; + swipeTranslateX.value = 0; + }, [currentIndex, swipeTranslateX]); + + const switchWithDirection = useCallback( + (targetIndex: number, direction: -1 | 1) => { + updateIndexFromSwipe(targetIndex, direction); + }, + [updateIndexFromSwipe] + ); + + const goToPrev = useCallback(() => { + if (currentIndex > 0) { + switchWithDirection(currentIndex - 1, 1); + } + }, [currentIndex, switchWithDirection]); + + const goToNext = useCallback(() => { + if (currentIndex < validImages.length - 1) { + switchWithDirection(currentIndex + 1, -1); + } + }, [currentIndex, validImages.length, switchWithDirection]); + // 统一的滑动手势:放大时拖动,未放大时切换图片 const panGesture = Gesture.Pan() .activeOffsetX([-10, 10]) // 水平方向需要移动10pt才激活,避免与点击冲突 @@ -248,16 +296,6 @@ export const ImageGallery: React.FC = ({ // 放大状态下:拖动图片 translateX.value = savedTranslateX.value + e.translationX; translateY.value = savedTranslateY.value + e.translationY; - } else if (validImages.length > 1) { - // 未放大且有多张图片:切换图片的跟随效果 - const isFirst = currentIndex === 0 && e.translationX > 0; - const isLast = currentIndex === validImages.length - 1 && e.translationX < 0; - if (isFirst || isLast) { - // 边界阻力效果 - swipeTranslateX.value = e.translationX * 0.3; - } else { - swipeTranslateX.value = e.translationX; - } } }) .onEnd((e) => { @@ -275,19 +313,10 @@ export const ImageGallery: React.FC = ({ if (shouldGoNext && currentIndex < validImages.length - 1) { // 向左滑动,显示下一张 - swipeTranslateX.value = withTiming(-SCREEN_WIDTH, { duration: 200 }, () => { - runOnJS(updateIndex)(currentIndex + 1); - swipeTranslateX.value = 0; - }); + runOnJS(updateIndexFromSwipe)(currentIndex + 1, -1); } else if (shouldGoPrev && currentIndex > 0) { // 向右滑动,显示上一张 - swipeTranslateX.value = withTiming(SCREEN_WIDTH, { duration: 200 }, () => { - runOnJS(updateIndex)(currentIndex - 1); - swipeTranslateX.value = 0; - }); - } else { - // 回弹到原位 - swipeTranslateX.value = withTiming(0, { duration: 200 }); + runOnJS(updateIndexFromSwipe)(currentIndex - 1, 1); } } }); @@ -297,7 +326,7 @@ export const ImageGallery: React.FC = ({ .numberOfTaps(1) .maxDistance(10) .onEnd(() => { - runOnJS(onClose)(); + runOnJS(requestClose)(); }); // 组合手势: @@ -326,7 +355,7 @@ export const ImageGallery: React.FC = ({ visible={visible} transparent animationType="fade" - onRequestClose={onClose} + onRequestClose={requestClose} statusBarTranslucent > @@ -334,7 +363,7 @@ export const ImageGallery: React.FC = ({ {/* 顶部控制栏 */} {showControls && ( - + @@ -351,7 +380,7 @@ export const ImageGallery: React.FC = ({ disabled={saving} > {saving ? ( - + ) : ( )} @@ -363,12 +392,6 @@ export const ImageGallery: React.FC = ({ {/* 图片显示区域 */} - {loading && ( - - - - )} - {error && ( @@ -384,15 +407,13 @@ export const ImageGallery: React.FC = ({ contentFit="contain" cachePolicy="disk" priority="high" - recyclingKey={currentImage.id} + recyclingKey={currentImage.url} + transition={null} allowDownscaling - onLoadStart={() => setLoading(true)} onLoad={() => { - setLoading(false); setError(false); }} onError={() => { - setLoading(false); setError(true); }} /> @@ -523,16 +544,11 @@ const styles = StyleSheet.create({ width: SCREEN_WIDTH, height: SCREEN_HEIGHT * 0.8, }, - loadingContainer: { - ...StyleSheet.absoluteFillObject, - justifyContent: 'center', - alignItems: 'center', - zIndex: 5, - }, errorContainer: { ...StyleSheet.absoluteFillObject, justifyContent: 'center', alignItems: 'center', + backgroundColor: '#000', }, errorText: { color: '#999', diff --git a/src/components/common/ImageGrid.tsx b/src/components/common/ImageGrid.tsx index ca28182..bf5678b 100644 --- a/src/components/common/ImageGrid.tsx +++ b/src/components/common/ImageGrid.tsx @@ -5,7 +5,7 @@ * 支持预览图优化 */ -import React, { useMemo, useCallback, useState, useEffect } from 'react'; +import React, { useMemo, useCallback, useState, useEffect, createContext, useContext } from 'react'; import { View, StyleSheet, @@ -15,8 +15,8 @@ import { Text, Image, } from 'react-native'; -import { SmartImage, ImageSource } from './SmartImage'; -import { colors, spacing, borderRadius } from '../../theme'; +import { SmartImage } from './SmartImage'; +import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme'; import { getPreviewImageUrl, ImageDisplayMode } from '../../utils/imageHelper'; const { width: SCREEN_WIDTH } = Dimensions.get('window'); @@ -40,6 +40,8 @@ export interface ImageGridItem { url?: string; thumbnailUrl?: string; thumbnail_url?: string; + preview_url?: string; + preview_url_large?: string; width?: number; height?: number; } @@ -99,6 +101,89 @@ const calculateGridDimensions = ( }; }; +function createImageGridStyles(colors: AppColors) { + return StyleSheet.create({ + container: { + marginTop: spacing.sm, + }, + fullSize: { + flex: 1, + width: '100%', + height: '100%', + }, + singleContainer: { + overflow: 'hidden', + backgroundColor: colors.background.disabled, + }, + horizontalContainer: { + flexDirection: 'row', + }, + horizontalItem: { + overflow: 'hidden', + backgroundColor: colors.background.disabled, + }, + gridContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + }, + gridItem: { + overflow: 'hidden', + backgroundColor: colors.background.disabled, + aspectRatio: 1, + }, + gridItem2: { + width: '49%', + }, + gridItem3: { + width: '32.5%', + }, + masonryContainer: { + flexDirection: 'row', + }, + masonryColumn: { + flex: 1, + }, + masonryItem: { + overflow: 'hidden', + backgroundColor: colors.background.disabled, + }, + moreOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0, 0, 0, 0.4)', + justifyContent: 'center', + alignItems: 'center', + borderRadius: borderRadius.md, + }, + moreText: { + color: colors.text.inverse, + fontSize: fontSizes.xl, + fontWeight: '500', + }, + compactContainer: { + marginTop: spacing.xs, + }, + compactGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + }, + compactItem: { + overflow: 'hidden', + backgroundColor: colors.background.disabled, + }, + }); +} + +type ImageGridStyles = ReturnType; +const ImageGridStylesContext = createContext(null); + +function useImageGridStyles(): ImageGridStyles { + const ctx = useContext(ImageGridStylesContext); + if (!ctx) { + throw new Error('useImageGridStyles must be used within ImageGrid or CompactImageGrid'); + } + return ctx; +} + // ─── 单张图片子组件 ─────────────────────────────────────────────────────────── // 独立成组件,方便用 useState 管理异步加载到的实际尺寸 @@ -108,6 +193,8 @@ interface SingleImageItemProps { maxHeight: number; borderRadiusValue: number; onPress: () => void; + usePreview: boolean; + displayMode: ImageDisplayMode; } const SingleImageItem: React.FC = ({ @@ -116,23 +203,35 @@ const SingleImageItem: React.FC = ({ maxHeight, borderRadiusValue, onPress, + usePreview, + displayMode, }) => { + const styles = useImageGridStyles(); const [aspectRatio, setAspectRatio] = useState(null); - const uri = image.uri || image.url || ''; + const mainUri = image.uri || image.url || ''; + const thumbnailUri = + image.thumbnail_url || + image.thumbnailUrl || + image.preview_url || + ''; useEffect(() => { - if (!uri) return; + if (image.width && image.height) { + setAspectRatio(image.width / image.height); + return; + } + const probeUri = thumbnailUri || mainUri; + if (!probeUri) return; let cancelled = false; - - // 添加超时处理,防止高分辨率图片加载卡住 + const timeoutId = setTimeout(() => { if (!cancelled && aspectRatio === null) { setAspectRatio(SINGLE_IMAGE_DEFAULT_ASPECT_RATIO); } }, 3000); - + Image.getSize( - uri, + probeUri, (w, h) => { if (!cancelled) { clearTimeout(timeoutId); @@ -146,11 +245,11 @@ const SingleImageItem: React.FC = ({ } }, ); - return () => { - cancelled = true; + return () => { + cancelled = true; clearTimeout(timeoutId); }; - }, [uri]); + }, [image.width, image.height, thumbnailUri, mainUri]); const effectiveContainerWidth = containerWidth || SCREEN_WIDTH - DEFAULT_CONTAINER_PADDING; @@ -189,13 +288,19 @@ const SingleImageItem: React.FC = ({ width = Math.min(SINGLE_IMAGE_MIN_HEIGHT * aspectRatio, effectiveContainerWidth); } + const previewForSmart = + usePreview && displayMode ? getPreviewImageUrl(image as any, displayMode) : ''; + const useSmartPreview = !!(usePreview && previewForSmart && mainUri && previewForSmart !== mainUri); + return ( = ({ displayMode = 'list', usePreview = true, }) => { - // 通过 onLayout 拿到容器实际宽度 + const colors = useAppColors(); + const gridStyles = useMemo(() => createImageGridStyles(colors), [colors]); const [containerWidth, setContainerWidth] = useState(0); // 过滤有效图片 - 支持 uri 或 url 字段 @@ -265,12 +371,7 @@ export const ImageGrid: React.FC = ({ const renderSingleImage = () => { const image = displayImages[0]; if (!image) return null; - - // 获取预览图 URL - const previewUrl = usePreview && displayMode - ? getPreviewImageUrl(image as any, displayMode) - : (image.uri || image.url || ''); - + return ( = ({ maxHeight={singleImageMaxHeight} borderRadiusValue={borderRadiusValue} onPress={() => handleImagePress(0)} + usePreview={usePreview} + displayMode={displayMode} /> ); }; @@ -285,7 +388,7 @@ export const ImageGrid: React.FC = ({ // 渲染横向双图 const renderHorizontal = () => { return ( - + {displayImages.map((image, index) => { const previewUrl = usePreview && displayMode ? getPreviewImageUrl(image as any, displayMode) @@ -296,7 +399,7 @@ export const ImageGrid: React.FC = ({ key={image.id || index} onPress={() => handleImagePress(index)} style={[ - styles.horizontalItem, + gridStyles.horizontalItem, { flex: 1, aspectRatio: 1, @@ -306,7 +409,7 @@ export const ImageGrid: React.FC = ({ > @@ -320,7 +423,7 @@ export const ImageGrid: React.FC = ({ // 渲染网格布局 const renderGrid = () => { return ( - + {displayImages.map((image, index) => { const isLastVisible = index === displayImages.length - 1; const showOverlay = isLastVisible && remainingCount > 0 && showMoreOverlay; @@ -334,8 +437,8 @@ export const ImageGrid: React.FC = ({ key={image.id || index} onPress={() => handleImagePress(index)} style={[ - styles.gridItem, - gridColumns === 3 ? styles.gridItem3 : styles.gridItem2, + gridStyles.gridItem, + gridColumns === 3 ? gridStyles.gridItem3 : gridStyles.gridItem2, { borderRadius: borderRadiusValue, }, @@ -343,13 +446,13 @@ export const ImageGrid: React.FC = ({ > {showOverlay && ( - - +{remainingCount} + + +{remainingCount} )} @@ -379,7 +482,7 @@ export const ImageGrid: React.FC = ({ const renderColumn = (columnImages: ImageGridItem[], columnIndex: number) => { return ( - + {columnImages.map((image, index) => { const actualIndex = columnIndex + index * 2; const aspectRatio = image.width && image.height @@ -396,7 +499,7 @@ export const ImageGrid: React.FC = ({ key={image.id || actualIndex} onPress={() => handleImagePress(actualIndex)} style={[ - styles.masonryItem, + gridStyles.masonryItem, { width: itemWidth, height: Math.max(height, itemWidth * 0.7), @@ -406,7 +509,7 @@ export const ImageGrid: React.FC = ({ > @@ -418,7 +521,7 @@ export const ImageGrid: React.FC = ({ }; return ( - + {renderColumn(leftColumn, 0)} {renderColumn(rightColumn, 1)} @@ -446,13 +549,15 @@ export const ImageGrid: React.FC = ({ } return ( - setContainerWidth(e.nativeEvent.layout.width)} - > - {renderContent()} - + + setContainerWidth(e.nativeEvent.layout.width)} + > + {renderContent()} + + ); }; @@ -472,6 +577,8 @@ export const CompactImageGrid: React.FC = ({ borderRadius: borderRadiusValue = borderRadius.sm, ...props }) => { + const colors = useAppColors(); + const gridStyles = useMemo(() => createImageGridStyles(colors), [colors]); const containerWidth = maxWidth || SCREEN_WIDTH - DEFAULT_CONTAINER_PADDING - 36 - spacing.sm; // 36是头像宽度 const renderCompactGrid = () => { @@ -488,7 +595,7 @@ export const CompactImageGrid: React.FC = ({ props.onImagePress?.(images, 0)} style={[ - styles.compactItem, + gridStyles.compactItem, { width: size, height: size, @@ -498,7 +605,7 @@ export const CompactImageGrid: React.FC = ({ > @@ -511,13 +618,13 @@ export const CompactImageGrid: React.FC = ({ const { itemSize } = calculateGridDimensions(count, containerWidth, gap, columns); return ( - + {images.slice(0, 6).map((image, index) => ( props.onImagePress?.(images, index)} style={[ - styles.compactItem, + gridStyles.compactItem, { width: itemSize, height: itemSize, @@ -527,13 +634,13 @@ export const CompactImageGrid: React.FC = ({ > {index === 5 && images.length > 6 && ( - - +{images.length - 6} + + +{images.length - 6} )} @@ -542,86 +649,11 @@ export const CompactImageGrid: React.FC = ({ ); }; - return {renderCompactGrid()}; + return ( + + {renderCompactGrid()} + + ); }; -const styles = StyleSheet.create({ - container: { - marginTop: spacing.sm, - }, - fullSize: { - flex: 1, - width: '100%', - height: '100%', - }, - // 单图样式 - singleContainer: { - overflow: 'hidden', - backgroundColor: colors.background.disabled, - }, - // 横向布局样式 - horizontalContainer: { - flexDirection: 'row', - }, - horizontalItem: { - overflow: 'hidden', - backgroundColor: colors.background.disabled, - }, - // 网格布局样式 - gridContainer: { - flexDirection: 'row', - flexWrap: 'wrap', - }, - gridItem: { - overflow: 'hidden', - backgroundColor: colors.background.disabled, - aspectRatio: 1, - }, - gridItem2: { - width: '48%', // 2列布局,每列约48%宽度,留有余量避免换行 - }, - gridItem3: { - width: '31%', // 3列布局,每列约31%宽度,留有余量避免换行 - }, - // 瀑布流样式 - masonryContainer: { - flexDirection: 'row', - }, - masonryColumn: { - flex: 1, - }, - masonryItem: { - overflow: 'hidden', - backgroundColor: colors.background.disabled, - }, - // 更多遮罩 - 类似微博的灰色蒙版 - moreOverlay: { - ...StyleSheet.absoluteFillObject, - backgroundColor: 'rgba(0, 0, 0, 0.4)', - justifyContent: 'center', - alignItems: 'center', - borderRadius: borderRadius.md, - }, - moreText: { - color: colors.text.inverse, - fontSize: fontSizes.xl, - fontWeight: '500', - }, - // 紧凑模式样式 - compactContainer: { - marginTop: spacing.xs, - }, - compactGrid: { - flexDirection: 'row', - flexWrap: 'wrap', - }, - compactItem: { - overflow: 'hidden', - backgroundColor: colors.background.disabled, - }, -}); - -// 导入字体大小 -import { fontSizes } from '../../theme'; - export default ImageGrid; diff --git a/src/components/common/Input.tsx b/src/components/common/Input.tsx index a4f958c..9d8229e 100644 --- a/src/components/common/Input.tsx +++ b/src/components/common/Input.tsx @@ -3,7 +3,7 @@ * 支持标签、错误提示、图标、多行输入等 */ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { View, TextInput, @@ -13,7 +13,7 @@ import { TextStyle, } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { colors, borderRadius, spacing, fontSizes } from '../../theme'; +import { borderRadius, spacing, fontSizes, useAppColors, type AppColors } from '../../theme'; import Text from './Text'; interface InputProps { @@ -36,6 +36,46 @@ interface InputProps { autoCorrect?: boolean; } +function createInputStyles(colors: AppColors) { + return StyleSheet.create({ + wrapper: { + width: '100%', + }, + label: { + marginBottom: spacing.xs, + }, + container: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.background.paper, + borderWidth: 1, + borderRadius: borderRadius.md, + paddingHorizontal: spacing.md, + borderColor: colors.divider, + }, + input: { + flex: 1, + fontSize: fontSizes.md, + color: colors.text.primary, + paddingVertical: spacing.md, + minHeight: 44, + }, + multilineInput: { + textAlignVertical: 'top', + minHeight: 100, + }, + leftIcon: { + marginRight: spacing.sm, + }, + rightIcon: { + marginLeft: spacing.sm, + }, + error: { + marginTop: spacing.xs, + }, + }); +} + const Input: React.FC = ({ value, onChangeText, @@ -55,6 +95,8 @@ const Input: React.FC = ({ keyboardType = 'default', autoCorrect = true, }) => { + const colors = useAppColors(); + const styles = useMemo(() => createInputStyles(colors), [colors]); const [isFocused, setIsFocused] = useState(false); const getBorderColor = () => { @@ -87,11 +129,7 @@ const Input: React.FC = ({ /> )} = ({ autoCapitalize={autoCapitalize} keyboardType={keyboardType} autoCorrect={autoCorrect} - // 确保光标可见 cursorColor={colors.primary.main} selectionColor={`${colors.primary.main}40`} /> {rightIcon && ( - + = ({ ); }; -const styles = StyleSheet.create({ - wrapper: { - width: '100%', - }, - label: { - marginBottom: spacing.xs, - }, - container: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.background.paper, - borderWidth: 1, - borderRadius: borderRadius.md, - paddingHorizontal: spacing.md, - // 减少非焦点状态的边框明显度 - borderColor: colors.divider, - }, - input: { - flex: 1, - fontSize: fontSizes.md, - color: colors.text.primary, - paddingVertical: spacing.md, - minHeight: 44, - }, - multilineInput: { - textAlignVertical: 'top', - minHeight: 100, - }, - leftIcon: { - marginRight: spacing.sm, - }, - rightIcon: { - marginLeft: spacing.sm, - }, - error: { - marginTop: spacing.xs, - }, -}); - export default Input; diff --git a/src/components/common/Loading.tsx b/src/components/common/Loading.tsx index 36a627c..f0caeb5 100644 --- a/src/components/common/Loading.tsx +++ b/src/components/common/Loading.tsx @@ -3,9 +3,9 @@ * 支持不同尺寸、全屏模式 */ -import React from 'react'; +import React, { useMemo } from 'react'; import { View, ActivityIndicator, StyleSheet, ViewStyle } from 'react-native'; -import { colors } from '../../theme'; +import { useAppColors, type AppColors } from '../../theme'; type LoadingSize = 'sm' | 'md' | 'lg'; @@ -16,12 +16,32 @@ interface LoadingProps { style?: ViewStyle; } +function createLoadingStyles(colors: AppColors) { + return StyleSheet.create({ + container: { + padding: 20, + alignItems: 'center', + justifyContent: 'center', + }, + fullScreen: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.background.default, + }, + }); +} + const Loading: React.FC = ({ size = 'md', - color = colors.primary.main, + color, fullScreen = false, style, }) => { + const colors = useAppColors(); + const styles = useMemo(() => createLoadingStyles(colors), [colors]); + const indicatorColor = color ?? colors.primary.main; + const getSize = (): 'small' | 'large' | undefined => { switch (size) { case 'sm': @@ -36,30 +56,16 @@ const Loading: React.FC = ({ if (fullScreen) { return ( - + ); } return ( - + ); }; -const styles = StyleSheet.create({ - container: { - padding: 20, - alignItems: 'center', - justifyContent: 'center', - }, - fullScreen: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.background.default, - }, -}); - export default Loading; diff --git a/src/components/common/SearchBar.tsx b/src/components/common/SearchBar.tsx new file mode 100644 index 0000000..0d66df7 --- /dev/null +++ b/src/components/common/SearchBar.tsx @@ -0,0 +1,137 @@ +/** + * SearchBar 搜索栏组件 + * 用于搜索内容 + */ + +import React, { useMemo, useState } from 'react'; +import { View, TextInput, TouchableOpacity, StyleSheet } from 'react-native'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme'; + +interface SearchBarProps { + value: string; + onChangeText: (text: string) => void; + onSubmit: () => void; + placeholder?: string; + onFocus?: () => void; + onBlur?: () => void; + autoFocus?: boolean; +} + +function createSearchBarStyles(colors: AppColors) { + return StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.background.paper, + borderRadius: borderRadius.full, + paddingHorizontal: spacing.xs, + height: 46, + borderWidth: 1, + borderColor: colors.divider, + shadowColor: 'transparent', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0, + shadowRadius: 0, + elevation: 0, + }, + containerFocused: { + borderColor: colors.primary.main, + shadowColor: 'transparent', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0, + shadowRadius: 0, + elevation: 0, + }, + searchIconWrap: { + width: 30, + height: 30, + marginLeft: spacing.xs, + marginRight: spacing.xs, + borderRadius: borderRadius.full, + backgroundColor: `${colors.text.secondary}12`, + alignItems: 'center', + justifyContent: 'center', + }, + searchIconWrapFocused: { + backgroundColor: `${colors.primary.main}1A`, + }, + input: { + flex: 1, + fontSize: fontSizes.md, + color: colors.text.primary, + paddingVertical: spacing.sm + 1, + paddingHorizontal: spacing.xs, + }, + clearButton: { + width: 24, + height: 24, + marginHorizontal: spacing.xs, + borderRadius: borderRadius.full, + backgroundColor: `${colors.text.secondary}14`, + alignItems: 'center', + justifyContent: 'center', + }, + }); +} + +const SearchBar: React.FC = ({ + value, + onChangeText, + onSubmit, + placeholder = '搜索...', + onFocus, + onBlur, + autoFocus = false, +}) => { + const colors = useAppColors(); + const styles = useMemo(() => createSearchBarStyles(colors), [colors]); + const [isFocused, setIsFocused] = useState(false); + + const handleFocus = () => { + setIsFocused(true); + onFocus?.(); + }; + + const handleBlur = () => { + setIsFocused(false); + onBlur?.(); + }; + + return ( + + + + + + {value.length > 0 && ( + onChangeText('')} + style={styles.clearButton} + activeOpacity={0.7} + > + + + )} + + ); +}; + +export default SearchBar; diff --git a/src/components/common/SmartImage.tsx b/src/components/common/SmartImage.tsx index da7851c..ed315f8 100644 --- a/src/components/common/SmartImage.tsx +++ b/src/components/common/SmartImage.tsx @@ -4,7 +4,7 @@ * 基于 expo-image 封装,原生支持 GIF/WebP 动图 */ -import React, { useState, useCallback, useRef, useEffect } from 'react'; +import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { View, StyleSheet, @@ -13,10 +13,40 @@ import { ActivityIndicator, Pressable, StyleProp, + Platform, } from 'react-native'; import { Image as ExpoImage } from 'expo-image'; import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { colors, borderRadius } from '../../theme'; +import { borderRadius, useAppColors, type AppColors } from '../../theme'; + +function createSmartImageStyles(colors: AppColors) { + return StyleSheet.create({ + image: { + flex: 1, + width: '100%', + height: '100%', + }, + variantImage: { + flex: 1, + }, + overlay: { + ...StyleSheet.absoluteFillObject, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: colors.background.disabled, + }, + loadingContainer: { + padding: 8, + borderRadius: borderRadius.md, + backgroundColor: `${colors.background.paper}E6`, + }, + errorContainer: { + padding: 12, + borderRadius: borderRadius.md, + backgroundColor: `${colors.text.primary}0D`, + }, + }); +} // 图片加载状态 export type ImageLoadState = 'loading' | 'success' | 'error'; @@ -61,6 +91,8 @@ export interface SmartImageProps { usePreview?: boolean; /** 是否懒加载 */ lazyLoad?: boolean; + /** Web 端 IntersectionObserver rootMargin,略提前加载进入视口附近的图 */ + lazyRootMargin?: string; /** 缓存策略 */ cachePolicy?: 'memory' | 'disk' | 'memory-disk' | 'none'; } @@ -85,11 +117,15 @@ export const SmartImage: React.FC = ({ previewUrl, usePreview = false, lazyLoad = false, + lazyRootMargin = '160px', cachePolicy = 'memory-disk', }) => { + const colors = useAppColors(); + const styles = useMemo(() => createSmartImageStyles(colors), [colors]); const [loadState, setLoadState] = useState('loading'); - const [isVisible, setIsVisible] = useState(!lazyLoad); + const [isVisible, setIsVisible] = useState(() => !lazyLoad || Platform.OS !== 'web'); const [shouldLoadOriginal, setShouldLoadOriginal] = useState(false); + const lazyTargetRef = useRef(null); // 解析图片源 - 支持 uri 或 url 字段 const imageUri = typeof source === 'string' @@ -99,6 +135,39 @@ export const SmartImage: React.FC = ({ // 解析预览图 URL const previewUri = typeof previewUrl === 'string' ? previewUrl : ''; + useEffect(() => { + setShouldLoadOriginal(false); + setLoadState('loading'); + }, [imageUri]); + + useEffect(() => { + if (!lazyLoad) { + setIsVisible(true); + return; + } + if (Platform.OS !== 'web') { + setIsVisible(true); + return; + } + setIsVisible(false); + const node = lazyTargetRef.current as unknown as Element | null; + if (!node || typeof IntersectionObserver === 'undefined') { + setIsVisible(true); + return; + } + const observer = new IntersectionObserver( + entries => { + if (entries.some(e => e.isIntersecting)) { + setIsVisible(true); + observer.disconnect(); + } + }, + { rootMargin: lazyRootMargin } + ); + observer.observe(node); + return () => observer.disconnect(); + }, [lazyLoad, lazyRootMargin, imageUri]); + // 智能选择图片 URL const getImageUrl = useCallback((): string => { // 如果应该加载原图(预览图已加载完成或没有预览图) @@ -200,11 +269,13 @@ export const SmartImage: React.FC = ({ ); } - // 如果是懒加载且不可见,显示占位 + // 如果是懒加载且不可见,显示占位(Web 上由 IntersectionObserver 驱动进入视口后再加载) if (lazyLoad && !isVisible) { return ( ); @@ -288,36 +359,15 @@ export const VariantImage: React.FC = ({ ); }; -const styles = StyleSheet.create({ - image: { - flex: 1, - width: '100%', - height: '100%', - }, +const variantStyles = StyleSheet.create({ variantImage: { flex: 1, }, - overlay: { - ...StyleSheet.absoluteFillObject, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: colors.background.disabled, - }, - loadingContainer: { - padding: 8, - borderRadius: borderRadius.md, - backgroundColor: 'rgba(255, 255, 255, 0.9)', - }, - errorContainer: { - padding: 12, - borderRadius: borderRadius.md, - backgroundColor: 'rgba(0, 0, 0, 0.05)', - }, }); export default SmartImage; diff --git a/src/components/common/Text.tsx b/src/components/common/Text.tsx index 81591cc..8870a08 100644 --- a/src/components/common/Text.tsx +++ b/src/components/common/Text.tsx @@ -5,7 +5,7 @@ import React from 'react'; import { Text as RNText, TextProps, StyleSheet, TextStyle, ViewStyle } from 'react-native'; -import { colors, fontSizes } from '../../theme'; +import { useAppColors, fontSizes } from '../../theme'; type TextVariant = 'h1' | 'h2' | 'h3' | 'body' | 'caption' | 'label'; @@ -60,6 +60,7 @@ const Text: React.FC = ({ style, ...props }) => { + const colors = useAppColors(); const textStyle = [ styles.base, variantStyles[variant], diff --git a/src/components/common/index.ts b/src/components/common/index.ts index 57bb5e1..6b737c4 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -16,6 +16,7 @@ export { default as ResponsiveContainer } from './ResponsiveContainer'; export { default as ResponsiveGrid } from './ResponsiveGrid'; export { default as ResponsiveStack, HStack, VStack } from './ResponsiveStack'; export { default as AdaptiveLayout, SidebarLayout } from './AdaptiveLayout'; +export { default as AppBackButton } from './AppBackButton'; // 图片相关组件 export { default as SmartImage } from './SmartImage'; diff --git a/src/core/entities/Post.ts b/src/core/entities/Post.ts new file mode 100644 index 0000000..684d3b3 --- /dev/null +++ b/src/core/entities/Post.ts @@ -0,0 +1,290 @@ +/** + * Post Entity - 帖子领域实体 + * 定义帖子的核心属性和行为,不依赖任何外部框架 + */ + +// ==================== 帖子状态枚举 ==================== + +export type PostStatus = 'published' | 'draft' | 'deleted'; + +// ==================== 值对象 ==================== + +/** + * 帖子作者信息 + */ +export interface PostAuthor { + id: string; + username: string; + nickname?: string; + avatar?: string; + is_following?: boolean; + is_following_me?: boolean; +} + +/** + * 帖子图片信息 + */ +export interface PostImage { + url: string; + width?: number; + height?: number; + thumbnailUrl?: string; +} + +/** + * 帖子标签 + */ +export interface PostTag { + id: string; + name: string; +} + +// ==================== 主实体 ==================== + +/** + * 帖子实体 + */ +export interface Post { + /** 帖子唯一标识 */ + id: string; + /** 作者ID */ + authorId: string; + /** 作者信息 */ + author?: PostAuthor; + /** 帖子标题 */ + title: string; + /** 帖子内容 */ + content: string; + /** 图片列表 */ + images: PostImage[]; + /** 点赞数 (camelCase) */ + likesCount: number; + /** 评论数 (camelCase) */ + commentsCount: number; + /** 分享数 (camelCase) */ + sharesCount: number; + /** 浏览数 (camelCase) */ + viewsCount: number; + /** 收藏数 (camelCase) */ + favoritesCount: number; + /** 当前用户是否已点赞 (camelCase) */ + isLiked: boolean; + /** 当前用户是否已收藏 (camelCase) */ + isFavorited: boolean; + /** 是否置顶 (camelCase) */ + isPinned: boolean; + /** 帖子状态 */ + status: PostStatus; + /** 所属频道ID */ + channelId?: string; + /** 频道摘要(列表 API 填充,供 PostCard 等展示) */ + channel?: { id: string; name: string } | null; + /** 标签列表 */ + tags: string[]; + /** 创建时间 */ + createdAt: string; + /** 更新时间 */ + updatedAt: string; + /** 用户编辑内容的时间(与统计类更新解耦) */ + contentEditedAt?: string; + + // ==================== 向后兼容字段 (snake_case) ==================== + // 这些字段用于与旧代码兼容,新代码应使用 camelCase 版本 + + /** @deprecated 请使用 likesCount */ + likes_count?: number; + /** @deprecated 请使用 commentsCount */ + comments_count?: number; + /** @deprecated 请使用 favoritesCount */ + favorites_count?: number; + /** @deprecated 请使用 sharesCount */ + shares_count?: number; + /** @deprecated 请使用 viewsCount */ + views_count?: number; + /** @deprecated 请使用 isLiked */ + is_liked?: boolean; + /** @deprecated 请使用 isFavorited */ + is_favorited?: boolean; + /** @deprecated 请使用 isPinned */ + is_pinned?: boolean; + /** @deprecated 请使用 authorId */ + user_id?: string; + /** @deprecated 请使用 createdAt */ + created_at?: string; + /** @deprecated 请使用 updatedAt */ + updated_at?: string; + /** @deprecated 请使用 contentEditedAt */ + content_edited_at?: string; + /** @deprecated 请使用 channelId */ + channel_id?: string; + /** @deprecated 请使用 status */ + status_str?: string; + /** 投票帖子标识 (部分旧代码使用) */ + is_vote?: boolean; + /** 锁定状态 (部分旧代码使用) */ + is_locked?: boolean; + /** 置顶评论 (部分旧代码使用) */ + top_comment?: any; +} + +// ==================== 工厂函数 ==================== + +/** + * 创建帖子作者信息 + */ +export const createPostAuthor = (data: Partial): PostAuthor => ({ + id: data.id || '', + username: data.username || '', + nickname: data.nickname, + avatar: data.avatar, + is_following: data.is_following, + is_following_me: data.is_following_me, +}); + +/** + * 创建帖子图片信息 + */ +export const createPostImage = (data: Partial): PostImage => ({ + url: data.url || '', + width: data.width, + height: data.height, + thumbnailUrl: data.thumbnailUrl, +}); + +/** + * 创建帖子实体 + */ +export const createPost = (data: Partial): Post => ({ + id: data.id || '', + authorId: data.authorId || '', + author: data.author, + title: data.title || '', + content: data.content || '', + images: data.images || [], + likesCount: data.likesCount || 0, + commentsCount: data.commentsCount || 0, + sharesCount: data.sharesCount || 0, + viewsCount: data.viewsCount || 0, + favoritesCount: data.favoritesCount || 0, + isLiked: data.isLiked || false, + isFavorited: data.isFavorited || false, + isPinned: data.isPinned || false, + status: data.status || 'published', + channelId: data.channelId, + tags: data.tags || [], + createdAt: data.createdAt || new Date().toISOString(), + updatedAt: data.updatedAt || new Date().toISOString(), +}); + +// ==================== 类型守卫 ==================== + +/** + * 检查是否为有效的帖子状态 + */ +export const isValidPostStatus = (status: string): status is PostStatus => { + return ['published', 'draft', 'deleted'].includes(status); +}; + +/** + * 检查帖子是否已发布 + */ +export const isPostPublished = (post: Post): boolean => { + return post.status === 'published'; +}; + +/** + * 检查帖子是否已删除 + */ +export const isPostDeleted = (post: Post): boolean => { + return post.status === 'deleted'; +}; + +/** + * 检查帖子是否为草稿 + */ +export const isPostDraft = (post: Post): boolean => { + return post.status === 'draft'; +}; + +// ==================== 辅助函数 ==================== + +/** + * 检查当前用户是否为帖子作者 + */ +export const isPostAuthor = (post: Post, userId: string): boolean => { + return post.authorId === userId; +}; + +/** + * 获取帖子的主要图片(第一张图片) + */ +export const getPostThumbnail = (post: Post): PostImage | undefined => { + return post.images[0]; +}; + +/** + * 检查帖子是否包含图片 + */ +export const hasPostImages = (post: Post): boolean => { + return post.images.length > 0; +}; + +/** + * 获取帖子的显示标题(优先使用标题,否则使用内容摘要) + */ +export const getPostDisplayTitle = (post: Post, maxLength: number = 50): string => { + if (post.title) { + return post.title; + } + if (post.content.length > maxLength) { + return post.content.substring(0, maxLength) + '...'; + } + return post.content; +}; + +/** + * 计算帖子的互动总数 + */ +export const getPostTotalEngagement = (post: Post): number => { + return post.likesCount + post.commentsCount + post.sharesCount + post.favoritesCount; +}; + +/** + * 根据创建时间 ISO 字符串格式化为列表用相对时间(与历史 formatPostTime 行为一致) + */ +export const formatPostCreatedAtString = (createdAt: string | undefined | null): string => { + if (!createdAt) return ''; + const createdAtDate = new Date(createdAt); + if (Number.isNaN(createdAtDate.getTime())) return ''; + + const now = new Date(); + const diffMs = now.getTime() - createdAtDate.getTime(); + if (diffMs < 0) { + return createdAtDate.toLocaleDateString('zh-CN'); + } + const diffSeconds = Math.floor(diffMs / 1000); + const diffMinutes = Math.floor(diffSeconds / 60); + const diffHours = Math.floor(diffMinutes / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffSeconds < 60) { + return '刚刚'; + } + if (diffMinutes < 60) { + return `${diffMinutes}分钟前`; + } + if (diffHours < 24) { + return `${diffHours}小时前`; + } + if (diffDays < 7) { + return `${diffDays}天前`; + } + return createdAtDate.toLocaleDateString('zh-CN'); +}; + +/** + * 格式化帖子创建时间为相对时间描述 + */ +export const formatPostTime = (post: Post): string => { + return formatPostCreatedAtString(post.createdAt); +}; \ No newline at end of file diff --git a/src/core/usecases/ProcessPostUseCase.ts b/src/core/usecases/ProcessPostUseCase.ts new file mode 100644 index 0000000..bb42fa4 --- /dev/null +++ b/src/core/usecases/ProcessPostUseCase.ts @@ -0,0 +1,1036 @@ +/** + * ProcessPostUseCase - 处理帖子用例 + * 处理帖子的业务逻辑,协调 Repository 和状态管理 + * 纯业务逻辑,无UI依赖 + */ + +import { postRepository } from '../../data/repositories/PostRepository'; +import type { Post } from '../entities/Post'; +import type { + GetPostsParams, + CreatePostData, + UpdatePostData, + PostsResult, + SearchPostsParams, +} from '../../data/repositories/interfaces/IPostRepository'; +import { PaginationStateManager } from '../../infrastructure/pagination/PaginationStateManager'; +import type { PaginationState } from '../../infrastructure/pagination/types'; + +// ==================== 事件类型定义 ==================== + +/** + * 帖子用例事件类型 + */ +export type PostUseCaseEventType = + | 'posts_loaded' + | 'post_created' + | 'post_updated' + | 'post_deleted' + | 'post_liked' + | 'post_unliked' + | 'post_favorited' + | 'post_unfavorited' + | 'loading_changed' + | 'error_occurred' + | 'state_changed'; + +/** + * 帖子用例事件 + */ +export interface PostUseCaseEvent { + type: PostUseCaseEventType; + payload: any; + timestamp: number; +} + +/** + * 事件订阅者类型 + */ +export type PostUseCaseSubscriber = (event: PostUseCaseEvent) => void; + +// ==================== 状态类型定义 ==================== + +/** + * 帖子列表状态 + */ +export interface PostsState { + /** 帖子列表 */ + posts: Post[]; + /** 是否正在加载 */ + isLoading: boolean; + /** 是否正在刷新 */ + isRefreshing: boolean; + /** 是否有更多数据 */ + hasMore: boolean; + /** 错误信息 */ + error: string | null; + /** 当前游标(cursor 模式) */ + cursor: string | null; + /** 当前页码(page 模式) */ + currentPage: number; + /** 最后加载时间 */ + lastLoadTime: number | null; + /** 上次请求的参数(用于加载更多和刷新) */ + lastParams?: GetPostsParams; +} + +/** + * 帖子详情状态 + */ +export interface PostDetailState { + /** 帖子详情 */ + post: Post | null; + /** 是否正在加载 */ + isLoading: boolean; + /** 错误信息 */ + error: string | null; +} + +// ==================== 默认配置 ==================== + +const DEFAULT_PAGE_SIZE = 20; + +// ==================== ProcessPostUseCase 类 ==================== + +class ProcessPostUseCase { + // 单例实例 + private static instance: ProcessPostUseCase; + + // 订阅者管理 + private subscribers: Set = new Set(); + + // 状态管理 + private postsState: Map = new Map(); + private postDetailState: Map = new Map(); + + // 分页状态管理器 + private paginationManager: PaginationStateManager; + + // 当前用户ID + private currentUserId: string | null = null; + + private readonly mergePerfWarnThresholdMs = 12; + + // 私有构造函数 + private constructor() { + this.paginationManager = new PaginationStateManager(); + } + + /** + * 获取单例实例 + */ + static getInstance(): ProcessPostUseCase { + if (!ProcessPostUseCase.instance) { + ProcessPostUseCase.instance = new ProcessPostUseCase(); + } + return ProcessPostUseCase.instance; + } + + // ==================== 初始化和销毁 ==================== + + /** + * 初始化用例 + * @param currentUserId 当前用户ID + */ + initialize(currentUserId: string | null = null): void { + this.currentUserId = currentUserId; + } + + /** + * 销毁用例 + */ + destroy(): void { + this.subscribers.clear(); + this.postsState.clear(); + this.postDetailState.clear(); + this.paginationManager.clear(); + this.currentUserId = null; + } + + // ==================== 订阅机制 ==================== + + /** + * 订阅事件 + * @param subscriber 订阅者函数 + * @returns 取消订阅函数 + */ + subscribe(subscriber: PostUseCaseSubscriber): () => void { + this.subscribers.add(subscriber); + return () => { + this.subscribers.delete(subscriber); + }; + } + + /** + * 通知所有订阅者 + * @param event 事件对象 + */ + private notifySubscribers(event: PostUseCaseEvent): void { + this.subscribers.forEach((subscriber) => { + try { + subscriber(event); + } catch (error) { + console.error('[ProcessPostUseCase] 订阅者执行失败:', error); + } + }); + } + + /** + * 通知状态变化 + * @param key 列表键 + */ + private notifyStateChanged(key: string): void { + const state = this.getPostsState(key); + this.notifySubscribers({ + type: 'state_changed', + payload: { key, state }, + timestamp: Date.now(), + }); + } + + // ==================== 状态管理 ==================== + + /** + * 获取帖子列表状态 + * @param key 列表键(默认为 'default') + */ + getPostsState(key: string = 'default'): PostsState { + let state = this.postsState.get(key); + if (!state) { + state = { + posts: [], + isLoading: false, + isRefreshing: false, + hasMore: true, + error: null, + cursor: null, + currentPage: 1, + lastLoadTime: null, + }; + this.postsState.set(key, state); + } + return state; + } + + /** + * 获取帖子详情状态 + * @param postId 帖子ID + */ + getPostDetailState(postId: string): PostDetailState { + let state = this.postDetailState.get(postId); + if (!state) { + state = { + post: null, + isLoading: false, + error: null, + }; + this.postDetailState.set(postId, state); + } + return state; + } + + /** + * 更新帖子列表状态 + * @param key 列表键 + * @param partialState 部分状态 + */ + private updatePostsState(key: string, partialState: Partial): void { + const currentState = this.getPostsState(key); + this.postsState.set(key, { + ...currentState, + ...partialState, + }); + this.notifyStateChanged(key); + } + + private getEntityId(item: unknown): string | null { + if (!item || typeof item !== 'object') { + return null; + } + const maybeId = (item as { id?: unknown }).id; + if (typeof maybeId === 'string' || typeof maybeId === 'number') { + return String(maybeId); + } + return null; + } + + private mergePostListWithFastPath(previousInput: Post[] | null | undefined, incomingInput: Post[] | null | undefined): Post[] { + const previous = Array.isArray(previousInput) ? previousInput : []; + const incoming = Array.isArray(incomingInput) ? incomingInput : []; + if (incoming.length === 0) { + return previous; + } + if (previous.length === 0) { + return incoming; + } + + const lastPrevId = this.getEntityId(previous[previous.length - 1]); + const firstIncomingId = this.getEntityId(incoming[0]); + if (lastPrevId && firstIncomingId && lastPrevId !== firstIncomingId) { + const prevIdSet = new Set(previous.map((item) => this.getEntityId(item)).filter(Boolean) as string[]); + if (!prevIdSet.has(firstIncomingId)) { + return [...previous, ...incoming]; + } + } + + const merged = [...previous]; + const indexById = new Map(); + for (let i = 0; i < merged.length; i += 1) { + const id = this.getEntityId(merged[i]); + if (id) { + indexById.set(id, i); + } + } + + for (const post of incoming) { + const id = this.getEntityId(post); + if (!id) { + merged.push(post); + continue; + } + const existingIndex = indexById.get(id); + if (existingIndex === undefined) { + indexById.set(id, merged.length); + merged.push(post); + } else { + merged[existingIndex] = post; + } + } + + return merged; + } + + private mergeRefreshWindow(previousInput: Post[] | null | undefined, latestInput: Post[] | null | undefined): Post[] { + const previous = Array.isArray(previousInput) ? previousInput : []; + const latest = Array.isArray(latestInput) ? latestInput : []; + if (latest.length === 0) { + return previous; + } + if (previous.length === 0) { + return latest; + } + + const latestIdSet = new Set( + latest.map((item) => this.getEntityId(item)).filter(Boolean) as string[] + ); + const remaining = previous.filter((item) => { + const id = this.getEntityId(item); + return !id || !latestIdSet.has(id); + }); + return [...latest, ...remaining]; + } + + /** + * 更新帖子详情状态 + * @param postId 帖子ID + * @param partialState 部分状态 + */ + private updatePostDetailState(postId: string, partialState: Partial): void { + const currentState = this.getPostDetailState(postId); + this.postDetailState.set(postId, { + ...currentState, + ...partialState, + }); + } + + /** + * 分享计数上报成功后,同步各列表与详情中的分享数 + */ + applyShareCountUpdate(postId: string, sharesCount: number): void { + this.postsState.forEach((state, key) => { + const index = state.posts.findIndex((p) => p.id === postId); + if (index !== -1) { + const newPosts = [...state.posts]; + const cur = newPosts[index]; + newPosts[index] = { + ...cur, + sharesCount, + shares_count: sharesCount, + }; + this.updatePostsState(key, { posts: newPosts }); + } + }); + const detail = this.getPostDetailState(postId); + if (detail.post) { + this.updatePostDetailState(postId, { + post: { + ...detail.post, + sharesCount, + shares_count: sharesCount, + }, + }); + } + } + + // ==================== 帖子列表操作 ==================== + + /** + * 获取帖子列表 + * @param params 查询参数 + * @param key 列表键(用于区分不同的列表) + */ + async fetchPosts(params?: GetPostsParams, key: string = 'default'): Promise { + // 更新加载状态 + this.updatePostsState(key, { isLoading: true, error: null }); + + try { + // 默认传递空字符串 cursor 来启动 cursor 模式 + // 注意:cursor 要放在 ...params 后面,这样只有在 params 没有传 cursor 时才使用默认值 + const queryParams: GetPostsParams = { + pageSize: params?.pageSize || DEFAULT_PAGE_SIZE, + ...params, + cursor: params?.cursor !== undefined ? params.cursor : '', // 默认使用空字符串启动 cursor 模式 + }; + + const result = await postRepository.getPosts(queryParams); + + // 判断是否为 cursor 模式(有 next_cursor 返回) + const isCursorMode = result.nextCursor !== undefined && result.nextCursor !== null; + + // 更新状态(保存请求参数以便加载更多时使用) + const mergeStart = Date.now(); + const currentState = this.getPostsState(key); + const incomingPosts = Array.isArray(result.posts) ? result.posts : []; + const mergedPosts = this.mergeRefreshWindow(currentState.posts, incomingPosts); + const mergeCost = Date.now() - mergeStart; + if (mergeCost >= this.mergePerfWarnThresholdMs) { + console.log('[PostPerf] fetchPosts merge cost', { key, costMs: mergeCost, prev: currentState.posts.length, next: incomingPosts.length }); + } + + this.updatePostsState(key, { + posts: mergedPosts, + hasMore: result.hasMore, + cursor: isCursorMode ? result.nextCursor : null, // cursor 模式保存 cursor,否则设为 null 表示 page 模式 + currentPage: isCursorMode ? undefined : 1, // page 模式从第 1 页开始 + isLoading: false, + lastLoadTime: Date.now(), + lastParams: params, // 保存请求参数 + }); + + // 通知订阅者 + this.notifySubscribers({ + type: 'posts_loaded', + payload: { key, posts: incomingPosts, hasMore: result.hasMore }, + timestamp: Date.now(), + }); + + return result; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '获取帖子列表失败'; + this.updatePostsState(key, { + isLoading: false, + error: errorMessage, + }); + + this.notifySubscribers({ + type: 'error_occurred', + payload: { key, error: errorMessage }, + timestamp: Date.now(), + }); + + throw error; + } + } + + /** + * 刷新帖子列表 + * @param key 列表键 + */ + async refreshPosts(key: string = 'default'): Promise { + const state = this.getPostsState(key); + + // 更新刷新状态 + this.updatePostsState(key, { isRefreshing: true, error: null }); + + try { + // 使用保存的请求参数(如 post_type)进行刷新 + // 默认传递空字符串 cursor 来启动 cursor 模式 + const result = await postRepository.getPosts({ + pageSize: DEFAULT_PAGE_SIZE, + ...state.lastParams, + cursor: state.lastParams?.cursor !== undefined ? state.lastParams.cursor : '', // 默认使用空字符串启动 cursor 模式 + }); + + // 判断是否为 cursor 模式(有 next_cursor 返回) + const isCursorMode = result.nextCursor !== undefined && result.nextCursor !== null; + + // 更新状态 + const mergeStart = Date.now(); + const incomingPosts = Array.isArray(result.posts) ? result.posts : []; + const mergedPosts = this.mergeRefreshWindow(state.posts, incomingPosts); + const mergeCost = Date.now() - mergeStart; + if (mergeCost >= this.mergePerfWarnThresholdMs) { + console.log('[PostPerf] refreshPosts merge cost', { key, costMs: mergeCost, prev: state.posts.length, next: incomingPosts.length }); + } + + this.updatePostsState(key, { + posts: mergedPosts, + hasMore: result.hasMore, + cursor: isCursorMode ? result.nextCursor : null, + currentPage: isCursorMode ? undefined : 1, + isRefreshing: false, + lastLoadTime: Date.now(), + }); + + // 重置分页状态 + this.paginationManager.reset(key); + + return result; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '刷新帖子列表失败'; + this.updatePostsState(key, { + isRefreshing: false, + error: errorMessage, + }); + + throw error; + } + } + + /** + * 加载更多帖子 + * @param key 列表键 + */ + async loadMorePosts(key: string = 'default'): Promise { + const state = this.getPostsState(key); + + // 检查是否还有更多数据 + if (!state.hasMore) { + return { + posts: [], + hasMore: false, + }; + } + + // 检查是否正在加载 + if (state.isLoading) { + return { + posts: [], + hasMore: state.hasMore, + }; + } + + // 更新加载状态 + this.updatePostsState(key, { isLoading: true, error: null }); + + try { + // 判断使用哪种分页模式:cursor 不为 null 表示使用 cursor 模式 + const useCursorMode = state.cursor !== null && state.cursor !== undefined; + + // 必须先展开 lastParams,再由本次分页字段覆盖。 + // lastParams 来自首次 fetch(通常含 page:1),若写在展开之前会把 nextPage/cursor 覆盖掉,导致永远请求第 1 页、无法加载第 3 页及以后。 + const base = { ...(state.lastParams ?? {}) } as GetPostsParams; + let queryParams: GetPostsParams; + + if (useCursorMode) { + delete base.page; + queryParams = { + ...base, + pageSize: DEFAULT_PAGE_SIZE, + cursor: state.cursor!, + }; + } else { + const nextPage = (state.currentPage || 1) + 1; + delete base.cursor; + queryParams = { + ...base, + pageSize: DEFAULT_PAGE_SIZE, + page: nextPage, + }; + } + + const result = await postRepository.getPosts(queryParams); + + // 合并新数据 + const mergeStart = Date.now(); + const incomingPosts = Array.isArray(result.posts) ? result.posts : []; + const newPosts = this.mergePostListWithFastPath(state.posts, incomingPosts); + const mergeCost = Date.now() - mergeStart; + if (mergeCost >= this.mergePerfWarnThresholdMs) { + console.log('[PostPerf] loadMore merge cost', { key, costMs: mergeCost, prev: state.posts.length, incoming: incomingPosts.length, merged: newPosts.length }); + } + + // 判断响应是否为 cursor 模式(有 next_cursor 返回) + const responseIsCursorMode = result.nextCursor !== undefined && result.nextCursor !== null; + + // 更新状态 + this.updatePostsState(key, { + posts: newPosts, + hasMore: result.hasMore, + cursor: responseIsCursorMode ? result.nextCursor : null, + currentPage: useCursorMode ? state.currentPage : ((state.currentPage || 1) + 1), + isLoading: false, + lastLoadTime: Date.now(), + }); + + return { + posts: incomingPosts, + hasMore: result.hasMore, + nextCursor: result.nextCursor, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '加载更多帖子失败'; + console.error('[ProcessPostUseCase] Load more error:', errorMessage); + this.updatePostsState(key, { + isLoading: false, + error: errorMessage, + }); + + throw error; + } + } + + // ==================== 帖子详情操作 ==================== + + /** + * 获取单个帖子详情 + * @param id 帖子ID + */ + async fetchPostById(id: string): Promise { + // 更新加载状态 + this.updatePostDetailState(id, { isLoading: true, error: null }); + + try { + const post = await postRepository.getPostById(id); + + // 更新状态 + this.updatePostDetailState(id, { + post, + isLoading: false, + }); + + return post; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '获取帖子详情失败'; + this.updatePostDetailState(id, { + isLoading: false, + error: errorMessage, + }); + + throw error; + } + } + + // ==================== 帖子CRUD操作 ==================== + + /** + * 创建帖子 + * @param data 创建数据 + */ + async createPost(data: CreatePostData): Promise { + try { + const post = await postRepository.createPost(data); + + // 通知订阅者 + this.notifySubscribers({ + type: 'post_created', + payload: { post }, + timestamp: Date.now(), + }); + + // 更新所有列表(新帖子应该出现在列表顶部) + this.postsState.forEach((state, key) => { + this.updatePostsState(key, { + posts: [post, ...state.posts], + }); + }); + + return post; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '创建帖子失败'; + this.notifySubscribers({ + type: 'error_occurred', + payload: { operation: 'create', error: errorMessage }, + timestamp: Date.now(), + }); + throw error; + } + } + + /** + * 更新帖子 + * @param id 帖子ID + * @param data 更新数据 + */ + async updatePost(id: string, data: UpdatePostData): Promise { + try { + const post = await postRepository.updatePost(id, data); + + // 更新详情状态 + this.updatePostDetailState(id, { post }); + + // 更新列表中的帖子 + this.updatePostInLists(id, post); + + // 通知订阅者 + this.notifySubscribers({ + type: 'post_updated', + payload: { postId: id, post }, + timestamp: Date.now(), + }); + + return post; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '更新帖子失败'; + this.notifySubscribers({ + type: 'error_occurred', + payload: { operation: 'update', postId: id, error: errorMessage }, + timestamp: Date.now(), + }); + throw error; + } + } + + /** + * 删除帖子 + * @param id 帖子ID + */ + async deletePost(id: string): Promise { + try { + await postRepository.deletePost(id); + + // 从详情状态中移除 + this.postDetailState.delete(id); + + // 从所有列表中移除 + this.postsState.forEach((state, key) => { + const filteredPosts = state.posts.filter((post) => post.id !== id); + this.updatePostsState(key, { + posts: filteredPosts, + }); + }); + + // 通知订阅者 + this.notifySubscribers({ + type: 'post_deleted', + payload: { postId: id }, + timestamp: Date.now(), + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '删除帖子失败'; + this.notifySubscribers({ + type: 'error_occurred', + payload: { operation: 'delete', postId: id, error: errorMessage }, + timestamp: Date.now(), + }); + throw error; + } + } + + // ==================== 互动操作 ==================== + + /** + * 点赞帖子 + * @param id 帖子ID + */ + async likePost(id: string): Promise { + try { + const post = await postRepository.likePost(id); + + // 更新详情状态 + this.updatePostDetailState(id, { post }); + + // 更新列表中的帖子 + this.updatePostInLists(id, post); + + // 通知订阅者 + this.notifySubscribers({ + type: 'post_liked', + payload: { postId: id, post }, + timestamp: Date.now(), + }); + + return post; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '点赞失败'; + this.notifySubscribers({ + type: 'error_occurred', + payload: { operation: 'like', postId: id, error: errorMessage }, + timestamp: Date.now(), + }); + throw error; + } + } + + /** + * 取消点赞 + * @param id 帖子ID + */ + async unlikePost(id: string): Promise { + try { + const post = await postRepository.unlikePost(id); + + // 更新详情状态 + this.updatePostDetailState(id, { post }); + + // 更新列表中的帖子 + this.updatePostInLists(id, post); + + // 通知订阅者 + this.notifySubscribers({ + type: 'post_unliked', + payload: { postId: id, post }, + timestamp: Date.now(), + }); + + return post; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '取消点赞失败'; + this.notifySubscribers({ + type: 'error_occurred', + payload: { operation: 'unlike', postId: id, error: errorMessage }, + timestamp: Date.now(), + }); + throw error; + } + } + + /** + * 收藏帖子 + * @param id 帖子ID + */ + async favoritePost(id: string): Promise { + try { + const post = await postRepository.favoritePost(id); + + // 更新详情状态 + this.updatePostDetailState(id, { post }); + + // 更新列表中的帖子 + this.updatePostInLists(id, post); + + // 通知订阅者 + this.notifySubscribers({ + type: 'post_favorited', + payload: { postId: id, post }, + timestamp: Date.now(), + }); + + return post; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '收藏失败'; + this.notifySubscribers({ + type: 'error_occurred', + payload: { operation: 'favorite', postId: id, error: errorMessage }, + timestamp: Date.now(), + }); + throw error; + } + } + + /** + * 取消收藏 + * @param id 帖子ID + */ + async unfavoritePost(id: string): Promise { + try { + const post = await postRepository.unfavoritePost(id); + + // 更新详情状态 + this.updatePostDetailState(id, { post }); + + // 更新列表中的帖子 + this.updatePostInLists(id, post); + + // 通知订阅者 + this.notifySubscribers({ + type: 'post_unfavorited', + payload: { postId: id, post }, + timestamp: Date.now(), + }); + + return post; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '取消收藏失败'; + this.notifySubscribers({ + type: 'error_occurred', + payload: { operation: 'unfavorite', postId: id, error: errorMessage }, + timestamp: Date.now(), + }); + throw error; + } + } + + // ==================== 搜索操作 ==================== + + /** + * 搜索帖子 + * @param params 搜索参数 + * @param key 列表键 + */ + async searchPosts(params: SearchPostsParams, key: string = 'search'): Promise { + // 更新加载状态 + this.updatePostsState(key, { isLoading: true, error: null }); + + try { + const result = await postRepository.searchPosts(params); + + // 更新状态 + this.updatePostsState(key, { + posts: result.posts, + hasMore: result.hasMore, + cursor: result.nextCursor || null, + isLoading: false, + lastLoadTime: Date.now(), + }); + + return result; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '搜索帖子失败'; + this.updatePostsState(key, { + isLoading: false, + error: errorMessage, + }); + + throw error; + } + } + + // ==================== 用户帖子操作 ==================== + + /** + * 获取用户发布的帖子 + * @param userId 用户ID + * @param params 查询参数 + * @param key 列表键 + */ + async fetchPostsByUser( + userId: string, + params?: GetPostsParams, + key?: string + ): Promise { + const listKey = key || `user_${userId}`; + + // 更新加载状态 + this.updatePostsState(listKey, { isLoading: true, error: null }); + + try { + const result = await postRepository.getPostsByUser(userId, params); + + // 更新状态 + this.updatePostsState(listKey, { + posts: result.posts, + hasMore: result.hasMore, + cursor: result.nextCursor || null, + isLoading: false, + lastLoadTime: Date.now(), + }); + + return result; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '获取用户帖子失败'; + this.updatePostsState(listKey, { + isLoading: false, + error: errorMessage, + }); + + throw error; + } + } + + // ==================== 辅助方法 ==================== + + /** + * 更新所有列表中的帖子 + * @param postId 帖子ID + * @param updatedPost 更新后的帖子 + */ + private updatePostInLists(postId: string, updatedPost: Post): void { + this.postsState.forEach((state, key) => { + const index = state.posts.findIndex((post) => post.id === postId); + if (index !== -1) { + const newPosts = [...state.posts]; + newPosts[index] = updatedPost; + this.updatePostsState(key, { posts: newPosts }); + } + }); + } + + /** + * 清除指定列表的状态 + * @param key 列表键 + */ + clearPostsState(key: string = 'default'): void { + this.postsState.delete(key); + this.paginationManager.reset(key); + } + + /** + * 清除帖子详情状态 + * @param postId 帖子ID + */ + clearPostDetailState(postId: string): void { + this.postDetailState.delete(postId); + } + + /** + * 清除所有状态 + */ + clearAllStates(): void { + this.postsState.clear(); + this.postDetailState.clear(); + this.paginationManager.clear(); + } + + /** + * 获取当前用户ID + */ + getCurrentUserId(): string | null { + return this.currentUserId; + } + + /** + * 设置当前用户ID + */ + setCurrentUserId(userId: string | null): void { + this.currentUserId = userId; + } + + // ==================== 分页状态管理集成 ==================== + + /** + * 获取分页状态 + * @param key 列表键 + */ + getPaginationState(key: string): PaginationState { + return this.paginationManager.getState(key); + } + + /** + * 重置分页状态 + * @param key 列表键 + */ + resetPagination(key: string): void { + this.paginationManager.reset(key); + } + + /** + * 检查是否正在加载 + * @param key 列表键 + */ + isLoading(key: string = 'default'): boolean { + return this.getPostsState(key).isLoading; + } + + /** + * 检查是否有错误 + * @param key 列表键 + */ + hasError(key: string = 'default'): boolean { + return this.getPostsState(key).error !== null; + } + + /** + * 获取错误信息 + * @param key 列表键 + */ + getError(key: string = 'default'): string | null { + return this.getPostsState(key).error; + } +} + +// ==================== 导出 ==================== + +// 导出单例实例 +export const processPostUseCase = ProcessPostUseCase.getInstance(); +export default processPostUseCase; \ No newline at end of file diff --git a/src/data/datasources/ApiDataSource.ts b/src/data/datasources/ApiDataSource.ts index f097e94..07009d0 100644 --- a/src/data/datasources/ApiDataSource.ts +++ b/src/data/datasources/ApiDataSource.ts @@ -72,12 +72,7 @@ export class ApiDataSource implements IApiDataSource { async delete(url: string, data?: any): Promise { try { const fullUrl = this.buildUrl(url); - // 处理带 request body 的 DELETE 请求 - if (data) { - const response = await api.request('DELETE', fullUrl, undefined, data); - return response.data; - } - const response = await api.delete(fullUrl); + const response = await api.delete(fullUrl, data); return response.data; } catch (error) { this.handleError(error, 'DELETE'); diff --git a/src/data/datasources/LocalDataSource.ts b/src/data/datasources/LocalDataSource.ts index 90ddc98..a0b5e1e 100644 --- a/src/data/datasources/LocalDataSource.ts +++ b/src/data/datasources/LocalDataSource.ts @@ -20,6 +20,7 @@ export class LocalDataSource implements ILocalDataSource { private db: SQLite.SQLiteDatabase | null = null; private dbName: string; private initialized = false; + private initializingPromise: Promise | null = null; constructor(config: LocalDataSourceConfig = {}) { // 如果提供了userId,使用用户专属数据库 @@ -34,6 +35,21 @@ export class LocalDataSource implements ILocalDataSource { return; } + // 防止并发初始化导致连接状态竞争 + if (this.initializingPromise) { + await this.initializingPromise; + return; + } + + this.initializingPromise = this.doInitialize(); + try { + await this.initializingPromise; + } finally { + this.initializingPromise = null; + } + } + + private async doInitialize(): Promise { try { // 使用全局实例管理 if (dbInstance && currentDbName === this.dbName) { @@ -132,6 +148,12 @@ export class LocalDataSource implements ILocalDataSource { updatedAt TEXT NOT NULL, PRIMARY KEY (groupId, userId) )`, + // 帖子缓存表 + `CREATE TABLE IF NOT EXISTS posts_cache ( + id TEXT PRIMARY KEY NOT NULL, + data TEXT NOT NULL, + updatedAt TEXT NOT NULL + )`, ]; for (const sql of tables) { @@ -183,7 +205,10 @@ export class LocalDataSource implements ILocalDataSource { async query(sql: string, params?: any[]): Promise { try { const db = this.ensureDb(); - return await db.getAllAsync(sql, params); + if (params && params.length > 0) { + return await db.getAllAsync(sql, params as any); + } + return await db.getAllAsync(sql); } catch (error) { this.handleError(error, 'QUERY'); } @@ -192,7 +217,10 @@ export class LocalDataSource implements ILocalDataSource { async getFirst(sql: string, params?: any[]): Promise { try { const db = this.ensureDb(); - return await db.getFirstAsync(sql, params); + if (params && params.length > 0) { + return await db.getFirstAsync(sql, params as any); + } + return await db.getFirstAsync(sql); } catch (error) { this.handleError(error, 'GET_FIRST'); } @@ -210,8 +238,22 @@ export class LocalDataSource implements ILocalDataSource { async run(sql: string, params?: any[]): Promise<{ lastInsertRowId: number; changes: number }> { try { const db = this.ensureDb(); - return await db.runAsync(sql, params); + if (params && params.length > 0) { + return await db.runAsync(sql, params as any); + } + return await db.runAsync(sql); } catch (error) { + if (this.isRecoverableError(error)) { + this.db = null; + this.initialized = false; + dbInstance = null; + await this.initialize(); + const db = this.ensureDb(); + if (params && params.length > 0) { + return await db.runAsync(sql, params as any); + } + return await db.runAsync(sql); + } this.handleError(error, 'RUN'); } } diff --git a/src/data/mappers/ConversationMapper.ts b/src/data/mappers/ConversationMapper.ts index 4eaaca0..98246d9 100644 --- a/src/data/mappers/ConversationMapper.ts +++ b/src/data/mappers/ConversationMapper.ts @@ -212,7 +212,7 @@ export class ConversationMapper { id: String(user.id || ''), username: user.username || '', nickname: user.nickname, - avatar: user.avatar, + avatar: user.avatar ?? undefined, }; } @@ -220,19 +220,31 @@ export class ConversationMapper { * 映射群组 API 响应 */ private static mapGroupFromApi(group: GroupResponse): GroupModel { + // 将数字类型的 join_type 转换为字符串类型 + // JoinType: 0 = 允许任何人, 1 = 需要审批, 2 = 不允许 + const mapJoinType = (joinType: number): 'anyone' | 'approval' | 'invite' => { + switch (joinType) { + case 0: return 'anyone'; + case 1: return 'approval'; + case 2: return 'invite'; + default: return 'approval'; + } + }; + return { id: String(group.id || ''), name: group.name || '', avatar: group.avatar, description: group.description, - announcement: group.announcement, + // GroupResponse 没有 announcement 和 updated_at 字段,使用默认值 + announcement: undefined, ownerId: String(group.owner_id || ''), memberCount: group.member_count || 0, - maxMemberCount: group.max_member_count || 500, - joinType: group.join_type || 'approval', + maxMemberCount: group.max_members || 500, + joinType: mapJoinType(group.join_type), isMuted: group.mute_all || false, createdAt: new Date(group.created_at || Date.now()), - updatedAt: new Date(group.updated_at || Date.now()), + updatedAt: new Date(group.created_at || Date.now()), }; } diff --git a/src/data/mappers/MessageMapper.ts b/src/data/mappers/MessageMapper.ts index d6e99ea..44a2742 100644 --- a/src/data/mappers/MessageMapper.ts +++ b/src/data/mappers/MessageMapper.ts @@ -32,13 +32,16 @@ export class MessageMapper { * 将 API 响应转换为应用模型 */ static fromApiResponse(response: MessageResponse): MessageModel { + const textSegment = response.segments?.find(s => s.type === 'text'); + const content = textSegment?.data?.text || textSegment?.data?.content || ''; + return { id: String(response.id || ''), conversationId: String(response.conversation_id || ''), senderId: String(response.sender_id || ''), - content: response.content || '', - type: response.message_type || 'text', - isRead: response.is_read || false, + content, + type: 'text', + isRead: false, createdAt: new Date(response.created_at || Date.now()), seq: response.seq || 0, status: response.status || 'normal', @@ -169,15 +172,12 @@ export class MessageMapper { } } - /** - * 映射发送者信息 - */ private static mapSenderFromApi(sender: UserDTO): UserModel { return { id: String(sender.id || ''), username: sender.username || '', nickname: sender.nickname, - avatar: sender.avatar, + avatar: sender.avatar || undefined, }; } } diff --git a/src/data/mappers/PostMapper.ts b/src/data/mappers/PostMapper.ts index 172a6ac..cac4a74 100644 --- a/src/data/mappers/PostMapper.ts +++ b/src/data/mappers/PostMapper.ts @@ -4,40 +4,46 @@ */ import { PostModel, UserModel } from '../models'; -import type { Post } from '../../types'; +import type { PostDTO } from '../../types/dto'; export class PostMapper { - /** - * 将 API 响应转换为应用模型 - */ - static fromApiResponse(response: Post): PostModel { + static fromApiResponse(response: PostDTO): PostModel { return { id: String(response.id || ''), - authorId: String(response.author_id || ''), + authorId: String(response.user_id || ''), author: response.author ? this.mapAuthorFromApi(response.author) : undefined, title: response.title || '', content: response.content || '', - images: response.images || [], - likeCount: response.like_count || 0, - commentCount: response.comment_count || 0, - shareCount: response.share_count || 0, - viewCount: response.view_count || 0, - favoriteCount: response.favorite_count || 0, + images: response.images?.map(img => img.url) || [], + likeCount: response.likes_count || 0, + commentCount: response.comments_count || 0, + shareCount: response.shares_count || 0, + viewCount: response.views_count || 0, + favoriteCount: response.favorites_count || 0, isLiked: response.is_liked || false, isFavorited: response.is_favorited || false, - isTop: response.is_top || false, - status: response.status || 'published', - communityId: response.community_id, - tags: response.tags || [], + isTop: response.is_pinned || false, + status: (response.status || 'published') as 'published' | 'draft' | 'deleted', + channelId: response.channel_id, + channel: + response.channel && response.channel.id + ? { id: String(response.channel.id), name: String(response.channel.name || '') } + : undefined, + tags: [], createdAt: new Date(response.created_at || Date.now()), - updatedAt: new Date(response.updated_at || Date.now()), + // 空字符串/缺省时用 created_at,避免误用 Date.now() 导致全部显示「刚修改」 + updatedAt: new Date( + response.updated_at || + response.created_at || + Date.now() + ), }; } /** * 将 API 响应数组转换为应用模型数组 */ - static fromApiResponseList(responses: Post[]): PostModel[] { + static fromApiResponseList(responses: PostDTO[]): PostModel[] { return responses.map(r => this.fromApiResponse(r)); } @@ -56,8 +62,8 @@ export class PostMapper { if (model.images !== undefined) { request.images = model.images; } - if (model.communityId !== undefined) { - request.community_id = model.communityId; + if (model.channelId !== undefined) { + request.channel_id = model.channelId; } if (model.tags !== undefined) { request.tags = model.tags; @@ -73,7 +79,7 @@ export class PostMapper { title: string, content: string, images?: string[], - communityId?: string + channelId?: string ): Record { const request: Record = { title, @@ -82,8 +88,8 @@ export class PostMapper { if (images && images.length > 0) { request.images = images; } - if (communityId) { - request.community_id = communityId; + if (channelId) { + request.channel_id = channelId; } return request; } diff --git a/src/data/mappers/UserMapper.ts b/src/data/mappers/UserMapper.ts index 46168a7..0d8027b 100644 --- a/src/data/mappers/UserMapper.ts +++ b/src/data/mappers/UserMapper.ts @@ -4,7 +4,7 @@ */ import { UserModel } from '../models'; -import type { UserDTO, User } from '../../types/dto'; +import type { UserDTO } from '../../types/dto'; // 数据库用户记录类型 export interface UserDbRecord { @@ -14,9 +14,6 @@ export interface UserDbRecord { } export class UserMapper { - /** - * 将 API DTO 转换为应用模型 - */ static fromDTO(dto: UserDTO): UserModel { return { id: String(dto.id || ''), @@ -32,39 +29,10 @@ export class UserMapper { followingCount: dto.following_count, postsCount: dto.posts_count, isFollowing: dto.is_following, - isBlocked: dto.is_blocked, createdAt: dto.created_at ? new Date(dto.created_at) : undefined, - updatedAt: dto.updated_at ? new Date(dto.updated_at) : undefined, }; } - /** - * 将 User 类型转换为应用模型 - */ - static fromUser(user: User): UserModel { - return { - id: String(user.id || ''), - username: user.username || '', - nickname: user.nickname, - avatar: user.avatar, - bio: user.bio, - website: user.website, - location: user.location, - email: user.email, - phone: user.phone, - followersCount: user.followers_count, - followingCount: user.following_count, - postsCount: user.posts_count, - isFollowing: user.is_following, - isBlocked: user.is_blocked, - createdAt: user.created_at ? new Date(user.created_at) : undefined, - updatedAt: user.updated_at ? new Date(user.updated_at) : undefined, - }; - } - - /** - * 将 DTO 数组转换为应用模型数组 - */ static fromDTOList(dtos: UserDTO[]): UserModel[] { return dtos.map(dto => this.fromDTO(dto)); } @@ -88,20 +56,19 @@ export class UserMapper { const dto: UserDTO = { id: model.id, username: model.username, - nickname: model.nickname, - avatar: model.avatar, - bio: model.bio, - website: model.website, - location: model.location, + nickname: model.nickname || '', + avatar: model.avatar || '', + cover_url: '', + bio: model.bio || '', + website: model.website || '', + location: model.location || '', email: model.email, phone: model.phone, - followers_count: model.followersCount, - following_count: model.followingCount, - posts_count: model.postsCount, + followers_count: model.followersCount || 0, + following_count: model.followingCount || 0, + posts_count: model.postsCount || 0, is_following: model.isFollowing, - is_blocked: model.isBlocked, - created_at: model.createdAt?.toISOString(), - updated_at: model.updatedAt?.toISOString(), + created_at: model.createdAt?.toISOString() || '', }; return { @@ -149,20 +116,19 @@ export class UserMapper { return { id: model.id, username: model.username, - nickname: model.nickname, - avatar: model.avatar, - bio: model.bio, - website: model.website, - location: model.location, + nickname: model.nickname || '', + avatar: model.avatar || '', + cover_url: '', + bio: model.bio || '', + website: model.website || '', + location: model.location || '', email: model.email, phone: model.phone, - followers_count: model.followersCount, - following_count: model.followingCount, - posts_count: model.postsCount, + followers_count: model.followersCount || 0, + following_count: model.followingCount || 0, + posts_count: model.postsCount || 0, is_following: model.isFollowing, - is_blocked: model.isBlocked, - created_at: model.createdAt?.toISOString(), - updated_at: model.updatedAt?.toISOString(), + created_at: model.createdAt?.toISOString() || '', }; } } diff --git a/src/data/models/index.ts b/src/data/models/index.ts index cfbf362..c8bc744 100644 --- a/src/data/models/index.ts +++ b/src/data/models/index.ts @@ -85,7 +85,9 @@ export interface PostModel { isFavorited: boolean; isTop: boolean; status: 'published' | 'draft' | 'deleted'; - communityId?: string; + channelId?: string; + /** 频道展示用(与 PostDTO.channel 一致) */ + channel?: { id: string; name: string } | null; tags?: string[]; createdAt: Date; updatedAt: Date; diff --git a/src/data/repositories/PostRepository.ts b/src/data/repositories/PostRepository.ts new file mode 100644 index 0000000..ebc0caf --- /dev/null +++ b/src/data/repositories/PostRepository.ts @@ -0,0 +1,579 @@ +/** + * PostRepository - 帖子仓库实现 + * 实现IPostRepository接口,封装帖子相关的数据访问逻辑 + */ + +import { ApiDataSource, apiDataSource } from '../datasources/ApiDataSource'; +import { LocalDataSource, localDataSource } from '../datasources/LocalDataSource'; +import { PostMapper } from '../mappers/PostMapper'; +import type { Post, PostImage, PostStatus } from '../../core/entities/Post'; +import { createPost } from '../../core/entities/Post'; +import type { + IPostRepository, + GetPostsParams, + CreatePostData, + UpdatePostData, + PostsResult, + SearchPostsParams, +} from './interfaces/IPostRepository'; + +// ==================== 类型定义 ==================== + +/** + * API帖子响应类型 + */ +interface PostApiResponse { + id: number; + user_id: number; + title: string; + content: string; + images?: Array<{ url: string; width?: number; height?: number; thumbnail_url?: string }>; + likes_count: number; + comments_count: number; + shares_count: number; + views_count: number; + favorites_count: number; + is_liked: boolean; + is_favorited: boolean; + is_pinned: boolean; + status: string; + channel_id?: string; + tags?: string[]; + created_at: string; + updated_at: string; + content_edited_at?: string; + channel?: { id: string; name: string } | null; + author?: { + id: number; + username: string; + nickname?: string; + avatar?: string; + is_following?: boolean; + is_following_me?: boolean; + }; +} + +/** + * API帖子列表响应类型 + */ +interface PostsListApiResponse { + list: PostApiResponse[]; + has_more?: boolean; + next_cursor?: string; + total?: number; + page?: number; + page_size?: number; + total_pages?: number; +} + +/** + * 缓存的帖子数据 + */ +interface CachedPost { + id: string; + data: string; + updatedAt: string; +} + +// ==================== PostRepository 实现 ==================== + +export class PostRepository implements IPostRepository { + private api: ApiDataSource; + private localDb: LocalDataSource; + private memoryCache: Map = new Map(); + private readonly CACHE_TTL = 5 * 60 * 1000; // 5分钟缓存过期时间 + + constructor(api: ApiDataSource, localDb: LocalDataSource) { + this.api = api; + this.localDb = localDb; + } + + // ==================== 私有辅助方法 ==================== + + /** + * 将API响应转换为领域实体 + * 同时设置 camelCase 和 snake_case 字段以保持向后兼容 + */ + private mapToPost(response: PostApiResponse): Post { + const model = PostMapper.fromApiResponse(response as any); + return { + // camelCase 字段(新标准) + id: model.id, + authorId: model.authorId, + author: model.author ? { + id: model.author.id, + username: model.author.username, + nickname: model.author.nickname, + avatar: model.author.avatar, + // 关注关系直接透传 API 字段,供详情页关注按钮状态使用 + is_following: response.author?.is_following, + is_following_me: response.author?.is_following_me, + } : undefined, + title: model.title, + content: model.content, + images: (model.images || []).map(url => ({ + url, + width: undefined, + height: undefined, + })) as PostImage[], + likesCount: model.likeCount, + commentsCount: model.commentCount, + sharesCount: model.shareCount, + viewsCount: model.viewCount, + favoritesCount: model.favoriteCount, + isLiked: model.isLiked, + isFavorited: model.isFavorited, + isPinned: model.isTop, + status: model.status, + channelId: model.channelId, + channel: model.channel, + tags: model.tags || [], + createdAt: model.createdAt.toISOString(), + updatedAt: model.updatedAt.toISOString(), + contentEditedAt: response.content_edited_at, + + // snake_case 字段(向后兼容) + likes_count: model.likeCount, + comments_count: model.commentCount, + favorites_count: model.favoriteCount, + shares_count: model.shareCount, + views_count: model.viewCount, + is_liked: model.isLiked, + is_favorited: model.isFavorited, + is_pinned: model.isTop, + user_id: model.authorId, + created_at: model.createdAt.toISOString(), + updated_at: model.updatedAt.toISOString(), + content_edited_at: response.content_edited_at, + channel_id: model.channelId, + }; + } + + /** + * 从内存缓存获取帖子 + */ + private getFromMemoryCache(id: string): Post | null { + const cached = this.memoryCache.get(id); + if (!cached) return null; + + // 检查缓存是否过期 + if (Date.now() - cached.timestamp > this.CACHE_TTL) { + this.memoryCache.delete(id); + return null; + } + + return cached.post; + } + + /** + * 保存帖子到内存缓存 + */ + private saveToMemoryCache(post: Post): void { + this.memoryCache.set(post.id, { + post, + timestamp: Date.now(), + }); + } + + /** + * 从本地数据库获取缓存的帖子 + */ + private async getFromLocalCache(id: string): Promise { + try { + await this.localDb.initialize(); + const result = await this.localDb.getFirst( + 'SELECT * FROM posts_cache WHERE id = ?', + [id] + ); + + if (!result) return null; + + const post = JSON.parse(result.data) as Post; + return post; + } catch (error) { + console.error('[PostRepository] 获取本地缓存失败:', error); + return null; + } + } + + /** + * 保存帖子到本地缓存 + */ + private async saveToLocalCache(post: Post): Promise { + try { + await this.localDb.initialize(); + await this.localDb.enqueueWrite(async () => { + await this.localDb.run( + `INSERT OR REPLACE INTO posts_cache (id, data, updatedAt) VALUES (?, ?, ?)`, + [post.id, JSON.stringify(post), new Date().toISOString()] + ); + }); + } catch (error) { + console.error('[PostRepository] 保存本地缓存失败:', error); + } + } + + /** + * 清除帖子的本地缓存 + */ + private async clearLocalCache(id: string): Promise { + try { + await this.localDb.initialize(); + await this.localDb.enqueueWrite(async () => { + await this.localDb.run('DELETE FROM posts_cache WHERE id = ?', [id]); + }); + } catch (error) { + console.error('[PostRepository] 清除本地缓存失败:', error); + } + } + + /** + * 处理错误 + */ + private handleError(error: unknown, operation: string): never { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error(`[PostRepository] ${operation} 失败:`, error); + throw new Error(`帖子${operation}失败: ${message}`); + } + + // ==================== 帖子查询 ==================== + + /** + * 获取帖子列表 + */ + async getPosts(params?: GetPostsParams): Promise { + try { + const queryParams: Record = {}; + + if (params?.page !== undefined) queryParams.page = params.page; + if (params?.pageSize) queryParams.page_size = params.pageSize; + // 支持 cursor 参数:空字符串也传递,用于启动 cursor 模式 + if (params?.cursor !== undefined && params?.cursor !== null) { + queryParams.cursor = params.cursor; + } + if (params?.post_type) queryParams.tab = params.post_type; + if (params?.channelId) queryParams.channel_id = params.channelId; + if (params?.authorId) queryParams.author_id = params.authorId; + if (params?.tags && params.tags.length > 0) queryParams.tags = params.tags.join(','); + if (params?.status) queryParams.status = params.status; + if (params?.sortBy) queryParams.sort_by = params.sortBy; + if (params?.sortOrder) queryParams.sort_order = params.sortOrder; + if (params?.pinnedOnly) queryParams.pinned_only = true; + + const response = await this.api.get('/posts', queryParams); + + const posts = (response.list || []).map(p => this.mapToPost(p)); + + // 缓存帖子 + for (const post of posts) { + this.saveToMemoryCache(post); + // 不等待本地缓存写入完成,避免阻塞 UI + this.saveToLocalCache(post).catch(err => { + console.warn('[PostRepository] 缓存帖子失败:', post.id, err); + }); + } + + // 计算 hasMore:优先使用 has_more,否则通过 page/total_pages 计算 + let hasMore = response.has_more; + if (hasMore === undefined && response.page !== undefined && response.total_pages !== undefined) { + hasMore = response.page < response.total_pages; + } + hasMore = hasMore ?? false; + + return { + posts, + hasMore, + nextCursor: response.next_cursor, + total: response.total, + }; + } catch (error) { + this.handleError(error, '获取帖子列表'); + } + } + + /** + * 获取单个帖子详情 + */ + async getPostById(id: string): Promise { + try { + // 详情页优先取最新服务端数据,避免旧缓存导致关注态等关系字段过期 + const response = await this.api.get(`/posts/${id}`); + const post = this.mapToPost(response); + + // 更新缓存 + this.saveToMemoryCache(post); + await this.saveToLocalCache(post); + + return post; + } catch (error) { + // API失败时再回退缓存,保证离线/弱网可用 + const memoryCached = this.getFromMemoryCache(id); + if (memoryCached) { + console.warn('[PostRepository] API获取失败,使用内存缓存:', error); + return memoryCached; + } + const localCached = await this.getFromLocalCache(id); + if (localCached) { + console.warn('[PostRepository] API获取失败,使用本地缓存:', error); + this.saveToMemoryCache(localCached); + return localCached; + } + this.handleError(error, '获取帖子详情'); + } + } + + /** + * 获取用户发布的帖子列表 + */ + async getPostsByUser(userId: string, params?: GetPostsParams): Promise { + try { + const queryParams: Record = { author_id: userId }; + + if (params?.page) queryParams.page = params.page; + if (params?.pageSize) queryParams.page_size = params.pageSize; + if (params?.cursor) queryParams.cursor = params.cursor; + if (params?.status) queryParams.status = params.status; + if (params?.sortBy) queryParams.sort_by = params.sortBy; + if (params?.sortOrder) queryParams.sort_order = params.sortOrder; + + const response = await this.api.get(`/users/${userId}/posts`, queryParams); + + const posts = (response.list || []).map(p => this.mapToPost(p)); + + // 缓存帖子 + for (const post of posts) { + this.saveToMemoryCache(post); + this.saveToLocalCache(post).catch(err => { + console.warn('[PostRepository] 缓存帖子失败:', post.id, err); + }); + } + + return { + posts, + hasMore: response.has_more ?? false, + nextCursor: response.next_cursor, + total: response.total, + }; + } catch (error) { + this.handleError(error, '获取用户帖子列表'); + } + } + + /** + * 搜索帖子 + */ + async searchPosts(params: SearchPostsParams): Promise { + try { + const queryParams: Record = { + keyword: params.keyword, + }; + + if (params.page) queryParams.page = params.page; + if (params.pageSize) queryParams.page_size = params.pageSize; + if (params.scope) queryParams.scope = params.scope; + if (params.channelId) queryParams.channel_id = params.channelId; + + const response = await this.api.get('/posts/search', queryParams); + + const posts = (response.list || []).map(p => this.mapToPost(p)); + + // 缓存帖子 + for (const post of posts) { + this.saveToMemoryCache(post); + this.saveToLocalCache(post).catch(err => { + console.warn('[PostRepository] 缓存帖子失败:', post.id, err); + }); + } + + return { + posts, + hasMore: response.has_more ?? false, + nextCursor: response.next_cursor, + total: response.total, + }; + } catch (error) { + this.handleError(error, '搜索帖子'); + } + } + + // ==================== 帖子操作 ==================== + + /** + * 创建帖子 + */ + async createPost(data: CreatePostData): Promise { + try { + const requestData = PostMapper.createPostRequest( + data.title, + data.content, + data.images?.map(img => img.url), + data.channelId + ); + + if (data.tags && data.tags.length > 0) { + requestData.tags = data.tags; + } + + if (data.status) { + requestData.status = data.status; + } + + const response = await this.api.post('/posts', requestData); + const post = this.mapToPost(response); + + // 缓存新创建的帖子 + this.saveToMemoryCache(post); + await this.saveToLocalCache(post); + + return post; + } catch (error) { + this.handleError(error, '创建帖子'); + } + } + + /** + * 更新帖子 + */ + async updatePost(id: string, data: UpdatePostData): Promise { + try { + const requestData: Record = {}; + + if (data.title !== undefined) requestData.title = data.title; + if (data.content !== undefined) requestData.content = data.content; + if (data.images !== undefined) requestData.images = data.images.map(img => img.url); + if (data.tags !== undefined) requestData.tags = data.tags; + if (data.status !== undefined) requestData.status = data.status; + if (data.isPinned !== undefined) requestData.is_pinned = data.isPinned; + + const response = await this.api.put(`/posts/${id}`, requestData); + const post = this.mapToPost(response); + + // 更新缓存 + this.saveToMemoryCache(post); + await this.saveToLocalCache(post); + + return post; + } catch (error) { + this.handleError(error, '更新帖子'); + } + } + + /** + * 删除帖子 + */ + async deletePost(id: string): Promise { + try { + await this.api.delete(`/posts/${id}`); + + // 清除缓存 + this.memoryCache.delete(id); + await this.clearLocalCache(id); + } catch (error) { + this.handleError(error, '删除帖子'); + } + } + + // ==================== 互动操作 ==================== + + /** + * 点赞帖子 + */ + async likePost(id: string): Promise { + try { + const response = await this.api.post(`/posts/${id}/like`); + const post = this.mapToPost(response); + + // 更新缓存 + this.saveToMemoryCache(post); + await this.saveToLocalCache(post); + + return post; + } catch (error) { + this.handleError(error, '点赞帖子'); + } + } + + /** + * 取消点赞 + */ + async unlikePost(id: string): Promise { + try { + const response = await this.api.delete(`/posts/${id}/like`); + const post = this.mapToPost(response); + + // 更新缓存 + this.saveToMemoryCache(post); + await this.saveToLocalCache(post); + + return post; + } catch (error) { + this.handleError(error, '取消点赞'); + } + } + + /** + * 收藏帖子 + */ + async favoritePost(id: string): Promise { + try { + const response = await this.api.post(`/posts/${id}/favorite`); + const post = this.mapToPost(response); + + // 更新缓存 + this.saveToMemoryCache(post); + await this.saveToLocalCache(post); + + return post; + } catch (error) { + this.handleError(error, '收藏帖子'); + } + } + + /** + * 取消收藏 + */ + async unfavoritePost(id: string): Promise { + try { + const response = await this.api.delete(`/posts/${id}/favorite`); + const post = this.mapToPost(response); + + // 更新缓存 + this.saveToMemoryCache(post); + await this.saveToLocalCache(post); + + return post; + } catch (error) { + this.handleError(error, '取消收藏'); + } + } + + // ==================== 缓存管理 ==================== + + /** + * 清除所有缓存 + */ + async clearAllCache(): Promise { + this.memoryCache.clear(); + try { + await this.localDb.initialize(); + await this.localDb.enqueueWrite(async () => { + await this.localDb.run('DELETE FROM posts_cache'); + }); + } catch (error) { + console.error('[PostRepository] 清除所有缓存失败:', error); + } + } + + /** + * 使指定帖子的缓存失效 + */ + async invalidateCache(id: string): Promise { + this.memoryCache.delete(id); + await this.clearLocalCache(id); + } +} + +// ==================== 单例导出 ==================== + +export const postRepository = new PostRepository(apiDataSource, localDataSource); +export default postRepository; diff --git a/src/data/repositories/interfaces/IMessageRepository.ts b/src/data/repositories/interfaces/IMessageRepository.ts index 1964401..8619eab 100644 --- a/src/data/repositories/interfaces/IMessageRepository.ts +++ b/src/data/repositories/interfaces/IMessageRepository.ts @@ -3,7 +3,7 @@ * 定义消息数据访问的抽象 */ -import type { Message, Conversation, ConversationType } from '../../types/dto'; +import type { Message, Conversation } from '../../../core/entities/Message'; export interface IMessageRepository { // ==================== 消息操作 ==================== diff --git a/src/data/repositories/interfaces/IPostRepository.ts b/src/data/repositories/interfaces/IPostRepository.ts new file mode 100644 index 0000000..6423134 --- /dev/null +++ b/src/data/repositories/interfaces/IPostRepository.ts @@ -0,0 +1,190 @@ +/** + * 帖子 Repository 接口 + * 定义帖子数据访问的抽象 + */ + +import type { Post, PostImage, PostStatus } from '../../../core/entities/Post'; + +// ==================== 请求/响应类型 ==================== + +/** + * 获取帖子列表参数 + */ +export interface GetPostsParams { + /** 分页 - 页码(从1开始) */ + page?: number; + /** 分页 - 每页数量 */ + pageSize?: number; + /** 游标 - 用于无限滚动加载 */ + cursor?: string; + /** 帖子类型筛选(可选):follow, hot, latest */ + post_type?: 'follow' | 'hot' | 'latest'; + /** 频道ID过滤 */ + channelId?: string; + /** 作者ID过滤 */ + authorId?: string; + /** 标签过滤 */ + tags?: string[]; + /** 状态过滤 */ + status?: PostStatus; + /** 排序字段 */ + sortBy?: 'createdAt' | 'likesCount' | 'commentsCount' | 'viewsCount'; + /** 排序方向 */ + sortOrder?: 'asc' | 'desc'; + /** 是否只获取置顶帖子 */ + pinnedOnly?: boolean; +} + +/** + * 创建帖子数据 + */ +export interface CreatePostData { + /** 帖子标题 */ + title: string; + /** 帖子内容 */ + content: string; + /** 图片列表 */ + images?: PostImage[]; + /** 所属频道ID */ + channelId?: string; + /** 标签列表 */ + tags?: string[]; + /** 帖子状态 */ + status?: PostStatus; +} + +/** + * 更新帖子数据 + */ +export interface UpdatePostData { + /** 帖子标题 */ + title?: string; + /** 帖子内容 */ + content?: string; + /** 图片列表 */ + images?: PostImage[]; + /** 标签列表 */ + tags?: string[]; + /** 帖子状态 */ + status?: PostStatus; + /** 是否置顶 */ + isPinned?: boolean; +} + +/** + * 帖子列表结果 + */ +export interface PostsResult { + /** 帖子列表 */ + posts: Post[]; + /** 是否有更多数据 */ + hasMore: boolean; + /** 下一页游标 */ + nextCursor?: string; + /** 总数(如果支持) */ + total?: number; +} + +/** + * 搜索帖子参数 + */ +export interface SearchPostsParams { + /** 搜索关键词 */ + keyword: string; + /** 分页 - 页码 */ + page?: number; + /** 分页 - 每页数量 */ + pageSize?: number; + /** 搜索范围:标题、内容、或全部 */ + scope?: 'title' | 'content' | 'all'; + /** 频道ID过滤 */ + channelId?: string; +} + +// ==================== Repository 接口 ==================== + +export interface IPostRepository { + // ==================== 帖子查询 ==================== + + /** + * 获取帖子列表 + * @param params 查询参数 + * @returns 帖子列表结果 + */ + getPosts(params?: GetPostsParams): Promise; + + /** + * 获取单个帖子详情 + * @param id 帖子ID + * @returns 帖子实体,不存在则返回null + */ + getPostById(id: string): Promise; + + /** + * 获取用户发布的帖子列表 + * @param userId 用户ID + * @param params 查询参数 + * @returns 帖子列表结果 + */ + getPostsByUser(userId: string, params?: GetPostsParams): Promise; + + /** + * 搜索帖子 + * @param params 搜索参数 + * @returns 帖子列表结果 + */ + searchPosts(params: SearchPostsParams): Promise; + + // ==================== 帖子操作 ==================== + + /** + * 创建帖子 + * @param data 创建帖子数据 + * @returns 创建的帖子实体 + */ + createPost(data: CreatePostData): Promise; + + /** + * 更新帖子 + * @param id 帖子ID + * @param data 更新数据 + * @returns 更新后的帖子实体 + */ + updatePost(id: string, data: UpdatePostData): Promise; + + /** + * 删除帖子 + * @param id 帖子ID + */ + deletePost(id: string): Promise; + + // ==================== 互动操作 ==================== + + /** + * 点赞帖子 + * @param id 帖子ID + * @returns 更新后的帖子实体 + */ + likePost(id: string): Promise; + + /** + * 取消点赞 + * @param id 帖子ID + * @returns 更新后的帖子实体 + */ + unlikePost(id: string): Promise; + + /** + * 收藏帖子 + * @param id 帖子ID + * @returns 更新后的帖子实体 + */ + favoritePost(id: string): Promise; + + /** + * 取消收藏 + * @param id 帖子ID + * @returns 更新后的帖子实体 + */ + unfavoritePost(id: string): Promise; +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 88416f5..0b1fd9b 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -83,6 +83,9 @@ export type { UsePaginationReturn, } from './usePagination'; +// ==================== 游标分页 Hooks ==================== +export { useCursorPagination } from './useCursorPagination'; + // ==================== 连接状态 Hooks ==================== export { useConnectionState } from './useConnectionState'; @@ -92,3 +95,19 @@ export type { UseConnectionStateResult } from './useConnectionState'; export { useMediaCache } from './useMediaCache'; export type { UseMediaCacheReturn } from './useMediaCache'; + +// ==================== 差异更新 Hooks ==================== +export { useDifferentialMessages } from './useDifferentialMessages'; + +export type { + UseDifferentialMessagesOptions, + UseDifferentialMessagesResult, +} from './useDifferentialMessages'; + +export { useDifferentialPosts } from './useDifferentialPosts'; + +export type { + UseDifferentialPostsOptions, + UseDifferentialPostsResult, + DiffUpdatesInfo, +} from './useDifferentialPosts'; diff --git a/src/hooks/useCursorPagination.ts b/src/hooks/useCursorPagination.ts new file mode 100644 index 0000000..b48f1b6 --- /dev/null +++ b/src/hooks/useCursorPagination.ts @@ -0,0 +1,383 @@ +import { useState, useCallback, useRef, useEffect } from 'react'; +import { + CursorPaginationState, + CursorPaginationConfig, + CursorDirection, + UseCursorPaginationReturn, + CursorFetchFunction, +} from '../infrastructure/pagination/types'; +import { CursorPaginationResponse } from '../types/dto'; + +const DEFAULT_PAGE_SIZE = 20; +const MAX_PAGE_SIZE = 100; + +function getItemId(item: unknown): string | null { + if (!item || typeof item !== 'object') return null; + const maybeId = (item as { id?: unknown }).id; + if (typeof maybeId === 'string' || typeof maybeId === 'number') { + return String(maybeId); + } + return null; +} + +function mergeByIdFastPath(previousInput: T[] | null | undefined, incomingInput: T[] | null | undefined): T[] { + const previous = Array.isArray(previousInput) ? previousInput : []; + const incoming = Array.isArray(incomingInput) ? incomingInput : []; + if (incoming.length === 0) return previous; + if (previous.length === 0) return incoming; + + const prevLastId = getItemId(previous[previous.length - 1]); + const incomingFirstId = getItemId(incoming[0]); + if (prevLastId && incomingFirstId && prevLastId !== incomingFirstId) { + const prevSet = new Set(previous.map((item) => getItemId(item)).filter(Boolean) as string[]); + if (!prevSet.has(incomingFirstId)) { + return [...previous, ...incoming]; + } + } + + const merged = [...previous]; + const indexById = new Map(); + for (let i = 0; i < merged.length; i += 1) { + const id = getItemId(merged[i]); + if (id) indexById.set(id, i); + } + + for (const item of incoming) { + const id = getItemId(item); + if (!id) { + merged.push(item); + continue; + } + const existingIndex = indexById.get(id); + if (existingIndex === undefined) { + indexById.set(id, merged.length); + merged.push(item); + } else { + merged[existingIndex] = item; + } + } + return merged; +} + +function normalizeList(value: T[] | null | undefined): T[] { + return Array.isArray(value) ? value : []; +} + +/** + * 游标分页 Hook + * + * @param fetchFunction 数据获取函数 + * @param config 分页配置 + * @param extraParams 额外参数(会传递给 fetchFunction) + */ +export function useCursorPagination( + fetchFunction: CursorFetchFunction, + config: Partial = {}, + extraParams?: P +): UseCursorPaginationReturn { + const { pageSize = DEFAULT_PAGE_SIZE, bidirectional = false, autoLoad = true } = config; + + // 限制 pageSize 在有效范围内 + const effectivePageSize = Math.min(Math.max(1, pageSize), MAX_PAGE_SIZE); + + const [state, setState] = useState>({ + list: [], + nextCursor: null, + prevCursor: null, + hasMore: true, // 初始设为 true,以便首次加载可以执行 + isLoading: false, + isInitialLoading: false, + isLoadingMore: false, + isRefreshing: false, + error: null, + isFirstLoad: true, + }); + + // 用于取消请求的标志 + const cancelledRef = useRef(false); + + // 用于追踪是否已自动加载 + const autoLoadRef = useRef(false); + + // 用于追踪是否为首次加载的 ref + const isFirstLoadRef = useRef(true); + + // 用于追踪是否正在加载中(防止重复调用) + const isLoadingRef = useRef(false); + + // 加载更多(下一页) + const loadMore = useCallback(async () => { + // 使用 ref 来检查是否为首次加载,避免依赖项变化 + const isFirstLoad = isFirstLoadRef.current; + + // 首次加载时允许执行(跳过 hasMore 检查) + // 使用 isLoadingRef 防止重复调用 + if (isLoadingRef.current || state.isLoading || (!isFirstLoad && !state.hasMore)) { + return; + } + + // 标记正在加载 + isLoadingRef.current = true; + cancelledRef.current = false; + + setState(prev => ({ + ...prev, + isLoading: true, + isInitialLoading: prev.list.length === 0, + isLoadingMore: prev.list.length > 0, + error: null, + })); + + try { + const response: CursorPaginationResponse = await fetchFunction({ + cursor: state.nextCursor || undefined, + direction: 'forward', + pageSize: effectivePageSize, + extraParams, + }); + + if (cancelledRef.current) { + isLoadingRef.current = false; + return; + } + + // 标记首次加载完成 + isFirstLoadRef.current = false; + + const incomingList = normalizeList(response.list); + setState(prev => ({ + ...prev, + list: mergeByIdFastPath(prev.list, incomingList), + nextCursor: response.next_cursor, + prevCursor: response.prev_cursor, + hasMore: response.has_more && response.next_cursor !== null, + isLoading: false, + isInitialLoading: false, + isLoadingMore: false, + isFirstLoad: false, + })); + + isLoadingRef.current = false; + } catch (error) { + if (cancelledRef.current) { + isLoadingRef.current = false; + return; + } + + setState(prev => ({ + ...prev, + isLoading: false, + isInitialLoading: false, + isLoadingMore: false, + error: error instanceof Error ? error.message : '加载失败', + })); + + isLoadingRef.current = false; + } + }, [state.isLoading, state.hasMore, state.nextCursor, fetchFunction, effectivePageSize, extraParams]); + + // 加载上一页(双向分页) + const loadPrevious = useCallback(async () => { + if (!bidirectional || state.isLoading || !state.prevCursor) { + return; + } + + cancelledRef.current = false; + + setState(prev => ({ ...prev, isLoading: true, isLoadingMore: true, error: null })); + + try { + const response: CursorPaginationResponse = await fetchFunction({ + cursor: state.prevCursor, + direction: 'backward', + pageSize: effectivePageSize, + extraParams, + }); + + if (cancelledRef.current) return; + + const incomingList = normalizeList(response.list); + setState(prev => ({ + ...prev, + // 向前加载时,新数据放在前面 + list: mergeByIdFastPath(incomingList, prev.list), + nextCursor: response.next_cursor, + prevCursor: response.prev_cursor, + hasMore: response.has_more, + isLoading: false, + isLoadingMore: false, + isFirstLoad: false, + })); + } catch (error) { + if (cancelledRef.current) return; + + setState(prev => ({ + ...prev, + isLoading: false, + isLoadingMore: false, + error: error instanceof Error ? error.message : '加载失败', + })); + } + }, [bidirectional, state.isLoading, state.prevCursor, fetchFunction, effectivePageSize, extraParams]); + + // 刷新数据 + const refresh = useCallback(async () => { + cancelledRef.current = false; + + setState(prev => ({ ...prev, isRefreshing: true, error: null })); + + try { + const response: CursorPaginationResponse = await fetchFunction({ + cursor: undefined, + direction: 'forward', + pageSize: effectivePageSize, + extraParams, + }); + + if (cancelledRef.current) return; + + const refreshedList = normalizeList(response.list); + setState({ + list: mergeByIdFastPath([], refreshedList), + nextCursor: response.next_cursor, + prevCursor: response.prev_cursor, + hasMore: response.has_more && response.next_cursor !== null, + isLoading: false, + isInitialLoading: false, + isLoadingMore: false, + isRefreshing: false, + error: null, + isFirstLoad: false, + }); + } catch (error) { + if (cancelledRef.current) return; + + setState(prev => ({ + ...prev, + isRefreshing: false, + error: error instanceof Error ? error.message : '刷新失败', + })); + } + }, [fetchFunction, effectivePageSize, extraParams]); + + // 重置状态 + const reset = useCallback(() => { + cancelledRef.current = true; + autoLoadRef.current = false; + isFirstLoadRef.current = true; + isLoadingRef.current = false; + setState({ + list: [], + nextCursor: null, + prevCursor: null, + hasMore: true, // 重置后也设为 true,以便可以重新加载 + isLoading: false, + isInitialLoading: false, + isLoadingMore: false, + isRefreshing: false, + error: null, + isFirstLoad: true, + }); + }, []); + + // 设置数据(用于外部数据注入) + const setList = useCallback( + ( + list: T[], + nextCursor: string | null, + prevCursor: string | null, + hasMore: boolean + ) => { + setState(prev => ({ + ...prev, + list: mergeByIdFastPath([], normalizeList(list)), + nextCursor, + prevCursor, + hasMore, + isInitialLoading: false, + isLoadingMore: false, + isFirstLoad: false, + })); + }, + [] + ); + + // 自动加载第一页 + useEffect(() => { + // 使用 ref 防止重复加载,不依赖 loadMore 函数引用 + // 只检查 autoLoad 和 autoLoadRef,不再依赖 state.list.length + if (!autoLoad || autoLoadRef.current) return; + + autoLoadRef.current = true; + + // 直接调用加载逻辑,避免依赖 loadMore 函数引用 + const loadInitialData = async () => { + if (isLoadingRef.current) return; + + isLoadingRef.current = true; + cancelledRef.current = false; + + setState(prev => ({ ...prev, isLoading: true, isInitialLoading: prev.list.length === 0, error: null })); + + try { + const response: CursorPaginationResponse = await fetchFunction({ + cursor: undefined, + direction: 'forward', + pageSize: effectivePageSize, + extraParams, + }); + + if (cancelledRef.current) { + isLoadingRef.current = false; + return; + } + + isFirstLoadRef.current = false; + + const initialList = normalizeList(response.list); + setState(prev => ({ + ...prev, + list: mergeByIdFastPath([], initialList), + nextCursor: response.next_cursor, + prevCursor: response.prev_cursor, + hasMore: response.has_more && response.next_cursor !== null, + isLoading: false, + isInitialLoading: false, + isLoadingMore: false, + isFirstLoad: false, + })); + + isLoadingRef.current = false; + } catch (error) { + if (cancelledRef.current) { + isLoadingRef.current = false; + return; + } + + setState(prev => ({ + ...prev, + isLoading: false, + isInitialLoading: false, + isLoadingMore: false, + error: error instanceof Error ? error.message : '加载失败', + })); + + isLoadingRef.current = false; + } + }; + + loadInitialData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoLoad]); // 只依赖 autoLoad,确保只执行一次 + + return { + ...state, + loadMore, + loadPrevious, + refresh, + reset, + setList, + }; +} + +export default useCursorPagination; \ No newline at end of file diff --git a/src/hooks/useDifferentialPosts.ts b/src/hooks/useDifferentialPosts.ts new file mode 100644 index 0000000..9a37618 --- /dev/null +++ b/src/hooks/useDifferentialPosts.ts @@ -0,0 +1,538 @@ +/** + * 帖子差异更新 Hook + * 接收原始帖子列表,返回优化后的帖子列表,自动处理批量更新 + * 集成 ProcessPostUseCase 进行状态管理 + */ + +import { useState, useRef, useCallback, useEffect } from 'react'; +import { + PostIdentifier, + PostUpdate, + PostUpdateType, + PostDiffConfig, +} from '../infrastructure/diff/postTypes'; +import { + PostDiffCalculator, + createPostDiffCalculator, +} from '../infrastructure/diff/PostDiffCalculator'; +import { + PostUpdateBatcher, + PostBatcherOptions, + createPostUpdateBatcher, +} from '../infrastructure/diff/PostUpdateBatcher'; +import { processPostUseCase } from '../core/usecases/ProcessPostUseCase'; +import type { PostUseCaseEvent, PostsState } from '../core/usecases/ProcessPostUseCase'; +import type { Post } from '../core/entities/Post'; + +// ==================== 类型定义 ==================== + +/** + * 差异更新 Hook 配置选项 + */ +export interface UseDifferentialPostsOptions { + /** 差异计算配置 */ + diffConfig?: PostDiffConfig; + /** 批量处理配置 */ + batcherOptions?: PostBatcherOptions; + /** 是否启用差异计算,默认 true */ + enableDiff?: boolean; + /** 是否启用批量处理,默认 true */ + enableBatching?: boolean; + /** 最大帖子数量限制,超出时触发重置 */ + maxPostCount?: number; + /** 变化比例阈值(0-1),超过此值触发重置 */ + changeRatioThreshold?: number; + /** 列表键(用于区分不同的帖子列表) */ + listKey?: string; + /** 是否自动订阅 UseCase 状态变化,默认 true */ + autoSubscribe?: boolean; +} + +/** + * 差异更新信息 + */ +export interface DiffUpdatesInfo { + /** 新增数量 */ + addedCount: number; + /** 更新数量 */ + updatedCount: number; + /** 删除数量 */ + deletedCount: number; + /** 上次更新时间 */ + lastUpdateTime: number; +} + +/** + * 差异更新 Hook 返回值 + */ +export interface UseDifferentialPostsResult { + /** 优化后的帖子列表 */ + posts: T[]; + /** 是否正在加载 */ + loading: boolean; + /** 是否首屏加载 */ + isInitialLoading: boolean; + /** 是否正在加载更多 */ + isLoadingMore: boolean; + /** 是否正在刷新 */ + refreshing: boolean; + /** 错误信息 */ + error: string | null; + /** 是否有更多数据 */ + hasMore: boolean; + /** 待处理更新数量 */ + pendingUpdateCount: number; + /** 是否正在处理更新 */ + isProcessing: boolean; + /** 刷新方法 */ + refresh: () => Promise; + /** 加载更多方法 */ + loadMore: () => Promise; + /** 手动刷新批量处理队列 */ + flush: () => void; + /** 重置方法 */ + reset: () => void; + /** 强制更新(跳过批量处理) */ + forceUpdate: (posts: T[]) => void; + /** 获取差异统计信息 */ + getDiffStats: () => { + totalBatches: number; + totalUpdates: number; + averageBatchSize: number; + }; + /** 差异更新信息 */ + diffUpdates: DiffUpdatesInfo; +} + +// ==================== Hook 实现 ==================== + +/** + * 帖子差异更新 Hook + * @param initialPosts 初始帖子列表(可选) + * @param options 配置选项 + * @returns 优化后的帖子列表和相关方法 + */ +export function useDifferentialPosts( + initialPosts: T[] = [], + options: UseDifferentialPostsOptions = {} +): UseDifferentialPostsResult { + const { + diffConfig, + batcherOptions, + enableDiff = true, + enableBatching = true, + maxPostCount = 10000, + listKey = 'default', + autoSubscribe = true, + } = options; + + // ==================== 状态 ==================== + + const [posts, setPosts] = useState(initialPosts); + const [loading, setLoading] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const [hasMore, setHasMore] = useState(true); + const [isProcessing, setIsProcessing] = useState(false); + const [pendingUpdateCount, setPendingUpdateCount] = useState(0); + const [diffUpdates, setDiffUpdates] = useState({ + addedCount: 0, + updatedCount: 0, + deletedCount: 0, + lastUpdateTime: 0, + }); + + // ==================== Refs ==================== + + const calculatorRef = useRef | null>(null); + const batcherRef = useRef(null); + const previousPostsRef = useRef(initialPosts); + const unsubscribeRef = useRef<(() => void) | null>(null); + + // ==================== 批量更新处理(须在订阅 batcher 的 useEffect 之前定义)==================== + + /** + * 处理批量更新 + */ + const handleBatchUpdates = useCallback((updates: PostUpdate[]) => { + setIsProcessing(true); + + try { + setPosts((currentPosts) => { + let newPosts = [...currentPosts]; + let addedCount = 0; + let updatedCount = 0; + let deletedCount = 0; + + for (const update of updates) { + switch (update.type) { + case PostUpdateType.ADD: { + const addUpdate = update as any; + const index = addUpdate.index ?? newPosts.length; + newPosts.splice(index, 0, addUpdate.post as T); + addedCount++; + break; + } + + case PostUpdateType.BATCH_ADD: { + const batchAddUpdate = update as any; + const postsToAdd = batchAddUpdate.posts || []; + const startIndex = batchAddUpdate.startIndex ?? newPosts.length; + newPosts.splice(startIndex, 0, ...postsToAdd as T[]); + addedCount += postsToAdd.length; + break; + } + + case PostUpdateType.UPDATE: { + const updatePost = update as any; + const index = newPosts.findIndex(p => p.id === updatePost.postId); + if (index !== -1) { + newPosts[index] = { ...newPosts[index], ...updatePost.updates }; + updatedCount++; + } + break; + } + + case PostUpdateType.BATCH_UPDATE: { + const batchUpdate = update as any; + const updatesList = batchUpdate.updates || []; + for (const { postId, changes } of updatesList) { + const index = newPosts.findIndex(p => p.id === postId); + if (index !== -1) { + newPosts[index] = { ...newPosts[index], ...changes }; + updatedCount++; + } + } + break; + } + + case PostUpdateType.DELETE: { + const deleteUpdate = update as any; + const prevLength = newPosts.length; + newPosts = newPosts.filter(p => p.id !== deleteUpdate.postId); + if (newPosts.length < prevLength) { + deletedCount++; + } + break; + } + + case PostUpdateType.BATCH_DELETE: { + const batchDelete = update as any; + const idsToDelete = new Set(batchDelete.postIds || []); + const prevLength = newPosts.length; + newPosts = newPosts.filter(p => !idsToDelete.has(p.id)); + deletedCount += prevLength - newPosts.length; + break; + } + + case PostUpdateType.MOVE: { + const moveUpdate = update as any; + const fromIndex = newPosts.findIndex(p => p.id === moveUpdate.postId); + if (fromIndex !== -1) { + const [movedPost] = newPosts.splice(fromIndex, 1); + newPosts.splice(moveUpdate.toIndex, 0, movedPost); + } + break; + } + + case PostUpdateType.RESET: { + const resetUpdate = update as any; + newPosts = resetUpdate.posts || []; + break; + } + } + } + + // 更新差异统计 + if (addedCount > 0 || updatedCount > 0 || deletedCount > 0) { + setDiffUpdates(prev => ({ + addedCount: prev.addedCount + addedCount, + updatedCount: prev.updatedCount + updatedCount, + deletedCount: prev.deletedCount + deletedCount, + lastUpdateTime: Date.now(), + })); + } + + return newPosts; + }); + } finally { + setIsProcessing(false); + setPendingUpdateCount(batcherRef.current?.getPendingCount() ?? 0); + } + }, []); + + // ==================== 初始化 ==================== + + // 初始化差异计算器 + useEffect(() => { + if (enableDiff) { + calculatorRef.current = createPostDiffCalculator(diffConfig); + } + return () => { + calculatorRef.current = null; + }; + }, [enableDiff, diffConfig]); + + // 初始化批量处理器 + useEffect(() => { + if (!enableBatching) return; + + batcherRef.current = createPostUpdateBatcher(batcherOptions); + + const unsubscribe = batcherRef.current.subscribe((updates) => { + handleBatchUpdates(updates); + }); + + return () => { + unsubscribe(); + batcherRef.current?.destroy(); + batcherRef.current = null; + }; + }, [enableBatching, batcherOptions, handleBatchUpdates]); + + // ==================== UseCase 订阅 ==================== + + /** + * 处理 UseCase 事件 + */ + const handleUseCaseEvent = useCallback((event: PostUseCaseEvent) => { + switch (event.type) { + case 'state_changed': { + const { key: eventKey, state } = event.payload as { key: string; state: PostsState }; + // 全局订阅:只应用当前列表 key,避免其他 Tab/列表的 state 覆盖本列表(如 createPost 会更新所有 key) + if (eventKey !== listKey) { + break; + } + if (state) { + setLoading(state.isLoading); + setRefreshing(state.isRefreshing); + setError(state.error); + setHasMore(state.hasMore); + + // ProcessPostUseCase 的 state.posts 已是合并后的完整列表。 + // 不再走差异计算 + PostUpdateBatcher:批量应用晚于 previousPostsRef 更新时, + // BATCH_ADD 会在过短的 currentPosts 上 splice,导致连续游标加载「请求成功但列表不增长」。 + batcherRef.current?.clearPending(); + calculatorRef.current?.reset(); + if (state.posts && state.posts.length > 0) { + const next = state.posts as unknown as T[]; + if (next.length > maxPostCount) { + console.warn(`[useDifferentialPosts] Post count (${next.length}) exceeds limit (${maxPostCount})`); + } + setPosts(next); + previousPostsRef.current = next; + } else { + setPosts([]); + previousPostsRef.current = []; + } + } + break; + } + + case 'posts_loaded': { + // fetchPosts 在 updatePostsState 时已发出 state_changed(含合并后 posts),此处不再应用,避免与其它 key 竞态或重复计算 + break; + } + + case 'post_created': { + const { post } = event.payload; + if (post) { + // 新帖子添加到列表开头 + setPosts(prev => [post as T, ...prev]); + setDiffUpdates(prev => ({ + ...prev, + addedCount: prev.addedCount + 1, + lastUpdateTime: Date.now(), + })); + } + break; + } + + case 'post_updated': { + const { post } = event.payload; + if (post) { + setPosts(prev => prev.map(p => p.id === post.id ? { ...p, ...post } as T : p)); + setDiffUpdates(prev => ({ + ...prev, + updatedCount: prev.updatedCount + 1, + lastUpdateTime: Date.now(), + })); + } + break; + } + + case 'post_deleted': { + const { postId } = event.payload; + if (postId) { + setPosts(prev => prev.filter(p => p.id !== postId)); + setDiffUpdates(prev => ({ + ...prev, + deletedCount: prev.deletedCount + 1, + lastUpdateTime: Date.now(), + })); + } + break; + } + + case 'loading_changed': { + const { isLoading } = event.payload; + setLoading(isLoading); + break; + } + + case 'error_occurred': { + const payload = event.payload as { key?: string; error?: string }; + if (payload.key !== undefined && payload.key !== listKey) { + break; + } + if (payload.error !== undefined) { + setError(payload.error); + } + break; + } + } + }, [listKey, maxPostCount]); + + // 订阅 UseCase 状态变化 + useEffect(() => { + if (autoSubscribe) { + unsubscribeRef.current = processPostUseCase.subscribe(handleUseCaseEvent); + + // 初始化时获取当前状态 + const currentState = processPostUseCase.getPostsState(listKey); + if (currentState.posts.length > 0) { + setPosts(currentState.posts as unknown as T[]); + previousPostsRef.current = currentState.posts as unknown as T[]; + } + setLoading(currentState.isLoading); + setRefreshing(currentState.isRefreshing); + setError(currentState.error); + setHasMore(currentState.hasMore); + + return () => { + if (unsubscribeRef.current) { + unsubscribeRef.current(); + unsubscribeRef.current = null; + } + }; + } + }, [autoSubscribe, listKey, handleUseCaseEvent]); + + // ==================== 操作方法 ==================== + + /** + * 刷新帖子列表 + */ + const refresh = useCallback(async () => { + setRefreshing(true); + setError(null); + try { + await processPostUseCase.refreshPosts(listKey); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : '刷新失败'; + setError(errorMsg); + } finally { + setRefreshing(false); + } + }, [listKey]); + + /** + * 加载更多帖子 + */ + const loadMore = useCallback(async () => { + if (hasMore && !loading) { + setLoading(true); + setError(null); + try { + await processPostUseCase.loadMorePosts(listKey); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : '加载更多失败'; + setError(errorMsg); + } finally { + setLoading(false); + } + } + }, [listKey, hasMore, loading]); + + /** + * 手动刷新批量处理队列 + */ + const flush = useCallback(() => { + batcherRef.current?.flush(); + }, []); + + /** + * 重置方法 + */ + const reset = useCallback(() => { + calculatorRef.current?.reset(); + batcherRef.current?.clearPending(); + setPosts([]); + setLoading(false); + setRefreshing(false); + setError(null); + setHasMore(true); + previousPostsRef.current = []; + setDiffUpdates({ + addedCount: 0, + updatedCount: 0, + deletedCount: 0, + lastUpdateTime: 0, + }); + }, []); + + /** + * 强制更新方法 + */ + const forceUpdate = useCallback((newPosts: T[]) => { + setPosts(newPosts); + previousPostsRef.current = newPosts; + }, []); + + /** + * 获取统计信息 + */ + const getDiffStats = useCallback(() => { + const batcherStats = batcherRef.current?.getStats(); + return { + totalBatches: batcherStats?.totalBatches ?? 0, + totalUpdates: batcherStats?.totalUpdates ?? 0, + averageBatchSize: batcherStats?.averageBatchSize ?? 0, + }; + }, []); + + // ==================== 定期更新待处理数量 ==================== + + useEffect(() => { + if (!enableBatching || !batcherRef.current) return; + + const interval = setInterval(() => { + setPendingUpdateCount(batcherRef.current?.getPendingCount() ?? 0); + }, 50); + + return () => clearInterval(interval); + }, [enableBatching]); + + // ==================== 返回值 ==================== + + return { + posts, + loading, + isInitialLoading: loading && posts.length === 0 && !refreshing, + isLoadingMore: loading && posts.length > 0 && !refreshing, + refreshing, + error, + hasMore, + pendingUpdateCount, + isProcessing, + refresh, + loadMore, + flush, + reset, + forceUpdate, + getDiffStats, + diffUpdates, + }; +} + +export default useDifferentialPosts; diff --git a/src/hooks/usePrefetch.ts b/src/hooks/usePrefetch.ts index eab5ee4..539d65b 100644 --- a/src/hooks/usePrefetch.ts +++ b/src/hooks/usePrefetch.ts @@ -134,7 +134,7 @@ const prefetchService = new PrefetchService(); * 预取帖子数据 * @param types 帖子类型数组 */ -function prefetchPosts(types: string[] = ['recommend', 'hot']): void { +function prefetchPosts(types: string[] = ['latest', 'hot']): void { types.forEach((type, index) => { prefetchService.schedule({ key: `posts:${type}:1`, @@ -152,7 +152,10 @@ function prefetchConversations(): void { key: 'conversations:list', executor: async () => { await messageManager.initialize(); - await messageManager.fetchConversations(true); + await messageManager.requestConversationListRefresh('prefetch', { + force: true, + allowDefer: true, + }); return messageManager.getConversations(); }, priority: Priority.HIGH, @@ -227,7 +230,7 @@ function prefetchOnAppLaunch(): void { prefetchConversations(); // 中优先级:帖子列表 - prefetchPosts(['recommend']); + prefetchPosts(['latest']); } /** @@ -254,7 +257,7 @@ function prefetchMessageScreen(): void { * 进入首页时预取 */ function prefetchHomeScreen(): void { - prefetchPosts(['recommend', 'hot', 'latest']); + prefetchPosts(['latest', 'hot', 'follow']); } // ==================== React Hook ==================== diff --git a/src/infrastructure/diff/PostDiffCalculator.ts b/src/infrastructure/diff/PostDiffCalculator.ts new file mode 100644 index 0000000..cc6f8bd --- /dev/null +++ b/src/infrastructure/diff/PostDiffCalculator.ts @@ -0,0 +1,430 @@ +/** + * 帖子差异计算器 + * 计算两个帖子列表之间的差异,支持增量更新 + */ + +import { + PostIdentifier, + PostDiffResult, + PostDiffConfig, + DEFAULT_POST_DIFF_CONFIG, + PostComparator, + PostSorter, + PostDiffStats, +} from './postTypes'; + +/** + * 帖子差异计算器类 + * 用于高效计算帖子列表的变化 + */ +export class PostDiffCalculator { + private config: Required; + private previousPosts: Map = new Map(); + private previousOrder: string[] = []; + private stats: PostDiffStats = { + totalBatches: 0, + totalUpdates: 0, + averageBatchSize: 0, + lastBatchTime: 0, + addedCount: 0, + updatedCount: 0, + deletedCount: 0, + }; + + constructor(config: PostDiffConfig = {}) { + this.config = { ...DEFAULT_POST_DIFF_CONFIG, ...config }; + } + + /** + * 计算两个帖子列表的差异 + * @param oldList 旧帖子列表 + * @param newList 新帖子列表 + * @returns 差异结果 + */ + calculateDiff(oldList: T[], newList: T[]): PostDiffResult { + const result: PostDiffResult = { + added: [], + updated: [], + deleted: [], + moved: [], + unchanged: [], + shouldReset: false, + }; + + // 如果旧列表为空,直接返回新列表作为新增 + if (oldList.length === 0) { + result.added = [...newList]; + this.updateCache(newList); + this.updateStats(0, newList.length, 0); + return result; + } + + // 如果新列表为空,表示全部删除 + if (newList.length === 0) { + result.deleted = oldList.map((post, index) => ({ post, index })); + this.clearCache(); + this.updateStats(0, 0, oldList.length); + return result; + } + + // 检查是否需要重置(变化太大) + const changeRatio = this.calculateChangeRatio(oldList, newList); + if (changeRatio > this.config.resetThreshold) { + result.shouldReset = true; + result.added = [...newList]; + this.updateCache(newList); + this.updateStats(0, newList.length, 0); + return result; + } + + // 构建旧列表的索引映射 + const oldIndexMap = new Map(); + oldList.forEach((post, index) => { + oldIndexMap.set(post.id, index); + }); + + // 构建新列表的索引映射 + const newIndexMap = new Map(); + newList.forEach((post, index) => { + newIndexMap.set(post.id, index); + }); + + // 检测新增、更新和未变更的帖子 + const processedIds = new Set(); + let addedCount = 0; + let updatedCount = 0; + + for (let newIndex = 0; newIndex < newList.length; newIndex++) { + const newPost = newList[newIndex]; + const oldIndex = oldIndexMap.get(newPost.id); + + if (oldIndex === undefined) { + // 新增帖子 + result.added.push(newPost); + addedCount++; + } else { + const oldPost = oldList[oldIndex]; + processedIds.add(newPost.id); + + // 检测是否有更新 + const changes = this.detectChanges(oldPost, newPost); + if (changes && Object.keys(changes).length > 0) { + result.updated.push({ + post: newPost, + index: oldIndex, + changes, + }); + updatedCount++; + } else { + result.unchanged.push(newPost); + } + + // 检测是否移动 + if (oldIndex !== newIndex) { + result.moved.push({ + post: newPost, + fromIndex: oldIndex, + toIndex: newIndex, + }); + } + } + } + + // 检测删除的帖子 + let deletedCount = 0; + for (let oldIndex = 0; oldIndex < oldList.length; oldIndex++) { + const oldPost = oldList[oldIndex]; + if (!processedIds.has(oldPost.id)) { + result.deleted.push({ post: oldPost, index: oldIndex }); + deletedCount++; + } + } + + this.updateCache(newList); + this.updateStats(addedCount, updatedCount, deletedCount); + return result; + } + + /** + * 计算增量差异(基于缓存的上次状态) + * @param newList 新帖子列表 + * @returns 差异结果 + */ + calculateIncrementalDiff(newList: T[]): PostDiffResult { + const oldList = Array.from(this.previousPosts.values()); + // 按照之前的顺序排序 + oldList.sort((a, b) => { + const indexA = this.previousOrder.indexOf(a.id); + const indexB = this.previousOrder.indexOf(b.id); + return indexA - indexB; + }); + return this.calculateDiff(oldList, newList); + } + + /** + * 计算单个帖子的变化 + * @param oldPost 旧帖子 + * @param newPost 新帖子 + * @returns 变化的字段 + */ + detectChanges(oldPost: T, newPost: T): Partial | null { + const changes: Partial = {}; + let hasChanges = false; + + // 如果配置了追踪特定字段,只检测这些字段 + const fieldsToCheck = this.config.detectPartialUpdates + ? this.config.trackedFields + : (Object.keys(newPost) as Array); + + for (const key of fieldsToCheck) { + if (key === 'id') continue; // ID 不变 + + const oldValue = (oldPost as any)[key]; + const newValue = (newPost as any)[key]; + + // 深度比较 + if (this.hasValueChanged(oldValue, newValue)) { + (changes as any)[key] = newValue; + hasChanges = true; + } + } + + return hasChanges ? changes : null; + } + + /** + * 检测值是否变化 + * @param oldValue 旧值 + * @param newValue 新值 + * @returns 是否变化 + */ + private hasValueChanged(oldValue: any, newValue: any): boolean { + // 简单类型直接比较 + if (typeof oldValue !== 'object' || oldValue === null || newValue === null) { + return oldValue !== newValue; + } + + // 数组比较 + if (Array.isArray(oldValue) && Array.isArray(newValue)) { + if (oldValue.length !== newValue.length) return true; + return JSON.stringify(oldValue) !== JSON.stringify(newValue); + } + + // 对象比较 + return JSON.stringify(oldValue) !== JSON.stringify(newValue); + } + + /** + * 计算变化比例 + * @param oldList 旧列表 + * @param newList 新列表 + * @returns 变化比例(0-1) + */ + private calculateChangeRatio(oldList: T[], newList: T[]): number { + if (oldList.length === 0 && newList.length === 0) return 0; + if (oldList.length === 0 || newList.length === 0) return 1; + + const oldIds = new Set(oldList.map(p => p.id)); + const newIds = new Set(newList.map(p => p.id)); + + let commonCount = 0; + oldIds.forEach(id => { + if (newIds.has(id)) { + commonCount++; + } + }); + + const maxLength = Math.max(oldList.length, newList.length); + return 1 - commonCount / maxLength; + } + + /** + * 更新缓存 + * @param posts 帖子列表 + */ + private updateCache(posts: T[]): void { + this.previousPosts.clear(); + this.previousOrder = []; + + for (const post of posts) { + this.previousPosts.set(post.id, post); + this.previousOrder.push(post.id); + } + } + + /** + * 清除缓存 + */ + private clearCache(): void { + this.previousPosts.clear(); + this.previousOrder = []; + } + + /** + * 更新统计信息 + */ + private updateStats(addedCount: number, updatedCount: number, deletedCount: number): void { + this.stats.totalBatches++; + const totalChanges = addedCount + updatedCount + deletedCount; + this.stats.totalUpdates += totalChanges; + this.stats.averageBatchSize = this.stats.totalUpdates / this.stats.totalBatches; + this.stats.lastBatchTime = Date.now(); + this.stats.addedCount += addedCount; + this.stats.updatedCount += updatedCount; + this.stats.deletedCount += deletedCount; + } + + /** + * 设置配置 + * @param config 配置 + */ + setConfig(config: Partial): void { + this.config = { ...this.config, ...config }; + } + + /** + * 获取当前配置 + * @returns 当前配置 + */ + getConfig(): Required { + return { ...this.config }; + } + + /** + * 重置计算器状态 + */ + reset(): void { + this.clearCache(); + this.stats = { + totalBatches: 0, + totalUpdates: 0, + averageBatchSize: 0, + lastBatchTime: 0, + addedCount: 0, + updatedCount: 0, + deletedCount: 0, + }; + } + + /** + * 获取缓存的帖子数量 + * @returns 缓存数量 + */ + getCachedCount(): number { + return this.previousPosts.size; + } + + /** + * 检查帖子是否在缓存中 + * @param postId 帖子 ID + * @returns 是否存在 + */ + hasPost(postId: string): boolean { + return this.previousPosts.has(postId); + } + + /** + * 获取缓存中的帖子 + * @param postId 帖子 ID + * @returns 帖子或 undefined + */ + getCachedPost(postId: string): T | undefined { + return this.previousPosts.get(postId); + } + + /** + * 获取统计信息 + * @returns 统计信息 + */ + getStats(): PostDiffStats { + return { ...this.stats }; + } + + /** + * 批量检测帖子变化 + * @param posts 要检测的帖子列表 + * @returns 变化的帖子列表 + */ + batchDetectChanges(posts: T[]): Array<{ post: T; changes: Partial }> { + const results: Array<{ post: T; changes: Partial }> = []; + + for (const post of posts) { + const cachedPost = this.previousPosts.get(post.id); + if (cachedPost) { + const changes = this.detectChanges(cachedPost, post); + if (changes && Object.keys(changes).length > 0) { + results.push({ post, changes }); + } + } + } + + return results; + } + + /** + * 比较两个帖子列表是否相同 + * @param list1 列表1 + * @param list2 列表2 + * @returns 是否相同 + */ + areEqual(list1: T[], list2: T[]): boolean { + if (list1.length !== list2.length) return false; + + for (let i = 0; i < list1.length; i++) { + if (list1[i].id !== list2[i].id) return false; + } + + return true; + } + + /** + * 获取两个帖子列表的交集ID + * @param list1 列表1 + * @param list2 列表2 + * @returns 交集ID集合 + */ + getIntersection(list1: T[], list2: T[]): Set { + const ids1 = new Set(list1.map(p => p.id)); + const ids2 = new Set(list2.map(p => p.id)); + const intersection = new Set(); + + ids1.forEach(id => { + if (ids2.has(id)) { + intersection.add(id); + } + }); + + return intersection; + } + + /** + * 获取两个帖子列表的差集ID + * @param list1 列表1 + * @param list2 列表2 + * @returns 差集ID集合(在list1中但不在list2中) + */ + getDifference(list1: T[], list2: T[]): Set { + const ids1 = new Set(list1.map(p => p.id)); + const ids2 = new Set(list2.map(p => p.id)); + const difference = new Set(); + + ids1.forEach(id => { + if (!ids2.has(id)) { + difference.add(id); + } + }); + + return difference; + } +} + +/** + * 创建帖子差异计算器实例的工厂函数 + * @param config 配置 + * @returns PostDiffCalculator 实例 + */ +export function createPostDiffCalculator( + config?: PostDiffConfig +): PostDiffCalculator { + return new PostDiffCalculator(config); +} diff --git a/src/infrastructure/diff/PostUpdateBatcher.ts b/src/infrastructure/diff/PostUpdateBatcher.ts new file mode 100644 index 0000000..dafa49b --- /dev/null +++ b/src/infrastructure/diff/PostUpdateBatcher.ts @@ -0,0 +1,505 @@ +/** + * 帖子更新批量处理器 + * 收集更新操作并按批处理,减少 UI 更新次数 + */ + +import { + PostUpdate, + PostUpdateType, + PostBatchUpdateListener, + PostDiffConfig, + DEFAULT_POST_DIFF_CONFIG, + PostIdentifier, + PostUpdateBatch, +} from './postTypes'; + +/** + * 批量处理器配置选项 + */ +export interface PostBatcherOptions { + /** 批量处理间隔(毫秒),默认 16ms(约 60fps) */ + batchInterval?: number; + /** 最大批量大小,默认 50 */ + maxBatchSize?: number; + /** 是否启用去重,默认 true */ + enableDeduplication?: boolean; + /** 是否合并相同帖子的多次更新,默认 true */ + enableMerge?: boolean; + /** 最大等待时间(毫秒),超过此时间强制刷新,默认 100ms */ + maxWaitTime?: number; + /** 是否自动启动批量处理,默认 true */ + autoStart?: boolean; +} + +/** + * 默认批量处理器配置 + */ +export const DEFAULT_POST_BATCHER_OPTIONS: Required = { + batchInterval: 16, + maxBatchSize: 50, + enableDeduplication: true, + enableMerge: true, + maxWaitTime: 100, + autoStart: true, +}; + +/** + * 帖子更新批量处理器类 + */ +export class PostUpdateBatcher { + /** 待处理的更新队列 */ + private pendingUpdates: Map = new Map(); + /** 批量处理定时器 */ + private batchTimer: ReturnType | null = null; + /** 最大等待时间定时器 */ + private maxWaitTimer: ReturnType | null = null; + /** 监听器集合 */ + private listeners: Set = new Set(); + /** 是否正在批处理中 */ + private isProcessing: boolean = false; + /** 配置选项 */ + private options: Required; + /** 处理器是否已启动 */ + private isStarted: boolean = false; + /** 批量处理统计信息 */ + private stats = { + totalBatches: 0, + totalUpdates: 0, + averageBatchSize: 0, + lastBatchTime: 0, + }; + /** 批次 ID 计数器 */ + private batchIdCounter: number = 0; + + constructor(options: PostBatcherOptions = {}) { + this.options = { ...DEFAULT_POST_BATCHER_OPTIONS, ...options }; + if (this.options.autoStart) { + this.start(); + } + } + + /** + * 启动批量处理器 + */ + start(): void { + if (this.isStarted) return; + this.isStarted = true; + this.scheduleBatch(); + } + + /** + * 停止批量处理器 + */ + stop(): void { + this.isStarted = false; + this.clearTimers(); + } + + /** + * 添加更新操作到批量队列 + * @param update 帖子更新操作 + */ + addUpdate(update: PostUpdate): void { + const key = this.generateUpdateKey(update); + + // 去重:如果已有相同帖子 ID 的待处理更新,进行合并 + if (this.options.enableDeduplication && this.pendingUpdates.has(key)) { + const existing = this.pendingUpdates.get(key)!; + + if (this.options.enableMerge) { + const merged = this.mergeUpdates(existing, update); + if (merged) { + this.pendingUpdates.set(key, merged); + return; + } + } + } + + this.pendingUpdates.set(key, update); + + // 如果达到最大批量大小,立即刷新 + if (this.pendingUpdates.size >= this.options.maxBatchSize) { + this.flush(); + } + } + + /** + * 添加多个更新操作 + * @param updates 更新操作数组 + */ + addUpdates(updates: PostUpdate[]): void { + for (const update of updates) { + this.addUpdate(update); + } + } + + /** + * 立即刷新所有待处理更新 + * @returns 处理的更新数组 + */ + flush(): PostUpdate[] { + if (this.pendingUpdates.size === 0 || this.isProcessing) { + return []; + } + + this.isProcessing = true; + this.clearTimers(); + + const updates = Array.from(this.pendingUpdates.values()); + this.pendingUpdates.clear(); + + // 更新统计信息 + this.stats.totalBatches++; + this.stats.totalUpdates += updates.length; + this.stats.averageBatchSize = this.stats.totalUpdates / this.stats.totalBatches; + this.stats.lastBatchTime = Date.now(); + + // 通知监听器 + this.notifyListeners(updates); + + this.isProcessing = false; + + // 重新调度定时器 + this.scheduleBatch(); + + return updates; + } + + /** + * 刷新并返回批次对象 + * @returns 批次对象或 null + */ + flushAsBatch(): PostUpdateBatch | null { + const updates = this.flush(); + if (updates.length === 0) return null; + + return { + batchId: `batch_${++this.batchIdCounter}_${Date.now()}`, + updates, + createdAt: Date.now(), + processed: false, + }; + } + + /** + * 订阅批量更新事件 + * @param listener 监听器函数 + * @returns 取消订阅函数 + */ + subscribe(listener: PostBatchUpdateListener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** + * 取消订阅 + * @param listener 监听器函数 + */ + unsubscribe(listener: PostBatchUpdateListener): void { + this.listeners.delete(listener); + } + + /** + * 获取待处理更新数量 + * @returns 待处理数量 + */ + getPendingCount(): number { + return this.pendingUpdates.size; + } + + /** + * 获取统计信息 + * @returns 统计信息 + */ + getStats() { + return { ...this.stats }; + } + + /** + * 检查是否有待处理更新 + * @returns 是否有待处理更新 + */ + hasPendingUpdates(): boolean { + return this.pendingUpdates.size > 0; + } + + /** + * 清空所有待处理更新 + */ + clearPending(): void { + this.pendingUpdates.clear(); + this.clearTimers(); + } + + /** + * 销毁批量处理器 + */ + destroy(): void { + this.stop(); + this.clearPending(); + this.listeners.clear(); + } + + /** + * 生成更新键值 + * @param update 更新操作 + * @returns 键值 + */ + private generateUpdateKey(update: PostUpdate): string { + // 根据更新类型生成唯一键 + const updateType = (update as PostUpdate).type; + switch (updateType) { + case PostUpdateType.ADD: + case PostUpdateType.BATCH_ADD: + return `${updateType}_${(update as any).timestamp}`; + case PostUpdateType.UPDATE: + case PostUpdateType.BATCH_UPDATE: + return `${updateType}_${this.extractPostId(update)}`; + case PostUpdateType.DELETE: + case PostUpdateType.BATCH_DELETE: + return `${updateType}_${this.extractPostId(update)}`; + case PostUpdateType.MOVE: + return `${updateType}_${(update as any).postId}`; + case PostUpdateType.RESET: + return `${updateType}_${(update as any).timestamp}`; + default: + // 兜底处理未知类型 + return `unknown_${Date.now()}_${Math.random()}`; + } + } + + /** + * 提取帖子 ID + * @param update 更新操作 + * @returns 帖子 ID 或空字符串 + */ + private extractPostId(update: PostUpdate): string { + const payload = (update as any).payload; + if (payload) { + if (payload.postId) return payload.postId; + if (payload.postIds) return payload.postIds.join(','); + } + // 直接从更新对象提取 + if ((update as any).postId) return (update as any).postId; + if ((update as any).postIds) return (update as any).postIds.join(','); + return ''; + } + + /** + * 合并更新操作 + * @param existing 已有更新 + * @param incoming 新更新 + * @returns 合并后的更新或 null(如果无法合并) + */ + private mergeUpdates(existing: PostUpdate, incoming: PostUpdate): PostUpdate | null { + // 只有同类型的更新才能合并 + if (existing.type !== incoming.type) { + return null; + } + + switch (existing.type) { + case PostUpdateType.UPDATE: + // 合并更新字段 + return { + ...existing, + payload: { + ...(existing as any).payload, + updates: { + ...(existing as any).payload?.updates, + ...(incoming as any).payload?.updates, + }, + }, + timestamp: incoming.timestamp, + }; + + case PostUpdateType.BATCH_UPDATE: + // 合并批量更新 + const existingUpdates = (existing as any).payload?.updates || []; + const incomingUpdates = (incoming as any).payload?.updates || []; + const updatesMap = new Map(); + + for (const update of [...existingUpdates, ...incomingUpdates]) { + const existing = updatesMap.get(update.postId); + if (existing) { + existing.changes = { ...existing.changes, ...update.changes }; + } else { + updatesMap.set(update.postId, { ...update }); + } + } + + return { + ...existing, + payload: { + ...(existing as any).payload, + updates: Array.from(updatesMap.values()), + }, + timestamp: incoming.timestamp, + }; + + case PostUpdateType.BATCH_DELETE: + // 合并删除 ID 列表 + const existingIds = new Set((existing as any).payload?.postIds || []); + const incomingIds = (incoming as any).payload?.postIds || []; + for (const id of incomingIds) { + existingIds.add(id); + } + return { + ...existing, + payload: { + ...(existing as any).payload, + postIds: Array.from(existingIds), + }, + timestamp: incoming.timestamp, + }; + + default: + return null; + } + } + + /** + * 调度批量处理 + */ + private scheduleBatch(): void { + if (!this.isStarted) return; + + this.clearTimers(); + + // 设置最大等待时间定时器 + this.maxWaitTimer = setTimeout(() => { + this.flush(); + }, this.options.maxWaitTime); + + // 设置批量处理定时器 + this.batchTimer = setTimeout(() => { + this.flush(); + }, this.options.batchInterval); + } + + /** + * 清除所有定时器 + */ + private clearTimers(): void { + if (this.batchTimer) { + clearTimeout(this.batchTimer); + this.batchTimer = null; + } + if (this.maxWaitTimer) { + clearTimeout(this.maxWaitTimer); + this.maxWaitTimer = null; + } + } + + /** + * 通知监听器 + * @param updates 更新数组 + */ + private notifyListeners(updates: PostUpdate[]): void { + if (this.listeners.size === 0) return; + + // 转换为数组以支持迭代 + const listenersArray = Array.from(this.listeners); + for (const listener of listenersArray) { + try { + listener(updates); + } catch (error) { + console.error('Error in post batch update listener:', error); + } + } + } + + // ==================== 便捷方法 ==================== + + /** + * 添加帖子新增操作 + */ + addPost(post: PostIdentifier, index?: number): void { + this.addUpdate({ + type: PostUpdateType.ADD, + post, + index, + timestamp: Date.now(), + }); + } + + /** + * 添加帖子更新操作 + */ + updatePost(postId: string, updates: Partial): void { + this.addUpdate({ + type: PostUpdateType.UPDATE, + postId, + updates, + timestamp: Date.now(), + }); + } + + /** + * 添加帖子删除操作 + */ + deletePost(postId: string): void { + this.addUpdate({ + type: PostUpdateType.DELETE, + postId, + timestamp: Date.now(), + }); + } + + /** + * 批量添加帖子 + */ + batchAddPosts(posts: PostIdentifier[], startIndex?: number): void { + this.addUpdate({ + type: PostUpdateType.BATCH_ADD, + posts, + startIndex, + timestamp: Date.now(), + }); + } + + /** + * 批量更新帖子 + */ + batchUpdatePosts(updates: Array<{ postId: string; changes: Partial }>): void { + this.addUpdate({ + type: PostUpdateType.BATCH_UPDATE, + updates, + timestamp: Date.now(), + }); + } + + /** + * 批量删除帖子 + */ + batchDeletePosts(postIds: string[]): void { + this.addUpdate({ + type: PostUpdateType.BATCH_DELETE, + postIds, + timestamp: Date.now(), + }); + } + + /** + * 重置帖子列表 + */ + resetPosts(posts: PostIdentifier[]): void { + this.addUpdate({ + type: PostUpdateType.RESET, + posts, + timestamp: Date.now(), + }); + } +} + +/** + * 创建帖子批量处理器的工厂函数 + * @param options 配置选项 + * @returns PostUpdateBatcher 实例 + */ +export function createPostUpdateBatcher( + options?: PostBatcherOptions +): PostUpdateBatcher { + return new PostUpdateBatcher(options); +} diff --git a/src/infrastructure/diff/index.ts b/src/infrastructure/diff/index.ts index dfa746a..5b4959d 100644 --- a/src/infrastructure/diff/index.ts +++ b/src/infrastructure/diff/index.ts @@ -1,8 +1,10 @@ /** * 差异更新模块 - * 提供消息列表的增量更新功能,减少 UI 重渲染次数 + * 提供消息列表和帖子列表的增量更新功能,减少 UI 重渲染次数 */ +// ==================== Message 相关 ==================== + // 类型定义 export { MessageUpdateType, @@ -38,3 +40,45 @@ export { BatcherOptions, DEFAULT_BATCHER_OPTIONS, } from './MessageUpdateBatcher'; + +// ==================== Post 相关 ==================== + +// 类型定义 +export { + PostChangeType, + PostUpdateType, + PostIdentifier, + PostChange, + PostDiffResult, + BasePostUpdate, + AddPostUpdate, + UpdatePostUpdate, + DeletePostUpdate, + MovePostUpdate, + BatchAddPostUpdate, + BatchUpdatePostUpdate, + BatchDeletePostUpdate, + ResetPostUpdate, + PostUpdate, + PostUpdateBatch, + PostDiffConfig, + PostBatchUpdateListener, + PostComparator, + PostSorter, + PostDiffStats, + DEFAULT_POST_DIFF_CONFIG, +} from './postTypes'; + +// 差异计算器 +export { + PostDiffCalculator, + createPostDiffCalculator, +} from './PostDiffCalculator'; + +// 批量处理器 +export { + PostUpdateBatcher, + createPostUpdateBatcher, + PostBatcherOptions, + DEFAULT_POST_BATCHER_OPTIONS, +} from './PostUpdateBatcher'; diff --git a/src/infrastructure/diff/postTypes.ts b/src/infrastructure/diff/postTypes.ts new file mode 100644 index 0000000..3b6634c --- /dev/null +++ b/src/infrastructure/diff/postTypes.ts @@ -0,0 +1,325 @@ +/** + * Post差异更新类型定义 + * 用于帖子列表的增量更新,减少 UI 重渲染次数 + */ + +import { Post } from '../../core/entities/Post'; + +// ==================== 变化类型枚举 ==================== + +/** + * 帖子变化类型枚举 + */ +export enum PostChangeType { + /** 新增帖子 */ + ADDED = 'added', + /** 更新帖子 */ + UPDATED = 'updated', + /** 删除帖子 */ + DELETED = 'deleted', +} + +/** + * 帖子更新类型枚举 + */ +export enum PostUpdateType { + /** 添加单条帖子 */ + ADD = 'ADD', + /** 更新单条帖子 */ + UPDATE = 'UPDATE', + /** 删除单条帖子 */ + DELETE = 'DELETE', + /** 移动帖子位置 */ + MOVE = 'MOVE', + /** 批量添加 */ + BATCH_ADD = 'BATCH_ADD', + /** 批量更新 */ + BATCH_UPDATE = 'BATCH_UPDATE', + /** 批量删除 */ + BATCH_DELETE = 'BATCH_DELETE', + /** 重置整个列表 */ + RESET = 'RESET', +} + +// ==================== 基础接口 ==================== + +/** + * 帖子唯一标识接口 + */ +export interface PostIdentifier { + /** 帖子唯一 ID */ + id: string; + /** 帖子创建时间(可选,用于排序) */ + createdAt?: string; + /** 更新时间(可选,用于检测变化) */ + updatedAt?: string; +} + +/** + * 单个帖子变化记录 + */ +export interface PostChange { + /** 变化类型 */ + type: PostChangeType; + /** 帖子数据 */ + post: T; + /** 变化前的索引位置(用于删除和移动) */ + oldIndex?: number; + /** 变化后的索引位置(用于新增和移动) */ + newIndex?: number; + /** 变化的字段(用于更新) */ + changes?: Partial; +} + +// ==================== 差异结果接口 ==================== + +/** + * Post差异计算结果接口 + */ +export interface PostDiffResult { + /** 新增的帖子 */ + added: T[]; + /** 更新的帖子(包含变化详情) */ + updated: Array<{ + post: T; + index: number; + changes: Partial; + }>; + /** 删除的帖子 */ + deleted: Array<{ + post: T; + index: number; + }>; + /** 移动的帖子 */ + moved: Array<{ + post: T; + fromIndex: number; + toIndex: number; + }>; + /** 未变更的帖子 */ + unchanged: T[]; + /** 是否需要重置(变化比例过大时) */ + shouldReset: boolean; +} + +// ==================== 更新操作接口 ==================== + +/** + * 基础帖子更新操作接口 + */ +export interface BasePostUpdate { + /** 更新类型 */ + type: PostUpdateType; + /** 更新时间戳 */ + timestamp: number; + /** 社区 ID(可选) */ + channelId?: string; + /** 载荷数据(可选,用于合并更新) */ + payload?: Record; +} + +/** + * 添加单条帖子更新 + */ +export interface AddPostUpdate extends BasePostUpdate { + type: PostUpdateType.ADD; + /** 帖子数据 */ + post: PostIdentifier; + /** 插入位置索引(可选,默认追加到末尾) */ + index?: number; +} + +/** + * 更新单条帖子更新 + */ +export interface UpdatePostUpdate extends BasePostUpdate { + type: PostUpdateType.UPDATE; + /** 帖子 ID */ + postId: string; + /** 更新的字段 */ + updates: Partial; +} + +/** + * 删除单条帖子更新 + */ +export interface DeletePostUpdate extends BasePostUpdate { + type: PostUpdateType.DELETE; + /** 帖子 ID */ + postId: string; +} + +/** + * 移动帖子更新 + */ +export interface MovePostUpdate extends BasePostUpdate { + type: PostUpdateType.MOVE; + /** 帖子 ID */ + postId: string; + /** 目标位置索引 */ + toIndex: number; +} + +/** + * 批量添加帖子更新 + */ +export interface BatchAddPostUpdate extends BasePostUpdate { + type: PostUpdateType.BATCH_ADD; + /** 帖子列表 */ + posts: PostIdentifier[]; + /** 插入位置索引(可选,默认追加到末尾) */ + startIndex?: number; +} + +/** + * 批量更新帖子更新 + */ +export interface BatchUpdatePostUpdate extends BasePostUpdate { + type: PostUpdateType.BATCH_UPDATE; + /** 批量更新项 */ + updates: Array<{ + postId: string; + changes: Partial; + }>; +} + +/** + * 批量删除帖子更新 + */ +export interface BatchDeletePostUpdate extends BasePostUpdate { + type: PostUpdateType.BATCH_DELETE; + /** 要删除的帖子 ID 列表 */ + postIds: string[]; +} + +/** + * 重置帖子列表更新 + */ +export interface ResetPostUpdate extends BasePostUpdate { + type: PostUpdateType.RESET; + /** 新的帖子列表 */ + posts: PostIdentifier[]; +} + +/** + * 帖子更新联合类型 + */ +export type PostUpdate = + | AddPostUpdate + | UpdatePostUpdate + | DeletePostUpdate + | MovePostUpdate + | BatchAddPostUpdate + | BatchUpdatePostUpdate + | BatchDeletePostUpdate + | ResetPostUpdate; + +// ==================== 批量更新接口 ==================== + +/** + * Post批量更新类型 + */ +export interface PostUpdateBatch { + /** 批次 ID */ + batchId: string; + /** 更新列表 */ + updates: PostUpdate[]; + /** 创建时间戳 */ + createdAt: number; + /** 是否已处理 */ + processed: boolean; +} + +// ==================== 配置接口 ==================== + +/** + * Post差异配置接口 + */ +export interface PostDiffConfig { + /** 批量处理间隔(毫秒),默认 16ms(60fps) */ + batchInterval?: number; + /** 最大批量大小,默认 50 */ + maxBatchSize?: number; + /** 是否启用去重,默认 true */ + enableDeduplication?: boolean; + /** 是否合并相同帖子的多次更新,默认 true */ + enableMerge?: boolean; + /** 帖子唯一标识字段,默认 'id' */ + idField?: string; + /** 排序字段,默认 'createdAt' */ + sortField?: string; + /** 变化比例阈值,超过此值触发重置,默认 0.5 */ + resetThreshold?: number; + /** 是否检测部分字段更新 */ + detectPartialUpdates?: boolean; + /** 需要检测变化的字段列表 */ + trackedFields?: (keyof Post)[]; +} + +/** + * 默认Post差异配置常量 + */ +export const DEFAULT_POST_DIFF_CONFIG: Required = { + batchInterval: 16, + maxBatchSize: 50, + enableDeduplication: true, + enableMerge: true, + idField: 'id', + sortField: 'createdAt', + resetThreshold: 0.5, + detectPartialUpdates: true, + trackedFields: [ + 'likesCount', + 'commentsCount', + 'sharesCount', + 'viewsCount', + 'favoritesCount', + 'isLiked', + 'isFavorited', + 'isPinned', + 'status', + 'title', + 'content', + 'images', + 'tags', + ], +}; + +// ==================== 监听器类型 ==================== + +/** + * 批量更新监听器类型 + */ +export type PostBatchUpdateListener = (updates: PostUpdate[]) => void; + +/** + * Post比较函数类型 + */ +export type PostComparator = (a: T, b: T) => boolean; + +/** + * Post排序函数类型 + */ +export type PostSorter = (a: T, b: T) => number; + +// ==================== 统计接口 ==================== + +/** + * Post差异统计接口 + */ +export interface PostDiffStats { + /** 总批次数 */ + totalBatches: number; + /** 总更新数 */ + totalUpdates: number; + /** 平均批量大小 */ + averageBatchSize: number; + /** 上次批次时间 */ + lastBatchTime: number; + /** 新增帖子数 */ + addedCount: number; + /** 更新帖子数 */ + updatedCount: number; + /** 删除帖子数 */ + deletedCount: number; +} \ No newline at end of file diff --git a/src/infrastructure/navigation/hooks/useNavigationState.ts b/src/infrastructure/navigation/hooks/useNavigationState.ts deleted file mode 100644 index 2792e37..0000000 --- a/src/infrastructure/navigation/hooks/useNavigationState.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * 导航状态管理 Hook - * 管理导航相关状态,如当前路由、导航历史等 - */ - -import { useState, useCallback, useEffect } from 'react'; -import { useNavigationState as useRNNavigationState, Route } from '@react-navigation/native'; -import type { RootStackParamList } from '../../../navigation/types'; - -type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab'; - -interface NavigationState { - currentTab: TabName; - isCollapsed: boolean; - isReady: boolean; -} - -interface NavigationActions { - setCurrentTab: (tab: TabName) => void; - toggleCollapse: () => void; - setIsReady: (ready: boolean) => void; -} - -/** - * 导航状态管理 Hook - */ -export function useNavigationState(): NavigationState & NavigationActions { - const [currentTab, setCurrentTab] = useState('HomeTab'); - const [isCollapsed, setIsCollapsed] = useState(false); - const [isReady, setIsReady] = useState(false); - - const toggleCollapse = useCallback(() => { - setIsCollapsed(prev => !prev); - }, []); - - return { - currentTab, - isCollapsed, - isReady, - setCurrentTab, - toggleCollapse, - setIsReady, - }; -} - -/** - * 获取当前路由名称 - */ -export function useCurrentRouteName(): string | null { - const routeName = useRNNavigationState(state => state?.routes[state.index]?.name ?? null); - return routeName; -} - -/** - * 检查是否在指定路由 - */ -export function useIsRoute(routeName: keyof RootStackParamList): boolean { - const currentRoute = useCurrentRouteName(); - return currentRoute === routeName; -} diff --git a/src/infrastructure/navigation/navigationService.ts b/src/infrastructure/navigation/navigationService.ts deleted file mode 100644 index f9ca8fa..0000000 --- a/src/infrastructure/navigation/navigationService.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * 导航服务层 - * 提供全局导航功能,解耦组件与导航状态的直接依赖 - */ - -import { NavigationContainerRef, Route } from '@react-navigation/native'; - -class NavigationService { - private navigationRef: NavigationContainerRef | null = null; - private isReady: boolean = false; - - /** - * 设置导航引用 - */ - setNavigationRef(ref: NavigationContainerRef | null): void { - this.navigationRef = ref; - this.isReady = ref !== null; - } - - /** - * 检查导航是否就绪 - */ - isNavigationReady(): boolean { - return this.isReady && this.navigationRef !== null; - } - - /** - * 导航到指定路由 - */ - navigate(routeName: string, params?: any): void { - if (!this.isNavigationReady()) { - console.warn('[NavigationService] Navigation not ready'); - return; - } - this.navigationRef!.navigate(routeName as any, params); - } - - /** - * 返回上一页 - */ - goBack(): void { - if (!this.isNavigationReady()) { - console.warn('[NavigationService] Navigation not ready'); - return; - } - this.navigationRef!.goBack(); - } - - /** - * 获取当前路由 - */ - getCurrentRoute(): Route | null { - if (!this.isNavigationReady()) { - return null; - } - return this.navigationRef!.getCurrentRoute(); - } - - /** - * 获取当前路由名称 - */ - getCurrentRouteName(): string | null { - const route = this.getCurrentRoute(); - return route?.name ?? null; - } - - /** - * 重置导航到指定路由 - */ - reset(routes: { name: string; params?: any }[], index: number = 0): void { - if (!this.isNavigationReady()) { - console.warn('[NavigationService] Navigation not ready'); - return; - } - this.navigationRef!.reset({ - index, - routes: routes.map(r => ({ name: r.name, params: r.params })), - }); - } - - /** - * 替换当前路由 - */ - replace(routeName: string, params?: any): void { - if (!this.isNavigationReady()) { - console.warn('[NavigationService] Navigation not ready'); - return; - } - this.navigationRef!.dispatch({ - type: 'REPLACE', - payload: { name: routeName, params }, - }); - } - - /** - * 导航到根路由 - */ - navigateToRoot(): void { - this.reset([{ name: 'Main' }]); - } - - /** - * 导航到认证页面 - */ - navigateToAuth(): void { - this.reset([{ name: 'Auth' }]); - } -} - -export const navigationService = new NavigationService(); diff --git a/src/infrastructure/navigation/types.ts b/src/infrastructure/navigation/types.ts deleted file mode 100644 index 065edc6..0000000 --- a/src/infrastructure/navigation/types.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * 导航基础设施类型定义 - */ - -import { NavigationContainerRef, Route } from '@react-navigation/native'; -import type { - RootStackParamList, - MainTabParamList, - HomeStackParamList, - MessageStackParamList, - ScheduleStackParamList, - ProfileStackParamList, - AuthStackParamList, -} from '../../navigation/types'; - -// ==================== 导航服务类型 ==================== -export interface NavigationServiceInterface { - setNavigationRef(ref: NavigationContainerRef | null): void; - isNavigationReady(): boolean; - navigate(routeName: string, params?: any): void; - goBack(): void; - getCurrentRoute(): Route | null; - getCurrentRouteName(): string | null; - reset(routes: { name: string; params?: any }[], index?: number): void; - replace(routeName: string, params?: any): void; - navigateToRoot(): void; - navigateToAuth(): void; -} - -// ==================== Tab 类型 ==================== -export type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab'; - -export interface NavItemConfig { - name: TabName; - label: string; - icon: string; - iconOutline: string; -} - -// ==================== 导航配置常量 ==================== -export const NAVIGATION_CONSTANTS = { - SIDEBAR_WIDTH_DESKTOP: 240, - SIDEBAR_WIDTH_TABLET: 200, - SIDEBAR_COLLAPSED_WIDTH: 72, - MOBILE_TAB_FLOATING_MARGIN: 12, - LAST_ACTIVE_TAB_KEY: 'last_active_tab', -} as const; - -// ==================== 重新导出导航类型 ==================== -export type { - RootStackParamList, - MainTabParamList, - HomeStackParamList, - MessageStackParamList, - ScheduleStackParamList, - ProfileStackParamList, - AuthStackParamList, -}; diff --git a/src/infrastructure/pagination/index.ts b/src/infrastructure/pagination/index.ts index de25c21..495dd7d 100644 --- a/src/infrastructure/pagination/index.ts +++ b/src/infrastructure/pagination/index.ts @@ -19,6 +19,16 @@ export type { FetchPageFunction, } from './types'; +// 导出游标分页相关类型 +export type { + CursorDirection, + CursorPaginationConfig, + CursorPaginationState, + CursorPaginationActions, + UseCursorPaginationReturn, + CursorFetchFunction, +} from './types'; + // 导出工具函数和常量 export { DEFAULT_PAGINATION_CONFIG, diff --git a/src/infrastructure/pagination/types.ts b/src/infrastructure/pagination/types.ts index 6c96bab..6e54336 100644 --- a/src/infrastructure/pagination/types.ts +++ b/src/infrastructure/pagination/types.ts @@ -180,3 +180,85 @@ export function createPageCache( export function isCacheExpired(cache: PageCache, ttl: number): boolean { return Date.now() - cache.cachedAt > ttl; } + +// ==================== 游标分页相关类型 ==================== + +/** + * 游标分页方向 + */ +export type CursorDirection = 'forward' | 'backward'; + +/** + * 游标分页配置 + */ +export interface CursorPaginationConfig { + /** 每页数量 */ + pageSize: number; + /** 是否启用双向分页 */ + bidirectional?: boolean; + /** 是否自动加载第一页,默认为 true */ + autoLoad?: boolean; +} + +/** + * 游标分页状态 + */ +export interface CursorPaginationState { + /** 数据项列表 */ + list: T[]; + /** 下一页游标 */ + nextCursor: string | null; + /** 上一页游标 */ + prevCursor: string | null; + /** 是否有更多数据 */ + hasMore: boolean; + /** 是否正在加载 */ + isLoading: boolean; + /** 是否首屏加载(空列表初始化) */ + isInitialLoading: boolean; + /** 是否正在加载更多 */ + isLoadingMore: boolean; + /** 是否正在刷新 */ + isRefreshing: boolean; + /** 错误信息 */ + error: string | null; + /** 是否为首次加载 */ + isFirstLoad: boolean; +} + +/** + * 游标分页操作 + */ +export interface CursorPaginationActions { + /** 加载更多(下一页) */ + loadMore: () => Promise; + /** 加载上一页(双向分页) */ + loadPrevious: () => Promise; + /** 刷新数据(重新从第一页加载) */ + refresh: () => Promise; + /** 重置状态 */ + reset: () => void; + /** 设置数据(用于外部数据注入) */ + setList: (list: T[], nextCursor: string | null, prevCursor: string | null, hasMore: boolean) => void; +} + +/** + * 游标分页 Hook 返回值 + */ +export interface UseCursorPaginationReturn extends CursorPaginationState, CursorPaginationActions {} + +/** + * 游标分页数据获取函数 + */ +export type CursorFetchFunction = (params: { + cursor?: string; + direction: CursorDirection; + pageSize: number; + /** 额外参数 */ + extraParams?: P; +}) => Promise<{ + list: T[]; + next_cursor: string | null; + prev_cursor: string | null; + has_more: boolean; +}>; diff --git a/src/navigation/AuthNavigator.tsx b/src/navigation/AuthNavigator.tsx deleted file mode 100644 index f1041be..0000000 --- a/src/navigation/AuthNavigator.tsx +++ /dev/null @@ -1,25 +0,0 @@ -/** - * 认证流程导航 - * 处理登录、注册、忘记密码等认证页面 - */ -import React from 'react'; -import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import type { AuthStackParamList } from './types'; - -import { LoginScreen, RegisterScreen, ForgotPasswordScreen } from '../screens/auth'; - -const AuthStack = createNativeStackNavigator(); - -export function AuthNavigator() { - return ( - - - - - - ); -} diff --git a/src/navigation/DesktopNavigator.tsx b/src/navigation/DesktopNavigator.tsx deleted file mode 100644 index fd77770..0000000 --- a/src/navigation/DesktopNavigator.tsx +++ /dev/null @@ -1,261 +0,0 @@ -/** - * 桌面端导航器 - * 为平板/桌面设备提供侧边栏导航体验 - */ -import React, { useState, useCallback, useEffect } from 'react'; -import { - View, - StyleSheet, - ScrollView, - TouchableOpacity, - Text, - Animated, - Platform, -} from 'react-native'; -import { useSafeAreaInsets, SafeAreaView } from 'react-native-safe-area-context'; -import { MaterialCommunityIcons } from '@expo/vector-icons'; - -import type { TabName, NavItemConfig } from '../infrastructure/navigation/types'; -import { NAVIGATION_CONSTANTS } from '../infrastructure/navigation/types'; -import { colors, shadows } from '../theme'; -import { useNavigationState } from '../infrastructure/navigation/hooks/useNavigationState'; - -// 导入屏幕 -import { HomeScreen } from '../screens/home'; -import { ScheduleScreen } from '../screens/schedule'; -import { MessageListScreen } from '../screens/message'; -import { ProfileScreen } from '../screens/profile'; - -// 侧边栏常量 -const { SIDEBAR_WIDTH_DESKTOP, SIDEBAR_WIDTH_TABLET, SIDEBAR_COLLAPSED_WIDTH } = NAVIGATION_CONSTANTS; - -// 导航项配置 -const NAV_ITEMS: NavItemConfig[] = [ - { name: 'HomeTab', label: '首页', icon: 'home', iconOutline: 'home-outline' }, - { name: 'MessageTab', label: '消息', icon: 'message-text', iconOutline: 'message-text-outline' }, - { name: 'ScheduleTab', label: '课表', icon: 'calendar-today', iconOutline: 'calendar-today' }, - { name: 'ProfileTab', label: '我的', icon: 'account', iconOutline: 'account-outline' }, -]; - -interface DesktopNavigatorProps { - unreadCount?: number; -} - -export function DesktopNavigator({ unreadCount = 0 }: DesktopNavigatorProps) { - const insets = useSafeAreaInsets(); - const { currentTab, isCollapsed, setCurrentTab, toggleCollapse, setIsReady } = useNavigationState(); - const [isDesktop, setIsDesktop] = useState(false); - - // 检测是否是桌面尺寸 - useEffect(() => { - const checkDesktop = () => { - const width = window.innerWidth || document.documentElement.clientWidth; - setIsDesktop(width >= SIDEBAR_WIDTH_DESKTOP); - }; - - checkDesktop(); - window.addEventListener('resize', checkDesktop); - setIsReady(true); - - return () => window.removeEventListener('resize', checkDesktop); - }, [setIsReady]); - - // 计算侧边栏宽度 - const sidebarWidth = isCollapsed ? SIDEBAR_COLLAPSED_WIDTH : (isDesktop ? SIDEBAR_WIDTH_DESKTOP : SIDEBAR_WIDTH_TABLET); - - // 处理 Tab 切换 - const handleTabChange = useCallback((tab: TabName) => { - setCurrentTab(tab); - }, [setCurrentTab]); - - // 渲染当前 Tab 的内容 - const renderTabContent = () => { - switch (currentTab) { - case 'HomeTab': - return ; - case 'MessageTab': - return ; - case 'ScheduleTab': - return ; - case 'ProfileTab': - return ; - default: - return ; - } - }; - - return ( - - {/* 侧边栏 */} - - {/* Logo 区域 */} - - - {!isCollapsed && ( - 胡萝卜BBS - )} - - - {/* 导航项 */} - - {NAV_ITEMS.map((item) => { - const isActive = currentTab === item.name; - const showBadge = item.name === 'MessageTab' && unreadCount > 0; - - return ( - handleTabChange(item.name)} - activeOpacity={0.7} - > - - - {showBadge && ( - - {unreadCount > 99 ? '99+' : unreadCount} - - )} - - {!isCollapsed && ( - - {item.label} - - )} - - ); - })} - - - {/* 折叠按钮 */} - - - - - - {/* 主内容区域 */} - - {renderTabContent()} - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - flexDirection: 'row', - backgroundColor: colors.background.default, - }, - sidebar: { - backgroundColor: colors.background.paper, - borderRightWidth: 1, - borderRightColor: colors.divider, - flexDirection: 'column', - ...shadows.md, - }, - sidebarHeader: { - paddingHorizontal: 16, - paddingVertical: 20, - borderBottomWidth: 1, - borderBottomColor: colors.divider, - alignItems: 'center', - justifyContent: 'center', - flexDirection: 'row', - }, - logoText: { - fontSize: 18, - fontWeight: '700', - color: colors.primary.main, - marginLeft: 8, - }, - sidebarContent: { - flex: 1, - paddingTop: 8, - }, - sidebarItem: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 12, - marginHorizontal: 8, - marginVertical: 4, - borderRadius: 12, - }, - sidebarItemActive: { - backgroundColor: `${colors.primary.main}15`, - }, - sidebarItemCollapsed: { - justifyContent: 'center', - paddingHorizontal: 0, - }, - sidebarIconContainer: { - position: 'relative', - width: 40, - height: 40, - alignItems: 'center', - justifyContent: 'center', - borderRadius: 12, - }, - sidebarLabel: { - fontSize: 15, - fontWeight: '500', - color: colors.text.secondary, - marginLeft: 12, - flex: 1, - }, - sidebarLabelActive: { - color: colors.primary.main, - fontWeight: '600', - }, - collapseButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'flex-end', - paddingHorizontal: 16, - paddingVertical: 12, - borderTopWidth: 1, - borderTopColor: colors.divider, - }, - collapseButtonCollapsed: { - justifyContent: 'center', - paddingHorizontal: 0, - }, - badge: { - position: 'absolute', - top: 2, - right: 2, - backgroundColor: colors.error.main, - borderRadius: 10, - minWidth: 18, - height: 18, - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: 4, - }, - badgeText: { - color: colors.primary.contrast, - fontSize: 10, - fontWeight: '600', - }, - mainContent: { - flex: 1, - backgroundColor: colors.background.default, - }, -}); diff --git a/src/navigation/HomeNavigator.tsx b/src/navigation/HomeNavigator.tsx deleted file mode 100644 index 2589651..0000000 --- a/src/navigation/HomeNavigator.tsx +++ /dev/null @@ -1,49 +0,0 @@ -/** - * 首页 Stack 导航 - * 处理首页相关页面:首页、搜索等 - */ -import React from 'react'; -import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import type { HomeStackParamList } from './types'; - -import { colors } from '../theme'; -import { HomeScreen, SearchScreen } from '../screens/home'; - -const HomeStack = createNativeStackNavigator(); - -export function HomeNavigator() { - return ( - - - - - ); -} diff --git a/src/navigation/MainNavigator.tsx b/src/navigation/MainNavigator.tsx deleted file mode 100644 index 4d0dbea..0000000 --- a/src/navigation/MainNavigator.tsx +++ /dev/null @@ -1,100 +0,0 @@ -/** - * 主导航组件(重构版) - * - * 此文件仅保留导航器组装逻辑,所有业务逻辑已解耦至: - * - RootNavigator: 处理根导航和认证状态 - * - DesktopNavigator: 处理桌面端侧边栏导航 - * - SimpleMobileTabNavigator: 处理移动端 Tab 导航 - * - navigationService: 提供全局导航方法 - * - * 原文件 1118 行已精简至约 200 行 - */ - -import React, { useEffect, useState } from 'react'; -import { LinkingOptions } from '@react-navigation/native'; -import { Platform } from 'react-native'; - -import type { RootStackParamList } from './types'; -import { RootNavigator } from './RootNavigator'; - -// 导入认证 Store -import { useAuthStore } from '../stores'; - -// Deep linking 配置 -const linking: LinkingOptions = { - prefixes: [], - config: { - screens: { - Auth: { - screens: { - Login: 'login', - Register: 'register', - ForgotPassword: 'forgot-password', - }, - }, - Main: { - screens: { - HomeTab: { - screens: { - Home: 'home', - Search: 'search', - }, - }, - MessageTab: { - screens: { - MessageList: 'messages', - Notifications: 'notifications', - }, - }, - ProfileTab: { - screens: { - Profile: 'me', - Settings: 'settings', - EditProfile: 'me/edit', - AccountSecurity: 'me/security', - NotificationSettings: 'me/notifications', - BlockedUsers: 'me/blocked', - }, - }, - }, - }, - PostDetail: 'posts/:postId', - UserProfile: 'users/:userId', - CreatePost: 'posts/create', - Chat: 'chat/:conversationId', - FollowList: 'users/:userId/:type', - }, - }, -}; - -/** - * 主导航组件 - * - * 职责: - * 1. 初始化认证状态 - * 2. 根据认证状态渲染 RootNavigator - * 3. 配置 Deep Linking - */ -export default function MainNavigator() { - const { isAuthenticated, fetchCurrentUser } = useAuthStore(); - const [isInitializing, setIsInitializing] = useState(true); - - // 初始化认证状态 - useEffect(() => { - const initAuth = async () => { - await fetchCurrentUser(); - setIsInitializing(false); - }; - initAuth(); - }, [fetchCurrentUser]); - - return ( - - ); -} - -// 导出 linking 配置供外部使用 -export { linking }; diff --git a/src/navigation/MessageNavigator.tsx b/src/navigation/MessageNavigator.tsx deleted file mode 100644 index cb343c5..0000000 --- a/src/navigation/MessageNavigator.tsx +++ /dev/null @@ -1,60 +0,0 @@ -/** - * 消息 Stack 导航 - * 处理消息相关页面:消息列表、通知等 - */ -import React from 'react'; -import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import type { MessageStackParamList } from './types'; - -import { colors } from '../theme'; -import { - MessageListScreen, - NotificationsScreen, - PrivateChatInfoScreen, -} from '../screens/message'; - -const MessageStack = createNativeStackNavigator(); - -export function MessageNavigator() { - return ( - - - - - - ); -} diff --git a/src/navigation/MobileTabNavigatorWithDelay.tsx b/src/navigation/MobileTabNavigatorWithDelay.tsx deleted file mode 100644 index 2417596..0000000 --- a/src/navigation/MobileTabNavigatorWithDelay.tsx +++ /dev/null @@ -1,555 +0,0 @@ -/** - * 移动端 Tab Navigator(完全独立版本) - * - * 这个组件完全独立于 MainNavigator.tsx 中的其他导航组件, - * 用于解决从大屏切换到小屏时的白屏问题。 - * - * 问题根源: - * - React Navigation 的 TabNavigator 在初始化时需要完整的 navigation state - * - 从大屏(Sidebar)切换到小屏(BottomTab)时,state 可能还没有准备好 - * - 导致 TabRouter 报错:"Cannot read properties of undefined (reading 'filter')" - * - * 解决方案: - * - 完全独立的组件,不依赖外部 navigation state - * - 使用延迟初始化,确保 React Navigation 内部状态就绪 - * - 错误边界和重试机制 - */ - -import React, { useEffect, useState, useRef, useCallback } from 'react'; -import { - View, - ActivityIndicator, - StyleSheet, - AppState, - Platform, -} from 'react-native'; -import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; -import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { MaterialCommunityIcons } from '@expo/vector-icons'; - -import { colors, shadows } from '../theme'; -import { useTotalUnreadCount } from '../stores'; -import { messageManager } from '../stores'; - -// ==================== 导入屏幕组件 ==================== -import { HomeScreen, PostDetailScreen, SearchScreen } from '../screens/home'; -import { ScheduleScreen, CourseDetailScreen } from '../screens/schedule'; -import { - MessageListScreen, - ChatScreen, - NotificationsScreen, - CreateGroupScreen, - JoinGroupScreen, - GroupRequestDetailScreen, - GroupInviteDetailScreen, - GroupInfoScreen, - GroupMembersScreen, - PrivateChatInfoScreen -} from '../screens/message'; -import { ProfileScreen, SettingsScreen, EditProfileScreen, NotificationSettingsScreen, BlockedUsersScreen, AccountSecurityScreen } from '../screens/profile'; -import { CreatePostScreen } from '../screens/create'; -import { UserScreen } from '../screens/profile'; -import FollowListScreen from '../screens/profile/FollowListScreen'; - -// ==================== 类型定义 ==================== -export type MainTabParamList = { - HomeTab: undefined; - MessageTab: undefined; - ScheduleTab: undefined; - ProfileTab: undefined; -}; - -export type HomeStackParamList = { - Home: undefined; - Search: undefined; - CreatePost: undefined; -}; - -export type MessageStackParamList = { - MessageList: undefined; - Notifications: undefined; - PrivateChatInfo: { conversationId: string; userId: string }; -}; - -export type ScheduleStackParamList = { - Schedule: undefined; - CourseDetail: { courseId: string }; -}; - -export type ProfileStackParamList = { - Profile: undefined; - Settings: undefined; - EditProfile: undefined; - AccountSecurity: undefined; - MyPosts: undefined; - Bookmarks: undefined; - NotificationSettings: undefined; - BlockedUsers: undefined; -}; - -// ==================== Stack Navigators ==================== -const HomeStack = createNativeStackNavigator(); -const MessageStack = createNativeStackNavigator(); -const ScheduleStack = createNativeStackNavigator(); -const ProfileStack = createNativeStackNavigator(); -const Tab = createBottomTabNavigator(); - -// ==================== 常量 ==================== -const MOBILE_TAB_FLOATING_MARGIN = 12; -const INITIALIZATION_DELAY = 500; // ms,增加延迟时间确保 React Navigation 完全初始化 -const MAX_RETRY_ATTEMPTS = 5; -const RETRY_DELAY = 500; // ms - -// ==================== Stack Navigator 组件 ==================== - -function HomeStackNavigatorComponent() { - return ( - - - - - - ); -} - -function MessageStackNavigatorComponent() { - return ( - - - - - - ); -} - -function ScheduleStackNavigatorComponent() { - return ( - - - - - ); -} - -function ProfileStackNavigatorComponent() { - return ( - - - - - - - - - - - ); -} - -// ==================== 主组件:MobileTabNavigatorWithDelay ==================== - -export function MobileTabNavigatorWithDelay() { - const insets = useSafeAreaInsets(); - const messageUnreadCount = useTotalUnreadCount(); - const [isReady, setIsReady] = useState(false); - const [hasError, setHasError] = useState(false); - const initAttemptRef = useRef(0); - - // 初始化 MessageManager - useEffect(() => { - messageManager.initialize(); - }, []); - - // 延迟初始化 - useEffect(() => { - let timeoutId: NodeJS.Timeout; - let isMounted = true; - - const initializeNavigator = async () => { - try { - initAttemptRef.current += 1; - console.log( - `[MobileTabNavigator] Initialization attempt ${initAttemptRef.current}/${MAX_RETRY_ATTEMPTS}` - ); - - // 延迟初始化 - await new Promise((resolve) => { - timeoutId = setTimeout(resolve, INITIALIZATION_DELAY); - }); - - if (!isMounted) return; - - // 后台检测 - if (Platform.OS !== 'web' && AppState.currentState === 'background') { - console.log('[MobileTabNavigator] App in background, delaying...'); - await new Promise((resolve) => { - timeoutId = setTimeout(resolve, 300); - }); - } - - if (!isMounted) return; - - setIsReady(true); - setHasError(false); - console.log('[MobileTabNavigator] Initialization successful'); - } catch (error) { - console.error('[MobileTabNavigator] Initialization error:', error); - - if (!isMounted) return; - - if (initAttemptRef.current < MAX_RETRY_ATTEMPTS) { - console.log('[MobileTabNavigator] Retrying...'); - timeoutId = setTimeout(initializeNavigator, RETRY_DELAY); - } else { - setHasError(true); - } - } - }; - - initializeNavigator(); - - return () => { - isMounted = false; - if (timeoutId) { - clearTimeout(timeoutId); - } - }; - }, []); - - // 渲染加载状态 - if (!isReady && !hasError) { - return ( - - - - ); - } - - // 渲染错误状态(带重试按钮) - if (hasError) { - return ( - - - - - ); - } - - // 渲染 Tab Navigator - return ( - - ( - - - - ), - }} - /> - 0 - ? messageUnreadCount > 99 - ? '99+' - : messageUnreadCount - : undefined, - tabBarIcon: ({ color, size, focused }) => ( - - - - ), - }} - /> - ( - - - - ), - }} - /> - ( - - - - ), - }} - /> - - ); -} - -// ==================== 样式 ==================== -const styles = StyleSheet.create({ - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: colors.background.default, - }, - errorContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: colors.background.default, - padding: 20, - }, - retryIndicator: { - marginTop: 16, - }, - tabIconContainer: { - alignItems: 'center', - justifyContent: 'center', - width: 38, - height: 30, - borderRadius: 15, - }, - tabIconActive: { - backgroundColor: `${colors.primary.main}20`, - transform: [{ translateY: -1 }], - }, -}); diff --git a/src/navigation/ProfileNavigator.tsx b/src/navigation/ProfileNavigator.tsx deleted file mode 100644 index 0d57373..0000000 --- a/src/navigation/ProfileNavigator.tsx +++ /dev/null @@ -1,101 +0,0 @@ -/** - * 个人中心 Stack 导航 - * 处理个人中心相关页面 - */ -import React from 'react'; -import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import type { ProfileStackParamList } from './types'; - -import { colors } from '../theme'; -import { - ProfileScreen, - SettingsScreen, - EditProfileScreen, - NotificationSettingsScreen, - BlockedUsersScreen, - AccountSecurityScreen, -} from '../screens/profile'; - -const ProfileStack = createNativeStackNavigator(); - -export function ProfileNavigator() { - return ( - - - - - - - - - - - ); -} diff --git a/src/navigation/RootNavigator.tsx b/src/navigation/RootNavigator.tsx deleted file mode 100644 index c996800..0000000 --- a/src/navigation/RootNavigator.tsx +++ /dev/null @@ -1,275 +0,0 @@ -/** - * 根导航器 - * 处理整个应用的顶级导航,包括认证状态切换 - */ -import React, { useEffect, useState } from 'react'; -import { View, ActivityIndicator, StyleSheet } from 'react-native'; -import { NavigationContainer } from '@react-navigation/native'; -import { createNativeStackNavigator } from '@react-navigation/native-stack'; - -import type { RootStackParamList } from './types'; -import { colors } from '../theme'; -import { navigationService } from '../infrastructure/navigation/navigationService'; - -import { AuthNavigator } from './AuthNavigator'; -import { SimpleMobileTabNavigator } from './SimpleMobileTabNavigator'; -import { DesktopNavigator } from './DesktopNavigator'; - -import { useResponsive } from '../hooks'; -import { useTotalUnreadCount } from '../stores'; - -// 导入全局屏幕组件 -import { PostDetailScreen } from '../screens/home'; -import { UserScreen } from '../screens/profile'; -import FollowListScreen from '../screens/profile/FollowListScreen'; -import { CreatePostScreen } from '../screens/create'; -import { - ChatScreen, - CreateGroupScreen, - JoinGroupScreen, - GroupInfoScreen, - GroupMembersScreen, - GroupRequestDetailScreen, - GroupInviteDetailScreen, - PrivateChatInfoScreen, -} from '../screens/message'; - -const RootStack = createNativeStackNavigator(); - -interface RootNavigatorProps { - isAuthenticated: boolean; - isInitializing: boolean; -} - -// 未认证时可访问的屏幕 - 返回数组而不是 JSX -const getPublicScreens = () => [ - , - , -]; - -// 认证后可访问的屏幕 - 返回数组而不是 JSX -const getAuthenticatedScreens = () => [ - , - , - , - , - , - , - , - , - , - , - , - , -]; - -export function RootNavigator({ isAuthenticated, isInitializing }: RootNavigatorProps) { - const { isMobile } = useResponsive(); - const unreadCount = useTotalUnreadCount(); - - // 设置导航引用 - const setNavigationRef = (ref: any) => { - navigationService.setNavigationRef(ref); - }; - - // 加载中显示 - if (isInitializing) { - return ( - - - - ); - } - - return ( - - - {isAuthenticated ? ( - <> - - {() => - isMobile ? ( - - ) : ( - - ) - } - - {getAuthenticatedScreens()} - - ) : ( - <> - - {getPublicScreens()} - - )} - - - ); -} - -const styles = StyleSheet.create({ - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: colors.background.default, - }, -}); diff --git a/src/navigation/ScheduleNavigator.tsx b/src/navigation/ScheduleNavigator.tsx deleted file mode 100644 index 30676aa..0000000 --- a/src/navigation/ScheduleNavigator.tsx +++ /dev/null @@ -1,42 +0,0 @@ -/** - * 课程表 Stack 导航 - * 处理课程表相关页面 - */ -import React from 'react'; -import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import type { ScheduleStackParamList } from './types'; - -import { colors } from '../theme'; -import { ScheduleScreen, CourseDetailScreen } from '../screens/schedule'; - -const ScheduleStack = createNativeStackNavigator(); - -export function ScheduleNavigator() { - return ( - - - - - ); -} diff --git a/src/navigation/SimpleMobileTabNavigator.tsx b/src/navigation/SimpleMobileTabNavigator.tsx deleted file mode 100644 index 4045aa6..0000000 --- a/src/navigation/SimpleMobileTabNavigator.tsx +++ /dev/null @@ -1,282 +0,0 @@ -/** - * 简单的移动端 Tab Navigator - * - * 不使用 React Navigation 的 Tab Navigator,而是使用一个简单的自定义实现 - * 这样可以完全避免 React Navigation 状态恢复的问题 - */ - -import React, { useEffect, useState, useCallback } from 'react'; -import { - View, - StyleSheet, - TouchableOpacity, - Text, - Dimensions, -} from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { MaterialCommunityIcons } from '@expo/vector-icons'; - -import { colors, shadows } from '../theme'; -import { useTotalUnreadCount } from '../stores'; -import { messageManager } from '../stores'; - -// ==================== 导入屏幕组件 ==================== -import { HomeScreen, SearchScreen } from '../screens/home'; -import { ScheduleScreen, CourseDetailScreen } from '../screens/schedule'; -import { - MessageListScreen, - NotificationsScreen, - PrivateChatInfoScreen, -} from '../screens/message'; -import { ProfileScreen, SettingsScreen, EditProfileScreen, NotificationSettingsScreen, BlockedUsersScreen, AccountSecurityScreen } from '../screens/profile'; - -// ==================== 类型定义 ==================== -type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab'; - -// ==================== 常量 ==================== -const TAB_BAR_HEIGHT = 64; -const TAB_BAR_MARGIN = 14; -const TAB_BAR_FLOATING_MARGIN = 12; - -// ==================== 组件 ==================== - -/** - * 简化的 Stack Navigator 包装 - */ -function SimpleStackNavigator({ - children, - screenName -}: { - children: React.ReactNode; - screenName: string; -}) { - return ( - - {children} - - ); -} - -/** - * 主组件:SimpleMobileTabNavigator - */ -export function SimpleMobileTabNavigator() { - const insets = useSafeAreaInsets(); - const messageUnreadCount = useTotalUnreadCount(); - const [activeTab, setActiveTab] = useState('HomeTab'); - - // 初始化 MessageManager - useEffect(() => { - messageManager.initialize(); - }, []); - - // 处理 Tab 切换 - const handleTabPress = useCallback((tabName: TabName) => { - setActiveTab(tabName); - }, []); - - // 渲染当前 Tab 的内容 - const renderTabContent = () => { - switch (activeTab) { - case 'HomeTab': - return ; - case 'MessageTab': - return ; - case 'ScheduleTab': - return ; - case 'ProfileTab': - return ; - default: - return ; - } - }; - - // 渲染 Tab Bar 图标 - const renderTabIcon = (tabName: TabName, isActive: boolean) => { - const iconColor = isActive ? colors.primary.main : colors.text.secondary; - const iconSize = isActive ? 26 : 24; - - switch (tabName) { - case 'HomeTab': - return ( - - ); - case 'MessageTab': - return ( - - ); - case 'ScheduleTab': - return ( - - ); - case 'ProfileTab': - return ( - - ); - default: - return null; - } - }; - - // 渲染 Tab Bar 标签 - const renderTabLabel = (tabName: TabName, isActive: boolean) => { - const labels: Record = { - HomeTab: '首页', - MessageTab: '消息', - ScheduleTab: '课表', - ProfileTab: '我的', - }; - - return ( - - {labels[tabName]} - - ); - }; - - return ( - - {/* 内容区域 */} - - {renderTabContent()} - - - {/* Tab Bar */} - - {(['HomeTab', 'MessageTab', 'ScheduleTab', 'ProfileTab'] as TabName[]).map( - (tabName) => { - const isActive = activeTab === tabName; - const showBadge = tabName === 'MessageTab' && messageUnreadCount > 0; - - return ( - handleTabPress(tabName)} - activeOpacity={0.7} - > - - {renderTabIcon(tabName, isActive)} - {showBadge && ( - - - {messageUnreadCount > 99 ? '99+' : messageUnreadCount} - - - )} - - {renderTabLabel(tabName, isActive)} - - ); - } - )} - - - ); -} - -// ==================== 样式 ==================== -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.background.default, - }, - stackContainer: { - flex: 1, - }, - content: { - flex: 1, - marginBottom: TAB_BAR_HEIGHT + TAB_BAR_FLOATING_MARGIN * 2, - }, - tabBar: { - position: 'absolute', - left: TAB_BAR_MARGIN, - right: TAB_BAR_MARGIN, - height: TAB_BAR_HEIGHT, - backgroundColor: colors.background.paper, - borderRadius: 24, - borderTopWidth: 1, - borderTopColor: `${colors.divider}88`, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-around', - paddingHorizontal: 8, - ...shadows.lg, - }, - tabItem: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 6, - borderRadius: 18, - }, - tabItemActive: { - backgroundColor: `${colors.primary.main}15`, - }, - tabIconContainer: { - position: 'relative', - width: 38, - height: 30, - alignItems: 'center', - justifyContent: 'center', - borderRadius: 15, - }, - tabLabel: { - fontSize: 12, - fontWeight: '600', - marginTop: -2, - letterSpacing: 0.2, - color: colors.text.secondary, - }, - tabLabelActive: { - color: colors.primary.main, - }, - badge: { - position: 'absolute', - top: -2, - right: -2, - backgroundColor: colors.error.main, - borderRadius: 10, - minWidth: 18, - height: 18, - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: 4, - }, - badgeText: { - color: colors.primary.contrast, - fontSize: 10, - fontWeight: '600', - }, -}); diff --git a/src/navigation/TabNavigator.tsx b/src/navigation/TabNavigator.tsx deleted file mode 100644 index 771cd07..0000000 --- a/src/navigation/TabNavigator.tsx +++ /dev/null @@ -1,155 +0,0 @@ -/** - * Tab 导航器 - * 处理底部标签导航(移动端) - */ -import React from 'react'; -import { View } from 'react-native'; -import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { MaterialCommunityIcons } from '@expo/vector-icons'; - -import type { MainTabParamList } from './types'; -import { colors, shadows } from '../theme'; - -import { HomeNavigator } from './HomeNavigator'; -import { MessageNavigator } from './MessageNavigator'; -import { ScheduleNavigator } from './ScheduleNavigator'; -import { ProfileNavigator } from './ProfileNavigator'; - -const Tab = createBottomTabNavigator(); - -// 常量 -const MOBILE_TAB_FLOATING_MARGIN = 12; - -interface TabNavigatorProps { - unreadCount: number; -} - -export function TabNavigator({ unreadCount }: TabNavigatorProps) { - const insets = useSafeAreaInsets(); - - return ( - - ( - - - - ), - }} - /> - 0 ? (unreadCount > 99 ? '99+' : unreadCount) : undefined, - tabBarIcon: ({ color, size, focused }) => ( - - - - ), - }} - /> - ( - - - - ), - }} - /> - ( - - - - ), - }} - /> - - ); -} - -import { StyleSheet } from 'react-native'; - -const styles = StyleSheet.create({ - tabIconContainer: { - alignItems: 'center', - justifyContent: 'center', - width: 38, - height: 30, - borderRadius: 15, - }, - tabIconActive: { - backgroundColor: `${colors.primary.main}20`, - transform: [{ translateY: -1 }], - }, -}); diff --git a/src/navigation/hrefs.ts b/src/navigation/hrefs.ts new file mode 100644 index 0000000..93e2c82 --- /dev/null +++ b/src/navigation/hrefs.ts @@ -0,0 +1,157 @@ +/** + * Expo Router 路径构建(单一事实来源,避免魔法字符串散落) + */ +import type { SystemMessageResponse } from '../types/dto'; +import { routePayloadCache } from '../stores/routePayloadCache'; + +export function hrefPostDetail(postId: string, scrollToComments?: boolean): string { + const q = scrollToComments ? '?scrollToComments=1' : ''; + return `/post/${encodeURIComponent(postId)}${q}`; +} + +export function hrefUserProfile(userId: string): string { + return `/user/${encodeURIComponent(userId)}`; +} + +export function hrefHome(): string { + return '/home'; +} + +export function hrefHomeSearch(): string { + return '/home/search'; +} + +export function hrefMessages(): string { + return '/messages'; +} + +export function hrefNotifications(): string { + return '/messages/notifications'; +} + +export function hrefApps(): string { + return '/apps'; +} + +export function hrefSchedule(): string { + return '/apps/schedule'; +} + +export function hrefScheduleCourse(courseId: string): string { + return `/apps/schedule/course?courseId=${encodeURIComponent(courseId)}`; +} + +export function hrefProfileSettings(): string { + return '/profile/settings'; +} + +export function hrefProfileEdit(): string { + return '/profile/edit-profile'; +} + +export function hrefProfileSecurity(): string { + return '/profile/account-security'; +} + +export function hrefProfileNotifications(): string { + return '/profile/notification-settings'; +} + +export function hrefProfileBlocked(): string { + return '/profile/blocked-users'; +} + +export function hrefProfileMyPosts(): string { + return '/profile/my-posts'; +} + +export function hrefProfileBookmarks(): string { + return '/profile/bookmarks'; +} + +export function hrefCreatePost(mode?: 'create' | 'edit', postId?: string): string { + if (mode === 'edit' && postId) { + return `/posts/create?mode=edit&postId=${encodeURIComponent(postId)}`; + } + return '/posts/create'; +} + +export function hrefChat(params: { + conversationId: string; + userId?: string; + isGroupChat?: boolean; + groupId?: string; + groupName?: string; +}): string { + const { conversationId, userId, isGroupChat, groupId, groupName } = params; + const q = new URLSearchParams(); + if (userId) q.set('userId', userId); + if (isGroupChat) q.set('isGroupChat', '1'); + if (groupId != null && groupId !== '') q.set('groupId', String(groupId)); + if (groupName) q.set('groupName', groupName); + const qs = q.toString(); + return `/chat/${encodeURIComponent(conversationId)}${qs ? `?${qs}` : ''}`; +} + +export function hrefFollowList(userId: string, type: 'following' | 'followers'): string { + return `/users/${encodeURIComponent(userId)}/${type}`; +} + +export function hrefGroupCreate(): string { + return '/group/create'; +} + +export function hrefGroupJoin(): string { + return '/group/join'; +} + +export function hrefGroupInfo(groupId: string, conversationId?: string): string { + const base = `/group/${encodeURIComponent(groupId)}`; + if (!conversationId) return base; + return `${base}?conversationId=${encodeURIComponent(conversationId)}`; +} + +export function hrefGroupMembers(groupId: string): string { + return `/group/${encodeURIComponent(groupId)}/members`; +} + +export function hrefGroupRequestDetail(message: SystemMessageResponse): string { + routePayloadCache.stashSystemMessage(message); + return `/group/request?messageId=${encodeURIComponent(message.id)}`; +} + +export function hrefGroupInviteDetail(message: SystemMessageResponse): string { + routePayloadCache.stashSystemMessage(message); + return `/group/invite?messageId=${encodeURIComponent(message.id)}`; +} + +export function hrefPrivateChatInfo(params: { + conversationId: string; + userId: string; + userName?: string; + userAvatar?: string | null; +}): string { + const q = new URLSearchParams({ + conversationId: params.conversationId, + userId: params.userId, + }); + if (params.userName) q.set('userName', params.userName); + if (params.userAvatar != null) q.set('userAvatar', params.userAvatar ?? ''); + return `/chat/private-info?${q.toString()}`; +} + +export function hrefQrLoginConfirm(sessionId: string): string { + return `/qrcode/login/${encodeURIComponent(sessionId)}`; +} + +export function hrefAuthLogin(): string { + return '/login'; +} + +export function hrefAuthRegister(): string { + return '/register'; +} + +export function hrefAuthForgot(): string { + return '/forgot-password'; +} diff --git a/src/navigation/index.ts b/src/navigation/index.ts index f7462b5..f59562c 100644 --- a/src/navigation/index.ts +++ b/src/navigation/index.ts @@ -1,30 +1,4 @@ /** - * 导出所有导航组件 + * 导航路径工具(运行时导航请使用 expo-router) */ - -// 类型 -export type { - RootStackParamList, - MainTabParamList, - HomeStackParamList, - MessageStackParamList, - ScheduleStackParamList, - ProfileStackParamList, - AuthStackParamList, - TabName, - NavItemConfig, -} from './types'; - -// 导航器 -export { AuthNavigator } from './AuthNavigator'; -export { HomeNavigator } from './HomeNavigator'; -export { MessageNavigator } from './MessageNavigator'; -export { ScheduleNavigator } from './ScheduleNavigator'; -export { ProfileNavigator } from './ProfileNavigator'; -export { TabNavigator } from './TabNavigator'; -export { DesktopNavigator } from './DesktopNavigator'; -export { RootNavigator } from './RootNavigator'; -export { MainNavigator } from './MainNavigator'; - -// 移动端简化导航 -export { SimpleMobileTabNavigator } from './SimpleMobileTabNavigator'; +export * from './hrefs'; diff --git a/src/navigation/paramUtils.ts b/src/navigation/paramUtils.ts new file mode 100644 index 0000000..3dfd707 --- /dev/null +++ b/src/navigation/paramUtils.ts @@ -0,0 +1,11 @@ +/** + * Expo Router 的 searchParams、动态段可能是 string | string[] + * 群 ID 等在应用内一律为 string;路由参数禁止 Number() 以免精度丢失 + */ +export function firstRouteParam(value: string | string[] | undefined): string | undefined { + if (value == null) return undefined; + const raw = Array.isArray(value) ? value[0] : value; + if (raw == null) return undefined; + const s = String(raw).trim(); + return s === '' ? undefined : s; +} diff --git a/src/navigation/types.ts b/src/navigation/types.ts deleted file mode 100644 index 450f8ef..0000000 --- a/src/navigation/types.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * 导航类型定义 - * 定义所有导航栈的类型参数 - */ - -import { NavigatorScreenParams } from '@react-navigation/native'; -import type { SystemMessageResponse } from '../types/dto'; -import type { Course } from '../types/schedule'; - -// ==================== 主Tab导航参数 ==================== -export type MainTabParamList = { - HomeTab: NavigatorScreenParams; - MessageTab: NavigatorScreenParams; - ScheduleTab: NavigatorScreenParams; - ProfileTab: NavigatorScreenParams; -}; - -// ==================== 首页Stack ==================== -export type HomeStackParamList = { - Home: undefined; - PostDetail: { postId: string; scrollToComments?: boolean }; - Search: undefined; - UserProfile: { userId: string }; - FollowList: { userId: string; type: 'following' | 'followers' }; -}; - -// ==================== 消息Stack ==================== -export type MessageStackParamList = { - MessageList: undefined; - Chat: { - conversationId: string; - userId?: string; - isGroupChat?: boolean; - groupId?: number; - groupName?: string; - }; - Notifications: undefined; - CreateGroup: undefined; - JoinGroup: undefined; - GroupInfo: { groupId: number; conversationId?: string }; - GroupMembers: { groupId: number }; - GroupRequestDetail: { message: SystemMessageResponse }; - GroupInviteDetail: { message: SystemMessageResponse }; - PrivateChatInfo: { - conversationId: string; - userId: string; - userName?: string; - userAvatar?: string | null; - }; -}; - -// ==================== 个人中心Stack ==================== -export type ProfileStackParamList = { - Profile: undefined; - Settings: undefined; - EditProfile: undefined; - AccountSecurity: undefined; - MyPosts: undefined; - Bookmarks: undefined; - NotificationSettings: undefined; - BlockedUsers: undefined; -}; - -// ==================== 课程表Stack ==================== -export type ScheduleStackParamList = { - Schedule: undefined; - CourseDetail: { course: Course; relatedCourses: Course[] }; -}; - -// ==================== 认证Stack ==================== -export type AuthStackParamList = { - Login: undefined; - Register: undefined; - ForgotPassword: undefined; -}; - -// ==================== 根导航 ==================== -export type RootStackParamList = { - Main: NavigatorScreenParams; - Auth: undefined; - PostDetail: { postId: string; scrollToComments?: boolean }; - UserProfile: { userId: string }; - CreatePost: - | undefined - | { - mode?: 'create' | 'edit'; - postId?: string; - }; - Chat: { - conversationId: string; - userId?: string; - isGroupChat?: boolean; - groupId?: number; - groupName?: string; - }; - FollowList: { userId: string; type: 'following' | 'followers' }; - CreateGroup: undefined; - JoinGroup: undefined; - GroupInfo: { groupId: number; conversationId?: string }; - GroupMembers: { groupId: number }; - GroupRequestDetail: { message: SystemMessageResponse }; - GroupInviteDetail: { message: SystemMessageResponse }; - PrivateChatInfo: { - conversationId: string; - userId: string; - userName?: string; - userAvatar?: string | null; - }; -}; - -// ==================== 全局类型声明 ==================== -declare global { - namespace ReactNavigation { - interface RootParamList extends RootStackParamList {} - } -} - -// ==================== 屏幕名称类型(用于路由)==================== -export type HomeScreenNames = keyof HomeStackParamList; -export type MessageScreenNames = keyof MessageStackParamList; -export type ProfileScreenNames = keyof ProfileStackParamList; -export type MainTabScreenNames = keyof MainTabParamList; -export type RootScreenNames = keyof RootStackParamList; - -// ==================== Tab 类型 ==================== -export type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab'; - -// ==================== 导航项配置 ==================== -export interface NavItemConfig { - name: TabName; - label: string; - icon: string; - iconOutline: string; -} diff --git a/src/presentation/hooks/responsive/MIGRATION.md b/src/presentation/hooks/responsive/MIGRATION.md deleted file mode 100644 index 7eb181c..0000000 --- a/src/presentation/hooks/responsive/MIGRATION.md +++ /dev/null @@ -1,223 +0,0 @@ -# useResponsive 拆分迁移指南 - -## 概述 - -原有的 `useResponsive.ts` (485行) 已被拆分为多个专注的 hooks,以提高代码的可维护性和性能。 - -## 新目录结构 - -``` -src/presentation/hooks/responsive/ -├── types.ts # 共享类型定义 -├── useBreakpoint.ts # 断点检测 -├── useScreenSize.ts # 屏幕尺寸 -├── useOrientation.ts # 方向检测 -├── usePlatform.ts # 平台检测 -├── useResponsiveValue.ts # 响应式值映射 -├── useResponsiveSpacing.ts # 响应式间距 -├── useColumnCount.ts # 列数计算 -├── useMediaQuery.ts # 媒体查询 -├── useBreakpointCheck.ts # 断点范围检查 (兼容层) -├── useResponsive.ts # 兼容层 (保持向后兼容) -└── index.ts # 统一导出 -``` - -## 新 Hook API 说明 - -### useBreakpoint - 断点检测 - -```typescript -import { useBreakpoint, useFineBreakpoint } from '../presentation/hooks/responsive'; - -// 基础断点: 'mobile' | 'tablet' | 'desktop' | 'wide' -const breakpoint = useBreakpoint(); - -// 细粒度断点: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' -const fineBreakpoint = useFineBreakpoint(); -``` - -### useScreenSize - 屏幕尺寸 - -```typescript -import { useScreenSize, useWindowDimensions } from '../presentation/hooks/responsive'; - -// 完整屏幕尺寸信息 -const { - width, height, - breakpoint, fineBreakpoint, - isMobile, isTablet, isDesktop, isWide -} = useScreenSize(); - -// 仅获取窗口尺寸 -const { width, height, scale, fontScale } = useWindowDimensions(); -``` - -### useOrientation - 方向检测 - -```typescript -import { useOrientation } from '../presentation/hooks/responsive'; - -const { orientation, isPortrait, isLandscape } = useOrientation(); -``` - -### usePlatform - 平台检测 - -```typescript -import { usePlatform } from '../presentation/hooks/responsive'; - -const { OS, isWeb, isIOS, isAndroid, isNative } = usePlatform(); -``` - -### useResponsiveValue - 响应式值 - -```typescript -import { useResponsiveValue } from '../presentation/hooks/responsive'; - -// 根据断点返回不同值 -const padding = useResponsiveValue({ xs: 8, md: 16, lg: 24 }); - -// 响应式样式 -const style = useResponsiveStyle({ - padding: { xs: 8, md: 16, lg: 24 }, - fontSize: { xs: 14, lg: 16 } -}); -``` - -### useResponsiveSpacing - 响应式间距 - -```typescript -import { useResponsiveSpacing } from '../presentation/hooks/responsive'; - -const gap = useResponsiveSpacing({ xs: 8, md: 16, lg: 24 }); -``` - -### useColumnCount - 列数计算 - -```typescript -import { useColumnCount } from '../presentation/hooks/responsive'; - -const columns = useColumnCount({ xs: 1, sm: 2, md: 3, lg: 4 }); -``` - -### useBreakpointGTE / useBreakpointLT - 断点范围检查 - -```typescript -import { useBreakpointGTE, useBreakpointLT, useBreakpointBetween } from '../presentation/hooks/responsive'; - -const isMediumUp = useBreakpointGTE('md'); -const isMobileOnly = useBreakpointLT('lg'); -const isTabletRange = useBreakpointBetween('md', 'lg'); -``` - -### useMediaQuery - 媒体查询 - -```typescript -import { useMediaQuery } from '../presentation/hooks/responsive'; - -const isMinWidth768 = useMediaQuery({ minWidth: 768 }); -const isPortrait = useMediaQuery({ orientation: 'portrait' }); -``` - -## 迁移示例 - -### 示例 1: 使用 useResponsive (旧) - -```typescript -import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../hooks'; - -function Component() { - const { width, isMobile, isTablet, isDesktop, isWide } = useResponsive(); - const padding = useResponsiveSpacing({ xs: 8, md: 16 }); - - return ; -} -``` - -### 示例 2: 使用新专注 hooks (推荐) - -```typescript -import { - useScreenSize, - useResponsiveSpacing -} from '../presentation/hooks/responsive'; - -function Component() { - const { width, isMobile, isTablet, isDesktop, isWide } = useScreenSize(); - const padding = useResponsiveSpacing({ xs: 8, md: 16 }); - - return ; -} -``` - -### 示例 3: 仅使用部分功能 (性能优化) - -```typescript -import { useBreakpoint, usePlatform } from '../presentation/hooks/responsive'; - -function Component() { - // 只订阅断点变化,不订阅尺寸变化 - const breakpoint = useBreakpoint(); - const { isIOS } = usePlatform(); // 平台不会变化,只计算一次 - - return ; -} -``` - -## 性能优势 - -1. **按需订阅**: 只使用需要的功能,避免不必要的重渲染 -2. **细粒度更新**: 每个 hook 独立管理状态 -3. **平台常量**: usePlatform 返回常量,不会触发重渲染 - -## 向后兼容 - -原有的 `useResponsive` 仍然可用,但标记为 `@deprecated`: - -```typescript -import { useResponsive } from '../hooks'; // 仍然可用 - -function Component() { - const { width, height, isMobile, platform } = useResponsive(); // 仍然可用 - return ; -} -``` - -## 工具函数 - -```typescript -import { - getBreakpoint, - getFineBreakpoint, - isBreakpointGTE, - isBreakpointLT -} from '../presentation/hooks/responsive'; - -// 非 hooks 版本,用于工具函数 -const breakpoint = getBreakpoint(800); -const fineBreakpoint = getFineBreakpoint(800); -const isGTE = isBreakpointGTE('md', 'sm'); -``` - -## 类型导出 - -```typescript -import type { - BreakpointKey, - FineBreakpointKey, - ResponsiveValue, - Orientation, - PlatformInfo, - ScreenSize, - MediaQueryOptions, - ResponsiveInfo, // 兼容层 -} from '../presentation/hooks/responsive'; -``` - -## 常量 - -```typescript -import { BREAKPOINTS, FINE_BREAKPOINTS } from '../presentation/hooks/responsive'; - -// BREAKPOINTS = { mobile: 0, tablet: 768, desktop: 1024, wide: 1440 } -// FINE_BREAKPOINTS = { xs: 0, sm: 375, md: 414, lg: 768, xl: 1024, '2xl': 1280, '3xl': 1440, '4xl': 1920 } -``` diff --git a/src/presentation/hooks/responsive/useBreakpoint.ts b/src/presentation/hooks/responsive/useBreakpoint.ts index 8a2bdb9..6f35b46 100644 --- a/src/presentation/hooks/responsive/useBreakpoint.ts +++ b/src/presentation/hooks/responsive/useBreakpoint.ts @@ -3,8 +3,8 @@ * 断点检测 - 检测当前断点 */ -import { useMemo } from 'react'; -import { useWindowDimensions } from './useScreenSize'; +import { useMemo, useState, useEffect } from 'react'; +import { Dimensions, ScaledSize } from 'react-native'; import { BREAKPOINTS, FINE_BREAKPOINTS } from './types'; import type { BreakpointKey, FineBreakpointKey } from './types'; @@ -74,6 +74,22 @@ export function isBreakpointBetween( // ==================== Hooks ==================== +function useWindowDimensionsLocal(): ScaledSize { + const [dimensions, setDimensions] = useState(() => Dimensions.get('window')); + + useEffect(() => { + const subscription = Dimensions.addEventListener('change', ({ window }) => { + setDimensions(window); + }); + + return () => { + subscription.remove(); + }; + }, []); + + return dimensions; +} + /** * 断点检测 Hook * 返回当前的基础断点 @@ -85,7 +101,7 @@ export function isBreakpointBetween( * // 'mobile' | 'tablet' | 'desktop' | 'wide' */ export function useBreakpoint(): BreakpointKey { - const { width } = useWindowDimensions(); + const { width } = useWindowDimensionsLocal(); return useMemo(() => { return getBreakpoint(width); @@ -103,7 +119,7 @@ export function useBreakpoint(): BreakpointKey { * // 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' */ export function useFineBreakpoint(): FineBreakpointKey { - const { width } = useWindowDimensions(); + const { width } = useWindowDimensionsLocal(); return useMemo(() => { return getFineBreakpoint(width); @@ -120,7 +136,7 @@ export function useFineBreakpoint(): FineBreakpointKey { * const isMediumUp = useBreakpointGTE('md'); */ export function useBreakpointGTE(target: FineBreakpointKey): boolean { - const { width } = useWindowDimensions(); + const { width } = useWindowDimensionsLocal(); return useMemo(() => { const current = getFineBreakpoint(width); @@ -138,7 +154,7 @@ export function useBreakpointGTE(target: FineBreakpointKey): boolean { * const isMobileOnly = useBreakpointLT('lg'); */ export function useBreakpointLT(target: FineBreakpointKey): boolean { - const { width } = useWindowDimensions(); + const { width } = useWindowDimensionsLocal(); return useMemo(() => { const current = getFineBreakpoint(width); @@ -160,7 +176,7 @@ export function useBreakpointBetween( min: FineBreakpointKey, max: FineBreakpointKey ): boolean { - const { width } = useWindowDimensions(); + const { width } = useWindowDimensionsLocal(); return useMemo(() => { const current = getFineBreakpoint(width); diff --git a/src/presentation/hooks/responsive/useBreakpointCheck.ts b/src/presentation/hooks/responsive/useBreakpointCheck.ts deleted file mode 100644 index 14f0da6..0000000 --- a/src/presentation/hooks/responsive/useBreakpointCheck.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * useBreakpointCheck Hook - * 断点检查 - 提供断点范围检查功能 - * @deprecated 请直接使用 useBreakpoint.ts 中的 hooks - */ - -export { useBreakpointGTE, useBreakpointLT, useBreakpointBetween } from './useBreakpoint'; diff --git a/src/screens/apps/AppsScreen.tsx b/src/screens/apps/AppsScreen.tsx new file mode 100644 index 0000000..4c4da95 --- /dev/null +++ b/src/screens/apps/AppsScreen.tsx @@ -0,0 +1,252 @@ +/** + * 应用中心:与首页顶栏、个人主页帖子卡片同一套圆角 / 阴影 / 主色体系 + */ + +import React, { useCallback, useMemo } from 'react'; +import { View, StyleSheet, ScrollView, TouchableOpacity, StatusBar } from 'react-native'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useRouter } from 'expo-router'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; + +import { + spacing, + fontSizes, + borderRadius, + shadows, + useAppColors, + useResolvedColorScheme, + type AppColors, +} from '../../theme'; +import * as hrefs from '../../navigation/hrefs'; +import { Text, ResponsiveContainer } from '../../components/common'; +import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks/useResponsive'; + +type AppItem = { + id: string; + title: string; + subtitle: string; + icon: React.ComponentProps['name']; + href: string; +}; + +const APP_ENTRIES: AppItem[] = [ + { + id: 'schedule', + title: '课表', + subtitle: '周课表、教务同步与课程管理', + icon: 'calendar-week', + href: hrefs.hrefSchedule(), + }, +]; + +export const AppsScreen: React.FC = () => { + const colors = useAppColors(); + const resolvedScheme = useResolvedColorScheme(); + const statusBarStyle = resolvedScheme === 'dark' ? 'light-content' : 'dark-content'; + const styles = useMemo(() => createAppsStyles(colors), [colors]); + /** 与个人主页 PostCard 外层 `postWrapper` 一致 */ + const postCardShell = useMemo( + () => ({ + marginBottom: spacing.md, + backgroundColor: colors.background.paper, + borderRadius: 16, + overflow: 'hidden' as const, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.06, + shadowRadius: 8, + elevation: 2, + }), + [colors] + ); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { isMobile } = useResponsive(); + /** 与 MessageListScreen 顶栏宽屏 padding 一致 */ + const isWideScreen = useBreakpointGTE('lg'); + const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 }); + + const scrollBottomInset = isMobile ? 88 + insets.bottom + spacing.md : spacing['3xl']; + + const onOpenApp = useCallback( + (href: string) => { + router.push(href); + }, + [router] + ); + + const renderTiles = () => ( + <> + + 与社区账号打通的轻应用入口 + + {APP_ENTRIES.map(item => ( + onOpenApp(item.href)} + activeOpacity={0.88} + > + + + + + + + {item.title} + + + {item.subtitle} + + + + + + ))} + + + + 更多应用陆续上线 + + + + ); + + return ( + + + + {/* 与 MessageListScreen 顶栏同一结构 / 样式 */} + + + + + 应用 + + + + + + + + {isWideScreen ? ( + + + {renderTiles()} + + + ) : ( + + {renderTiles()} + + )} + + ); +}; + +export default AppsScreen; + +function createAppsStyles(colors: AppColors) { + const headerBg = colors.background.default; + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.default, + }, + msgHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.md, + paddingVertical: spacing.md, + backgroundColor: headerBg, + ...shadows.sm, + }, + msgHeaderWide: { + paddingHorizontal: spacing.lg, + paddingVertical: spacing.lg, + }, + msgHeaderLeft: { + width: 44, + }, + msgHeaderCenter: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + }, + msgHeaderTitle: { + fontSize: 19, + fontWeight: '700', + color: colors.text.primary, + }, + msgHeaderTitleWide: { + fontSize: 22, + }, + msgHeaderRight: { + flexDirection: 'row', + alignItems: 'center', + }, + /** 与消息页「+」按钮同占位宽度,标题视觉居中 */ + msgHeaderRightSpacer: { + width: 36, + height: 44, + }, + pageSubtitle: { + marginBottom: spacing.md, + lineHeight: fontSizes.sm * 1.45, + }, + /** 与消息列表区同色,避免顶栏阴影落在灰底上像一条线 */ + scroll: { + flex: 1, + backgroundColor: headerBg, + }, + scrollContent: { + paddingTop: spacing.md, + flexGrow: 1, + backgroundColor: headerBg, + }, + appCardInner: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: spacing.lg, + paddingHorizontal: spacing.lg, + }, + /** 与首页发帖 FAB 同主色实心圆 */ + appIconCircle: { + width: 52, + height: 52, + borderRadius: borderRadius.full, + backgroundColor: colors.primary.main, + alignItems: 'center', + justifyContent: 'center', + }, + appCardText: { + flex: 1, + marginLeft: spacing.md, + marginRight: spacing.sm, + }, + appTitle: { + fontWeight: '700', + fontSize: fontSizes.md + 1, + }, + appSubtitle: { + marginTop: 4, + }, + footer: { + alignItems: 'center', + paddingTop: spacing.xl, + paddingBottom: spacing.md, + }, + }); +} diff --git a/src/screens/apps/index.ts b/src/screens/apps/index.ts new file mode 100644 index 0000000..4aa5ef8 --- /dev/null +++ b/src/screens/apps/index.ts @@ -0,0 +1,2 @@ +export { AppsScreen } from './AppsScreen'; +export { default } from './AppsScreen'; diff --git a/src/screens/auth/ForgotPasswordScreen.tsx b/src/screens/auth/ForgotPasswordScreen.tsx index 6d418ab..fe092de 100644 --- a/src/screens/auth/ForgotPasswordScreen.tsx +++ b/src/screens/auth/ForgotPasswordScreen.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { View, Text, @@ -11,19 +11,121 @@ import { ActivityIndicator, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useNavigation } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useRouter } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; -import { colors, spacing, borderRadius, shadows, fontSizes } from '../../theme'; -import { RootStackParamList } from '../../navigation/types'; +import { spacing, borderRadius, shadows, fontSizes, useAppColors, type AppColors } from '../../theme'; import { authService, resolveAuthApiError } from '../../services/authService'; import { showPrompt } from '../../services/promptService'; -type ForgotPasswordNavigationProp = NativeStackNavigationProp; +function createForgotPasswordStyles(colors: AppColors) { + return StyleSheet.create({ + container: { flex: 1 }, + gradient: { flex: 1 }, + keyboardView: { flex: 1 }, + scrollContent: { + flexGrow: 1, + justifyContent: 'center', + paddingHorizontal: spacing.xl, + paddingVertical: spacing['2xl'], + }, + formCard: { + backgroundColor: colors.background.paper, + borderRadius: borderRadius['2xl'], + padding: spacing.xl, + ...shadows.md, + }, + title: { + fontSize: fontSizes['2xl'], + fontWeight: '700', + color: colors.text.primary, + textAlign: 'center', + marginBottom: spacing.xs, + }, + subtitle: { + fontSize: fontSizes.sm, + color: colors.text.secondary, + textAlign: 'center', + marginBottom: spacing.lg, + }, + inputWrapper: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.background.default, + borderRadius: borderRadius.lg, + borderWidth: 1.5, + borderColor: colors.divider, + paddingHorizontal: spacing.md, + height: 52, + marginBottom: spacing.md, + }, + inputIcon: { + marginRight: spacing.sm, + }, + input: { + flex: 1, + fontSize: fontSizes.md, + color: colors.text.primary, + }, + codeRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + marginBottom: spacing.md, + }, + codeInput: { + flex: 1, + marginBottom: 0, + }, + sendCodeButton: { + height: 52, + minWidth: 110, + borderRadius: borderRadius.lg, + backgroundColor: colors.primary.main, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: spacing.md, + }, + sendCodeButtonDisabled: { + opacity: 0.6, + }, + sendCodeButtonText: { + color: '#fff', + fontSize: fontSizes.sm, + fontWeight: '600', + }, + submitButton: { + height: 50, + borderRadius: borderRadius.lg, + backgroundColor: colors.primary.main, + alignItems: 'center', + justifyContent: 'center', + marginTop: spacing.sm, + }, + submitButtonDisabled: { + opacity: 0.6, + }, + submitButtonText: { + color: '#fff', + fontSize: fontSizes.lg, + fontWeight: '700', + }, + backButton: { + alignItems: 'center', + marginTop: spacing.md, + }, + backButtonText: { + color: colors.primary.main, + fontSize: fontSizes.md, + fontWeight: '500', + }, + }); +} export const ForgotPasswordScreen: React.FC = () => { - const navigation = useNavigation(); + const colors = useAppColors(); + const styles = useMemo(() => createForgotPasswordStyles(colors), [colors]); + const router = useRouter(); const [email, setEmail] = useState(''); const [verificationCode, setVerificationCode] = useState(''); const [newPassword, setNewPassword] = useState(''); @@ -95,7 +197,7 @@ export const ForgotPasswordScreen: React.FC = () => { }); if (ok) { showPrompt({ title: '重置成功', message: '密码已重置,请重新登录', type: 'success' }); - navigation.goBack(); + router.back(); } } catch (error: any) { showPrompt({ title: '重置失败', message: resolveAuthApiError(error, '请稍后重试'), type: 'error' }); @@ -192,7 +294,7 @@ export const ForgotPasswordScreen: React.FC = () => { {loading ? : 重置密码} - navigation.goBack()}> + router.back()}> 返回登录 @@ -203,106 +305,4 @@ export const ForgotPasswordScreen: React.FC = () => { ); }; -const styles = StyleSheet.create({ - container: { flex: 1 }, - gradient: { flex: 1 }, - keyboardView: { flex: 1 }, - scrollContent: { - flexGrow: 1, - justifyContent: 'center', - paddingHorizontal: spacing.xl, - paddingVertical: spacing['2xl'], - }, - formCard: { - backgroundColor: colors.background.paper, - borderRadius: borderRadius['2xl'], - padding: spacing.xl, - ...shadows.md, - }, - title: { - fontSize: fontSizes['2xl'], - fontWeight: '700', - color: colors.text.primary, - textAlign: 'center', - marginBottom: spacing.xs, - }, - subtitle: { - fontSize: fontSizes.sm, - color: colors.text.secondary, - textAlign: 'center', - marginBottom: spacing.lg, - }, - inputWrapper: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.background.default, - borderRadius: borderRadius.lg, - borderWidth: 1.5, - borderColor: colors.divider, - paddingHorizontal: spacing.md, - height: 52, - marginBottom: spacing.md, - }, - inputIcon: { - marginRight: spacing.sm, - }, - input: { - flex: 1, - fontSize: fontSizes.md, - color: colors.text.primary, - }, - codeRow: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm, - marginBottom: spacing.md, - }, - codeInput: { - flex: 1, - marginBottom: 0, - }, - sendCodeButton: { - height: 52, - minWidth: 110, - borderRadius: borderRadius.lg, - backgroundColor: colors.primary.main, - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: spacing.md, - }, - sendCodeButtonDisabled: { - opacity: 0.6, - }, - sendCodeButtonText: { - color: '#fff', - fontSize: fontSizes.sm, - fontWeight: '600', - }, - submitButton: { - height: 50, - borderRadius: borderRadius.lg, - backgroundColor: colors.primary.main, - alignItems: 'center', - justifyContent: 'center', - marginTop: spacing.sm, - }, - submitButtonDisabled: { - opacity: 0.6, - }, - submitButtonText: { - color: '#fff', - fontSize: fontSizes.lg, - fontWeight: '700', - }, - backButton: { - alignItems: 'center', - marginTop: spacing.md, - }, - backButtonText: { - color: colors.primary.main, - fontSize: fontSizes.md, - fontWeight: '500', - }, -}); - export default ForgotPasswordScreen; diff --git a/src/screens/auth/LoginScreen.tsx b/src/screens/auth/LoginScreen.tsx index 711bcfb..9443b65 100644 --- a/src/screens/auth/LoginScreen.tsx +++ b/src/screens/auth/LoginScreen.tsx @@ -7,7 +7,7 @@ * - 屏幕宽度 >= 768px:分栏布局,左侧橙色右侧中性灰 */ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useMemo } from 'react'; import { View, Text, @@ -22,24 +22,24 @@ import { StatusBar, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useNavigation } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useRouter } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; -import { colors, spacing, borderRadius, shadows, fontSizes } from '../../theme'; +import { spacing, borderRadius, shadows, fontSizes, useAppColors, type AppColors } from '../../theme'; import { useAuthStore } from '../../stores'; -import { RootStackParamList } from '../../navigation/types'; +import * as hrefs from '../../navigation/hrefs'; import { useResponsive, useResponsiveValue } from '../../hooks'; import { showPrompt } from '../../services/promptService'; -type LoginNavigationProp = NativeStackNavigationProp; - // 分栏布局断点 const SPLIT_BREAKPOINT = 768; export const LoginScreen: React.FC = () => { - const navigation = useNavigation(); + const colors = useAppColors(); + const styles = useMemo(() => createLoginStyles(colors), [colors]); + const router = useRouter(); const login = useAuthStore((state) => state.login); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const storeError = useAuthStore((state) => state.error); const setStoreError = useAuthStore((state) => state.setError); @@ -131,6 +131,12 @@ export const LoginScreen: React.FC = () => { } }, [storeError]); + useEffect(() => { + if (isAuthenticated) { + router.replace(hrefs.hrefHome()); + } + }, [isAuthenticated, router]); + const clearError = () => { setErrorMsg(null); setStoreError(null); @@ -158,7 +164,7 @@ export const LoginScreen: React.FC = () => { // 跳转到注册页 const handleGoToRegister = () => { - navigation.navigate('Register' as any); + router.push(hrefs.hrefAuthRegister()); }; // 渲染左侧面板(Logo和品牌信息)- 优化大屏布局 @@ -188,7 +194,7 @@ export const LoginScreen: React.FC = () => { {/* 品牌名称 - 优化大屏排版 */} - 胡萝卜 + 萝卜社区 发现有趣的内容,结识志同道合的朋友 {/* 装饰元素 - 大屏模式改为水平排列 + 大幅放大 */} @@ -297,7 +303,10 @@ export const LoginScreen: React.FC = () => { {/* 忘记密码 */} - navigation.navigate('ForgotPassword' as any)}> + router.push(hrefs.hrefAuthForgot())} + > 忘记密码? @@ -431,7 +440,7 @@ export const LoginScreen: React.FC = () => { color="#FFF" /> - 胡萝卜 + 萝卜社区 发现有趣的内容,结识志同道合的朋友 @@ -446,7 +455,8 @@ export const LoginScreen: React.FC = () => { return isSplitLayout ? renderSplitLayout() : renderSingleLayout(); }; -const styles = StyleSheet.create({ +function createLoginStyles(colors: AppColors) { + return StyleSheet.create({ container: { flex: 1, }, @@ -755,6 +765,7 @@ const styles = StyleSheet.create({ width: '100%', // 移除这里的阴影,避免重复 }, -}); + }); +} export default LoginScreen; \ No newline at end of file diff --git a/src/screens/auth/QRCodeConfirmScreen.tsx b/src/screens/auth/QRCodeConfirmScreen.tsx new file mode 100644 index 0000000..dfe9e94 --- /dev/null +++ b/src/screens/auth/QRCodeConfirmScreen.tsx @@ -0,0 +1,287 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + Image, + ActivityIndicator, + Alert, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { useRouter, useLocalSearchParams } from 'expo-router'; +import { qrcodeApi } from '../../services/authService'; +import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme'; +import { AppBackButton } from '../../components/common'; + +function createQrCodeConfirmStyles(colors: AppColors) { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.default, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + backgroundColor: colors.background.paper, + borderBottomWidth: 1, + borderBottomColor: colors.divider, + }, + closeButton: { + padding: spacing.sm, + }, + headerTitle: { + fontSize: fontSizes.lg, + fontWeight: '600', + color: colors.text.primary, + }, + placeholder: { + width: 40, + }, + content: { + flex: 1, + padding: spacing.lg, + justifyContent: 'center', + }, + infoCard: { + backgroundColor: colors.background.paper, + borderRadius: borderRadius.lg, + padding: spacing.xl, + alignItems: 'center', + marginBottom: spacing.xl, + shadowColor: colors.chat.shadow, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + infoText: { + fontSize: fontSizes.md, + color: colors.text.secondary, + marginBottom: spacing.lg, + }, + userInfo: { + alignItems: 'center', + }, + avatar: { + width: 80, + height: 80, + borderRadius: 40, + marginBottom: spacing.md, + }, + avatarPlaceholder: { + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: colors.background.disabled, + justifyContent: 'center', + alignItems: 'center', + marginBottom: spacing.md, + }, + nickname: { + fontSize: fontSizes.lg, + fontWeight: '600', + color: colors.text.primary, + }, + buttonContainer: { + gap: spacing.md, + }, + button: { + paddingVertical: spacing.md, + paddingHorizontal: spacing.xl, + borderRadius: borderRadius.md, + alignItems: 'center', + }, + confirmButton: { + backgroundColor: colors.primary.main, + }, + confirmButtonText: { + color: colors.text.inverse, + fontSize: fontSizes.md, + fontWeight: '600', + }, + cancelButton: { + backgroundColor: colors.background.paper, + borderWidth: 1, + borderColor: colors.divider, + }, + cancelButtonText: { + color: colors.text.secondary, + fontSize: fontSizes.md, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + loadingText: { + marginTop: spacing.md, + fontSize: fontSizes.md, + color: colors.text.secondary, + }, + errorContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: spacing.xl, + }, + errorText: { + marginTop: spacing.md, + fontSize: fontSizes.md, + color: colors.text.secondary, + textAlign: 'center', + }, + backButton: { + marginTop: spacing.xl, + paddingVertical: spacing.md, + paddingHorizontal: spacing.xl, + backgroundColor: colors.primary.main, + borderRadius: borderRadius.md, + }, + backButtonText: { + color: colors.text.inverse, + fontSize: fontSizes.md, + fontWeight: '600', + }, + }); +} + +export const QRCodeConfirmScreen: React.FC = () => { + const router = useRouter(); + const colors = useAppColors(); + const styles = useMemo(() => createQrCodeConfirmStyles(colors), [colors]); + const { sessionId: sessionIdParam } = useLocalSearchParams<{ sessionId?: string }>(); + const sessionId = sessionIdParam || ''; + + const [loading, setLoading] = useState(false); + const [userInfo, setUserInfo] = useState<{ id: string; nickname: string; avatar: string } | null>(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!sessionId) return; + void handleScan(); + }, [sessionId]); + + const handleScan = async () => { + try { + setLoading(true); + const response = await qrcodeApi.scan(sessionId); + setUserInfo(response.user); + } catch (err: any) { + const errorMsg = err.response?.data?.message || '扫码失败'; + setError(errorMsg); + Alert.alert('扫码失败', errorMsg, [{ text: '确定', onPress: () => router.back() }]); + } finally { + setLoading(false); + } + }; + + const handleConfirm = async () => { + try { + setLoading(true); + await qrcodeApi.confirm(sessionId); + Alert.alert('登录成功', '网页端已登录', [{ text: '确定', onPress: () => router.back() }]); + } catch (err: any) { + const errorMsg = err.response?.data?.message || '确认登录失败'; + Alert.alert('登录失败', errorMsg); + } finally { + setLoading(false); + } + }; + + const handleCancel = async () => { + try { + await qrcodeApi.cancel(sessionId); + } catch { + // 忽略取消错误 + } + router.back(); + }; + + if (loading && !userInfo) { + return ( + + + + 正在扫码... + + + ); + } + + if (error) { + return ( + + + + {error} + router.back()}> + 返回 + + + + ); + } + + return ( + + + + 确认登录 + + + + + + 您正在登录网页端 + + {userInfo && ( + + {userInfo.avatar ? ( + + ) : ( + + + + )} + {userInfo.nickname} + + )} + + + + + {loading ? ( + + ) : ( + 确认登录 + )} + + + + 取消 + + + + + ); +}; + +export default QRCodeConfirmScreen; diff --git a/src/screens/auth/RegisterScreen.tsx b/src/screens/auth/RegisterScreen.tsx index 12f9794..19c0b90 100644 --- a/src/screens/auth/RegisterScreen.tsx +++ b/src/screens/auth/RegisterScreen.tsx @@ -7,7 +7,7 @@ * - 屏幕宽度 >= 768px:分栏布局,左侧橙色右侧中性灰 */ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useMemo } from 'react'; import { View, Text, @@ -22,25 +22,25 @@ import { StatusBar, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useNavigation } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useRouter } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; -import { colors, spacing, borderRadius, shadows, fontSizes } from '../../theme'; +import { spacing, borderRadius, shadows, fontSizes, useAppColors, type AppColors } from '../../theme'; import { authService, resolveAuthApiError } from '../../services/authService'; import { useAuthStore } from '../../stores'; -import { RootStackParamList } from '../../navigation/types'; +import * as hrefs from '../../navigation/hrefs'; import { useResponsive, useResponsiveValue } from '../../hooks'; import { showPrompt } from '../../services/promptService'; -type RegisterNavigationProp = NativeStackNavigationProp; - // 分栏布局断点 const SPLIT_BREAKPOINT = 768; export const RegisterScreen: React.FC = () => { - const navigation = useNavigation(); + const colors = useAppColors(); + const styles = useMemo(() => createRegisterStyles(colors), [colors]); + const router = useRouter(); const register = useAuthStore((state) => state.register); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); // 响应式布局 const { isLandscape, width } = useResponsive(); @@ -48,6 +48,12 @@ export const RegisterScreen: React.FC = () => { // 是否使用分栏布局 const isSplitLayout = width >= SPLIT_BREAKPOINT; + useEffect(() => { + if (isAuthenticated) { + router.replace(hrefs.hrefHome()); + } + }, [isAuthenticated, router]); + const [username, setUsername] = useState(''); const [nickname, setNickname] = useState(''); const [email, setEmail] = useState(''); @@ -238,7 +244,7 @@ export const RegisterScreen: React.FC = () => { // 跳转到登录页 const handleGoToLogin = () => { - navigation.navigate('Login' as any); + router.push(hrefs.hrefAuthLogin()); }; // 渲染左侧面板(Logo和品牌信息)- 优化大屏布局 @@ -268,8 +274,8 @@ export const RegisterScreen: React.FC = () => { {/* 品牌名称 - 优化大屏排版 */} - 创建账号 - 加入胡萝卜,开始你的旅程 + 加入萝卜社区 + 加入萝卜社区,发现有趣的内容 {/* 装饰元素 - 大屏模式改为水平排列 + 大幅放大 */} @@ -720,8 +726,8 @@ export const RegisterScreen: React.FC = () => { color="#FFF" /> - 创建账号 - 加入胡萝卜,开始你的旅程 + 加入萝卜社区 + 加入萝卜社区,发现有趣的内容 {/* 表单 */} @@ -735,7 +741,8 @@ export const RegisterScreen: React.FC = () => { return isSplitLayout ? renderSplitLayout() : renderSingleLayout(); }; -const styles = StyleSheet.create({ +function createRegisterStyles(colors: AppColors) { + return StyleSheet.create({ container: { flex: 1, }, @@ -1072,6 +1079,7 @@ const styles = StyleSheet.create({ marginBottom: spacing.xl, textAlign: 'center', }, -}); + }); +} export default RegisterScreen; diff --git a/src/screens/create/CreatePostScreen.tsx b/src/screens/create/CreatePostScreen.tsx index f622470..12dc697 100644 --- a/src/screens/create/CreatePostScreen.tsx +++ b/src/screens/create/CreatePostScreen.tsx @@ -7,7 +7,7 @@ * 投票编辑器在宽屏下优化布局 */ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useMemo } from 'react'; import { View, ScrollView, @@ -19,19 +19,27 @@ import { Platform, Animated, Image, + useWindowDimensions, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { useRouter, useLocalSearchParams, useNavigation } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import * as ImagePicker from 'expo-image-picker'; -import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme'; -import { Text, Button, ResponsiveContainer } from '../../components/common'; -import { postService, showPrompt, voteService } from '../../services'; +import { + spacing, + fontSizes, + borderRadius, + shadows, + useAppColors, + type AppColors, +} from '../../theme'; +import { Text, ResponsiveContainer } from '../../components/common'; +import { channelService, postService, showPrompt, voteService } from '../../services'; import { ApiError } from '../../services/api'; import { uploadService } from '../../services/uploadService'; import VoteEditor from '../../components/business/VoteEditor'; import { useResponsive, useResponsiveValue } from '../../hooks'; -import { RootStackParamList } from '../../navigation/types'; +import * as hrefs from '../../navigation/hrefs'; // Props 接口 interface CreatePostScreenProps { @@ -40,6 +48,10 @@ interface CreatePostScreenProps { const MAX_TITLE_LENGTH = 100; const MAX_CONTENT_LENGTH = 2000; +type ChannelOption = { + id: string; + name: string; +}; // 表情面板高度 const EMOJI_PANEL_HEIGHT = 280; @@ -79,21 +91,25 @@ const getPublishErrorMessage = (error: unknown): string => { }; export const CreatePostScreen: React.FC = (props) => { + const colors = useAppColors(); + const styles = useMemo(() => createCreatePostStyles(colors), [colors]); const { onClose } = props; + const router = useRouter(); const navigation = useNavigation(); - const route = useRoute>(); - const isEditMode = route.params?.mode === 'edit' && !!route.params?.postId; - const editPostID = route.params?.postId || ''; + const { mode, postId: postIdParam } = useLocalSearchParams<{ mode?: string; postId?: string }>(); + const isEditMode = mode === 'edit' && !!postIdParam; + const editPostID = postIdParam || ''; // 响应式布局 const { isWideScreen, width } = useResponsive(); + const { height: windowHeight } = useWindowDimensions(); const [title, setTitle] = useState(''); const [content, setContent] = useState(''); const [images, setImages] = useState<{ uri: string; uploading?: boolean }[]>([]); - const [tags, setTags] = useState([]); - const [tagInput, setTagInput] = useState(''); - const [showTagInput, setShowTagInput] = useState(false); + const [channelOptions, setChannelOptions] = useState([]); + const [selectedChannelId, setSelectedChannelId] = useState(null); + const [showChannelPicker, setShowChannelPicker] = useState(false); const [showEmojiPanel, setShowEmojiPanel] = useState(false); const [posting, setPosting] = useState(false); const [loadingPost, setLoadingPost] = useState(false); @@ -112,11 +128,16 @@ export const CreatePostScreen: React.FC = (props) => { // 响应式图片网格配置 const imagesPerRow = useResponsiveValue({ xs: 3, sm: 3, md: 4, lg: 5, xl: 6 }); - const imageGap = 8; + const imageGap = 4; const availableWidth = isWideScreen ? Math.min(width, 800) - spacing.lg * 2 : width - spacing.lg * 2; - const imageSize = (availableWidth - imageGap * (imagesPerRow - 1)) / imagesPerRow; + const imageSize = Math.floor((availableWidth - imageGap * (imagesPerRow - 1)) / imagesPerRow); + const contentInputMinHeight = Math.max( + isWideScreen ? 460 : 320, + Math.floor(windowHeight * (isWideScreen ? 0.56 : 0.5)) + ); + const contentSectionMinHeight = Math.floor(windowHeight * (isWideScreen ? 0.68 : 0.62)); React.useEffect(() => { Animated.parallel([ @@ -133,17 +154,19 @@ export const CreatePostScreen: React.FC = (props) => { ]).start(); }, []); + React.useEffect(() => { + const loadChannels = async () => { + const list = await channelService.list(); + setChannelOptions(list.map(item => ({ id: item.id, name: item.name }))); + }; + loadChannels(); + }, []); + React.useLayoutEffect(() => { navigation.setOptions({ - title: isEditMode ? '编辑帖子' : '发布帖子', - // 当作为 Modal 使用时,显示关闭按钮 - headerLeft: onClose ? () => ( - - - - ) : undefined, + headerShown: false, }); - }, [navigation, isEditMode, onClose]); + }, [navigation]); React.useEffect(() => { if (!isEditMode || !editPostID) { @@ -156,7 +179,7 @@ export const CreatePostScreen: React.FC = (props) => { const existingPost = await postService.getPost(editPostID); if (!existingPost) { Alert.alert('提示', '帖子不存在或已被删除'); - navigation.goBack(); + router.back(); return; } setTitle(existingPost.title || ''); @@ -165,14 +188,14 @@ export const CreatePostScreen: React.FC = (props) => { } catch (error) { console.error('加载待编辑帖子失败:', error); Alert.alert('错误', '加载帖子失败,请稍后重试'); - navigation.goBack(); + router.back(); } finally { setLoadingPost(false); } }; loadPostForEdit(); - }, [isEditMode, editPostID, navigation]); + }, [isEditMode, editPostID, router]); // 选择图片 const handlePickImage = async () => { @@ -264,20 +287,11 @@ export const CreatePostScreen: React.FC = (props) => { setImages(images.filter((_, i) => i !== index)); }; - // 添加标签 - const handleAddTag = useCallback(() => { - const tag = tagInput.trim().replace(/^#/, ''); - if (tag && !tags.includes(tag) && tags.length < 5) { - setTags([...tags, tag]); - setTagInput(''); - setShowTagInput(false); - } - }, [tagInput, tags]); - - // 删除标签 - const handleRemoveTag = (tag: string) => { - setTags(tags.filter(t => t !== tag)); - }; + // 选择频道(单选,可再次点击取消) + const handleSelectChannel = useCallback((channelId: string) => { + setSelectedChannelId(prev => (prev === channelId ? null : channelId)); + setShowChannelPicker(false); + }, []); // 插入表情 const handleInsertEmoji = (emoji: string) => { @@ -290,7 +304,7 @@ export const CreatePostScreen: React.FC = (props) => { // 关闭所有面板 const closeAllPanels = () => { setShowEmojiPanel(false); - setShowTagInput(false); + setShowChannelPicker(false); }; // 切换表情面板 @@ -299,10 +313,10 @@ export const CreatePostScreen: React.FC = (props) => { setShowEmojiPanel(!showEmojiPanel); }; - // 切换标签输入 - const handleToggleTagInput = () => { + // 切换频道选择 + const handleToggleChannelPicker = () => { closeAllPanels(); - setShowTagInput(!showTagInput); + setShowChannelPicker(!showChannelPicker); }; // 切换投票模式 @@ -337,6 +351,11 @@ export const CreatePostScreen: React.FC = (props) => { // 防止重复点击 if (posting) return; + if (!title.trim()) { + Alert.alert('错误', '请输入帖子标题'); + return; + } + if (!content.trim()) { Alert.alert('错误', '请输入帖子内容'); return; @@ -365,9 +384,10 @@ export const CreatePostScreen: React.FC = (props) => { // 创建投票帖子 await voteService.createVotePost({ - title: title.trim() || '无标题', + title: title.trim(), content: content.trim(), images: imageUrls, + channel_id: selectedChannelId || undefined, vote_options: validOptions, }); showPrompt({ @@ -376,14 +396,11 @@ export const CreatePostScreen: React.FC = (props) => { message: '投票帖已提交,内容审核中,稍后展示', duration: 2600, }); - navigation.reset({ - index: 0, - routes: [{ name: 'Main' }], - }); + router.replace(hrefs.hrefHome()); } else { if (isEditMode && editPostID) { const updated = await postService.updatePost(editPostID, { - title: title.trim() || '无标题', + title: title.trim(), content: content.trim(), images: imageUrls, }); @@ -396,16 +413,14 @@ export const CreatePostScreen: React.FC = (props) => { message: '帖子内容已更新', duration: 2200, }); - navigation.reset({ - index: 0, - routes: [{ name: 'Main' }], - }); + router.replace(hrefs.hrefHome()); } else { // 创建普通帖子 await postService.createPost({ - title: title.trim() || '无标题', + title: title.trim(), content: content.trim(), images: imageUrls, + channel_id: selectedChannelId || undefined, }); showPrompt({ type: 'info', @@ -413,10 +428,7 @@ export const CreatePostScreen: React.FC = (props) => { message: '帖子已提交,内容审核中,稍后展示', duration: 2600, }); - navigation.reset({ - index: 0, - routes: [{ name: 'Main' }], - }); + router.replace(hrefs.hrefHome()); } } } catch (error) { @@ -478,66 +490,80 @@ export const CreatePostScreen: React.FC = (props) => { ); }; - // 渲染标签 - const renderTags = () => { - if (tags.length === 0 && !showTagInput) return null; + // 渲染频道(单选) + const renderChannelPicker = () => { + if (!showChannelPicker) return null; return ( - {tags.map((tag, index) => ( - - - {tag} - {/* 独立的删除按钮 */} + {channelOptions.map((channel) => { + const isSelected = selectedChannelId === channel.id; + return ( handleRemoveTag(tag)} - hitSlop={{ top: 5, right: 5, bottom: 5, left: 5 }} + key={channel.id} + style={[styles.tag, isSelected && styles.channelSelected]} + onPress={() => handleSelectChannel(channel.id)} > - + + {channel.name} + - - ))} - {tags.length < 5 && showTagInput && ( - - - - { setTagInput(''); setShowTagInput(false); }} style={styles.tagCancelButton}> - - - - - - - )} - {tags.length < 5 && !showTagInput && ( - setShowTagInput(true)}> - - 话题 - - )} + ); + })} + {channelOptions.length === 0 && ( + 暂无可用频道 + )} ); }; + const selectedChannelName = React.useMemo(() => { + if (!selectedChannelId) return ''; + return channelOptions.find(c => c.id === selectedChannelId)?.name || ''; + }, [selectedChannelId, channelOptions]); + + const renderChannelEntryRow = () => ( + + + 发表至: + + + + {selectedChannelName || '选择频道'} + + + + + ); + // 渲染投票编辑器 const renderVoteEditor = () => { if (!isVotePost) return null; return ( + + + 已插入投票 + + setIsVotePost(false)} activeOpacity={0.7}> + 移除投票 + + = (props) => { // 渲染内容输入区 const renderContentSection = () => ( - + {/* 标题输入 */} = (props) => { ]} value={title} onChangeText={setTitle} - placeholder="添加标题(可选)" + placeholder="请输入标题(必填)" placeholderTextColor={colors.text.hint} maxLength={MAX_TITLE_LENGTH} /> @@ -573,6 +604,7 @@ export const CreatePostScreen: React.FC = (props) => { style={[ styles.contentInput, isWideScreen && styles.contentInputWide, + { minHeight: contentInputMinHeight }, ]} value={content} onChangeText={setContent} @@ -583,6 +615,10 @@ export const CreatePostScreen: React.FC = (props) => { maxLength={MAX_CONTENT_LENGTH} onSelectionChange={(e) => setSelection(e.nativeEvent.selection)} /> + + {/* 作为正文容器的子模块,追加在文本后面 */} + {renderImageGrid()} + {renderVoteEditor()} ); @@ -646,25 +682,6 @@ export const CreatePostScreen: React.FC = (props) => { - = 5 || posting} - > - - = 5 ? colors.text.disabled : (showTagInput ? colors.primary.main : colors.text.secondary)} - /> - {tags.length > 0 && ( - - {tags.length} - - )} - - - = (props) => { + + ); - {/* 右侧发布按钮 */} - - + const renderTopActionBar = () => ( + + (onClose ? onClose() : router.back())} + style={styles.topActionButton} + activeOpacity={0.7} + > + 取消 + + + + + {isEditMode ? '编辑帖子' : '发布帖子'} + + {content.length}/{MAX_CONTENT_LENGTH} - - {posting ? ( - - ) : ( - - {isEditMode ? '保存' : '发布'} - - )} - + + + + {posting ? '发布中' : '发布'} + + ); // 渲染主内容 const renderMainContent = () => ( <> + {renderTopActionBar()} + = (props) => { > {/* 内容输入区 */} {renderContentSection()} - - {/* 投票编辑器 */} - {renderVoteEditor()} - - {/* 图片网格 */} - {renderImageGrid()} - - {/* 标签 */} - {renderTags()} + {renderChannelEntryRow()} + + {/* 频道选择面板 */} + {renderChannelPicker()} + {/* 表情面板 - 位于底部工具栏上方 */} {renderEmojiPanel()} @@ -783,7 +814,8 @@ export const CreatePostScreen: React.FC = (props) => { ); }; -const styles = StyleSheet.create({ +function createCreatePostStyles(colors: AppColors) { + return StyleSheet.create({ flex: { flex: 1, }, @@ -792,10 +824,48 @@ const styles = StyleSheet.create({ backgroundColor: colors.background.paper, }, scrollContent: { + flexGrow: 1, paddingBottom: spacing.xl, }, + topActionBar: { + height: 52, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.divider, + backgroundColor: colors.background.paper, + }, + topActionButton: { + minWidth: 44, + height: 36, + justifyContent: 'center', + }, + topActionButtonRight: { + alignItems: 'flex-end', + }, + topActionButtonDisabled: { + opacity: 0.7, + }, + topActionTitle: { + fontSize: fontSizes.lg, + fontWeight: '600', + }, + topActionCenter: { + alignItems: 'center', + justifyContent: 'center', + }, + topActionCount: { + marginTop: 2, + fontSize: fontSizes.xs, + }, + topActionPublishText: { + fontWeight: '600', + }, // 内容输入区 contentSection: { + flexGrow: 1, paddingHorizontal: spacing.lg, paddingTop: spacing.md, }, @@ -810,24 +880,23 @@ const styles = StyleSheet.create({ fontSize: fontSizes.xl, }, contentInput: { + flexGrow: 1, fontSize: fontSizes.md, color: colors.text.primary, - minHeight: 120, lineHeight: 22, paddingVertical: spacing.sm, }, contentInputWide: { fontSize: fontSizes.lg, lineHeight: 26, - minHeight: 150, }, // 图片网格 imageGrid: { flexDirection: 'row', flexWrap: 'wrap', - paddingHorizontal: spacing.lg, + paddingHorizontal: 0, paddingTop: spacing.md, - gap: 8, + gap: 4, }, imageGridItem: { borderRadius: borderRadius.md, @@ -870,7 +939,11 @@ const styles = StyleSheet.create({ // 标签 tagsSection: { paddingHorizontal: spacing.lg, - paddingTop: spacing.lg, + paddingTop: spacing.md, + paddingBottom: spacing.sm, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.divider, + backgroundColor: colors.background.paper, }, tagsContainer: { flexDirection: 'row', @@ -878,60 +951,19 @@ const styles = StyleSheet.create({ gap: spacing.sm, }, tag: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.primary.light + '15', - borderRadius: borderRadius.full, - paddingLeft: spacing.sm, - paddingRight: spacing.xs, - paddingVertical: spacing.xs, - }, - tagText: { - marginLeft: spacing.xs, - fontWeight: '500', - }, - tagDeleteButton: { - marginLeft: spacing.xs, - padding: 2, - }, - tagInputWrapper: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.background.default, - borderRadius: borderRadius.full, - paddingLeft: spacing.sm, - paddingRight: spacing.xs, - paddingVertical: spacing.xs, - borderWidth: 1, - borderColor: colors.primary.main, - }, - tagInput: { - fontSize: fontSizes.sm, - color: colors.text.primary, - minWidth: 80, - marginLeft: spacing.xs, - padding: 0, - }, - tagCancelButton: { - padding: spacing.xs, - marginRight: spacing.xs, - }, - tagConfirmButton: { - padding: spacing.xs, - }, - addTagButton: { - flexDirection: 'row', - alignItems: 'center', backgroundColor: colors.background.default, borderRadius: borderRadius.full, paddingHorizontal: spacing.md, paddingVertical: spacing.xs, borderWidth: 1, borderColor: colors.divider, - borderStyle: 'dashed', }, - addTagText: { - marginLeft: spacing.xs, + channelSelected: { + backgroundColor: colors.primary.main, + borderColor: colors.primary.main, + }, + tagText: { + fontWeight: '500', }, // 投票编辑器宽屏样式 voteEditorWide: { @@ -939,7 +971,36 @@ const styles = StyleSheet.create({ alignSelf: 'center', width: '100%', }, + voteEditorHeaderRow: { + marginHorizontal: spacing.lg, + marginTop: spacing.md, + marginBottom: -spacing.sm, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, // 底部工具栏 + channelEntryRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.divider, + backgroundColor: colors.background.paper, + }, + channelEntryLabel: { + fontSize: fontSizes.md, + }, + channelEntryValueWrap: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + }, + channelEntryValue: { + fontSize: fontSizes.md, + }, toolbar: { flexDirection: 'row', justifyContent: 'space-between', @@ -955,11 +1016,6 @@ const styles = StyleSheet.create({ alignItems: 'center', gap: spacing.xs, }, - toolbarRight: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.md, - }, toolbarButton: { position: 'relative', }, @@ -970,41 +1026,6 @@ const styles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', }, - tagBadge: { - position: 'absolute', - top: 4, - right: 4, - minWidth: 16, - height: 16, - borderRadius: 8, - backgroundColor: colors.primary.main, - justifyContent: 'center', - alignItems: 'center', - }, - tagBadgeText: { - color: colors.primary.contrast, - fontSize: fontSizes.xs, - fontWeight: '600', - }, - charCount: { - fontSize: fontSizes.xs, - }, - postButton: { - backgroundColor: colors.primary.main, - paddingHorizontal: spacing.lg, - paddingVertical: spacing.sm, - borderRadius: borderRadius.full, - minWidth: 64, - alignItems: 'center', - justifyContent: 'center', - }, - postButtonDisabled: { - backgroundColor: colors.text.disabled, - }, - postButtonText: { - fontWeight: '600', - fontSize: fontSizes.sm, - }, // 表情面板 emojiPanel: { height: EMOJI_PANEL_HEIGHT, @@ -1039,6 +1060,7 @@ const styles = StyleSheet.create({ loadingText: { fontSize: fontSizes.md, }, -}); + }); +} export default CreatePostScreen; diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index c55e5ad..05e76a7 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -4,60 +4,177 @@ * 支持列表和多列网格模式(响应式布局) */ -import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import React, { useState, useEffect, useLayoutEffect, useCallback, useMemo, useRef } from 'react'; import { View, FlatList, ScrollView, + Platform, StyleSheet, RefreshControl, StatusBar, TouchableOpacity, - NativeScrollEvent, NativeSyntheticEvent, + NativeScrollEvent, Alert, Clipboard, Modal, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useNavigation } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useRouter, useFocusEffect } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; -import { colors, spacing, borderRadius, shadows } from '../../theme'; +import { useAppColors, useResolvedColorScheme, spacing, borderRadius, shadows, type AppColors } from '../../theme'; import { Post } from '../../types'; -import { useUserStore } from '../../stores'; +import { useUserStore, useHomeTabBarVisibilityStore } from '../../stores'; import { useCurrentUser } from '../../stores/authStore'; -import { postService } from '../../services'; +import { channelService, postService } from '../../services'; import { PostCard, TabBar, SearchBar } from '../../components/business'; +import type { PostCardAction } from '../../components/business/PostCard'; import { Loading, EmptyState, Text, ImageGallery, ImageGridItem, ResponsiveGrid } from '../../components/common'; -import { HomeStackParamList, RootStackParamList } from '../../navigation/types'; import { useResponsive, useResponsiveSpacing } from '../../hooks/useResponsive'; +import { useDifferentialPosts } from '../../hooks/useDifferentialPosts'; +import { processPostUseCase } from '../../core/usecases/ProcessPostUseCase'; import { SearchScreen } from './SearchScreen'; import { CreatePostScreen } from '../create/CreatePostScreen'; -import { navigationService } from '../../infrastructure/navigation/navigationService'; +import * as hrefs from '../../navigation/hrefs'; -type NavigationProp = NativeStackNavigationProp & NativeStackNavigationProp; - -const TABS = ['推荐', '关注', '热门', '最新']; -const TAB_ICONS = ['compass-outline', 'account-heart-outline', 'fire', 'clock-outline']; +const TABS = ['关注', '最新', '热门']; +const TAB_ICONS = ['account-heart-outline', 'clock-outline', 'fire']; const DEFAULT_PAGE_SIZE = 20; -const SCROLL_BOTTOM_THRESHOLD = 240; -const LOAD_MORE_COOLDOWN_MS = 800; const SWIPE_TRANSLATION_THRESHOLD = 40; const SWIPE_COOLDOWN_MS = 300; const MOBILE_TAB_BAR_HEIGHT = 64; const MOBILE_TAB_FLOATING_MARGIN = 12; const MOBILE_FAB_GAP = 12; +/** 首页纵向滑动超过此阈值视为明确向下/向上划,用于隐藏或显示底部 Tab */ +const TAB_BAR_SCROLL_DELTA_Y = 10; +/** 停止滑动多久后自动恢复底部 Tab */ +const TAB_BAR_IDLE_RESTORE_MS = 2200; type ViewMode = 'list' | 'grid'; +type PostType = 'follow' | 'hot' | 'latest'; +type LatestCapsule = { id: string; name: string }; + +function createHomeStyles(colors: AppColors) { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.default, + }, + header: { + backgroundColor: colors.background.paper, + }, + searchWrapper: { + paddingTop: spacing.lg, + paddingBottom: spacing.sm, + shadowColor: 'transparent', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0, + shadowRadius: 0, + elevation: 0, + }, + homeTabBar: { + marginTop: spacing.xs, + marginBottom: spacing.sm, + }, + viewToggleBtn: { + width: 44, + height: 44, + alignItems: 'center', + justifyContent: 'center', + }, + capsuleWrapper: { + paddingBottom: spacing.sm, + backgroundColor: colors.background.paper, + }, + capsuleList: { + gap: spacing.xs, + paddingRight: spacing.lg, + }, + capsuleItem: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs, + borderRadius: 999, + }, + capsuleText: { + fontSize: 13, + fontWeight: '600', + color: colors.text.secondary, + }, + capsuleTextActive: { + color: colors.primary.main, + }, + listContent: { + flexGrow: 1, + }, + listHeader: { + width: '100%', + }, + contentContainer: { + flex: 1, + }, + listItem: {}, + waterfallScroll: { + flex: 1, + }, + waterfallContainer: { + width: '100%', + flexGrow: 1, + }, + waterfallColumnsRow: { + width: '100%', + flexDirection: 'row', + alignItems: 'flex-start', + }, + waterfallColumn: { + flex: 1, + }, + waterfallItem: {}, + floatingButton: { + position: 'absolute', + right: 20, + bottom: 20, + width: 56, + height: 56, + borderRadius: 28, + backgroundColor: colors.primary.main, + alignItems: 'center', + justifyContent: 'center', + ...shadows.lg, + }, + floatingButtonDesktop: { + right: 40, + bottom: 40, + width: 64, + height: 64, + borderRadius: 32, + }, + floatingButtonWide: { + right: 60, + bottom: 60, + width: 72, + height: 72, + borderRadius: 36, + }, + loadingMoreFooter: { + paddingVertical: 20, + alignItems: 'center', + justifyContent: 'center', + }, + }); +} export const HomeScreen: React.FC = () => { - const navigation = useNavigation(); + const router = useRouter(); const insets = useSafeAreaInsets(); - const { fetchPosts, likePost, unlikePost, favoritePost, unfavoritePost, posts: storePosts } = useUserStore(); + const { posts: storePosts } = useUserStore(); const currentUser = useCurrentUser(); - + const colors = useAppColors(); + const resolvedScheme = useResolvedColorScheme(); + const styles = useMemo(() => createHomeStyles(colors), [colors]); + const statusBarStyle = resolvedScheme === 'dark' ? 'light-content' : 'dark-content'; + // 使用响应式 hook const { width, @@ -65,23 +182,16 @@ export const HomeScreen: React.FC = () => { isTablet, isDesktop, isWideScreen, - breakpoint, - orientation, - isLandscape } = useResponsive(); // 响应式间距 const responsiveGap = useResponsiveSpacing({ xs: 4, sm: 6, md: 8, lg: 12, xl: 16 }); const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 }); - const [activeIndex, setActiveIndex] = useState(0); - const [posts, setPosts] = useState([]); - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); - const [loadingMore, setLoadingMore] = useState(false); - const [page, setPage] = useState(1); - const [hasMore, setHasMore] = useState(true); + const [activeIndex, setActiveIndex] = useState(1); const [viewMode, setViewMode] = useState('list'); + const [latestCapsules, setLatestCapsules] = useState([{ id: '', name: '全部' }]); + const [activeCapsuleId, setActiveCapsuleId] = useState(''); // 图片查看器状态 const [showImageViewer, setShowImageViewer] = useState(false); @@ -95,18 +205,226 @@ export const HomeScreen: React.FC = () => { const [showCreatePost, setShowCreatePost] = useState(false); // 用于跟踪当前页面显示的帖子 ID,以便从 store 同步状态 - const postIdsRef = React.useRef>(new Set()); - const inFlightRequestKeysRef = React.useRef>(new Set()); - const lastLoadMoreTriggerAtRef = useRef(0); + const postIdsRef = useRef>(new Set()); const lastSwipeAtRef = useRef(0); + + const isLoadingMoreRef = useRef(false); - // 用 ref 同步关键状态,避免 onWaterfallScroll 的陈旧闭包问题 - const pageRef = useRef(page); - const loadingMoreRef = useRef(loadingMore); - const hasMoreRef = useRef(hasMore); - pageRef.current = page; - loadingMoreRef.current = loadingMore; - hasMoreRef.current = hasMore; + const setBottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.setBottomTabBarHiddenByScroll); + const bottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll); + const homeListScrollYRef = useRef(0); + const tabBarIdleRestoreTimerRef = useRef | null>(null); + + const clearTabBarIdleRestoreTimer = useCallback(() => { + if (tabBarIdleRestoreTimerRef.current != null) { + clearTimeout(tabBarIdleRestoreTimerRef.current); + tabBarIdleRestoreTimerRef.current = null; + } + }, []); + + const scheduleTabBarIdleRestore = useCallback(() => { + clearTabBarIdleRestoreTimer(); + tabBarIdleRestoreTimerRef.current = setTimeout(() => { + setBottomTabBarHiddenByScroll(false); + tabBarIdleRestoreTimerRef.current = null; + }, TAB_BAR_IDLE_RESTORE_MS); + }, [clearTabBarIdleRestoreTimer, setBottomTabBarHiddenByScroll]); + + const handleHomeVerticalScroll = useCallback( + (event: NativeSyntheticEvent) => { + const y = event.nativeEvent.contentOffset.y; + const dy = y - homeListScrollYRef.current; + homeListScrollYRef.current = y; + + scheduleTabBarIdleRestore(); + + if (y <= 0) { + setBottomTabBarHiddenByScroll(false); + return; + } + + if (dy > TAB_BAR_SCROLL_DELTA_Y) { + setBottomTabBarHiddenByScroll(true); + } else if (dy < -TAB_BAR_SCROLL_DELTA_Y) { + setBottomTabBarHiddenByScroll(false); + } + }, + [scheduleTabBarIdleRestore, setBottomTabBarHiddenByScroll] + ); + + useFocusEffect( + useCallback(() => { + return () => { + clearTabBarIdleRestoreTimer(); + homeListScrollYRef.current = 0; + setBottomTabBarHiddenByScroll(false); + }; + }, [clearTabBarIdleRestoreTimer, setBottomTabBarHiddenByScroll]) + ); + + useEffect(() => { + if (showSearch) { + clearTabBarIdleRestoreTimer(); + setBottomTabBarHiddenByScroll(false); + } + }, [showSearch, clearTabBarIdleRestoreTimer, setBottomTabBarHiddenByScroll]); + + /** 横向胶囊条滚动位置:切换频道刷新列表时不重置 */ + const capsuleHScrollRef = useRef(null); + const capsuleScrollXRef = useRef(0); + + const restoreCapsuleStripScroll = useCallback(() => { + const x = capsuleScrollXRef.current; + if (x <= 0) return; + const scroll = () => capsuleHScrollRef.current?.scrollTo({ x, animated: false }); + scroll(); + requestAnimationFrame(() => { + requestAnimationFrame(scroll); + }); + }, []); + + const onCapsuleHorizontalScroll = useCallback((e: NativeSyntheticEvent) => { + capsuleScrollXRef.current = e.nativeEvent.contentOffset.x; + }, []); + + // 构建一个以 postId 为 key 的 map,用于快速查找 + const postsMap = useMemo(() => { + const map = new Map(); + storePosts.forEach(post => { + map.set(post.id, post); + }); + return map; + }, [storePosts]); + + // 获取当前 tab 对应的帖子类型 + const getPostType = useCallback((): PostType => { + switch (activeIndex) { + case 0: return 'follow'; + case 1: return 'latest'; + case 2: return 'hot'; + default: return 'latest'; + } + }, [activeIndex]); + + const isLatestTab = activeIndex === 1; + const currentChannelId = isLatestTab && activeCapsuleId ? activeCapsuleId : undefined; + + useEffect(() => { + homeListScrollYRef.current = 0; + setBottomTabBarHiddenByScroll(false); + }, [activeIndex, currentChannelId, viewMode, setBottomTabBarHiddenByScroll]); + + useLayoutEffect(() => { + if (!isLatestTab) return; + restoreCapsuleStripScroll(); + }, [isLatestTab, activeCapsuleId, latestCapsules.length, restoreCapsuleStripScroll]); + + // 使用差异更新 Hook 获取帖子列表 + const listKey = useMemo( + () => `home_${getPostType()}_${currentChannelId || 'all'}`, + [getPostType, currentChannelId] + ); + + const { + posts, + loading: isLoading, + isInitialLoading, + isLoadingMore, + refreshing: isRefreshing, + hasMore, + error, + reset, + } = useDifferentialPosts( + [], + { + listKey, + enableDiff: true, + enableBatching: true, + autoSubscribe: true, + } + ); + + // 加载更多方法 + const loadMore = useCallback(async () => { + if (hasMore && !isLoadingMore && !isLoadingMoreRef.current) { + isLoadingMoreRef.current = true; + const startedAt = Date.now(); + try { + await processPostUseCase.loadMorePosts(listKey); + console.log('[PostPerf] home loadMore', { + listKey, + costMs: Date.now() - startedAt, + totalItems: posts.length, + }); + } catch (err) { + console.error('加载更多失败:', err); + } finally { + isLoadingMoreRef.current = false; + } + } + }, [posts.length, listKey, hasMore, isLoadingMore]); + + // 网格模式滚动处理 - 检测是否滚动到底部 + 底部 Tab 显隐 + const handleGridScroll = useCallback( + (event: NativeSyntheticEvent) => { + handleHomeVerticalScroll(event); + const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent; + const scrollY = contentOffset.y; + const visibleHeight = layoutMeasurement.height; + const contentHeight = contentSize.height; + + if (scrollY + visibleHeight >= contentHeight - 200) { + loadMore(); + } + }, + [handleHomeVerticalScroll, loadMore] + ); + + // 刷新方法 - 先获取正确类型的帖子,再刷新 + const refresh = useCallback(async () => { + try { + // 先获取正确类型的帖子 + await processPostUseCase.fetchPosts( + { + page: 1, + pageSize: DEFAULT_PAGE_SIZE, + post_type: getPostType(), + channelId: currentChannelId, + }, + listKey + ); + } catch (err) { + console.error('刷新失败:', err); + } + }, [listKey, getPostType, currentChannelId]); + + // Tab 切换时刷新数据并重置列表 + useEffect(() => { + reset(); + refresh(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeIndex, currentChannelId]); + + // 将获取到的原始帖子与 store 中的状态合并 + // 这样 UI 始终显示的是 store 中的最新状态(包括点赞、收藏等) + const displayPosts = useMemo(() => { + return posts.map(post => { + const storePost = postsMap.get(post.id); + if (storePost) { + // store 同步点赞等状态;channel 等列表专用字段在 store 里常缺失,从列表帖合并 + return { + ...post, + ...storePost, + channel: storePost.channel ?? post.channel, + }; + } + return post; + }); + }, [posts, postsMap]); + + /** 按 id 取当前列表中的最新 post,配合 PostCard memo 忽略 onAction 引用 */ + const postByIdRef = useRef>(new Map()); + postByIdRef.current = new Map(displayPosts.map((p) => [p.id, p])); // 根据屏幕尺寸确定网格列数 const gridColumns = useMemo(() => { @@ -150,150 +468,12 @@ export const HomeScreen: React.FC = () => { if (!isMobile) { return undefined; } - // 往底部导航栏靠近一个按钮的位置 - return insets.bottom + MOBILE_TAB_BAR_HEIGHT + MOBILE_TAB_FLOATING_MARGIN + MOBILE_FAB_GAP - MOBILE_TAB_BAR_HEIGHT; - }, [isMobile, insets.bottom]); - - const appendUniquePosts = useCallback((prevPosts: Post[], incomingPosts: Post[]) => { - if (incomingPosts.length === 0) return prevPosts; - const seenIds = new Set(prevPosts.map(item => item.id)); - const dedupedIncoming = incomingPosts.filter(item => { - if (seenIds.has(item.id)) return false; - seenIds.add(item.id); - return true; - }); - return dedupedIncoming.length > 0 ? [...prevPosts, ...dedupedIncoming] : prevPosts; - }, []); - - const uniquePostsById = useCallback((items: Post[]) => { - if (items.length <= 1) return items; - const map = new Map(); - for (const item of items) { - map.set(item.id, item); + if (bottomTabBarHiddenByScroll) { + return MOBILE_TAB_FLOATING_MARGIN + MOBILE_FAB_GAP + insets.bottom; } - return Array.from(map.values()); - }, []); - - const getPostType = (): 'recommend' | 'follow' | 'hot' | 'latest' => { - switch (activeIndex) { - case 0: return 'recommend'; - case 1: return 'follow'; - case 2: return 'hot'; - case 3: return 'latest'; - default: return 'recommend'; - } - }; - - // 加载帖子列表 - const loadPosts = useCallback(async (pageNum: number = 1, isRefresh: boolean = false) => { - const postType = getPostType(); - const requestKey = `${postType}:${pageNum}`; - if (inFlightRequestKeysRef.current.has(requestKey)) { - return; - } - - try { - inFlightRequestKeysRef.current.add(requestKey); - if (isRefresh) { - setRefreshing(true); - } else if (pageNum === 1) { - setLoading(true); - } else { - setLoadingMore(true); - } - - const response = await fetchPosts(postType, pageNum); - const newPosts = response.list || []; - - if (isRefresh) { - setPosts(uniquePostsById(newPosts)); - setPage(1); - } else if (pageNum === 1) { - setPosts(uniquePostsById(newPosts)); - setPage(1); - } else { - setPosts(prev => appendUniquePosts(prev, newPosts)); - setPage(pageNum); - } - - const hasMoreByPage = response.total_pages > 0 ? response.page < response.total_pages : false; - const hasMoreBySize = newPosts.length >= (response.page_size || DEFAULT_PAGE_SIZE); - setHasMore(hasMoreByPage || hasMoreBySize); - } catch (error) { - console.error('Failed to load posts:', error); - } finally { - inFlightRequestKeysRef.current.delete(requestKey); - setLoading(false); - setRefreshing(false); - setLoadingMore(false); - } - }, [fetchPosts, activeIndex, appendUniquePosts, uniquePostsById]); - - // 切换Tab时重新加载 - useEffect(() => { - loadPosts(1, true); - }, [activeIndex]); - - // 同步 store 中的帖子状态到本地(用于点赞、收藏等状态更新) - useEffect(() => { - if (posts.length === 0) return; - - // 更新 postIdsRef - const currentPostIds = new Set(posts.map(p => p.id)); - postIdsRef.current = currentPostIds; - - // 从 store 中找到对应的帖子并同步状态 - let hasChanges = false; - const updatedPosts = posts.map(localPost => { - const storePost = storePosts.find(sp => sp.id === localPost.id); - if (storePost && ( - storePost.is_liked !== localPost.is_liked || - storePost.is_favorited !== localPost.is_favorited || - storePost.likes_count !== localPost.likes_count || - storePost.favorites_count !== localPost.favorites_count - )) { - hasChanges = true; - return { - ...localPost, - is_liked: storePost.is_liked, - is_favorited: storePost.is_favorited, - likes_count: storePost.likes_count, - favorites_count: storePost.favorites_count, - }; - } - return localPost; - }); - - if (hasChanges) { - setPosts(updatedPosts); - } - }, [storePosts]); - - // 下拉刷新 - const onRefresh = useCallback(() => { - loadPosts(1, true); - }, [loadPosts]); - - // 上拉加载更多 - const onEndReached = useCallback(() => { - if (!loadingMoreRef.current && hasMoreRef.current) { - loadPosts(pageRef.current + 1); - } - }, [loadPosts]); - - const onWaterfallScroll = useCallback((event: NativeSyntheticEvent) => { - if (loadingMoreRef.current || !hasMoreRef.current) return; - const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; - const distanceToBottom = contentSize.height - (contentOffset.y + layoutMeasurement.height); - const now = Date.now(); - if (distanceToBottom <= SCROLL_BOTTOM_THRESHOLD) { - if (now - lastLoadMoreTriggerAtRef.current < LOAD_MORE_COOLDOWN_MS) { - return; - } - lastLoadMoreTriggerAtRef.current = now; - loadPosts(pageRef.current + 1); - } - }, [loadPosts]); + // TabBar 悬浮在底部,发帖按钮需要在 TabBar 上方 + return MOBILE_TAB_BAR_HEIGHT + MOBILE_TAB_FLOATING_MARGIN * 2 + MOBILE_FAB_GAP + insets.bottom; + }, [isMobile, insets.bottom, bottomTabBarHiddenByScroll]); // 切换视图模式 const toggleViewMode = () => { @@ -308,6 +488,18 @@ export const HomeScreen: React.FC = () => { setActiveIndex(nextIndex); }, [activeIndex]); + useEffect(() => { + const loadChannels = async () => { + const list = await channelService.list(); + const capsules: LatestCapsule[] = [ + { id: '', name: '全部' }, + ...list.map(item => ({ id: item.id, name: item.name })), + ]; + setLatestCapsules(capsules); + }; + loadChannels(); + }, []); + const handleSwipeTabChange = useCallback((translationX: number) => { setActiveIndex(prev => ( translationX < 0 @@ -344,29 +536,37 @@ export const HomeScreen: React.FC = () => { // 跳转到帖子详情 const handlePostPress = (postId: string, scrollToComments: boolean = false) => { - navigationService.navigate('PostDetail', { postId, scrollToComments }); + router.push(hrefs.hrefPostDetail(postId, scrollToComments)); }; // 跳转到用户主页 const handleUserPress = (userId: string) => { - navigationService.navigate('UserProfile', { userId }); + router.push(hrefs.hrefUserProfile(userId)); }; // 点赞帖子 - const handleLike = (post: Post) => { - if (post.is_liked) { - unlikePost(post.id); - } else { - likePost(post.id); + const handleLike = async (post: Post) => { + try { + if (post.is_liked) { + await processPostUseCase.unlikePost(post.id); + } else { + await processPostUseCase.likePost(post.id); + } + } catch (error) { + console.error('点赞操作失败:', error); } }; // 收藏帖子 - const handleBookmark = (post: Post) => { - if (post.is_favorited) { - unfavoritePost(post.id); - } else { - favoritePost(post.id); + const handleBookmark = async (post: Post) => { + try { + if (post.is_favorited) { + await processPostUseCase.unfavoritePost(post.id); + } else { + await processPostUseCase.favoritePost(post.id); + } + } catch (error) { + console.error('收藏操作失败:', error); } }; @@ -374,28 +574,31 @@ export const HomeScreen: React.FC = () => { const handleShare = async (post: Post) => { if (!post?.id) return; try { - await postService.sharePost(post.id); - } catch (error) { - console.error('上报分享次数失败:', error); + const res = await postService.sharePost(post.id, { channel: 'copy_link' }); + if (res.ok && res.shares_count != null) { + processPostUseCase.applyShareCountUpdate(post.id, res.shares_count); + } + } catch (shareError) { + console.error('上报分享次数失败:', shareError); } const postUrl = `https://browser.littlelan.cn/posts/${encodeURIComponent(post.id)}`; Clipboard.setString(postUrl); Alert.alert('已复制', '帖子链接已复制到剪贴板'); }; - // 删除帖子 + // 删除帖子 - 删除后刷新列表 const handleDeletePost = async (postId: string) => { try { const success = await postService.deletePost(postId); if (success) { - // 从列表中移除已删除的帖子 - setPosts(prev => prev.filter(p => p.id !== postId)); + // 刷新列表以移除已删除的帖子 + refresh(); } else { console.error('删除帖子失败'); } - } catch (error) { - console.error('删除帖子失败:', error); - throw error; // 重新抛出错误,让 PostCard 处理错误提示 + } catch (deleteError) { + console.error('删除帖子失败:', deleteError); + throw deleteError; // 重新抛出错误,让 PostCard 处理错误提示 } }; @@ -406,13 +609,102 @@ export const HomeScreen: React.FC = () => { setShowImageViewer(true); }; + // 统一处理 PostCard 的所有操作 + const handlePostAction = (post: Post, action: PostCardAction) => { + switch (action.type) { + case 'press': + handlePostPress(post.id); + break; + case 'userPress': + const authorId = post.author?.id; + if (authorId) handleUserPress(authorId); + break; + case 'like': + case 'unlike': + handleLike(post); + break; + case 'comment': + handlePostPress(post.id, true); + break; + case 'bookmark': + case 'unbookmark': + handleBookmark(post); + break; + case 'share': + handleShare(post); + break; + case 'imagePress': + if (action.payload?.images && action.payload?.imageIndex !== undefined) { + handleImagePress(action.payload.images, action.payload.imageIndex); + } + break; + case 'delete': + handleDeletePost(post.id); + break; + } + }; + + const handlePostActionRef = useRef(handlePostAction); + handlePostActionRef.current = handlePostAction; + + const stableOnPostAction = useCallback((postId: string, action: PostCardAction) => { + const post = postByIdRef.current.get(postId); + if (post) { + handlePostActionRef.current(post, action); + } + }, []); + // 跳转到发帖页面(使用 Modal 方式) const handleCreatePost = () => { setShowCreatePost(true); }; + const renderLatestCapsules = () => { + if (!isLatestTab) { + return null; + } + + return ( + + + {latestCapsules.map((item) => { + const isActive = item.id === activeCapsuleId; + return ( + setActiveCapsuleId(item.id)} + style={styles.capsuleItem} + > + + {item.name} + + + ); + })} + + + ); + }; + // 渲染帖子卡片(列表模式) - const renderPostList = ({ item }: { item: Post }) => { + const keyExtractor = useCallback((item: Post) => item.id, []); + + const renderPostList = useCallback(({ item }: { item: Post }) => { const authorId = item.author?.id || ''; const isPostAuthor = currentUser?.id === authorId; return ( @@ -428,19 +720,12 @@ export const HomeScreen: React.FC = () => { ]}> handlePostPress(item.id)} - onUserPress={() => authorId && handleUserPress(authorId)} - onLike={() => handleLike(item)} - onComment={() => handlePostPress(item.id, true)} - onBookmark={() => handleBookmark(item)} - onShare={() => handleShare(item)} - onImagePress={(images, index) => handleImagePress(images, index)} - onDelete={() => handleDeletePost(item.id)} + onAction={(action) => stableOnPostAction(item.id, action)} isPostAuthor={isPostAuthor} /> ); - }; + }, [currentUser?.id, stableOnPostAction, isMobile, listItemWidth, responsiveGap]); // 估算帖子在瀑布流中的高度(用于均匀分配) const estimatePostHeight = (post: Post, columnWidth: number): number => { @@ -487,11 +772,16 @@ export const HomeScreen: React.FC = () => { const columns: Post[][] = Array.from({ length: gridColumns }, () => []); const columnHeights: number[] = Array(gridColumns).fill(0); + // 防御性检查:确保 posts 存在且是数组 + if (!displayPosts || !Array.isArray(displayPosts) || displayPosts.length === 0) { + return columns; + } + // 计算单列宽度 const totalGap = (gridColumns - 1) * responsiveGap; const columnWidth = (width - responsivePadding * 2 - totalGap) / gridColumns; - posts.forEach((post) => { + displayPosts.forEach((post) => { const postHeight = estimatePostHeight(post, columnWidth); // 找到当前高度最小的列 @@ -501,7 +791,7 @@ export const HomeScreen: React.FC = () => { }); return columns; - }, [posts, gridColumns, width, responsiveGap, responsivePadding]); + }, [displayPosts, gridColumns, width, responsiveGap, responsivePadding]); // 渲染单列帖子 const renderWaterfallColumn = (column: Post[], columnIndex: number) => ( @@ -514,14 +804,7 @@ export const HomeScreen: React.FC = () => { handlePostPress(post.id)} - onUserPress={() => authorId && handleUserPress(authorId)} - onLike={() => handleLike(post)} - onComment={() => handlePostPress(post.id, true)} - onBookmark={() => handleBookmark(post)} - onShare={() => handleShare(post)} - onImagePress={(images, index) => handleImagePress(images, index)} - onDelete={() => handleDeletePost(post.id)} + onAction={(action) => stableOnPostAction(post.id, action)} isPostAuthor={isPostAuthor} /> @@ -545,18 +828,27 @@ export const HomeScreen: React.FC = () => { } ]} showsVerticalScrollIndicator={false} - onScroll={onWaterfallScroll} - scrollEventThrottle={100} + scrollEventThrottle={16} + onScroll={handleGridScroll} refreshControl={ } > - {distributePostsToColumns.map((column, index) => renderWaterfallColumn(column, index))} + {renderLatestCapsules()} + + {distributePostsToColumns.map((column, index) => renderWaterfallColumn(column, index))} + + {/* 独立底部加载区:避免参与横向列布局导致列宽抖动 */} + {isLoadingMore && displayPosts.length > 0 && ( + + + + )} ); } @@ -568,22 +860,15 @@ export const HomeScreen: React.FC = () => { gap={{ xs: 8, sm: 12, md: 16, lg: 20, xl: 24 }} containerStyle={{ paddingHorizontal: responsivePadding, paddingBottom: 80 }} > - {posts.map(post => { + {displayPosts.map(post => { const authorId = post.author?.id || ''; const isPostAuthor = currentUser?.id === authorId; return ( handlePostPress(post.id)} - onUserPress={() => authorId && handleUserPress(authorId)} - onLike={() => handleLike(post)} - onComment={() => handlePostPress(post.id, true)} - onBookmark={() => handleBookmark(post)} - onShare={() => handleShare(post)} - onImagePress={(images, index) => handleImagePress(images, index)} - onDelete={() => handleDeletePost(post.id)} + variant={viewMode === 'grid' ? 'grid' : 'list'} + onAction={(action) => stableOnPostAction(post.id, action)} isPostAuthor={isPostAuthor} /> ); @@ -593,8 +878,8 @@ export const HomeScreen: React.FC = () => { }; // 渲染空状态 - const renderEmpty = () => { - if (loading) return null; + const renderEmpty = useCallback(() => { + if (isInitialLoading) return null; return ( { description={activeIndex === 1 ? '关注一些用户来获取内容吧' : '暂无帖子,快去发布第一条内容吧'} /> ); - }; + }, [activeIndex, isInitialLoading]); // 渲染列表内容 const renderListContent = () => { @@ -614,9 +899,11 @@ export const HomeScreen: React.FC = () => { // 移动端和宽屏都使用单列 FlatList,宽屏下居中显示 return ( item.id} + keyExtractor={keyExtractor} + ListHeaderComponent={renderLatestCapsules()} + ListHeaderComponentStyle={styles.listHeader} contentContainerStyle={[ styles.listContent, { @@ -628,16 +915,23 @@ export const HomeScreen: React.FC = () => { showsVerticalScrollIndicator={false} refreshControl={ } - onEndReached={onEndReached} + onEndReached={loadMore} onEndReachedThreshold={0.3} + onScroll={handleHomeVerticalScroll} + scrollEventThrottle={16} ListEmptyComponent={renderEmpty} - ListFooterComponent={loadingMore ? : null} + ListFooterComponent={isLoadingMore ? : null} + initialNumToRender={8} + maxToRenderPerBatch={8} + updateCellsBatchingPeriod={60} + windowSize={7} + removeClippedSubviews={false} /> ); }; @@ -646,7 +940,7 @@ export const HomeScreen: React.FC = () => { if (showSearch) { return ( - + setShowSearch(false)} /> @@ -657,8 +951,8 @@ export const HomeScreen: React.FC = () => { // 正常首页内容 return ( - - + + {/* 顶部Header */} {/* 搜索栏 */} @@ -679,6 +973,7 @@ export const HomeScreen: React.FC = () => { activeIndex={activeIndex} onTabChange={changeTab} variant="modern" + style={styles.homeTabBar} rightContent={ { // 移动端:使用 GestureDetector 支持手势切换 Tab - {viewMode === 'list' ? ( + {isInitialLoading ? ( + + ) : viewMode === 'list' ? ( renderListContent() ) : ( // 网格模式:使用响应式网格 @@ -707,7 +1004,9 @@ export const HomeScreen: React.FC = () => { ) : ( // 桌面端/平板端:不使用 GestureDetector,避免拦截侧边栏点击 - {viewMode === 'list' ? ( + {isInitialLoading ? ( + + ) : viewMode === 'list' ? ( renderListContent() ) : ( // 网格模式:使用响应式网格 @@ -756,77 +1055,3 @@ export const HomeScreen: React.FC = () => { ); }; - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.background.default, - }, - header: { - backgroundColor: colors.background.paper, - }, - searchWrapper: { - paddingBottom: spacing.sm, - // 移除阴影效果 - shadowColor: 'transparent', - shadowOffset: { width: 0, height: 0 }, - shadowOpacity: 0, - shadowRadius: 0, - elevation: 0, - }, - viewToggleBtn: { - width: 44, - height: 44, - alignItems: 'center', - justifyContent: 'center', - }, - listContent: { - flexGrow: 1, - }, - contentContainer: { - flex: 1, - }, - listItem: { - // 动态设置 marginBottom - }, - waterfallScroll: { - flex: 1, - }, - waterfallContainer: { - flexDirection: 'row', - flexGrow: 1, - alignItems: 'flex-start', - }, - waterfallColumn: { - flex: 1, - }, - waterfallItem: { - // 动态设置 marginBottom - }, - floatingButton: { - position: 'absolute', - right: 20, - bottom: 20, - width: 56, - height: 56, - borderRadius: 28, - backgroundColor: colors.primary.main, - alignItems: 'center', - justifyContent: 'center', - ...shadows.lg, - }, - floatingButtonDesktop: { - right: 40, - bottom: 40, - width: 64, - height: 64, - borderRadius: 32, - }, - floatingButtonWide: { - right: 60, - bottom: 60, - width: 72, - height: 72, - borderRadius: 36, - }, -}); diff --git a/src/screens/home/PostDetailScreen.tsx b/src/screens/home/PostDetailScreen.tsx index f7ced6d..630058c 100644 --- a/src/screens/home/PostDetailScreen.tsx +++ b/src/screens/home/PostDetailScreen.tsx @@ -23,29 +23,39 @@ import { Clipboard, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useNavigation, useRouter, useLocalSearchParams } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import * as ImagePicker from 'expo-image-picker'; -import { colors, spacing, fontSizes, borderRadius } from '../../theme'; +import { + spacing, + fontSizes, + borderRadius, + useAppColors, + type AppColors, +} from '../../theme'; import { Post, Comment, VoteResultDTO } from '../../types'; import { useUserStore } from '../../stores'; import { useCurrentUser } from '../../stores/authStore'; import { postService, commentService, uploadService, authService, showPrompt, voteService } from '../../services'; +import { processPostUseCase } from '../../core/usecases/ProcessPostUseCase'; +import { useCursorPagination } from '../../hooks/useCursorPagination'; import { CommentItem, VoteCard } from '../../components/business'; -import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout } from '../../components/common'; -import { RootStackParamList } from '../../navigation/types'; +import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout, AppBackButton } from '../../components/common'; import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks/useResponsive'; - -type NavigationProp = NativeStackNavigationProp; -type PostDetailRouteProp = RouteProp; +import * as hrefs from '../../navigation/hrefs'; export const PostDetailScreen: React.FC = () => { - const navigation = useNavigation(); - const route = useRoute(); + const colors = useAppColors(); + const styles = useMemo(() => createPostDetailStyles(colors), [colors]); + const navigation = useNavigation(); + const router = useRouter(); const insets = useSafeAreaInsets(); - const postId = route.params?.postId || ''; - const shouldScrollToComments = route.params?.scrollToComments || false; + const { postId: postIdParam, scrollToComments: scrollParam } = useLocalSearchParams<{ + postId?: string; + scrollToComments?: string; + }>(); + const postId = postIdParam || ''; + const shouldScrollToComments = scrollParam === '1' || scrollParam === 'true'; // 使用响应式 hook const { @@ -61,21 +71,98 @@ export const PostDetailScreen: React.FC = () => { const responsivePadding = useResponsiveSpacing({ xs: 12, sm: 14, md: 16, lg: 20, xl: 24, '2xl': 28, '3xl': 32, '4xl': 40 }); const responsiveGap = useResponsiveSpacing({ xs: 8, sm: 10, md: 12, lg: 16, xl: 20, '2xl': 24, '3xl': 28, '4xl': 32 }); - const { - likePost, - unlikePost, - favoritePost, - unfavoritePost, - likeComment, - unlikeComment - } = useUserStore(); - const currentUser = useCurrentUser(); const [post, setPost] = useState(null); const [comments, setComments] = useState([]); - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); + const [isPostInitialLoading, setIsPostInitialLoading] = useState(true); + const [isPostRefreshing, setIsPostRefreshing] = useState(false); + + // 使用游标分页 Hook 管理评论列表 + const { + list: paginatedComments, + isLoading: isCommentsLoading, + isInitialLoading: isCommentsInitialLoading, + isLoadingMore: isCommentsLoadingMore, + isRefreshing: isCommentsRefreshing, + hasMore: hasMoreComments, + loadMore: loadMoreComments, + refresh: refreshComments, + error: commentsError, + } = useCursorPagination( + async ({ cursor, pageSize }) => { + return await commentService.getPostCommentsCursor(postId, { + cursor, + page_size: pageSize, + }); + }, + { pageSize: 20 } + ); + + const getCommentId = useCallback((comment: Comment) => String(comment.id), []); + + const mergeCommentsById = useCallback((previousInput: Comment[] | null | undefined, incomingInput: Comment[] | null | undefined): Comment[] => { + const previous = Array.isArray(previousInput) ? previousInput : []; + const incoming = Array.isArray(incomingInput) ? incomingInput : []; + if (incoming.length === 0) return previous; + if (previous.length === 0) return incoming; + + const previousSet = new Set(previous.map(getCommentId)); + const incomingFirstId = getCommentId(incoming[0]); + if (!previousSet.has(incomingFirstId)) { + return [...previous, ...incoming]; + } + + const merged = [...previous]; + const indexById = new Map(); + for (let i = 0; i < merged.length; i += 1) { + indexById.set(getCommentId(merged[i]), i); + } + + for (const comment of incoming) { + const id = getCommentId(comment); + const existingIndex = indexById.get(id); + if (existingIndex === undefined) { + indexById.set(id, merged.length); + merged.push(comment); + } else { + merged[existingIndex] = comment; + } + } + return merged; + }, [getCommentId]); + + const mergeCommentsRefreshWindow = useCallback((previousInput: Comment[] | null | undefined, latestInput: Comment[] | null | undefined): Comment[] => { + const previous = Array.isArray(previousInput) ? previousInput : []; + const latest = Array.isArray(latestInput) ? latestInput : []; + if (latest.length === 0) return previous; + if (previous.length === 0) return latest; + + const latestSet = new Set(latest.map(getCommentId)); + const tail = previous.filter((item) => !latestSet.has(getCommentId(item))); + return [...latest, ...tail]; + }, [getCommentId]); + + // 切换帖子时,先清空上一帖评论,避免跨帖残留。 + useEffect(() => { + setComments([]); + }, [postId]); + + // 同步分页评论到本地状态(增量合并,避免全量替换) + useEffect(() => { + setComments((prev) => { + if (isCommentsRefreshing) { + return mergeCommentsRefreshWindow(prev, paginatedComments); + } + return mergeCommentsById(prev, paginatedComments); + }); + }, [isCommentsRefreshing, mergeCommentsById, mergeCommentsRefreshWindow, paginatedComments]); + + useEffect(() => { + if (commentsError) { + console.warn('[PostPerf] comments request error', { postId, error: commentsError }); + } + }, [commentsError, postId]); const [commentText, setCommentText] = useState(''); const [showImageModal, setShowImageModal] = useState(false); const [selectedImageIndex, setSelectedImageIndex] = useState(0); @@ -114,19 +201,20 @@ export const PostDetailScreen: React.FC = () => { // 加载帖子详情(可选择是否记录浏览量) const loadPostDetail = useCallback(async (recordView: boolean = false) => { if (!postId) { - setLoading(false); + setIsPostInitialLoading(false); return; } try { - // 从API获取帖子详情 - const postData = await postService.getPost(postId); + // 使用 ProcessPostUseCase 获取帖子详情 + const postData = await processPostUseCase.fetchPostById(postId); if (postData) { - setPost(postData); + // 类型转换:将 core/entities/Post 转换为 PostDTO 以保持兼容性 + setPost(postData as unknown as Post); // 初始化关注状态 if (postData.author) { - setIsFollowing(postData.author.is_following || false); - setIsFollowingMe(postData.author.is_following_me || false); + setIsFollowing((postData.author as any).is_following || false); + setIsFollowingMe((postData.author as any).is_following_me || false); } // 只在首次加载时记录浏览量 if (recordView && !hasRecordedView.current) { @@ -138,7 +226,7 @@ export const PostDetailScreen: React.FC = () => { } // 如果是投票帖子,立即加载投票数据 - if (postData.is_vote) { + if ((postData as any).is_vote) { setIsVoteLoading(true); try { const voteData = await voteService.getVoteResult(postId); @@ -159,19 +247,18 @@ export const PostDetailScreen: React.FC = () => { setPost(updatedPost); // 初始化关注状态 if (updatedPost.author) { - setIsFollowing(updatedPost.author.is_following || false); - setIsFollowingMe(updatedPost.author.is_following_me || false); + setIsFollowing((updatedPost.author as any).is_following || false); + setIsFollowingMe((updatedPost.author as any).is_following_me || false); } } } - // 加载评论 - const commentsData = await commentService.getPostComments(postId); - setComments(commentsData.list); + // 加载评论(使用游标分页刷新) + await refreshComments(); } catch (error) { console.error('加载帖子详情失败:', error); } finally { - setLoading(false); + setIsPostInitialLoading(false); } }, [postId]); @@ -189,21 +276,36 @@ export const PostDetailScreen: React.FC = () => { // 如果是从评论按钮跳转过来的,加载完成后滚动到评论区 useEffect(() => { - if (shouldScrollToComments && !loading && comments.length > 0) { + if (shouldScrollToComments && !isPostInitialLoading && (comments?.length ?? 0) > 0) { // 延迟确保列表已完成渲染和布局 setTimeout(() => { flatListRef.current?.scrollToIndex({ index: 0, animated: true, viewPosition: 0 }); }, 500); } - }, [shouldScrollToComments, loading, comments.length]); + }, [shouldScrollToComments, isPostInitialLoading, comments?.length]); // 动态设置导航头部 useEffect(() => { if (!post?.author) return; const author = post.author; + const handleBackPress = () => { + if (router.canGoBack()) { + router.back(); + return; + } + router.replace(hrefs.hrefHome()); + }; navigation.setOptions({ + // 与页面容器 background.default 一致,并去掉原生 header 底部分隔阴影,避免「一条线」 + headerStyle: { backgroundColor: colors.background.default }, + headerShadowVisible: false, + headerBackVisible: false, + headerTitleAlign: 'left', + headerLeft: () => ( + + ), headerTitle: () => ( { handleUserPress(author.id)}> - {author.nickname} + {author.nickname && author.nickname.length > 10 + ? `${author.nickname.slice(0, 10)}...` + : author.nickname} @@ -231,7 +335,7 @@ export const PostDetailScreen: React.FC = () => { ), }); - }, [post?.author, isFollowing, isFollowingMe, isFollowLoading, navigation]); + }, [post?.author, isFollowing, isFollowingMe, isFollowLoading, navigation, colors.background.default, styles]); // 监听键盘事件 useEffect(() => { @@ -261,12 +365,28 @@ export const PostDetailScreen: React.FC = () => { }; }, []); - // 下拉刷新 - const onRefresh = useCallback(() => { - setRefreshing(true); - loadPostDetail(); - setRefreshing(false); - }, [loadPostDetail]); + // 下拉刷新 - 同时刷新帖子和评论 + const onRefresh = useCallback(async () => { + const startedAt = Date.now(); + setIsPostRefreshing(true); + await loadPostDetail(); + console.log('[PostPerf] detail refresh', { + postId, + costMs: Date.now() - startedAt, + comments: comments.length, + }); + setIsPostRefreshing(false); + }, [comments.length, loadPostDetail, postId, refreshComments]); + + const handleLoadMoreComments = useCallback(async () => { + const startedAt = Date.now(); + await loadMoreComments(); + console.log('[PostPerf] detail loadMoreComments', { + postId, + costMs: Date.now() - startedAt, + totalItems: comments.length, + }); + }, [comments.length, loadMoreComments, postId]); const formatDateTime = (dateString?: string | null): string => { if (!dateString) return ''; @@ -300,12 +420,23 @@ export const PostDetailScreen: React.FC = () => { return formatDateTime(dateString); }; - const isPostEdited = (createdAt?: string, updatedAt?: string): boolean => { - if (!createdAt || !updatedAt) return false; - const created = new Date(createdAt).getTime(); - const updated = new Date(updatedAt).getTime(); - if (Number.isNaN(created) || Number.isNaN(updated)) return false; - return updated-created > 1000; + const getPostEditedDisplayAt = ( + createdAt?: string | null, + contentEditedAt?: string | null, + updatedAt?: string | null, + ): string | null => { + if (contentEditedAt) { + const c = new Date(createdAt || '').getTime(); + const e = new Date(contentEditedAt).getTime(); + if (!Number.isNaN(c) && !Number.isNaN(e) && e - c > 1000) return contentEditedAt; + return null; + } + if (createdAt && updatedAt) { + const c = new Date(createdAt).getTime(); + const u = new Date(updatedAt).getTime(); + if (!Number.isNaN(c) && !Number.isNaN(u) && u - c > 1000) return updatedAt; + } + return null; }; // 格式化数字 @@ -336,10 +467,11 @@ export const PostDetailScreen: React.FC = () => { try { if (oldIsLiked) { - await unlikePost(post.id); + await processPostUseCase.unlikePost(post.id); } else { - await likePost(post.id); + await processPostUseCase.likePost(post.id); } + // UseCase 会更新 store,本地状态已经是乐观更新的,无需再次更新 } catch (error) { console.error('点赞操作失败:', error); // 失败时回滚状态 @@ -349,7 +481,7 @@ export const PostDetailScreen: React.FC = () => { likes_count: oldLikesCount } : null); } - }, [post, likePost, unlikePost]); + }, [post]); // 收藏帖子 const handleFavorite = useCallback(async () => { @@ -368,10 +500,11 @@ export const PostDetailScreen: React.FC = () => { try { if (oldIsFavorited) { - await unfavoritePost(post.id); + await processPostUseCase.unfavoritePost(post.id); } else { - await favoritePost(post.id); + await processPostUseCase.favoritePost(post.id); } + // UseCase 会更新 store,本地状态已经是乐观更新的,无需再次更新 } catch (error) { console.error('收藏操作失败:', error); // 失败时回滚状态 @@ -381,13 +514,21 @@ export const PostDetailScreen: React.FC = () => { favorites_count: oldFavoritesCount } : null); } - }, [post, favoritePost, unfavoritePost]); + }, [post]); // 分享帖子 const handleShare = useCallback(async () => { if (!post?.id) return; try { - await postService.sharePost(post.id); + const res = await postService.sharePost(post.id, { channel: 'copy_link' }); + if (res.ok && res.shares_count != null) { + setPost((prev) => + prev + ? { ...prev, shares_count: res.shares_count!, sharesCount: res.shares_count! } + : null + ); + processPostUseCase.applyShareCountUpdate(post.id, res.shares_count); + } } catch (error) { console.error('上报分享次数失败:', error); } @@ -502,17 +643,13 @@ export const PostDetailScreen: React.FC = () => { onPress: async () => { setIsDeleting(true); try { - const success = await postService.deletePost(post.id); - if (success) { - Alert.alert('删除成功', '帖子已删除', [ - { - text: '确定', - onPress: () => navigation.goBack(), - }, - ]); - } else { - Alert.alert('删除失败', '删除帖子时发生错误,请稍后重试'); - } + await processPostUseCase.deletePost(post.id); + Alert.alert('删除成功', '帖子已删除', [ + { + text: '确定', + onPress: () => router.back(), + }, + ]); } catch (error) { console.error('删除帖子失败:', error); Alert.alert('删除失败', '删除帖子时发生错误,请稍后重试'); @@ -523,15 +660,12 @@ export const PostDetailScreen: React.FC = () => { }, ], ); - }, [post, isDeleting, currentUser?.id, navigation]); + }, [post, isDeleting, currentUser?.id, router]); const handleEditPost = useCallback(() => { if (!post) return; - navigation.navigate('CreatePost', { - mode: 'edit', - postId: post.id, - }); - }, [navigation, post]); + router.push(hrefs.hrefCreatePost('edit', post.id)); + }, [router, post]); // 点击图片查看大图 const handleImagePress = useCallback((images: ImageGridItem[], index: number) => { @@ -635,13 +769,13 @@ export const PostDetailScreen: React.FC = () => { id: '', username: 'guest', nickname: '游客', - avatar: null, - cover_url: null, - bio: null, - website: null, - location: null, - phone: null, - email: null, + avatar: '', + cover_url: '', + bio: '', + website: '', + location: '', + phone: undefined, + email: undefined, posts_count: 0, followers_count: 0, following_count: 0, @@ -792,9 +926,9 @@ export const PostDetailScreen: React.FC = () => { try { if (oldIsLiked) { - await unlikeComment(comment.id); + await commentService.unlikeComment(comment.id); } else { - await likeComment(comment.id); + await commentService.likeComment(comment.id); } } catch (error) { console.error('评论点赞操作失败:', error); @@ -868,7 +1002,7 @@ export const PostDetailScreen: React.FC = () => { // 跳转到用户主页 const handleUserPress = (userId: string) => { - navigation.navigate('UserProfile', { userId }); + router.push(hrefs.hrefUserProfile(userId)); }; // 渲染身份标识 @@ -1065,14 +1199,21 @@ export const PostDetailScreen: React.FC = () => { 发布 {formatRelativeTime(post.created_at)} - {isPostEdited(post.created_at, post.updated_at) && ( + {(() => { + const editedAt = getPostEditedDisplayAt( + post.created_at, + post.content_edited_at, + post.updated_at, + ); + return editedAt ? ( <> · - 修改 {formatRelativeTime(post.updated_at)} + 修改 {formatRelativeTime(editedAt)} - )} + ) : null; + })()} {post.views_count !== undefined && post.views_count > 0 && ( <> · @@ -1156,27 +1297,22 @@ export const PostDetailScreen: React.FC = () => { - {/* 评论标题 - 现代化设计 */} - - - - - - - 评论 - - - {post.comments_count} + {/* 评论标题 - 现代化简洁分区头 */} + + + + + 评论区 + + + {post.comments_count} + @@ -1225,7 +1361,9 @@ export const PostDetailScreen: React.FC = () => { }; // 渲染评论 - 带楼层号 - const renderComment = ({ item, index }: { item: Comment; index: number }) => { + const commentKeyExtractor = useCallback((item: Comment) => item.id, []); + + const renderComment = useCallback(({ item, index }: { item: Comment; index: number }) => { const authorId = item.author?.id || ''; // 收集当前评论的所有回复,用于根据 target_id 查找被回复用户 const allReplies = item.replies || []; @@ -1249,16 +1387,16 @@ export const PostDetailScreen: React.FC = () => { currentUserId={currentUser?.id} /> ); - }; + }, [currentUser?.id, handleDeleteComment, handleImagePress, handleLikeComment, handleLoadMoreReplies, post?.user_id]); // 渲染空评论 - 现代化设计 - const renderEmptyComments = () => ( + const renderEmptyComments = useCallback(() => ( @@ -1268,7 +1406,7 @@ export const PostDetailScreen: React.FC = () => { 成为第一个评论的人吧~ - ); + ), [isDesktop]); // 渲染评论输入框 const renderCommentInput = () => ( @@ -1364,7 +1502,7 @@ export const PostDetailScreen: React.FC = () => { ref={flatListRef} data={comments} renderItem={renderComment} - keyExtractor={item => item.id} + keyExtractor={commentKeyExtractor} ListEmptyComponent={renderEmptyComments} contentContainerStyle={{ paddingBottom: responsiveGap }} showsVerticalScrollIndicator={false} @@ -1375,12 +1513,43 @@ export const PostDetailScreen: React.FC = () => { }} refreshControl={ } + onEndReached={handleLoadMoreComments} + onEndReachedThreshold={0.3} + initialNumToRender={10} + maxToRenderPerBatch={10} + updateCellsBatchingPeriod={60} + windowSize={7} + removeClippedSubviews + ListFooterComponent={ + isCommentsInitialLoading ? ( + + + + ) : isCommentsLoadingMore ? ( + + + + ) : hasMoreComments ? ( + + + 加载更多评论 + + + ) : (comments?.length ?? 0) > 0 ? ( + + 没有更多评论了 + + ) : null + } /> ); @@ -1400,7 +1569,7 @@ export const PostDetailScreen: React.FC = () => { ); - if (loading) { + if (isPostInitialLoading) { return ; } @@ -1433,7 +1602,7 @@ export const PostDetailScreen: React.FC = () => { showsVerticalScrollIndicator={false} refreshControl={ { // 移动端单栏布局 return ( - { ref={flatListRef} data={comments} renderItem={renderComment} - keyExtractor={item => item.id} + keyExtractor={commentKeyExtractor} ListHeaderComponent={renderPostHeader} ListEmptyComponent={renderEmptyComments} contentContainerStyle={{ paddingBottom: responsiveGap }} @@ -1486,12 +1655,43 @@ export const PostDetailScreen: React.FC = () => { }} refreshControl={ } + onEndReached={handleLoadMoreComments} + onEndReachedThreshold={0.3} + initialNumToRender={10} + maxToRenderPerBatch={10} + updateCellsBatchingPeriod={60} + windowSize={7} + removeClippedSubviews + ListFooterComponent={ + isCommentsInitialLoading ? ( + + + + ) : isCommentsLoadingMore ? ( + + + + ) : hasMoreComments ? ( + + + 加载更多评论 + + + ) : (comments?.length ?? 0) > 0 ? ( + + 没有更多评论了 + + ) : null + } /> {/* 评论输入框 - 跟随键盘 */} @@ -1513,7 +1713,8 @@ export const PostDetailScreen: React.FC = () => { ); }; -const styles = StyleSheet.create({ +function createPostDetailStyles(colors: AppColors) { + return StyleSheet.create({ flex: { flex: 1, }, @@ -1526,7 +1727,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', flex: 1, - marginLeft: -8, + marginLeft: spacing.sm, }, headerAvatarWrapper: { marginRight: spacing.sm, @@ -1545,6 +1746,12 @@ const styles = StyleSheet.create({ alignItems: 'center', marginRight: spacing.sm, }, + headerBackButton: { + paddingHorizontal: spacing.xs, + paddingVertical: spacing.xs, + marginLeft: spacing.xs, + marginRight: spacing.sm, + }, // 帖子容器 postContainer: { backgroundColor: colors.background.paper, @@ -1623,7 +1830,7 @@ const styles = StyleSheet.create({ postMetaInfo: { flexDirection: 'row', alignItems: 'center', - justifyContent: 'space-between', + justifyContent: 'flex-start', marginBottom: spacing.sm, }, metaInfoMain: { @@ -1720,38 +1927,42 @@ const styles = StyleSheet.create({ marginLeft: 6, fontWeight: '500', }, - commentTitle: { + commentSectionHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.divider, + paddingTop: spacing.lg, + }, + commentSectionTitleBlock: { + flex: 1, + minWidth: 0, + }, + commentSectionTitleRow: { flexDirection: 'row', alignItems: 'center', }, - commentTitleLeft: { - flexDirection: 'row', - alignItems: 'center', - }, - commentTitleIconContainer: { - borderRadius: borderRadius.md, - backgroundColor: colors.primary.main, - justifyContent: 'center', - alignItems: 'center', - marginRight: spacing.sm, - }, - commentTitleText: { - fontWeight: '700', + commentSectionTitle: { + fontWeight: '800', color: colors.text.primary, + letterSpacing: -0.2, }, commentCountBadge: { - backgroundColor: colors.primary.light + '25', - paddingHorizontal: spacing.sm, - paddingVertical: 2, - borderRadius: borderRadius.md, + backgroundColor: colors.background.default, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.divider, + paddingHorizontal: spacing.sm + 2, + paddingVertical: 3, + borderRadius: 999, marginLeft: spacing.sm, minWidth: 24, alignItems: 'center', }, commentCountText: { - fontSize: fontSizes.sm, + fontSize: fontSizes.xs, fontWeight: '700', - color: colors.primary.main, + color: colors.text.secondary, }, inputContainer: { backgroundColor: colors.background.paper, @@ -1855,21 +2066,23 @@ const styles = StyleSheet.create({ paddingVertical: spacing.xs, borderRadius: borderRadius.md, }, - // 空评论状态样式 + // 空评论状态样式(与平铺评论区一致,无卡片气泡) emptyCommentsContainer: { alignItems: 'center', justifyContent: 'center', - paddingVertical: spacing.xl * 2, - paddingHorizontal: spacing.lg, + marginHorizontal: spacing.md, + marginTop: spacing.md, + marginBottom: spacing.lg, + paddingVertical: spacing.xl, + paddingHorizontal: spacing.md, }, emptyCommentsIconWrapper: { - width: 72, - height: 72, - borderRadius: 36, - backgroundColor: colors.primary.light + '18', + width: 40, + height: 40, alignItems: 'center', justifyContent: 'center', - marginBottom: spacing.lg, + marginBottom: spacing.sm, + opacity: 0.85, }, emptyCommentsTitle: { fontSize: fontSizes.md, @@ -1905,4 +2118,23 @@ const styles = StyleSheet.create({ marginTop: spacing.md, textAlign: 'center', }, -}); + // 评论加载更多样式 + commentsLoadingFooter: { + paddingVertical: spacing.md, + alignItems: 'center', + justifyContent: 'center', + }, + loadMoreButton: { + paddingVertical: spacing.md, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.background.paper, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.divider, + }, + noMoreComments: { + textAlign: 'center', + paddingVertical: spacing.md, + }, + }); +} diff --git a/src/screens/home/SearchScreen.tsx b/src/screens/home/SearchScreen.tsx index ac4774a..1ad58d0 100644 --- a/src/screens/home/SearchScreen.tsx +++ b/src/screens/home/SearchScreen.tsx @@ -4,41 +4,42 @@ * 支持响应式布局,宽屏下显示更大的搜索结果区域 */ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useEffect, useMemo } from 'react'; import { View, FlatList, StyleSheet, TouchableOpacity, ScrollView, + RefreshControl, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useNavigation } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useRouter } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { colors, spacing, fontSizes, borderRadius } from '../../theme'; +import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme'; import { Post, User } from '../../types'; import { useUserStore } from '../../stores'; import { postService, authService } from '../../services'; import { PostCard, TabBar, SearchBar } from '../../components/business'; -import { Avatar, EmptyState, Text, ResponsiveGrid } from '../../components/common'; -import { HomeStackParamList } from '../../navigation/types'; +import type { PostCardAction } from '../../components/business/PostCard'; +import { Avatar, EmptyState, Text, ResponsiveGrid, Loading } from '../../components/common'; +import * as hrefs from '../../navigation/hrefs'; import { useResponsive, useResponsiveSpacing, useResponsiveValue } from '../../hooks/useResponsive'; - -type NavigationProp = NativeStackNavigationProp; +import { useCursorPagination } from '../../hooks/useCursorPagination'; const TABS = ['帖子', '用户']; +const DEFAULT_PAGE_SIZE = 20; type SearchType = 'posts' | 'users'; interface SearchScreenProps { onBack?: () => void; - navigation?: NavigationProp; } -export const SearchScreen: React.FC = ({ onBack, navigation: propNavigation }) => { - // 如果传入了 navigation 则使用传入的,否则使用 hook 获取的 - const navigation = propNavigation || useNavigation(); +export const SearchScreen: React.FC = ({ onBack }) => { + const colors = useAppColors(); + const styles = useMemo(() => createSearchScreenStyles(colors), [colors]); + const router = useRouter(); const insets = useSafeAreaInsets(); const { searchHistory: history, addSearchHistory, clearSearchHistory } = useUserStore(); @@ -74,17 +75,47 @@ export const SearchScreen: React.FC = ({ onBack, navigation: const [searchText, setSearchText] = useState(''); const [activeIndex, setActiveIndex] = useState(0); - const [searchResults, setSearchResults] = useState<{ - posts: Post[]; - users: User[]; - }>({ - posts: [], - users: [], - }); const [hasSearched, setHasSearched] = useState(false); // 保存当前搜索关键词,用于Tab切换时重新搜索 const [currentKeyword, setCurrentKeyword] = useState(''); + // 使用游标分页进行帖子搜索 + const { + list: searchResults, + isLoading, + isRefreshing, + hasMore, + loadMore, + refresh, + reset, + } = useCursorPagination( + async ({ cursor, pageSize, extraParams }) => { + if (!extraParams?.query) { + return { list: [], next_cursor: null, prev_cursor: null, has_more: false }; + } + const response = await postService.searchPostsCursor(extraParams.query, { + cursor, + page_size: pageSize, + }); + return response; + }, + { pageSize: DEFAULT_PAGE_SIZE }, + { query: '' } + ); + + // 用户搜索结果(保持原有分页方式) + const [userResults, setUserResults] = useState([]); + const [userLoading, setUserLoading] = useState(false); + + // 当搜索词变化时重置 + useEffect(() => { + if (currentKeyword) { + refresh(); + } else { + reset(); + } + }, [currentKeyword]); + // 执行搜索 - 根据当前Tab执行对应类型的搜索 const performSearch = useCallback(async (keyword: string) => { if (!keyword.trim()) return; @@ -101,26 +132,20 @@ export const SearchScreen: React.FC = ({ onBack, navigation: const searchType = getSearchType(); if (searchType === 'posts') { - // 搜索帖子 - const postsResponse = await postService.searchPosts(trimmedKeyword, 1, 20); - setSearchResults(prev => ({ - ...prev, - posts: postsResponse.list - })); + // 帖子搜索由 useCursorPagination 处理,这里只需触发刷新 + refresh(); } else if (searchType === 'users') { - // 搜索用户 + // 用户搜索保持原有方式 + setUserLoading(true); const usersResponse = await authService.searchUsers(trimmedKeyword, 1, 20); - setSearchResults(prev => ({ - ...prev, - users: usersResponse.list - })); + setUserResults(usersResponse.list || []); } } catch (error) { console.error('搜索失败:', error); } setHasSearched(true); - }, [addSearchHistory, activeIndex]); + }, [addSearchHistory, activeIndex, refresh]); // 处理搜索提交 const handleSearch = () => { @@ -140,12 +165,47 @@ export const SearchScreen: React.FC = ({ onBack, navigation: // 跳转到帖子详情 const handlePostPress = (postId: string, scrollToComments: boolean = false) => { - navigation.navigate('PostDetail', { postId, scrollToComments }); + router.push(hrefs.hrefPostDetail(postId, scrollToComments)); }; // 跳转到用户主页 const handleUserPress = (userId: string) => { - navigation.navigate('UserProfile', { userId }); + router.push(hrefs.hrefUserProfile(userId)); + }; + + // 统一处理 PostCard 的操作(搜索页不支持删除) + const handlePostAction = (post: Post, action: PostCardAction) => { + switch (action.type) { + case 'press': + handlePostPress(post.id); + break; + case 'userPress': + if (post.author?.id) { + handleUserPress(post.author.id); + } + break; + case 'like': + postService.likePost(post.id); + break; + case 'unlike': + postService.unlikePost(post.id); + break; + case 'comment': + handlePostPress(post.id, true); + break; + case 'bookmark': + postService.favoritePost(post.id); + break; + case 'unbookmark': + postService.unfavoritePost(post.id); + break; + case 'share': + // 搜索页暂不处理分享 + break; + case 'imagePress': + case 'delete': + break; + } }; // 获取当前搜索类型 @@ -159,9 +219,9 @@ export const SearchScreen: React.FC = ({ onBack, navigation: // 渲染帖子搜索结果(使用响应式网格) const renderPostResults = () => { - const posts = searchResults.posts; + const posts = searchResults; - if (posts.length === 0) { + if (posts.length === 0 && !isLoading) { return ( = ({ onBack, navigation: + } > = ({ onBack, navigation: handlePostPress(post.id)} - onUserPress={() => post.author ? handleUserPress(post.author.id) : () => {}} - onLike={() => {}} - onComment={() => handlePostPress(post.id, true)} - onBookmark={() => {}} - onShare={() => {}} - compact={isMobile} + onAction={(action) => handlePostAction(post, action)} + variant="list" + features={isMobile ? 'compact' : 'full'} /> ))} + {isLoading && ( + + + + )} ); } @@ -208,27 +277,34 @@ export const SearchScreen: React.FC = ({ onBack, navigation: renderItem={({ item }) => ( handlePostPress(item.id)} - onUserPress={() => item.author ? handleUserPress(item.author.id) : () => {}} - onLike={() => {}} - onComment={() => handlePostPress(item.id, true)} - onBookmark={() => {}} - onShare={() => {}} - compact + onAction={(action) => handlePostAction(item, action)} + variant="list" + features="compact" /> )} keyExtractor={item => item.id} contentContainerStyle={{ paddingBottom: responsivePadding }} showsVerticalScrollIndicator={false} + refreshControl={ + + } + onEndReached={loadMore} + onEndReachedThreshold={0.3} + ListFooterComponent={isLoading ? : null} /> ); }; // 渲染用户搜索结果 const renderUserResults = () => { - const users = searchResults.users; + const users = userResults; - if (users.length === 0) { + if (users.length === 0 && !userLoading) { return ( = ({ onBack, navigation: ))} + {userLoading && ( + + + + )} ); } @@ -329,6 +410,7 @@ export const SearchScreen: React.FC = ({ onBack, navigation: keyExtractor={item => item.id} contentContainerStyle={{ paddingVertical: responsiveGap }} showsVerticalScrollIndicator={false} + ListFooterComponent={userLoading ? : null} /> ); }; @@ -430,7 +512,7 @@ export const SearchScreen: React.FC = ({ onBack, navigation: onBack ? onBack() : navigation.goBack()} + onPress={() => (onBack ? onBack() : router.back())} activeOpacity={0.85} > = ({ onBack, navigation: ); }; -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.background.default, - }, - searchHeader: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.background.paper, - borderBottomWidth: 1, - borderBottomColor: `${colors.divider}70`, - }, - searchShell: { - flex: 1, - borderRadius: borderRadius.xl, - backgroundColor: `${colors.primary.main}08`, - paddingHorizontal: spacing.xs, - paddingVertical: spacing.xs, - // 移除阴影效果 - shadowColor: 'transparent', - shadowOffset: { width: 0, height: 0 }, - shadowOpacity: 0, - shadowRadius: 0, - elevation: 0, - }, - cancelButton: { - backgroundColor: `${colors.primary.main}14`, - borderRadius: borderRadius.full, - paddingHorizontal: spacing.md, - paddingVertical: spacing.xs, - }, - cancelText: { - fontSize: fontSizes.md, - fontWeight: '600', - }, - tabWrapper: { - backgroundColor: colors.background.paper, - paddingTop: spacing.xs, - paddingBottom: spacing.xs, - }, - suggestionsContainer: { - flex: 1, - }, - section: { - marginTop: spacing.lg, - }, - sectionHeader: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - marginBottom: spacing.md, - }, - sectionTitle: { - fontWeight: '600', - color: colors.text.primary, - }, - tagContainer: { - flexDirection: 'row', - flexWrap: 'wrap', - }, - tag: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.background.paper, - borderRadius: borderRadius.md, - }, - tagText: { - marginLeft: spacing.xs, - }, - // 移动端用户列表样式 - userItem: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.background.paper, - borderRadius: borderRadius.md, - }, - userInfo: { - flex: 1, - marginLeft: spacing.md, - }, - userName: { - fontWeight: '600', - color: colors.text.primary, - }, - followingBadge: { - width: 24, - height: 24, - borderRadius: 12, - backgroundColor: colors.primary.light + '30', - alignItems: 'center', - justifyContent: 'center', - }, - // 桌面端用户卡片样式 - userCard: { - backgroundColor: colors.background.paper, - borderRadius: borderRadius.lg, - alignItems: 'center', - justifyContent: 'center', - minHeight: 180, - }, - userCardInfo: { - alignItems: 'center', - marginTop: spacing.md, - }, - userCardName: { - fontWeight: '600', - color: colors.text.primary, - marginBottom: spacing.xs, - }, -}); +function createSearchScreenStyles(colors: AppColors) { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.default, + }, + searchHeader: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.background.paper, + borderBottomWidth: 1, + borderBottomColor: `${colors.divider}70`, + }, + searchShell: { + flex: 1, + borderRadius: borderRadius.xl, + backgroundColor: `${colors.primary.main}08`, + paddingHorizontal: spacing.xs, + paddingVertical: spacing.xs, + // 移除阴影效果 + shadowColor: 'transparent', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0, + shadowRadius: 0, + elevation: 0, + }, + cancelButton: { + backgroundColor: `${colors.primary.main}14`, + borderRadius: borderRadius.full, + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs, + }, + cancelText: { + fontSize: fontSizes.md, + fontWeight: '600', + }, + tabWrapper: { + backgroundColor: colors.background.paper, + borderBottomWidth: 1, + borderBottomColor: `${colors.divider}50`, + }, + suggestionsContainer: { + flex: 1, + }, + section: { + marginTop: spacing.md, + }, + sectionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: spacing.sm, + }, + sectionTitle: { + fontWeight: '600', + color: colors.text.primary, + }, + tagContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + }, + tag: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.background.paper, + borderRadius: borderRadius.lg, + borderWidth: 1, + borderColor: colors.divider, + }, + tagText: { + marginLeft: spacing.xs, + }, + userCard: { + backgroundColor: colors.background.paper, + borderRadius: borderRadius.lg, + flexDirection: 'row', + alignItems: 'center', + }, + userCardInfo: { + flex: 1, + marginLeft: spacing.md, + }, + userCardName: { + fontWeight: '600', + color: colors.text.primary, + }, + userItem: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.background.paper, + borderRadius: borderRadius.lg, + }, + userInfo: { + flex: 1, + marginLeft: spacing.md, + }, + userName: { + fontWeight: '600', + color: colors.text.primary, + }, + followingBadge: { + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: `${colors.primary.main}14`, + alignItems: 'center', + justifyContent: 'center', + }, + loadingMore: { + paddingVertical: spacing.md, + alignItems: 'center', + }, + }); +} diff --git a/src/screens/message/ChatScreen.tsx b/src/screens/message/ChatScreen.tsx index 3bf4b36..b4f56fd 100644 --- a/src/screens/message/ChatScreen.tsx +++ b/src/screens/message/ChatScreen.tsx @@ -19,25 +19,27 @@ * - ChatInput.tsx: 输入框组件 */ -import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { View, FlatList, - TouchableWithoutFeedback, ActivityIndicator, KeyboardAvoidingView, Platform, + TouchableOpacity, } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { useNavigation, useRouter } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { Text, ImageGallery, ImageGridItem } from '../../components/common'; -import { colors } from '../../theme'; +import { useAppColors } from '../../theme'; +import * as hrefs from '../../navigation/hrefs'; import { messageManager } from '../../stores'; import { useBreakpointGTE } from '../../hooks/useResponsive'; import { useChatScreen, - chatScreenStyles as baseStyles, + useChatScreenStyles, EmojiPanel, MorePanel, MentionPanel, @@ -50,34 +52,38 @@ import { } from './components/ChatScreen'; export const ChatScreen: React.FC = () => { - const navigation = useNavigation(); + const navigation = useNavigation(); + const router = useRouter(); const insets = useSafeAreaInsets(); - + const colors = useAppColors(); + const styles = useChatScreenStyles(); + // 响应式布局 const isWideScreen = useBreakpointGTE('lg'); - const styles = baseStyles; // 监听屏幕宽度变化,当变为大屏幕时自动跳转到web端首页 useEffect(() => { if (isWideScreen) { - // 导航到大屏幕模式的主界面首页 - navigation.reset({ - index: 0, - routes: [ - { - name: 'Main', - params: { - screen: 'HomeTab', - params: { screen: 'Home' } - } - } - ] - }); + router.replace(hrefs.hrefHome()); } - }, [isWideScreen, navigation]); + }, [isWideScreen, router]); // 输入框区域高度(用于定位浮动 mention 面板) const [inputWrapperHeight, setInputWrapperHeight] = useState(60); + const [showEdgeLoadingIndicator, setShowEdgeLoadingIndicator] = useState(false); + const replyTargetMessageIdRef = useRef(null); + const replyHighlightTimerRef = useRef | null>(null); + const isPreloadingRef = useRef(false); + const lastScrollYRef = useRef(0); + const isUserDraggingRef = useRef(false); + const historyAnchorRef = useRef<{ active: boolean; beforeY: number; beforeHeight: number }>({ + active: false, + beforeY: 0, + beforeHeight: 0, + }); + const preloadCooldownTimerRef = useRef | null>(null); + const showJumpToLatestRef = useRef(false); + const [showJumpToLatest, setShowJumpToLatest] = useState(false); const containerStyle = useMemo(() => ([ styles.container, @@ -135,13 +141,16 @@ export const ChatScreen: React.FC = () => { currentUserId, keyboardHeight, loading, - sending, + isComposerBusy, activePanel, sendingImage, + uploadingAttachments, + pendingAttachments, replyingTo, longPressMenuVisible, selectedMessage, selectedMessageId, + setSelectedMessageId, menuPosition, isGroupChat, groupInfo, @@ -170,6 +179,7 @@ export const ChatScreen: React.FC = () => { shouldShowTime, handleInputChange, handleSend, + removePendingAttachment, handleMoreAction, handleInsertEmoji, handleSendSticker, @@ -194,57 +204,212 @@ export const ChatScreen: React.FC = () => { navigateToChatSettings, loadMoreHistory, handleMessageListContentSizeChange, + handleReachLatestEdge, + jumpToLatestMessages, + setBrowsingHistory, } = useChatScreen(); + const displayMessages = useMemo(() => [...messages].reverse(), [messages]); + + useEffect(() => { + if (!loadingMore && showEdgeLoadingIndicator) { + setShowEdgeLoadingIndicator(false); + } + }, [loadingMore, showEdgeLoadingIndicator]); + + useEffect(() => { + if (loading) { + showJumpToLatestRef.current = false; + setShowJumpToLatest(false); + } + }, [loading]); + + useEffect(() => { + showJumpToLatestRef.current = false; + setShowJumpToLatest(false); + }, [conversationId]); + + useEffect(() => { + return () => { + if (preloadCooldownTimerRef.current) { + clearTimeout(preloadCooldownTimerRef.current); + } + if (replyHighlightTimerRef.current) { + clearTimeout(replyHighlightTimerRef.current); + } + }; + }, []); + + const handleReplyPreviewPress = useCallback((messageId: string) => { + const targetId = String(messageId); + const targetIndex = displayMessages.findIndex(msg => String(msg.id) === targetId); + if (targetIndex < 0) return; + + replyTargetMessageIdRef.current = targetId; + setBrowsingHistory(true); + + flatListRef.current?.scrollToIndex({ + index: targetIndex, + animated: true, + viewPosition: 0.5, + }); + }, [displayMessages, flatListRef, setBrowsingHistory]); // 监听返回事件,刷新会话列表 useEffect(() => { const unsubscribe = navigation.addListener('beforeRemove', () => { // 刷新会话列表,确保已读状态正确显示 - messageManager.fetchConversations(true); + messageManager.requestConversationListRefresh('chat-before-remove', { + force: true, + allowDefer: false, + }); }); return unsubscribe; }, [navigation]); // 渲染消息气泡 - const renderMessage = ({ item, index }: { item: any; index: number }) => ( - - ); + const messageIndexMap = useMemo(() => { + const map = new Map(); + messages.forEach((msg, idx) => { + map.set(String(msg.id), idx); + }); + return map; + }, [messages]); + + const renderMessage = useCallback(({ item, index }: { item: any; index: number }) => { + // inverted 下 renderItem 的 index 与时间顺序不一致,需回查原始序索引 + const logicalIndex = messageIndexMap.get(String(item.id)) ?? index; + return ( + + ); + }, [ + messageIndexMap, + currentUserId, + currentUser, + otherUser, + isGroupChat, + groupMembers, + otherUserLastReadSeq, + selectedMessageId, + messageMap, + handleLongPressMessage, + handleAvatarPress, + handleAvatarLongPress, + formatTime, + shouldShowTime, + handleImagePress, + handleReplyMessage, + handleReplyPreviewPress, + ]); + + const keyExtractor = useCallback((item: any) => String(item.id), []); // 获取正在输入提示 const typingHint = getTypingHint(); - const handleMessageListScroll = useCallback((event: any) => { - const { contentSize, contentOffset } = event.nativeEvent; + const { contentSize, contentOffset, layoutMeasurement } = event.nativeEvent; scrollPositionRef.current = { contentHeight: contentSize.height, scrollY: contentOffset.y, + viewportHeight: layoutMeasurement.height, }; - }, [scrollPositionRef]); + + // inverted:offset 越大离最新消息端越远,与 setBrowsingHistory(120) 阈值一致 + const shouldShowJump = + contentOffset.y > 120 && !loading && messages.length > 0; + if (showJumpToLatestRef.current !== shouldShowJump) { + showJumpToLatestRef.current = shouldShowJump; + setShowJumpToLatest(shouldShowJump); + } + + // 离开最新消息端后进入“浏览历史”模式,屏蔽自动跟随到底 + if (contentOffset.y > 120) { + setBrowsingHistory(true); + } + + // 仅用户手势主动回到底部时才解除历史阅读锁(避免加载阶段误解锁) + const isScrollingTowardLatest = contentOffset.y < lastScrollYRef.current; + if ( + isUserDraggingRef.current && + !loadingMore && + isScrollingTowardLatest && + contentOffset.y <= 40 + ) { + handleReachLatestEdge(); + } + + // inverted 下历史端在“列表尾部”,对应 offset 增大且距离尾部变小 + const distanceToHistoryEdge = contentSize.height - (contentOffset.y + layoutMeasurement.height); + lastScrollYRef.current = contentOffset.y; + + // 预加载阈值:按屏幕高度动态提前(至少 420),确保边界前开始加载 + const PRELOAD_HISTORY_THRESHOLD = Math.max(420, layoutMeasurement.height * 1.2); + // 仅在真正接近边界时才显示旋转提示 + const EDGE_LOADING_INDICATOR_THRESHOLD = 60; + const shouldShowEdgeIndicator = loadingMore && distanceToHistoryEdge <= EDGE_LOADING_INDICATOR_THRESHOLD; + if (shouldShowEdgeIndicator !== showEdgeLoadingIndicator) { + setShowEdgeLoadingIndicator(shouldShowEdgeIndicator); + } + + if ( + distanceToHistoryEdge <= PRELOAD_HISTORY_THRESHOLD && + hasMoreHistory && + !loadingMore && + !loading && + !isPreloadingRef.current + ) { + isPreloadingRef.current = true; + loadMoreHistory().finally(() => { + if (preloadCooldownTimerRef.current) { + clearTimeout(preloadCooldownTimerRef.current); + } + preloadCooldownTimerRef.current = setTimeout(() => { + isPreloadingRef.current = false; + }, 350); + }); + } + }, [ + scrollPositionRef, + hasMoreHistory, + loadingMore, + loading, + messages.length, + loadMoreHistory, + handleReachLatestEdge, + showEdgeLoadingIndicator, + setBrowsingHistory, + ]); + + const handleContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => { + handleMessageListContentSizeChange(contentWidth, contentHeight); + }, [handleMessageListContentSizeChange]); return ( - - + + + {/* 顶部栏 */} { otherUser={otherUser} routeGroupName={routeGroupName} typingHint={typingHint} - onBack={() => navigation.goBack()} + onBack={() => router.back()} onTitlePress={navigateToInfo} onMorePress={navigateToChatSettings} onGroupInfoPress={handleGroupInfoPress} @@ -261,36 +426,103 @@ export const ChatScreen: React.FC = () => { /> {/* 消息列表 */} - - - {loading ? ( - - + { + scrollPositionRef.current.viewportHeight = e.nativeEvent.layout.height; + }} + onTouchEnd={handleDismiss} + > + {loading ? ( + + + + ) : ( + { + isUserDraggingRef.current = true; + handleDismiss(); + }} + onScrollEndDrag={() => { + isUserDraggingRef.current = false; + }} + onMomentumScrollEnd={() => { + isUserDraggingRef.current = false; + }} + scrollEventThrottle={16} + onContentSizeChange={handleContentSizeChange} + onScrollToIndexFailed={(info) => { + const targetId = replyTargetMessageIdRef.current; + if (!targetId || !flatListRef.current) return; + + flatListRef.current.scrollToOffset({ + offset: Math.max(0, info.averageItemLength * info.index), + animated: true, + }); + + setTimeout(() => { + const retryIndex = displayMessages.findIndex(msg => String(msg.id) === targetId); + if (retryIndex >= 0) { + flatListRef.current?.scrollToIndex({ + index: retryIndex, + animated: true, + viewPosition: 0.5, + }); + } + }, 120); + }} + /> + )} + {showEdgeLoadingIndicator && ( + + + - ) : ( - String(item.id)} - contentContainerStyle={listContentStyle} - showsVerticalScrollIndicator={false} - keyboardShouldPersistTaps="handled" - refreshing={loadingMore} - onRefresh={hasMoreHistory ? loadMoreHistory : undefined} - progressViewOffset={0} - onContentSizeChange={handleMessageListContentSizeChange} - onScroll={handleMessageListScroll} - scrollEventThrottle={16} - // 优化渲染性能 - initialNumToRender={15} - maxToRenderPerBatch={10} - windowSize={10} - removeClippedSubviews={true} - /> - )} - - + + )} + {showJumpToLatest && ( + + + 最新消息 + + )} + {/* 底部区域:输入框 + 面板 */} 0 ? keyboardHeight : 0 }}> @@ -306,12 +538,14 @@ export const ChatScreen: React.FC = () => { onToggleEmoji={toggleEmojiPanel} onToggleMore={toggleMorePanel} activePanel={activePanel} - sending={sending} + isComposerBusy={isComposerBusy} isMuted={isMuted} isGroupChat={isGroupChat} muteAll={muteAll} restrictionHint={followRestrictionHint} replyingTo={replyingTo} + pendingAttachments={pendingAttachments} + onRemovePendingAttachment={removePendingAttachment} onCancelReply={handleCancelReply} onFocus={() => { // 输入框获得焦点时,关闭其他面板(但不要关闭键盘) @@ -348,11 +582,13 @@ export const ChatScreen: React.FC = () => { {/* 发送图片加载遮罩 */} - {sendingImage && ( + {(sendingImage || uploadingAttachments) && ( - 发送图片中... + + {uploadingAttachments ? '正在上传图片…' : '发送图片中…'} + )} @@ -407,32 +643,33 @@ export const ChatScreen: React.FC = () => { /> {/* 群信息侧边栏面板 - 仅大屏幕显示 */} - { - // TODO: 实现置顶功能 - console.log('Toggle pin:', pinned); - }} - onLeaveGroup={() => { - // TODO: 实现退出群聊功能 - console.log('Leave group'); - }} - onInviteMembers={() => { - // TODO: 实现邀请新成员功能 - console.log('Invite members'); - }} - onViewAllMembers={() => { - // TODO: 实现查看全部成员功能 - console.log('View all members'); - }} - /> - + { + // TODO: 实现置顶功能 + console.log('Toggle pin:', pinned); + }} + onLeaveGroup={() => { + // TODO: 实现退出群聊功能 + console.log('Leave group'); + }} + onInviteMembers={() => { + // TODO: 实现邀请新成员功能 + console.log('Invite members'); + }} + onViewAllMembers={() => { + // TODO: 实现查看全部成员功能 + console.log('View all members'); + }} + /> + + ); }; diff --git a/src/screens/message/CreateGroupScreen.tsx b/src/screens/message/CreateGroupScreen.tsx index 2f35d03..c755ed3 100644 --- a/src/screens/message/CreateGroupScreen.tsx +++ b/src/screens/message/CreateGroupScreen.tsx @@ -4,7 +4,7 @@ * 支持响应式布局 */ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { View, StyleSheet, @@ -16,22 +16,20 @@ import { Image, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useNavigation } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useRouter } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import * as ImagePicker from 'expo-image-picker'; -import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme'; +import { spacing, fontSizes, borderRadius, shadows, useAppColors, type AppColors } from '../../theme'; import { groupService } from '../../services/groupService'; import { uploadService } from '../../services/uploadService'; import { Avatar, Text, Button, Loading } from '../../components/common'; import { User } from '../../types'; -import { RootStackParamList } from '../../navigation/types'; import MutualFollowSelectorModal from './components/MutualFollowSelectorModal'; -type NavigationProp = NativeStackNavigationProp; - const CreateGroupScreen: React.FC = () => { - const navigation = useNavigation(); + const colors = useAppColors(); + const styles = useMemo(() => createCreateGroupStyles(colors), [colors]); + const router = useRouter(); // 表单状态 const [groupName, setGroupName] = useState(''); @@ -138,7 +136,7 @@ const CreateGroupScreen: React.FC = () => { Alert.alert('成功', '群组创建成功', [ { text: '确定', - onPress: () => navigation.goBack(), + onPress: () => router.back(), }, ]); } catch (error: any) { @@ -314,171 +312,173 @@ const CreateGroupScreen: React.FC = () => { ); }; -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.background.default, - }, - scrollView: { - flex: 1, - }, - scrollContent: { - padding: spacing.lg, - paddingBottom: spacing.xl, - }, - // 头部区域样式 - headerSection: { - flexDirection: 'row', - alignItems: 'flex-start', - marginBottom: spacing.xl, - }, - avatarContainer: { - marginRight: spacing.lg, - }, - avatarWrapper: { - position: 'relative', - }, - avatarImage: { - width: 80, - height: 80, - borderRadius: 40, - }, - avatarPlaceholder: { - justifyContent: 'center', - alignItems: 'center', - backgroundColor: colors.background.default, - borderRadius: 40, - }, - avatarBadge: { - position: 'absolute', - bottom: 0, - right: 0, - backgroundColor: colors.primary.main, - width: 28, - height: 28, - borderRadius: 14, - justifyContent: 'center', - alignItems: 'center', - borderWidth: 2, - borderColor: colors.background.paper, - }, - nameInputContainer: { - flex: 1, - paddingTop: spacing.sm, - }, - inputLabel: { - marginBottom: spacing.sm, - fontWeight: '600', - }, - nameInput: { - backgroundColor: colors.background.paper, - borderRadius: borderRadius.lg, - paddingHorizontal: spacing.md, - paddingVertical: spacing.md, - fontSize: fontSizes.lg, - color: colors.text.primary, - borderWidth: 1, - borderColor: colors.divider, - }, - charCount: { - textAlign: 'right', - marginTop: spacing.xs, - }, - // 区域样式 - section: { - marginBottom: spacing.xl, - }, - sectionHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: spacing.sm, - }, - sectionTitle: { - fontWeight: '600', - marginBottom: spacing.sm, - }, - // 文本域样式 - textAreaContainer: { - backgroundColor: colors.background.paper, - borderRadius: borderRadius.lg, - borderWidth: 1, - borderColor: colors.divider, - padding: spacing.md, - }, - textArea: { - minHeight: 100, - fontSize: fontSizes.md, - color: colors.text.primary, - lineHeight: 22, - }, - textAreaCharCount: { - textAlign: 'right', - marginTop: spacing.sm, - }, - // 已选成员样式 - selectedMembersList: { - paddingVertical: spacing.sm, - }, - selectedMemberItem: { - alignItems: 'center', - marginRight: spacing.lg, - width: 64, - }, - removeMemberButton: { - position: 'absolute', - top: -4, - right: 4, - }, - removeIconContainer: { - backgroundColor: colors.text.secondary, - borderRadius: 10, - width: 20, - height: 20, - justifyContent: 'center', - alignItems: 'center', - borderWidth: 2, - borderColor: colors.background.paper, - }, - selectedMemberName: { - marginTop: spacing.xs, - textAlign: 'center', - width: '100%', - }, - // 邀请按钮样式 - inviteButton: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.background.paper, - borderRadius: borderRadius.lg, - padding: spacing.md, - marginBottom: spacing.xl, - ...shadows.sm, - }, - inviteIconContainer: { - width: 48, - height: 48, - borderRadius: borderRadius.lg, - backgroundColor: colors.primary.light + '20', - justifyContent: 'center', - alignItems: 'center', - marginRight: spacing.md, - }, - inviteTextContainer: { - flex: 1, - }, - inviteTitle: { - fontWeight: '600', - marginBottom: 2, - }, - // 底部按钮样式 - footer: { - padding: spacing.lg, - paddingBottom: spacing.xl, - backgroundColor: colors.background.paper, - borderTopWidth: 1, - borderTopColor: colors.divider, - }, -}); +function createCreateGroupStyles(colors: AppColors) { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.default, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + padding: spacing.lg, + paddingBottom: spacing.xl, + }, + // 头部区域样式 + headerSection: { + flexDirection: 'row', + alignItems: 'flex-start', + marginBottom: spacing.xl, + }, + avatarContainer: { + marginRight: spacing.lg, + }, + avatarWrapper: { + position: 'relative', + }, + avatarImage: { + width: 80, + height: 80, + borderRadius: 40, + }, + avatarPlaceholder: { + justifyContent: 'center', + alignItems: 'center', + backgroundColor: colors.background.default, + borderRadius: 40, + }, + avatarBadge: { + position: 'absolute', + bottom: 0, + right: 0, + backgroundColor: colors.primary.main, + width: 28, + height: 28, + borderRadius: 14, + justifyContent: 'center', + alignItems: 'center', + borderWidth: 2, + borderColor: colors.background.paper, + }, + nameInputContainer: { + flex: 1, + paddingTop: spacing.sm, + }, + inputLabel: { + marginBottom: spacing.sm, + fontWeight: '600', + }, + nameInput: { + backgroundColor: colors.background.paper, + borderRadius: borderRadius.lg, + paddingHorizontal: spacing.md, + paddingVertical: spacing.md, + fontSize: fontSizes.lg, + color: colors.text.primary, + borderWidth: 1, + borderColor: colors.divider, + }, + charCount: { + textAlign: 'right', + marginTop: spacing.xs, + }, + // 区域样式 + section: { + marginBottom: spacing.xl, + }, + sectionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: spacing.sm, + }, + sectionTitle: { + fontWeight: '600', + marginBottom: spacing.sm, + }, + // 文本域样式 + textAreaContainer: { + backgroundColor: colors.background.paper, + borderRadius: borderRadius.lg, + borderWidth: 1, + borderColor: colors.divider, + padding: spacing.md, + }, + textArea: { + minHeight: 100, + fontSize: fontSizes.md, + color: colors.text.primary, + lineHeight: 22, + }, + textAreaCharCount: { + textAlign: 'right', + marginTop: spacing.sm, + }, + // 已选成员样式 + selectedMembersList: { + paddingVertical: spacing.sm, + }, + selectedMemberItem: { + alignItems: 'center', + marginRight: spacing.lg, + width: 64, + }, + removeMemberButton: { + position: 'absolute', + top: -4, + right: 4, + }, + removeIconContainer: { + backgroundColor: colors.text.secondary, + borderRadius: 10, + width: 20, + height: 20, + justifyContent: 'center', + alignItems: 'center', + borderWidth: 2, + borderColor: colors.background.paper, + }, + selectedMemberName: { + marginTop: spacing.xs, + textAlign: 'center', + width: '100%', + }, + // 邀请按钮样式 + inviteButton: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.background.paper, + borderRadius: borderRadius.lg, + padding: spacing.md, + marginBottom: spacing.xl, + ...shadows.sm, + }, + inviteIconContainer: { + width: 48, + height: 48, + borderRadius: borderRadius.lg, + backgroundColor: colors.primary.light + '20', + justifyContent: 'center', + alignItems: 'center', + marginRight: spacing.md, + }, + inviteTextContainer: { + flex: 1, + }, + inviteTitle: { + fontWeight: '600', + marginBottom: 2, + }, + // 底部按钮样式 + footer: { + padding: spacing.lg, + paddingBottom: spacing.xl, + backgroundColor: colors.background.paper, + borderTopWidth: 1, + borderTopColor: colors.divider, + }, + }); +} export default CreateGroupScreen; diff --git a/src/screens/message/GroupInfoScreen.tsx b/src/screens/message/GroupInfoScreen.tsx index 812a411..206fff5 100644 --- a/src/screens/message/GroupInfoScreen.tsx +++ b/src/screens/message/GroupInfoScreen.tsx @@ -21,11 +21,18 @@ import { Dimensions, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useNavigation, useRoute, RouteProp, useFocusEffect } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useFocusEffect } from '@react-navigation/native'; +import { useLocalSearchParams, useRouter } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import * as ImagePicker from 'expo-image-picker'; -import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme'; +import { + spacing, + fontSizes, + borderRadius, + shadows, + useAppColors, + type AppColors, +} from '../../theme'; import { useAuthStore } from '../../stores'; import { groupService } from '../../services/groupService'; import { uploadService } from '../../services/uploadService'; @@ -39,16 +46,15 @@ import { JoinType, } from '../../types/dto'; import { User } from '../../types'; -import { RootStackParamList } from '../../navigation/types'; +import { firstRouteParam } from '../../navigation/paramUtils'; +import * as hrefs from '../../navigation/hrefs'; import { messageManager } from '../../stores/messageManager'; import { groupManager } from '../../stores/groupManager'; import MutualFollowSelectorModal from './components/MutualFollowSelectorModal'; +import { AppBackButton } from '../../components/common'; const { width: SCREEN_WIDTH } = Dimensions.get('window'); -type NavigationProp = NativeStackNavigationProp; -type GroupInfoRouteProp = RouteProp; - // 加群方式选项 const JOIN_TYPE_OPTIONS: { value: JoinType; label: string; icon: string; desc: string }[] = [ { value: 0, label: '允许任何人加入', icon: 'earth', desc: '任何人都可以直接加入群聊' }, @@ -57,9 +63,15 @@ const JOIN_TYPE_OPTIONS: { value: JoinType; label: string; icon: string; desc: s ]; const GroupInfoScreen: React.FC = () => { - const navigation = useNavigation(); - const route = useRoute(); - const { groupId, conversationId } = route.params; + const colors = useAppColors(); + const styles = useMemo(() => createGroupInfoStyles(colors), [colors]); + const router = useRouter(); + const { groupId: groupIdParam, conversationId: conversationIdParam } = useLocalSearchParams<{ + groupId?: string | string[]; + conversationId?: string | string[]; + }>(); + const groupId = firstRouteParam(groupIdParam) ?? ''; + const conversationId = firstRouteParam(conversationIdParam); const { currentUser } = useAuthStore(); // 响应式布局 @@ -112,6 +124,7 @@ const GroupInfoScreen: React.FC = () => { // 加载群组信息 const loadGroupInfo = useCallback(async () => { + if (!groupId) return; try { setLoading(true); @@ -138,7 +151,7 @@ const GroupInfoScreen: React.FC = () => { } catch (error) { console.error('加载群组信息失败:', error); Alert.alert('错误', '加载群组信息失败', [ - { text: '确定', onPress: () => navigation.goBack() }, + { text: '确定', onPress: () => router.back() }, ]); } finally { setLoading(false); @@ -394,7 +407,7 @@ const GroupInfoScreen: React.FC = () => { try { await groupService.dissolveGroup(groupId); Alert.alert('成功', '群组已解散', [ - { text: '确定', onPress: () => navigation.goBack() }, + { text: '确定', onPress: () => router.back() }, ]); } catch (error: any) { console.error('解散群组失败:', error); @@ -420,7 +433,7 @@ const GroupInfoScreen: React.FC = () => { try { await groupService.leaveGroup(groupId); Alert.alert('成功', '已退出群聊', [ - { text: '确定', onPress: () => navigation.goBack() }, + { text: '确定', onPress: () => router.back() }, ]); } catch (error: any) { console.error('退出群聊失败:', error); @@ -464,7 +477,7 @@ const GroupInfoScreen: React.FC = () => { // 跳转到成员管理 const goToMembers = () => { - navigation.navigate('GroupMembers' as any, { groupId }); + router.push(hrefs.hrefGroupMembers(groupId)); }; // 获取加群方式文本 @@ -485,8 +498,8 @@ const GroupInfoScreen: React.FC = () => { Alert.alert('已复制', '群号已复制到剪贴板'); }; - const formatGroupNo = (id: string | number) => { - const raw = String(id); + const formatGroupNo = (id: string) => { + const raw = id; if (raw.length <= 12) return raw; return `${raw.slice(0, 6)}...${raw.slice(-4)}`; }; @@ -534,7 +547,12 @@ const GroupInfoScreen: React.FC = () => { if (loading) { return ( - + + + router.back()} /> + 群聊信息 + + ); @@ -542,7 +560,12 @@ const GroupInfoScreen: React.FC = () => { if (!group) { return ( - + + + router.back()} /> + 群聊信息 + + 群组信息不存在 @@ -553,7 +576,12 @@ const GroupInfoScreen: React.FC = () => { } return ( - + + + router.back()} /> + 群聊信息 + + { ); }; -const styles = StyleSheet.create({ +function createGroupInfoStyles(colors: AppColors) { + return StyleSheet.create({ container: { flex: 1, backgroundColor: colors.background.default, }, + pageHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + backgroundColor: colors.background.paper, + borderBottomWidth: 1, + borderBottomColor: colors.divider, + }, + pageTitle: { + fontWeight: '600', + }, + pageHeaderPlaceholder: { + width: 40, + height: 40, + }, scrollView: { flex: 1, }, @@ -1558,6 +1604,7 @@ const styles = StyleSheet.create({ backgroundColor: colors.primary.main, borderColor: colors.primary.main, }, -}); + }); +} export default GroupInfoScreen; diff --git a/src/screens/message/GroupInviteDetailScreen.tsx b/src/screens/message/GroupInviteDetailScreen.tsx index cb7788f..c0fffbd 100644 --- a/src/screens/message/GroupInviteDetailScreen.tsx +++ b/src/screens/message/GroupInviteDetailScreen.tsx @@ -1,25 +1,38 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { View, StyleSheet, ActivityIndicator, Alert, ScrollView } from 'react-native'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { View, StyleSheet, ActivityIndicator, Alert, ScrollView, TouchableOpacity } from 'react-native'; +import { useRouter, useLocalSearchParams } from 'expo-router'; import { SafeAreaView } from 'react-native-safe-area-context'; import { MaterialCommunityIcons } from '@expo/vector-icons'; - -import { colors, spacing, borderRadius, shadows } from '../../theme'; +import { spacing, borderRadius, shadows, useAppColors, type AppColors } from '../../theme'; import { Avatar, Text } from '../../components/common'; -import { RootStackParamList } from '../../navigation/types'; +import { routePayloadCache } from '../../stores/routePayloadCache'; import { groupService } from '../../services/groupService'; import { groupManager } from '../../stores/groupManager'; import { GroupMemberResponse } from '../../types/dto'; import { GroupInfoSummaryCard, DecisionFooter } from './components/GroupRequestShared'; -type Route = RouteProp; -type Navigation = NativeStackNavigationProp; - const GroupInviteDetailScreen: React.FC = () => { - const route = useRoute(); - const navigation = useNavigation(); - const { message } = route.params; + const colors = useAppColors(); + const styles = useMemo(() => createGroupInviteDetailStyles(colors), [colors]); + const router = useRouter(); + const { messageId } = useLocalSearchParams<{ messageId?: string }>(); + const message = messageId ? routePayloadCache.getSystemMessage(messageId) : undefined; + + if (!message) { + return ( + + + + 无法加载该消息,请从通知列表重新打开。 + + router.back()} style={styles.backBtn}> + 返回 + + + + ); + } + const { extra_data } = message; const [loadingMembers, setLoadingMembers] = useState(false); @@ -35,7 +48,7 @@ const GroupInviteDetailScreen: React.FC = () => { if (!extra_data?.group_id) return; setLoadingGroup(true); try { - const group = await groupManager.getGroup(extra_data.group_id); + const group = await groupManager.getGroup(String(extra_data.group_id)); setMemberCount(group.member_count ?? null); } catch { setMemberCount(null); @@ -51,7 +64,7 @@ const GroupInviteDetailScreen: React.FC = () => { if (!extra_data?.group_id) return; setLoadingMembers(true); try { - const res = await groupManager.getMembers(extra_data.group_id, 1, 100); + const res = await groupManager.getMembers(String(extra_data.group_id), 1, 100); const admins = (res.list || []).filter(m => m.role === 'owner' || m.role === 'admin'); setMembers(admins); } catch { @@ -76,9 +89,9 @@ const GroupInviteDetailScreen: React.FC = () => { } setSubmitting(true); try { - await groupService.respondInvite(groupId, { flag, approve }); + await groupService.respondInvite(String(groupId), { flag, approve }); Alert.alert('成功', approve ? '已同意加入群聊' : '已拒绝邀请', [ - { text: '确定', onPress: () => navigation.goBack() }, + { text: '确定', onPress: () => router.back() }, ]); } catch { Alert.alert('操作失败', '请稍后重试'); @@ -87,7 +100,7 @@ const GroupInviteDetailScreen: React.FC = () => { } }; - const groupNo = useMemo(() => extra_data?.group_id || '-', [extra_data?.group_id]); + const groupNo = useMemo(() => String(extra_data?.group_id ?? '-'), [extra_data?.group_id]); const formatGroupNo = (id: string) => { if (id.length <= 12) return id; return `${id.slice(0, 6)}...${id.slice(-4)}`; @@ -163,74 +176,86 @@ const GroupInviteDetailScreen: React.FC = () => { ); }; -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.background.default, - }, - scrollView: { - flex: 1, - }, - scrollContent: { - padding: spacing.lg, - paddingBottom: spacing.xl, - }, - card: { - backgroundColor: colors.background.paper, - borderRadius: borderRadius.lg, - padding: spacing.lg, - ...shadows.sm, - }, - cardHeader: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: spacing.md, - }, - cardIconContainer: { - width: 30, - height: 30, - borderRadius: 15, - backgroundColor: colors.info.light + '30', - alignItems: 'center', - justifyContent: 'center', - marginRight: spacing.sm, - }, - cardTitle: { - fontWeight: '600', - flex: 1, - }, - memberCount: { - marginRight: spacing.xs, - }, - loadingWrap: { - paddingVertical: spacing.md, - alignItems: 'center', - }, - memberPreview: { - flexDirection: 'row', - alignItems: 'center', - flexWrap: 'wrap', - }, - memberAvatar: { - marginLeft: -8, - marginBottom: spacing.sm, - }, - memberAvatarFirst: { - marginLeft: 0, - }, - ownerBadge: { - position: 'absolute', - bottom: -2, - right: -2, - backgroundColor: colors.warning.main, - borderRadius: 8, - paddingHorizontal: 4, - paddingVertical: 1, - }, - ownerBadgeText: { - fontSize: 10, - fontWeight: '700', - }, -}); +function createGroupInviteDetailStyles(colors: AppColors) { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.default, + }, + emptyWrap: { + flex: 1, + padding: spacing.lg, + justifyContent: 'center', + alignItems: 'center', + gap: spacing.md, + }, + backBtn: { + padding: spacing.sm, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + padding: spacing.lg, + paddingBottom: spacing.xl, + }, + card: { + backgroundColor: colors.background.paper, + borderRadius: borderRadius.lg, + padding: spacing.lg, + ...shadows.sm, + }, + cardHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: spacing.md, + }, + cardIconContainer: { + width: 30, + height: 30, + borderRadius: 15, + backgroundColor: colors.info.light + '30', + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.sm, + }, + cardTitle: { + fontWeight: '600', + flex: 1, + }, + memberCount: { + marginRight: spacing.xs, + }, + loadingWrap: { + paddingVertical: spacing.md, + alignItems: 'center', + }, + memberPreview: { + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + }, + memberAvatar: { + marginLeft: -8, + marginBottom: spacing.sm, + }, + memberAvatarFirst: { + marginLeft: 0, + }, + ownerBadge: { + position: 'absolute', + bottom: -2, + right: -2, + backgroundColor: colors.warning.main, + borderRadius: 8, + paddingHorizontal: 4, + paddingVertical: 1, + }, + ownerBadgeText: { + fontSize: 10, + fontWeight: '700', + }, + }); +} export default GroupInviteDetailScreen; diff --git a/src/screens/message/GroupMembersScreen.tsx b/src/screens/message/GroupMembersScreen.tsx index 3612f99..a2a9624 100644 --- a/src/screens/message/GroupMembersScreen.tsx +++ b/src/screens/message/GroupMembersScreen.tsx @@ -2,6 +2,7 @@ * GroupMembersScreen 群成员管理界面 * 显示群成员列表,支持管理员管理成员 * 支持响应式网格布局 + * 使用游标分页 */ import React, { useState, useEffect, useCallback, useMemo } from 'react'; @@ -18,22 +19,27 @@ import { Dimensions, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useLocalSearchParams } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { colors, spacing, fontSizes, borderRadius } from '../../theme'; +import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme'; import { useAuthStore } from '../../stores'; import { groupService } from '../../services/groupService'; -import { groupManager } from '../../stores/groupManager'; +import { + createRemoteGroupMemberListSource, + GroupMemberListSourceKind, + IGroupMemberListPagedSource, +} from '../../stores/groupMemberListSources'; +import { enrichGroupMembersWithUserProfiles } from '../../stores/groupMemberProfileResolver'; import { Avatar, Text, Button, Loading, EmptyState, Divider, ResponsiveContainer } from '../../components/common'; import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive'; +import { useCursorPagination } from '../../hooks/useCursorPagination'; import { GroupMemberResponse, GroupRole, } from '../../types/dto'; -import { RootStackParamList } from '../../navigation/types'; - +import { firstRouteParam } from '../../navigation/paramUtils'; const { width: SCREEN_WIDTH } = Dimensions.get('window'); +const GROUP_MEMBER_REMOTE_LIST_KIND: GroupMemberListSourceKind = 'cursor'; // 网格布局配置 const GRID_CONFIG = { @@ -42,9 +48,6 @@ const GRID_CONFIG = { desktop: { columns: 3, itemWidth: '31%' }, }; -type NavigationProp = NativeStackNavigationProp; -type GroupMembersRouteProp = RouteProp; - // 成员分组 interface MemberGroup { title: string; @@ -52,9 +55,10 @@ interface MemberGroup { } const GroupMembersScreen: React.FC = () => { - const navigation = useNavigation(); - const route = useRoute(); - const { groupId } = route.params; + const colors = useAppColors(); + const styles = useMemo(() => createGroupMembersStyles(colors), [colors]); + const { groupId: groupIdParam } = useLocalSearchParams<{ groupId?: string | string[] }>(); + const groupId = firstRouteParam(groupIdParam) ?? ''; const { currentUser } = useAuthStore(); // 响应式布局 @@ -68,12 +72,51 @@ const GroupMembersScreen: React.FC = () => { return GRID_CONFIG.mobile; }, [width]); - // 成员列表状态 - const [members, setMembers] = useState([]); + const memberListSource = useMemo(() => { + return createRemoteGroupMemberListSource(GROUP_MEMBER_REMOTE_LIST_KIND, groupId, 50); + }, [groupId]); + + // 使用统一数据源 + 游标 Hook 管理成员列表 + const { + list: members, + isLoading, + isRefreshing, + hasMore, + loadMore, + refresh, + error, + } = useCursorPagination( + async ({ cursor }) => { + // cursor 为空表示第一页/刷新,此时重置数据源状态 + if (!cursor) { + memberListSource.restart(); + } + return await memberListSource.loadNext(); + }, + { pageSize: 50 } + ); + + // 本地成员状态(用于乐观更新) + const [localMembers, setLocalMembers] = useState([]); const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); - const [page, setPage] = useState(1); - const [hasMore, setHasMore] = useState(true); + + // 同步分页数据到本地状态 + useEffect(() => { + let active = true; + + const syncMembers = async () => { + const enrichedMembers = await enrichGroupMembersWithUserProfiles(members); + if (!active) return; + setLocalMembers(enrichedMembers); + setLoading(false); + }; + + syncMembers(); + + return () => { + active = false; + }; + }, [members]); // 当前用户的成员信息 const [currentMember, setCurrentMember] = useState(null); @@ -91,65 +134,32 @@ const GroupMembersScreen: React.FC = () => { const isOwner = currentMember?.role === 'owner'; const isAdmin = currentMember?.role === 'admin' || isOwner; - // 加载成员列表 - const loadMembers = useCallback( - async ( - pageNum: number = 1, - refresh: boolean = false, - forceRefresh: boolean = false - ) => { - if (!hasMore && !refresh) return; - - try { - const response = await groupManager.getMembers(groupId, pageNum, 50, forceRefresh); - - if (refresh) { - setMembers(response.list); - setPage(1); - } else { - setMembers(prev => [...prev, ...response.list]); - } - - setHasMore(response.list.length === 50); - - // 查找当前用户的成员信息 - const myMember = response.list.find(m => m.user_id === currentUser?.id); - if (myMember) { - setCurrentMember(myMember); - } - } catch (error) { - console.error('加载成员列表失败:', error); + // 查找当前用户的成员信息 + useEffect(() => { + const myMember = localMembers.find(m => m.user_id === currentUser?.id); + if (myMember) { + setCurrentMember(myMember); } + }, [localMembers, currentUser]); + + // 下拉刷新 + const onRefresh = useCallback(async () => { + setLoading(true); + await refresh(); setLoading(false); - setRefreshing(false); - }, [groupId, currentUser, hasMore]); + }, [refresh]); // 初始加载 useEffect(() => { - loadMembers(1, true, true); - }, [groupId]); - - // 下拉刷新 - const onRefresh = useCallback(() => { - setRefreshing(true); - setHasMore(true); - loadMembers(1, true, true); - }, [loadMembers]); - - // 加载更多 - const loadMore = useCallback(() => { - if (!loading && hasMore) { - const nextPage = page + 1; - setPage(nextPage); - loadMembers(nextPage); - } - }, [loading, hasMore, page, loadMembers]); + if (!groupId) return; + refresh(); + }, [groupId, refresh]); // 按角色分组 const groupMembers = useCallback((): MemberGroup[] => { - const owners = members.filter(m => m.role === 'owner'); - const admins = members.filter(m => m.role === 'admin'); - const normalMembers = members.filter(m => m.role === 'member'); + const owners = localMembers.filter(m => m.role === 'owner'); + const admins = localMembers.filter(m => m.role === 'admin'); + const normalMembers = localMembers.filter(m => m.role === 'member'); const groups: MemberGroup[] = []; @@ -164,7 +174,7 @@ const GroupMembersScreen: React.FC = () => { } return groups; - }, [members]); + }, [localMembers]); // 打开操作菜单 const openActionModal = (member: GroupMemberResponse) => { @@ -203,7 +213,7 @@ const GroupMembersScreen: React.FC = () => { }); // 更新本地数据 - setMembers(prev => prev.map(m => { + setLocalMembers(prev => prev.map(m => { if (m.user_id === selectedMember.user_id) { return { ...m, role: newRole }; } @@ -245,14 +255,14 @@ const GroupMembersScreen: React.FC = () => { await groupService.muteMember(groupId, selectedMember.user_id, newMuted ? -1 : 0); // 更新本地数据 - setMembers(prev => prev.map(m => { + setLocalMembers(prev => prev.map(m => { if (m.user_id === selectedMember.user_id) { return { ...m, muted: newMuted }; } return m; })); // 强制刷新远端状态,避免命中旧缓存导致解禁后仍显示禁言 - await loadMembers(1, true, true); + await refresh(); setActionModalVisible(false); Alert.alert('成功', `已${actionText}`); @@ -286,7 +296,7 @@ const GroupMembersScreen: React.FC = () => { await groupService.removeMember(groupId, selectedMember.user_id); // 更新本地数据 - setMembers(prev => prev.filter(m => m.user_id !== selectedMember.user_id)); + setLocalMembers(prev => prev.filter(m => m.user_id !== selectedMember.user_id)); setActionModalVisible(false); Alert.alert('成功', '已移除成员'); @@ -320,7 +330,7 @@ const GroupMembersScreen: React.FC = () => { }); // 更新本地数据 - setMembers(prev => prev.map(m => { + setLocalMembers(prev => prev.map(m => { if (m.user_id === selectedMember.user_id) { return { ...m, nickname: newNickname.trim() }; } @@ -422,7 +432,7 @@ const GroupMembersScreen: React.FC = () => { // 渲染空状态 const renderEmpty = () => { - if (loading) return ; + if (loading || isLoading) return ; return ( { keyExtractor={(item) => item.title} refreshControl={ { onEndReached={loadMore} onEndReachedThreshold={0.3} showsVerticalScrollIndicator={false} + ListFooterComponent={ + isLoading ? ( + + + + ) : hasMore ? ( + + + 加载更多成员 + + + ) : localMembers.length > 0 ? ( + + 没有更多成员了 + + ) : null + } renderItem={({ item: group }) => ( {renderSectionHeader(group.title, group.data.length)} @@ -621,109 +648,124 @@ const GroupMembersScreen: React.FC = () => { ); }; -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.background.default, - }, - section: { - marginBottom: spacing.md, - }, - sectionHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - backgroundColor: colors.background.default, - }, - memberItem: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: spacing.md, - paddingVertical: spacing.md, - backgroundColor: colors.background.paper, - borderBottomWidth: 1, - borderBottomColor: colors.divider, - }, - memberInfo: { - flex: 1, - marginLeft: spacing.md, - }, - memberNameRow: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 2, - }, - memberName: { - fontWeight: '500', - }, - roleBadge: { - paddingHorizontal: spacing.xs, - paddingVertical: 2, - borderRadius: borderRadius.sm, - marginLeft: spacing.sm, - }, - mutedBadge: { - flexDirection: 'row', - alignItems: 'center', - marginTop: 2, - }, - // 模态框样式 - modalOverlay: { - flex: 1, - backgroundColor: 'rgba(0, 0, 0, 0.5)', - justifyContent: 'flex-end', - }, - modalContent: { - backgroundColor: colors.background.paper, - borderTopLeftRadius: borderRadius.lg, - borderTopRightRadius: borderRadius.lg, - padding: spacing.lg, - maxHeight: '80%', - }, - modalHeader: { - alignItems: 'center', - marginBottom: spacing.md, - }, - modalTitle: { - fontWeight: '700', - marginTop: spacing.sm, - marginBottom: spacing.xs, - }, - actionItem: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: spacing.md, - borderBottomWidth: 1, - borderBottomColor: colors.divider, - }, - actionText: { - marginLeft: spacing.md, - }, - inputLabel: { - marginBottom: spacing.xs, - }, - input: { - backgroundColor: colors.background.default, - borderRadius: borderRadius.md, - paddingHorizontal: spacing.md, - paddingVertical: spacing.md, - fontSize: fontSizes.md, - color: colors.text.primary, - borderWidth: 1, - borderColor: colors.divider, - marginBottom: spacing.md, - }, - modalButtons: { - flexDirection: 'row', - justifyContent: 'space-between', - marginTop: spacing.md, - }, - modalButton: { - flex: 1, - marginHorizontal: spacing.xs, - }, -}); +function createGroupMembersStyles(colors: AppColors) { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.default, + }, + section: { + marginBottom: spacing.md, + }, + sectionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + backgroundColor: colors.background.default, + }, + memberItem: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingVertical: spacing.md, + backgroundColor: colors.background.paper, + borderBottomWidth: 1, + borderBottomColor: colors.divider, + }, + memberInfo: { + flex: 1, + marginLeft: spacing.md, + }, + memberNameRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 2, + }, + memberName: { + fontWeight: '500', + }, + roleBadge: { + paddingHorizontal: spacing.xs, + paddingVertical: 2, + borderRadius: borderRadius.sm, + marginLeft: spacing.sm, + }, + mutedBadge: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 2, + }, + // 模态框样式 + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + justifyContent: 'flex-end', + }, + modalContent: { + backgroundColor: colors.background.paper, + borderTopLeftRadius: borderRadius.lg, + borderTopRightRadius: borderRadius.lg, + padding: spacing.lg, + maxHeight: '80%', + }, + modalHeader: { + alignItems: 'center', + marginBottom: spacing.md, + }, + modalTitle: { + fontWeight: '700', + marginTop: spacing.sm, + marginBottom: spacing.xs, + }, + actionItem: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: spacing.md, + borderBottomWidth: 1, + borderBottomColor: colors.divider, + }, + actionText: { + marginLeft: spacing.md, + }, + inputLabel: { + marginBottom: spacing.xs, + }, + input: { + backgroundColor: colors.background.default, + borderRadius: borderRadius.md, + paddingHorizontal: spacing.md, + paddingVertical: spacing.md, + fontSize: fontSizes.md, + color: colors.text.primary, + borderWidth: 1, + borderColor: colors.divider, + marginBottom: spacing.md, + }, + modalButtons: { + flexDirection: 'row', + justifyContent: 'space-between', + marginTop: spacing.md, + }, + modalButton: { + flex: 1, + marginHorizontal: spacing.xs, + }, + // 分页加载样式 + loadingFooter: { + paddingVertical: spacing.md, + alignItems: 'center', + }, + loadMoreBtn: { + paddingVertical: spacing.md, + alignItems: 'center', + }, + noMoreText: { + textAlign: 'center', + paddingVertical: spacing.md, + }, + }); +} export default GroupMembersScreen; diff --git a/src/screens/message/GroupRequestDetailScreen.tsx b/src/screens/message/GroupRequestDetailScreen.tsx index 833be5a..5764b49 100644 --- a/src/screens/message/GroupRequestDetailScreen.tsx +++ b/src/screens/message/GroupRequestDetailScreen.tsx @@ -1,25 +1,40 @@ import React, { useEffect, useMemo, useState } from 'react'; import { View, StyleSheet, TouchableOpacity, Alert, ScrollView } from 'react-native'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useRouter, useLocalSearchParams } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { colors, spacing, borderRadius, shadows } from '../../theme'; +import { spacing, borderRadius, shadows, useAppColors, type AppColors } from '../../theme'; import { Avatar, Text } from '../../components/common'; -import { RootStackParamList } from '../../navigation/types'; +import * as hrefs from '../../navigation/hrefs'; +import { routePayloadCache } from '../../stores/routePayloadCache'; import { groupService } from '../../services/groupService'; import { groupManager } from '../../stores/groupManager'; import { userManager } from '../../stores/userManager'; import { GroupInfoSummaryCard, DecisionFooter } from './components/GroupRequestShared'; -type Route = RouteProp; -type Navigation = NativeStackNavigationProp; - const GroupRequestDetailScreen: React.FC = () => { - const route = useRoute(); - const navigation = useNavigation(); - const { message } = route.params; + const colors = useAppColors(); + const styles = useMemo(() => createGroupRequestDetailStyles(colors), [colors]); + const router = useRouter(); + const { messageId } = useLocalSearchParams<{ messageId?: string }>(); + const message = messageId ? routePayloadCache.getSystemMessage(messageId) : undefined; + + if (!message) { + return ( + + + + 无法加载该消息,请从通知列表重新打开。 + + router.back()} style={styles.backBtn}> + 返回 + + + + ); + } + const { extra_data } = message; const [submitting, setSubmitting] = useState(false); const [memberCount, setMemberCount] = useState(null); @@ -64,7 +79,7 @@ const GroupRequestDetailScreen: React.FC = () => { if (!extra_data?.group_id) return; setLoadingGroup(true); try { - const group = await groupManager.getGroup(extra_data.group_id); + const group = await groupManager.getGroup(String(extra_data.group_id)); setMemberCount(group.member_count ?? null); } catch { setMemberCount(null); @@ -90,18 +105,18 @@ const GroupRequestDetailScreen: React.FC = () => { setSubmitting(true); try { if (message.system_type === 'group_invite') { - await groupService.respondInvite(groupId, { + await groupService.respondInvite(String(groupId), { flag, approve, }); } else { - await groupService.reviewJoinRequest(groupId, { + await groupService.reviewJoinRequest(String(groupId), { flag, approve, }); } Alert.alert('成功', approve ? '已同意申请' : '已拒绝申请', [ - { text: '确定', onPress: () => navigation.goBack() }, + { text: '确定', onPress: () => router.back() }, ]); } catch (error) { Alert.alert('操作失败', '请稍后重试'); @@ -114,7 +129,7 @@ const GroupRequestDetailScreen: React.FC = () => { if (!applicantId) return; const user = await userManager.getUserById(applicantId); if (user?.id) { - navigation.navigate('UserProfile', { userId: String(user.id) }); + router.push(hrefs.hrefUserProfile(String(user.id))); } }; @@ -124,7 +139,7 @@ const GroupRequestDetailScreen: React.FC = () => { @@ -161,42 +176,54 @@ const GroupRequestDetailScreen: React.FC = () => { ); }; -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.background.default, - }, - scrollView: { - flex: 1, - }, - scrollContent: { - padding: spacing.lg, - paddingBottom: spacing.xl, - }, - card: { - backgroundColor: colors.background.paper, - borderRadius: borderRadius.lg, - padding: spacing.lg, - marginBottom: spacing.md, - ...shadows.sm, - }, - sectionTitle: { - marginBottom: spacing.sm, - }, - row: { - flexDirection: 'row', - alignItems: 'center', - }, - meta: { - marginLeft: spacing.md, - flex: 1, - }, - name: { - marginBottom: 2, - }, - subDesc: { - marginTop: spacing.sm, - }, -}); +function createGroupRequestDetailStyles(colors: AppColors) { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.default, + }, + emptyWrap: { + flex: 1, + padding: spacing.lg, + justifyContent: 'center', + alignItems: 'center', + gap: spacing.md, + }, + backBtn: { + padding: spacing.sm, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + padding: spacing.lg, + paddingBottom: spacing.xl, + }, + card: { + backgroundColor: colors.background.paper, + borderRadius: borderRadius.lg, + padding: spacing.lg, + marginBottom: spacing.md, + ...shadows.sm, + }, + sectionTitle: { + marginBottom: spacing.sm, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + }, + meta: { + marginLeft: spacing.md, + flex: 1, + }, + name: { + marginBottom: 2, + }, + subDesc: { + marginTop: spacing.sm, + }, + }); +} export default GroupRequestDetailScreen; diff --git a/src/screens/message/JoinGroupScreen.tsx b/src/screens/message/JoinGroupScreen.tsx index 378b1f1..be4d3b1 100644 --- a/src/screens/message/JoinGroupScreen.tsx +++ b/src/screens/message/JoinGroupScreen.tsx @@ -1,27 +1,207 @@ -import React, { useState } from 'react'; -import { View, StyleSheet, TextInput, TouchableOpacity, Alert, ActivityIndicator, Clipboard } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import React, { useState, useCallback, useMemo } from 'react'; +import { + View, + StyleSheet, + TextInput, + TouchableOpacity, + Alert, + ActivityIndicator, + Clipboard, + FlatList, + RefreshControl, +} from 'react-native'; +import { useRouter } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { colors, spacing, borderRadius } from '../../theme'; +import { spacing, borderRadius, useAppColors, type AppColors } from '../../theme'; import Avatar from '../../components/common/Avatar'; import Text from '../../components/common/Text'; import { groupService } from '../../services/groupService'; import { groupManager } from '../../stores/groupManager'; -import { RootStackParamList } from '../../navigation/types'; import { GroupResponse, JoinType } from '../../types/dto'; +import { useCursorPagination } from '../../hooks/useCursorPagination'; +import { EmptyState } from '../../components/common'; -type NavigationProp = NativeStackNavigationProp; +function createJoinGroupStyles(colors: AppColors) { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.default, + padding: spacing.lg, + }, + heroCard: { + backgroundColor: colors.background.paper, + borderRadius: borderRadius.lg, + padding: spacing.lg, + marginBottom: spacing.lg, + }, + heroIconWrap: { + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: colors.primary.light + '26', + alignItems: 'center', + justifyContent: 'center', + marginBottom: spacing.md, + }, + heroTitle: { + marginBottom: spacing.xs, + }, + tip: { + lineHeight: 20, + }, + formCard: { + backgroundColor: colors.background.paper, + borderRadius: borderRadius.lg, + padding: spacing.lg, + flex: 1, + }, + label: { + marginBottom: spacing.xs, + }, + input: { + flex: 1, + borderWidth: 1, + borderColor: colors.divider, + borderRadius: borderRadius.md, + backgroundColor: colors.background.paper, + paddingHorizontal: spacing.md, + paddingVertical: spacing.md, + color: colors.text.primary, + }, + searchRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: spacing.md, + }, + searchBtn: { + width: 46, + height: 46, + borderRadius: borderRadius.md, + backgroundColor: colors.primary.main, + alignItems: 'center', + justifyContent: 'center', + marginLeft: spacing.sm, + }, + searchResultSection: { + marginBottom: spacing.lg, + }, + sectionTitle: { + marginBottom: spacing.sm, + fontWeight: '600', + }, + listSection: { + flex: 1, + }, + groupCard: { + borderWidth: 1, + borderColor: colors.divider, + borderRadius: borderRadius.md, + padding: spacing.md, + backgroundColor: colors.background.default, + marginBottom: spacing.md, + }, + groupHeader: { + flexDirection: 'row', + alignItems: 'center', + }, + groupMeta: { + marginLeft: spacing.md, + flex: 1, + }, + groupName: { + marginBottom: spacing.xs, + fontWeight: '600', + }, + groupDesc: { + marginTop: spacing.sm, + lineHeight: 18, + }, + groupInfoRow: { + marginTop: spacing.sm, + marginBottom: spacing.xs, + flexDirection: 'row', + justifyContent: 'space-between', + }, + groupNoRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: spacing.md, + }, + copyBtn: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.sm, + paddingVertical: 4, + borderRadius: borderRadius.sm, + backgroundColor: colors.primary.light + '22', + }, + copyBtnText: { + marginLeft: 4, + }, + submitBtn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + borderRadius: borderRadius.md, + backgroundColor: colors.primary.main, + minHeight: 46, + }, + submitBtnDisabled: { + opacity: 0.5, + }, + submitText: { + marginLeft: spacing.xs, + }, + emptyText: { + marginTop: spacing.sm, + textAlign: 'center', + }, + loadingFooter: { + paddingVertical: spacing.md, + alignItems: 'center', + }, + loadMoreBtn: { + paddingVertical: spacing.md, + alignItems: 'center', + }, + noMoreText: { + textAlign: 'center', + paddingVertical: spacing.md, + }, + }); +} const JoinGroupScreen: React.FC = () => { - const navigation = useNavigation(); + const colors = useAppColors(); + const styles = useMemo(() => createJoinGroupStyles(colors), [colors]); + const router = useRouter(); const [keyword, setKeyword] = useState(''); const [searching, setSearching] = useState(false); - const [joining, setJoining] = useState(false); - const [group, setGroup] = useState(null); + const [joiningGroupId, setJoiningGroupId] = useState(null); + const [searchedGroup, setSearchedGroup] = useState(null); const [searched, setSearched] = useState(false); + // 使用游标分页 Hook 管理群组列表 + const { + list: groups, + isLoading, + isRefreshing, + hasMore, + loadMore, + refresh, + error, + } = useCursorPagination( + async ({ cursor, pageSize }) => { + return await groupService.getGroupsCursor({ + cursor, + page_size: pageSize, + }); + }, + { pageSize: 20 } + ); + const getJoinTypeText = (joinType: JoinType) => { if (joinType === 0) return '允许加入'; if (joinType === 1) return '需要审批'; @@ -39,9 +219,9 @@ const JoinGroupScreen: React.FC = () => { setSearched(true); try { const result = await groupManager.getGroup(trimmed, true); - setGroup(result); + setSearchedGroup(result); } catch (error: any) { - setGroup(null); + setSearchedGroup(null); const message = error?.response?.data?.message || error?.message || ''; if (String(message).includes('不存在') || error?.response?.status === 404) { Alert.alert('未找到', '未搜索到该群聊,请确认群ID是否正确'); @@ -53,15 +233,15 @@ const JoinGroupScreen: React.FC = () => { } }; - const handleJoin = async () => { + const handleJoin = async (group: GroupResponse) => { if (!group?.id) return; - setJoining(true); + setJoiningGroupId(String(group.id)); try { await groupService.joinGroup(group.id); Alert.alert('成功', '操作已提交', [ { text: '确定', - onPress: () => navigation.goBack(), + onPress: () => router.back(), }, ]); } catch (error: any) { @@ -71,22 +251,114 @@ const JoinGroupScreen: React.FC = () => { '操作失败,请稍后重试'; Alert.alert('操作失败', String(message)); } finally { - setJoining(false); + setJoiningGroupId(null); } }; - const handleCopyGroupId = () => { - if (!group?.id) return; - Clipboard.setString(String(group.id)); + const handleCopyGroupId = (groupId: string) => { + Clipboard.setString(groupId); Alert.alert('已复制', '群号已复制到剪贴板'); }; - const formatGroupNo = (id: string | number) => { - const raw = String(id); + const formatGroupNo = (id: string) => { + const raw = id; if (raw.length <= 12) return raw; return `${raw.slice(0, 6)}...${raw.slice(-4)}`; }; + const renderGroupItem = ({ item: group }: { item: GroupResponse }) => { + const isJoining = joiningGroupId === String(group.id); + + return ( + + + + + + {group.name} + + + + {!!group.description && ( + + {group.description} + + )} + + + 成员 {group.member_count}/{group.max_members} + + + {getJoinTypeText(group.join_type)} + + + + + 群号:{formatGroupNo(group.id)} + + handleCopyGroupId(group.id)}> + + + 复制 + + + + handleJoin(group)} + disabled={isJoining} + > + {isJoining ? ( + + ) : ( + <> + + + 申请入群 + + + )} + + + ); + }; + + const renderEmptyList = () => { + if (isLoading) return null; + return ( + + ); + }; + + const renderSearchResult = () => { + if (!searched) return null; + + if (searchedGroup) { + return ( + + + 搜索结果 + + {renderGroupItem({ item: searchedGroup })} + + ); + } + + if (!searching) { + return ( + + 暂无搜索结果,请检查群ID后重试 + + ); + } + + return null; + }; + return ( @@ -100,23 +372,25 @@ const JoinGroupScreen: React.FC = () => { - 搜索群聊(群ID) + + 搜索群聊(群ID) + {searching ? ( @@ -126,184 +400,52 @@ const JoinGroupScreen: React.FC = () => { - {group && ( - - - - - {group.name} - - - {!!group.description && ( - - {group.description} - - )} - - - 成员 {group.member_count}/{group.max_members} - - - {getJoinTypeText(group.join_type)} - - - - - 群号:{formatGroupNo(group.id)} - - - - 复制 - - - - {joining ? ( - - ) : ( - <> - - 申请入群 - - )} - - - )} + {/* 搜索结果 */} + {renderSearchResult()} - {searched && !group && !searching && ( - - 暂无搜索结果,请检查群ID后重试 + {/* 群组列表 */} + + + 推荐群组 - )} + String(item.id)} + refreshControl={ + + } + onEndReached={loadMore} + onEndReachedThreshold={0.3} + ListEmptyComponent={renderEmptyList} + ListFooterComponent={ + isLoading ? ( + + + + ) : hasMore ? ( + + + 加载更多 + + + ) : groups.length > 0 ? ( + + 没有更多群组了 + + ) : null + } + showsVerticalScrollIndicator={false} + /> + ); }; -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.background.default, - padding: spacing.lg, - }, - heroCard: { - backgroundColor: colors.background.paper, - borderRadius: borderRadius.lg, - padding: spacing.lg, - marginBottom: spacing.lg, - }, - heroIconWrap: { - width: 48, - height: 48, - borderRadius: 24, - backgroundColor: colors.primary.light + '26', - alignItems: 'center', - justifyContent: 'center', - marginBottom: spacing.md, - }, - heroTitle: { - marginBottom: spacing.xs, - }, - tip: { - lineHeight: 20, - }, - formCard: { - backgroundColor: colors.background.paper, - borderRadius: borderRadius.lg, - padding: spacing.lg, - }, - label: { - marginBottom: spacing.xs, - }, - input: { - flex: 1, - borderWidth: 1, - borderColor: colors.divider, - borderRadius: borderRadius.md, - backgroundColor: colors.background.paper, - paddingHorizontal: spacing.md, - paddingVertical: spacing.md, - color: colors.text.primary, - }, - searchRow: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: spacing.md, - }, - searchBtn: { - width: 46, - height: 46, - borderRadius: borderRadius.md, - backgroundColor: colors.primary.main, - alignItems: 'center', - justifyContent: 'center', - marginLeft: spacing.sm, - }, - groupCard: { - borderWidth: 1, - borderColor: colors.divider, - borderRadius: borderRadius.md, - padding: spacing.md, - backgroundColor: colors.background.default, - }, - groupHeader: { - flexDirection: 'row', - alignItems: 'center', - }, - groupMeta: { - marginLeft: spacing.md, - flex: 1, - }, - groupName: { - marginBottom: spacing.xs, - }, - groupDesc: { - marginTop: spacing.sm, - lineHeight: 18, - }, - groupInfoRow: { - marginTop: spacing.sm, - marginBottom: spacing.xs, - flexDirection: 'row', - justifyContent: 'space-between', - }, - groupNoRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - marginBottom: spacing.md, - }, - copyBtn: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: spacing.sm, - paddingVertical: 4, - borderRadius: borderRadius.sm, - backgroundColor: colors.primary.light + '22', - }, - copyBtnText: { - marginLeft: 4, - }, - submitBtn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - borderRadius: borderRadius.md, - backgroundColor: colors.primary.main, - minHeight: 46, - }, - submitBtnDisabled: { - opacity: 0.5, - }, - submitText: { - marginLeft: spacing.xs, - }, - emptyText: { - marginTop: spacing.sm, - }, -}); - export default JoinGroupScreen; diff --git a/src/screens/message/MessageListScreen.tsx b/src/screens/message/MessageListScreen.tsx index 217ca1d..958942f 100644 --- a/src/screens/message/MessageListScreen.tsx +++ b/src/screens/message/MessageListScreen.tsx @@ -22,29 +22,44 @@ import { Animated, TextInput, Keyboard, + BackHandler, + Platform, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useNavigation, useIsFocused } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useRouter } from 'expo-router'; +import { useIsFocused } from '@react-navigation/native'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { colors, spacing, fontSizes, shadows, borderRadius } from '../../theme'; -import { ConversationResponse, UserDTO, MessageResponse, extractTextFromSegments, extractTextFromSegmentsAsync, MessageSegment } from '../../types/dto'; +import { + spacing, + fontSizes, + shadows, + borderRadius, + useAppColors, + type AppColors, +} from '../../theme'; +import { ConversationResponse, UserDTO, MessageResponse, extractTextFromSegments } from '../../types/dto'; import { authService } from '../../services'; import { useUserStore, useAuthStore } from '../../stores'; -// 【新架构】使用MessageManager hooks -import { useMessageList, messageManager, useMessageListRefresh, useCreateConversation } from '../../stores'; -import { Avatar, Text, EmptyState, ResponsiveContainer } from '../../components/common'; +// 【新架构】使用MessageManager hooks(会话列表数据源自 MessageManager 游标同步) +import { + messageManager, + useMessageListRefresh, + useCreateConversation, + useUnreadCount, + useMarkAsRead, + useMessageManagerConversations, +} from '../../stores'; +import { Avatar, Text, EmptyState, ResponsiveContainer, AppBackButton } from '../../components/common'; import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive'; -import { RootStackParamList, MessageStackParamList } from '../../navigation/types'; -import { getUserCache } from '../../services/database'; +import * as hrefs from '../../navigation/hrefs'; // 导入 EmbeddedChat 组件用于桌面端双栏布局 import { EmbeddedChat } from './components/EmbeddedChat'; +import { ConversationListRow } from './components/ConversationListRow'; // 导入 NotificationsScreen 用于在内部显示 import { NotificationsScreen } from './NotificationsScreen'; - -type NavigationProp = NativeStackNavigationProp; -type MessageNavProp = NativeStackNavigationProp; +// 导入扫码组件 +import { QRCodeScanner } from '../../components/business/QRCodeScanner'; // 系统通知会话特殊ID const SYSTEM_MESSAGE_CHANNEL_ID = '-1'; @@ -59,87 +74,15 @@ interface SearchResultItem { matchedMessages?: MessageResponse[]; } -// 动画值 -const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity); -const MAX_CONVERSATION_NAME_LENGTH = 10; - -const truncateDisplayName = (name: string, maxLength: number = MAX_CONVERSATION_NAME_LENGTH): string => { - if (!name) return ''; - if (name.length <= maxLength) return name; - return `${name.slice(0, maxLength)}...`; -}; - -/** - * 异步消息预览组件 - */ -const AsyncMessagePreview: React.FC<{ - segments?: MessageSegment[]; - status?: string; - isGroupChat?: boolean; - senderName?: string; -}> = ({ segments, status, isGroupChat, senderName }) => { - const [displayText, setDisplayText] = useState(''); - const isMountedRef = useRef(true); - - useEffect(() => { - isMountedRef.current = true; - - const loadPreview = async () => { - if (status === 'recalled') { - if (isMountedRef.current) { - setDisplayText('消息已撤回'); - } - return; - } - - const initialText = extractTextFromSegments(segments); - if (isMountedRef.current) { - setDisplayText(initialText); - } - - const hasAtWithoutNickname = segments?.some( - s => s.type === 'at' && s.data.user_id !== 'all' && !s.data.nickname - ); - - if (hasAtWithoutNickname) { - try { - const asyncText = await extractTextFromSegmentsAsync( - segments, - getUserCache, - authService.getUserById.bind(authService) - ); - if (isMountedRef.current && asyncText !== initialText) { - setDisplayText(asyncText); - } - } catch (error) { - console.warn('[AsyncMessagePreview] 获取用户名失败:', error); - } - } - }; - - loadPreview(); - - return () => { - isMountedRef.current = false; - }; - }, [segments, status]); - - if (!displayText) return null; - - if (isGroupChat && senderName) { - return <>{senderName}: {displayText}; - } - - return <>{displayText}; -}; - /** * MessageListScreen - 纯渲染组件 * 所有数据通过useMessageList hook获取 * 支持响应式双栏布局 */ export const MessageListScreen: React.FC = () => { - const navigation = useNavigation(); + const colors = useAppColors(); + const styles = useMemo(() => createMessageListStyles(colors), [colors]); + const router = useRouter(); const isFocused = useIsFocused(); const insets = useSafeAreaInsets(); // 在大屏幕模式下不使用 Bottom Tab Navigator,所以不需要 tabBarHeight @@ -158,22 +101,33 @@ export const MessageListScreen: React.FC = () => { const { isDesktop, isTablet, width } = useResponsive(); const isWideScreen = useBreakpointGTE('lg'); - // 【新架构】使用MessageManager的hook获取数据 + // 会话列表:MessageManager 统一游标拉取与合并,与已读/角标同源 const { - conversations, + conversations: conversationList, isLoading, + hasMore, + loadMore, refresh, - totalUnreadCount, - systemUnreadCount, - markAllAsRead, - isMarking, - } = useMessageList(); + } = useMessageManagerConversations(); + + // 使用 MessageManager 获取未读数和系统通知数 + const { totalUnreadCount, systemUnreadCount } = useUnreadCount(); + const { markAllAsRead, isMarking } = useMarkAsRead(null); // 【新架构】使用MessageManager的hook创建会话 const { createConversation } = useCreateConversation(); // 本地刷新状态(仅用于下拉刷新的UI显示) const [refreshing, setRefreshing] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + + // 上拉加载更多 + const onEndReached = useCallback(async () => { + if (isLoading || loadingMore || !hasMore) return; + setLoadingMore(true); + await loadMore(); + setLoadingMore(false); + }, [isLoading, loadingMore, hasMore, loadMore]); // 搜索相关状态 const [isSearchMode, setIsSearchMode] = useState(false); @@ -182,6 +136,7 @@ export const MessageListScreen: React.FC = () => { const [isSearching, setIsSearching] = useState(false); const [activeSearchTab, setActiveSearchTab] = useState<'chat' | 'user'>('chat'); const [actionMenuVisible, setActionMenuVisible] = useState(false); + const [scannerVisible, setScannerVisible] = useState(false); // 系统通知显示状态 - 用于在移动端显示通知页面 const [showNotifications, setShowNotifications] = useState(false); @@ -224,10 +179,12 @@ export const MessageListScreen: React.FC = () => { }, [width]); // 给底部列表预留安全空间,避免被 TabBar 遮挡导致最后几项无法完整滑出 + // TabBar 悬浮:高度 64 + 浮动间距 12*2 = 88 const listBottomInset = useMemo(() => { if (isWideScreen) return spacing.lg; - return tabBarHeight + insets.bottom + spacing.md; - }, [isWideScreen, tabBarHeight, insets.bottom]); + const FLOATING_TAB_BAR_HEIGHT = 64 + 24; // TabBar高度 + 上下浮动间距 + return FLOATING_TAB_BAR_HEIGHT + insets.bottom + spacing.md; + }, [isWideScreen, insets.bottom]); // 【新架构】页面获得焦点时初始化MessageManager useEffect(() => { @@ -237,7 +194,7 @@ export const MessageListScreen: React.FC = () => { }, [isFocused]); // 【新架构】使用focus刷新hook,从ChatScreen返回时自动刷新未读数 - useMessageListRefresh(isFocused); + useMessageListRefresh(); // 同步未读数到userStore(用于TabBar角标显示) useEffect(() => { @@ -256,6 +213,19 @@ export const MessageListScreen: React.FC = () => { } }, [isFocused]); + // 处理 Android 物理返回键 - 当显示通知页面时,返回键关闭通知页面 + useEffect(() => { + const backHandler = BackHandler.addEventListener('hardwareBackPress', () => { + if (showNotifications) { + setShowNotifications(false); + return true; // 阻止默认行为 + } + return false; + }); + + return () => backHandler.remove(); + }, [showNotifications]); + // 下拉刷新 const onRefresh = useCallback(async () => { setRefreshing(true); @@ -263,8 +233,8 @@ export const MessageListScreen: React.FC = () => { setRefreshing(false); }, [refresh]); - // 格式化时间 - const formatTime = (dateString: string): string => { + // 格式化时间(稳定引用,配合会话行 memo) + const formatTime = useCallback((dateString: string): string => { try { const date = new Date(dateString); const now = new Date(); @@ -289,7 +259,7 @@ export const MessageListScreen: React.FC = () => { } catch { return ''; } - }; + }, []); // 跳转到聊天页 const handleConversationPress = (conversation: ConversationResponse, index: number) => { @@ -297,12 +267,12 @@ export const MessageListScreen: React.FC = () => { Animated.timing(scaleAnims[index], { toValue: 0.98, duration: 100, - useNativeDriver: true, + useNativeDriver: Platform.OS !== 'web', }), Animated.timing(scaleAnims[index], { toValue: 1, duration: 100, - useNativeDriver: true, + useNativeDriver: Platform.OS !== 'web', }), ]).start(() => { if (conversation.id === SYSTEM_MESSAGE_CHANNEL_ID) { @@ -322,41 +292,47 @@ export const MessageListScreen: React.FC = () => { setSelectedConversation(conversation); } else if (conversation.type === 'group' && conversation.group) { // 群聊 - 窄屏下导航 - (navigation as any).navigate('Chat', { - conversationId: String(conversation.id), - groupId: String(conversation.group.id), - groupName: conversation.group.name, - isGroupChat: true, - }); + router.push( + hrefs.hrefChat({ + conversationId: String(conversation.id), + groupId: String(conversation.group.id), + groupName: conversation.group.name, + isGroupChat: true, + }) + ); } else { // 私聊 - 窄屏下导航 const currentUserId = useAuthStore.getState().currentUser?.id; const otherUser = conversation.participants?.find(p => String(p.id) !== String(currentUserId)); - (navigation as any).navigate('Chat', { - conversationId: String(conversation.id), - userId: otherUser ? String(otherUser.id) : undefined, - userName: otherUser?.nickname || otherUser?.username, - isGroupChat: false, - }); + router.push( + hrefs.hrefChat({ + conversationId: String(conversation.id), + userId: otherUser ? String(otherUser.id) : undefined, + isGroupChat: false, + }) + ); } }); }; + const handleConversationPressRef = useRef(handleConversationPress); + handleConversationPressRef.current = handleConversationPress; + // 创建群聊 const handleCreateGroup = () => { - (navigation as any).navigate('CreateGroup'); + router.push(hrefs.hrefGroupCreate()); }; // 主动加群 const handleJoinGroup = () => { - (navigation as any).navigate('JoinGroup'); + router.push(hrefs.hrefGroupJoin()); }; const handleOpenActionMenu = () => { setActionMenuVisible(true); }; - const runMenuAction = (action: 'join' | 'create' | 'readAll') => { + const runMenuAction = (action: 'join' | 'create' | 'readAll' | 'scanQRCode') => { setActionMenuVisible(false); if (action === 'join') { handleJoinGroup(); @@ -366,6 +342,10 @@ export const MessageListScreen: React.FC = () => { handleCreateGroup(); return; } + if (action === 'scanQRCode') { + setScannerVisible(true); + return; + } handleMarkAllRead(); }; @@ -408,7 +388,7 @@ export const MessageListScreen: React.FC = () => { if (activeSearchTab === 'chat') { const results: SearchResultItem[] = []; - for (const conv of conversations) { + for (const conv of conversationList) { await messageManager.fetchMessages(String(conv.id)); const messages = messageManager.getMessages(String(conv.id)); const matchedMessages = messages.filter(msg => { @@ -439,7 +419,7 @@ export const MessageListScreen: React.FC = () => { } finally { setIsSearching(false); } - }, [conversations, activeSearchTab]); + }, [conversationList, activeSearchTab]); // 搜索文本变化时触发搜索 useEffect(() => { @@ -488,139 +468,40 @@ export const MessageListScreen: React.FC = () => { // 合并列表数据 const listData: ConversationResponse[] = [ createSystemMessageItem(), - ...conversations, + ...conversationList, ]; + const listConvRef = useRef(new Map()); + listConvRef.current = new Map(listData.map((c) => [String(c.id), c])); + + const stableConversationPress = useCallback((conversationId: string, index: number) => { + const c = listConvRef.current.get(conversationId); + if (c) { + handleConversationPressRef.current(c, index); + } + }, []); + // 总未读数 const totalUnread = totalUnreadCount + systemUnreadCount; - // 渲染会话项 - const renderConversation = ({ item, index }: { item: ConversationResponse; index: number }) => { - const isSystemChannel = item.id === SYSTEM_MESSAGE_CHANNEL_ID; - const isGroupChat = !!(item.type === 'group' && item.group); - - // 获取显示名称和头像 - let displayNameRaw: string; - let displayName: string; - let displayAvatar: string | null = null; - - if (isSystemChannel) { - displayNameRaw = '系统通知'; - } else if (isGroupChat && item.group) { - displayNameRaw = item.group.name || '群聊'; - displayAvatar = item.group.avatar && item.group.avatar.trim() !== '' ? item.group.avatar : null; - } else { - displayNameRaw = item.participants?.[0]?.nickname || '未知用户'; - displayAvatar = item.participants?.[0]?.avatar || null; - } - displayName = truncateDisplayName(displayNameRaw); - - const getSenderName = (): string | undefined => { - if (isGroupChat && item.last_message?.sender) { - return item.last_message.sender.nickname || item.last_message.sender.username; - } - return undefined; - }; - - // 是否选中(用于桌面端双栏布局高亮) - const isSelected = selectedConversation?.id === item.id; - - return ( - handleConversationPress(item, index)} - activeOpacity={0.7} - > - - {isSystemChannel ? ( - - - - ) : isGroupChat ? ( - - {displayAvatar ? ( - - ) : ( - - - - )} - - ) : ( - - )} - - - - - - {isSystemChannel && ( - - 官方 - - )} - {isGroupChat && ( - - )} - {displayName} - {item.is_pinned && !isSystemChannel && ( - - )} - {isGroupChat && item.member_count !== undefined && ( - ({item.member_count}) - )} - - - {formatTime(item.last_message_at || item.updated_at)} - - - - 0 ? [styles.messageText, styles.unreadMessageText] : styles.messageText} - numberOfLines={1} - > - {!item.last_message ? ( - '暂无消息' - ) : ( - - )} - - {item.unread_count > 0 && ( - - - {item.unread_count > 99 ? '99+' : item.unread_count} - - - )} - {__DEV__ && ( - - [DEBUG: {item.unread_count || 0}] - - )} - - - - ); - }; + // 渲染会话项(行级 memo + 按 id 解析最新会话,避免 Tab 切换时整表无效重绘) + const renderConversation = useCallback( + ({ item, index }: { item: ConversationResponse; index: number }) => { + const isSelected = selectedConversation?.id === item.id; + return ( + stableConversationPress(String(item.id), index)} + /> + ); + }, + [selectedConversation?.id, scaleAnims, formatTime, stableConversationPress] + ); // 渲染空状态 const renderEmpty = () => ( @@ -693,7 +574,7 @@ export const MessageListScreen: React.FC = () => { ))} - + ); } @@ -709,12 +590,13 @@ export const MessageListScreen: React.FC = () => { const conversation = await createConversation(String(user.id)); if (conversation) { - (navigation as any).navigate('Chat', { - conversationId: String(conversation.id), - userId: String(user.id), - userName: user.nickname || user.username, - isGroupChat: false, - }); + router.push( + hrefs.hrefChat({ + conversationId: String(conversation.id), + userId: String(user.id), + isGroupChat: false, + }) + ); } } catch (error) { console.error('创建会话失败:', error); @@ -741,7 +623,7 @@ export const MessageListScreen: React.FC = () => { )} {!user.is_following && ( - + )} ); @@ -788,13 +670,11 @@ export const MessageListScreen: React.FC = () => { const renderSearchMode = () => ( - - - + { /> {searchText.length > 0 && ( setSearchText('')}> - + )} @@ -862,7 +742,7 @@ export const MessageListScreen: React.FC = () => { - + @@ -873,12 +753,12 @@ export const MessageListScreen: React.FC = () => { activeOpacity={0.7} > - + 搜索 - {isLoading ? ( + {isLoading && conversationList.length === 0 ? ( @@ -894,6 +774,8 @@ export const MessageListScreen: React.FC = () => { ]} showsVerticalScrollIndicator={false} ListEmptyComponent={renderEmpty} + onEndReached={onEndReached} + onEndReachedThreshold={0.5} refreshControl={ { tintColor={colors.primary.main} /> } + ListFooterComponent={ + loadingMore ? ( + + + 加载中... + + ) : null + } /> )} @@ -911,16 +801,20 @@ export const MessageListScreen: React.FC = () => { setActionMenuVisible(false)}> setActionMenuVisible(false)}> + runMenuAction('scanQRCode')}> + + 扫码登录 + runMenuAction('join')}> - + 加入群聊 runMenuAction('create')}> - + 创建群聊 runMenuAction('readAll')} disabled={isMarking}> - + {isMarking ? '处理中...' : '全部已读'} @@ -953,7 +847,7 @@ export const MessageListScreen: React.FC = () => { // 默认占位符 - + 选择一个会话开始聊天 @@ -980,394 +874,295 @@ export const MessageListScreen: React.FC = () => { renderConversationList() )} {renderActionMenu()} + setScannerVisible(false)} /> ); }; -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#FAFAFA', - }, - // 桌面端双栏布局 - desktopContainer: { - flex: 1, - flexDirection: 'row', - }, - sidebar: { - backgroundColor: '#FAFAFA', - borderRightWidth: 1, - borderRightColor: '#E8E8E8', - }, - chatArea: { - flex: 1, - backgroundColor: '#F5F7FA', - }, - chatPlaceholder: { - flex: 1, - backgroundColor: '#F5F7FA', - alignItems: 'center', - justifyContent: 'center', - }, - chatPlaceholderContent: { - alignItems: 'center', - justifyContent: 'center', - }, - chatPlaceholderText: { - marginTop: spacing.md, - fontSize: 16, - color: '#999', - }, - header: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: spacing.md, - paddingVertical: spacing.md, - backgroundColor: '#FAFAFA', - ...shadows.sm, - }, - headerWide: { - paddingHorizontal: spacing.lg, - paddingVertical: spacing.lg, - }, - headerTitleWide: { - fontSize: 22, - }, - searchContainerWide: { - paddingHorizontal: spacing.lg, - }, - listContentWide: { - paddingHorizontal: spacing.lg, - }, - headerLeft: { - width: 44, - }, - headerCenter: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.xs, - }, - headerTitle: { - fontSize: 19, - fontWeight: '700', - color: '#333', - }, - totalBadge: { - minWidth: 18, - height: 18, - borderRadius: 9, - backgroundColor: '#FF4757', - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: 5, - }, - totalBadgeText: { - color: '#FFF', - fontSize: 11, - fontWeight: '600', - }, - headerRight: { - width: 44, - height: 44, - alignItems: 'center', - justifyContent: 'center', - }, - headerRightContainer: { - flexDirection: 'row', - alignItems: 'center', - }, - headerRightBtn: { - width: 36, - height: 44, - alignItems: 'center', - justifyContent: 'center', - }, - actionMenuBackdrop: { - flex: 1, - backgroundColor: 'rgba(0,0,0,0.08)', - }, - actionMenu: { - position: 'absolute', - top: 88, - right: spacing.md, - width: 188, - backgroundColor: '#FFF', - borderRadius: 12, - paddingVertical: spacing.sm, - ...shadows.md, - }, - actionMenuItem: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: spacing.md, - paddingVertical: spacing.md, - }, - actionMenuText: { - marginLeft: spacing.sm, - fontSize: 16, - color: '#333', - }, - searchContainer: { - paddingHorizontal: spacing.md, - paddingBottom: spacing.sm, - backgroundColor: '#FAFAFA', - }, - searchBox: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: '#F0F0F0', - borderRadius: 10, - paddingHorizontal: spacing.sm, - paddingVertical: 10, - }, - searchPlaceholder: { - fontSize: 14, - color: '#999', - marginLeft: spacing.xs, - }, - listContent: { - flexGrow: 1, - backgroundColor: '#FAFAFA', - }, - conversationItem: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: spacing.md, - paddingVertical: 14, - backgroundColor: '#FFF', - marginHorizontal: spacing.md, - marginTop: spacing.sm, - borderRadius: 12, - ...shadows.sm, - }, - conversationItemSelected: { - backgroundColor: colors.primary.light + '20', - borderColor: colors.primary.main, - borderWidth: 1, - }, - avatarContainer: { - position: 'relative', - }, - systemAvatar: { - width: 50, - height: 50, - borderRadius: 12, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.primary.main, - }, - conversationContent: { - flex: 1, - marginLeft: spacing.md, - }, - conversationHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 4, - }, - nameRow: { - flexDirection: 'row', - alignItems: 'center', - }, - officialBadge: { - backgroundColor: colors.primary.main, - borderRadius: 4, - paddingHorizontal: 6, - paddingVertical: 2, - marginRight: spacing.xs, - }, - officialBadgeText: { - color: '#FFF', - fontSize: 10, - fontWeight: '600', - }, - userName: { - fontWeight: '600', - color: '#333', - fontSize: 16, - }, - groupIcon: { - marginRight: 4, - }, - memberCount: { - fontSize: 12, - color: '#999', - marginLeft: 2, - }, - pinnedIcon: { - marginLeft: 6, - }, - groupAvatar: { - width: 50, - height: 50, - }, - groupAvatarPlaceholder: { - width: 50, - height: 50, - borderRadius: 12, - backgroundColor: colors.primary.main, - alignItems: 'center', - justifyContent: 'center', - }, - timeText: { - color: '#999', - fontSize: 12, - }, - messageRow: { - flexDirection: 'row', - alignItems: 'center', - }, - messageText: { - flex: 1, - color: '#888', - fontSize: 14, - }, - unreadMessageText: { - color: '#333', - fontWeight: '500', - }, - unreadBadge: { - minWidth: 20, - height: 20, - borderRadius: 10, - backgroundColor: colors.primary.main, - alignItems: 'center', - justifyContent: 'center', - marginLeft: spacing.sm, - paddingHorizontal: 6, - }, - unreadBadgeText: { - color: '#FFF', - fontSize: 12, - fontWeight: '600', - }, - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - paddingVertical: spacing.xl * 2, - }, - searchModeContainer: { - flex: 1, - backgroundColor: '#FAFAFA', - }, - searchInputContainer: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: '#F0F0F0', - borderRadius: 10, - paddingHorizontal: spacing.md, - marginHorizontal: spacing.md, - marginTop: spacing.md, - height: 40, - }, - searchInput: { - flex: 1, - fontSize: 15, - color: '#333', - marginLeft: spacing.md, - }, - searchTabs: { - flexDirection: 'row', - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - gap: spacing.sm, - }, - searchTab: { - flex: 1, - paddingVertical: spacing.sm, - alignItems: 'center', - backgroundColor: '#F0F0F0', - borderRadius: 8, - }, - searchTabActive: { - backgroundColor: colors.primary.main, - }, - searchTabText: { - fontSize: 14, - color: '#666', - fontWeight: '500', - }, - searchTabTextActive: { - color: '#FFF', - }, - searchResultsContent: { - flexGrow: 1, - paddingHorizontal: spacing.md, - }, - searchResultItem: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: spacing.md, - backgroundColor: '#FFF', - borderRadius: 10, - marginBottom: spacing.sm, - paddingHorizontal: spacing.md, - }, - searchResultContent: { - flex: 1, - marginLeft: spacing.md, - }, - searchResultHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 2, - }, - searchResultName: { - fontSize: 15, - fontWeight: '600', - color: '#333', - }, - searchResultTime: { - fontSize: 12, - color: '#999', - }, - searchResultMessage: { - fontSize: 13, - color: '#888', - }, - searchingText: { - marginTop: spacing.sm, - fontSize: 14, - color: '#999', - }, - followingBadge: { - width: 24, - height: 24, - borderRadius: 12, - backgroundColor: colors.primary.light + '30', - alignItems: 'center', - justifyContent: 'center', - }, - highlightText: { - color: colors.primary.main, - fontWeight: '700', - backgroundColor: colors.primary.light + '30', - }, - matchedMessagesContainer: { - marginTop: spacing.sm, - }, - matchedMessageItem: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'flex-start', - paddingVertical: spacing.xs, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: '#F0F0F0', - }, - matchedMessageText: { - flex: 1, - fontSize: 13, - color: '#555', - lineHeight: 18, - }, - matchedMessageTime: { - fontSize: 11, - color: '#AAA', - marginLeft: spacing.sm, - minWidth: 45, - }, -}); +function createMessageListStyles(colors: AppColors) { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.default, + }, + // 桌面端双栏布局 + desktopContainer: { + flex: 1, + flexDirection: 'row', + }, + sidebar: { + backgroundColor: colors.background.default, + borderRightWidth: 1, + borderRightColor: colors.chat.border, + }, + chatArea: { + flex: 1, + backgroundColor: colors.chat.surfaceMuted, + }, + chatPlaceholder: { + flex: 1, + backgroundColor: colors.chat.surfaceMuted, + alignItems: 'center', + justifyContent: 'center', + }, + chatPlaceholderContent: { + alignItems: 'center', + justifyContent: 'center', + }, + chatPlaceholderText: { + marginTop: spacing.md, + fontSize: 16, + color: colors.text.hint, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.md, + paddingVertical: spacing.md, + backgroundColor: colors.background.default, + ...shadows.sm, + }, + headerWide: { + paddingHorizontal: spacing.lg, + paddingVertical: spacing.lg, + }, + headerTitleWide: { + fontSize: 22, + }, + searchContainerWide: { + paddingHorizontal: spacing.lg, + }, + listContentWide: { + paddingHorizontal: spacing.lg, + }, + headerLeft: { + width: 44, + }, + headerCenter: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + }, + headerTitle: { + fontSize: 19, + fontWeight: '700', + color: colors.text.primary, + }, + totalBadge: { + minWidth: 18, + height: 18, + borderRadius: 9, + backgroundColor: colors.error.main, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 5, + }, + totalBadgeText: { + color: colors.primary.contrast, + fontSize: 11, + fontWeight: '600', + }, + headerRight: { + width: 44, + height: 44, + alignItems: 'center', + justifyContent: 'center', + }, + headerRightContainer: { + flexDirection: 'row', + alignItems: 'center', + }, + headerRightBtn: { + width: 36, + height: 44, + alignItems: 'center', + justifyContent: 'center', + }, + actionMenuBackdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.08)', + }, + actionMenu: { + position: 'absolute', + top: 88, + right: spacing.md, + width: 188, + backgroundColor: colors.background.paper, + borderRadius: 12, + paddingVertical: spacing.sm, + ...shadows.md, + }, + actionMenuItem: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingVertical: spacing.md, + }, + actionMenuText: { + marginLeft: spacing.sm, + fontSize: 16, + color: colors.text.primary, + }, + searchContainer: { + paddingHorizontal: spacing.md, + paddingBottom: spacing.sm, + backgroundColor: colors.background.default, + }, + searchBox: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.chat.surfaceInput, + borderRadius: 10, + paddingHorizontal: spacing.sm, + paddingVertical: 10, + }, + searchPlaceholder: { + fontSize: 14, + color: colors.text.hint, + marginLeft: spacing.xs, + }, + listContent: { + flexGrow: 1, + backgroundColor: colors.background.default, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingVertical: spacing.xl * 2, + }, + loadingMoreContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: spacing.md, + }, + loadingMoreText: { + marginLeft: spacing.sm, + fontSize: 14, + color: colors.text.hint, + }, + searchModeContainer: { + flex: 1, + backgroundColor: colors.background.default, + }, + searchInputContainer: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.chat.surfaceInput, + borderRadius: 10, + paddingHorizontal: spacing.md, + marginHorizontal: spacing.md, + marginTop: spacing.md, + height: 40, + }, + searchInput: { + flex: 1, + fontSize: 15, + color: colors.text.primary, + marginLeft: spacing.md, + }, + searchTabs: { + flexDirection: 'row', + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + gap: spacing.sm, + }, + searchTab: { + flex: 1, + paddingVertical: spacing.sm, + alignItems: 'center', + backgroundColor: colors.chat.surfaceInput, + borderRadius: 8, + }, + searchTabActive: { + backgroundColor: colors.primary.main, + }, + searchTabText: { + fontSize: 14, + color: colors.text.secondary, + fontWeight: '500', + }, + searchTabTextActive: { + color: colors.primary.contrast, + }, + searchResultsContent: { + flexGrow: 1, + paddingHorizontal: spacing.md, + }, + searchResultItem: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: spacing.md, + backgroundColor: colors.background.paper, + borderRadius: 10, + marginBottom: spacing.sm, + paddingHorizontal: spacing.md, + }, + searchResultContent: { + flex: 1, + marginLeft: spacing.md, + }, + searchResultHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 2, + }, + searchResultName: { + fontSize: 15, + fontWeight: '600', + color: colors.text.primary, + }, + searchResultTime: { + fontSize: 12, + color: colors.text.hint, + }, + searchResultMessage: { + fontSize: 13, + color: colors.text.secondary, + }, + searchingText: { + marginTop: spacing.sm, + fontSize: 14, + color: colors.text.hint, + }, + followingBadge: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: colors.primary.light + '30', + alignItems: 'center', + justifyContent: 'center', + }, + highlightText: { + color: colors.primary.main, + fontWeight: '700', + backgroundColor: colors.primary.light + '30', + }, + matchedMessagesContainer: { + marginTop: spacing.sm, + }, + matchedMessageItem: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + paddingVertical: spacing.xs, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.divider, + }, + matchedMessageText: { + flex: 1, + fontSize: 13, + color: colors.text.secondary, + lineHeight: 18, + }, + matchedMessageTime: { + fontSize: 11, + color: colors.text.hint, + marginLeft: spacing.sm, + minWidth: 45, + }, + }); +} diff --git a/src/screens/message/NotificationsScreen.tsx b/src/screens/message/NotificationsScreen.tsx index f7ddef1..5c69eed 100644 --- a/src/screens/message/NotificationsScreen.tsx +++ b/src/screens/message/NotificationsScreen.tsx @@ -1,11 +1,11 @@ /** * 通知页 NotificationsScreen * 胡萝卜BBS - 系统消息列表 - * 使用新的系统消息API + * 【游标分页】使用 messageService.getSystemMessagesCursor * 支持响应式布局 */ -import React, { useState, useCallback, useEffect, useMemo } from 'react'; +import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'; import { View, FlatList, @@ -14,20 +14,22 @@ import { RefreshControl, ActivityIndicator, Dimensions, + BackHandler, } from 'react-native'; -import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { useIsFocused } from '@react-navigation/native'; -import { useNavigation } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useRouter } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { colors, spacing } from '../../theme'; +import { spacing, borderRadius, shadows, useAppColors, type AppColors } from '../../theme'; import { SystemMessageResponse } from '../../types/dto'; import { messageService } from '../../services/messageService'; import { commentService } from '../../services/commentService'; import { SystemMessageItem } from '../../components/business'; -import { Text, EmptyState, ResponsiveContainer } from '../../components/common'; -import { RootStackParamList } from '../../navigation/types'; -import { useMessageManagerSystemUnreadCount, useUserStore } from '../../stores'; +import { Text, EmptyState, ResponsiveContainer, AppBackButton } from '../../components/common'; +import { useCursorPagination } from '../../hooks/useCursorPagination'; +import * as hrefs from '../../navigation/hrefs'; +import { useMessageManagerSystemUnreadCount } from '../../stores'; +import { messageManager } from '../../stores/messageManager'; const MESSAGE_TYPES = [ { key: 'all', title: '全部' }, @@ -43,10 +45,11 @@ const LIKE_SYSTEM_TYPES = new Set(['like_post', 'like_comment', 'like_reply', 'f const GROUP_SYSTEM_TYPES = new Set(['group_invite', 'group_join_apply', 'group_join_approved', 'group_join_rejected']); export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack }) => { + const colors = useAppColors(); + const styles = useMemo(() => createNotificationsStyles(colors), [colors]); const isFocused = useIsFocused(); - const fetchMessageUnreadCount = useUserStore(state => state.fetchMessageUnreadCount); const { setSystemUnreadCount, decrementSystemUnreadCount } = useMessageManagerSystemUnreadCount(); - const navigation = useNavigation>(); + const router = useRouter(); // 修复竞态条件:使用本地状态管理窗口尺寸,避免 hydration 问题 const [windowSize, setWindowSize] = useState({ width: 0, height: 0 }); @@ -70,14 +73,35 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack // Web端使用更大的容器宽度 const containerMaxWidth = isDesktop ? 1200 : isTablet ? 1000 : 900; - const [messages, setMessages] = useState([]); const [activeType, setActiveType] = useState('all'); - const [refreshing, setRefreshing] = useState(false); - const [loading, setLoading] = useState(true); - const [hasMore, setHasMore] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); const [unreadCount, setUnreadCount] = useState(0); + // 【游标分页】使用 useCursorPagination hook 获取系统消息列表 + // 使用 useCallback 包裹 fetchFunction,避免无限重新创建导致循环 + const fetchSystemMessages = useCallback( + async ({ cursor, pageSize }: { cursor?: string; pageSize: number }) => { + return await messageService.getSystemMessagesCursor({ + cursor, + page_size: pageSize, + }); + }, + [] + ); + + const { + list: messages, + isLoading, + isRefreshing, + hasMore, + loadMore, + refresh, + error: paginationError, + } = useCursorPagination(fetchSystemMessages, { pageSize: 20 }); + + // 本地刷新状态(仅用于下拉刷新的UI显示) + const [refreshing, setRefreshing] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + // 同一 flag 只要有人审批过,就将待处理消息同步展示为已处理状态 const displayMessages = useMemo(() => { const reviewedByFlag = new Map(); @@ -116,21 +140,6 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack }); }, [messages]); - // 获取系统消息数据 - const fetchMessages = useCallback(async () => { - try { - setLoading(true); - const response = await messageService.getSystemMessages(50, 1); - // 添加防御性检查,确保 messages 数组存在 - setMessages(response.messages || []); - setHasMore(response.has_more ?? false); - } catch (error) { - console.error('获取系统消息失败:', error); - } finally { - setLoading(false); - } - }, []); - // 获取未读数 const fetchUnreadCount = useCallback(async () => { try { @@ -145,27 +154,28 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack const handleMarkAllRead = useCallback(async () => { try { await messageService.markAllSystemMessagesRead(); - setMessages(prev => prev.map(m => ({ ...m, is_read: true }))); + // 刷新消息列表 + await refresh(); setUnreadCount(0); setSystemUnreadCount(0); // 同步更新全局 TabBar 红点 - fetchMessageUnreadCount(); - // 刷新消息列表 - fetchMessages(); + messageManager.fetchUnreadCount(); } catch (error) { console.error('一键已读失败:', error); } - }, [fetchMessages, fetchMessageUnreadCount, setSystemUnreadCount]); + }, [refresh, setSystemUnreadCount]); // 页面加载和获得焦点时刷新,并自动标记所有消息为已读 + // 使用 ref 防止重复执行,避免无限循环 + const hasInitializedRef = useRef(false); useEffect(() => { - if (isFocused) { - fetchMessages(); + if (isFocused && !hasInitializedRef.current) { + hasInitializedRef.current = true; fetchUnreadCount(); // 进入界面自动标记所有消息为已读 handleMarkAllRead(); } - }, [isFocused, fetchMessages, fetchUnreadCount, handleMarkAllRead]); + }, [isFocused]); // 只依赖 isFocused,函数使用 ref 稳定引用 // 屏幕失去焦点时,如果有 onBack 回调则调用它(用于内嵌模式) useEffect(() => { @@ -174,6 +184,21 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack } }, [isFocused, onBack]); + // 处理 Android 物理返回键 - 当 onBack 存在时(内嵌模式),拦截返回键 + useEffect(() => { + if (!onBack) return; + + const backHandler = BackHandler.addEventListener('hardwareBackPress', () => { + if (isFocused) { + onBack(); + return true; // 阻止默认行为 + } + return false; + }); + + return () => backHandler.remove(); + }, [isFocused, onBack]); + // 筛选消息 const filteredMessages = activeType === 'all' ? displayMessages @@ -190,29 +215,17 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack // 下拉刷新 const onRefresh = useCallback(async () => { setRefreshing(true); - await Promise.all([fetchMessages(), fetchUnreadCount()]); + await Promise.all([refresh(), fetchUnreadCount()]); setRefreshing(false); - }, [fetchMessages, fetchUnreadCount]); + }, [refresh, fetchUnreadCount]); // 加载更多 - const loadMore = useCallback(async () => { - if (loadingMore || !hasMore || messages.length === 0) return; - - try { - setLoadingMore(true); - // 使用时间戳或seq作为游标分页(后端使用page分页) - const nextPage = Math.floor((messages.length / 20)) + 1; - const response = await messageService.getSystemMessages(20, nextPage); - // 添加防御性检查 - const newMessages = response.messages || []; - setMessages(prev => [...prev, ...newMessages]); - setHasMore(response.has_more ?? false); - } catch (error) { - console.error('加载更多失败:', error); - } finally { - setLoadingMore(false); - } - }, [loadingMore, hasMore, messages]); + const onEndReached = useCallback(async () => { + if (isLoading || loadingMore || !hasMore) return; + setLoadingMore(true); + await loadMore(); + setLoadingMore(false); + }, [isLoading, loadingMore, hasMore, loadMore]); // 标记单条消息已读并处理导航 const extractPostIdFromActionUrl = (actionUrl?: string): string | null => { @@ -262,16 +275,14 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack const messageId = String(message.id); const wasUnread = message.is_read !== true; await messageService.markSystemMessageRead(messageId); - setMessages(prev => - prev.map(m => (String(m.id) === messageId ? { ...m, is_read: true } : m)) - ); + // 【游标分页】不再直接修改 messages 状态,而是通过刷新获取最新数据 if (wasUnread) { setUnreadCount(prev => Math.max(0, prev - 1)); decrementSystemUnreadCount(1); } // 更新本地未读数以及全局 TabBar 红点 fetchUnreadCount(); - fetchMessageUnreadCount(); + messageManager.fetchUnreadCount(); // 根据消息类型处理导航 const { system_type, extra_data } = message; @@ -281,17 +292,17 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack ) { const postId = await resolvePostId(message); if (postId) { - navigation.navigate('PostDetail', { postId }); + router.push(hrefs.hrefPostDetail(postId)); } } else if (system_type === 'follow') { // 关注 - 跳转到用户主页 if (extra_data?.actor_id_str) { - navigation.navigate('UserProfile', { userId: extra_data.actor_id_str }); + router.push(hrefs.hrefUserProfile(extra_data.actor_id_str)); } } else if (system_type === 'group_join_apply') { - navigation.navigate('GroupRequestDetail', { message }); + router.push(hrefs.hrefGroupRequestDetail(message)); } else if (system_type === 'group_invite') { - navigation.navigate('GroupInviteDetail', { message }); + router.push(hrefs.hrefGroupInviteDetail(message)); } // 其他类型暂不处理跳转 } catch (error) { @@ -306,7 +317,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack const actorId = extra_data?.actor_id_str || (extra_data?.actor_id ? String(extra_data.actor_id) : null); if (actorId) { - navigation.navigate('UserProfile', { userId: actorId }); + router.push(hrefs.hrefUserProfile(actorId)); } }; @@ -340,19 +351,20 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack return ( {shouldShowBackButton ? ( - - - + ) : ( )} - - 系统通知 - + + + 系统通知 + + {unreadCount > 0 && ( + + {unreadCount > 99 ? '99+' : unreadCount} + + )} + ); @@ -400,30 +412,34 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack } return m.system_type === type.key; }).length; + const isActive = activeType === type.key; return ( setActiveType(type.key)} + activeOpacity={0.8} > {type.title} {count > 0 && ( - - {count} - + + + {count} + + )} ); @@ -431,7 +447,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack {/* 消息列表 */} - {loading ? ( + {isLoading && messages.length === 0 ? ( @@ -444,7 +460,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack showsVerticalScrollIndicator={false} ListEmptyComponent={renderEmpty} ListFooterComponent={renderFooter} - onEndReached={loadMore} + onEndReached={onEndReached} onEndReachedThreshold={0.3} refreshControl={ void }> = ({ onBack } return m.system_type === type.key; }).length; + const isActive = activeType === type.key; return ( setActiveType(type.key)} + activeOpacity={0.8} > {type.title} {count > 0 && ( - - {count} - + + + {count} + + )} ); @@ -505,7 +525,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack {/* 消息列表 */} - {loading ? ( + {isLoading && messages.length === 0 ? ( @@ -518,7 +538,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack showsVerticalScrollIndicator={false} ListEmptyComponent={renderEmpty} ListFooterComponent={renderFooter} - onEndReached={loadMore} + onEndReached={onEndReached} onEndReachedThreshold={0.3} refreshControl={ void }> = ({ onBack ); }; -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.background.default, - }, - filterContainer: { - flexDirection: 'row', - paddingHorizontal: spacing.lg, - paddingVertical: spacing.sm, - backgroundColor: colors.background.paper, - borderBottomWidth: 1, - borderBottomColor: colors.divider, - }, - filterContainerWideWeb: { - paddingHorizontal: spacing.xl, - paddingVertical: spacing.md, - justifyContent: 'center', - backgroundColor: colors.background.paper, - }, - filterTag: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - marginRight: spacing.sm, - borderRadius: 16, - backgroundColor: colors.background.default, - }, - filterTagWide: { - paddingHorizontal: spacing.lg, - paddingVertical: spacing.md, - marginRight: spacing.md, - }, - filterTagActive: { - backgroundColor: colors.primary.light + '30', - }, - filterCount: { - marginLeft: spacing.xs, - }, - // Header 样式 - header: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: spacing.lg, - paddingVertical: spacing.md, - backgroundColor: colors.background.paper, - borderBottomWidth: 1, - borderBottomColor: colors.divider, - }, - headerWide: { - paddingHorizontal: spacing.xl, - paddingVertical: spacing.lg, - }, - backButton: { - width: 40, - height: 40, - justifyContent: 'center', - alignItems: 'flex-start', - }, - backButtonPlaceholder: { - width: 40, - }, - headerTitle: { - fontSize: 18, - fontWeight: '600', - color: colors.text.primary, - }, - headerTitleWide: { - fontSize: 20, - }, - listContent: { - flexGrow: 1, - }, - listContentWide: { - paddingHorizontal: spacing.xl, - }, - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - paddingVertical: spacing.xl * 2, - }, - loadingMore: { - flexDirection: 'row', - justifyContent: 'center', - alignItems: 'center', - paddingVertical: spacing.lg, - }, - loadingMoreText: { - marginLeft: spacing.sm, - }, - emptyContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, - emptyContainerWeb: { - minHeight: 500, - justifyContent: 'center', - paddingTop: 100, - }, -}); +function createNotificationsStyles(colors: AppColors) { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.default, + }, + // Header 样式 - 美化版 + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + backgroundColor: colors.background.paper, + ...shadows.sm, + }, + headerWide: { + paddingHorizontal: spacing.xl, + paddingVertical: spacing.lg, + }, + headerTitleContainer: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + justifyContent: 'center', + }, + headerTitle: { + fontSize: 18, + fontWeight: '600', + color: colors.text.primary, + }, + headerTitleWide: { + fontSize: 20, + }, + unreadBadge: { + backgroundColor: colors.primary.main, + borderRadius: 10, + paddingHorizontal: 6, + paddingVertical: 2, + marginLeft: spacing.sm, + minWidth: 20, + alignItems: 'center', + justifyContent: 'center', + }, + unreadBadgeText: { + color: colors.text.inverse, + fontSize: 11, + fontWeight: '600', + }, + backButton: { + width: 40, + height: 40, + justifyContent: 'center', + alignItems: 'flex-start', + }, + backButtonPlaceholder: { + width: 40, + }, + // 分类筛选 - 美化版(使用分段式设计) + filterContainer: { + flexDirection: 'row', + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + backgroundColor: colors.background.paper, + }, + filterContainerWideWeb: { + paddingHorizontal: spacing.xl, + paddingVertical: spacing.md, + justifyContent: 'center', + backgroundColor: colors.background.paper, + }, + filterTag: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + marginRight: spacing.xs, + borderRadius: borderRadius.lg, + backgroundColor: colors.background.default, + borderWidth: 1, + borderColor: 'transparent', + }, + filterTagWide: { + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + marginRight: spacing.sm, + }, + filterTagActive: { + backgroundColor: colors.primary.main, + borderColor: colors.primary.main, + }, + filterTagTextActive: { + fontWeight: '600', + }, + filterCount: { + marginLeft: spacing.xs, + backgroundColor: colors.primary.light + '40', + paddingHorizontal: 6, + paddingVertical: 1, + borderRadius: 8, + minWidth: 18, + alignItems: 'center', + }, + filterCountActive: { + backgroundColor: 'rgba(255, 255, 255, 0.25)', + }, + listContent: { + flexGrow: 1, + paddingTop: spacing.md, + paddingBottom: spacing.lg, + }, + listContentWide: { + paddingHorizontal: spacing.xl, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingVertical: spacing.xl * 2, + }, + loadingMore: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + paddingVertical: spacing.lg, + }, + loadingMoreText: { + marginLeft: spacing.sm, + }, + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingTop: spacing['3xl'], + }, + emptyContainerWeb: { + minHeight: 500, + justifyContent: 'center', + paddingTop: 100, + }, + }); +} diff --git a/src/screens/message/PrivateChatInfoScreen.tsx b/src/screens/message/PrivateChatInfoScreen.tsx index 0a8c5e3..2b8ace7 100644 --- a/src/screens/message/PrivateChatInfoScreen.tsx +++ b/src/screens/message/PrivateChatInfoScreen.tsx @@ -4,7 +4,7 @@ * 参考QQ和微信的实现,提供聊天管理功能 */ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { View, StyleSheet, @@ -14,10 +14,9 @@ import { Switch, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useRouter, useLocalSearchParams } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme'; +import { spacing, borderRadius, shadows, useAppColors, type AppColors } from '../../theme'; import { useAuthStore } from '../../stores'; import { authService } from '../../services/authService'; import { messageService } from '../../services/messageService'; @@ -30,16 +29,23 @@ import { messageManager } from '../../stores/messageManager'; import { userManager } from '../../stores/userManager'; import { Avatar, Text, Button, Loading, Divider } from '../../components/common'; import { User } from '../../types'; -import { RootStackParamList, MessageStackParamList } from '../../navigation/types'; -import { navigationService } from '../../infrastructure/navigation/navigationService'; - -type NavigationProp = NativeStackNavigationProp; -type PrivateChatInfoRouteProp = RouteProp; +import * as hrefs from '../../navigation/hrefs'; +import { AppBackButton } from '../../components/common'; const PrivateChatInfoScreen: React.FC = () => { - const navigation = useNavigation(); - const route = useRoute(); - const { conversationId, userId, userName, userAvatar } = route.params; + const colors = useAppColors(); + const styles = useMemo(() => createPrivateChatInfoStyles(colors), [colors]); + const router = useRouter(); + const raw = useLocalSearchParams<{ + conversationId?: string; + userId?: string; + userName?: string; + userAvatar?: string; + }>(); + const conversationId = raw.conversationId ?? ''; + const userId = raw.userId ?? ''; + const userName = raw.userName; + const userAvatar = raw.userAvatar; const { currentUser } = useAuthStore(); // 用户信息状态 @@ -150,8 +156,7 @@ const PrivateChatInfoScreen: React.FC = () => { // 查看用户资料 const handleViewProfile = () => { - // 使用导航服务跳转到用户资料页面 - navigationService.navigate('UserProfile', { userId }); + router.push(hrefs.hrefUserProfile(userId)); }; // 举报/投诉 @@ -219,7 +224,7 @@ const PrivateChatInfoScreen: React.FC = () => { } setIsBlocked(true); messageManager.removeConversation(conversationId); - navigation.navigate('MessageList' as any); + router.replace(hrefs.hrefMessages()); Alert.alert('已拉黑', '你已成功拉黑该用户'); } catch (error) { Alert.alert('错误', '拉黑失败,请稍后重试'); @@ -245,7 +250,7 @@ const PrivateChatInfoScreen: React.FC = () => { await messageService.deleteConversationForSelf(conversationId); messageManager.removeConversation(conversationId); // 返回消息列表 - navigation.navigate('MessageList' as any); + router.replace(hrefs.hrefMessages()); } catch (error) { const msg = error instanceof ApiError ? error.message : '删除聊天失败'; Alert.alert('错误', msg); @@ -274,7 +279,7 @@ const PrivateChatInfoScreen: React.FC = () => { value={value} onValueChange={onValueChange} trackColor={{ false: '#E0E0E0', true: colors.primary.light }} - thumbColor={value ? colors.primary.main : '#F5F5F5'} + thumbColor={value ? colors.primary.main : colors.background.default} /> ); @@ -307,7 +312,12 @@ const PrivateChatInfoScreen: React.FC = () => { if (loading) { return ( - + + + router.back()} /> + 私聊信息 + + ); @@ -320,7 +330,12 @@ const PrivateChatInfoScreen: React.FC = () => { }; return ( - + + + router.back()} /> + 私聊信息 + + { ); }; -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.background.default, - }, - scrollView: { - flex: 1, - }, - scrollContent: { - paddingBottom: spacing.xl, - }, - headerCard: { - backgroundColor: colors.background.paper, - marginHorizontal: spacing.md, - marginTop: spacing.md, - borderRadius: borderRadius.lg, - padding: spacing.lg, - ...shadows.sm, - }, - userHeader: { - flexDirection: 'row', - alignItems: 'center', - }, - userInfo: { - flex: 1, - marginLeft: spacing.md, - }, - userName: { - fontWeight: '600', - marginBottom: spacing.xs, - }, - section: { - marginTop: spacing.lg, - paddingHorizontal: spacing.md, - }, - sectionTitle: { - marginBottom: spacing.sm, - marginLeft: spacing.sm, - fontWeight: '500', - }, - card: { - backgroundColor: colors.background.paper, - borderRadius: borderRadius.lg, - ...shadows.sm, - overflow: 'hidden', - }, - settingItem: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: spacing.sm, - paddingHorizontal: spacing.md, - }, - settingIconContainer: { - width: 36, - height: 36, - borderRadius: borderRadius.md, - backgroundColor: colors.primary.light + '20', - justifyContent: 'center', - alignItems: 'center', - marginRight: spacing.md, - }, - settingIconDanger: { - backgroundColor: colors.error.light + '20', - }, - settingTitle: { - flex: 1, - }, - dangerText: { - color: colors.error.main, - }, - divider: { - marginLeft: spacing.xl + 36, - width: 'auto', - marginVertical: spacing.xs, - }, - deleteButton: { - marginTop: spacing.sm, - }, -}); +function createPrivateChatInfoStyles(colors: AppColors) { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.default, + }, + pageHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + backgroundColor: colors.background.paper, + borderBottomWidth: 1, + borderBottomColor: colors.divider, + }, + pageTitle: { + fontWeight: '600', + }, + pageHeaderPlaceholder: { + width: 40, + height: 40, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingBottom: spacing.xl, + }, + headerCard: { + backgroundColor: colors.background.paper, + marginHorizontal: spacing.md, + marginTop: spacing.md, + borderRadius: borderRadius.lg, + padding: spacing.lg, + ...shadows.sm, + }, + userHeader: { + flexDirection: 'row', + alignItems: 'center', + }, + userInfo: { + flex: 1, + marginLeft: spacing.md, + }, + userName: { + fontWeight: '600', + marginBottom: spacing.xs, + }, + section: { + marginTop: spacing.lg, + paddingHorizontal: spacing.md, + }, + sectionTitle: { + marginBottom: spacing.sm, + marginLeft: spacing.sm, + fontWeight: '500', + }, + card: { + backgroundColor: colors.background.paper, + borderRadius: borderRadius.lg, + ...shadows.sm, + overflow: 'hidden', + }, + settingItem: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + }, + settingIconContainer: { + width: 36, + height: 36, + borderRadius: borderRadius.md, + backgroundColor: colors.primary.light + '20', + justifyContent: 'center', + alignItems: 'center', + marginRight: spacing.md, + }, + settingIconDanger: { + backgroundColor: colors.error.light + '20', + }, + settingTitle: { + flex: 1, + }, + dangerText: { + color: colors.error.main, + }, + divider: { + marginLeft: spacing.xl + 36, + width: 'auto', + marginVertical: spacing.xs, + }, + deleteButton: { + marginTop: spacing.sm, + }, + }); +} export default PrivateChatInfoScreen; diff --git a/src/screens/message/components/ChatScreen/ChatHeader.tsx b/src/screens/message/components/ChatScreen/ChatHeader.tsx index 437a6d1..6945dac 100644 --- a/src/screens/message/components/ChatScreen/ChatHeader.tsx +++ b/src/screens/message/components/ChatScreen/ChatHeader.tsx @@ -5,12 +5,11 @@ import React, { useMemo } from 'react'; import { View, TouchableOpacity, StyleSheet } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { Avatar, Text } from '../../../../components/common'; -import { colors, spacing } from '../../../../theme'; -import { chatScreenStyles as baseStyles } from './styles'; -import { useResponsive, useBreakpointGTE } from '../../../../hooks/useResponsive'; +import { Avatar, Text, AppBackButton } from '../../../../components/common'; +import { useAppColors, spacing } from '../../../../theme'; +import { useChatScreenStyles } from './styles'; +import { useBreakpointGTE } from '../../../../hooks/useResponsive'; import { ChatHeaderProps } from './types'; export const ChatHeader: React.FC = ({ @@ -25,8 +24,10 @@ export const ChatHeader: React.FC = ({ onGroupInfoPress, isWideScreen: propIsWideScreen, }) => { + const colors = useAppColors(); + const baseStyles = useChatScreenStyles(); + // 响应式布局 - const { width } = useResponsive(); // 使用 768px 作为大屏幕和小屏幕的分界线 const isWideScreenHook = useBreakpointGTE('lg'); // 优先使用 props 传入的 isWideScreen,否则使用 hook @@ -59,7 +60,7 @@ export const ChatHeader: React.FC = ({ fontSize: isWideScreen ? 13 : 12, }, }); - }, [isWideScreen]); + }, [isWideScreen, baseStyles]); // 获取显示标题 const getDisplayTitle = () => { @@ -70,19 +71,9 @@ export const ChatHeader: React.FC = ({ }; return ( - + - {/* 大屏幕(>= 768px)时隐藏返回按钮 */} - {!isWideScreen ? ( - - - - ) : ( - - )} + = ({ style={styles.moreButton} onPress={onGroupInfoPress} > - + ) : ( - + )} - + ); }; diff --git a/src/screens/message/components/ChatScreen/ChatInput.tsx b/src/screens/message/components/ChatScreen/ChatInput.tsx index 79858c8..f48d46d 100644 --- a/src/screens/message/components/ChatScreen/ChatInput.tsx +++ b/src/screens/message/components/ChatScreen/ChatInput.tsx @@ -4,11 +4,11 @@ */ import React, { useMemo } from 'react'; -import { View, TouchableOpacity, TextInput, ActivityIndicator } from 'react-native'; +import { View, TouchableOpacity, TextInput, ActivityIndicator, ScrollView, StyleSheet, Image as RNImage } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { Text } from '../../../../components/common'; -import { colors } from '../../../../theme'; -import { chatScreenStyles as baseStyles } from './styles'; +import { useAppColors } from '../../../../theme'; +import { useChatScreenStyles } from './styles'; import { useBreakpointGTE } from '../../../../hooks/useResponsive'; import { ChatInputProps, GroupMessage, SenderInfo } from './types'; import { extractTextFromSegments } from '../../../../types/dto'; @@ -24,7 +24,7 @@ export const ChatInput: React.FC { + const colors = useAppColors(); + const styles = useChatScreenStyles(); const isDisabled = isGroupChat && isMuted; - + // 获取当前用户ID const currentUserId = currentUser?.id || ''; - + // 响应式布局 const isWideScreen = useBreakpointGTE('lg'); - const styles = baseStyles; const inputContainerStyle = useMemo(() => ([ styles.inputContainer, isWideScreen ? { maxWidth: 900, alignSelf: 'center' as const, width: '100%' as const } : null, ]), [isWideScreen, styles.inputContainer]); + const canSend = + (!!inputText.trim() || pendingAttachments.length > 0) && + !isComposerBusy && + !isDisabled; + // 回复预览组件 - 定义在内部以访问 styles const ReplyPreview: React.FC<{ replyingTo: GroupMessage; @@ -69,7 +77,16 @@ export const ChatInput: React.FC 回复 {senderInfo.nickname} - {replyingTo.segments?.some(seg => seg.type === 'image') ? '[图片]' : extractTextFromSegments(replyingTo.segments)} + {(() => { + const segs = replyingTo.segments; + const imgCount = segs?.filter(seg => seg.type === 'image').length ?? 0; + const raw = extractTextFromSegments(segs); + if (imgCount > 1) { + const textPart = raw.replace(/\[图片\]/g, '').replace(/\s+/g, ' ').trim(); + return textPart ? `${textPart} [${imgCount}张图片]` : `[${imgCount}张图片]`; + } + return raw; + })()} @@ -106,6 +123,30 @@ export const ChatInput: React.FC{restrictionHint} )} + + {pendingAttachments.length > 0 && ( + + {pendingAttachments.map(item => ( + + + {!isDisabled && onRemovePendingAttachment ? ( + onRemovePendingAttachment(item.id)} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + + + ) : null} + + ))} + + )} - {inputText.trim() && !sending && !isDisabled ? ( + {canSend ? ( - ) : sending ? ( + ) : isComposerBusy && !isDisabled ? ( = ({ onInsertSticker, onClose, }) => { + const colors = useAppColors(); + const baseStyles = useChatScreenStyles(); const [activeTab, setActiveTab] = useState('emoji'); const [stickers, setStickers] = useState([]); const [loadingStickers, setLoadingStickers] = useState(false); @@ -82,7 +84,7 @@ export const EmojiPanel: React.FC = ({ lineHeight: emojiSize.size + 6, }, }); - }, [emojiSize, emojiColumns]); + }, [emojiSize, emojiColumns, baseStyles]); // 加载自定义表情 const loadStickers = useCallback(async () => { diff --git a/src/screens/message/components/ChatScreen/GroupInfoPanel.tsx b/src/screens/message/components/ChatScreen/GroupInfoPanel.tsx index c89ff3b..aad0407 100644 --- a/src/screens/message/components/ChatScreen/GroupInfoPanel.tsx +++ b/src/screens/message/components/ChatScreen/GroupInfoPanel.tsx @@ -5,7 +5,7 @@ * 实现手机端群信息界面的核心功能 */ -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useEffect, useState, useCallback, useMemo } from 'react'; import { View, StyleSheet, @@ -17,7 +17,7 @@ import { } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { Avatar, Text } from '../../../../components/common'; -import { colors, spacing, fontSizes, shadows } from '../../../../theme'; +import { useAppColors, spacing, fontSizes, shadows, type AppColors } from '../../../../theme'; import { GroupMemberResponse, GroupResponse, GroupAnnouncementResponse } from '../../../../types/dto'; import { useBreakpointGTE } from '../../../../hooks/useResponsive'; import { groupManager } from '../../../../stores/groupManager'; @@ -53,6 +53,8 @@ export const GroupInfoPanel: React.FC = ({ onInviteMembers, onViewAllMembers, }) => { + const colors = useAppColors(); + const styles = useMemo(() => createGroupInfoPanelStyles(colors), [colors]); const isWideScreen = useBreakpointGTE('lg'); const slideAnim = React.useRef(new Animated.Value(PANEL_WIDTH)).current; const fadeAnim = React.useRef(new Animated.Value(0)).current; @@ -363,7 +365,8 @@ export const GroupInfoPanel: React.FC = ({ ); }; -const styles = StyleSheet.create({ +function createGroupInfoPanelStyles(colors: AppColors) { + return StyleSheet.create({ overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0, 0, 0, 0.3)', @@ -378,7 +381,7 @@ const styles = StyleSheet.create({ top: 0, bottom: 0, width: PANEL_WIDTH, - backgroundColor: '#FFF', + backgroundColor: colors.background.paper, zIndex: 101, ...shadows.lg, }, @@ -389,12 +392,12 @@ const styles = StyleSheet.create({ paddingHorizontal: spacing.md, paddingVertical: spacing.md, borderBottomWidth: 1, - borderBottomColor: '#E8E8E8', + borderBottomColor: colors.divider, }, headerTitle: { fontSize: fontSizes.xl, fontWeight: '600', - color: '#333', + color: colors.text.primary, }, closeButton: { padding: spacing.xs, @@ -409,12 +412,12 @@ const styles = StyleSheet.create({ groupName: { fontSize: fontSizes.xl, fontWeight: '600', - color: '#333', + color: colors.text.primary, marginTop: spacing.md, }, memberCount: { fontSize: fontSizes.md, - color: '#999', + color: colors.text.secondary, marginTop: spacing.xs, }, joinType: { @@ -442,7 +445,7 @@ const styles = StyleSheet.create({ }, divider: { height: 1, - backgroundColor: '#E8E8E8', + backgroundColor: colors.divider, marginHorizontal: spacing.md, }, section: { @@ -456,11 +459,11 @@ const styles = StyleSheet.create({ sectionTitle: { fontSize: fontSizes.md, fontWeight: '600', - color: '#333', + color: colors.text.primary, marginLeft: spacing.xs, }, noticeBox: { - backgroundColor: '#FFF9E6', + backgroundColor: colors.chat.tipBg, padding: spacing.md, borderRadius: 8, borderLeftWidth: 3, @@ -468,23 +471,23 @@ const styles = StyleSheet.create({ }, noticeText: { fontSize: fontSizes.sm, - color: '#666', + color: colors.text.secondary, lineHeight: 20, }, noticeTime: { fontSize: fontSizes.xs, - color: '#999', + color: colors.text.hint, marginTop: spacing.xs, textAlign: 'right', }, descriptionBox: { - backgroundColor: '#F5F7FA', + backgroundColor: colors.chat.surfaceMuted, padding: spacing.md, borderRadius: 8, }, description: { fontSize: fontSizes.sm, - color: '#666', + color: colors.text.secondary, lineHeight: 20, }, memberList: { @@ -518,7 +521,7 @@ const styles = StyleSheet.create({ }, memberName: { fontSize: fontSizes.md, - color: '#333', + color: colors.text.primary, flex: 1, }, ownerBadge: { @@ -541,7 +544,7 @@ const styles = StyleSheet.create({ }, moreMembers: { fontSize: fontSizes.sm, - color: '#999', + color: colors.text.secondary, textAlign: 'center', paddingVertical: spacing.sm, }, @@ -550,15 +553,15 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', paddingVertical: spacing.sm, borderBottomWidth: 1, - borderBottomColor: '#F0F0F0', + borderBottomColor: colors.chat.borderHairline, }, infoLabel: { fontSize: fontSizes.sm, - color: '#999', + color: colors.text.secondary, }, infoValue: { fontSize: fontSizes.sm, - color: '#333', + color: colors.text.primary, }, actionSection: { padding: spacing.md, @@ -588,6 +591,7 @@ const styles = StyleSheet.create({ leaveButtonText: { color: colors.error.main, }, -}); + }); +} export default GroupInfoPanel; diff --git a/src/screens/message/components/ChatScreen/LongPressMenu.tsx b/src/screens/message/components/ChatScreen/LongPressMenu.tsx index 3c21ddf..58257b2 100644 --- a/src/screens/message/components/ChatScreen/LongPressMenu.tsx +++ b/src/screens/message/components/ChatScreen/LongPressMenu.tsx @@ -7,14 +7,15 @@ import { View, Modal, TouchableOpacity, - TouchableWithoutFeedback, + Pressable, Animated, Alert, Clipboard, Dimensions, + Platform, } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { chatScreenStyles as styles } from './styles'; +import { useChatScreenStyles } from './styles'; import { LongPressMenuProps } from './types'; import { RECALL_TIME_LIMIT } from './constants'; import { extractTextFromSegments, ImageSegmentData } from '../../../../types/dto'; @@ -33,36 +34,44 @@ export const LongPressMenu: React.FC = ({ onDelete, onAddSticker, }) => { + const styles = useChatScreenStyles(); const scaleAnimation = useRef(new Animated.Value(0)).current; const opacityAnimation = useRef(new Animated.Value(0)).current; + const blurActiveElementOnWeb = () => { + if (Platform.OS !== 'web') return; + const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined; + active?.blur?.(); + }; // 显示动画 - 缩放弹出 useEffect(() => { if (visible) { + blurActiveElementOnWeb(); Animated.parallel([ Animated.spring(scaleAnimation, { toValue: 1, - useNativeDriver: true, + useNativeDriver: Platform.OS !== 'web', friction: 8, tension: 100, }), Animated.timing(opacityAnimation, { toValue: 1, duration: 150, - useNativeDriver: true, + useNativeDriver: Platform.OS !== 'web', }), ]).start(); } else { + blurActiveElementOnWeb(); Animated.parallel([ Animated.timing(scaleAnimation, { toValue: 0, duration: 150, - useNativeDriver: true, + useNativeDriver: Platform.OS !== 'web', }), Animated.timing(opacityAnimation, { toValue: 0, duration: 150, - useNativeDriver: true, + useNativeDriver: Platform.OS !== 'web', }), ]).start(); } @@ -242,11 +251,18 @@ export const LongPressMenu: React.FC = ({ visible={visible} transparent animationType="none" + onShow={blurActiveElementOnWeb} onRequestClose={onClose} > - - - + { + blurActiveElementOnWeb(); + onClose(); + }} + style={styles.qqMenuOverlay} + > + + {}}> = ({ ))} - + - + ); }; diff --git a/src/screens/message/components/ChatScreen/MentionPanel.tsx b/src/screens/message/components/ChatScreen/MentionPanel.tsx index b1829af..18c1e4a 100644 --- a/src/screens/message/components/ChatScreen/MentionPanel.tsx +++ b/src/screens/message/components/ChatScreen/MentionPanel.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { View, ScrollView, TouchableOpacity } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { Avatar, Text } from '../../../../components/common'; -import { chatScreenStyles as styles } from './styles'; +import { useChatScreenStyles } from './styles'; import { MentionPanelProps } from './types'; export const MentionPanel: React.FC = ({ @@ -18,6 +18,7 @@ export const MentionPanel: React.FC = ({ onMentionAll, onClose, }) => { + const styles = useChatScreenStyles(); // 过滤成员列表 const filteredMembers = members.filter(member => { if (member.user_id === currentUserId) return false; // 排除自己 diff --git a/src/screens/message/components/ChatScreen/MessageBubble.tsx b/src/screens/message/components/ChatScreen/MessageBubble.tsx index 8f39a5b..5625c1a 100644 --- a/src/screens/message/components/ChatScreen/MessageBubble.tsx +++ b/src/screens/message/components/ChatScreen/MessageBubble.tsx @@ -4,25 +4,23 @@ * 支持响应式布局(宽屏下优化显示) */ -import React, { useRef, useMemo } from 'react'; +import React, { useRef, useMemo, useCallback } from 'react'; import { View, TouchableOpacity, Image, - findNodeHandle, - UIManager, GestureResponderEvent, StyleSheet, Dimensions, } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { Avatar, Text, ImageGridItem } from '../../../../components/common'; -import { chatScreenStyles as baseStyles } from './styles'; +import { useChatScreenStyles } from './styles'; import { useResponsive, useBreakpointGTE } from '../../../../hooks/useResponsive'; import { MessageBubbleProps, SenderInfo, MenuPosition, GroupMessage } from './types'; import { MessageSegmentsRenderer } from './SegmentRenderer'; import { MessageSegment, ImageSegmentData, extractTextFromSegments } from '../../../../types/dto'; - +import { SwipeableMessageBubble } from './SwipeableMessageBubble'; // 获取屏幕宽度 const { width: SCREEN_WIDTH } = Dimensions.get('window'); @@ -50,10 +48,13 @@ export const MessageBubble: React.FC = ({ formatTime, shouldShowTime, onImagePress, + onReply, + onReplyPress, }) => { const bubbleRef = useRef(null); const pressPositionRef = useRef({ x: 0, y: 0 }); - + const baseStyles = useChatScreenStyles(); + // 响应式布局 const { width } = useResponsive(); const isWideScreen = useBreakpointGTE('lg'); @@ -81,7 +82,7 @@ export const MessageBubble: React.FC = ({ height: isWideScreen ? 280 : 220, }, }); - }, [maxContentWidth, isWideScreen]); + }, [maxContentWidth, isWideScreen, baseStyles]); // 系统通知消息特殊处理 // 支持两种判断方式: @@ -99,11 +100,14 @@ export const MessageBubble: React.FC = ({ // 检查是否有消息链(必须使用 segments 格式) // 安全检查:确保 segments 是数组 const segments = Array.isArray(message.segments) ? message.segments : []; - const hasSegments = segments.length > 0; - // 检查是否是图片消息 + const hasSegments = segments.length > 0; + // 检查是否是图片消息 const isImage = Array.isArray(segments) && segments.some(s => s.type === 'image'); - // 检查是否是纯图片消息(只有图片segment,没有文本等其他内容) - const isPureImageMessage = isImage && segments.every(s => s.type === 'image' || !s.type); + // 纯图片:除 reply 外均为 image(支持多图同条) + const segmentsWithoutReply = segments.filter(s => s.type !== 'reply'); + const isPureImageMessage = + segmentsWithoutReply.length > 0 && + segmentsWithoutReply.every(s => s.type === 'image'); // 提取所有图片 segments const imageSegments = segments @@ -112,7 +116,7 @@ export const MessageBubble: React.FC = ({ const data = s.data as ImageSegmentData; return { id: `img-${message.id}-${idx}`, - url: data.url, + url: data.url || data.thumbnail_url || '', thumbnail_url: data.thumbnail_url, width: data.width, height: data.height, @@ -120,7 +124,6 @@ export const MessageBubble: React.FC = ({ }); // 检查当前消息是否被选中 - const isSelected = selectedMessageId === String(message.id); // 获取发送者信息(群聊)- 供子组件使用 const getSenderInfo = React.useCallback((senderId: string): SenderInfo => { @@ -210,47 +213,62 @@ export const MessageBubble: React.FC = ({ // 处理长按并获取位置 const handleLongPress = () => { if (bubbleRef.current) { - const node = findNodeHandle(bubbleRef.current); - if (node) { - UIManager.measureInWindow(node, (x, y, width, height) => { - const position: MenuPosition = { - x, - y, - width, - height, - pressX: pressPositionRef.current.x, - pressY: pressPositionRef.current.y, - }; - onLongPress(message, position); - }); - } else { - onLongPress(message); - } + bubbleRef.current.measureInWindow((x, y, width, height) => { + const position: MenuPosition = { + x, + y, + width, + height, + pressX: pressPositionRef.current.x, + pressY: pressPositionRef.current.y, + }; + onLongPress(message, position); + }); } else { onLongPress(message); } }; + const renderBubbleShell = useCallback( + ( + child: React.ReactNode, + innerExtra: (object | undefined | null | false)[] = [] + ) => ( + + + {child} + + + ), + [isMe, styles] + ); + // 渲染消息内容 const renderMessageContent = () => { // 撤回消息 if (isRecalled) { - return ( - - - 消息已撤回 - - + return renderBubbleShell( + + 消息已撤回 + , + [styles.recalledBubble] ); } @@ -277,72 +295,70 @@ export const MessageBubble: React.FC = ({ // 如果没有 segments,显示空内容或错误提示 if (!hasSegments) { - return ( - - - [消息格式错误] - - + return renderBubbleShell( + + [消息格式错误] + ); } // 检查是否有回复,用于调整气泡样式 const hasReply = segments.some(s => s.type === 'reply'); - return ( - - undefined} - onReplyPress={() => undefined} - onImagePress={(url) => { - // 查找点击的图片索引 - const clickIndex = imageSegments.findIndex(img => img.url === url); - if (clickIndex !== -1 && onImagePress) { - onImagePress(imageSegments, clickIndex); - } - }} - onImageLongPress={(position?: { x: number; y: number }) => { - // 图片长按触发和消息气泡一样的菜单 - // 如果有位置信息,直接使用;否则使用气泡位置 - if (position) { - onLongPress(message, { - x: 0, - y: 0, - width: 0, - height: 0, - pressX: position.x, - pressY: position.y, - }); - } else { - handleLongPress(); - } - }} - onLinkPress={() => undefined} - /> - + return renderBubbleShell( + undefined} + onReplyPress={onReplyPress} + onImagePress={(url) => { + // 查找点击的图片索引 + const clickIndex = imageSegments.findIndex( + img => img.url === url || img.thumbnail_url === url + ); + if (clickIndex !== -1 && onImagePress) { + onImagePress(imageSegments, clickIndex); + } + }} + onImageLongPress={(position?: { x: number; y: number }) => { + // 图片长按触发和消息气泡一样的菜单 + // 如果有位置信息,直接使用;否则使用气泡位置 + if (position) { + onLongPress(message, { + x: 0, + y: 0, + width: 0, + height: 0, + pressX: position.x, + pressY: position.y, + }); + } else { + handleLongPress(); + } + }} + onLinkPress={() => undefined} + />, + [hasReply && segmentStyles.replyBubble, isPureImageMessage && segmentStyles.pureImageBubble] ); }; + // 处理滑动回复 + const handleSwipeReply = useCallback(() => { + if (onReply) { + onReply(message); + } + }, [onReply, message]); + // 系统通知消息渲染 if (isSystemNotice) { return ( @@ -361,7 +377,8 @@ export const MessageBubble: React.FC = ({ ); } - return ( + // 消息内容渲染 + const messageContent = ( = ({ )} - + {/* 群聊模式:显示发送者昵称 */} {isGroupChat && !isMe && senderInfo && ( {senderInfo.nickname} @@ -444,6 +461,17 @@ export const MessageBubble: React.FC = ({ ); + + // 使用 SwipeableMessageBubble 包裹消息内容 + return ( + + {messageContent} + + ); }; // Segment 消息的特殊样式 - 自适应宽度 diff --git a/src/screens/message/components/ChatScreen/MorePanel.tsx b/src/screens/message/components/ChatScreen/MorePanel.tsx index 2dcbe04..af957a2 100644 --- a/src/screens/message/components/ChatScreen/MorePanel.tsx +++ b/src/screens/message/components/ChatScreen/MorePanel.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { View, TouchableOpacity } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { Text } from '../../../../components/common'; -import { chatScreenStyles as styles } from './styles'; +import { useChatScreenStyles } from './styles'; import { MORE_ACTIONS } from './constants'; import { MorePanelProps } from './types'; @@ -14,6 +14,7 @@ export const MorePanel: React.FC = ({ onAction, disabledActionIds = [], }) => { + const styles = useChatScreenStyles(); const disabledSet = new Set(disabledActionIds); return ( diff --git a/src/screens/message/components/ChatScreen/SegmentRenderer.tsx b/src/screens/message/components/ChatScreen/SegmentRenderer.tsx index a7d92e0..a80b937 100644 --- a/src/screens/message/components/ChatScreen/SegmentRenderer.tsx +++ b/src/screens/message/components/ChatScreen/SegmentRenderer.tsx @@ -3,7 +3,7 @@ * 用于渲染消息链中的各种 Segment 类型 */ -import React, { useState, useEffect, useCallback, useRef } from 'react'; +import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { View, Text, @@ -31,13 +31,51 @@ import { UserDTO, extractTextFromSegments, } from '../../../../types/dto'; -import { colors, spacing } from '../../../../theme'; +import { spacing, useAppColors, type AppColors } from '../../../../theme'; import { SenderInfo } from './types'; const IMAGE_MAX_WIDTH = 200; const IMAGE_MAX_HEIGHT = 260; const IMAGE_FALLBACK_SIZE = { width: 200, height: 150 }; +/** 将非 reply 的 segments 分成:行内文案/@、连续图片组、其它块级(音视频文件等) */ +type ContentChunk = + | { kind: 'inline'; parts: MessageSegment[] } + | { kind: 'images'; parts: MessageSegment[] } + | { kind: 'block'; segment: MessageSegment }; + +function partitionMessageSegments(segments: MessageSegment[]): ContentChunk[] { + const out: ContentChunk[] = []; + let inlineBuf: MessageSegment[] = []; + const flushInline = () => { + if (inlineBuf.length) { + out.push({ kind: 'inline', parts: [...inlineBuf] }); + inlineBuf = []; + } + }; + + for (const s of segments) { + if (s.type === 'image') { + flushInline(); + const last = out[out.length - 1]; + if (last?.kind === 'images') { + last.parts.push(s); + } else { + out.push({ kind: 'images', parts: [s] }); + } + } else if (s.type === 'video' || s.type === 'file' || s.type === 'link' || s.type === 'voice') { + flushInline(); + out.push({ kind: 'block', segment: s }); + } else if (s.type === 'text' || s.type === 'at' || s.type === 'face') { + inlineBuf.push(s); + } else { + inlineBuf.push(s); + } + } + flushInline(); + return out; +} + // Segment 渲染器 Props export interface SegmentRendererProps { segment: MessageSegment; @@ -104,12 +142,11 @@ export const renderSegment = ( /** * 渲染文本 Segment */ -const renderTextSegment = (data: TextSegmentData, isMe: boolean): React.ReactNode => { - // 兼容 content 和 text 两种字段 +const TextSegmentBody: React.FC<{ data: TextSegmentData; isMe: boolean }> = ({ data, isMe }) => { + const styles = useSegmentStyles(); const text = data.content ?? data.text ?? ''; return ( @@ -118,6 +155,11 @@ const renderTextSegment = (data: TextSegmentData, isMe: boolean): React.ReactNod ); }; +const renderTextSegment = (data: TextSegmentData, isMe: boolean): React.ReactNode => { + const key = `text-${(data.content ?? data.text ?? '').substring(0, 10)}`; + return ; +}; + /** * 渲染图片 Segment */ @@ -128,14 +170,25 @@ const ImageSegment: React.FC<{ onImagePress?: (url: string) => void; onImageLongPress?: (position?: { x: number; y: number }) => void; }> = ({ data, isMe, onImagePress, onImageLongPress }) => { + const styles = useSegmentStyles(); const pressPositionRef = useRef({ x: 0, y: 0 }); const [dimensions, setDimensions] = useState<{ width: number; height: number } | null>(null); const [loadError, setLoadError] = useState(false); - const imageUrl = data.thumbnail_url || data.url; + const [currentImageUri, setCurrentImageUri] = useState(data.thumbnail_url || data.url || ''); + const imageUrl = currentImageUri; + const pressUrl = data.url || data.thumbnail_url || ''; + const stableImageKey = `${data.url || ''}|${data.thumbnail_url || ''}|${data.width || 0}x${data.height || 0}`; // 如果没有有效的图片URL,显示图片占位符 const hasValidUrl = !!imageUrl && imageUrl.trim() !== ''; + useEffect(() => { + // 切换消息图片时重置状态,避免列表复用导致旧状态污染 + setCurrentImageUri(data.thumbnail_url || data.url || ''); + setDimensions(null); + setLoadError(false); + }, [data.thumbnail_url, data.url]); + useEffect(() => { // 重置状态 setLoadError(false); @@ -169,9 +222,7 @@ const ImageSegment: React.FC<{ // 添加超时处理,防止高分辨率图片加载卡住 const timeoutId = setTimeout(() => { - if (!dimensions) { - setDimensions(IMAGE_FALLBACK_SIZE); - } + setDimensions(prev => prev || IMAGE_FALLBACK_SIZE); }, 3000); // 否则从图片获取实际尺寸 @@ -196,7 +247,7 @@ const ImageSegment: React.FC<{ setDimensions({ width: Math.round(newWidth), height: Math.round(newHeight) }); }, - (error) => { + () => { clearTimeout(timeoutId); // 获取失败时使用默认 4:3 比例 setDimensions(IMAGE_FALLBACK_SIZE); @@ -221,10 +272,9 @@ const ImageSegment: React.FC<{ if (!hasValidUrl || loadError) { return ( hasValidUrl && onImagePress?.(data.url)} + onPress={() => hasValidUrl && onImagePress?.(pressUrl)} onLongPress={handleLongPress} delayLongPress={500} activeOpacity={0.9} @@ -250,10 +300,9 @@ const ImageSegment: React.FC<{ if (!dimensions) { return ( onImagePress?.(data.url)} + onPress={() => onImagePress?.(pressUrl)} onLongPress={handleLongPress} delayLongPress={500} activeOpacity={0.9} @@ -272,10 +321,9 @@ const ImageSegment: React.FC<{ return ( onImagePress?.(data.url)} + onPress={() => onImagePress?.(pressUrl)} onLongPress={handleLongPress} delayLongPress={500} activeOpacity={0.9} @@ -287,10 +335,20 @@ const ImageSegment: React.FC<{ height: dimensions.height, borderRadius: 8, }} + recyclingKey={stableImageKey} contentFit="cover" - cachePolicy="disk" + cachePolicy="memory-disk" priority="normal" + transition={200} + onLoadStart={() => { + setLoadError(false); + }} onError={() => { + // 缩略图失败时自动回退原图,降低跳转定位后的白块/丢图概率 + if (data.thumbnail_url && data.url && imageUrl === data.thumbnail_url) { + setCurrentImageUri(data.url); + return; + } setLoadError(true); }} /> @@ -309,16 +367,13 @@ const renderImageSegment = ( /** * 渲染 @ Segment */ -const renderAtSegment = ( - data: AtSegmentData, - props: Omit -): React.ReactNode => { +const AtSegmentBody: React.FC<{ + data: AtSegmentData; + props: Omit; +}> = ({ data, props }) => { + const styles = useSegmentStyles(); const { currentUserId, memberMap, onAtPress, isMe } = props; - - // 判断是否@当前用户 const isAtMe = data.user_id === currentUserId || data.user_id === 'all'; - - // 优先从 memberMap 中查找昵称,兜底使用 data.nickname(兼容旧消息),再兜底显示"用户" let displayName: string; if (data.user_id === 'all') { displayName = '所有人'; @@ -326,10 +381,8 @@ const renderAtSegment = ( const member = memberMap?.get(data.user_id); displayName = member?.nickname || data.nickname || '用户'; } - return ( +): React.ReactNode => ; + /** * 渲染表情 Segment */ -const renderFaceSegment = (data: FaceSegmentData, isMe: boolean): React.ReactNode => { - // 如果有自定义表情URL,显示图片 +const FaceSegmentBody: React.FC<{ data: FaceSegmentData }> = ({ data }) => { + const styles = useSegmentStyles(); if (data.url) { return ( ); } - - // 否则显示表情名称(后续可以映射到本地表情) return ( - + [{data.name || `表情${data.id}`}] ); }; +const renderFaceSegment = (data: FaceSegmentData, _isMe: boolean): React.ReactNode => ( + +); + /** * 渲染语音 Segment */ -const renderVoiceSegment = (data: VoiceSegmentData, isMe: boolean): React.ReactNode => { +const VoiceSegmentBody: React.FC<{ data: VoiceSegmentData; isMe: boolean }> = ({ data, isMe }) => { + const styles = useSegmentStyles(); + const themeColors = useAppColors(); return ( {data.duration ? `${data.duration}"` : '语音'} @@ -389,10 +449,16 @@ const renderVoiceSegment = (data: VoiceSegmentData, isMe: boolean): React.ReactN ); }; +const renderVoiceSegment = (data: VoiceSegmentData, isMe: boolean): React.ReactNode => ( + +); + /** * 视频 Segment 组件 - 支持点击播放 */ const VideoSegment: React.FC<{ data: VideoSegmentData; isMe: boolean }> = ({ data, isMe }) => { + const styles = useSegmentStyles(); + const themeColors = useAppColors(); const [playerVisible, setPlayerVisible] = useState(false); const handlePress = useCallback(() => { @@ -416,11 +482,11 @@ const VideoSegment: React.FC<{ data: VideoSegmentData; isMe: boolean }> = ({ dat /> ) : ( - + )} - + {data.duration && ( {formatDuration(data.duration)} @@ -443,12 +509,12 @@ const renderVideoSegment = (data: VideoSegmentData, isMe: boolean): React.ReactN /** * 渲染文件 Segment */ -const renderFileSegment = (data: FileSegmentData, isMe: boolean): React.ReactNode => { +const FileSegmentBody: React.FC<{ data: FileSegmentData; isMe: boolean }> = ({ data, isMe }) => { + const styles = useSegmentStyles(); + const themeColors = useAppColors(); const fileSize = data.size ? formatFileSize(data.size) : ''; - return ( @@ -456,33 +522,36 @@ const renderFileSegment = (data: FileSegmentData, isMe: boolean): React.ReactNod - {data.name} - {fileSize && ( - {fileSize} - )} + {fileSize ? {fileSize} : null} ); }; +const renderFileSegment = (data: FileSegmentData, isMe: boolean): React.ReactNode => ( + +); + /** * 渲染链接 Segment */ -const renderLinkSegment = ( - data: LinkSegmentData, - props: Omit -): React.ReactNode => { +const LinkSegmentBody: React.FC<{ + data: LinkSegmentData; + props: Omit; +}> = ({ data, props }) => { + const styles = useSegmentStyles(); const { onLinkPress, isMe } = props; - + const handlePress = async () => { if (onLinkPress) { onLinkPress(data.url); @@ -499,26 +568,25 @@ const renderLinkSegment = ( } } }; - + return ( - {data.image && ( + {data.image ? ( - )} + ) : null} {data.title || data.url} - {data.description && ( + {data.description ? ( {data.description} - )} + ) : null} {(() => { try { @@ -533,6 +601,11 @@ const renderLinkSegment = ( ); }; +const renderLinkSegment = ( + data: LinkSegmentData, + props: Omit +): React.ReactNode => ; + /** * 回复预览组件(用于消息气泡内显示回复引用) */ @@ -543,6 +616,9 @@ export const ReplyPreviewSegment: React.FC = ({ onPress, getSenderInfo, }) => { + const themeColors = useAppColors(); + const styles = useMemo(() => createSegmentStyles(themeColors), [themeColors]); + if (!replyMessage) { // 如果没有引用消息详情,只显示引用ID return ( @@ -552,9 +628,11 @@ export const ReplyPreviewSegment: React.FC = ({ activeOpacity={0.7} > - - 引用消息 - + + + 引用消息 + + ); } @@ -585,7 +663,8 @@ export const ReplyPreviewSegment: React.FC = ({ const hasFile = replyMessage.segments.some(s => s.type === 'file'); if (hasImage) { - previewContent = '[图片]'; + const imageCount = replyMessage.segments.filter(seg => seg.type === 'image').length; + previewContent = imageCount > 1 ? `[${imageCount}张图片]` : '[图片]'; } else if (hasVoice) { previewContent = '[语音]'; } else if (hasVideo) { @@ -604,6 +683,7 @@ export const ReplyPreviewSegment: React.FC = ({ onPress={onPress} activeOpacity={0.7} > + {senderName}: @@ -648,10 +728,13 @@ export const MessageSegmentsRenderer: React.FC<{ onLinkPress, getSenderInfo, }) => { - // 找出 reply segment(如果有) + const themeColors = useAppColors(); + const segStyles = useMemo(() => createSegmentStyles(themeColors), [themeColors]); + const replySegment = segments.find(s => s.type === 'reply'); const otherSegments = segments.filter(s => s.type !== 'reply'); - + const chunks = partitionMessageSegments(otherSegments); + const renderProps = { isMe, currentUserId, @@ -662,29 +745,58 @@ export const MessageSegmentsRenderer: React.FC<{ onImageLongPress, onLinkPress, }; - + return ( - - {/* 回复预览 */} - {replySegment && ( - onReplyPress?.((replySegment.data as ReplySegmentData).id)} - getSenderInfo={getSenderInfo} - /> - )} - - {/* 其他 Segment 内容 */} - - {otherSegments.map((segment, index) => ( - - {renderSegment(segment, renderProps)} - - ))} + + + {replySegment ? ( + onReplyPress?.((replySegment.data as ReplySegmentData).id)} + getSenderInfo={getSenderInfo} + /> + ) : null} + + + {chunks.map((chunk, i) => { + if (chunk.kind === 'inline') { + return ( + + {chunk.parts.map((segment, j) => ( + + {renderSegment(segment, renderProps)} + + ))} + + ); + } + if (chunk.kind === 'images') { + return ( + + {chunk.parts.map((segment, j) => ( + + {renderSegment(segment, renderProps)} + + ))} + + ); + } + return ( + + {renderSegment(chunk.segment, renderProps)} + + ); + })} + - + ); }; @@ -703,16 +815,36 @@ const formatDuration = (seconds: number): string => { return mins > 0 ? `${mins}:${secs.toString().padStart(2, '0')}` : `0:${secs.toString().padStart(2, '0')}`; }; -const styles = StyleSheet.create({ +function createSegmentStyles(colors: AppColors) { + return StyleSheet.create({ // 容器 segmentsContainer: { flexDirection: 'column', width: '100%', }, - segmentsContent: { + segmentsColumn: { + flexDirection: 'column', + width: '100%', + }, + inlineChunk: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'flex-end', + width: '100%', + }, + imagesChunk: { + width: '100%', + }, + imageStackItem: { + width: '100%', + marginBottom: 6, + }, + imageStackItemLast: { + width: '100%', + }, + blockChunk: { + width: '100%', + marginTop: 2, }, // 文本 - Telegram风格:统一深色字体 @@ -723,10 +855,10 @@ const styles = StyleSheet.create({ fontWeight: '400', }, textMe: { - color: '#1A1A1A', // 统一使用深色字体 + color: colors.chat.textPrimary, }, textOther: { - color: '#1A1A1A', // 统一使用深色字体 + color: colors.chat.textPrimary, }, // @提及 - Telegram风格:蓝色高亮 @@ -737,17 +869,17 @@ const styles = StyleSheet.create({ paddingHorizontal: 2, }, atTextMe: { - color: '#1976D2', // 蓝色 - backgroundColor: 'rgba(25, 118, 210, 0.12)', + color: '#4A88C7', + backgroundColor: 'rgba(74, 136, 199, 0.12)', borderRadius: 4, }, atTextOther: { - color: '#1976D2', // 蓝色 - backgroundColor: 'rgba(25, 118, 210, 0.12)', + color: colors.chat.link, + backgroundColor: 'rgba(74, 136, 199, 0.12)', borderRadius: 4, }, atHighlight: { - backgroundColor: 'rgba(25, 118, 210, 0.2)', + backgroundColor: 'rgba(74, 136, 199, 0.2)', borderRadius: 4, paddingHorizontal: 4, }, @@ -757,7 +889,7 @@ const styles = StyleSheet.create({ borderRadius: 12, overflow: 'hidden', marginTop: 4, - shadowColor: '#000', + shadowColor: colors.chat.shadow, shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.15, shadowRadius: 4, @@ -789,7 +921,7 @@ const styles = StyleSheet.create({ paddingVertical: spacing.sm + 2, borderRadius: 20, minWidth: 100, - shadowColor: '#000', + shadowColor: colors.chat.shadow, shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.1, shadowRadius: 2, @@ -813,7 +945,7 @@ const styles = StyleSheet.create({ overflow: 'hidden', width: 220, height: 165, - shadowColor: '#000', + shadowColor: colors.chat.shadow, shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.15, shadowRadius: 4, @@ -850,7 +982,7 @@ const styles = StyleSheet.create({ position: 'absolute', bottom: 10, right: 10, - color: '#FFFFFF', + color: colors.primary.contrast, fontSize: 13, fontWeight: '600', backgroundColor: 'rgba(0, 0, 0, 0.6)', @@ -868,7 +1000,7 @@ const styles = StyleSheet.create({ borderRadius: 16, minWidth: 220, maxWidth: 300, - shadowColor: '#000', + shadowColor: colors.chat.shadow, shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.08, shadowRadius: 2, @@ -878,7 +1010,7 @@ const styles = StyleSheet.create({ backgroundColor: 'rgba(255, 255, 255, 0.25)', }, fileOther: { - backgroundColor: '#F8F9FA', + backgroundColor: colors.chat.surfaceRaised, }, fileIcon: { marginRight: spacing.md, @@ -895,11 +1027,11 @@ const styles = StyleSheet.create({ fileName: { fontSize: 15, fontWeight: '600', - color: '#1A1A1A', + color: colors.chat.textPrimary, }, fileSize: { fontSize: 13, - color: '#8E8E93', + color: colors.chat.textSecondary, marginTop: 4, fontWeight: '500', }, @@ -909,8 +1041,8 @@ const styles = StyleSheet.create({ borderRadius: 16, overflow: 'hidden', maxWidth: 280, - backgroundColor: '#FFFFFF', - shadowColor: '#000', + backgroundColor: colors.chat.card, + shadowColor: colors.chat.shadow, shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 4, @@ -920,7 +1052,7 @@ const styles = StyleSheet.create({ backgroundColor: 'rgba(255, 255, 255, 0.95)', }, linkOther: { - backgroundColor: '#FFFFFF', + backgroundColor: colors.chat.card, }, linkImage: { width: '100%', @@ -932,12 +1064,12 @@ const styles = StyleSheet.create({ linkTitle: { fontSize: 15, fontWeight: '600', - color: '#1A1A1A', + color: colors.chat.textPrimary, lineHeight: 20, }, linkDescription: { fontSize: 13, - color: '#666666', + color: colors.chat.textTertiary, marginTop: 6, lineHeight: 18, }, @@ -948,16 +1080,17 @@ const styles = StyleSheet.create({ fontWeight: '500', }, - // 回复预览 - Telegram风格:简洁设计 + // 回复预览 - Telegram风格:左侧色条 + 简洁背景 replyPreview: { flexDirection: 'row', + alignItems: 'center', marginBottom: 0, paddingVertical: spacing.sm, paddingHorizontal: spacing.sm + 2, borderRadius: 6, width: '100%', minWidth: 200, - // 移除阴影 + gap: 8, shadowColor: 'transparent', shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0, @@ -965,34 +1098,51 @@ const styles = StyleSheet.create({ elevation: 0, }, replyMe: { - backgroundColor: 'rgba(25, 118, 210, 0.1)', + backgroundColor: 'rgba(74, 136, 199, 0.12)', }, replyOther: { backgroundColor: 'rgba(0, 0, 0, 0.03)', }, replyLine: { - width: 0, - marginRight: 0, + width: 3, + alignSelf: 'stretch', + minHeight: 28, + borderRadius: 2, + backgroundColor: colors.chat.link, }, replyContent: { flex: 1, flexDirection: 'row', alignItems: 'center', flexWrap: 'nowrap', + minWidth: 0, }, replySender: { fontSize: 13, fontWeight: '600', - color: '#1976D2', + color: colors.chat.link, marginRight: 6, flexShrink: 0, }, replyText: { fontSize: 13, - color: '#666666', + color: colors.chat.textTertiary, flex: 1, flexShrink: 1, }, -}); + }); +} + +type SegmentStyles = ReturnType; + +const SegmentStylesContext = React.createContext(null); + +function useSegmentStyles(): SegmentStyles { + const ctx = React.useContext(SegmentStylesContext); + if (!ctx) { + throw new Error('useSegmentStyles 必须在 MessageSegmentsRenderer 内使用'); + } + return ctx; +} export default MessageSegmentsRenderer; \ No newline at end of file diff --git a/src/screens/message/components/ChatScreen/SwipeableMessageBubble.tsx b/src/screens/message/components/ChatScreen/SwipeableMessageBubble.tsx new file mode 100644 index 0000000..6d47c97 --- /dev/null +++ b/src/screens/message/components/ChatScreen/SwipeableMessageBubble.tsx @@ -0,0 +1,158 @@ +/** + * 可滑动的消息气泡容器 + * 实现滑动回复手势功能 - 严格单向滑动,无抖动 + */ + +import React, { useRef, useCallback } from 'react'; +import { View, StyleSheet, Animated } from 'react-native'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { PanGestureHandler, State } from 'react-native-gesture-handler'; +import * as Haptics from 'expo-haptics'; +import { useAppColors } from '../../../../theme'; + +// 滑动阈值 +const SWIPE_THRESHOLD = 30; +const MAX_SWIPE_DISTANCE = 50; + +interface SwipeableMessageBubbleProps { + children: React.ReactNode; + isMe: boolean; + onReply: () => void; + enabled?: boolean; +} + +export const SwipeableMessageBubble: React.FC = ({ + children, + isMe, + onReply, + enabled = true, +}) => { + const colors = useAppColors(); + const translateX = useRef(new Animated.Value(0)).current; + const hasTriggeredRef = useRef(false); + + // 处理手势事件 - 使用原生驱动,不干预值 + const onGestureEvent = Animated.event( + [{ nativeEvent: { translationX: translateX } }], + { useNativeDriver: true } + ); + + // 处理手势状态变化 + const onHandlerStateChange = useCallback((event: any) => { + const { nativeEvent } = event; + + if (nativeEvent.state === State.END) { + const translationX = nativeEvent.translationX; + + // 只允许特定方向的滑动 + const shouldTrigger = isMe + ? translationX < -SWIPE_THRESHOLD // 自己消息只能向左滑 + : translationX > SWIPE_THRESHOLD; // 对方消息只能向右滑 + + if (shouldTrigger && !hasTriggeredRef.current) { + hasTriggeredRef.current = true; + + // 触觉反馈 + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + + // 立即触发回复 + onReply(); + + // 重置标记 + setTimeout(() => { + hasTriggeredRef.current = false; + }, 300); + } + + // 立即回到原位 + translateX.setValue(0); + } + }, [isMe, onReply]); + + // 计算回复图标的透明度 - 只在允许的方向显示 + const iconOpacity = translateX.interpolate({ + inputRange: isMe + ? [-MAX_SWIPE_DISTANCE, -SWIPE_THRESHOLD, 0, 10] + : [-10, 0, SWIPE_THRESHOLD, MAX_SWIPE_DISTANCE], + outputRange: isMe ? [1, 0.8, 0, 0] : [0, 0, 0.8, 1], + extrapolate: 'clamp', + }); + + // 计算消息的位移 - 限制在允许的方向 + const clampedTranslateX = translateX.interpolate({ + inputRange: isMe + ? [-MAX_SWIPE_DISTANCE - 10, -MAX_SWIPE_DISTANCE, 0, 10] + : [-10, 0, MAX_SWIPE_DISTANCE, MAX_SWIPE_DISTANCE + 10], + outputRange: isMe + ? [-MAX_SWIPE_DISTANCE, -MAX_SWIPE_DISTANCE, 0, 0] + : [0, 0, MAX_SWIPE_DISTANCE, MAX_SWIPE_DISTANCE], + extrapolate: 'clamp', + }); + + // 如果禁用手势,直接返回子元素 + if (!enabled) { + return <>{children}; + } + + return ( + + {/* 回复图标 - 在消息后面 */} + + + + + {/* 可滑动的消息内容 */} + + + {children} + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + position: 'relative', + }, + replyIconContainer: { + position: 'absolute', + top: '50%', + marginTop: -15, + width: 30, + height: 30, + borderRadius: 15, + backgroundColor: 'rgba(255, 107, 53, 0.1)', + alignItems: 'center', + justifyContent: 'center', + }, + replyIconLeft: { + left: 8, + }, + replyIconRight: { + right: 8, + }, +}); + +export default SwipeableMessageBubble; \ No newline at end of file diff --git a/src/screens/message/components/ChatScreen/bubbleStyles.ts b/src/screens/message/components/ChatScreen/bubbleStyles.ts new file mode 100644 index 0000000..9f47781 --- /dev/null +++ b/src/screens/message/components/ChatScreen/bubbleStyles.ts @@ -0,0 +1,233 @@ +/** + * 消息气泡样式工具 + * 参考 Element X 设计,实现动态圆角和现代化气泡样式 + */ + +import { StyleSheet, ViewStyle, TextStyle } from 'react-native'; +import { spacing, type AppColors } from '../../../../theme'; + +export const BUBBLE_RADIUS = 16; + +export function getBubbleColors(colors: AppColors) { + return { + outgoing: { + background: colors.chat.bubbleOutgoing, + text: colors.chat.textPrimary, + }, + incoming: { + background: colors.chat.bubbleIncoming, + text: colors.chat.textPrimary, + }, + replyHighlight: { + background: colors.chat.replyTint, + borderLeft: colors.chat.replyBorder, + }, + }; +} + +export type MessageGroupPosition = 'single' | 'first' | 'middle' | 'last'; + +export const getBubbleBorderRadius = (isMe: boolean, position: MessageGroupPosition): ViewStyle => { + const radius = BUBBLE_RADIUS; + + if (isMe) { + switch (position) { + case 'single': + return { + borderTopLeftRadius: radius, + borderTopRightRadius: radius, + borderBottomLeftRadius: radius, + borderBottomRightRadius: 4, + }; + case 'first': + return { + borderTopLeftRadius: radius, + borderTopRightRadius: radius, + borderBottomLeftRadius: radius, + borderBottomRightRadius: 4, + }; + case 'middle': + return { + borderTopLeftRadius: radius, + borderTopRightRadius: 4, + borderBottomLeftRadius: radius, + borderBottomRightRadius: 4, + }; + case 'last': + return { + borderTopLeftRadius: radius, + borderTopRightRadius: 4, + borderBottomLeftRadius: radius, + borderBottomRightRadius: radius, + }; + } + } else { + switch (position) { + case 'single': + return { + borderTopLeftRadius: 4, + borderTopRightRadius: radius, + borderBottomLeftRadius: radius, + borderBottomRightRadius: radius, + }; + case 'first': + return { + borderTopLeftRadius: 4, + borderTopRightRadius: radius, + borderBottomLeftRadius: 4, + borderBottomRightRadius: radius, + }; + case 'middle': + return { + borderTopLeftRadius: 4, + borderTopRightRadius: radius, + borderBottomLeftRadius: 4, + borderBottomRightRadius: radius, + }; + case 'last': + return { + borderTopLeftRadius: radius, + borderTopRightRadius: radius, + borderBottomLeftRadius: radius, + borderBottomRightRadius: radius, + }; + } + } +}; + +export const getMessageGroupPosition = ( + index: number, + messages: Array<{ sender_id: string }>, + currentUserId: string +): MessageGroupPosition => { + const currentMessage = messages[index]; + if (!currentMessage) return 'single'; + + const prevMessage = index > 0 ? messages[index - 1] : null; + const nextMessage = index < messages.length - 1 ? messages[index + 1] : null; + + const isSameSenderAsPrev = prevMessage && prevMessage.sender_id === currentMessage.sender_id; + const isSameSenderAsNext = nextMessage && nextMessage.sender_id === currentMessage.sender_id; + + if (!isSameSenderAsPrev && !isSameSenderAsNext) { + return 'single'; + } else if (!isSameSenderAsPrev && isSameSenderAsNext) { + return 'first'; + } else if (isSameSenderAsPrev && isSameSenderAsNext) { + return 'middle'; + } else { + return 'last'; + } +}; + +export const shouldShowSenderInfo = ( + index: number, + messages: Array<{ sender_id: string }> +): boolean => { + if (index === 0) return true; + + const currentMessage = messages[index]; + const prevMessage = messages[index - 1]; + + return prevMessage.sender_id !== currentMessage.sender_id; +}; + +export const getBubbleBaseStyle = (isMe: boolean, colors: AppColors): ViewStyle => { + const bc = getBubbleColors(colors); + return { + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + 4, + minWidth: 60, + maxWidth: '75%', + backgroundColor: isMe ? bc.outgoing.background : bc.incoming.background, + shadowColor: colors.chat.shadow, + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.06, + shadowRadius: 2, + elevation: 2, + }; +}; + +export const getBubbleTextStyle = (isMe: boolean, colors: AppColors): TextStyle => { + const bc = getBubbleColors(colors); + return { + color: isMe ? bc.outgoing.text : bc.incoming.text, + fontSize: 16, + lineHeight: 23, + letterSpacing: 0.2, + fontWeight: '400', + }; +}; + +export function createBubbleStyles(colors: AppColors) { + const bc = getBubbleColors(colors); + return StyleSheet.create({ + bubble: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + 4, + minWidth: 60, + }, + outgoing: { + backgroundColor: bc.outgoing.background, + }, + incoming: { + backgroundColor: bc.incoming.background, + }, + text: { + fontSize: 16, + lineHeight: 23, + letterSpacing: 0.2, + fontWeight: '400', + }, + shadow: { + shadowColor: colors.chat.shadow, + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.06, + shadowRadius: 2, + elevation: 2, + }, + longPressShadow: { + shadowColor: colors.chat.shadow, + shadowOffset: { width: 0, height: 3 }, + shadowOpacity: 0.12, + shadowRadius: 8, + elevation: 6, + }, + replyHighlight: { + borderLeftWidth: 3, + borderLeftColor: colors.primary.main, + backgroundColor: bc.replyHighlight.background, + }, + recalled: { + backgroundColor: colors.chat.surfaceMuted, + borderWidth: 1, + borderColor: colors.chat.border, + borderStyle: 'dashed', + borderRadius: 12, + }, + systemNotice: { + alignItems: 'center', + marginVertical: spacing.sm, + }, + systemNoticeText: { + fontSize: 12, + color: colors.chat.textSecondary, + backgroundColor: colors.chat.surfaceMuted, + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs, + borderRadius: 12, + overflow: 'hidden', + }, + }); +} + +export default { + BUBBLE_RADIUS, + getBubbleColors, + getBubbleBorderRadius, + getMessageGroupPosition, + shouldShowSenderInfo, + getBubbleBaseStyle, + getBubbleTextStyle, + createBubbleStyles, +}; diff --git a/src/screens/message/components/ChatScreen/constants.ts b/src/screens/message/components/ChatScreen/constants.ts index 9130636..1d57c2d 100644 --- a/src/screens/message/components/ChatScreen/constants.ts +++ b/src/screens/message/components/ChatScreen/constants.ts @@ -165,5 +165,8 @@ export const PANEL_HEIGHTS = { // 输入框最大长度 export const MAX_INPUT_LENGTH = 500; +// 单条消息最多附带本地待发送图片数(相册多选 + 拍摄累加) +export const MAX_PENDING_CHAT_IMAGES = 9; + // 群成员加载每页数量 export const MEMBERS_PAGE_SIZE = 100; diff --git a/src/screens/message/components/ChatScreen/index.ts b/src/screens/message/components/ChatScreen/index.ts index c905b0f..1ab1e27 100644 --- a/src/screens/message/components/ChatScreen/index.ts +++ b/src/screens/message/components/ChatScreen/index.ts @@ -9,7 +9,7 @@ export * from './types'; export * from './constants'; // 样式 -export { chatScreenStyles } from './styles'; +export { createChatScreenStyles, useChatScreenStyles } from './styles'; // 组件 export { EmojiPanel } from './EmojiPanel'; @@ -20,6 +20,7 @@ export { ChatHeader } from './ChatHeader'; export { MessageBubble } from './MessageBubble'; export { ChatInput } from './ChatInput'; export { GroupInfoPanel } from './GroupInfoPanel'; +export { SwipeableMessageBubble } from './SwipeableMessageBubble'; // Segment 渲染组件 export { diff --git a/src/screens/message/components/ChatScreen/styles.ts b/src/screens/message/components/ChatScreen/styles.ts index 1a2bd32..a8264c7 100644 --- a/src/screens/message/components/ChatScreen/styles.ts +++ b/src/screens/message/components/ChatScreen/styles.ts @@ -3,8 +3,9 @@ * 支持响应式布局 */ +import { useMemo } from 'react'; import { StyleSheet, Dimensions } from 'react-native'; -import { colors, spacing, shadows } from '../../../../theme'; +import { spacing, shadows, useAppColors, type AppColors } from '../../../../theme'; const { width: SCREEN_WIDTH } = Dimensions.get('window'); @@ -18,19 +19,20 @@ const BREAKPOINTS = { const isWideScreen = SCREEN_WIDTH >= BREAKPOINTS.desktop; const isTablet = SCREEN_WIDTH >= BREAKPOINTS.tablet; -export const chatScreenStyles = StyleSheet.create({ +export function createChatScreenStyles(colors: AppColors) { + return StyleSheet.create({ // 容器 container: { flex: 1, - backgroundColor: '#F0F2F5', + backgroundColor: colors.chat.screen, }, // 头部 headerContainer: { - backgroundColor: '#FFFFFF', + backgroundColor: colors.chat.card, borderBottomWidth: 1, borderBottomColor: 'rgba(255, 107, 53, 0.12)', - shadowColor: '#000', + shadowColor: colors.chat.shadow, shadowOffset: { width: 0, height: 3 }, shadowOpacity: 0.06, shadowRadius: 10, @@ -42,7 +44,7 @@ export const chatScreenStyles = StyleSheet.create({ justifyContent: 'space-between', paddingHorizontal: spacing.md, paddingVertical: spacing.sm + 2, - backgroundColor: '#FFFFFF', + backgroundColor: colors.chat.card, }, backButton: { width: 38, @@ -112,36 +114,61 @@ export const chatScreenStyles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', borderRadius: 19, - backgroundColor: '#F7F8FA', + backgroundColor: colors.chat.surfaceRaised, borderWidth: 1, - borderColor: '#ECEFF3', + borderColor: colors.chat.borderLight, }, // 消息列表 messageListContainer: { flex: 1, }, + // 回到底部(Telegram 式浮动条) + jumpToLatestFab: { + position: 'absolute', + right: 14, + bottom: 10, + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 20, + backgroundColor: 'rgba(255, 255, 255, 0.96)', + borderWidth: StyleSheet.hairlineWidth, + borderColor: 'rgba(0, 0, 0, 0.08)', + shadowColor: colors.chat.shadow, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.12, + shadowRadius: 6, + elevation: 4, + }, + jumpToLatestFabText: { + fontSize: 13, + fontWeight: '600', + color: colors.primary.main, + }, listContent: { paddingHorizontal: spacing.md, paddingTop: spacing.md, paddingBottom: spacing.xl, }, - // 时间分隔 - QQ风格:更明显的时间标签 + // 时间分隔 - 轻量胶囊(弱化字重,接近 Telegram 日期条) timeContainer: { alignItems: 'center', marginVertical: spacing.lg, }, timeText: { - color: '#8E8E93', - fontSize: 12, - backgroundColor: 'rgba(142, 142, 147, 0.12)', - paddingHorizontal: spacing.md, - paddingVertical: 6, - borderRadius: 14, - fontWeight: '600', + color: 'rgba(142, 142, 147, 0.95)', + fontSize: 11, + backgroundColor: 'rgba(142, 142, 147, 0.1)', + paddingHorizontal: spacing.sm + 4, + paddingVertical: 5, + borderRadius: 12, + fontWeight: '500', overflow: 'hidden', - letterSpacing: 0.3, + letterSpacing: 0.2, }, // 系统通知 @@ -152,7 +179,7 @@ export const chatScreenStyles = StyleSheet.create({ }, systemNoticeText: { fontSize: 12, - color: '#8E8E93', + color: colors.chat.textSecondary, backgroundColor: 'rgba(142, 142, 147, 0.12)', paddingHorizontal: spacing.md, paddingVertical: spacing.xs, @@ -177,7 +204,7 @@ export const chatScreenStyles = StyleSheet.create({ // 头像 avatarWrapper: { marginHorizontal: 2, - shadowColor: '#000', + shadowColor: colors.chat.shadow, shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.08, shadowRadius: 2, @@ -185,7 +212,7 @@ export const chatScreenStyles = StyleSheet.create({ }, groupAvatarWrapper: { marginHorizontal: 2, - shadowColor: '#000', + shadowColor: colors.chat.shadow, shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.08, shadowRadius: 2, @@ -200,53 +227,59 @@ export const chatScreenStyles = StyleSheet.create({ }, senderName: { fontSize: 13, - color: '#1976D2', + color: colors.chat.link, marginBottom: 4, marginLeft: 4, fontWeight: '600', }, - // 消息气泡 - Telegram风格:浅色背景,统一字体颜色 - messageBubble: { + // 消息气泡 - 外层极轻阴影(纯文字气泡更接近扁平 IM) + messageBubbleOuter: { + borderRadius: 16, + minWidth: 60, + shadowColor: colors.chat.shadow, + shadowOffset: { width: 0, height: 0.5 }, + shadowOpacity: 0.04, + shadowRadius: 1, + elevation: 1, + }, + myBubbleOuter: { + borderBottomRightRadius: 4, + }, + theirBubbleOuter: { + borderTopLeftRadius: 4, + }, + // 内层:背景 + 裁剪,防止回复条/@ 高亮在圆角外露出颜色 + messageBubbleInner: { + borderRadius: 16, + overflow: 'hidden', paddingHorizontal: spacing.md, paddingVertical: spacing.sm + 4, - borderRadius: 16, - // 自适应内容宽度 minWidth: 60, }, - myBubble: { - backgroundColor: '#E3F2FD', // Telegram风格的浅蓝色 - borderBottomRightRadius: 4, // 右下角尖,箭头在右下 - shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.08, - shadowRadius: 2, - elevation: 2, + myBubbleInner: { + backgroundColor: colors.chat.replyTint, + borderBottomRightRadius: 4, }, - theirBubble: { - backgroundColor: '#FFFFFF', - borderTopLeftRadius: 4, // 左上角尖,箭头在左上 - shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.08, - shadowRadius: 2, - elevation: 2, + theirBubbleInner: { + backgroundColor: colors.chat.card, + borderTopLeftRadius: 4, }, recalledBubble: { - backgroundColor: '#F5F7FA', + backgroundColor: colors.chat.surfaceMuted, borderWidth: 1, - borderColor: '#E8E8E8', + borderColor: colors.chat.border, borderStyle: 'dashed', }, myBubbleText: { - color: '#1A1A1A', // 统一使用深色字体 + color: colors.chat.textPrimary, // 主文案 fontSize: 16, lineHeight: 23, letterSpacing: 0.2, fontWeight: '400', }, theirBubbleText: { - color: '#1A1A1A', // 统一使用深色字体 + color: colors.chat.textPrimary, // 主文案 fontSize: 16, lineHeight: 23, letterSpacing: 0.2, @@ -255,23 +288,27 @@ export const chatScreenStyles = StyleSheet.create({ // 选中状态 selectedBubble: { borderWidth: 2, - borderColor: '#007AFF', + borderColor: colors.chat.replyBorder, + }, + // 仅对齐右侧,不再套一层浅色底,避免与气泡叠色产生「边缘多出一块蓝」 + myMessageContentPanel: { + alignItems: 'flex-end', }, mySelectedBubble: { - backgroundColor: '#0051D5', + backgroundColor: colors.chat.replyTintActive, borderWidth: 2, - borderColor: 'rgba(255,255,255,0.5)', + borderColor: colors.chat.replyBorder, }, theirSelectedBubble: { - backgroundColor: '#E8F1FF', + backgroundColor: colors.chat.menuHighlight, borderWidth: 2, - borderColor: '#007AFF', + borderColor: colors.chat.replyBorder, }, selectedText: { // 文本选中时的样式 }, recalledText: { - color: '#8E8E93', + color: colors.chat.textSecondary, fontStyle: 'italic', }, @@ -279,7 +316,7 @@ export const chatScreenStyles = StyleSheet.create({ imageBubble: { borderRadius: 18, overflow: 'hidden', - shadowColor: '#000', + shadowColor: colors.chat.shadow, shadowOffset: { width: 0, height: 3 }, shadowOpacity: 0.15, shadowRadius: 6, @@ -308,27 +345,28 @@ export const chatScreenStyles = StyleSheet.create({ }, readStatusText: { fontSize: 10, - color: '#8E8E93', + color: colors.chat.textSecondary, fontWeight: '500', }, readStatusTextRead: { - color: '#34C759', + color: colors.chat.success, }, - // 输入框区域 + // 输入框区域 - 与列表背景同色底栏,顶部细分隔 inputWrapper: { - backgroundColor: 'transparent', + backgroundColor: colors.chat.screen, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: 'rgba(0, 0, 0, 0.06)', }, inputContainer: { - backgroundColor: colors.background.paper, - borderWidth: 1, - borderColor: colors.divider, - borderRadius: 24, - marginHorizontal: 14, - marginBottom: 12, + backgroundColor: 'transparent', + borderWidth: 0, + borderRadius: 0, + marginHorizontal: 10, + marginBottom: 10, + marginTop: 2, paddingHorizontal: spacing.sm, paddingVertical: spacing.sm, - // 移除明显的阴影效果,改用更微妙的边框 shadowColor: 'transparent', shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0, @@ -338,7 +376,7 @@ export const chatScreenStyles = StyleSheet.create({ inputInner: { flexDirection: 'row', alignItems: 'center', - backgroundColor: '#F7F8FA', + backgroundColor: colors.chat.surfaceRaised, borderRadius: 20, paddingHorizontal: spacing.xs, paddingVertical: 4, @@ -351,7 +389,7 @@ export const chatScreenStyles = StyleSheet.create({ elevation: 0, }, inputInnerMuted: { - backgroundColor: '#F0F0F0', + backgroundColor: colors.chat.borderHairline, opacity: 0.7, }, @@ -360,7 +398,7 @@ export const chatScreenStyles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', - backgroundColor: '#FFF5F5', + backgroundColor: colors.chat.warningBg, paddingVertical: spacing.xs, paddingHorizontal: spacing.md, marginBottom: spacing.xs, @@ -369,7 +407,7 @@ export const chatScreenStyles = StyleSheet.create({ }, mutedBannerText: { fontSize: 13, - color: '#FF3B30', + color: colors.chat.danger, fontWeight: '500', }, @@ -415,14 +453,14 @@ export const chatScreenStyles = StyleSheet.create({ }, input: { fontSize: 16, - color: '#1A1A1A', + color: colors.chat.textPrimary, paddingTop: 8, paddingBottom: 8, maxHeight: 100, lineHeight: 20, }, inputMuted: { - color: '#CCC', + color: colors.chat.iconMuted, }, inputTransparent: { color: 'transparent', @@ -438,7 +476,7 @@ export const chatScreenStyles = StyleSheet.create({ }, inputHighlightText: { fontSize: 16, - color: '#1A1A1A', + color: colors.chat.textPrimary, lineHeight: 20, }, inputHighlightMention: { @@ -456,10 +494,10 @@ export const chatScreenStyles = StyleSheet.create({ // 面板 panelWrapper: { - backgroundColor: '#F5F7FA', + backgroundColor: colors.chat.surfaceMuted, borderTopWidth: 1, - borderTopColor: '#E8E8E8', - shadowColor: '#000', + borderTopColor: colors.chat.border, + shadowColor: colors.chat.shadow, shadowOffset: { width: 0, height: -2 }, shadowOpacity: 0.1, shadowRadius: 8, @@ -467,11 +505,11 @@ export const chatScreenStyles = StyleSheet.create({ }, // 表情面板包装器 - 底部 tab 栏是白色,安全区域也用白色填充 emojiPanelWrapper: { - backgroundColor: '#FFFFFF', + backgroundColor: colors.chat.card, }, panelContainer: { flex: 1, - backgroundColor: '#F5F7FA', + backgroundColor: colors.chat.surfaceMuted, }, // 表情面板 @@ -499,16 +537,16 @@ export const chatScreenStyles = StyleSheet.create({ paddingHorizontal: spacing.md, paddingVertical: spacing.sm, borderTopWidth: 1, - borderTopColor: '#E8E8E8', + borderTopColor: colors.chat.border, }, panelCloseButton: { width: 40, height: 40, alignItems: 'center', justifyContent: 'center', - backgroundColor: '#FFFFFF', + backgroundColor: colors.chat.card, borderRadius: 8, - shadowColor: '#000', + shadowColor: colors.chat.shadow, shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.1, shadowRadius: 2, @@ -527,8 +565,8 @@ export const chatScreenStyles = StyleSheet.create({ paddingHorizontal: spacing.sm, paddingVertical: spacing.xs, borderTopWidth: 1, - borderTopColor: '#E8E8E8', - backgroundColor: '#FFFFFF', + borderTopColor: colors.chat.border, + backgroundColor: colors.chat.card, }, panelTab: { width: 44, @@ -539,7 +577,7 @@ export const chatScreenStyles = StyleSheet.create({ marginRight: spacing.xs, }, panelTabActive: { - backgroundColor: '#E3F2FD', + backgroundColor: colors.chat.replyTint, }, panelTabEmoji: { fontSize: 24, @@ -547,7 +585,7 @@ export const chatScreenStyles = StyleSheet.create({ panelTabDivider: { width: 1, height: 24, - backgroundColor: '#E8E8E8', + backgroundColor: colors.chat.bubbleOutgoing, marginHorizontal: spacing.sm, }, @@ -565,13 +603,13 @@ export const chatScreenStyles = StyleSheet.create({ }, stickerEmptyText: { fontSize: 16, - color: '#666', + color: colors.chat.textTertiary, marginTop: spacing.md, fontWeight: '500', }, stickerEmptySubText: { fontSize: 13, - color: '#999', + color: colors.chat.textMuted, marginTop: spacing.xs, }, stickerGrid: { @@ -587,7 +625,7 @@ export const chatScreenStyles = StyleSheet.create({ margin: 4, borderRadius: 8, overflow: 'hidden', - backgroundColor: '#F5F5F5', + backgroundColor: colors.chat.surfaceInput, }, stickerImage: { width: '100%', @@ -612,8 +650,8 @@ export const chatScreenStyles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', marginBottom: spacing.sm, - backgroundColor: '#FFFFFF', - shadowColor: '#000', + backgroundColor: colors.chat.card, + shadowColor: colors.chat.shadow, shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.08, shadowRadius: 4, @@ -621,7 +659,7 @@ export const chatScreenStyles = StyleSheet.create({ }, moreItemText: { fontSize: 13, - color: '#666', + color: colors.chat.textTertiary, fontWeight: '500', }, @@ -630,7 +668,7 @@ export const chatScreenStyles = StyleSheet.create({ position: 'absolute', left: 12, right: 12, - backgroundColor: '#FFFFFF', + backgroundColor: colors.chat.card, borderRadius: 18, shadowColor: colors.primary.main, shadowOffset: { width: 0, height: -3 }, @@ -645,7 +683,7 @@ export const chatScreenStyles = StyleSheet.create({ // @成员选择面板 mentionPanelContainer: { flex: 1, - backgroundColor: '#FFFFFF', + backgroundColor: colors.chat.card, borderRadius: 18, }, mentionPanelDragHandle: { @@ -657,7 +695,7 @@ export const chatScreenStyles = StyleSheet.create({ width: 32, height: 3, borderRadius: 2, - backgroundColor: '#E0E0E0', + backgroundColor: colors.chat.sheetGrip, }, mentionPanelHeader: { flexDirection: 'row', @@ -667,12 +705,12 @@ export const chatScreenStyles = StyleSheet.create({ paddingTop: 4, paddingBottom: 8, borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: '#F0F0F0', + borderBottomColor: colors.chat.borderHairline, }, mentionPanelTitle: { fontSize: 12, fontWeight: '600', - color: '#AAAAAA', + color: colors.chat.textPlaceholder, letterSpacing: 0.5, textTransform: 'uppercase', }, @@ -680,7 +718,7 @@ export const chatScreenStyles = StyleSheet.create({ width: 26, height: 26, borderRadius: 13, - backgroundColor: '#F5F5F5', + backgroundColor: colors.chat.surfaceInput, alignItems: 'center', justifyContent: 'center', }, @@ -722,7 +760,7 @@ export const chatScreenStyles = StyleSheet.create({ mentionItemName: { fontSize: 15, fontWeight: '500', - color: '#1A1A1A', + color: colors.chat.textPrimary, }, mentionItemNameAll: { fontSize: 15, @@ -731,7 +769,7 @@ export const chatScreenStyles = StyleSheet.create({ }, mentionItemSub: { fontSize: 12, - color: '#BBBBBB', + color: colors.chat.iconMuted, marginTop: 1, }, mentionItemRole: { @@ -746,7 +784,7 @@ export const chatScreenStyles = StyleSheet.create({ }, mentionEmptyText: { fontSize: 14, - color: '#C8C8C8', + color: colors.chat.iconSoft, }, // 长按菜单 - 底部横条形式(类似QQ) @@ -757,10 +795,10 @@ export const chatScreenStyles = StyleSheet.create({ }, // 旧版菜单样式(保留兼容) menuContainer: { - backgroundColor: '#FFFFFF', + backgroundColor: colors.chat.card, borderRadius: 12, minWidth: 160, - shadowColor: '#000', + shadowColor: colors.chat.shadow, shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.15, shadowRadius: 12, @@ -775,21 +813,21 @@ export const chatScreenStyles = StyleSheet.create({ }, menuItemBorder: { borderBottomWidth: 1, - borderBottomColor: '#F0F0F0', + borderBottomColor: colors.chat.borderHairline, }, menuItemText: { fontSize: 15, - color: '#333', + color: colors.text.primary, fontWeight: '500', }, // 底部菜单容器 bottomMenuContainer: { - backgroundColor: '#FFFFFF', + backgroundColor: colors.chat.card, borderTopLeftRadius: 20, borderTopRightRadius: 20, paddingTop: spacing.md, paddingBottom: spacing.xl, - shadowColor: '#000', + shadowColor: colors.chat.shadow, shadowOffset: { width: 0, height: -4 }, shadowOpacity: 0.1, shadowRadius: 8, @@ -797,7 +835,7 @@ export const chatScreenStyles = StyleSheet.create({ }, // 消息预览区域 messagePreviewContainer: { - backgroundColor: '#F5F7FA', + backgroundColor: colors.chat.surfaceMuted, marginHorizontal: spacing.md, marginBottom: spacing.md, paddingHorizontal: spacing.md, @@ -807,7 +845,7 @@ export const chatScreenStyles = StyleSheet.create({ }, messagePreviewText: { fontSize: 14, - color: '#666', + color: colors.chat.textTertiary, lineHeight: 20, }, // 操作按钮横条 @@ -818,7 +856,7 @@ export const chatScreenStyles = StyleSheet.create({ paddingHorizontal: spacing.sm, paddingVertical: spacing.md, borderBottomWidth: 1, - borderBottomColor: '#F0F0F0', + borderBottomColor: colors.chat.borderHairline, marginBottom: spacing.md, }, menuActionItem: { @@ -833,19 +871,19 @@ export const chatScreenStyles = StyleSheet.create({ width: 56, height: 56, borderRadius: 16, - backgroundColor: '#F0F7FF', + backgroundColor: colors.chat.overlayQuote, alignItems: 'center', justifyContent: 'center', marginBottom: 8, }, menuActionLabel: { fontSize: 13, - color: '#333', + color: colors.text.primary, fontWeight: '500', }, // 取消按钮 menuCancelButton: { - backgroundColor: '#F5F7FA', + backgroundColor: colors.chat.surfaceMuted, marginHorizontal: spacing.md, paddingVertical: spacing.md, borderRadius: 12, @@ -853,7 +891,7 @@ export const chatScreenStyles = StyleSheet.create({ }, menuCancelText: { fontSize: 16, - color: '#333', + color: colors.text.primary, fontWeight: '600', }, @@ -868,7 +906,7 @@ export const chatScreenStyles = StyleSheet.create({ borderRadius: 6, paddingVertical: 6, paddingHorizontal: 2, - shadowColor: '#000', + shadowColor: colors.chat.shadow, shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.3, shadowRadius: 4, @@ -895,20 +933,21 @@ export const chatScreenStyles = StyleSheet.create({ }, qqMenuItemLabel: { fontSize: 11, - color: '#FFFFFF', + color: colors.primary.contrast, fontWeight: '500', }, - // 回复预览 + // 回复预览(输入区)- 与气泡内引用同色条 replyPreviewContainer: { flexDirection: 'row', alignItems: 'center', - backgroundColor: '#F5F7FA', + backgroundColor: 'rgba(74, 136, 199, 0.08)', paddingHorizontal: spacing.md, paddingVertical: spacing.sm, borderLeftWidth: 3, - borderLeftColor: colors.primary.main, + borderLeftColor: colors.chat.link, marginBottom: spacing.xs, + borderRadius: 8, }, replyPreviewContent: { flex: 1, @@ -926,7 +965,7 @@ export const chatScreenStyles = StyleSheet.create({ }, replyPreviewMessage: { fontSize: 12, - color: '#8E8E93', + color: colors.chat.textSecondary, marginTop: 2, }, replyPreviewClose: { @@ -942,11 +981,11 @@ export const chatScreenStyles = StyleSheet.create({ zIndex: 1000, }, overlayContent: { - backgroundColor: '#FFFFFF', + backgroundColor: colors.chat.card, padding: spacing.xl, borderRadius: 16, alignItems: 'center', - shadowColor: '#000', + shadowColor: colors.chat.shadow, shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.15, shadowRadius: 12, @@ -955,13 +994,13 @@ export const chatScreenStyles = StyleSheet.create({ overlayText: { marginTop: spacing.md, fontSize: 15, - color: '#666', + color: colors.chat.textTertiary, fontWeight: '500', }, // 表情包添加按钮(管理界面第一位) stickerAddButton: { - backgroundColor: '#FFFFFF', + backgroundColor: colors.chat.card, borderWidth: 1.5, borderColor: colors.primary.main, borderStyle: 'dashed', @@ -980,9 +1019,9 @@ export const chatScreenStyles = StyleSheet.create({ // 表情包管理按钮(表情面板第一位) stickerManageButton: { - backgroundColor: '#FFFFFF', + backgroundColor: colors.chat.card, borderWidth: 1, - borderColor: '#E8E8E8', + borderColor: colors.chat.border, borderStyle: 'dashed', alignItems: 'center', justifyContent: 'center', @@ -994,7 +1033,7 @@ export const chatScreenStyles = StyleSheet.create({ }, stickerManageText: { fontSize: 12, - color: '#666', + color: colors.chat.textTertiary, marginTop: 4, fontWeight: '500', }, @@ -1012,24 +1051,24 @@ export const chatScreenStyles = StyleSheet.create({ width: 36, height: 5, borderRadius: 3, - backgroundColor: '#E0E0E0', + backgroundColor: colors.chat.sheetGrip, alignSelf: 'center', marginTop: 8, marginBottom: 4, }, manageHeaderDivider: { height: StyleSheet.hairlineWidth, - backgroundColor: '#F0F0F0', + backgroundColor: colors.chat.borderHairline, }, // 表情包选择状态 stickerItemSelected: { borderWidth: 2, - borderColor: '#007AFF', + borderColor: colors.chat.replyBorder, }, stickerCheckOverlay: { ...StyleSheet.absoluteFillObject, - backgroundColor: 'rgba(0, 122, 255, 0.1)', + backgroundColor: 'rgba(127, 182, 230, 0.14)', alignItems: 'flex-end', justifyContent: 'flex-start', padding: 4, @@ -1039,19 +1078,19 @@ export const chatScreenStyles = StyleSheet.create({ height: 22, borderRadius: 11, borderWidth: 2, - borderColor: '#FFF', + borderColor: colors.chat.card, backgroundColor: 'rgba(255, 255, 255, 0.8)', alignItems: 'center', justifyContent: 'center', }, stickerCheckBoxSelected: { - backgroundColor: '#007AFF', - borderColor: '#007AFF', + backgroundColor: colors.chat.replyBorder, + borderColor: colors.chat.replyBorder, }, // 表情管理模态框 manageModalContainer: { - backgroundColor: '#F5F7FA', + backgroundColor: colors.chat.surfaceMuted, borderTopLeftRadius: 20, borderTopRightRadius: 20, overflow: 'hidden', @@ -1062,9 +1101,9 @@ export const chatScreenStyles = StyleSheet.create({ justifyContent: 'space-between', paddingHorizontal: spacing.md, paddingVertical: spacing.md, - backgroundColor: '#FFFFFF', + backgroundColor: colors.chat.card, borderBottomWidth: 1, - borderBottomColor: '#E8E8E8', + borderBottomColor: colors.chat.border, }, manageHeaderButton: { minWidth: 60, @@ -1073,28 +1112,28 @@ export const chatScreenStyles = StyleSheet.create({ manageModalTitle: { fontSize: 17, fontWeight: '600', - color: '#1A1A1A', + color: colors.chat.textPrimary, }, manageDoneText: { fontSize: 16, - color: '#007AFF', + color: colors.chat.link, fontWeight: '500', }, manageSelectText: { fontSize: 16, - color: '#007AFF', + color: colors.chat.link, fontWeight: '500', }, manageInfoBar: { paddingHorizontal: spacing.md, paddingVertical: spacing.sm, - backgroundColor: '#FFFFFF', + backgroundColor: colors.chat.card, borderBottomWidth: 1, - borderBottomColor: '#E8E8E8', + borderBottomColor: colors.chat.border, }, manageInfoText: { fontSize: 13, - color: '#8E8E93', + color: colors.chat.textSecondary, }, manageStickerGrid: { flexDirection: 'row', @@ -1109,9 +1148,9 @@ export const chatScreenStyles = StyleSheet.create({ justifyContent: 'space-between', paddingHorizontal: spacing.md, paddingVertical: spacing.md, - backgroundColor: '#FFFFFF', + backgroundColor: colors.chat.card, borderTopWidth: 1, - borderTopColor: '#E8E8E8', + borderTopColor: colors.chat.border, paddingBottom: 34, // 安全区域 }, manageSelectAllButton: { @@ -1120,24 +1159,24 @@ export const chatScreenStyles = StyleSheet.create({ }, manageSelectAllText: { fontSize: 15, - color: '#007AFF', + color: colors.chat.link, fontWeight: '500', }, manageDeleteButton: { flexDirection: 'row', alignItems: 'center', - backgroundColor: '#FF3B30', + backgroundColor: colors.chat.danger, paddingHorizontal: spacing.lg, paddingVertical: spacing.sm + 2, borderRadius: 20, gap: 6, }, manageDeleteButtonDisabled: { - backgroundColor: '#CCC', + backgroundColor: colors.chat.iconMuted, }, manageDeleteText: { fontSize: 15, - color: '#FFFFFF', + color: colors.primary.contrast, fontWeight: '600', }, manageTipBar: { @@ -1146,12 +1185,12 @@ export const chatScreenStyles = StyleSheet.create({ justifyContent: 'center', paddingHorizontal: spacing.md, paddingVertical: spacing.sm, - backgroundColor: '#FFF9E6', + backgroundColor: colors.chat.tipBg, gap: 6, }, manageTipText: { fontSize: 12, - color: '#999', + color: colors.chat.textMuted, }, // 管理界面空状态 @@ -1180,5 +1219,9 @@ export const chatScreenStyles = StyleSheet.create({ textAlign: 'center', }, }); +} -export default chatScreenStyles; +export function useChatScreenStyles() { + const colors = useAppColors(); + return useMemo(() => createChatScreenStyles(colors), [colors]); +} diff --git a/src/screens/message/components/ChatScreen/types.ts b/src/screens/message/components/ChatScreen/types.ts index ea704fa..866f6bd 100644 --- a/src/screens/message/components/ChatScreen/types.ts +++ b/src/screens/message/components/ChatScreen/types.ts @@ -73,8 +73,6 @@ export interface MessageBubbleProps { otherUser: { id?: string; nickname?: string; avatar?: string | null } | null; isGroupChat: boolean; groupMembers: GroupMemberResponse[]; - /** @deprecated 发送者信息现在直接从 message.sender 获取,不再使用 senderCache */ - senderCache?: Map; otherUserLastReadSeq: number; selectedMessageId: string | null; // 消息映射,用于查找被回复的消息 @@ -86,6 +84,17 @@ export interface MessageBubbleProps { shouldShowTime: (index: number) => boolean; // 图片点击回调 onImagePress?: (images: { id: string; url: string; thumbnail_url?: string; width?: number; height?: number }[], index: number) => void; + // 滑动回复回调 + onReply?: (message: GroupMessage) => void; + // 点击回复预览回调(定位到原消息) + onReplyPress?: (messageId: string) => void; +} + +/** 输入框中待发送的本地图片(未上传) */ +export interface PendingChatAttachment { + id: string; + uri: string; + mimeType?: string; } // 输入框 Props @@ -96,7 +105,8 @@ export interface ChatInputProps { onToggleEmoji: () => void; onToggleMore: () => void; activePanel: PanelType; - sending: boolean; + /** 发送消息或上传附件进行中,用于禁用发送按钮 */ + isComposerBusy: boolean; isMuted: boolean; isGroupChat: boolean; muteAll: boolean; @@ -105,6 +115,8 @@ export interface ChatInputProps { onFocus: () => void; restrictionHint?: string | null; disableMore?: boolean; + pendingAttachments?: PendingChatAttachment[]; + onRemovePendingAttachment?: (id: string) => void; } // 表情面板 Props @@ -180,6 +192,14 @@ export interface ReplyPreviewProps { onCancel: () => void; } +// 可滑动消息气泡 Props +export interface SwipeableMessageBubbleProps { + children: React.ReactNode; + isMe: boolean; + onReply: () => void; + enabled?: boolean; +} + // ChatScreen 状态接口 export interface ChatScreenState { // 基础状态 diff --git a/src/screens/message/components/ChatScreen/useChatScreen.ts b/src/screens/message/components/ChatScreen/useChatScreen.ts index 33ab6e7..225f410 100644 --- a/src/screens/message/components/ChatScreen/useChatScreen.ts +++ b/src/screens/message/components/ChatScreen/useChatScreen.ts @@ -17,7 +17,7 @@ import { KeyboardEvent, Alert, } from 'react-native'; -import { useRoute, RouteProp, useNavigation } from '@react-navigation/native'; +import { useLocalSearchParams, router } from 'expo-router'; import { formatDistanceToNow } from 'date-fns'; import { zhCN } from 'date-fns/locale'; import * as ImagePicker from 'expo-image-picker'; @@ -30,8 +30,8 @@ import { useChat, useGroupTyping, useGroupMuted, messageManager } from '../../.. import { groupService } from '../../../../services/groupService'; import { userManager } from '../../../../stores/userManager'; import { groupManager } from '../../../../stores/groupManager'; -import { RootStackParamList } from '../../../../navigation/types'; -import { navigationService } from '../../../../infrastructure/navigation/navigationService'; +import * as hrefs from '../../../../navigation/hrefs'; +import { firstRouteParam } from '../../../../navigation/paramUtils'; import { GroupMessage, PanelType, @@ -39,15 +39,14 @@ import { SenderInfo, ChatRouteParams, MenuPosition, + PendingChatAttachment, } from './types'; -import { TIME_SEPARATOR_INTERVAL, MEMBERS_PAGE_SIZE } from './constants'; +import { TIME_SEPARATOR_INTERVAL, MEMBERS_PAGE_SIZE, MAX_PENDING_CHAT_IMAGES } from './constants'; import { deleteMessage as deleteMessageFromDb, clearConversationMessages, } from '../../../../services/database'; -type ChatRouteProp = RouteProp; - export const useChatScreen = () => { const getSendErrorMessage = useCallback((error: unknown, fallback: string) => { if (error instanceof ApiError && error.message) { @@ -56,17 +55,30 @@ export const useChatScreen = () => { return fallback; }, []); - const route = useRoute(); - const navigation = useNavigation(); - - // 路由参数 - const routeParams = useMemo(() => ({ - conversationId: route.params?.conversationId || null, - userId: route.params?.userId || null, - isGroupChat: route.params?.isGroupChat || false, - groupId: route.params?.groupId, - groupName: route.params?.groupName, - }), [route.params?.conversationId, route.params?.userId, route.params?.isGroupChat, route.params?.groupId, route.params?.groupName]); + const rawParams = useLocalSearchParams<{ + conversationId?: string | string[]; + userId?: string | string[]; + isGroupChat?: string | string[]; + groupId?: string | string[]; + groupName?: string | string[]; + }>(); + // 路由参数(动态段 + query);群 ID 勿用 Number(),避免超过 2^53-1 时精度丢失 + const routeParams = useMemo(() => { + const isGroupFlag = firstRouteParam(rawParams.isGroupChat); + return { + conversationId: firstRouteParam(rawParams.conversationId) ?? null, + userId: firstRouteParam(rawParams.userId) ?? null, + isGroupChat: isGroupFlag === '1' || isGroupFlag === 'true', + groupId: firstRouteParam(rawParams.groupId), + groupName: firstRouteParam(rawParams.groupName), + }; + }, [ + rawParams.conversationId, + rawParams.userId, + rawParams.isGroupChat, + rawParams.groupId, + rawParams.groupName, + ]); const { conversationId: routeConversationId, userId: routeUserId, isGroupChat, groupId: routeGroupId, groupName: routeGroupName } = routeParams; @@ -109,15 +121,25 @@ export const useChatScreen = () => { const [otherUserLastReadSeq, setOtherUserLastReadSeq] = useState(0); const [activePanel, setActivePanel] = useState('none'); const [sendingImage, setSendingImage] = useState(false); + const [uploadingAttachments, setUploadingAttachments] = useState(false); + const [pendingAttachments, setPendingAttachments] = useState([]); const [loadingMore, setLoadingMore] = useState(false); const [hasMoreHistory, setHasMoreHistory] = useState(true); const flatListRef = useRef(null); const textInputRef = useRef(null); - // 防止重复加载的 ref - const shouldAutoScrollOnEnterRef = useRef(true); - const autoScrollTimersRef = useRef[]>([]); - const scrollPositionRef = useRef({ contentHeight: 0, scrollY: 0 }); + // 滚动状态机 refs(Telegram/Element 风格:底部粘附 + 阅读锚点) + const scrollPositionRef = useRef({ contentHeight: 0, scrollY: 0, viewportHeight: 0 }); + const hasInitialAnchorDoneRef = useRef(false); + const prevMessageCountRef = useRef(0); + const prevLatestSeqRef = useRef(0); + const prevMarkedReadSeqRef = useRef(0); + const enterMarkedKeyRef = useRef(''); + const isProgrammaticScrollRef = useRef(false); + const suppressAutoFollowRef = useRef(false); + const isBrowsingHistoryRef = useRef(false); + const hasShownMessageListRef = useRef(false); + const historyLoadingLockUntilRef = useRef(0); // 回复消息状态 const [replyingTo, setReplyingTo] = useState(null); @@ -196,10 +218,20 @@ export const useChatScreen = () => { return '对方未关注你前:仅可发送1条文字消息,且不能发送图片'; }, [followRestricted, myPrivateSentCount]); - // 【改造】同步加载状态 + // 加载态语义修正: + // isLoadingMessages 在分页加载历史时也会短暂为 true。 + // 若直接驱动 ChatScreen 的 loading,会导致消息列表被卸载重挂载,触发“回到底部”。 + // 这里只在“首屏且尚未展示过消息列表”时展示 loading 占位。 useEffect(() => { - setLoading(isLoadingMessages); - }, [isLoadingMessages]); + if (messageManagerMessages.length > 0) { + hasShownMessageListRef.current = true; + } + const shouldShowInitialLoading = + !hasShownMessageListRef.current && + isLoadingMessages && + messageManagerMessages.length === 0; + setLoading(shouldShowInitialLoading); + }, [isLoadingMessages, messageManagerMessages.length]); // 【改造】同步 hasMore 状态 useEffect(() => { @@ -263,32 +295,93 @@ export const useChatScreen = () => { }; }, [isGroupChat, otherUserId]); - // 进入新会话时重置首次自动滚动标记 + // 进入新会话时重置滚动状态 useEffect(() => { - shouldAutoScrollOnEnterRef.current = true; - autoScrollTimersRef.current.forEach(clearTimeout); - autoScrollTimersRef.current = []; + hasInitialAnchorDoneRef.current = false; + prevMessageCountRef.current = 0; + prevLatestSeqRef.current = 0; + prevMarkedReadSeqRef.current = 0; + enterMarkedKeyRef.current = ''; + suppressAutoFollowRef.current = false; + isBrowsingHistoryRef.current = false; + hasShownMessageListRef.current = false; + historyLoadingLockUntilRef.current = 0; }, [conversationId]); - // 组件卸载时清理定时器 useEffect(() => { - return () => { - autoScrollTimersRef.current.forEach(clearTimeout); - autoScrollTimersRef.current = []; - }; + setPendingAttachments([]); + }, [conversationId]); + + const removePendingAttachment = useCallback((id: string) => { + setPendingAttachments(prev => prev.filter(p => p.id !== id)); }, []); - // 消息加载完成后滚动到底部 + const isComposerBusy = useMemo( + () => sending || sendingImage || uploadingAttachments, + [sending, sendingImage, uploadingAttachments] + ); + + const isHistoryLoadingLocked = useCallback(() => { + return Date.now() < historyLoadingLockUntilRef.current; + }, []); + + const scrollToLatest = useCallback((animated: boolean, force: boolean = false, reason: string = 'unknown') => { + if (!force && (suppressAutoFollowRef.current || isBrowsingHistoryRef.current || isHistoryLoadingLocked())) return; + // inverted 列表下,最新消息端对应 offset=0 + isProgrammaticScrollRef.current = true; + flatListRef.current?.scrollToOffset({ + offset: 0, + animated, + }); + setTimeout(() => { + isProgrammaticScrollRef.current = false; + }, animated ? 220 : 32); + }, [flatListRef, isHistoryLoadingLocked]); + + const isNearBottom = useCallback(() => { + const scrollY = scrollPositionRef.current.scrollY || 0; + // inverted 列表下,越接近 0 越靠近最新消息端 + return scrollY <= 100; + }, [scrollPositionRef]); + + // 首屏加载完成后仅锚定一次到最新消息端 useEffect(() => { - if (!loading && !loadingMore && messages.length > 0 && shouldAutoScrollOnEnterRef.current) { - const timer = setTimeout(() => { - if (shouldAutoScrollOnEnterRef.current) { - flatListRef.current?.scrollToEnd({ animated: false }); - } - }, 500); - return () => clearTimeout(timer); + if ( + loading || + loadingMore || + messages.length === 0 || + hasInitialAnchorDoneRef.current || + scrollPositionRef.current.viewportHeight <= 0 + ) { + return; } - }, [loading, loadingMore, messages.length]); + hasInitialAnchorDoneRef.current = true; + const timer = setTimeout(() => { + scrollToLatest(false, true, 'initial-anchor'); + }, 0); + return () => clearTimeout(timer); + }, [loading, loadingMore, messages.length, scrollToLatest]); + + // 新消息跟随策略(Telegram/QQ 风格): + // 仅当“最新端消息 seq 增长”且用户在底部附近时才跟随; + // 历史加载只会增加旧消息,不会提升 latest seq,因此不会触发回底。 + useEffect(() => { + const currentCount = messages.length; + const latestSeq = currentCount > 0 ? Math.max(...messages.map(m => m.seq || 0)) : 0; + const prevLatestSeq = prevLatestSeqRef.current; + prevMessageCountRef.current = currentCount; + prevLatestSeqRef.current = latestSeq; + + if (loading || loadingMore) return; + if (latestSeq <= prevLatestSeq) return; + if (suppressAutoFollowRef.current) return; + if (!isNearBottom()) return; + + const timer = setTimeout(() => { + scrollToLatest(false, false, 'new-message-follow'); + }, 0); + return () => clearTimeout(timer); + }, [messages, loading, loadingMore, isNearBottom, scrollToLatest]); // 获取当前用户信息 useEffect(() => { @@ -333,8 +426,30 @@ export const useChatScreen = () => { console.error('获取成员信息失败:', error); } } catch (error) { + const isGroupNotFound = + error instanceof ApiError && + (error.code === 404 || + error.message === '群组不存在' || + error.message.includes('群组不存在')); + + if (isGroupNotFound) { + Alert.alert('提示', '该群组不存在或已解散', [ + { + text: '确定', + onPress: () => { + if (router.canGoBack()) { + router.back(); + } else { + router.replace(hrefs.hrefMessages()); + } + }, + }, + ]); + return; + } + console.error('获取群组信息失败:', error); - Alert.alert('错误', '无法获取群组信息'); + Alert.alert('错误', getSendErrorMessage(error, '无法获取群组信息')); } }; @@ -384,12 +499,20 @@ export const useChatScreen = () => { }; }, [routeUserId, conversationId, currentUserId, isGroupChat]); - // 【改造】加载更多历史消息 + // 加载更多历史消息(inverted 下保持阅读锚点) const loadMoreHistory = useCallback(async () => { if (!conversationId || !hasMoreHistory || loadingMore) { return; } + // 历史加载期间禁止“新消息自动跟随到底” + suppressAutoFollowRef.current = true; + isBrowsingHistoryRef.current = true; + historyLoadingLockUntilRef.current = Date.now() + 3000; + + // 保存加载前的滚动位置和内容高度 + const scrollYBefore = scrollPositionRef.current.scrollY; + const contentHeightBefore = scrollPositionRef.current.contentHeight; setLoadingMore(true); try { @@ -400,49 +523,87 @@ export const useChatScreen = () => { const minSeq = Math.min(...messages.map(m => m.seq)); setFirstSeq(minSeq); } + + // inverted + maintainVisibleContentPosition 下由列表原生保持位置 + // 不做手动 scrollToOffset,避免与原生锚点冲突导致回到底部 } catch (error) { console.error('加载历史消息失败:', error); } finally { setLoadingMore(false); + // 加载结束后继续保留短暂锁窗,避免布局结算阶段误触自动回底 + historyLoadingLockUntilRef.current = Date.now() + 800; } }, [conversationId, hasMoreHistory, loadingMore, loadMoreMessages, messages]); - // 列表内容尺寸变化后触发首次自动滚动 + // 列表内容尺寸变化:仅同步内容高度,锚点补偿由 loadMoreHistory 统一处理 const handleMessageListContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => { - if (!shouldAutoScrollOnEnterRef.current) return; - if (loading || loadingMore) return; - if (messages.length === 0) return; - - const prevContentHeight = scrollPositionRef.current.contentHeight; scrollPositionRef.current.contentHeight = contentHeight; + }, []); - if (prevContentHeight > 0 && contentHeight > prevContentHeight) { - return; - } + const handleReachLatestEdge = useCallback(() => { + if (isHistoryLoadingLocked()) return; + suppressAutoFollowRef.current = false; + isBrowsingHistoryRef.current = false; + }, [isHistoryLoadingLocked]); - shouldAutoScrollOnEnterRef.current = false; - autoScrollTimersRef.current.forEach(clearTimeout); - autoScrollTimersRef.current = []; + /** 用户点击「回到底部」:解除浏览历史锁并滚到最新消息端(inverted 下 offset=0) */ + const jumpToLatestMessages = useCallback(() => { + suppressAutoFollowRef.current = false; + isBrowsingHistoryRef.current = false; + scrollToLatest(true, true, 'jump-latest-button'); + }, [scrollToLatest]); - [100, 300, 500, 800, 1200].forEach(delay => { - const timer = setTimeout(() => { - flatListRef.current?.scrollToEnd({ animated: false }); - }, delay); - autoScrollTimersRef.current.push(timer); + const setBrowsingHistory = useCallback((browsing: boolean) => { + isBrowsingHistoryRef.current = browsing; + }, []); + + // 进入聊天详情后立即清未读(不依赖滚动位置) + useEffect(() => { + if (!conversationId) return; + if (!conversation) return; + + const unreadCount = Number((conversation as any).unread_count || 0); + if (unreadCount <= 0) return; + + const latestFromConversation = Number((conversation as any).last_seq || 0); + const latestFromMessages = messages.length > 0 ? Math.max(...messages.map(m => m.seq || 0)) : 0; + const targetSeq = Math.max(latestFromConversation, latestFromMessages); + if (targetSeq <= 0) return; + + const markKey = `${conversationId}:${targetSeq}`; + if (enterMarkedKeyRef.current === markKey) return; + enterMarkedKeyRef.current = markKey; + + prevMarkedReadSeqRef.current = Math.max(prevMarkedReadSeqRef.current, targetSeq); + markAsRead(targetSeq).catch(error => { + console.error('[ChatScreen] 进入会话清未读失败:', error); }); - }, [loading, loadingMore, messages.length]); + }, [conversationId, conversation, messages, markAsRead]); - // 【改造】自动标记已读 - 当有新消息且是当前会话时 + // 自动标记已读(QQ/Telegram 风格): + // 仅当出现更大的 latest seq,且用户在最新端附近时才上报。 + // 历史加载/浏览历史不会触发 read。 useEffect(() => { if (!conversationId || messages.length === 0) return; + if (loading || loadingMore) return; + if (suppressAutoFollowRef.current || isBrowsingHistoryRef.current) return; + if (!isNearBottom()) return; - const maxSeq = Math.max(...messages.map(m => m.seq), 0); - if (maxSeq > 0) { - markAsRead(maxSeq).catch(error => { - console.error('[ChatScreen] 自动标记已读失败:', error); - }); - } - }, [messages, conversationId, markAsRead]); + const latestSeq = Math.max(...messages.map(m => m.seq), 0); + if (latestSeq <= 0) return; + + // 没有新增最新消息,不重复上报 + if (latestSeq <= prevLatestSeqRef.current) return; + prevLatestSeqRef.current = latestSeq; + + // 避免对同一 seq 重复标记 + if (latestSeq <= prevMarkedReadSeqRef.current) return; + prevMarkedReadSeqRef.current = latestSeq; + + markAsRead(latestSeq).catch(error => { + console.error('[ChatScreen] 自动标记已读失败:', error); + }); + }, [messages, conversationId, markAsRead, loading, loadingMore, isNearBottom]); // 使用 ref 存储 groupMembers const groupMembersRef = useRef(groupMembers); @@ -461,16 +622,10 @@ export const useChatScreen = () => { const keyboardWillShow = (e: KeyboardEvent) => { setKeyboardHeight(e.endCoordinates.height); setActivePanel(prev => prev === 'mention' ? 'mention' : 'none'); - setTimeout(() => { - flatListRef.current?.scrollToEnd({ animated: false }); - }, 150); }; const keyboardWillHide = () => { setKeyboardHeight(0); - setTimeout(() => { - flatListRef.current?.scrollToEnd({ animated: false }); - }, 100); }; const showSubscription = Keyboard.addListener( @@ -701,10 +856,11 @@ export const useChatScreen = () => { return segments; }, []); - // 【新架构】发送消息 + // 【新架构】发送消息(支持纯文字、纯多图、图文同条) const handleSend = useCallback(async () => { const trimmedText = inputText.trim(); - if (!trimmedText || !conversationId) return; + const hasPending = pendingAttachments.length > 0; + if ((!trimmedText && !hasPending) || !conversationId) return; if (isGroupChat && isMuted) { if (muteAll) { @@ -715,15 +871,61 @@ export const useChatScreen = () => { return; } - if (!isGroupChat && !canSendFirstPrivateText) { - Alert.alert('无法发送', '对方未关注你前,仅允许发送一条消息'); - return; + if (!isGroupChat) { + if (trimmedText && !canSendFirstPrivateText) { + Alert.alert('无法发送', '对方未关注你前,仅允许发送一条消息'); + return; + } + if (hasPending && !canSendPrivateImage) { + Alert.alert('无法发送', '对方未关注你前,暂不支持发送图片'); + return; + } + } + + const uploadedUrls: string[] = []; + if (hasPending) { + setUploadingAttachments(true); + try { + const results = await Promise.all( + pendingAttachments.map(p => + uploadService.uploadChatImage({ uri: p.uri, type: p.mimeType }) + ) + ); + for (let i = 0; i < results.length; i++) { + const r = results[i]; + if (!r?.url) { + Alert.alert('上传失败', `第 ${i + 1} 张图片上传失败,请重试`); + return; + } + uploadedUrls.push(r.url); + } + } catch (error) { + console.error('上传图片失败:', error); + Alert.alert('上传失败', getSendErrorMessage(error, '图片上传失败,请重试')); + return; + } finally { + setUploadingAttachments(false); + } } setSending(true); try { - const segments = buildTextSegments(trimmedText, replyingTo); + const segments: MessageSegment[] = [...buildTextSegments(trimmedText, replyingTo)]; + for (const url of uploadedUrls) { + segments.push({ + type: 'image', + data: { + url, + thumbnail_url: url, + } as ImageSegmentData, + }); + } + + if (segments.length === 0) { + Alert.alert('无法发送', '消息内容为空'); + return; + } if (isGroupChat && routeGroupId) { await messageService.sendMessageByAction('group', conversationId, segments); @@ -732,15 +934,18 @@ export const useChatScreen = () => { setSelectedMentions([]); setMentionAll(false); setReplyingTo(null); + setPendingAttachments([]); } else { - // 【新架构】私聊消息通过 MessageManager 发送 await sendMessageViaManager(segments); setInputText(''); + setSelectedMentions([]); + setMentionAll(false); setReplyingTo(null); + setPendingAttachments([]); } setTimeout(() => { - flatListRef.current?.scrollToEnd({ animated: true }); + scrollToLatest(false, false, hasPending ? 'send-mixed' : 'send-text'); }, 100); } catch (error) { console.error('发送消息失败:', error); @@ -748,60 +953,31 @@ export const useChatScreen = () => { } finally { setSending(false); } - }, [inputText, conversationId, isGroupChat, routeGroupId, selectedMentions, mentionAll, sendMessageViaManager, isMuted, muteAll, buildTextSegments, replyingTo, getSendErrorMessage, canSendFirstPrivateText]); + }, [ + inputText, + pendingAttachments, + conversationId, + isGroupChat, + routeGroupId, + sendMessageViaManager, + isMuted, + muteAll, + buildTextSegments, + replyingTo, + getSendErrorMessage, + canSendFirstPrivateText, + canSendPrivateImage, + scrollToLatest, + ]); - // 发送图片消息 - const handleSendImage = useCallback(async (imageUri: string, mimeType?: string) => { - if (!conversationId) return; - - if (isGroupChat && isMuted) { - if (muteAll) { - Alert.alert('无法发送', '当前群组已开启全员禁言'); - } else { - Alert.alert('无法发送', '你已被管理员禁言'); - } - return; - } - - if (!isGroupChat && !canSendPrivateImage) { - Alert.alert('无法发送', '对方未关注你前,暂不支持发送图片'); - return; - } - - setSendingImage(true); - - try { - const uploadResult = await uploadService.uploadChatImage({ uri: imageUri, type: mimeType }); - - if (!uploadResult) { - Alert.alert('上传失败', '图片上传失败,请重试'); - return; - } - - const segments = buildImageSegments(uploadResult.url, uploadResult.url, undefined, undefined, replyingTo); - - if (isGroupChat && routeGroupId) { - await messageService.sendMessageByAction('group', conversationId, segments); - setReplyingTo(null); - } else { - // 【新架构】私聊图片通过 MessageManager 发送 - await sendMessageViaManager(segments); - setReplyingTo(null); - } - - setTimeout(() => { - flatListRef.current?.scrollToEnd({ animated: true }); - }, 100); - } catch (error) { - console.error('发送图片失败:', error); - Alert.alert('发送失败', getSendErrorMessage(error, '图片发送失败,请重试')); - } finally { - setSendingImage(false); - } - }, [conversationId, isGroupChat, routeGroupId, sendMessageViaManager, isMuted, muteAll, buildImageSegments, replyingTo, getSendErrorMessage, canSendPrivateImage]); - - // 选择图片 + // 选择图片(多选进入输入框待发送,点发送时与文字一并发出) const handlePickImage = useCallback(async () => { + if (pendingAttachments.length >= MAX_PENDING_CHAT_IMAGES) { + Alert.alert('提示', `最多添加 ${MAX_PENDING_CHAT_IMAGES} 张图片`); + setActivePanel('none'); + return; + } + try { const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); @@ -810,16 +986,23 @@ export const useChatScreen = () => { return; } + const remaining = MAX_PENDING_CHAT_IMAGES - pendingAttachments.length; + const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: 'images', allowsEditing: false, quality: 1, - allowsMultipleSelection: false, + allowsMultipleSelection: true, + selectionLimit: remaining, }); if (!result.canceled && result.assets && result.assets.length > 0) { - const asset = result.assets[0]; - await handleSendImage(asset.uri, asset.mimeType ?? undefined); + const newItems: PendingChatAttachment[] = result.assets.map((asset, i) => ({ + id: `${Date.now()}-${i}-${Math.random().toString(36).slice(2, 9)}`, + uri: asset.uri, + mimeType: asset.mimeType ?? undefined, + })); + setPendingAttachments(prev => [...prev, ...newItems].slice(0, MAX_PENDING_CHAT_IMAGES)); } } catch (error) { console.error('选择图片失败:', error); @@ -827,10 +1010,16 @@ export const useChatScreen = () => { } setActivePanel('none'); - }, [handleSendImage]); + }, [pendingAttachments.length]); - // 拍摄照片 + // 拍摄照片(加入待发送队列) const handleTakePhoto = useCallback(async () => { + if (pendingAttachments.length >= MAX_PENDING_CHAT_IMAGES) { + Alert.alert('提示', `最多添加 ${MAX_PENDING_CHAT_IMAGES} 张图片`); + setActivePanel('none'); + return; + } + try { const { status } = await ImagePicker.requestCameraPermissionsAsync(); @@ -847,7 +1036,18 @@ export const useChatScreen = () => { if (!result.canceled && result.assets && result.assets.length > 0) { const asset = result.assets[0]; - await handleSendImage(asset.uri, asset.mimeType ?? undefined); + setPendingAttachments(prev => + prev.length >= MAX_PENDING_CHAT_IMAGES + ? prev + : [ + ...prev, + { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + uri: asset.uri, + mimeType: asset.mimeType ?? undefined, + }, + ] + ); } } catch (error) { console.error('拍摄照片失败:', error); @@ -855,7 +1055,7 @@ export const useChatScreen = () => { } setActivePanel('none'); - }, [handleSendImage]); + }, [pendingAttachments.length]); // 处理更多功能 const handleMoreAction = useCallback((actionId: string) => { @@ -917,7 +1117,7 @@ export const useChatScreen = () => { } setTimeout(() => { - flatListRef.current?.scrollToEnd({ animated: true }); + scrollToLatest(false, false, 'send-sticker'); }, 100); } catch (error) { console.error('发送自定义表情失败:', error); @@ -925,18 +1125,13 @@ export const useChatScreen = () => { } finally { setSendingImage(false); } - }, [conversationId, isGroupChat, routeGroupId, sendMessageViaManager, isMuted, muteAll, buildImageSegments, replyingTo, canSendPrivateImage]); + }, [conversationId, isGroupChat, routeGroupId, sendMessageViaManager, isMuted, muteAll, buildImageSegments, replyingTo, canSendPrivateImage, scrollToLatest]); // 切换表情面板 const toggleEmojiPanel = useCallback(() => { Keyboard.dismiss(); setActivePanel(prev => { const newPanel = prev === 'emoji' ? 'none' : 'emoji'; - if (newPanel !== 'none') { - setTimeout(() => { - flatListRef.current?.scrollToEnd({ animated: false }); - }, 200); - } return newPanel; }); }, []); @@ -946,11 +1141,6 @@ export const useChatScreen = () => { Keyboard.dismiss(); setActivePanel(prev => { const newPanel = prev === 'more' ? 'none' : 'more'; - if (newPanel !== 'none') { - setTimeout(() => { - flatListRef.current?.scrollToEnd({ animated: false }); - }, 200); - } return newPanel; }); }, []); @@ -1040,7 +1230,7 @@ export const useChatScreen = () => { // 点击头像跳转到用户主页 const handleAvatarPress = useCallback((userId: string) => { if (userId && userId !== currentUserId) { - navigationService.navigate('UserProfile', { userId: String(userId) }); + router.push(hrefs.hrefUserProfile(String(userId))); } }, [currentUserId]); @@ -1109,31 +1299,27 @@ export const useChatScreen = () => { // 导航到群组信息或用户资料 const navigateToInfo = useCallback(() => { if (isGroupChat && routeGroupId) { - navigation.navigate('GroupInfo' as any, { - groupId: routeGroupId, - conversationId: conversationId || undefined, - }); + router.push(hrefs.hrefGroupInfo(routeGroupId, conversationId || undefined)); } else if (otherUser?.id) { - navigationService.navigate('UserProfile', { userId: String(otherUser.id) }); + router.push(hrefs.hrefUserProfile(String(otherUser.id))); } - }, [navigation, isGroupChat, routeGroupId, otherUser]); + }, [isGroupChat, routeGroupId, otherUser, conversationId]); // 导航到聊天管理页面 const navigateToChatSettings = useCallback(() => { if (isGroupChat && routeGroupId) { - navigation.navigate('GroupInfo' as any, { - groupId: routeGroupId, - conversationId: conversationId || undefined, - }); + router.push(hrefs.hrefGroupInfo(routeGroupId, conversationId || undefined)); } else if (otherUser?.id && conversationId) { - navigation.navigate('PrivateChatInfo' as any, { - conversationId: conversationId, - userId: String(otherUser.id), - userName: otherUser.nickname, - userAvatar: otherUser.avatar, - }); + router.push( + hrefs.hrefPrivateChatInfo({ + conversationId, + userId: String(otherUser.id), + userName: otherUser.nickname, + userAvatar: otherUser.avatar, + }) + ); } - }, [navigation, isGroupChat, routeGroupId, otherUser, conversationId]); + }, [isGroupChat, routeGroupId, otherUser, conversationId]); return { // 状态 @@ -1148,10 +1334,14 @@ export const useChatScreen = () => { sending, activePanel, sendingImage, + uploadingAttachments, + pendingAttachments, + isComposerBusy, replyingTo, longPressMenuVisible, selectedMessage, selectedMessageId, + setSelectedMessageId, menuPosition, isGroupChat, groupInfo, @@ -1182,8 +1372,8 @@ export const useChatScreen = () => { shouldShowTime, handleInputChange, handleSend, - handleSendImage, handlePickImage, + removePendingAttachment, handleTakePhoto, handleMoreAction, handleInsertEmoji, @@ -1212,5 +1402,8 @@ export const useChatScreen = () => { loadMoreHistory, handleClearConversation, handleMessageListContentSizeChange, + handleReachLatestEdge, + jumpToLatestMessages, + setBrowsingHistory, }; }; diff --git a/src/screens/message/components/ConversationListRow.tsx b/src/screens/message/components/ConversationListRow.tsx new file mode 100644 index 0000000..cf1e9b3 --- /dev/null +++ b/src/screens/message/components/ConversationListRow.tsx @@ -0,0 +1,393 @@ +/** + * 会话列表行:React.memo + 稳定比较,减少 Tab 切换等场景下的无关重渲染。 + * onPress 引用不参与比较,请配合父组件 ref + 按 id 查最新会话使用。 + */ + +import React, { memo, useEffect, useMemo, useRef, useState } from 'react'; +import { Animated, StyleSheet, TouchableOpacity, View } from 'react-native'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { spacing, shadows, useAppColors, type AppColors } from '../../../theme'; +import { + ConversationResponse, + extractTextFromSegments, + extractTextFromSegmentsAsync, + MessageSegment, +} from '../../../types/dto'; +import { authService } from '../../../services'; +import { getUserCache } from '../../../services/database'; +import { Avatar, Text } from '../../../components/common'; + +const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity); +const MAX_CONVERSATION_NAME_LENGTH = 10; + +const truncateDisplayName = (name: string, maxLength: number = MAX_CONVERSATION_NAME_LENGTH): string => { + if (!name) return ''; + if (name.length <= maxLength) return name; + return `${name.slice(0, maxLength)}...`; +}; + +/** + * 异步消息预览(与会话行同文件,仅供列表使用) + */ +const AsyncMessagePreview: React.FC<{ + segments?: MessageSegment[]; + status?: string; + isGroupChat?: boolean; + senderName?: string; +}> = ({ segments, status, isGroupChat, senderName }) => { + const [displayText, setDisplayText] = useState(''); + const isMountedRef = useRef(true); + + useEffect(() => { + isMountedRef.current = true; + + const loadPreview = async () => { + if (status === 'recalled') { + if (isMountedRef.current) { + setDisplayText('消息已撤回'); + } + return; + } + + const initialText = extractTextFromSegments(segments); + if (isMountedRef.current) { + setDisplayText(initialText); + } + + const hasAtWithoutNickname = segments?.some( + (s) => s.type === 'at' && s.data.user_id !== 'all' && !s.data.nickname + ); + + if (hasAtWithoutNickname) { + try { + const asyncText = await extractTextFromSegmentsAsync( + segments, + getUserCache, + authService.getUserById.bind(authService) + ); + if (isMountedRef.current && asyncText !== initialText) { + setDisplayText(asyncText); + } + } catch (error) { + console.warn('[AsyncMessagePreview] 获取用户名失败:', error); + } + } + }; + + loadPreview(); + + return () => { + isMountedRef.current = false; + }; + }, [segments, status]); + + if (!displayText) return null; + + if (isGroupChat && senderName) { + return ( + <> + {senderName}: {displayText} + + ); + } + + return <>{displayText}; +}; + +export interface ConversationListRowProps { + item: ConversationResponse; + index: number; + scale: Animated.Value | number; + isSelected: boolean; + onPress: () => void; + formatTime: (dateString: string) => string; + systemChannelId: string; +} + +function conversationVisualSignature(item: ConversationResponse): string { + const lm = item.last_message; + const lmSender = lm?.sender; + const lmPart = lm + ? [ + lm.id, + String(lm.seq), + lm.status, + String(lm.segments?.length ?? 0), + extractTextFromSegments(lm.segments), + lmSender?.nickname ?? '', + lmSender?.username ?? '', + ].join('|') + : ''; + const g = item.group; + const gPart = g ? [String(g.id), g.name, g.avatar ?? ''].join('|') : ''; + const p0 = item.participants?.[0]; + const pPart = p0 ? [p0.id, p0.nickname ?? '', p0.avatar ?? ''].join('|') : ''; + return [ + String(item.id), + item.type, + item.is_pinned ? '1' : '0', + String(item.unread_count), + item.last_message_at, + item.updated_at, + String(item.last_seq), + String(item.member_count ?? ''), + lmPart, + gPart, + pPart, + ].join('\u001f'); +} + +function areConversationRowEqual( + prev: ConversationListRowProps, + next: ConversationListRowProps +): boolean { + if (prev.isSelected !== next.isSelected) return false; + if (prev.index !== next.index) return false; + if (prev.scale !== next.scale) return false; + if (prev.systemChannelId !== next.systemChannelId) return false; + if (conversationVisualSignature(prev.item) !== conversationVisualSignature(next.item)) return false; + // onPress、formatTime 故意不比:父组件用 ref 保证行为最新 + return true; +} + +function createConversationRowStyles(colors: AppColors) { + return StyleSheet.create({ + conversationItem: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingVertical: 14, + backgroundColor: colors.background.paper, + marginHorizontal: spacing.md, + marginTop: spacing.sm, + borderRadius: 12, + ...shadows.sm, + }, + conversationItemSelected: { + backgroundColor: colors.primary.light + '20', + borderColor: colors.primary.main, + borderWidth: 1, + }, + avatarContainer: { + position: 'relative', + }, + systemAvatar: { + width: 50, + height: 50, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.primary.main, + }, + conversationContent: { + flex: 1, + marginLeft: spacing.md, + }, + conversationHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 4, + }, + nameRow: { + flexDirection: 'row', + alignItems: 'center', + }, + officialBadge: { + backgroundColor: colors.primary.main, + borderRadius: 4, + paddingHorizontal: 6, + paddingVertical: 2, + marginRight: spacing.xs, + }, + officialBadgeText: { + color: colors.primary.contrast, + fontSize: 10, + fontWeight: '600', + }, + userName: { + fontWeight: '600', + color: colors.text.primary, + fontSize: 16, + }, + groupIcon: { + marginRight: 4, + }, + memberCount: { + fontSize: 12, + color: colors.text.secondary, + marginLeft: 2, + }, + pinnedIcon: { + marginLeft: 6, + }, + groupAvatar: { + width: 50, + height: 50, + }, + groupAvatarPlaceholder: { + width: 50, + height: 50, + borderRadius: 12, + backgroundColor: colors.primary.main, + alignItems: 'center', + justifyContent: 'center', + }, + timeText: { + color: colors.text.secondary, + fontSize: 12, + }, + messageRow: { + flexDirection: 'row', + alignItems: 'center', + }, + messageText: { + flex: 1, + color: colors.text.secondary, + fontSize: 14, + }, + unreadMessageText: { + color: colors.text.primary, + fontWeight: '500', + }, + unreadBadge: { + minWidth: 20, + height: 20, + borderRadius: 10, + backgroundColor: colors.primary.main, + alignItems: 'center', + justifyContent: 'center', + marginLeft: spacing.sm, + paddingHorizontal: 6, + }, + unreadBadgeText: { + color: colors.primary.contrast, + fontSize: 12, + fontWeight: '600', + }, + }); +} + +const ConversationListRowInner: React.FC = ({ + item, + scale, + isSelected, + onPress, + formatTime, + systemChannelId, +}) => { + const colors = useAppColors(); + const styles = useMemo(() => createConversationRowStyles(colors), [colors]); + const isSystemChannel = item.id === systemChannelId; + const isGroupChat = !!(item.type === 'group' && item.group); + + let displayNameRaw: string; + let displayName: string; + let displayAvatar: string | null = null; + + if (isSystemChannel) { + displayNameRaw = '系统通知'; + } else if (isGroupChat && item.group) { + displayNameRaw = item.group.name || '群聊'; + displayAvatar = item.group.avatar && item.group.avatar.trim() !== '' ? item.group.avatar : null; + } else { + displayNameRaw = item.participants?.[0]?.nickname || '未知用户'; + displayAvatar = item.participants?.[0]?.avatar || null; + } + displayName = truncateDisplayName(displayNameRaw); + + const getSenderName = (): string | undefined => { + if (isGroupChat && item.last_message?.sender) { + return item.last_message.sender.nickname || item.last_message.sender.username; + } + return undefined; + }; + + return ( + + + {isSystemChannel ? ( + + + + ) : isGroupChat ? ( + + {displayAvatar ? ( + + ) : ( + + + + )} + + ) : ( + + )} + + + + + + {isSystemChannel && ( + + 官方 + + )} + {isGroupChat && ( + + )} + {displayName} + {item.is_pinned && !isSystemChannel && ( + + )} + {isGroupChat && item.member_count !== undefined && ( + ({item.member_count}) + )} + + {formatTime(item.last_message_at || item.updated_at)} + + + 0 ? [styles.messageText, styles.unreadMessageText] : styles.messageText} + numberOfLines={1} + > + {!item.last_message ? ( + '暂无消息' + ) : ( + + )} + + {item.unread_count > 0 && ( + + + {item.unread_count > 99 ? '99+' : item.unread_count} + + + )} + + + + ); +}; + +ConversationListRowInner.displayName = 'ConversationListRow'; + +export const ConversationListRow = memo(ConversationListRowInner, areConversationRowEqual); diff --git a/src/screens/message/components/EmbeddedChat.tsx b/src/screens/message/components/EmbeddedChat.tsx index 8f7eecf..0238ad2 100644 --- a/src/screens/message/components/EmbeddedChat.tsx +++ b/src/screens/message/components/EmbeddedChat.tsx @@ -3,7 +3,7 @@ * 嵌入式聊天组件 - 用于桌面端双栏布局右侧 */ -import React, { useState, useEffect, useCallback, useRef } from 'react'; +import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { View, FlatList, @@ -16,30 +16,229 @@ import { Dimensions, Animated, } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; +import { useRouter } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { Image as ExpoImage } from 'expo-image'; -import { colors, spacing, fontSizes, shadows } from '../../../theme'; +import { spacing, fontSizes, shadows, useAppColors, type AppColors } from '../../../theme'; import { ConversationResponse, MessageResponse, MessageSegment, GroupMemberResponse, ImageSegmentData } from '../../../types/dto'; -import { Avatar, Text, ImageGallery } from '../../../components/common'; +import { Avatar, Text, ImageGallery, AppBackButton } from '../../../components/common'; import { useAuthStore, messageManager, MessageEvent, MessageSubscriber } from '../../../stores'; import { extractTextFromSegments } from '../../../types/dto'; import { useBreakpointGTE } from '../../../hooks/useResponsive'; import { useMarkAsRead } from '../../../stores/messageManagerHooks'; import { messageService } from '../../../services'; +import * as hrefs from '../../../navigation/hrefs'; import { GroupInfoPanel } from './ChatScreen/GroupInfoPanel'; import { LongPressMenu, MenuPosition, GroupMessage } from './ChatScreen'; const { width: screenWidth } = Dimensions.get('window'); const PANEL_WIDTH = 360; +function createEmbeddedChatStyles(colors: AppColors) { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.chat.screen, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + backgroundColor: colors.background.paper, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.divider, + shadowColor: 'transparent', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0, + shadowRadius: 0, + elevation: 0, + height: 56, + }, + headerButton: { + padding: spacing.xs, + width: 40, + height: 40, + alignItems: 'center', + justifyContent: 'center', + }, + headerCenter: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + justifyContent: 'center', + }, + headerTitle: { + fontSize: fontSizes.lg, + fontWeight: '600', + color: colors.text.primary, + marginLeft: spacing.sm, + maxWidth: 200, + }, + messageList: { + flex: 1, + backgroundColor: colors.chat.screen, + }, + centerContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: spacing.xl, + }, + emptyText: { + marginTop: spacing.md, + fontSize: 16, + color: colors.text.secondary, + }, + emptySubtext: { + marginTop: spacing.xs, + fontSize: 14, + color: colors.text.disabled, + }, + listContent: { + padding: spacing.md, + paddingBottom: spacing.xl, + }, + messageRow: { + flexDirection: 'row', + alignItems: 'flex-end', + marginBottom: spacing.md, + maxWidth: screenWidth * 0.7, + }, + messageRowLeft: { + alignSelf: 'flex-start', + }, + messageRowRight: { + alignSelf: 'flex-end', + justifyContent: 'flex-end', + }, + messageBubble: { + maxWidth: screenWidth * 0.5, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: 18, + marginHorizontal: spacing.sm, + }, + messageBubbleMe: { + backgroundColor: colors.chat.replyTint, + borderBottomRightRadius: 4, + }, + messageBubbleOther: { + backgroundColor: colors.chat.bubbleIncoming, + borderBottomLeftRadius: 4, + shadowColor: colors.chat.shadow, + shadowOffset: { width: 0, height: 0.5 }, + shadowOpacity: 0.04, + shadowRadius: 1, + elevation: 1, + }, + senderName: { + fontSize: 12, + color: colors.text.secondary, + marginBottom: 2, + }, + messageText: { + fontSize: 15, + lineHeight: 20, + }, + messageTextMe: { + color: colors.chat.textPrimary, + }, + messageTextOther: { + color: colors.chat.textPrimary, + }, + inputArea: { + backgroundColor: colors.chat.screen, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.divider, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + paddingBottom: Platform.OS === 'ios' ? spacing.md : spacing.sm, + }, + inputRow: { + flexDirection: 'row', + alignItems: 'center', + }, + iconButton: { + padding: spacing.xs, + width: 44, + height: 44, + alignItems: 'center', + justifyContent: 'center', + }, + inputContainer: { + flex: 1, + backgroundColor: colors.chat.surfaceInput, + borderRadius: 20, + paddingHorizontal: spacing.md, + minHeight: 40, + justifyContent: 'center', + shadowColor: 'transparent', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0, + shadowRadius: 0, + elevation: 0, + }, + textInput: { + fontSize: 15, + color: colors.text.primary, + padding: 0, + maxHeight: 100, + }, + sendButton: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: colors.primary.main, + alignItems: 'center', + justifyContent: 'center', + marginLeft: spacing.xs, + }, + sendButtonDisabled: { + backgroundColor: colors.background.disabled, + }, + messageTextRecalled: { + fontStyle: 'italic', + color: colors.text.secondary, + }, + imageGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + marginTop: 4, + }, + messageImage: { + borderRadius: 8, + marginRight: 4, + marginBottom: 4, + }, + imagePlaceholder: { + width: 120, + height: 120, + borderRadius: 8, + backgroundColor: colors.background.disabled, + justifyContent: 'center', + alignItems: 'center', + marginRight: 4, + marginBottom: 4, + }, + imagePlaceholderText: { + color: colors.text.secondary, + fontSize: 12, + marginTop: 4, + }, + }); +} + interface EmbeddedChatProps { conversation: ConversationResponse; onBack: () => void; } export const EmbeddedChat: React.FC = ({ conversation, onBack }) => { - const navigation = useNavigation(); + const colors = useAppColors(); + const styles = useMemo(() => createEmbeddedChatStyles(colors), [colors]); + const router = useRouter(); const currentUser = useAuthStore(state => state.currentUser); // 响应式布局 - 使用 768px 作为大屏幕和小屏幕的分界线 @@ -190,21 +389,24 @@ export const EmbeddedChat: React.FC = ({ conversation, onBack // 导航到完整聊天页面 const handleNavigateToFullChat = () => { if (isGroupChat && conversation.group) { - (navigation as any).navigate('Chat', { - conversationId: String(conversation.id), - groupId: String(conversation.group.id), - groupName: conversation.group.name, - isGroupChat: true, - }); + router.push( + hrefs.hrefChat({ + conversationId: String(conversation.id), + groupId: String(conversation.group.id), + groupName: conversation.group.name, + isGroupChat: true, + }) as any + ); } else { const currentUserId = currentUser?.id; const otherUser = conversation.participants?.find(p => String(p.id) !== String(currentUserId)); - (navigation as any).navigate('Chat', { - conversationId: String(conversation.id), - userId: otherUser ? String(otherUser.id) : undefined, - userName: otherUser?.nickname || otherUser?.username, - isGroupChat: false, - }); + router.push( + hrefs.hrefChat({ + conversationId: String(conversation.id), + userId: otherUser ? String(otherUser.id) : undefined, + isGroupChat: false, + }) as any + ); } }; @@ -407,9 +609,7 @@ export const EmbeddedChat: React.FC = ({ conversation, onBack {/* 大屏幕(>= 768px)时隐藏返回按钮 */} {!isWideScreen ? ( - - - + ) : ( )} @@ -424,11 +624,11 @@ export const EmbeddedChat: React.FC = ({ conversation, onBack {/* 大屏幕 + 群聊时显示群信息按钮,否则显示放大按钮 */} {isWideScreen && isGroupChat ? ( - + ) : ( - + )} @@ -441,7 +641,7 @@ export const EmbeddedChat: React.FC = ({ conversation, onBack ) : messages.length === 0 ? ( - + 暂无消息 发送第一条消息开始聊天 @@ -470,7 +670,7 @@ export const EmbeddedChat: React.FC = ({ conversation, onBack - + @@ -478,7 +678,7 @@ export const EmbeddedChat: React.FC = ({ conversation, onBack ref={inputRef} style={styles.textInput} placeholder="发送消息..." - placeholderTextColor="#999" + placeholderTextColor={colors.text.hint} value={inputText} onChangeText={setInputText} multiline={false} @@ -492,7 +692,7 @@ export const EmbeddedChat: React.FC = ({ conversation, onBack - + = ({ conversation, onBack disabled={!inputText.trim() || sending} > {sending ? ( - + ) : ( - + )} @@ -602,190 +802,3 @@ export const EmbeddedChat: React.FC = ({ conversation, onBack ); }; - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#F5F7FA', - }, - header: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - backgroundColor: '#FFF', - borderBottomWidth: 1, - borderBottomColor: '#E8E8E8', - ...shadows.sm, - height: 56, - }, - headerButton: { - padding: spacing.xs, - width: 40, - height: 40, - alignItems: 'center', - justifyContent: 'center', - }, - headerCenter: { - flexDirection: 'row', - alignItems: 'center', - flex: 1, - justifyContent: 'center', - }, - headerTitle: { - fontSize: fontSizes.lg, - fontWeight: '600', - color: '#333', - marginLeft: spacing.sm, - maxWidth: 200, - }, - messageList: { - flex: 1, - backgroundColor: '#F5F7FA', - }, - centerContainer: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - padding: spacing.xl, - }, - emptyText: { - marginTop: spacing.md, - fontSize: 16, - color: '#999', - }, - emptySubtext: { - marginTop: spacing.xs, - fontSize: 14, - color: '#BBB', - }, - listContent: { - padding: spacing.md, - paddingBottom: spacing.xl, - }, - messageRow: { - flexDirection: 'row', - alignItems: 'flex-end', - marginBottom: spacing.md, - maxWidth: screenWidth * 0.7, - }, - messageRowLeft: { - alignSelf: 'flex-start', - }, - messageRowRight: { - alignSelf: 'flex-end', - justifyContent: 'flex-end', - }, - messageBubble: { - maxWidth: screenWidth * 0.5, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - borderRadius: 18, - marginHorizontal: spacing.sm, - }, - messageBubbleMe: { - backgroundColor: colors.primary.main, - borderBottomRightRadius: 4, - }, - messageBubbleOther: { - backgroundColor: '#FFF', - borderBottomLeftRadius: 4, - ...shadows.sm, - }, - senderName: { - fontSize: 12, - color: '#999', - marginBottom: 2, - }, - messageText: { - fontSize: 15, - lineHeight: 20, - }, - messageTextMe: { - color: '#FFF', - }, - messageTextOther: { - color: '#333', - }, - inputArea: { - backgroundColor: '#FFF', - borderTopWidth: 1, - borderTopColor: '#E8E8E8', - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - paddingBottom: Platform.OS === 'ios' ? spacing.md : spacing.sm, - }, - inputRow: { - flexDirection: 'row', - alignItems: 'center', - }, - iconButton: { - padding: spacing.xs, - width: 44, - height: 44, - alignItems: 'center', - justifyContent: 'center', - }, - inputContainer: { - flex: 1, - backgroundColor: '#F5F5F5', - borderRadius: 20, - paddingHorizontal: spacing.md, - minHeight: 40, - justifyContent: 'center', - // 移除阴影 - shadowColor: 'transparent', - shadowOffset: { width: 0, height: 0 }, - shadowOpacity: 0, - shadowRadius: 0, - elevation: 0, - }, - textInput: { - fontSize: 15, - color: '#333', - padding: 0, - maxHeight: 100, - }, - sendButton: { - width: 40, - height: 40, - borderRadius: 20, - backgroundColor: colors.primary.main, - alignItems: 'center', - justifyContent: 'center', - marginLeft: spacing.xs, - }, - sendButtonDisabled: { - backgroundColor: '#E0E0E0', - }, - messageTextRecalled: { - fontStyle: 'italic', - color: '#999', - }, - imageGrid: { - flexDirection: 'row', - flexWrap: 'wrap', - marginTop: 4, - }, - messageImage: { - borderRadius: 8, - marginRight: 4, - marginBottom: 4, - }, - imagePlaceholder: { - width: 120, - height: 120, - borderRadius: 8, - backgroundColor: 'rgba(0,0,0,0.1)', - justifyContent: 'center', - alignItems: 'center', - marginRight: 4, - marginBottom: 4, - }, - imagePlaceholderText: { - color: '#999', - fontSize: 12, - marginTop: 4, - }, -}); diff --git a/src/screens/message/components/GroupRequestShared.tsx b/src/screens/message/components/GroupRequestShared.tsx index 856286c..1512354 100644 --- a/src/screens/message/components/GroupRequestShared.tsx +++ b/src/screens/message/components/GroupRequestShared.tsx @@ -1,10 +1,10 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { View, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { Avatar, Text } from '../../../components/common'; -import { colors, spacing, borderRadius, shadows } from '../../../theme'; +import { spacing, borderRadius, shadows, useAppColors, type AppColors } from '../../../theme'; interface GroupInfoSummaryCardProps { groupName?: string; @@ -14,6 +14,79 @@ interface GroupInfoSummaryCardProps { memberCountText?: string; } +function createGroupRequestSharedStyles(colors: AppColors) { + return StyleSheet.create({ + headerCard: { + backgroundColor: colors.background.paper, + borderRadius: borderRadius.lg, + padding: spacing.lg, + marginBottom: spacing.md, + ...shadows.sm, + }, + groupHeader: { + flexDirection: 'row', + alignItems: 'center', + }, + groupInfo: { + marginLeft: spacing.lg, + flex: 1, + }, + groupName: { + marginBottom: spacing.xs, + fontWeight: '700', + }, + groupMeta: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + }, + groupNoText: { + marginTop: spacing.xs, + }, + descriptionContainer: { + flexDirection: 'row', + alignItems: 'flex-start', + backgroundColor: colors.background.default, + borderRadius: borderRadius.lg, + padding: spacing.md, + marginTop: spacing.md, + }, + groupDesc: { + marginLeft: spacing.sm, + flex: 1, + lineHeight: 20, + }, + footer: { + paddingHorizontal: spacing.lg, + paddingTop: spacing.sm, + flexDirection: 'row', + backgroundColor: colors.background.default, + }, + btn: { + flex: 1, + height: 42, + borderRadius: borderRadius.md, + alignItems: 'center', + justifyContent: 'center', + }, + reject: { + marginRight: spacing.sm, + backgroundColor: colors.error.light + '25', + borderWidth: 1, + borderColor: colors.error.light, + }, + approve: { + marginLeft: spacing.sm, + backgroundColor: colors.primary.main, + }, + statusBox: { + alignItems: 'center', + paddingTop: spacing.sm, + backgroundColor: colors.background.default, + }, + }); +} + export const GroupInfoSummaryCard: React.FC = ({ groupName, groupAvatar, @@ -21,12 +94,16 @@ export const GroupInfoSummaryCard: React.FC = ({ groupDescription, memberCountText, }) => { + const colors = useAppColors(); + const styles = useMemo(() => createGroupRequestSharedStyles(colors), [colors]); return ( - {groupName || '群聊'} + + {groupName || '群聊'} + @@ -63,93 +140,34 @@ export const DecisionFooter: React.FC = ({ onApprove, processedText = '该请求已处理', }) => { + const colors = useAppColors(); + const styles = useMemo(() => createGroupRequestSharedStyles(colors), [colors]); const insets = useSafeAreaInsets(); if (canAction) { return ( - 拒绝 + + 拒绝 + - {submitting ? : 同意} + {submitting ? ( + + ) : ( + + 同意 + + )} ); } return ( - {processedText} + + {processedText} + ); }; - -const styles = StyleSheet.create({ - headerCard: { - backgroundColor: colors.background.paper, - borderRadius: borderRadius.lg, - padding: spacing.lg, - marginBottom: spacing.md, - ...shadows.sm, - }, - groupHeader: { - flexDirection: 'row', - alignItems: 'center', - }, - groupInfo: { - marginLeft: spacing.lg, - flex: 1, - }, - groupName: { - marginBottom: spacing.xs, - fontWeight: '700', - }, - groupMeta: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.xs, - }, - groupNoText: { - marginTop: spacing.xs, - }, - descriptionContainer: { - flexDirection: 'row', - alignItems: 'flex-start', - backgroundColor: colors.background.default, - borderRadius: borderRadius.lg, - padding: spacing.md, - marginTop: spacing.md, - }, - groupDesc: { - marginLeft: spacing.sm, - flex: 1, - lineHeight: 20, - }, - footer: { - paddingHorizontal: spacing.lg, - paddingTop: spacing.sm, - flexDirection: 'row', - backgroundColor: colors.background.default, - }, - btn: { - flex: 1, - height: 42, - borderRadius: borderRadius.md, - alignItems: 'center', - justifyContent: 'center', - }, - reject: { - marginRight: spacing.sm, - backgroundColor: colors.error.light + '25', - borderWidth: 1, - borderColor: colors.error.light, - }, - approve: { - marginLeft: spacing.sm, - backgroundColor: colors.primary.main, - }, - statusBox: { - alignItems: 'center', - paddingTop: spacing.sm, - backgroundColor: colors.background.default, - }, -}); diff --git a/src/screens/message/components/MutualFollowSelectorModal.tsx b/src/screens/message/components/MutualFollowSelectorModal.tsx index 9341041..2b04d75 100644 --- a/src/screens/message/components/MutualFollowSelectorModal.tsx +++ b/src/screens/message/components/MutualFollowSelectorModal.tsx @@ -12,7 +12,7 @@ import { } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { colors, spacing, fontSizes, borderRadius } from '../../../theme'; +import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../../theme'; import { authService } from '../../../services/authService'; import { useAuthStore } from '../../../stores'; import { Avatar, EmptyState, Loading, Text } from '../../../components/common'; @@ -41,6 +41,8 @@ const MutualFollowSelectorModal: React.FC = ({ onClose, onConfirm, }) => { + const colors = useAppColors(); + const styles = useMemo(() => createMutualFollowSelectorStyles(colors), [colors]); const { currentUser } = useAuthStore(); const [friendList, setFriendList] = useState([]); @@ -209,93 +211,96 @@ const MutualFollowSelectorModal: React.FC = ({ ); }; -const styles = StyleSheet.create({ - modalOverlay: { - flex: 1, - backgroundColor: 'rgba(0, 0, 0, 0.5)', - justifyContent: 'flex-end', - }, - modalContent: { - backgroundColor: colors.background.paper, - borderTopLeftRadius: borderRadius['2xl'], - borderTopRightRadius: borderRadius['2xl'], - height: '80%', - paddingHorizontal: spacing.lg, - paddingTop: spacing.lg, - paddingBottom: spacing.xl, - }, - modalHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: spacing.lg, - }, - modalHeaderButton: { - paddingVertical: spacing.sm, - minWidth: 60, - }, - modalTitle: { - fontWeight: '700', - fontSize: fontSizes.xl, - }, - confirmButton: { - fontWeight: '600', - textAlign: 'right', - }, - tipContainer: { - flexDirection: 'row', - backgroundColor: colors.background.default, - borderRadius: borderRadius.lg, - padding: spacing.xs, - marginBottom: spacing.md, - }, - tipText: { - fontWeight: '600', - }, - selectedBadge: { - backgroundColor: colors.primary.light + '20', - paddingHorizontal: spacing.md, - paddingVertical: spacing.xs, - borderRadius: borderRadius.sm, - alignSelf: 'flex-start', - marginBottom: spacing.md, - }, - friendListContent: { - paddingBottom: spacing.xl, - }, - friendItem: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: spacing.md, - paddingHorizontal: spacing.sm, - borderRadius: borderRadius.lg, - marginBottom: spacing.xs, - }, - friendItemSelected: { - backgroundColor: colors.primary.light + '15', - }, - friendInfo: { - flex: 1, - marginLeft: spacing.md, - marginRight: spacing.sm, - }, - nickname: { - fontWeight: '500', - marginBottom: 2, - }, - checkbox: { - width: 26, - height: 26, - borderRadius: 13, - borderWidth: 2, - borderColor: colors.divider, - justifyContent: 'center', - alignItems: 'center', - }, - checkboxSelected: { - backgroundColor: colors.primary.main, - borderColor: colors.primary.main, - }, -}); +function createMutualFollowSelectorStyles(colors: AppColors) { + return StyleSheet.create({ + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + justifyContent: 'flex-end', + }, + modalContent: { + backgroundColor: colors.background.paper, + borderTopLeftRadius: borderRadius['2xl'], + borderTopRightRadius: borderRadius['2xl'], + height: '80%', + paddingHorizontal: spacing.lg, + paddingTop: spacing.lg, + paddingBottom: spacing.xl, + }, + modalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: spacing.lg, + }, + modalHeaderButton: { + paddingVertical: spacing.sm, + minWidth: 60, + }, + modalTitle: { + fontWeight: '700', + fontSize: fontSizes.xl, + color: colors.text.primary, + }, + confirmButton: { + fontWeight: '600', + textAlign: 'right', + }, + tipContainer: { + flexDirection: 'row', + backgroundColor: colors.background.default, + borderRadius: borderRadius.lg, + padding: spacing.xs, + marginBottom: spacing.md, + }, + tipText: { + fontWeight: '600', + }, + selectedBadge: { + backgroundColor: colors.primary.light + '20', + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs, + borderRadius: borderRadius.sm, + alignSelf: 'flex-start', + marginBottom: spacing.md, + }, + friendListContent: { + paddingBottom: spacing.xl, + }, + friendItem: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: spacing.md, + paddingHorizontal: spacing.sm, + borderRadius: borderRadius.lg, + marginBottom: spacing.xs, + }, + friendItemSelected: { + backgroundColor: colors.primary.light + '15', + }, + friendInfo: { + flex: 1, + marginLeft: spacing.md, + marginRight: spacing.sm, + }, + nickname: { + fontWeight: '500', + marginBottom: 2, + }, + checkbox: { + width: 26, + height: 26, + borderRadius: 13, + borderWidth: 2, + borderColor: colors.divider, + justifyContent: 'center', + alignItems: 'center', + }, + checkboxSelected: { + backgroundColor: colors.primary.main, + borderColor: colors.primary.main, + }, + }); +} export default MutualFollowSelectorModal; diff --git a/src/screens/profile/AccountSecurityScreen.tsx b/src/screens/profile/AccountSecurityScreen.tsx index e65bcb7..ac85f26 100644 --- a/src/screens/profile/AccountSecurityScreen.tsx +++ b/src/screens/profile/AccountSecurityScreen.tsx @@ -7,9 +7,9 @@ import { ActivityIndicator, ScrollView, } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { colors, spacing, fontSizes, borderRadius } from '../../theme'; +import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme'; import { Text, ResponsiveContainer } from '../../components/common'; import { useResponsive } from '../../hooks'; import { authService, resolveAuthApiError } from '../../services/authService'; @@ -17,9 +17,15 @@ import { showPrompt } from '../../services/promptService'; import { useAuthStore } from '../../stores'; export const AccountSecurityScreen: React.FC = () => { - const { isWideScreen } = useResponsive(); + const colors = useAppColors(); + const styles = useMemo(() => createAccountSecurityStyles(colors), [colors]); + const { isWideScreen, isMobile } = useResponsive(); + const insets = useSafeAreaInsets(); const currentUser = useAuthStore((state) => state.currentUser); const fetchCurrentUser = useAuthStore((state) => state.fetchCurrentUser); + + // 底部间距,避免被 TabBar 遮挡 + const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.md; const [email, setEmail] = useState(currentUser?.email || ''); const [verificationCode, setVerificationCode] = useState(''); @@ -228,7 +234,7 @@ export const AccountSecurityScreen: React.FC = () => { disabled={sendingCode || countdown > 0} > {sendingCode ? ( - + ) : ( {countdown > 0 ? `${countdown}s` : '发送验证码'} )} @@ -240,7 +246,11 @@ export const AccountSecurityScreen: React.FC = () => { onPress={handleVerifyEmail} disabled={verifyingEmail} > - {verifyingEmail ? : 验证邮箱} + {verifyingEmail ? ( + + ) : ( + 验证邮箱 + )} @@ -294,7 +304,7 @@ export const AccountSecurityScreen: React.FC = () => { disabled={sendingChangePwdCode || changePwdCountdown > 0} > {sendingChangePwdCode ? ( - + ) : ( {changePwdCountdown > 0 ? `${changePwdCountdown}s` : '发送验证码'} @@ -319,7 +329,11 @@ export const AccountSecurityScreen: React.FC = () => { onPress={handleChangePassword} disabled={updatingPassword} > - {updatingPassword ? : 更新密码} + {updatingPassword ? ( + + ) : ( + 更新密码 + )} @@ -330,118 +344,126 @@ export const AccountSecurityScreen: React.FC = () => { {isWideScreen ? ( - {content} + {content} ) : ( - {content} + {content} )} ); }; -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.background.default, - }, - scrollContent: { - paddingVertical: spacing.md, - }, - section: { - marginBottom: spacing.lg, - }, - sectionHeader: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: spacing.lg, - marginBottom: spacing.sm, - gap: spacing.xs, - }, - sectionTitle: { - fontWeight: '600', - }, - card: { - backgroundColor: colors.background.paper, - marginHorizontal: spacing.lg, - borderRadius: borderRadius.lg, - padding: spacing.md, - }, - statusRow: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: spacing.md, - }, - statusBadge: { - paddingHorizontal: spacing.sm, - paddingVertical: 4, - borderRadius: borderRadius.sm, - }, - statusVerified: { - backgroundColor: '#E8F5E9', - }, - statusUnverified: { - backgroundColor: '#FFF3E0', - }, - inputWrapper: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.background.default, - borderRadius: borderRadius.lg, - borderWidth: 1, - borderColor: colors.divider, - paddingHorizontal: spacing.md, - height: 48, - marginBottom: spacing.sm, - }, - inputIcon: { - marginRight: spacing.sm, - }, - input: { - flex: 1, - color: colors.text.primary, - fontSize: fontSizes.md, - }, - codeRow: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm, - marginBottom: spacing.sm, - }, - codeInput: { - flex: 1, - marginBottom: 0, - }, - sendCodeButton: { - height: 48, - minWidth: 110, - borderRadius: borderRadius.lg, - backgroundColor: colors.primary.main, - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: spacing.sm, - }, - sendCodeButtonText: { - color: '#fff', - fontSize: fontSizes.sm, - fontWeight: '600', - }, - primaryButton: { - height: 48, - borderRadius: borderRadius.lg, - backgroundColor: colors.primary.main, - alignItems: 'center', - justifyContent: 'center', - marginTop: spacing.xs, - }, - primaryButtonText: { - color: '#fff', - fontSize: fontSizes.md, - fontWeight: '700', - }, - buttonDisabled: { - opacity: 0.6, - }, -}); +function createAccountSecurityStyles(colors: AppColors) { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.default, + }, + scrollContent: { + paddingVertical: spacing.md, + }, + section: { + marginBottom: spacing.lg, + }, + sectionHeader: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.lg, + marginBottom: spacing.sm, + gap: spacing.xs, + }, + sectionTitle: { + fontWeight: '600', + }, + card: { + backgroundColor: colors.background.paper, + marginHorizontal: spacing.lg, + borderRadius: borderRadius.lg, + padding: spacing.md, + }, + statusRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: spacing.md, + }, + statusBadge: { + paddingHorizontal: spacing.sm, + paddingVertical: 4, + borderRadius: borderRadius.sm, + }, + statusVerified: { + backgroundColor: colors.success.light + '40', + }, + statusUnverified: { + backgroundColor: colors.warning.light + '40', + }, + statusVerifiedText: { + color: colors.success.dark, + }, + statusUnverifiedText: { + color: colors.warning.dark, + }, + inputWrapper: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.background.default, + borderRadius: borderRadius.lg, + borderWidth: 1, + borderColor: colors.divider, + paddingHorizontal: spacing.md, + height: 48, + marginBottom: spacing.sm, + }, + inputIcon: { + marginRight: spacing.sm, + }, + input: { + flex: 1, + color: colors.text.primary, + fontSize: fontSizes.md, + }, + codeRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + marginBottom: spacing.sm, + }, + codeInput: { + flex: 1, + marginBottom: 0, + }, + sendCodeButton: { + height: 48, + minWidth: 110, + borderRadius: borderRadius.lg, + backgroundColor: colors.primary.main, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: spacing.sm, + }, + sendCodeButtonText: { + color: colors.text.inverse, + fontSize: fontSizes.sm, + fontWeight: '600', + }, + primaryButton: { + height: 48, + borderRadius: borderRadius.lg, + backgroundColor: colors.primary.main, + alignItems: 'center', + justifyContent: 'center', + marginTop: spacing.xs, + }, + primaryButtonText: { + color: colors.text.inverse, + fontSize: fontSizes.md, + fontWeight: '700', + }, + buttonDisabled: { + opacity: 0.6, + }, + }); +} export default AccountSecurityScreen; diff --git a/src/screens/profile/BlockedUsersScreen.tsx b/src/screens/profile/BlockedUsersScreen.tsx index 9ba463b..97ef4a5 100644 --- a/src/screens/profile/BlockedUsersScreen.tsx +++ b/src/screens/profile/BlockedUsersScreen.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { ActivityIndicator, FlatList, @@ -8,23 +8,66 @@ import { View, Alert, } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { useNavigation } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useRouter } from 'expo-router'; import { Avatar, Button, EmptyState, ResponsiveContainer, Text } from '../../components/common'; import { authService } from '../../services'; -import { colors, spacing, borderRadius, shadows } from '../../theme'; +import { spacing, borderRadius, shadows, useAppColors, type AppColors } from '../../theme'; import { User } from '../../types'; -import { RootStackParamList } from '../../navigation/types'; +import { useResponsive } from '../../hooks'; +import * as hrefs from '../../navigation/hrefs'; -type NavigationProp = NativeStackNavigationProp; +function createBlockedUsersStyles(colors: AppColors) { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.default, + }, + loadingWrap: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + listContent: { + padding: spacing.md, + gap: spacing.sm, + }, + emptyContent: { + flexGrow: 1, + justifyContent: 'center', + }, + item: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.background.paper, + borderRadius: borderRadius.lg, + padding: spacing.md, + ...shadows.sm, + }, + content: { + flex: 1, + marginLeft: spacing.md, + marginRight: spacing.sm, + }, + nickname: { + fontWeight: '600', + }, + }); +} export const BlockedUsersScreen: React.FC = () => { - const navigation = useNavigation(); + const colors = useAppColors(); + const styles = useMemo(() => createBlockedUsersStyles(colors), [colors]); + const router = useRouter(); + const { isMobile } = useResponsive(); + const insets = useSafeAreaInsets(); const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [processingUserId, setProcessingUserId] = useState(null); + + // 底部间距,避免被 TabBar 遮挡 + const listBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.md; const loadBlockedUsers = useCallback(async () => { try { @@ -77,7 +120,7 @@ export const BlockedUsersScreen: React.FC = () => { navigation.navigate('UserProfile', { userId: item.id })} + onPress={() => router.push(hrefs.hrefUserProfile(item.id))} > @@ -115,7 +158,7 @@ export const BlockedUsersScreen: React.FC = () => { data={users} keyExtractor={item => item.id} renderItem={renderItem} - contentContainerStyle={[styles.listContent, users.length === 0 && styles.emptyContent]} + contentContainerStyle={[styles.listContent, users.length === 0 && styles.emptyContent, { paddingBottom: listBottomInset }]} refreshControl={ { ); }; -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.background.default, - }, - loadingWrap: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - }, - listContent: { - padding: spacing.md, - gap: spacing.sm, - }, - emptyContent: { - flexGrow: 1, - justifyContent: 'center', - }, - item: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.background.paper, - borderRadius: borderRadius.lg, - padding: spacing.md, - ...shadows.sm, - }, - content: { - flex: 1, - marginLeft: spacing.md, - marginRight: spacing.sm, - }, - nickname: { - fontWeight: '600', - }, -}); - export default BlockedUsersScreen; diff --git a/src/screens/profile/EditProfileScreen.tsx b/src/screens/profile/EditProfileScreen.tsx index ce6c7b3..a57576a 100644 --- a/src/screens/profile/EditProfileScreen.tsx +++ b/src/screens/profile/EditProfileScreen.tsx @@ -5,7 +5,7 @@ * 表单在宽屏下居中显示 */ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { View, ScrollView, @@ -22,519 +22,21 @@ import { useNavigation } from '@react-navigation/native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import * as ImagePicker from 'expo-image-picker'; import { LinearGradient } from 'expo-linear-gradient'; -import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme'; +import { + spacing, + fontSizes, + borderRadius, + shadows, + useAppColors, + type AppColors, +} from '../../theme'; import { useAuthStore } from '../../stores'; import { Avatar, Button, Text, ResponsiveContainer } from '../../components/common'; import { authService, uploadService } from '../../services'; import { useResponsive } from '../../hooks'; -// 表单输入项组件 -interface FormFieldProps { - icon: string; - label: string; - value: string; - onChangeText: (text: string) => void; - placeholder: string; - maxLength?: number; - multiline?: boolean; - numberOfLines?: number; - autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters'; - keyboardType?: 'default' | 'email-address' | 'numeric' | 'phone-pad' | 'url'; - editable?: boolean; -} - -const FormField: React.FC = ({ - icon, - label, - value, - onChangeText, - placeholder, - maxLength, - multiline = false, - numberOfLines = 1, - autoCapitalize = 'sentences', - keyboardType = 'default', - editable = true, -}) => { - return ( - - - - - - {label} - - - - ); -}; - -export const EditProfileScreen: React.FC = () => { - const navigation = useNavigation(); - const { currentUser, updateUser } = useAuthStore(); - const { isWideScreen, isMobile, width } = useResponsive(); - const insets = useSafeAreaInsets(); - - const [nickname, setNickname] = useState(currentUser?.nickname || ''); - const [bio, setBio] = useState(currentUser?.bio || ''); - const [location, setLocation] = useState(currentUser?.location || ''); - const [website, setWebsite] = useState(currentUser?.website || ''); - const [phone, setPhone] = useState(currentUser?.phone || ''); - const [email, setEmail] = useState(currentUser?.email || ''); - const [avatar, setAvatar] = useState(currentUser?.avatar || ''); - const [coverUrl, setCoverUrl] = useState(currentUser?.cover_url || ''); - const [uploadingAvatar, setUploadingAvatar] = useState(false); - const [uploadingCover, setUploadingCover] = useState(false); - const [saving, setSaving] = useState(false); - const bottomSafeDistance = isMobile ? insets.bottom + 92 : spacing.xl; - - // 根据屏幕宽度计算封面高度 - const coverHeight = isWideScreen ? Math.min((width * 9) / 16, 300) : (width * 9) / 16; - - // 选择头图 - const handlePickCover = async () => { - const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); - - if (!permissionResult.granted) { - Alert.alert('权限不足', '需要访问相册权限来选择头图'); - return; - } - - const result = await ImagePicker.launchImageLibraryAsync({ - mediaTypes: 'images', - allowsEditing: true, - aspect: [16, 9], - quality: 0.9, - }); - - if (!result.canceled && result.assets[0]) { - const selectedImage = result.assets[0]; - setCoverUrl(selectedImage.uri); - - try { - setUploadingCover(true); - const uploadResult = await uploadService.uploadCover({ - uri: selectedImage.uri, - name: selectedImage.fileName || 'cover.jpg', - type: selectedImage.mimeType || 'image/jpeg', - }); - - if (uploadResult) { - updateUser({ cover_url: uploadResult.url }); - Alert.alert('成功', '头图已更新'); - } else { - Alert.alert('错误', '头图上传失败,请重试'); - } - } catch (error) { - console.error('上传头图失败:', error); - Alert.alert('错误', '头图上传失败,请重试'); - } finally { - setUploadingCover(false); - } - } - }; - - // 选择头像 - const handlePickAvatar = async () => { - const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); - - if (!permissionResult.granted) { - Alert.alert('权限不足', '需要访问相册权限来选择头像'); - return; - } - - const result = await ImagePicker.launchImageLibraryAsync({ - mediaTypes: 'images', - allowsEditing: true, - aspect: [1, 1], - quality: 0.8, - }); - - if (!result.canceled && result.assets[0]) { - const selectedImage = result.assets[0]; - setAvatar(selectedImage.uri); - - try { - setUploadingAvatar(true); - const uploadResult = await uploadService.uploadAvatar({ - uri: selectedImage.uri, - name: selectedImage.fileName || 'avatar.jpg', - type: selectedImage.mimeType || 'image/jpeg', - }); - - if (uploadResult) { - updateUser({ avatar: uploadResult.url }); - Alert.alert('成功', '头像已更新'); - } else { - Alert.alert('错误', '头像上传失败,请重试'); - } - } catch (error) { - console.error('上传头像失败:', error); - Alert.alert('错误', '头像上传失败,请重试'); - } finally { - setUploadingAvatar(false); - } - } - }; - - // 保存资料 - const handleSave = async () => { - if (!nickname.trim()) { - Alert.alert('错误', '昵称不能为空'); - return; - } - - if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { - Alert.alert('错误', '请输入正确的邮箱地址'); - return; - } - - if (phone && !/^1[3-9]\d{9}$/.test(phone)) { - Alert.alert('错误', '请输入正确的手机号'); - return; - } - - setSaving(true); - - try { - const updatedUser = await authService.updateUser({ - nickname: nickname.trim(), - bio: bio.trim() || undefined, - location: location.trim() || undefined, - website: website.trim() || undefined, - phone: phone.trim() || undefined, - email: email.trim() || undefined, - }); - - if (updatedUser) { - updateUser({ - nickname: nickname.trim(), - bio: bio.trim() || null, - location: location.trim() || null, - website: website.trim() || null, - phone: phone.trim() || null, - email: email.trim() || null, - }); - - Alert.alert('成功', '资料已更新', [ - { text: '确定', onPress: () => navigation.goBack() } - ]); - } else { - Alert.alert('错误', '保存失败,请重试'); - } - } catch (error) { - console.error('保存资料失败:', error); - Alert.alert('错误', '保存失败,请重试'); - } finally { - setSaving(false); - } - }; - - // 渲染表单内容 - const renderFormContent = () => ( - <> - {/* ===== 用户资料预览区域 - 与 UserProfileHeader 完全一致 ===== */} - - {/* 封面背景 */} - - - {coverUrl ? ( - - ) : ( - - )} - - {/* 头图编辑蒙版 */} - {uploadingCover ? ( - - - - ) : ( - - - 更换头图 - - )} - - - {/* 装饰性波浪 */} - - - - - - {/* 用户信息卡片 */} - - {/* 悬浮头像 */} - - - - {uploadingAvatar && ( - - - - )} - - - - - - - {/* 用户名和简介 */} - - - {nickname || currentUser?.nickname} - - - @{currentUser?.username} - - - {bio ? ( - - {bio} - - ) : ( - - 这个人很懒,还没有写简介~ - - )} - - - {/* 个人信息标签 */} - - {location ? ( - - - - {location} - - - ) : ( - - - - 未设置地区 - - - )} - {website ? ( - - - - {website.replace(/^https?:\/\//, '')} - - - ) : ( - - - - 未设置网站 - - - )} - - - - 加入于 {new Date(currentUser?.created_at || Date.now()).getFullYear()}年 - - - - - {/* 编辑提示 */} - - - - 点击头像或头图可更换照片 - - - - - - {/* ===== 表单区域 ===== */} - - - 个人资料 - - - - - - - - - - - - - - - - - - {/* 联系方式区域 */} - - - 联系方式 - - - - - - - - - - {/* 保存按钮 */} - - - - {saving ? ( - - ) : ( - - )} - - {saving ? '保存中...' : '保存修改'} - - - - - - ); - - return ( - - - {isWideScreen ? ( - - - {renderFormContent()} - - - ) : ( - - {renderFormContent()} - - )} - - - ); -}; - -const styles = StyleSheet.create({ +function createEditProfileStyles(colors: AppColors) { + return StyleSheet.create({ container: { flex: 1, backgroundColor: colors.background.default, @@ -574,11 +76,7 @@ const styles = StyleSheet.create({ alignItems: 'center', }, coverEditOverlay: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - height: '60%', + ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0, 0, 0, 0.3)', justifyContent: 'center', alignItems: 'center', @@ -786,6 +284,536 @@ const styles = StyleSheet.create({ saveButtonText: { color: colors.text.inverse, }, -}); + }); +} + +type EditProfileSheet = ReturnType; + + +// 表单输入项组件 +interface FormFieldProps { + icon: string; + label: string; + value: string; + onChangeText: (text: string) => void; + placeholder: string; + maxLength?: number; + multiline?: boolean; + numberOfLines?: number; + autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters'; + keyboardType?: 'default' | 'email-address' | 'numeric' | 'phone-pad' | 'url'; + editable?: boolean; + sheet: EditProfileSheet; + colors: AppColors; +} + +const FormField: React.FC = ({ + icon, + label, + value, + onChangeText, + placeholder, + maxLength, + multiline = false, + numberOfLines = 1, + autoCapitalize = 'sentences', + keyboardType = 'default', + editable = true, + sheet, + colors, +}) => { + return ( + + + + + + {label} + + + + ); +}; + +export const EditProfileScreen: React.FC = () => { + const colors = useAppColors(); + const styles = useMemo(() => createEditProfileStyles(colors), [colors]); + const navigation = useNavigation(); + const { currentUser, updateUser } = useAuthStore(); + const { isWideScreen, isMobile, width } = useResponsive(); + const insets = useSafeAreaInsets(); + + const [nickname, setNickname] = useState(currentUser?.nickname || ''); + const [bio, setBio] = useState(currentUser?.bio || ''); + const [location, setLocation] = useState(currentUser?.location || ''); + const [website, setWebsite] = useState(currentUser?.website || ''); + const [phone, setPhone] = useState(currentUser?.phone || ''); + const [email, setEmail] = useState(currentUser?.email || ''); + const [avatar, setAvatar] = useState(currentUser?.avatar || ''); + const [coverUrl, setCoverUrl] = useState(currentUser?.cover_url || ''); + const [uploadingAvatar, setUploadingAvatar] = useState(false); + const [uploadingCover, setUploadingCover] = useState(false); + const [saving, setSaving] = useState(false); + // 底部间距,避免被 TabBar 遮挡 + const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.lg; + + // 根据屏幕宽度计算封面高度 + const coverHeight = isWideScreen ? Math.min((width * 9) / 16, 300) : (width * 9) / 16; + + // 选择头图 + const handlePickCover = async () => { + const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); + + if (!permissionResult.granted) { + Alert.alert('权限不足', '需要访问相册权限来选择头图'); + return; + } + + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: 'images', + allowsEditing: true, + aspect: [16, 9], + quality: 0.9, + }); + + if (!result.canceled && result.assets[0]) { + const selectedImage = result.assets[0]; + setCoverUrl(selectedImage.uri); + + try { + setUploadingCover(true); + const uploadResult = await uploadService.uploadCover({ + uri: selectedImage.uri, + name: selectedImage.fileName || 'cover.jpg', + type: selectedImage.mimeType || 'image/jpeg', + }); + + if (uploadResult) { + updateUser({ cover_url: uploadResult.url }); + Alert.alert('成功', '头图已更新'); + } else { + Alert.alert('错误', '头图上传失败,请重试'); + } + } catch (error) { + console.error('上传头图失败:', error); + Alert.alert('错误', '头图上传失败,请重试'); + } finally { + setUploadingCover(false); + } + } + }; + + // 选择头像 + const handlePickAvatar = async () => { + const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); + + if (!permissionResult.granted) { + Alert.alert('权限不足', '需要访问相册权限来选择头像'); + return; + } + + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: 'images', + allowsEditing: true, + aspect: [1, 1], + quality: 0.8, + }); + + if (!result.canceled && result.assets[0]) { + const selectedImage = result.assets[0]; + setAvatar(selectedImage.uri); + + try { + setUploadingAvatar(true); + const uploadResult = await uploadService.uploadAvatar({ + uri: selectedImage.uri, + name: selectedImage.fileName || 'avatar.jpg', + type: selectedImage.mimeType || 'image/jpeg', + }); + + if (uploadResult) { + updateUser({ avatar: uploadResult.url }); + Alert.alert('成功', '头像已更新'); + } else { + Alert.alert('错误', '头像上传失败,请重试'); + } + } catch (error) { + console.error('上传头像失败:', error); + Alert.alert('错误', '头像上传失败,请重试'); + } finally { + setUploadingAvatar(false); + } + } + }; + + // 保存资料 + const handleSave = async () => { + if (!nickname.trim()) { + Alert.alert('错误', '昵称不能为空'); + return; + } + + if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + Alert.alert('错误', '请输入正确的邮箱地址'); + return; + } + + if (phone && !/^1[3-9]\d{9}$/.test(phone)) { + Alert.alert('错误', '请输入正确的手机号'); + return; + } + + setSaving(true); + + try { + const updatedUser = await authService.updateUser({ + nickname: nickname.trim(), + bio: bio.trim() || undefined, + location: location.trim() || undefined, + website: website.trim() || undefined, + phone: phone.trim() || undefined, + email: email.trim() || undefined, + }); + + if (updatedUser) { + updateUser({ + nickname: nickname.trim(), + bio: bio.trim() || undefined, + location: location.trim() || undefined, + website: website.trim() || undefined, + phone: phone.trim() || undefined, + email: email.trim() || undefined, + }); + + Alert.alert('成功', '资料已更新', [ + { text: '确定', onPress: () => navigation.goBack() } + ]); + } else { + Alert.alert('错误', '保存失败,请重试'); + } + } catch (error) { + console.error('保存资料失败:', error); + Alert.alert('错误', '保存失败,请重试'); + } finally { + setSaving(false); + } + }; + + // 渲染表单内容 + const renderFormContent = () => ( + <> + {/* ===== 用户资料预览区域 - 与 UserProfileHeader 完全一致 ===== */} + + {/* 封面背景 */} + + + {coverUrl ? ( + + ) : ( + + )} + + {/* 头图编辑蒙版 */} + {uploadingCover ? ( + + + + ) : ( + + + 更换头图 + + )} + + + {/* 装饰性波浪 */} + + + + + + {/* 用户信息卡片 */} + + {/* 悬浮头像 */} + + + + {uploadingAvatar && ( + + + + )} + + + + + + + {/* 用户名和简介 */} + + + {nickname || currentUser?.nickname} + + + @{currentUser?.username} + + + {bio ? ( + + {bio} + + ) : ( + + 这个人很懒,还没有写简介~ + + )} + + + {/* 个人信息标签 */} + + {location ? ( + + + + {location} + + + ) : ( + + + + 未设置地区 + + + )} + {website ? ( + + + + {website.replace(/^https?:\/\//, '')} + + + ) : ( + + + + 未设置网站 + + + )} + + + + 加入于 {new Date(currentUser?.created_at || Date.now()).getFullYear()}年 + + + + + {/* 编辑提示 */} + + + + 点击头像或头图可更换照片 + + + + + + {/* ===== 表单区域 ===== */} + + + 个人资料 + + + + + + + + + + + + + + + + + + {/* 联系方式区域 */} + + + 联系方式 + + + + + + + + + + {/* 保存按钮 */} + + + + {saving ? ( + + ) : ( + + )} + + {saving ? '保存中...' : '保存修改'} + + + + + + ); + + return ( + + + {isWideScreen ? ( + + + {renderFormContent()} + + + ) : ( + + {renderFormContent()} + + )} + + + ); +}; + export default EditProfileScreen; diff --git a/src/screens/profile/FollowListScreen.tsx b/src/screens/profile/FollowListScreen.tsx index dafa64a..17c2ce2 100644 --- a/src/screens/profile/FollowListScreen.tsx +++ b/src/screens/profile/FollowListScreen.tsx @@ -5,7 +5,7 @@ * 在宽屏下使用网格布局 */ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { View, FlatList, @@ -16,23 +16,21 @@ import { ActivityIndicator, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { colors, spacing, borderRadius, shadows } from '../../theme'; +import { useRouter, useLocalSearchParams } from 'expo-router'; +import { spacing, borderRadius, shadows, useAppColors, type AppColors } from '../../theme'; import { User } from '../../types'; import { useAuthStore, useUserStore } from '../../stores'; import { authService } from '../../services'; import { Avatar, Text, Button, Loading, EmptyState, ResponsiveContainer } from '../../components/common'; -import { HomeStackParamList } from '../../navigation/types'; +import * as hrefs from '../../navigation/hrefs'; import { useResponsive, useColumnCount } from '../../hooks'; -type NavigationProp = NativeStackNavigationProp; -type FollowListRouteProp = RouteProp; - const FollowListScreen: React.FC = () => { - const navigation = useNavigation(); - const route = useRoute(); - const { userId, type } = route.params; + const colors = useAppColors(); + const styles = useMemo(() => createFollowListStyles(colors), [colors]); + const router = useRouter(); + const { userId = '', type: typeParam } = useLocalSearchParams<{ userId?: string; type?: string }>(); + const type = typeParam === 'followers' ? 'followers' : 'following'; const { currentUser } = useAuthStore(); const { followUser, unfollowUser } = useUserStore(); @@ -135,7 +133,7 @@ const FollowListScreen: React.FC = () => { // 跳转到用户主页 const handleUserPress = (targetUserId: string) => { if (targetUserId !== currentUser?.id) { - navigation.push('UserProfile', { userId: targetUserId }); + router.push(hrefs.hrefUserProfile(targetUserId)); } }; @@ -182,11 +180,11 @@ const FollowListScreen: React.FC = () => { @{item.username} - {item.bio && ( + {item.bio?.trim() ? ( {item.bio} - )} + ) : null} {!isSelf && (