dev #5

Merged
lan merged 38 commits from dev into master 2026-03-25 20:54:10 +08:00
13 changed files with 623 additions and 547 deletions
Showing only changes of commit b91e78c921 - Show all commits

View File

@@ -12,8 +12,8 @@
* /> * />
*/ */
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState, memo, useEffect } from 'react';
import { View, TouchableOpacity, StyleSheet, Alert } from 'react-native'; import { View, TouchableOpacity, StyleSheet, Alert, TextStyle } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { PostCardProps, PostCardAction } from './types'; import { PostCardProps, PostCardAction } from './types';
import { ImageGridItem, SmartImage } from '../../common'; import { ImageGridItem, SmartImage } from '../../common';
@@ -23,12 +23,95 @@ import { colors, spacing, borderRadius, fontSizes } from '../../../theme';
import { getPreviewImageUrl } from '../../../utils/imageHelper'; import { getPreviewImageUrl } from '../../../utils/imageHelper';
import PostImages from './components/PostImages'; import PostImages from './components/PostImages';
import { useResponsive } from '../../../hooks/useResponsive'; 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 主组件 * PostCard 主组件
* 仅支持新 API * 仅支持新 API
*/ */
const PostCard: React.FC<PostCardProps> = (normalizedProps) => { const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
const { const {
post, post,
onAction, onAction,
@@ -247,9 +330,7 @@ const PostCard: React.FC<PostCardProps> = (normalizedProps) => {
<Text style={styles.authorName} numberOfLines={1}>{author?.nickname || '匿名用户'}</Text> <Text style={styles.authorName} numberOfLines={1}>{author?.nickname || '匿名用户'}</Text>
{!showGrid && ( {!showGrid && (
<View style={styles.metaRow}> <View style={styles.metaRow}>
<Text style={styles.timeText} numberOfLines={1}> <PostCardRelativeTime createdAt={post.created_at} style={styles.timeText} />
{post.created_at ? new Date(post.created_at).toLocaleDateString() : ''}
</Text>
{post.is_pinned && ( {post.is_pinned && (
<View style={styles.pinnedTag}> <View style={styles.pinnedTag}>
<MaterialCommunityIcons name="pin" size={10} color={colors.warning.main} /> <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({ const styles = StyleSheet.create({
card: { card: {
backgroundColor: colors.background.paper, 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 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 {};
export { usePostCardFeatures } from './usePostCardFeatures';
export { usePostCardStyles, usePostGridStyles } from './usePostCardStyles';
export type { PostCardStylesConfig, PostGridStylesConfig } from './usePostCardStyles';

View File

@@ -248,12 +248,18 @@ export const getPostTotalEngagement = (post: Post): number => {
}; };
/** /**
* 格式化帖子创建时间为相对时间描述 * 根据创建时间 ISO 字符串格式化为列表用相对时间(与历史 formatPostTime 行为一致)
*/ */
export const formatPostTime = (post: Post): string => { export const formatPostCreatedAtString = (createdAt: string | undefined | null): string => {
const createdAt = new Date(post.createdAt); if (!createdAt) return '';
const createdAtDate = new Date(createdAt);
if (Number.isNaN(createdAtDate.getTime())) return '';
const now = new Date(); 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 diffSeconds = Math.floor(diffMs / 1000);
const diffMinutes = Math.floor(diffSeconds / 60); const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60); const diffHours = Math.floor(diffMinutes / 60);
@@ -271,5 +277,12 @@ export const formatPostTime = (post: Post): string => {
if (diffDays < 7) { if (diffDays < 7) {
return `${diffDays}天前`; 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 ? ( {isAuthenticated ? (
<> <>
<RootStack.Screen name="Main"> <RootStack.Screen
name="Main"
navigationKey={isMobile ? 'main-mobile' : 'main-desktop'}
>
{() => {() =>
isMobile ? ( isMobile ? (
<SimpleMobileTabNavigator /> <SimpleMobileTabNavigator />

View File

@@ -1,18 +1,18 @@
/** /**
* 简单的移动端 Tab Navigator * 简单的移动端 Tab Navigator(自定义 Tab 栏)
* *
* 不使用 React Navigation 的 Tab Navigator而是使用一个简单的自定义实现 * 策略(在 EnsureSingleNavigator 限制下尽量省加载):
* 这样可以完全避免 React Navigation 状态恢复的问题 * - 首页、消息:屏幕本身不带根级 NativeStack首次进入后保留挂载仅 display 隐藏 → 来回切换快。
* - 课表、我的:各含一个 NativeStack同一时刻只挂载当前选中的一个避免重复注册 Navigator。
*
* 其它可叠加的优化(按需再做,不放在本文件里):
* - 数据TanStack Query 调 staleTime、列表 prefetch、占位骨架
* - 时机InteractionManager.runAfterInteractions 再拉非首屏接口
* - 渲染React.memo 重列表项、避免 Tab 切换时整树不必要 setState
*/ */
import React, { useEffect, useState, useCallback } from 'react'; import React, { useEffect, useState, useCallback } from 'react';
import { import { View, StyleSheet, TouchableOpacity, Text } from 'react-native';
View,
StyleSheet,
TouchableOpacity,
Text,
Dimensions,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { createNativeStackNavigator } from '@react-navigation/native-stack';
@@ -22,13 +22,9 @@ import { useTotalUnreadCount } from '../stores';
import { messageManager } from '../stores'; import { messageManager } from '../stores';
// ==================== 导入屏幕组件 ==================== // ==================== 导入屏幕组件 ====================
import { HomeScreen, SearchScreen } from '../screens/home'; import { HomeScreen } from '../screens/home';
import { ScheduleScreen, CourseDetailScreen } from '../screens/schedule'; import { ScheduleScreen, CourseDetailScreen } from '../screens/schedule';
import { import { MessageListScreen } from '../screens/message';
MessageListScreen,
NotificationsScreen,
PrivateChatInfoScreen,
} from '../screens/message';
import { ProfileScreen, SettingsScreen, EditProfileScreen, NotificationSettingsScreen, BlockedUsersScreen, AccountSecurityScreen } from '../screens/profile'; import { ProfileScreen, SettingsScreen, EditProfileScreen, NotificationSettingsScreen, BlockedUsersScreen, AccountSecurityScreen } from '../screens/profile';
// ==================== 类型定义 ==================== // ==================== 类型定义 ====================
@@ -178,11 +174,10 @@ export function SimpleMobileTabNavigator() {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const messageUnreadCount = useTotalUnreadCount(); const messageUnreadCount = useTotalUnreadCount();
const [activeTab, setActiveTab] = useState<TabName>('HomeTab'); const [activeTab, setActiveTab] = useState<TabName>('HomeTab');
const [mountedTabs, setMountedTabs] = useState<Record<TabName, boolean>>({ /** 无嵌套 Stack 的 Tab保留实例避免每次切回都整页重挂载 */
const [plainTabEverShown, setPlainTabEverShown] = useState({
HomeTab: true, HomeTab: true,
MessageTab: false, MessageTab: false,
ScheduleTab: false,
ProfileTab: false,
}); });
// 初始化 MessageManager // 初始化 MessageManager
@@ -190,36 +185,13 @@ export function SimpleMobileTabNavigator() {
messageManager.initialize(); messageManager.initialize();
}, []); }, []);
// 处理 Tab 切换
const handleTabPress = useCallback((tabName: TabName) => { const handleTabPress = useCallback((tabName: TabName) => {
setMountedTabs((prev) => { if (tabName === 'HomeTab' || tabName === 'MessageTab') {
if (prev[tabName]) { setPlainTabEverShown((prev) => ({ ...prev, [tabName]: true }));
return prev; }
}
return {
...prev,
[tabName]: true,
};
});
setActiveTab(tabName); 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 图标 // 渲染 Tab Bar 图标
const renderTabIcon = (tabName: TabName, isActive: boolean) => { const renderTabIcon = (tabName: TabName, isActive: boolean) => {
const iconColor = isActive ? colors.primary.main : colors.text.secondary; 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 ( return (
<View style={styles.container}> <View style={styles.container}>
{/* 内容区域 */}
<View style={styles.content}> <View style={styles.content}>
{(Object.keys(mountedTabs) as TabName[]).map((tabName) => { {plainTabEverShown.HomeTab && (
if (!mountedTabs[tabName]) { <View
return null; key="HomeTab"
} style={[
const isActive = activeTab === tabName; styles.tabScreen,
return ( activeTab !== 'HomeTab' && styles.tabScreenHidden,
<View ]}
key={tabName} pointerEvents={activeTab === 'HomeTab' ? 'auto' : 'none'}
style={[ >
styles.tabScreen, <HomeScreen />
!isActive && styles.tabScreenHidden, </View>
]} )}
pointerEvents={isActive ? 'auto' : 'none'} {plainTabEverShown.MessageTab && (
> <View
{renderTabView(tabName)} key="MessageTab"
</View> 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> </View>
{/* Tab Bar */} {/* Tab Bar */}
@@ -361,9 +345,6 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
backgroundColor: colors.background.default, backgroundColor: colors.background.default,
}, },
stackContainer: {
flex: 1,
},
content: { content: {
flex: 1, flex: 1,
position: 'relative', position: 'relative',
@@ -374,9 +355,6 @@ const styles = StyleSheet.create({
tabScreenHidden: { tabScreenHidden: {
display: 'none', display: 'none',
}, },
bottomSpacer: {
backgroundColor: 'transparent',
},
tabBar: { tabBar: {
position: 'absolute', position: 'absolute',
left: TAB_BAR_MARGIN, left: TAB_BAR_MARGIN,
@@ -390,6 +368,9 @@ const styles = StyleSheet.create({
justifyContent: 'space-around', justifyContent: 'space-around',
paddingHorizontal: 8, paddingHorizontal: 8,
...shadows.lg, ...shadows.lg,
// 叠在全屏内容之上shadows.lg 含 elevation需最后覆盖
zIndex: 100,
elevation: 100,
}, },
tabItem: { tabItem: {
flex: 1, flex: 1,

View File

@@ -202,6 +202,10 @@ export const HomeScreen: React.FC = () => {
}); });
}, [posts, postsMap]); }, [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(() => { const gridColumns = useMemo(() => {
if (isWideScreen || width >= 1440) return 4; 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 方式) // 跳转到发帖页面(使用 Modal 方式)
const handleCreatePost = () => { const handleCreatePost = () => {
setShowCreatePost(true); setShowCreatePost(true);
@@ -430,12 +444,12 @@ export const HomeScreen: React.FC = () => {
]}> ]}>
<PostCard <PostCard
post={item} post={item}
onAction={(action) => handlePostAction(item, action)} onAction={(action) => stableOnPostAction(item.id, action)}
isPostAuthor={isPostAuthor} isPostAuthor={isPostAuthor}
/> />
</View> </View>
); );
}, [currentUser?.id, handlePostAction, isMobile, listItemWidth, responsiveGap]); }, [currentUser?.id, stableOnPostAction, isMobile, listItemWidth, responsiveGap]);
// 估算帖子在瀑布流中的高度(用于均匀分配) // 估算帖子在瀑布流中的高度(用于均匀分配)
const estimatePostHeight = (post: Post, columnWidth: number): number => { const estimatePostHeight = (post: Post, columnWidth: number): number => {
@@ -514,7 +528,7 @@ export const HomeScreen: React.FC = () => {
<PostCard <PostCard
post={post} post={post}
variant="grid" variant="grid"
onAction={(action) => handlePostAction(post, action)} onAction={(action) => stableOnPostAction(post.id, action)}
isPostAuthor={isPostAuthor} isPostAuthor={isPostAuthor}
/> />
</View> </View>
@@ -575,7 +589,7 @@ export const HomeScreen: React.FC = () => {
key={post.id} key={post.id}
post={post} post={post}
variant={viewMode === 'grid' ? 'grid' : 'list'} variant={viewMode === 'grid' ? 'grid' : 'list'}
onAction={(action) => handlePostAction(post, action)} onAction={(action) => stableOnPostAction(post.id, action)}
isPostAuthor={isPostAuthor} isPostAuthor={isPostAuthor}
/> />
); );

View File

@@ -31,7 +31,7 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, fontSizes, shadows, borderRadius } from '../../theme'; 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 { authService } from '../../services';
import { useUserStore, useAuthStore } from '../../stores'; import { useUserStore, useAuthStore } from '../../stores';
// 【新架构】使用MessageManager hooks会话列表数据源自 MessageManager 游标同步) // 【新架构】使用MessageManager hooks会话列表数据源自 MessageManager 游标同步)
@@ -46,9 +46,9 @@ import {
import { Avatar, Text, EmptyState, ResponsiveContainer } from '../../components/common'; import { Avatar, Text, EmptyState, ResponsiveContainer } from '../../components/common';
import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive'; import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive';
import { RootStackParamList, MessageStackParamList } from '../../navigation/types'; import { RootStackParamList, MessageStackParamList } from '../../navigation/types';
import { getUserCache } from '../../services/database';
// 导入 EmbeddedChat 组件用于桌面端双栏布局 // 导入 EmbeddedChat 组件用于桌面端双栏布局
import { EmbeddedChat } from './components/EmbeddedChat'; import { EmbeddedChat } from './components/EmbeddedChat';
import { ConversationListRow } from './components/ConversationListRow';
// 导入 NotificationsScreen 用于在内部显示 // 导入 NotificationsScreen 用于在内部显示
import { NotificationsScreen } from './NotificationsScreen'; import { NotificationsScreen } from './NotificationsScreen';
// 导入扫码组件 // 导入扫码组件
@@ -70,80 +70,6 @@ interface SearchResultItem {
matchedMessages?: MessageResponse[]; 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 - 纯渲染组件 * MessageListScreen - 纯渲染组件
* 所有数据通过useMessageList hook获取 * 所有数据通过useMessageList hook获取
@@ -301,8 +227,8 @@ export const MessageListScreen: React.FC = () => {
setRefreshing(false); setRefreshing(false);
}, [refresh]); }, [refresh]);
// 格式化时间 // 格式化时间(稳定引用,配合会话行 memo
const formatTime = (dateString: string): string => { const formatTime = useCallback((dateString: string): string => {
try { try {
const date = new Date(dateString); const date = new Date(dateString);
const now = new Date(); const now = new Date();
@@ -327,7 +253,7 @@ export const MessageListScreen: React.FC = () => {
} catch { } catch {
return ''; return '';
} }
}; }, []);
// 跳转到聊天页 // 跳转到聊天页
const handleConversationPress = (conversation: ConversationResponse, index: number) => { const handleConversationPress = (conversation: ConversationResponse, index: number) => {
@@ -380,6 +306,9 @@ export const MessageListScreen: React.FC = () => {
}); });
}; };
const handleConversationPressRef = useRef(handleConversationPress);
handleConversationPressRef.current = handleConversationPress;
// 创建群聊 // 创建群聊
const handleCreateGroup = () => { const handleCreateGroup = () => {
(navigation as any).navigate('CreateGroup'); (navigation as any).navigate('CreateGroup');
@@ -533,136 +462,37 @@ export const MessageListScreen: React.FC = () => {
...conversationList, ...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 totalUnread = totalUnreadCount + systemUnreadCount;
// 渲染会话项 // 渲染会话项(行级 memo + 按 id 解析最新会话,避免 Tab 切换时整表无效重绘)
const renderConversation = ({ item, index }: { item: ConversationResponse; index: number }) => { const renderConversation = useCallback(
const isSystemChannel = item.id === SYSTEM_MESSAGE_CHANNEL_ID; ({ item, index }: { item: ConversationResponse; index: number }) => {
const isGroupChat = !!(item.type === 'group' && item.group); const isSelected = selectedConversation?.id === item.id;
return (
// 获取显示名称和头像 <ConversationListRow
let displayNameRaw: string; item={item}
let displayName: string; index={index}
let displayAvatar: string | null = null; scale={scaleAnims[index] ?? 1}
isSelected={isSelected}
if (isSystemChannel) { formatTime={formatTime}
displayNameRaw = '系统通知'; systemChannelId={SYSTEM_MESSAGE_CHANNEL_ID}
} else if (isGroupChat && item.group) { onPress={() => stableConversationPress(String(item.id), index)}
displayNameRaw = item.group.name || '群聊'; />
displayAvatar = item.group.avatar && item.group.avatar.trim() !== '' ? item.group.avatar : null; );
} else { },
displayNameRaw = item.participants?.[0]?.nickname || '未知用户'; [selectedConversation?.id, scaleAnims, formatTime, stableConversationPress]
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>
);
};
// 渲染空状态 // 渲染空状态
const renderEmpty = () => ( const renderEmpty = () => (
@@ -1187,119 +1017,6 @@ const styles = StyleSheet.create({
flexGrow: 1, flexGrow: 1,
backgroundColor: '#FAFAFA', 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: { loadingContainer: {
flex: 1, flex: 1,
justifyContent: 'center', 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, Alert,
} from 'react-native'; } from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native'; import { navigationService } from '../../infrastructure/navigation/navigationService';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Avatar, Button, EmptyState, ResponsiveContainer, Text } from '../../components/common'; import { Avatar, Button, EmptyState, ResponsiveContainer, Text } from '../../components/common';
import { authService } from '../../services'; import { authService } from '../../services';
import { colors, spacing, borderRadius, shadows } from '../../theme'; import { colors, spacing, borderRadius, shadows } from '../../theme';
import { User } from '../../types'; import { User } from '../../types';
import { RootStackParamList } from '../../navigation/types';
import { useResponsive } from '../../hooks'; import { useResponsive } from '../../hooks';
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
export const BlockedUsersScreen: React.FC = () => { export const BlockedUsersScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp>();
const { isMobile } = useResponsive(); const { isMobile } = useResponsive();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
@@ -83,7 +78,7 @@ export const BlockedUsersScreen: React.FC = () => {
<TouchableOpacity <TouchableOpacity
style={styles.item} style={styles.item}
activeOpacity={0.75} 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} /> <Avatar source={item.avatar} size={46} name={item.nickname} />
<View style={styles.content}> <View style={styles.content}>

View File

@@ -12,7 +12,7 @@ import {
ScrollView, ScrollView,
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; 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 { colors } from '../../theme';
import { Post } from '../../types'; import { Post } from '../../types';
import { PostCard, TabBar, UserProfileHeader } from '../../components/business'; import { PostCard, TabBar, UserProfileHeader } from '../../components/business';
@@ -24,7 +24,8 @@ import { useUserProfile, ProfileMode, TABS, TAB_ICONS, sharedStyles } from './us
interface UserProfileScreenProps { interface UserProfileScreenProps {
mode: ProfileMode; mode: ProfileMode;
userId?: string; // 仅 other 模式需要 userId?: string; // 仅 other 模式需要
profileNavigation?: NativeStackNavigationProp<ProfileStackParamList>; // 仅 self 模式需要 /** 使用 NavigationProp 避免与具体 screen 的 NativeStackNavigationProp 在 setParams 上不兼容 */
profileNavigation?: NavigationProp<ProfileStackParamList>; // 仅 self 模式需要
} }
export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, userId, profileNavigation }) => { export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, userId, profileNavigation }) => {

View File

@@ -4,7 +4,7 @@
*/ */
import { useState, useEffect, useCallback, useMemo } from 'react'; 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 { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { colors, spacing } from '../../theme'; import { colors, spacing } from '../../theme';
@@ -23,7 +23,7 @@ export interface UseUserProfileOptions {
userId?: string; // 仅 other 模式需要 userId?: string; // 仅 other 模式需要
isDesktop?: boolean; isDesktop?: boolean;
isTablet?: boolean; isTablet?: boolean;
profileNavigation?: NativeStackNavigationProp<ProfileStackParamList>; // 仅 self 模式需要 profileNavigation?: NavigationProp<ProfileStackParamList>; // 仅 self 模式需要
} }
export interface UseUserProfileReturn { export interface UseUserProfileReturn {