dev #5
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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 {};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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 />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
392
src/screens/message/components/ConversationListRow.tsx
Normal file
392
src/screens/message/components/ConversationListRow.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user