refactor(PostCard, PostCardGrid, HomeScreen): enhance PostCard component and remove PostCardGrid
- Introduced a new PostCardRelativeTime component for dynamic time display, improving user experience with real-time updates. - Refactored PostCard to utilize memoization for performance optimization and prevent unnecessary re-renders. - Removed the PostCardGrid component to streamline the codebase, consolidating functionality within the PostCard component. - Updated HomeScreen to leverage the new PostCard features, ensuring consistent post rendering and interaction handling.
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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 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 {};
|
||||||
export { usePostCardFeatures } from './usePostCardFeatures';
|
|
||||||
export { usePostCardStyles, usePostGridStyles } from './usePostCardStyles';
|
|
||||||
export type { PostCardStylesConfig, PostGridStylesConfig } from './usePostCardStyles';
|
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
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,
|
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}>
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user