refactor(PostCard, PostCardGrid, HomeScreen): enhance PostCard component and remove PostCardGrid
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 2m44s
Frontend CI / ota-android (push) Successful in 11m56s
Frontend CI / build-android-apk (push) Successful in 49m3s

- 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.
This commit is contained in:
lafay
2026-03-24 05:52:24 +08:00
parent f9f4e73747
commit b91e78c921
13 changed files with 623 additions and 547 deletions

View File

@@ -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 (
<Text style={timeStyle} numberOfLines={1}>
{label}
</Text>
);
};
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<PostCardProps> = (normalizedProps) => {
const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
const {
post,
onAction,
@@ -247,9 +330,7 @@ const PostCard: React.FC<PostCardProps> = (normalizedProps) => {
<Text style={styles.authorName} numberOfLines={1}>{author?.nickname || '匿名用户'}</Text>
{!showGrid && (
<View style={styles.metaRow}>
<Text style={styles.timeText} numberOfLines={1}>
{post.created_at ? new Date(post.created_at).toLocaleDateString() : ''}
</Text>
<PostCardRelativeTime createdAt={post.created_at} style={styles.timeText} />
{post.is_pinned && (
<View style={styles.pinnedTag}>
<MaterialCommunityIcons name="pin" size={10} color={colors.warning.main} />
@@ -334,6 +415,10 @@ const PostCard: React.FC<PostCardProps> = (normalizedProps) => {
);
};
PostCardInner.displayName = 'PostCard';
const PostCard = memo(PostCardInner, arePostCardPropsEqual);
const styles = StyleSheet.create({
card: {
backgroundColor: colors.background.paper,

View File

@@ -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<PostCardGridProps> = ({
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<ViewStyle> = [
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 (
<TouchableOpacity
style={[containerStyle, style]}
onPress={handleContainerPress}
activeOpacity={0.9}
>
{/* 封面 */}
<PostGridCover
image={coverImage}
content={post.content}
isVote={post.is_vote}
aspectRatio={aspectRatio}
onImagePress={handleImagePressLocal}
/>
{/* 标题和信息 */}
<PostGridInfo
title={post.title}
author={author}
likesCount={post.likes_count}
onUserPress={handleUserPressLocal}
/>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
container: {
backgroundColor: '#FFF',
overflow: 'hidden',
borderRadius: 8,
},
containerDesktop: {
borderRadius: 12,
},
containerNoImage: {
minHeight: 200,
justifyContent: 'space-between',
},
});
export default PostCardGrid;

View File

@@ -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';

View File

@@ -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 {};

View File

@@ -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);
};

View File

@@ -249,7 +249,10 @@ export function RootNavigator({ isAuthenticated, isInitializing }: RootNavigator
>
{isAuthenticated ? (
<>
<RootStack.Screen name="Main">
<RootStack.Screen
name="Main"
navigationKey={isMobile ? 'main-mobile' : 'main-desktop'}
>
{() =>
isMobile ? (
<SimpleMobileTabNavigator />

View File

@@ -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<TabName>('HomeTab');
const [mountedTabs, setMountedTabs] = useState<Record<TabName, boolean>>({
/** 无嵌套 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 <HomeScreen />;
case 'MessageTab':
return <MessageListScreen />;
case 'ScheduleTab':
return <ScheduleStackNavigatorComponent />;
case 'ProfileTab':
return <ProfileStackNavigatorComponent />;
default:
return <HomeScreen />;
}
};
// 渲染 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 (
<View style={styles.container}>
{/* 内容区域 */}
<View style={styles.content}>
{(Object.keys(mountedTabs) as TabName[]).map((tabName) => {
if (!mountedTabs[tabName]) {
return null;
}
const isActive = activeTab === tabName;
return (
<View
key={tabName}
style={[
styles.tabScreen,
!isActive && styles.tabScreenHidden,
]}
pointerEvents={isActive ? 'auto' : 'none'}
>
{renderTabView(tabName)}
</View>
);
})}
{plainTabEverShown.HomeTab && (
<View
key="HomeTab"
style={[
styles.tabScreen,
activeTab !== 'HomeTab' && styles.tabScreenHidden,
]}
pointerEvents={activeTab === 'HomeTab' ? 'auto' : 'none'}
>
<HomeScreen />
</View>
)}
{plainTabEverShown.MessageTab && (
<View
key="MessageTab"
style={[
styles.tabScreen,
activeTab !== 'MessageTab' && styles.tabScreenHidden,
]}
pointerEvents={activeTab === 'MessageTab' ? 'auto' : 'none'}
>
<MessageListScreen />
</View>
)}
{activeTab === 'ScheduleTab' && (
<View key="ScheduleTab" style={styles.tabScreen}>
<ScheduleStackNavigatorComponent />
</View>
)}
{activeTab === 'ProfileTab' && (
<View key="ProfileTab" style={styles.tabScreen}>
<ProfileStackNavigatorComponent />
</View>
)}
</View>
{/* 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,

View File

@@ -202,6 +202,10 @@ export const HomeScreen: React.FC = () => {
});
}, [posts, postsMap]);
/** 按 id 取当前列表中的最新 post配合 PostCard memo 忽略 onAction 引用 */
const postByIdRef = useRef<Map<string, Post>>(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 = () => {
]}>
<PostCard
post={item}
onAction={(action) => handlePostAction(item, action)}
onAction={(action) => stableOnPostAction(item.id, action)}
isPostAuthor={isPostAuthor}
/>
</View>
);
}, [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 = () => {
<PostCard
post={post}
variant="grid"
onAction={(action) => handlePostAction(post, action)}
onAction={(action) => stableOnPostAction(post.id, action)}
isPostAuthor={isPostAuthor}
/>
</View>
@@ -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}
/>
);

View File

@@ -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<string>('');
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<string, ConversationResponse>());
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 (
<AnimatedTouchable
style={[
styles.conversationItem,
isSelected && styles.conversationItemSelected,
{ transform: [{ scale: scaleAnims[index] || 1 }] }
]}
onPress={() => handleConversationPress(item, index)}
activeOpacity={0.7}
>
<View style={styles.avatarContainer}>
{isSystemChannel ? (
<View style={styles.systemAvatar}>
<MaterialCommunityIcons name="bell-ring" size={24} color="#FFF" />
</View>
) : isGroupChat ? (
<View style={styles.groupAvatar}>
{displayAvatar ? (
<Avatar source={displayAvatar} size={50} name={displayName} />
) : (
<View style={styles.groupAvatarPlaceholder}>
<MaterialCommunityIcons name="account-group" size={28} color="#FFF" />
</View>
)}
</View>
) : (
<Avatar source={displayAvatar} size={50} name={displayName} />
)}
</View>
<View style={styles.conversationContent}>
<View style={styles.conversationHeader}>
<View style={styles.nameRow}>
{isSystemChannel && (
<View style={styles.officialBadge}>
<Text style={styles.officialBadgeText}></Text>
</View>
)}
{isGroupChat && (
<MaterialCommunityIcons
name="account-group"
size={16}
color={colors.primary.main}
style={styles.groupIcon}
/>
)}
<Text style={styles.userName}>{displayName}</Text>
{item.is_pinned && !isSystemChannel && (
<MaterialCommunityIcons
name="pin"
size={14}
color={colors.warning.main}
style={styles.pinnedIcon}
/>
)}
{isGroupChat && item.member_count !== undefined && (
<Text style={styles.memberCount}>({item.member_count})</Text>
)}
</View>
<Text style={styles.timeText}>
{formatTime(item.last_message_at || item.updated_at)}
</Text>
</View>
<View style={styles.messageRow}>
<Text
style={item.unread_count > 0 ? [styles.messageText, styles.unreadMessageText] : styles.messageText}
numberOfLines={1}
>
{!item.last_message ? (
'暂无消息'
) : (
<AsyncMessagePreview
segments={item.last_message?.segments}
status={item.last_message?.status}
isGroupChat={isGroupChat}
senderName={getSenderName()}
/>
)}
</Text>
{item.unread_count > 0 && (
<View style={styles.unreadBadge}>
<Text style={styles.unreadBadgeText}>
{item.unread_count > 99 ? '99+' : item.unread_count}
</Text>
</View>
)}
{__DEV__ && (
<Text style={{fontSize: 10, color: '#999', marginLeft: 8}}>
[DEBUG: {item.unread_count || 0}]
</Text>
)}
</View>
</View>
</AnimatedTouchable>
);
};
// 渲染会话项(行级 memo + 按 id 解析最新会话,避免 Tab 切换时整表无效重绘)
const renderConversation = useCallback(
({ item, index }: { item: ConversationResponse; index: number }) => {
const isSelected = selectedConversation?.id === item.id;
return (
<ConversationListRow
item={item}
index={index}
scale={scaleAnims[index] ?? 1}
isSelected={isSelected}
formatTime={formatTime}
systemChannelId={SYSTEM_MESSAGE_CHANNEL_ID}
onPress={() => 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',

View File

@@ -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<string>('');
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<ConversationListRowProps> = ({
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 (
<AnimatedTouchable
style={[
styles.conversationItem,
isSelected && styles.conversationItemSelected,
{ transform: [{ scale }] },
]}
onPress={onPress}
activeOpacity={0.7}
>
<View style={styles.avatarContainer}>
{isSystemChannel ? (
<View style={styles.systemAvatar}>
<MaterialCommunityIcons name="bell-ring" size={24} color="#FFF" />
</View>
) : isGroupChat ? (
<View style={styles.groupAvatar}>
{displayAvatar ? (
<Avatar source={displayAvatar} size={50} name={displayName} />
) : (
<View style={styles.groupAvatarPlaceholder}>
<MaterialCommunityIcons name="account-group" size={28} color="#FFF" />
</View>
)}
</View>
) : (
<Avatar source={displayAvatar} size={50} name={displayName} />
)}
</View>
<View style={styles.conversationContent}>
<View style={styles.conversationHeader}>
<View style={styles.nameRow}>
{isSystemChannel && (
<View style={styles.officialBadge}>
<Text style={styles.officialBadgeText}></Text>
</View>
)}
{isGroupChat && (
<MaterialCommunityIcons
name="account-group"
size={16}
color={colors.primary.main}
style={styles.groupIcon}
/>
)}
<Text style={styles.userName}>{displayName}</Text>
{item.is_pinned && !isSystemChannel && (
<MaterialCommunityIcons name="pin" size={14} color={colors.warning.main} style={styles.pinnedIcon} />
)}
{isGroupChat && item.member_count !== undefined && (
<Text style={styles.memberCount}>({item.member_count})</Text>
)}
</View>
<Text style={styles.timeText}>{formatTime(item.last_message_at || item.updated_at)}</Text>
</View>
<View style={styles.messageRow}>
<Text
style={item.unread_count > 0 ? [styles.messageText, styles.unreadMessageText] : styles.messageText}
numberOfLines={1}
>
{!item.last_message ? (
'暂无消息'
) : (
<AsyncMessagePreview
segments={item.last_message?.segments}
status={item.last_message?.status}
isGroupChat={isGroupChat}
senderName={getSenderName()}
/>
)}
</Text>
{item.unread_count > 0 && (
<View style={styles.unreadBadge}>
<Text style={styles.unreadBadgeText}>
{item.unread_count > 99 ? '99+' : item.unread_count}
</Text>
</View>
)}
{__DEV__ && (
<Text style={{ fontSize: 10, color: '#999', marginLeft: 8 }}>[DEBUG: {item.unread_count || 0}]</Text>
)}
</View>
</View>
</AnimatedTouchable>
);
};
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',
},
});

View File

@@ -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<RootStackParamList>;
export const BlockedUsersScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp>();
const { isMobile } = useResponsive();
const insets = useSafeAreaInsets();
const [users, setUsers] = useState<User[]>([]);
@@ -83,7 +78,7 @@ export const BlockedUsersScreen: React.FC = () => {
<TouchableOpacity
style={styles.item}
activeOpacity={0.75}
onPress={() => navigation.navigate('UserProfile', { userId: item.id })}
onPress={() => navigationService.navigate('UserProfile', { userId: item.id })}
>
<Avatar source={item.avatar} size={46} name={item.nickname} />
<View style={styles.content}>

View File

@@ -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<ProfileStackParamList>; // 仅 self 模式需要
/** 使用 NavigationProp 避免与具体 screen 的 NativeStackNavigationProp 在 setParams 上不兼容 */
profileNavigation?: NavigationProp<ProfileStackParamList>; // 仅 self 模式需要
}
export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, userId, profileNavigation }) => {

View File

@@ -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<ProfileStackParamList>; // 仅 self 模式需要
profileNavigation?: NavigationProp<ProfileStackParamList>; // 仅 self 模式需要
}
export interface UseUserProfileReturn {