feat(performance): migrate FlatList to FlashList and add animations
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 3m9s
Frontend CI / ota-android (push) Successful in 10m37s
Frontend CI / build-android-apk (push) Has been cancelled

Replace FlatList with FlashList across all message screens (ChatScreen, MessageListScreen, NotificationsScreen, HomeScreen) for improved list virtualization performance. Use `drawDistance={250}` instead of manual pagination. Simplify React.memo comparisons in MessageBubble and SegmentRenderer by removing function prop checks to prevent unnecessary re-renders.

Add AsyncStorage persistence for lastSystemMessageAt to avoid showing current time on first render. Include enter animations (fade and slide) for CreateGroupScreen and modernize UI styling to flat design.

BREAKING CHANGE: Upgrade @shopify/flash-list from 2.0.2 to ^2.3.1
This commit is contained in:
lafay
2026-04-25 15:09:00 +08:00
parent 19054d64b3
commit a6a4198ac5
14 changed files with 746 additions and 680 deletions

13
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{ {
"name": "with_you", "name": "with_you",
"version": "0.1.sha", "version": "0.0.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
@@ -14,7 +14,7 @@
"@react-navigation/material-top-tabs": "^7.4.16", "@react-navigation/material-top-tabs": "^7.4.16",
"@react-navigation/native": "^7.1.31", "@react-navigation/native": "^7.1.31",
"@react-navigation/native-stack": "^7.14.2", "@react-navigation/native-stack": "^7.14.2",
"@shopify/flash-list": "2.0.2", "@shopify/flash-list": "^2.3.1",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"axios": "^1.13.6", "axios": "^1.13.6",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
@@ -3664,13 +3664,10 @@
} }
}, },
"node_modules/@shopify/flash-list": { "node_modules/@shopify/flash-list": {
"version": "2.0.2", "version": "2.3.1",
"resolved": "https://registry.npmmirror.com/@shopify/flash-list/-/flash-list-2.0.2.tgz", "resolved": "https://registry.npmmirror.com/@shopify/flash-list/-/flash-list-2.3.1.tgz",
"integrity": "sha512-zhlrhA9eiuEzja4wxVvotgXHtqd3qsYbXkQ3rsBfOgbFA9BVeErpDE/yEwtlIviRGEqpuFj/oU5owD6ByaNX+w==", "integrity": "sha512-7oktg2NQR7KAODjFoDaWe8/OBzyYbdTE3zQTrUBMxjIbxHTHN7UXRX1hX3DHk8KvtkgQdRfZOV8Gjj2l4fGrXw==",
"license": "MIT", "license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"peerDependencies": { "peerDependencies": {
"@babel/runtime": "*", "@babel/runtime": "*",
"react": "*", "react": "*",

View File

@@ -20,7 +20,7 @@
"@react-navigation/material-top-tabs": "^7.4.16", "@react-navigation/material-top-tabs": "^7.4.16",
"@react-navigation/native": "^7.1.31", "@react-navigation/native": "^7.1.31",
"@react-navigation/native-stack": "^7.14.2", "@react-navigation/native-stack": "^7.14.2",
"@shopify/flash-list": "2.0.2", "@shopify/flash-list": "^2.3.1",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"axios": "^1.13.6", "axios": "^1.13.6",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",

View File

@@ -600,8 +600,14 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
<Text style={styles.gridUsername} numberOfLines={1}>{author?.nickname || '匿名用户'}</Text> <Text style={styles.gridUsername} numberOfLines={1}>{author?.nickname || '匿名用户'}</Text>
</TouchableOpacity> </TouchableOpacity>
<View style={styles.gridLikeArea}> <View style={styles.gridLikeArea}>
<MaterialCommunityIcons name="thumb-up-outline" size={14} color={colors.text.secondary} /> <MaterialCommunityIcons
<Text style={styles.gridLikeCount}>{formatNumber(post.likes_count || 0)}</Text> name={post.is_liked ? 'thumb-up' : 'thumb-up-outline'}
size={14}
color={post.is_liked ? colors.primary.main : colors.text.secondary}
/>
<Text style={[styles.gridLikeCount, post.is_liked && { color: colors.primary.main }]}>
{formatNumber(post.likes_count || 0)}
</Text>
</View> </View>
</View> </View>
</TouchableOpacity> </TouchableOpacity>

View File

@@ -7,7 +7,6 @@
import React, { useState, useEffect, useLayoutEffect, useCallback, useMemo, useRef } from 'react'; import React, { useState, useEffect, useLayoutEffect, useCallback, useMemo, useRef } from 'react';
import { import {
View, View,
FlatList,
ScrollView, ScrollView,
Platform, Platform,
StyleSheet, StyleSheet,
@@ -20,6 +19,7 @@ import {
Clipboard, Clipboard,
Modal, Modal,
} from 'react-native'; } from 'react-native';
import { FlashList, ListRenderItem, FlashListRef } from '@shopify/flash-list';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter, useFocusEffect } from 'expo-router'; import { useRouter, useFocusEffect } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
@@ -31,7 +31,7 @@ import { useCurrentUser, useIsAuthenticated, useIsVerified, useVerificationStore
import { channelService, postService } from '../../services'; import { channelService, postService } from '../../services';
import { PostCard, TabBar, SearchBar, ShareSheet } from '../../components/business'; import { PostCard, TabBar, SearchBar, ShareSheet } from '../../components/business';
import type { PostCardAction } from '../../components/business/PostCard'; import type { PostCardAction } from '../../components/business/PostCard';
import { Loading, EmptyState, Text, ImageGallery, ImageGridItem, ResponsiveGrid } from '../../components/common'; import { Loading, EmptyState, Text, ImageGallery, ImageGridItem } from '../../components/common';
import { useResponsive, useResponsiveSpacing } from '../../hooks/useResponsive'; import { useResponsive, useResponsiveSpacing } from '../../hooks/useResponsive';
import { useDifferentialPosts } from '../../hooks/useDifferentialPosts'; import { useDifferentialPosts } from '../../hooks/useDifferentialPosts';
import { postSyncService } from '@/services/post'; import { postSyncService } from '@/services/post';
@@ -114,22 +114,12 @@ function createHomeStyles(colors: AppColors) {
flex: 1, flex: 1,
}, },
listItem: {}, listItem: {},
waterfallScroll: { gridContent: {
flex: 1,
},
waterfallContainer: {
width: '100%',
flexGrow: 1, flexGrow: 1,
}, },
waterfallColumnsRow: { gridItem: {
width: '100%', marginBottom: 0,
flexDirection: 'row',
alignItems: 'flex-start',
}, },
waterfallColumn: {
flex: 1,
},
waterfallItem: {},
floatingButton: { floatingButton: {
position: 'absolute', position: 'absolute',
right: 20, right: 20,
@@ -237,8 +227,8 @@ export const HomeScreen: React.FC = () => {
const homeListScrollYRef = useRef(0); const homeListScrollYRef = useRef(0);
const tabBarIdleRestoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const tabBarIdleRestoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// FlatList 和 ScrollView 的 ref用于滚动到顶部 // FlashList 和 ScrollView 的 ref用于滚动到顶部
const flatListRef = useRef<FlatList<Post> | null>(null); const flashListRef = useRef<FlashListRef<Post> | null>(null);
const scrollViewRef = useRef<ScrollView | null>(null); const scrollViewRef = useRef<ScrollView | null>(null);
const clearTabBarIdleRestoreTimer = useCallback(() => { const clearTabBarIdleRestoreTimer = useCallback(() => {
@@ -298,8 +288,8 @@ export const HomeScreen: React.FC = () => {
// 监听首页 Tab 点击事件,滚动到顶部 // 监听首页 Tab 点击事件,滚动到顶部
useEffect(() => { useEffect(() => {
if (homeTabPressCount > 0) { if (homeTabPressCount > 0) {
// 滚动 FlatList 到顶部 // 滚动 FlashList 到顶部
flatListRef.current?.scrollToOffset({ offset: 0, animated: true }); flashListRef.current?.scrollToOffset({ offset: 0, animated: true });
// 滚动 ScrollView 到顶部(网格模式) // 滚动 ScrollView 到顶部(网格模式)
scrollViewRef.current?.scrollTo({ y: 0, animated: true }); scrollViewRef.current?.scrollTo({ y: 0, animated: true });
// 重置底部 Tab 栏状态 // 重置底部 Tab 栏状态
@@ -403,20 +393,12 @@ export const HomeScreen: React.FC = () => {
} }
}, [posts.length, listKey, hasMore, isLoadingMore]); }, [posts.length, listKey, hasMore, isLoadingMore]);
// 网格模式滚动处理 - 检测是否滚动到底部 + 底部 Tab 显隐 // 网格模式滚动处理 - 底部 Tab 显隐(加载更多由 FlashList onEndReached 处理)
const handleGridScroll = useCallback( const handleGridScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => { (event: NativeSyntheticEvent<NativeScrollEvent>) => {
handleHomeVerticalScroll(event); handleHomeVerticalScroll(event);
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
const scrollY = contentOffset.y;
const visibleHeight = layoutMeasurement.height;
const contentHeight = contentSize.height;
if (scrollY + visibleHeight >= contentHeight - 200) {
loadMore();
}
}, },
[handleHomeVerticalScroll, loadMore] [handleHomeVerticalScroll]
); );
// 刷新方法 - 先获取正确类型的帖子,再刷新 // 刷新方法 - 先获取正确类型的帖子,再刷新
@@ -733,7 +715,7 @@ export const HomeScreen: React.FC = () => {
// 渲染帖子卡片(列表模式) // 渲染帖子卡片(列表模式)
const keyExtractor = useCallback((item: Post) => item.id, []); const keyExtractor = useCallback((item: Post) => item.id, []);
const renderPostList = useCallback(({ item }: { item: Post }) => { const renderPostList = useCallback<ListRenderItem<Post>>(({ item }) => {
const authorId = item.author?.id || ''; const authorId = item.author?.id || '';
const isPostAuthor = currentUser?.id === authorId; const isPostAuthor = currentUser?.id === authorId;
return ( return (
@@ -756,110 +738,40 @@ export const HomeScreen: React.FC = () => {
); );
}, [currentUser?.id, stableOnPostAction, isMobile, listItemWidth, responsiveGap]); }, [currentUser?.id, stableOnPostAction, isMobile, listItemWidth, responsiveGap]);
// 估算帖子在瀑布流中的高度(用于均匀分配 // 渲染网格项(用于 FlashList 多列模式
const estimatePostHeight = (post: Post, columnWidth: number): number => { const renderGridItem = useCallback<ListRenderItem<Post>>(({ item }) => {
const hasImage = post.images && post.images.length > 0; const authorId = item.author?.id || '';
const hasTitle = !!post.title;
const hasContent = !!post.content;
let height = 0;
// 图片区域高度(如果有图)
if (hasImage) {
// 使用帖子 ID 生成一致的宽高比
const hash = post.id.split('').reduce((a, b) => a + b.charCodeAt(0), 0);
const aspectRatios = [0.7, 0.75, 0.8, 0.85, 0.9, 1, 1.1, 1.2];
const aspectRatio = aspectRatios[hash % aspectRatios.length];
height += columnWidth / aspectRatio;
} else {
// 无图帖子显示正文预览区域
if (hasContent) {
// 根据内容长度估算高度每行约20像素最多6行
const contentLength = post.content?.length || 0;
const estimatedLines = Math.min(6, Math.max(3, Math.ceil(contentLength / 20)));
height += 16 + estimatedLines * 20; // padding + 文本高度
}
}
// 标题高度
if (hasTitle) {
const titleLines = hasImage ? 2 : 3;
height += 8 + titleLines * 20; // paddingTop + 文本高度
}
// 底部信息栏高度
height += 40; // 用户信息 + 点赞数
// 间距
height += 2; // marginBottom
return height;
};
// 将帖子分成多列(瀑布流)- 使用贪心算法使各列高度尽量均匀
const distributePostsToColumns = useMemo(() => {
const columns: Post[][] = Array.from({ length: gridColumns }, () => []);
const columnHeights: number[] = Array(gridColumns).fill(0);
// 防御性检查:确保 posts 存在且是数组
if (!displayPosts || !Array.isArray(displayPosts) || displayPosts.length === 0) {
return columns;
}
// 计算单列宽度
const totalGap = (gridColumns - 1) * responsiveGap;
const columnWidth = (width - responsivePadding * 2 - totalGap) / gridColumns;
displayPosts.forEach((post) => {
const postHeight = estimatePostHeight(post, columnWidth);
// 找到当前高度最小的列
const minHeightIndex = columnHeights.indexOf(Math.min(...columnHeights));
columns[minHeightIndex].push(post);
columnHeights[minHeightIndex] += postHeight;
});
return columns;
}, [displayPosts, gridColumns, width, responsiveGap, responsivePadding]);
// 渲染单列帖子
const renderWaterfallColumn = (column: Post[], columnIndex: number) => (
<View key={`column-${columnIndex}`} style={[styles.waterfallColumn, { marginRight: columnIndex < gridColumns - 1 ? responsiveGap : 0 }]}>
{column.map(post => {
const authorId = post.author?.id || '';
const isPostAuthor = currentUser?.id === authorId; const isPostAuthor = currentUser?.id === authorId;
return ( return (
<View key={post.id} style={[styles.waterfallItem, { marginBottom: responsiveGap }]}> <View style={styles.gridItem}>
<PostCard <PostCard
post={post} post={item}
variant="grid" variant="grid"
onAction={(action) => stableOnPostAction(post.id, action)} onAction={(action) => stableOnPostAction(item.id, action)}
isPostAuthor={isPostAuthor} isPostAuthor={isPostAuthor}
/> />
</View> </View>
); );
})} }, [currentUser?.id, stableOnPostAction]);
</View>
);
// 渲染响应式网格布局(平板/桌面端使用多列 // 渲染响应式网格布局(所有端统一使用 FlashList masonry 模式
const renderResponsiveGrid = () => { const renderResponsiveGrid = () => {
if (isMobile && !isTablet) {
// 移动端使用瀑布流布局2列
return ( return (
<ScrollView <FlashList
ref={scrollViewRef} ref={scrollViewRef as any}
style={styles.waterfallScroll} data={displayPosts}
contentContainerStyle={[ renderItem={renderGridItem}
styles.waterfallContainer, keyExtractor={keyExtractor}
{ numColumns={gridColumns}
masonry
optimizeItemArrangement
ListHeaderComponent={renderLatestCapsules()}
ListHeaderComponentStyle={styles.listHeader}
contentContainerStyle={{
paddingHorizontal: responsivePadding, paddingHorizontal: responsivePadding,
paddingBottom: 80 + responsivePadding, paddingBottom: 80 + responsivePadding,
} }}
]}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
scrollEventThrottle={16}
onScroll={handleGridScroll}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
refreshing={isRefreshing} refreshing={isRefreshing}
@@ -868,43 +780,15 @@ export const HomeScreen: React.FC = () => {
tintColor={colors.primary.main} tintColor={colors.primary.main}
/> />
} }
> onEndReached={loadMore}
{renderLatestCapsules()} onEndReachedThreshold={0.3}
<View style={styles.waterfallColumnsRow}> onScroll={handleGridScroll}
{distributePostsToColumns.map((column, index) => renderWaterfallColumn(column, index))} scrollEventThrottle={16}
</View> ListEmptyComponent={renderEmpty}
{/* 独立底部加载区:避免参与横向列布局导致列宽抖动 */} ListFooterComponent={isLoadingMore ? <Loading size="sm" /> : null}
{isLoadingMore && displayPosts.length > 0 && ( drawDistance={250}
<View style={styles.loadingMoreFooter}>
<Loading size="sm" />
</View>
)}
</ScrollView>
);
}
// 平板/桌面端使用 ResponsiveGrid 组件
return (
<ResponsiveGrid
columns={{ xs: 1, sm: 2, md: 2, lg: 3, xl: 3, '2xl': 4, '3xl': 4, '4xl': 5 }}
gap={{ xs: 8, sm: 12, md: 16, lg: 20, xl: 24 }}
containerStyle={{ paddingHorizontal: responsivePadding, paddingBottom: 80 }}
>
{displayPosts.map(post => {
const authorId = post.author?.id || '';
const isPostAuthor = currentUser?.id === authorId;
return (
<PostCard
key={post.id}
post={post}
variant={viewMode === 'grid' ? 'grid' : 'list'}
onAction={(action) => stableOnPostAction(post.id, action)}
isPostAuthor={isPostAuthor}
/> />
); );
})}
</ResponsiveGrid>
);
}; };
// 渲染空状态 // 渲染空状态
@@ -926,23 +810,19 @@ export const HomeScreen: React.FC = () => {
return renderResponsiveGrid(); return renderResponsiveGrid();
} }
// 移动端和宽屏都使用单列 FlatList宽屏下居中显示 // 移动端和宽屏都使用单列 FlashList宽屏下居中显示
return ( return (
<FlatList <FlashList
ref={flatListRef} ref={flashListRef}
data={displayPosts} data={displayPosts}
renderItem={renderPostList} renderItem={renderPostList}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
ListHeaderComponent={renderLatestCapsules()} ListHeaderComponent={renderLatestCapsules()}
ListHeaderComponentStyle={styles.listHeader} ListHeaderComponentStyle={styles.listHeader}
contentContainerStyle={[ contentContainerStyle={{
styles.listContent,
{
paddingHorizontal: listHorizontalPadding, paddingHorizontal: listHorizontalPadding,
paddingBottom: 80 + responsivePadding, paddingBottom: 80 + responsivePadding,
alignItems: 'center', // 确保子项居中 }}
}
]}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
@@ -958,11 +838,7 @@ export const HomeScreen: React.FC = () => {
scrollEventThrottle={16} scrollEventThrottle={16}
ListEmptyComponent={renderEmpty} ListEmptyComponent={renderEmpty}
ListFooterComponent={isLoadingMore ? <Loading size="sm" /> : null} ListFooterComponent={isLoadingMore ? <Loading size="sm" /> : null}
initialNumToRender={8} drawDistance={250}
maxToRenderPerBatch={8}
updateCellsBatchingPeriod={60}
windowSize={7}
removeClippedSubviews={false}
/> />
); );
}; };

View File

@@ -22,12 +22,12 @@
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { import {
View, View,
FlatList,
ActivityIndicator, ActivityIndicator,
KeyboardAvoidingView, KeyboardAvoidingView,
Platform, Platform,
TouchableOpacity, TouchableOpacity,
} from 'react-native'; } from 'react-native';
import { FlashList, ListRenderItem } from '@shopify/flash-list';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useNavigation, useRouter } from 'expo-router'; import { useNavigation, useRouter } from 'expo-router';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
@@ -51,6 +51,7 @@ import {
PANEL_HEIGHTS, PANEL_HEIGHTS,
ChatScreenProps, ChatScreenProps,
} from './components/ChatScreen'; } from './components/ChatScreen';
import { GroupMessage } from './components/ChatScreen/types';
export const ChatScreen: React.FC<ChatScreenProps> = (props) => { export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
const navigation = useNavigation(); const navigation = useNavigation();
@@ -92,12 +93,12 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
isWideScreen && !props.isEmbedded ? { maxWidth: 1200, alignSelf: 'center' as const, width: '100%' as const } : null, isWideScreen && !props.isEmbedded ? { maxWidth: 1200, alignSelf: 'center' as const, width: '100%' as const } : null,
]), [isWideScreen, styles.container, props.isEmbedded]); ]), [isWideScreen, styles.container, props.isEmbedded]);
const listContentStyle = useMemo(() => ([ const listContentStyle = useMemo(() => ({
styles.listContent, paddingHorizontal: isWideScreen && !props.isEmbedded ? 24 : 16,
isWideScreen && !props.isEmbedded ...(isWideScreen && !props.isEmbedded ? { maxWidth: 900, alignSelf: 'center' as const } : {}),
? { paddingHorizontal: 24, maxWidth: 900, alignSelf: 'center' as const } paddingTop: 12,
: { paddingHorizontal: 16 }, paddingBottom: 24,
]), [isWideScreen, styles.listContent, props.isEmbedded]); }), [isWideScreen, props.isEmbedded]);
const inputWrapperStyle = useMemo(() => ([ const inputWrapperStyle = useMemo(() => ([
styles.inputWrapper, styles.inputWrapper,
@@ -285,7 +286,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
return map; return map;
}, [messages]); }, [messages]);
const renderMessage = useCallback(({ item, index }: { item: any; index: number }) => { const renderMessage = useCallback<ListRenderItem<GroupMessage>>(({ item, index }) => {
// inverted 下 renderItem 的 index 与时间顺序不一致,需回查原始序索引 // inverted 下 renderItem 的 index 与时间顺序不一致,需回查原始序索引
const logicalIndex = messageIndexMap.get(String(item.id)) ?? index; const logicalIndex = messageIndexMap.get(String(item.id)) ?? index;
return ( return (
@@ -330,7 +331,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
handleReplyPreviewPress, handleReplyPreviewPress,
]); ]);
const keyExtractor = useCallback((item: any) => String(item.id), []); const keyExtractor = useCallback((item: GroupMessage) => String(item.id), []);
// 获取正在输入提示 // 获取正在输入提示
const typingHint = getTypingHint(); const typingHint = getTypingHint();
@@ -448,22 +449,18 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
<ActivityIndicator size="large" color={colors.primary.main} /> <ActivityIndicator size="large" color={colors.primary.main} />
</View> </View>
) : ( ) : (
<FlatList <FlashList
ref={flatListRef} ref={flatListRef}
inverted inverted
data={displayMessages} data={displayMessages}
renderItem={renderMessage} renderItem={renderMessage}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
contentContainerStyle={listContentStyle as any} contentContainerStyle={listContentStyle}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag" keyboardDismissMode="on-drag"
scrollEnabled={true} scrollEnabled={true}
initialNumToRender={14} drawDistance={250}
maxToRenderPerBatch={10}
updateCellsBatchingPeriod={50}
windowSize={15}
removeClippedSubviews={false}
onScroll={handleMessageListScroll} onScroll={handleMessageListScroll}
onScrollBeginDrag={() => { onScrollBeginDrag={() => {
isUserDraggingRef.current = true; isUserDraggingRef.current = true;
@@ -477,26 +474,6 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
}} }}
scrollEventThrottle={16} scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange} onContentSizeChange={handleContentSizeChange}
onScrollToIndexFailed={(info) => {
const targetId = replyTargetMessageIdRef.current;
if (!targetId || !flatListRef.current) return;
flatListRef.current.scrollToOffset({
offset: Math.max(0, info.averageItemLength * info.index),
animated: true,
});
setTimeout(() => {
const retryIndex = displayMessages.findIndex(msg => String(msg.id) === targetId);
if (retryIndex >= 0) {
flatListRef.current?.scrollToIndex({
index: retryIndex,
animated: true,
viewPosition: 0.5,
});
}
}, 120);
}}
/> />
)} )}
{showEdgeLoadingIndicator && ( {showEdgeLoadingIndicator && (

View File

@@ -1,10 +1,16 @@
/** /**
* CreateGroupScreen 创建群聊界面 * CreateGroupScreen 创建群聊界面(现代化扁平风格)
* 允许用户创建新的群聊,设置群名称、描述,并选择初始成员 * 允许用户创建新的群聊,设置群名称、描述,并选择初始成员
* 支持响应式布局 * 支持响应式布局
*
* 设计风格:
* - 纯白/浅色背景,扁平化设计
* - 灰色填充输入框圆角14px
* - 橙色圆角按钮
* - 清晰的标签 + 输入框分组
*/ */
import React, { useState, useMemo, useEffect } from 'react'; import React, { useState, useMemo, useEffect, useRef } from 'react';
import { import {
View, View,
StyleSheet, StyleSheet,
@@ -15,6 +21,9 @@ import {
FlatList, FlatList,
Image, Image,
Platform, Platform,
Animated,
KeyboardAvoidingView,
ActivityIndicator,
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
@@ -24,7 +33,7 @@ import * as ImagePicker from 'expo-image-picker';
import { spacing, fontSizes, borderRadius, shadows, useAppColors, type AppColors } from '../../theme'; import { spacing, fontSizes, borderRadius, shadows, useAppColors, type AppColors } from '../../theme';
import { groupService } from '@/services/message'; import { groupService } from '@/services/message';
import { uploadService } from '@/services/upload'; import { uploadService } from '@/services/upload';
import { Avatar, Text, Button, Loading } from '../../components/common'; import { Avatar, Text, Loading } from '../../components/common';
import { User } from '../../types'; import { User } from '../../types';
import MutualFollowSelectorModal from './components/MutualFollowSelectorModal'; import MutualFollowSelectorModal from './components/MutualFollowSelectorModal';
@@ -47,6 +56,25 @@ const CreateGroupScreen: React.FC = () => {
// 邀请成员模态框状态 // 邀请成员模态框状态
const [inviteModalVisible, setInviteModalVisible] = useState(false); const [inviteModalVisible, setInviteModalVisible] = useState(false);
// 动画值
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(20)).current;
useEffect(() => {
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 400,
useNativeDriver: true,
}),
Animated.timing(slideAnim, {
toValue: 0,
duration: 400,
useNativeDriver: true,
}),
]).start();
}, []);
useEffect(() => { useEffect(() => {
if (inviteModalVisible) { if (inviteModalVisible) {
blurActiveElement(); blurActiveElement();
@@ -180,42 +208,60 @@ const CreateGroupScreen: React.FC = () => {
}; };
return ( return (
<SafeAreaView style={styles.container} edges={['bottom']}> <SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
>
<ScrollView <ScrollView
style={styles.scrollView} style={styles.scrollView}
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
{/* 群头像和名称区域 */} <Animated.View
<View style={styles.headerSection}> style={[
<View style={styles.avatarContainer}> styles.content,
{
opacity: fadeAnim,
transform: [{ translateY: slideAnim }],
},
]}
>
{/* 标题区域 */}
<View style={styles.titleSection}>
<Text style={styles.title}></Text>
<View style={styles.underline} />
<Text style={styles.subtitle}></Text>
</View>
{/* 群头像区域 */}
<View style={styles.avatarSection}>
<TouchableOpacity style={styles.avatarWrapper} onPress={handleSelectAvatar} disabled={uploadingAvatar}> <TouchableOpacity style={styles.avatarWrapper} onPress={handleSelectAvatar} disabled={uploadingAvatar}>
{uploadingAvatar ? ( {uploadingAvatar ? (
<View style={[styles.avatarPlaceholder, { width: 80, height: 80 }]}> <View style={styles.avatarPlaceholder}>
<Loading /> <Loading />
</View> </View>
) : groupAvatar ? ( ) : groupAvatar ? (
<Image source={{ uri: groupAvatar }} style={styles.avatarImage} /> <Image source={{ uri: groupAvatar }} style={styles.avatarImage} />
) : ( ) : (
<Avatar <View style={styles.avatarPlaceholder}>
source={undefined} <MaterialCommunityIcons name="camera" size={28} color={colors.text.hint} />
size={80} </View>
name={groupName || '群'}
/>
)} )}
<View style={styles.avatarBadge}> <View style={styles.avatarBadge}>
<MaterialCommunityIcons name="camera" size={14} color={colors.background.paper} /> <MaterialCommunityIcons name="pencil" size={12} color={colors.background.paper} />
</View> </View>
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.avatarHint}></Text>
</View> </View>
<View style={styles.nameInputContainer}> {/* 群名称输入 */}
<Text variant="label" style={styles.inputLabel}> <View style={styles.inputContainer}>
<Text color={colors.error.main}>*</Text> <Text style={styles.inputLabel}> <Text style={styles.requiredMark}>*</Text></Text>
</Text> <View style={styles.inputWrapper}>
<TextInput <TextInput
style={styles.nameInput} style={styles.input}
value={groupName} value={groupName}
onChangeText={setGroupName} onChangeText={setGroupName}
placeholder="请输入群名称" placeholder="请输入群名称"
@@ -223,21 +269,21 @@ const CreateGroupScreen: React.FC = () => {
maxLength={50} maxLength={50}
cursorColor={colors.primary.main} cursorColor={colors.primary.main}
selectionColor={`${colors.primary.main}40`} selectionColor={`${colors.primary.main}40`}
autoCapitalize="none"
/> />
<Text variant="caption" color={colors.text.hint} style={styles.charCount}> {groupName.length > 0 && (
{groupName.length}/50 <MaterialCommunityIcons name="check-circle" size={20} color={colors.primary.main} style={styles.checkIcon} />
</Text> )}
</View> </View>
<Text style={styles.charCount}>{groupName.length}/50</Text>
</View> </View>
{/* 群描述输入 */} {/* 群描述输入 */}
<View style={styles.section}> <View style={styles.inputContainer}>
<Text variant="label" style={styles.sectionTitle}> <Text style={styles.inputLabel}></Text>
<View style={[styles.inputWrapper, styles.textAreaWrapper]}>
</Text>
<View style={styles.textAreaContainer}>
<TextInput <TextInput
style={styles.textArea} style={[styles.input, styles.textArea]}
value={groupDescription} value={groupDescription}
onChangeText={setGroupDescription} onChangeText={setGroupDescription}
placeholder="介绍一下这个群聊吧..." placeholder="介绍一下这个群聊吧..."
@@ -249,22 +295,16 @@ const CreateGroupScreen: React.FC = () => {
cursorColor={colors.primary.main} cursorColor={colors.primary.main}
selectionColor={`${colors.primary.main}40`} selectionColor={`${colors.primary.main}40`}
/> />
<Text variant="caption" color={colors.text.hint} style={styles.textAreaCharCount}>
{groupDescription.length}/500
</Text>
</View> </View>
<Text style={styles.charCount}>{groupDescription.length}/500</Text>
</View> </View>
{/* 已选成员 */} {/* 已选成员 */}
{selectedMembers.length > 0 && ( {selectedMembers.length > 0 && (
<View style={styles.section}> <View style={styles.membersSection}>
<View style={styles.sectionHeader}> <View style={styles.sectionHeader}>
<Text variant="label" style={styles.sectionTitle}> <Text style={styles.sectionTitle}></Text>
<Text style={styles.memberCount}>{selectedMembers.length}</Text>
</Text>
<Text variant="caption" color={colors.primary.main}>
{selectedMembers.length}
</Text>
</View> </View>
<FlatList <FlatList
data={selectedMembers} data={selectedMembers}
@@ -284,29 +324,34 @@ const CreateGroupScreen: React.FC = () => {
activeOpacity={0.8} activeOpacity={0.8}
> >
<View style={styles.inviteIconContainer}> <View style={styles.inviteIconContainer}>
<MaterialCommunityIcons name="account-plus" size={24} color={colors.primary.main} /> <MaterialCommunityIcons name="account-plus" size={22} color={colors.primary.main} />
</View> </View>
<View style={styles.inviteTextContainer}> <View style={styles.inviteTextContainer}>
<Text variant="body" style={styles.inviteTitle}></Text> <Text style={styles.inviteTitle}></Text>
<Text variant="caption" color={colors.text.secondary}> <Text style={styles.inviteSubtitle}></Text>
</Text>
</View> </View>
<MaterialCommunityIcons name="chevron-right" size={24} color={colors.text.hint} /> <MaterialCommunityIcons name="chevron-right" size={22} color={colors.text.hint} />
</TouchableOpacity> </TouchableOpacity>
</ScrollView>
{/* 创建按钮 */} {/* 创建按钮 */}
<View style={styles.footer}> <TouchableOpacity
<Button style={[
title="创建群聊" styles.createButton,
(!groupName.trim() || submitting) && styles.createButtonDisabled,
]}
onPress={handleCreateGroup} onPress={handleCreateGroup}
loading={submitting}
disabled={!groupName.trim() || submitting} disabled={!groupName.trim() || submitting}
fullWidth activeOpacity={0.9}
size="lg" >
/> {submitting ? (
</View> <ActivityIndicator size="small" color={colors.text.inverse} />
) : (
<Text style={styles.createButtonText}></Text>
)}
</TouchableOpacity>
</Animated.View>
</ScrollView>
</KeyboardAvoidingView>
<MutualFollowSelectorModal <MutualFollowSelectorModal
visible={inviteModalVisible} visible={inviteModalVisible}
@@ -324,39 +369,73 @@ function createCreateGroupStyles(colors: AppColors) {
return StyleSheet.create({ return StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: colors.background.default, backgroundColor: colors.background.paper,
},
keyboardView: {
flex: 1,
}, },
scrollView: { scrollView: {
flex: 1, flex: 1,
}, },
scrollContent: { scrollContent: {
padding: spacing.md, flexGrow: 1,
paddingBottom: spacing.xl * 2, paddingHorizontal: 28,
paddingTop: 40,
paddingBottom: 40,
}, },
// 头部区域样式 - 扁平化 content: {
headerSection: { flex: 1,
flexDirection: 'row', maxWidth: 400,
alignItems: 'flex-start', width: '100%',
marginBottom: spacing.xl, alignSelf: 'center',
}, },
avatarContainer: {
marginRight: spacing.lg, // 标题区域
titleSection: {
marginBottom: 32,
marginTop: 12,
},
title: {
fontSize: 28,
fontWeight: '700',
color: colors.text.primary,
lineHeight: 36,
},
underline: {
width: 40,
height: 4,
backgroundColor: colors.primary.main,
borderRadius: 2,
marginTop: 12,
marginBottom: 12,
},
subtitle: {
fontSize: 15,
color: colors.text.secondary,
},
// 头像区域
avatarSection: {
alignItems: 'center',
marginBottom: 32,
}, },
avatarWrapper: { avatarWrapper: {
position: 'relative', position: 'relative',
}, },
avatarImage: { avatarImage: {
width: 80, width: 88,
height: 80, height: 88,
borderRadius: 40, borderRadius: 44,
borderWidth: 3, borderWidth: 3,
borderColor: colors.primary.light + '40', borderColor: colors.primary.light + '40',
}, },
avatarPlaceholder: { avatarPlaceholder: {
width: 88,
height: 88,
borderRadius: 44,
backgroundColor: colors.background.default,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
backgroundColor: colors.background.default,
borderRadius: 40,
borderWidth: 2, borderWidth: 2,
borderColor: colors.divider, borderColor: colors.divider,
borderStyle: 'dashed', borderStyle: 'dashed',
@@ -374,76 +453,89 @@ function createCreateGroupStyles(colors: AppColors) {
borderWidth: 3, borderWidth: 3,
borderColor: colors.background.paper, borderColor: colors.background.paper,
}, },
nameInputContainer: { avatarHint: {
flex: 1, marginTop: 10,
paddingTop: spacing.sm, fontSize: 13,
color: colors.text.hint,
fontWeight: '500',
},
// 输入框区域(参考登录注册风格)
inputContainer: {
marginBottom: 24,
}, },
inputLabel: { inputLabel: {
marginBottom: spacing.sm, fontSize: 14,
fontWeight: '700', fontWeight: '500',
fontSize: fontSizes.sm + 1, color: colors.text.secondary,
letterSpacing: 0.3, marginBottom: 10,
}, },
nameInput: { requiredMark: {
backgroundColor: colors.background.paper, color: colors.error.main,
borderRadius: borderRadius.lg,
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
fontSize: fontSizes.lg,
color: colors.text.primary,
borderWidth: 1.5,
borderColor: colors.divider + '80',
fontWeight: '600', fontWeight: '600',
}, },
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.default,
borderRadius: 14,
paddingHorizontal: 18,
minHeight: 56,
borderWidth: 1,
borderColor: 'transparent',
},
input: {
flex: 1,
fontSize: 16,
color: colors.text.primary,
minHeight: 56,
},
textAreaWrapper: {
paddingVertical: 14,
alignItems: 'flex-start',
},
textArea: {
minHeight: 100,
lineHeight: 22,
textAlignVertical: 'top',
},
checkIcon: {
marginLeft: 8,
},
charCount: { charCount: {
textAlign: 'right', textAlign: 'right',
marginTop: spacing.xs, marginTop: 6,
fontSize: 12,
color: colors.text.hint,
fontWeight: '500', fontWeight: '500',
fontSize: fontSizes.sm,
}, },
// 区域样式
section: { // 成员区域
marginBottom: spacing.xl, membersSection: {
marginBottom: 24,
}, },
sectionHeader: { sectionHeader: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
marginBottom: spacing.md, marginBottom: 12,
}, },
sectionTitle: { sectionTitle: {
fontWeight: '800', fontSize: 14,
fontSize: fontSizes.md + 1,
marginBottom: spacing.sm,
letterSpacing: 0.3,
},
// 文本域样式 - 更现代
textAreaContainer: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
borderWidth: 1.5,
borderColor: colors.divider + '80',
padding: spacing.md,
},
textArea: {
minHeight: 100,
fontSize: fontSizes.md,
color: colors.text.primary,
lineHeight: 22,
},
textAreaCharCount: {
textAlign: 'right',
marginTop: spacing.sm,
fontWeight: '500', fontWeight: '500',
fontSize: fontSizes.sm, color: colors.text.secondary,
},
memberCount: {
fontSize: 13,
color: colors.primary.main,
fontWeight: '600',
}, },
// 已选成员样式
selectedMembersList: { selectedMembersList: {
paddingVertical: spacing.sm, paddingVertical: 4,
}, },
selectedMemberItem: { selectedMemberItem: {
alignItems: 'center', alignItems: 'center',
marginRight: spacing.lg, marginRight: 16,
width: 64, width: 64,
}, },
removeMemberButton: { removeMemberButton: {
@@ -462,48 +554,62 @@ function createCreateGroupStyles(colors: AppColors) {
borderColor: colors.background.paper, borderColor: colors.background.paper,
}, },
selectedMemberName: { selectedMemberName: {
marginTop: spacing.xs, marginTop: 4,
textAlign: 'center', textAlign: 'center',
width: '100%', width: '100%',
fontWeight: '600', fontWeight: '600',
fontSize: fontSizes.sm, fontSize: 12,
color: colors.text.primary,
}, },
// 邀请按钮样式 - 扁平化
// 邀请按钮
inviteButton: { inviteButton: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
backgroundColor: colors.background.paper, backgroundColor: colors.background.default,
borderRadius: borderRadius.lg, borderRadius: 14,
padding: spacing.md, padding: 16,
marginBottom: spacing.xl, marginBottom: 32,
borderWidth: 0.5,
borderColor: colors.divider + '40',
}, },
inviteIconContainer: { inviteIconContainer: {
width: 48, width: 44,
height: 48, height: 44,
borderRadius: borderRadius.lg, borderRadius: 12,
backgroundColor: colors.primary.light + '15', backgroundColor: colors.primary.light + '15',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
marginRight: spacing.md, marginRight: 14,
}, },
inviteTextContainer: { inviteTextContainer: {
flex: 1, flex: 1,
}, },
inviteTitle: { inviteTitle: {
fontWeight: '700', fontWeight: '600',
fontSize: fontSizes.md + 1, fontSize: 16,
color: colors.text.primary,
marginBottom: 2, marginBottom: 2,
letterSpacing: 0.3,
}, },
// 底部按钮样式 inviteSubtitle: {
footer: { fontSize: 13,
padding: spacing.lg, color: colors.text.secondary,
paddingBottom: spacing.xl, },
backgroundColor: colors.background.paper,
borderTopWidth: 0.5, // 创建按钮
borderTopColor: colors.divider + '60', createButton: {
height: 56,
borderRadius: 14,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
},
createButtonDisabled: {
opacity: 0.6,
},
createButtonText: {
fontSize: 17,
fontWeight: '600',
color: colors.text.inverse,
}, },
}); });
} }

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback, useMemo } from 'react'; import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { import {
View, View,
StyleSheet, StyleSheet,
@@ -9,7 +9,12 @@ import {
Clipboard, Clipboard,
FlatList, FlatList,
RefreshControl, RefreshControl,
Animated,
KeyboardAvoidingView,
Platform,
ScrollView,
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
@@ -26,112 +31,137 @@ function createJoinGroupStyles(colors: AppColors) {
return StyleSheet.create({ return StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: colors.background.default,
padding: spacing.md,
},
heroCard: {
backgroundColor: colors.background.paper, backgroundColor: colors.background.paper,
borderRadius: borderRadius.xl,
padding: spacing.lg,
marginBottom: spacing.md,
borderWidth: 0.5,
borderColor: colors.divider + '40',
}, },
heroIconWrap: { keyboardView: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: colors.primary.light + '15',
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.md,
},
heroTitle: {
marginBottom: spacing.xs,
fontWeight: '800',
fontSize: fontSizes.xl + 1,
letterSpacing: 0.3,
},
tip: {
lineHeight: 22,
fontWeight: '400',
},
formCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.xl,
padding: spacing.lg,
flex: 1, flex: 1,
borderWidth: 0.5,
borderColor: colors.divider + '40',
}, },
label: { scrollContent: {
marginBottom: spacing.xs, flexGrow: 1,
paddingHorizontal: 28,
paddingTop: 40,
paddingBottom: 40,
},
content: {
flex: 1,
maxWidth: 400,
width: '100%',
alignSelf: 'center',
},
// 标题区域
titleSection: {
marginBottom: 32,
marginTop: 12,
},
title: {
fontSize: 28,
fontWeight: '700', fontWeight: '700',
fontSize: fontSizes.sm + 1,
},
input: {
flex: 1,
borderWidth: 1.5,
borderColor: colors.divider + '80',
borderRadius: borderRadius.lg,
backgroundColor: colors.background.default,
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
color: colors.text.primary, color: colors.text.primary,
fontSize: fontSizes.md, lineHeight: 36,
},
underline: {
width: 40,
height: 4,
backgroundColor: colors.primary.main,
borderRadius: 2,
marginTop: 12,
marginBottom: 12,
},
subtitle: {
fontSize: 15,
color: colors.text.secondary,
},
// 搜索区域
searchSection: {
marginBottom: 24,
},
inputLabel: {
fontSize: 14,
fontWeight: '500',
color: colors.text.secondary,
marginBottom: 10,
}, },
searchRow: { searchRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
marginBottom: spacing.md, },
inputWrapper: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.default,
borderRadius: 14,
paddingHorizontal: 18,
height: 56,
borderWidth: 1,
borderColor: 'transparent',
},
input: {
flex: 1,
fontSize: 16,
color: colors.text.primary,
height: 56,
}, },
searchBtn: { searchBtn: {
width: 46, width: 56,
height: 46, height: 56,
borderRadius: borderRadius.lg, borderRadius: 14,
backgroundColor: colors.primary.main, backgroundColor: colors.primary.main,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
marginLeft: spacing.sm, marginLeft: spacing.sm,
}, },
searchBtnDisabled: {
opacity: 0.5,
},
// 搜索结果区域
searchResultSection: { searchResultSection: {
marginBottom: spacing.md, marginBottom: 24,
}, },
sectionTitle: { sectionTitle: {
marginBottom: spacing.sm, fontSize: 14,
fontWeight: '800', fontWeight: '500',
fontSize: fontSizes.md + 1, color: colors.text.secondary,
letterSpacing: 0.3, marginBottom: 12,
}, },
listSection: { emptyText: {
flex: 1, textAlign: 'center',
fontSize: 14,
color: colors.text.hint,
fontWeight: '500',
paddingVertical: spacing.lg,
}, },
// 群卡片
groupCard: { groupCard: {
borderWidth: 0.5,
borderColor: colors.divider + '40',
borderRadius: borderRadius.lg,
padding: spacing.md,
backgroundColor: colors.background.default, backgroundColor: colors.background.default,
borderRadius: 16,
padding: spacing.lg,
marginBottom: spacing.md, marginBottom: spacing.md,
}, },
groupHeader: { groupHeader: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
marginBottom: spacing.sm,
}, },
groupMeta: { groupMeta: {
marginLeft: spacing.md, marginLeft: spacing.md,
flex: 1, flex: 1,
}, },
groupName: { groupName: {
marginBottom: spacing.xs,
fontWeight: '700', fontWeight: '700',
fontSize: fontSizes.md + 1, fontSize: 16,
letterSpacing: 0.3, color: colors.text.primary,
marginBottom: 2,
}, },
groupDesc: { groupDesc: {
marginTop: spacing.sm, fontSize: 13,
color: colors.text.secondary,
lineHeight: 20, lineHeight: 20,
fontWeight: '400', marginTop: spacing.sm,
}, },
groupInfoRow: { groupInfoRow: {
marginTop: spacing.sm, marginTop: spacing.sm,
@@ -139,12 +169,20 @@ function createJoinGroupStyles(colors: AppColors) {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
}, },
groupInfoText: {
fontSize: 12,
color: colors.text.secondary,
},
groupNoRow: { groupNoRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
marginBottom: spacing.md, marginBottom: spacing.md,
}, },
groupNoText: {
fontSize: 12,
color: colors.text.secondary,
},
copyBtn: { copyBtn: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@@ -155,15 +193,17 @@ function createJoinGroupStyles(colors: AppColors) {
}, },
copyBtnText: { copyBtnText: {
marginLeft: 4, marginLeft: 4,
fontSize: 12,
fontWeight: '600', fontWeight: '600',
color: colors.primary.main,
}, },
submitBtn: { submitBtn: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
borderRadius: borderRadius.lg, borderRadius: 14,
backgroundColor: colors.primary.main, backgroundColor: colors.primary.main,
minHeight: 46, height: 48,
}, },
submitBtnDisabled: { submitBtnDisabled: {
opacity: 0.5, opacity: 0.5,
@@ -171,12 +211,13 @@ function createJoinGroupStyles(colors: AppColors) {
submitText: { submitText: {
marginLeft: spacing.xs, marginLeft: spacing.xs,
fontWeight: '700', fontWeight: '700',
fontSize: fontSizes.md, fontSize: 15,
color: colors.text.inverse,
}, },
emptyText: {
marginTop: spacing.sm, // 推荐区域
textAlign: 'center', recommendSection: {
fontWeight: '500', flex: 1,
}, },
loadingFooter: { loadingFooter: {
paddingVertical: spacing.md, paddingVertical: spacing.md,
@@ -186,10 +227,16 @@ function createJoinGroupStyles(colors: AppColors) {
paddingVertical: spacing.md, paddingVertical: spacing.md,
alignItems: 'center', alignItems: 'center',
}, },
loadMoreText: {
fontSize: 14,
fontWeight: '500',
color: colors.primary.main,
},
noMoreText: { noMoreText: {
textAlign: 'center', textAlign: 'center',
paddingVertical: spacing.md, paddingVertical: spacing.md,
fontWeight: '500', fontWeight: '500',
fontSize: 13,
color: colors.text.hint, color: colors.text.hint,
}, },
}); });
@@ -205,6 +252,25 @@ const JoinGroupScreen: React.FC = () => {
const [searchedGroup, setSearchedGroup] = useState<GroupResponse | null>(null); const [searchedGroup, setSearchedGroup] = useState<GroupResponse | null>(null);
const [searched, setSearched] = useState(false); const [searched, setSearched] = useState(false);
// 动画值
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(20)).current;
useEffect(() => {
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 400,
useNativeDriver: true,
}),
Animated.timing(slideAnim, {
toValue: 0,
duration: 400,
useNativeDriver: true,
}),
]).start();
}, []);
// 使用游标分页 Hook 管理群组列表 // 使用游标分页 Hook 管理群组列表
const { const {
list: groups, list: groups,
@@ -296,31 +362,31 @@ const JoinGroupScreen: React.FC = () => {
<View style={styles.groupHeader}> <View style={styles.groupHeader}>
<Avatar source={group.avatar} size={52} name={group.name} /> <Avatar source={group.avatar} size={52} name={group.name} />
<View style={styles.groupMeta}> <View style={styles.groupMeta}>
<Text variant="body" style={styles.groupName} numberOfLines={1}> <Text style={styles.groupName} numberOfLines={1}>
{group.name} {group.name}
</Text> </Text>
</View> </View>
</View> </View>
{!!group.description && ( {!!group.description && (
<Text variant="caption" color={colors.text.secondary} style={styles.groupDesc}> <Text style={styles.groupDesc}>
{group.description} {group.description}
</Text> </Text>
)} )}
<View style={styles.groupInfoRow}> <View style={styles.groupInfoRow}>
<Text variant="caption" color={colors.text.secondary}> <Text style={styles.groupInfoText}>
{group.member_count}/{group.max_members} {group.member_count}/{group.max_members}
</Text> </Text>
<Text variant="caption" color={colors.text.secondary}> <Text style={styles.groupInfoText}>
{getJoinTypeText(group.join_type)} {getJoinTypeText(group.join_type)}
</Text> </Text>
</View> </View>
<View style={styles.groupNoRow}> <View style={styles.groupNoRow}>
<Text variant="caption" color={colors.text.secondary}> <Text style={styles.groupNoText}>
{formatGroupNo(group.id)} {formatGroupNo(group.id)}
</Text> </Text>
<TouchableOpacity style={styles.copyBtn} onPress={() => handleCopyGroupId(group.id)}> <TouchableOpacity style={styles.copyBtn} onPress={() => handleCopyGroupId(group.id)}>
<MaterialCommunityIcons name="content-copy" size={14} color={colors.primary.main} /> <MaterialCommunityIcons name="content-copy" size={14} color={colors.primary.main} />
<Text variant="caption" color={colors.primary.main} style={styles.copyBtnText}> <Text style={styles.copyBtnText}>
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@@ -331,11 +397,11 @@ const JoinGroupScreen: React.FC = () => {
disabled={isJoining} disabled={isJoining}
> >
{isJoining ? ( {isJoining ? (
<ActivityIndicator color="#fff" /> <ActivityIndicator color={colors.text.inverse} />
) : ( ) : (
<> <>
<MaterialCommunityIcons name="send-outline" size={18} color="#fff" /> <MaterialCommunityIcons name="send-outline" size={18} color={colors.text.inverse} />
<Text variant="body" color="#fff" style={styles.submitText}> <Text style={styles.submitText}>
</Text> </Text>
</> </>
@@ -362,9 +428,7 @@ const JoinGroupScreen: React.FC = () => {
if (searchedGroup) { if (searchedGroup) {
return ( return (
<View style={styles.searchResultSection}> <View style={styles.searchResultSection}>
<Text variant="caption" color={colors.text.secondary} style={styles.sectionTitle}> <Text style={styles.sectionTitle}></Text>
</Text>
{renderGroupItem({ item: searchedGroup })} {renderGroupItem({ item: searchedGroup })}
</View> </View>
); );
@@ -372,9 +436,12 @@ const JoinGroupScreen: React.FC = () => {
if (!searching) { if (!searching) {
return ( return (
<Text variant="caption" color={colors.text.secondary} style={styles.emptyText}> <View style={styles.searchResultSection}>
<Text style={styles.sectionTitle}></Text>
<Text style={styles.emptyText}>
ID后重试 ID后重试
</Text> </Text>
</View>
); );
} }
@@ -382,22 +449,45 @@ const JoinGroupScreen: React.FC = () => {
}; };
return ( return (
<View style={styles.container}> <SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.heroCard}> <KeyboardAvoidingView
<View style={styles.heroIconWrap}> behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
<MaterialCommunityIcons name="account-group-outline" size={28} color={colors.primary.main} /> style={styles.keyboardView}
</View> >
<Text variant="h3" style={styles.heroTitle}></Text> <ScrollView
<Text variant="body" color={colors.text.secondary} style={styles.tip}> contentContainerStyle={styles.scrollContent}
ID keyboardShouldPersistTaps="handled"
</Text> showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={refresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
>
<Animated.View
style={[
styles.content,
{
opacity: fadeAnim,
transform: [{ translateY: slideAnim }],
},
]}
>
{/* 标题区域 */}
<View style={styles.titleSection}>
<Text style={styles.title}></Text>
<View style={styles.underline} />
<Text style={styles.subtitle}>ID或浏览推荐群组</Text>
</View> </View>
<View style={styles.formCard}> {/* 搜索区域 */}
<Text variant="caption" color={colors.text.secondary} style={styles.label}> <View style={styles.searchSection}>
ID <Text style={styles.inputLabel}>ID</Text>
</Text>
<View style={styles.searchRow}> <View style={styles.searchRow}>
<View style={styles.inputWrapper}>
<TextInput <TextInput
value={keyword} value={keyword}
onChangeText={setKeyword} onChangeText={setKeyword}
@@ -409,64 +499,63 @@ const JoinGroupScreen: React.FC = () => {
cursorColor={colors.primary.main} cursorColor={colors.primary.main}
selectionColor={`${colors.primary.main}40`} selectionColor={`${colors.primary.main}40`}
/> />
</View>
<TouchableOpacity <TouchableOpacity
style={[styles.searchBtn, (!keyword.trim() || searching || !!joiningGroupId) && styles.submitBtnDisabled]} style={[
styles.searchBtn,
(!keyword.trim() || searching || !!joiningGroupId) && styles.searchBtnDisabled,
]}
onPress={handleSearch} onPress={handleSearch}
disabled={!keyword.trim() || searching || !!joiningGroupId} disabled={!keyword.trim() || searching || !!joiningGroupId}
> >
{searching ? ( {searching ? (
<ActivityIndicator color="#fff" /> <ActivityIndicator color={colors.text.inverse} />
) : ( ) : (
<MaterialCommunityIcons name="magnify" size={20} color="#fff" /> <MaterialCommunityIcons name="magnify" size={22} color={colors.text.inverse} />
)} )}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View>
{/* 搜索结果 */} {/* 搜索结果 */}
{renderSearchResult()} {renderSearchResult()}
{/* 群组列表 */} {/* 推荐群组 */}
<View style={styles.listSection}> <View style={styles.recommendSection}>
<Text variant="caption" color={colors.text.secondary} style={styles.sectionTitle}> <Text style={styles.sectionTitle}></Text>
{groups.length === 0 && !isLoading ? (
</Text> renderEmptyList()
<FlatList ) : (
data={groups} <>
renderItem={renderGroupItem} {groups.map((group) => (
keyExtractor={(item) => String(item.id)} <View key={String(group.id)}>
refreshControl={ {renderGroupItem({ item: group })}
<RefreshControl </View>
refreshing={isRefreshing} ))}
onRefresh={refresh} {isLoading && (
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
onEndReached={loadMore}
onEndReachedThreshold={0.3}
ListEmptyComponent={renderEmptyList}
ListFooterComponent={
isLoading ? (
<View style={styles.loadingFooter}> <View style={styles.loadingFooter}>
<ActivityIndicator color={colors.primary.main} /> <ActivityIndicator color={colors.primary.main} />
</View> </View>
) : hasMore ? ( )}
{hasMore && !isLoading && (
<TouchableOpacity style={styles.loadMoreBtn} onPress={loadMore}> <TouchableOpacity style={styles.loadMoreBtn} onPress={loadMore}>
<Text variant="caption" color={colors.primary.main}> <Text style={styles.loadMoreText}>
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
) : groups.length > 0 ? ( )}
<Text variant="caption" color={colors.text.hint} style={styles.noMoreText}> {!hasMore && groups.length > 0 && (
<Text style={styles.noMoreText}>
</Text> </Text>
) : null )}
} </>
showsVerticalScrollIndicator={false} )}
/>
</View>
</View>
</View> </View>
</Animated.View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
); );
}; };

View File

@@ -12,7 +12,6 @@
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'; import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import { import {
View, View,
FlatList,
StyleSheet, StyleSheet,
TouchableOpacity, TouchableOpacity,
Pressable, Pressable,
@@ -24,6 +23,7 @@ import {
BackHandler, BackHandler,
Platform, Platform,
} from 'react-native'; } from 'react-native';
import { FlashList, ListRenderItem } from '@shopify/flash-list';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useIsFocused } from '@react-navigation/native'; import { useIsFocused } from '@react-navigation/native';
@@ -115,6 +115,7 @@ export const MessageListScreen: React.FC = () => {
// 使用 MessageManager 获取未读数和系统通知数 // 使用 MessageManager 获取未读数和系统通知数
const { totalUnreadCount, systemUnreadCount } = useUnreadCount(); const { totalUnreadCount, systemUnreadCount } = useUnreadCount();
const lastSystemMessageAt = useMessageStore(state => state.lastSystemMessageAt); const lastSystemMessageAt = useMessageStore(state => state.lastSystemMessageAt);
const systemMessageTime = lastSystemMessageAt ?? '';
const { markAllAsRead, isMarking } = useMarkAsRead(null); const { markAllAsRead, isMarking } = useMarkAsRead(null);
// 【新架构】使用MessageManager的hook创建会话 // 【新架构】使用MessageManager的hook创建会话
@@ -162,14 +163,14 @@ export const MessageListScreen: React.FC = () => {
seq: 0, seq: 0,
segments: [{ type: 'text', data: { text: '系统通知' } }], segments: [{ type: 'text', data: { text: '系统通知' } }],
status: 'normal', status: 'normal',
created_at: lastSystemMessageAt || new Date().toISOString(), created_at: systemMessageTime,
}, },
last_message_at: lastSystemMessageAt || new Date().toISOString(), last_message_at: systemMessageTime,
unread_count: systemUnreadCount, unread_count: systemUnreadCount,
participants: [], participants: [],
created_at: lastSystemMessageAt || new Date().toISOString(), created_at: systemMessageTime,
updated_at: lastSystemMessageAt || new Date().toISOString(), updated_at: systemMessageTime,
}), [systemUnreadCount, lastSystemMessageAt]); }), [systemUnreadCount, systemMessageTime]);
// 动画值 // 动画值
const [scaleAnims] = useState(() => const [scaleAnims] = useState(() =>
@@ -453,7 +454,6 @@ export const MessageListScreen: React.FC = () => {
// 创建系统通知会话项 // 创建系统通知会话项
const createSystemMessageItem = (): ConversationResponse => { const createSystemMessageItem = (): ConversationResponse => {
const noticeContent = systemUnreadCount > 0 ? `您有 ${systemUnreadCount} 条未读通知` : '暂无新通知'; const noticeContent = systemUnreadCount > 0 ? `您有 ${systemUnreadCount} 条未读通知` : '暂无新通知';
const lastTime = lastSystemMessageAt || new Date().toISOString();
return { return {
id: SYSTEM_MESSAGE_CHANNEL_ID, id: SYSTEM_MESSAGE_CHANNEL_ID,
type: 'private', type: 'private',
@@ -465,13 +465,13 @@ export const MessageListScreen: React.FC = () => {
seq: 0, seq: 0,
segments: [{ type: 'text', data: { text: noticeContent } }], segments: [{ type: 'text', data: { text: noticeContent } }],
status: 'normal', status: 'normal',
created_at: lastTime, created_at: systemMessageTime,
}, },
last_message_at: lastTime, last_message_at: systemMessageTime,
unread_count: systemUnreadCount, unread_count: systemUnreadCount,
participants: [], participants: [],
created_at: lastTime, created_at: systemMessageTime,
updated_at: lastTime, updated_at: systemMessageTime,
}; };
}; };
@@ -500,8 +500,8 @@ export const MessageListScreen: React.FC = () => {
const totalUnread = totalUnreadCount + systemUnreadCount; const totalUnread = totalUnreadCount + systemUnreadCount;
// 渲染会话项(行级 memo + 按 id 解析最新会话,避免 Tab 切换时整表无效重绘) // 渲染会话项(行级 memo + 按 id 解析最新会话,避免 Tab 切换时整表无效重绘)
const renderConversation = useCallback( const renderConversation = useCallback<ListRenderItem<ConversationResponse>>(
({ item, index }: { item: ConversationResponse; index: number }) => { ({ item, index }) => {
const isSelected = selectedConversation?.id === item.id; const isSelected = selectedConversation?.id === item.id;
return ( return (
<ConversationListRow <ConversationListRow
@@ -715,7 +715,7 @@ export const MessageListScreen: React.FC = () => {
</View> </View>
{/* 搜索结果 */} {/* 搜索结果 */}
<FlatList <FlashList
data={searchResults} data={searchResults}
renderItem={renderSearchResult} renderItem={renderSearchResult}
keyExtractor={(item, index) => { keyExtractor={(item, index) => {
@@ -724,14 +724,16 @@ export const MessageListScreen: React.FC = () => {
} }
return `user-${item.user?.id || index}`; return `user-${item.user?.id || index}`;
}} }}
contentContainerStyle={[ contentContainerStyle={{
styles.searchResultsContent, paddingHorizontal: spacing.md,
{ paddingBottom: listBottomInset }, paddingTop: spacing.sm,
]} paddingBottom: listBottomInset,
}}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
scrollEnabled={true} scrollEnabled={true}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag" keyboardDismissMode="on-drag"
drawDistance={250}
ListEmptyComponent={renderSearchEmpty} ListEmptyComponent={renderSearchEmpty}
/> />
</View> </View>
@@ -779,15 +781,15 @@ export const MessageListScreen: React.FC = () => {
<ActivityIndicator size="large" color={colors.primary.main} /> <ActivityIndicator size="large" color={colors.primary.main} />
</View> </View>
) : ( ) : (
<FlatList <FlashList
data={listData} data={listData}
renderItem={renderConversation} renderItem={renderConversation}
keyExtractor={(item) => String(item.id)} keyExtractor={(item) => String(item.id)}
contentContainerStyle={[ contentContainerStyle={{
styles.listContent, backgroundColor: colors.background.default,
...(isWideScreen ? [styles.listContentWide] : []), paddingHorizontal: spacing.xs,
{ paddingBottom: listBottomInset }, paddingBottom: listBottomInset,
]} }}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
ListEmptyComponent={renderEmpty} ListEmptyComponent={renderEmpty}
onEndReached={onEndReached} onEndReached={onEndReached}
@@ -795,6 +797,7 @@ export const MessageListScreen: React.FC = () => {
scrollEnabled={true} scrollEnabled={true}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag" keyboardDismissMode="on-drag"
drawDistance={250}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
refreshing={refreshing} refreshing={refreshing}

View File

@@ -14,7 +14,6 @@
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'; import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import { import {
View, View,
FlatList,
StyleSheet, StyleSheet,
TouchableOpacity, TouchableOpacity,
RefreshControl, RefreshControl,
@@ -25,6 +24,7 @@ import {
StatusBar, StatusBar,
ScrollView, ScrollView,
} from 'react-native'; } from 'react-native';
import { FlashList, ListRenderItem } from '@shopify/flash-list';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useIsFocused } from '@react-navigation/native'; import { useIsFocused } from '@react-navigation/native';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
@@ -353,13 +353,13 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
}; };
// 渲染消息项 // 渲染消息项
const renderMessage = ({ item }: { item: SystemMessageResponse }) => ( const renderMessage = useCallback<ListRenderItem<SystemMessageResponse>>(({ item }) => (
<SystemMessageItem <SystemMessageItem
message={item} message={item}
onPress={() => handleMessagePress(item)} onPress={() => handleMessagePress(item)}
onAvatarPress={() => handleAvatarPress(item)} onAvatarPress={() => handleAvatarPress(item)}
/> />
); ), []);
// 渲染底部加载指示器 // 渲染底部加载指示器
const renderFooter = () => { const renderFooter = () => {
@@ -500,7 +500,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
<ActivityIndicator size="large" color={colors.primary.main} /> <ActivityIndicator size="large" color={colors.primary.main} />
</View> </View>
) : ( ) : (
<FlatList <FlashList
data={filteredMessages} data={filteredMessages}
renderItem={renderMessage} renderItem={renderMessage}
keyExtractor={item => item.id.toString()} keyExtractor={item => item.id.toString()}
@@ -513,6 +513,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
scrollEnabled={true} scrollEnabled={true}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag" keyboardDismissMode="on-drag"
drawDistance={250}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
refreshing={refreshing} refreshing={refreshing}
@@ -585,7 +586,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
<ActivityIndicator size="large" color={colors.primary.main} /> <ActivityIndicator size="large" color={colors.primary.main} />
</View> </View>
) : ( ) : (
<FlatList <FlashList
data={filteredMessages} data={filteredMessages}
renderItem={renderMessage} renderItem={renderMessage}
keyExtractor={item => item.id.toString()} keyExtractor={item => item.id.toString()}
@@ -598,6 +599,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
scrollEnabled={true} scrollEnabled={true}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag" keyboardDismissMode="on-drag"
drawDistance={250}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
refreshing={refreshing} refreshing={refreshing}

View File

@@ -8,7 +8,6 @@ import React, { useRef, useMemo, useCallback } from 'react';
import { import {
View, View,
TouchableOpacity, TouchableOpacity,
Image,
GestureResponderEvent, GestureResponderEvent,
StyleSheet, StyleSheet,
Dimensions, Dimensions,
@@ -123,8 +122,6 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
}; };
}); });
// 检查当前消息是否被选中
// 获取发送者信息(群聊)- 供子组件使用 // 获取发送者信息(群聊)- 供子组件使用
const getSenderInfo = React.useCallback((senderId: string): SenderInfo => { const getSenderInfo = React.useCallback((senderId: string): SenderInfo => {
if (senderId === currentUserId) { if (senderId === currentUserId) {
@@ -153,7 +150,7 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
}, [currentUserId, currentUser?.nickname, currentUser?.avatar, groupMembers]); }, [currentUserId, currentUser?.nickname, currentUser?.avatar, groupMembers]);
// 使用useMemo确保senderInfo在message.sender变化时重新计算 // 使用useMemo确保senderInfo在message.sender变化时重新计算
const senderInfo = React.useMemo((): SenderInfo | null => { const senderInfo = useMemo((): SenderInfo | null => {
if (!isGroupChat) return null; if (!isGroupChat) return null;
const senderId = message.sender_id; const senderId = message.sender_id;
@@ -204,6 +201,9 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
return map; return map;
}, [groupMembers]); }, [groupMembers]);
// FlashList 回收复用时重置内部状态
const messageKey = String(message.id);
// 记录按压位置 // 记录按压位置
const handlePressIn = (event: GestureResponderEvent) => { const handlePressIn = (event: GestureResponderEvent) => {
const { pageX, pageY } = event.nativeEvent; const { pageX, pageY } = event.nativeEvent;
@@ -464,6 +464,7 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
// 使用 SwipeableMessageBubble 包裹消息内容 // 使用 SwipeableMessageBubble 包裹消息内容
return ( return (
<View style={{ width: '100%' }} key={messageKey}>
<SwipeableMessageBubble <SwipeableMessageBubble
isMe={isMe} isMe={isMe}
onReply={handleSwipeReply} onReply={handleSwipeReply}
@@ -471,6 +472,7 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
> >
{messageContent} {messageContent}
</SwipeableMessageBubble> </SwipeableMessageBubble>
</View>
); );
}; };
@@ -507,10 +509,6 @@ export const MessageBubble = React.memo(MessageBubbleInner, (prev, next) => {
if (prev.currentUser !== next.currentUser) return false; if (prev.currentUser !== next.currentUser) return false;
if (prev.otherUser !== next.otherUser) return false; if (prev.otherUser !== next.otherUser) return false;
if (prev.messageMap !== next.messageMap) return false; if (prev.messageMap !== next.messageMap) return false;
if (prev.onLongPress !== next.onLongPress) return false;
if (prev.onImagePress !== next.onImagePress) return false;
if (prev.onReplyPress !== next.onReplyPress) return false;
if (prev.shouldShowTime !== next.shouldShowTime) return false;
return true; return true;
}); });

View File

@@ -359,8 +359,6 @@ const ImageSegment = React.memo(ImageSegmentInner, (prev, next) => {
if (prev.data.width !== next.data.width) return false; if (prev.data.width !== next.data.width) return false;
if (prev.data.height !== next.data.height) return false; if (prev.data.height !== next.data.height) return false;
if (prev.isMe !== next.isMe) return false; if (prev.isMe !== next.isMe) return false;
if (prev.onImagePress !== next.onImagePress) return false;
if (prev.onImageLongPress !== next.onImageLongPress) return false;
return true; return true;
}); });
@@ -1174,12 +1172,6 @@ export const MessageSegmentsRenderer = React.memo(MessageSegmentsRendererInner,
if (prev.currentUserId !== next.currentUserId) return false; if (prev.currentUserId !== next.currentUserId) return false;
if (prev.memberMap !== next.memberMap) return false; if (prev.memberMap !== next.memberMap) return false;
if (prev.replyMessage !== next.replyMessage) return false; if (prev.replyMessage !== next.replyMessage) return false;
if (prev.onAtPress !== next.onAtPress) return false;
if (prev.onReplyPress !== next.onReplyPress) return false;
if (prev.onImagePress !== next.onImagePress) return false;
if (prev.onImageLongPress !== next.onImageLongPress) return false;
if (prev.onLinkPress !== next.onLinkPress) return false;
if (prev.getSenderInfo !== next.getSenderInfo) return false;
return true; return true;
}); });

View File

@@ -25,7 +25,7 @@ import { MessageSyncService } from './services/MessageSyncService';
import { WSMessageHandler } from './services/WSMessageHandler'; import { WSMessageHandler } from './services/WSMessageHandler';
// 导入 store // 导入 store
import { useMessageStore, normalizeConversationId } from './store'; import { useMessageStore, normalizeConversationId, loadPersistedLastSystemMessageAt } from './store';
// 重新导出类型,保持兼容性 // 重新导出类型,保持兼容性
export type { export type {
@@ -146,6 +146,9 @@ class MessageManager {
this.wsHandler.setBootstrapping(true); this.wsHandler.setBootstrapping(true);
// 从本地缓存恢复最后系统通知时间(避免首次渲染显示当前时间)
await loadPersistedLastSystemMessageAt();
// 初始化SSE监听 // 初始化SSE监听
this.wsHandler.connect(); this.wsHandler.connect();

View File

@@ -17,6 +17,7 @@ export {
useMessageStore, useMessageStore,
normalizeConversationId, normalizeConversationId,
mergeMessagesById, mergeMessagesById,
loadPersistedLastSystemMessageAt,
} from './store'; } from './store';
export type { MessageState, MessageActions, MessageStore } from './store'; export type { MessageState, MessageActions, MessageStore } from './store';

View File

@@ -9,11 +9,14 @@
*/ */
import { create } from 'zustand'; import { create } from 'zustand';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { import type {
ConversationResponse, ConversationResponse,
MessageResponse, MessageResponse,
} from '../../types/dto'; } from '../../types/dto';
const LAST_SYSTEM_MESSAGE_AT_KEY = 'last_system_message_at';
// ==================== 状态接口 ==================== // ==================== 状态接口 ====================
/** /**
@@ -322,6 +325,11 @@ export const useMessageStore = create<MessageStore>((set, get) => ({
setLastSystemMessageAt: (time: string | null) => { setLastSystemMessageAt: (time: string | null) => {
set({ lastSystemMessageAt: time }); set({ lastSystemMessageAt: time });
if (time) {
AsyncStorage.setItem(LAST_SYSTEM_MESSAGE_AT_KEY, time).catch(() => {});
} else {
AsyncStorage.removeItem(LAST_SYSTEM_MESSAGE_AT_KEY).catch(() => {});
}
}, },
setSSEConnected: (connected: boolean) => { setSSEConnected: (connected: boolean) => {
@@ -380,6 +388,14 @@ export const useMessageStore = create<MessageStore>((set, get) => ({
typingUsersMap: new Map(), typingUsersMap: new Map(),
mutedStatusMap: new Map(), mutedStatusMap: new Map(),
}); });
AsyncStorage.removeItem(LAST_SYSTEM_MESSAGE_AT_KEY).catch(() => {});
}, },
})); }));
export async function loadPersistedLastSystemMessageAt(): Promise<void> {
const time = await AsyncStorage.getItem(LAST_SYSTEM_MESSAGE_AT_KEY);
if (time) {
useMessageStore.setState({ lastSystemMessageAt: time });
}
}