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
);
- }, [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 {