From b91e78c92179e1af3bcabb21ac88a245dcfaabf5 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Tue, 24 Mar 2026 05:52:24 +0800 Subject: [PATCH] refactor(PostCard, PostCardGrid, HomeScreen): enhance PostCard component and remove PostCardGrid - Introduced a new PostCardRelativeTime component for dynamic time display, improving user experience with real-time updates. - Refactored PostCard to utilize memoization for performance optimization and prevent unnecessary re-renders. - Removed the PostCardGrid component to streamline the codebase, consolidating functionality within the PostCard component. - Updated HomeScreen to leverage the new PostCard features, ensuring consistent post rendering and interaction handling. --- src/components/business/PostCard/PostCard.tsx | 97 ++++- .../business/PostCard/PostCardGrid.tsx | 115 ----- .../business/PostCard/components/index.ts | 9 +- .../business/PostCard/hooks/index.ts | 7 +- src/core/entities/Post.ts | 23 +- src/navigation/RootNavigator.tsx | 5 +- src/navigation/SimpleMobileTabNavigator.tsx | 127 +++--- src/screens/home/HomeScreen.tsx | 22 +- src/screens/message/MessageListScreen.tsx | 355 ++-------------- .../components/ConversationListRow.tsx | 392 ++++++++++++++++++ src/screens/profile/BlockedUsersScreen.tsx | 9 +- src/screens/profile/UserProfileScreen.tsx | 5 +- src/screens/profile/useUserProfile.ts | 4 +- 13 files changed, 623 insertions(+), 547 deletions(-) delete mode 100644 src/components/business/PostCard/PostCardGrid.tsx create mode 100644 src/screens/message/components/ConversationListRow.tsx diff --git a/src/components/business/PostCard/PostCard.tsx b/src/components/business/PostCard/PostCard.tsx index 47254be..ec77ca5 100644 --- a/src/components/business/PostCard/PostCard.tsx +++ b/src/components/business/PostCard/PostCard.tsx @@ -12,8 +12,8 @@ * /> */ -import React, { useMemo, useState } from 'react'; -import { View, TouchableOpacity, StyleSheet, Alert } from 'react-native'; +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'; @@ -23,12 +23,95 @@ import { colors, spacing, borderRadius, fontSizes } 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 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; + + return true; +} /** * PostCard 主组件 * 仅支持新 API */ -const PostCard: React.FC = (normalizedProps) => { +const PostCardInner: React.FC = (normalizedProps) => { const { post, onAction, @@ -247,9 +330,7 @@ const PostCard: React.FC = (normalizedProps) => { {author?.nickname || '匿名用户'} {!showGrid && ( - - {post.created_at ? new Date(post.created_at).toLocaleDateString() : ''} - + {post.is_pinned && ( @@ -334,6 +415,10 @@ const PostCard: React.FC = (normalizedProps) => { ); }; +PostCardInner.displayName = 'PostCard'; + +const PostCard = memo(PostCardInner, arePostCardPropsEqual); + const styles = StyleSheet.create({ card: { backgroundColor: colors.background.paper, diff --git a/src/components/business/PostCard/PostCardGrid.tsx b/src/components/business/PostCard/PostCardGrid.tsx deleted file mode 100644 index 93306d2..0000000 --- a/src/components/business/PostCard/PostCardGrid.tsx +++ /dev/null @@ -1,115 +0,0 @@ -/** - * PostCardGrid 容器组件 - * 网格模式(小红书风格)的帖子卡片容器 - */ - -import React from 'react'; -import { TouchableOpacity, StyleSheet, StyleProp, ViewStyle, GestureResponderEvent } from 'react-native'; -import { colors, borderRadius } from '../../../theme'; -import { useResponsive } from '../../../hooks/useResponsive'; -import { PostCardGridProps } from './types'; -import { usePostCardActions } from './hooks/usePostCardActions'; -import { usePostGridStyles } from './hooks/usePostCardStyles'; -import { PostGridCover, PostGridInfo } from './components'; -import { convertToImageGridItems, getConsistentAspectRatio } from './utils'; - -const PostCardGrid: React.FC = ({ - post, - onAction, - features, - style, -}) => { - const { isDesktop } = useResponsive(); - const gridStyles = usePostGridStyles(); - - const { - handlePress, - handleUserPress, - handleImagePress, - } = usePostCardActions(onAction); - - // 防御性检查:确保 author 存在 - const author = post.author ?? { - id: '', - nickname: '匿名用户', - avatar: '', - username: '', - cover_url: '', - bio: '', - website: '', - location: '', - posts_count: 0, - followers_count: 0, - following_count: 0, - created_at: '', - }; - - // 获取封面图(第一张图) - const coverImage = post.images && post.images.length > 0 ? post.images[0] : null; - - // 根据帖子 ID 生成一致的宽高比 - const aspectRatio = getConsistentAspectRatio(post.id); - - // 样式计算 - const containerStyle: StyleProp = [ - styles.container, - !coverImage && styles.containerNoImage, - isDesktop && styles.containerDesktop, - ]; - - const handleContainerPress = () => { - handlePress(); - }; - - const handleImagePressLocal = () => { - if (post.images && post.images.length > 0) { - handleImagePress(convertToImageGridItems(post.images), 0); - } - }; - - const handleUserPressLocal = () => { - handleUserPress(); - }; - - return ( - - {/* 封面 */} - - - {/* 标题和信息 */} - - - ); -}; - -const styles = StyleSheet.create({ - container: { - backgroundColor: '#FFF', - overflow: 'hidden', - borderRadius: 8, - }, - containerDesktop: { - borderRadius: 12, - }, - containerNoImage: { - minHeight: 200, - justifyContent: 'space-between', - }, -}); - -export default PostCardGrid; \ No newline at end of file diff --git a/src/components/business/PostCard/components/index.ts b/src/components/business/PostCard/components/index.ts index 98f9fc3..68a7cd6 100644 --- a/src/components/business/PostCard/components/index.ts +++ b/src/components/business/PostCard/components/index.ts @@ -1,12 +1,5 @@ /** - * PostCard 子组件导出 + * PostCard 子组件导出(仅导出仓库内已存在的实现) */ -export { default as PostHeader } from './PostHeader'; -export { default as PostTitle } from './PostTitle'; -export { default as PostContent } from './PostContent'; export { default as PostImages } from './PostImages'; -export { default as PostActions } from './PostActions'; -export { default as PostTopComment } from './PostTopComment'; -export { default as PostGridCover } from './PostGridCover'; -export { default as PostGridInfo } from './PostGridInfo'; \ No newline at end of file diff --git a/src/components/business/PostCard/hooks/index.ts b/src/components/business/PostCard/hooks/index.ts index 0025b4e..55cf8d1 100644 --- a/src/components/business/PostCard/hooks/index.ts +++ b/src/components/business/PostCard/hooks/index.ts @@ -1,8 +1,5 @@ /** - * PostCard Hooks 导出 + * PostCard Hooks(预留入口;实现文件尚未加入仓库时勿导出,避免 TS 无法解析) */ -export { usePostCardActions } from './usePostCardActions'; -export { usePostCardFeatures } from './usePostCardFeatures'; -export { usePostCardStyles, usePostGridStyles } from './usePostCardStyles'; -export type { PostCardStylesConfig, PostGridStylesConfig } from './usePostCardStyles'; +export {}; diff --git a/src/core/entities/Post.ts b/src/core/entities/Post.ts index f52cad9..90e2946 100644 --- a/src/core/entities/Post.ts +++ b/src/core/entities/Post.ts @@ -248,12 +248,18 @@ export const getPostTotalEngagement = (post: Post): number => { }; /** - * 格式化帖子创建时间为相对时间描述 + * 根据创建时间 ISO 字符串格式化为列表用相对时间(与历史 formatPostTime 行为一致) */ -export const formatPostTime = (post: Post): string => { - const createdAt = new Date(post.createdAt); +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() - createdAt.getTime(); + 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); @@ -271,5 +277,12 @@ export const formatPostTime = (post: Post): string => { if (diffDays < 7) { return `${diffDays}天前`; } - return createdAt.toLocaleDateString('zh-CN'); + 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/navigation/RootNavigator.tsx b/src/navigation/RootNavigator.tsx index 05d072a..8ae0620 100644 --- a/src/navigation/RootNavigator.tsx +++ b/src/navigation/RootNavigator.tsx @@ -249,7 +249,10 @@ export function RootNavigator({ isAuthenticated, isInitializing }: RootNavigator > {isAuthenticated ? ( <> - + {() => isMobile ? ( diff --git a/src/navigation/SimpleMobileTabNavigator.tsx b/src/navigation/SimpleMobileTabNavigator.tsx index da32915..f4720ad 100644 --- a/src/navigation/SimpleMobileTabNavigator.tsx +++ b/src/navigation/SimpleMobileTabNavigator.tsx @@ -1,18 +1,18 @@ /** - * 简单的移动端 Tab Navigator + * 简单的移动端 Tab Navigator(自定义 Tab 栏) * - * 不使用 React Navigation 的 Tab Navigator,而是使用一个简单的自定义实现 - * 这样可以完全避免 React Navigation 状态恢复的问题 + * 策略(在 EnsureSingleNavigator 限制下尽量省加载): + * - 首页、消息:屏幕本身不带根级 NativeStack,首次进入后保留挂载,仅 display 隐藏 → 来回切换快。 + * - 课表、我的:各含一个 NativeStack,同一时刻只挂载当前选中的一个,避免重复注册 Navigator。 + * + * 其它可叠加的优化(按需再做,不放在本文件里): + * - 数据:TanStack Query 调 staleTime、列表 prefetch、占位骨架 + * - 时机:InteractionManager.runAfterInteractions 再拉非首屏接口 + * - 渲染:React.memo 重列表项、避免 Tab 切换时整树不必要 setState */ import React, { useEffect, useState, useCallback } from 'react'; -import { - View, - StyleSheet, - TouchableOpacity, - Text, - Dimensions, -} from 'react-native'; +import { View, StyleSheet, TouchableOpacity, Text } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; @@ -22,13 +22,9 @@ import { useTotalUnreadCount } from '../stores'; import { messageManager } from '../stores'; // ==================== 导入屏幕组件 ==================== -import { HomeScreen, SearchScreen } from '../screens/home'; +import { HomeScreen } from '../screens/home'; import { ScheduleScreen, CourseDetailScreen } from '../screens/schedule'; -import { - MessageListScreen, - NotificationsScreen, - PrivateChatInfoScreen, -} from '../screens/message'; +import { MessageListScreen } from '../screens/message'; import { ProfileScreen, SettingsScreen, EditProfileScreen, NotificationSettingsScreen, BlockedUsersScreen, AccountSecurityScreen } from '../screens/profile'; // ==================== 类型定义 ==================== @@ -178,11 +174,10 @@ export function SimpleMobileTabNavigator() { const insets = useSafeAreaInsets(); const messageUnreadCount = useTotalUnreadCount(); const [activeTab, setActiveTab] = useState('HomeTab'); - const [mountedTabs, setMountedTabs] = useState>({ + /** 无嵌套 Stack 的 Tab:保留实例,避免每次切回都整页重挂载 */ + const [plainTabEverShown, setPlainTabEverShown] = useState({ HomeTab: true, MessageTab: false, - ScheduleTab: false, - ProfileTab: false, }); // 初始化 MessageManager @@ -190,36 +185,13 @@ export function SimpleMobileTabNavigator() { messageManager.initialize(); }, []); - // 处理 Tab 切换 const handleTabPress = useCallback((tabName: TabName) => { - setMountedTabs((prev) => { - if (prev[tabName]) { - return prev; - } - return { - ...prev, - [tabName]: true, - }; - }); + if (tabName === 'HomeTab' || tabName === 'MessageTab') { + setPlainTabEverShown((prev) => ({ ...prev, [tabName]: true })); + } setActiveTab(tabName); }, []); - // 根据 Tab 名称渲染对应内容 - const renderTabView = (tabName: TabName) => { - switch (tabName) { - 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; @@ -284,31 +256,43 @@ export function SimpleMobileTabNavigator() { ); }; - // 计算底部安全距离 - const bottomSafeArea = TAB_BAR_HEIGHT + TAB_BAR_FLOATING_MARGIN * 2 + insets.bottom; - return ( - {/* 内容区域 */} - {(Object.keys(mountedTabs) as TabName[]).map((tabName) => { - if (!mountedTabs[tabName]) { - return null; - } - const isActive = activeTab === tabName; - return ( - - {renderTabView(tabName)} - - ); - })} + {plainTabEverShown.HomeTab && ( + + + + )} + {plainTabEverShown.MessageTab && ( + + + + )} + {activeTab === 'ScheduleTab' && ( + + + + )} + {activeTab === 'ProfileTab' && ( + + + + )} {/* Tab Bar */} @@ -361,9 +345,6 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: colors.background.default, }, - stackContainer: { - flex: 1, - }, content: { flex: 1, position: 'relative', @@ -374,9 +355,6 @@ const styles = StyleSheet.create({ tabScreenHidden: { display: 'none', }, - bottomSpacer: { - backgroundColor: 'transparent', - }, tabBar: { position: 'absolute', left: TAB_BAR_MARGIN, @@ -390,6 +368,9 @@ const styles = StyleSheet.create({ justifyContent: 'space-around', paddingHorizontal: 8, ...shadows.lg, + // 叠在全屏内容之上(shadows.lg 含 elevation,需最后覆盖) + zIndex: 100, + elevation: 100, }, tabItem: { flex: 1, diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index 1221925..8ace932 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -202,6 +202,10 @@ export const HomeScreen: React.FC = () => { }); }, [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(() => { if (isWideScreen || width >= 1440) return 4; @@ -406,6 +410,16 @@ export const HomeScreen: React.FC = () => { } }; + 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); @@ -430,12 +444,12 @@ export const HomeScreen: React.FC = () => { ]}> handlePostAction(item, action)} + onAction={(action) => stableOnPostAction(item.id, action)} isPostAuthor={isPostAuthor} /> ); - }, [currentUser?.id, handlePostAction, isMobile, listItemWidth, responsiveGap]); + }, [currentUser?.id, stableOnPostAction, isMobile, listItemWidth, responsiveGap]); // 估算帖子在瀑布流中的高度(用于均匀分配) const estimatePostHeight = (post: Post, columnWidth: number): number => { @@ -514,7 +528,7 @@ export const HomeScreen: React.FC = () => { handlePostAction(post, action)} + onAction={(action) => stableOnPostAction(post.id, action)} isPostAuthor={isPostAuthor} /> @@ -575,7 +589,7 @@ export const HomeScreen: React.FC = () => { key={post.id} post={post} variant={viewMode === 'grid' ? 'grid' : 'list'} - onAction={(action) => handlePostAction(post, action)} + onAction={(action) => stableOnPostAction(post.id, action)} isPostAuthor={isPostAuthor} /> ); diff --git a/src/screens/message/MessageListScreen.tsx b/src/screens/message/MessageListScreen.tsx index e128038..1414d72 100644 --- a/src/screens/message/MessageListScreen.tsx +++ b/src/screens/message/MessageListScreen.tsx @@ -31,7 +31,7 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'; 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 { ConversationResponse, UserDTO, MessageResponse, extractTextFromSegments } from '../../types/dto'; import { authService } from '../../services'; import { useUserStore, useAuthStore } from '../../stores'; // 【新架构】使用MessageManager hooks(会话列表数据源自 MessageManager 游标同步) @@ -46,9 +46,9 @@ import { import { Avatar, Text, EmptyState, ResponsiveContainer } from '../../components/common'; import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive'; import { RootStackParamList, MessageStackParamList } from '../../navigation/types'; -import { getUserCache } from '../../services/database'; // 导入 EmbeddedChat 组件用于桌面端双栏布局 import { EmbeddedChat } from './components/EmbeddedChat'; +import { ConversationListRow } from './components/ConversationListRow'; // 导入 NotificationsScreen 用于在内部显示 import { NotificationsScreen } from './NotificationsScreen'; // 导入扫码组件 @@ -70,80 +70,6 @@ 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获取 @@ -301,8 +227,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(); @@ -327,7 +253,7 @@ export const MessageListScreen: React.FC = () => { } catch { return ''; } - }; + }, []); // 跳转到聊天页 const handleConversationPress = (conversation: ConversationResponse, index: number) => { @@ -380,6 +306,9 @@ export const MessageListScreen: React.FC = () => { }); }; + const handleConversationPressRef = useRef(handleConversationPress); + handleConversationPressRef.current = handleConversationPress; + // 创建群聊 const handleCreateGroup = () => { (navigation as any).navigate('CreateGroup'); @@ -533,136 +462,37 @@ export const MessageListScreen: React.FC = () => { ...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 = () => ( @@ -1187,119 +1017,6 @@ const styles = StyleSheet.create({ 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', diff --git a/src/screens/message/components/ConversationListRow.tsx b/src/screens/message/components/ConversationListRow.tsx new file mode 100644 index 0000000..139f0dc --- /dev/null +++ b/src/screens/message/components/ConversationListRow.tsx @@ -0,0 +1,392 @@ +/** + * 会话列表行:React.memo + 稳定比较,减少 Tab 切换等场景下的无关重渲染。 + * onPress 引用不参与比较,请配合父组件 ref + 按 id 查最新会话使用。 + */ + +import React, { memo, useEffect, useRef, useState } from 'react'; +import { Animated, StyleSheet, TouchableOpacity, View } from 'react-native'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { colors, spacing, shadows } 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; +} + +const ConversationListRowInner: React.FC = ({ + item, + scale, + isSelected, + onPress, + formatTime, + systemChannelId, +}) => { + 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} + + + )} + {__DEV__ && ( + [DEBUG: {item.unread_count || 0}] + )} + + + + ); +}; + +ConversationListRowInner.displayName = 'ConversationListRow'; + +export const ConversationListRow = memo(ConversationListRowInner, areConversationRowEqual); + +const styles = StyleSheet.create({ + 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', + }, +}); diff --git a/src/screens/profile/BlockedUsersScreen.tsx b/src/screens/profile/BlockedUsersScreen.tsx index c91117b..dc40c81 100644 --- a/src/screens/profile/BlockedUsersScreen.tsx +++ b/src/screens/profile/BlockedUsersScreen.tsx @@ -9,19 +9,14 @@ import { Alert, } 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 { navigationService } from '../../infrastructure/navigation/navigationService'; import { Avatar, Button, EmptyState, ResponsiveContainer, Text } from '../../components/common'; import { authService } from '../../services'; import { colors, spacing, borderRadius, shadows } from '../../theme'; import { User } from '../../types'; -import { RootStackParamList } from '../../navigation/types'; import { useResponsive } from '../../hooks'; -type NavigationProp = NativeStackNavigationProp; - export const BlockedUsersScreen: React.FC = () => { - const navigation = useNavigation(); const { isMobile } = useResponsive(); const insets = useSafeAreaInsets(); const [users, setUsers] = useState([]); @@ -83,7 +78,7 @@ export const BlockedUsersScreen: React.FC = () => { navigation.navigate('UserProfile', { userId: item.id })} + onPress={() => navigationService.navigate('UserProfile', { userId: item.id })} > diff --git a/src/screens/profile/UserProfileScreen.tsx b/src/screens/profile/UserProfileScreen.tsx index b567d82..8c73347 100644 --- a/src/screens/profile/UserProfileScreen.tsx +++ b/src/screens/profile/UserProfileScreen.tsx @@ -12,7 +12,7 @@ import { ScrollView, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { NavigationProp } from '@react-navigation/native'; import { colors } from '../../theme'; import { Post } from '../../types'; import { PostCard, TabBar, UserProfileHeader } from '../../components/business'; @@ -24,7 +24,8 @@ import { useUserProfile, ProfileMode, TABS, TAB_ICONS, sharedStyles } from './us interface UserProfileScreenProps { mode: ProfileMode; userId?: string; // 仅 other 模式需要 - profileNavigation?: NativeStackNavigationProp; // 仅 self 模式需要 + /** 使用 NavigationProp 避免与具体 screen 的 NativeStackNavigationProp 在 setParams 上不兼容 */ + profileNavigation?: NavigationProp; // 仅 self 模式需要 } export const UserProfileScreen: React.FC = ({ mode, userId, profileNavigation }) => { diff --git a/src/screens/profile/useUserProfile.ts b/src/screens/profile/useUserProfile.ts index bacc757..0fddf6d 100644 --- a/src/screens/profile/useUserProfile.ts +++ b/src/screens/profile/useUserProfile.ts @@ -4,7 +4,7 @@ */ import { useState, useEffect, useCallback, useMemo } from 'react'; -import { useNavigation } from '@react-navigation/native'; +import { useNavigation, NavigationProp } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { colors, spacing } from '../../theme'; @@ -23,7 +23,7 @@ export interface UseUserProfileOptions { userId?: string; // 仅 other 模式需要 isDesktop?: boolean; isTablet?: boolean; - profileNavigation?: NativeStackNavigationProp; // 仅 self 模式需要 + profileNavigation?: NavigationProp; // 仅 self 模式需要 } export interface UseUserProfileReturn {