feat(performance): migrate FlatList to FlashList and add animations
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:
13
package-lock.json
generated
13
package-lock.json
generated
@@ -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": "*",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,154 +738,56 @@ 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 isPostAuthor = currentUser?.id === authorId;
|
||||||
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;
|
|
||||||
return (
|
|
||||||
<View key={post.id} style={[styles.waterfallItem, { marginBottom: responsiveGap }]}>
|
|
||||||
<PostCard
|
|
||||||
post={post}
|
|
||||||
variant="grid"
|
|
||||||
onAction={(action) => stableOnPostAction(post.id, action)}
|
|
||||||
isPostAuthor={isPostAuthor}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 渲染响应式网格布局(平板/桌面端使用多列)
|
|
||||||
const renderResponsiveGrid = () => {
|
|
||||||
if (isMobile && !isTablet) {
|
|
||||||
// 移动端使用瀑布流布局(2列)
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
ref={scrollViewRef}
|
|
||||||
style={styles.waterfallScroll}
|
|
||||||
contentContainerStyle={[
|
|
||||||
styles.waterfallContainer,
|
|
||||||
{
|
|
||||||
paddingHorizontal: responsivePadding,
|
|
||||||
paddingBottom: 80 + responsivePadding,
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
scrollEventThrottle={16}
|
|
||||||
onScroll={handleGridScroll}
|
|
||||||
refreshControl={
|
|
||||||
<RefreshControl
|
|
||||||
refreshing={isRefreshing}
|
|
||||||
onRefresh={refresh}
|
|
||||||
colors={[colors.primary.main]}
|
|
||||||
tintColor={colors.primary.main}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{renderLatestCapsules()}
|
|
||||||
<View style={styles.waterfallColumnsRow}>
|
|
||||||
{distributePostsToColumns.map((column, index) => renderWaterfallColumn(column, index))}
|
|
||||||
</View>
|
|
||||||
{/* 独立底部加载区:避免参与横向列布局导致列宽抖动 */}
|
|
||||||
{isLoadingMore && displayPosts.length > 0 && (
|
|
||||||
<View style={styles.loadingMoreFooter}>
|
|
||||||
<Loading size="sm" />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 平板/桌面端使用 ResponsiveGrid 组件
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveGrid
|
<View style={styles.gridItem}>
|
||||||
columns={{ xs: 1, sm: 2, md: 2, lg: 3, xl: 3, '2xl': 4, '3xl': 4, '4xl': 5 }}
|
<PostCard
|
||||||
gap={{ xs: 8, sm: 12, md: 16, lg: 20, xl: 24 }}
|
post={item}
|
||||||
containerStyle={{ paddingHorizontal: responsivePadding, paddingBottom: 80 }}
|
variant="grid"
|
||||||
>
|
onAction={(action) => stableOnPostAction(item.id, action)}
|
||||||
{displayPosts.map(post => {
|
isPostAuthor={isPostAuthor}
|
||||||
const authorId = post.author?.id || '';
|
/>
|
||||||
const isPostAuthor = currentUser?.id === authorId;
|
</View>
|
||||||
return (
|
);
|
||||||
<PostCard
|
}, [currentUser?.id, stableOnPostAction]);
|
||||||
key={post.id}
|
|
||||||
post={post}
|
// 渲染响应式网格布局(所有端统一使用 FlashList masonry 模式)
|
||||||
variant={viewMode === 'grid' ? 'grid' : 'list'}
|
const renderResponsiveGrid = () => {
|
||||||
onAction={(action) => stableOnPostAction(post.id, action)}
|
return (
|
||||||
isPostAuthor={isPostAuthor}
|
<FlashList
|
||||||
/>
|
ref={scrollViewRef as any}
|
||||||
);
|
data={displayPosts}
|
||||||
})}
|
renderItem={renderGridItem}
|
||||||
</ResponsiveGrid>
|
keyExtractor={keyExtractor}
|
||||||
|
numColumns={gridColumns}
|
||||||
|
masonry
|
||||||
|
optimizeItemArrangement
|
||||||
|
ListHeaderComponent={renderLatestCapsules()}
|
||||||
|
ListHeaderComponentStyle={styles.listHeader}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: responsivePadding,
|
||||||
|
paddingBottom: 80 + responsivePadding,
|
||||||
|
}}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={isRefreshing}
|
||||||
|
onRefresh={refresh}
|
||||||
|
colors={[colors.primary.main]}
|
||||||
|
tintColor={colors.primary.main}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onEndReached={loadMore}
|
||||||
|
onEndReachedThreshold={0.3}
|
||||||
|
onScroll={handleGridScroll}
|
||||||
|
scrollEventThrottle={16}
|
||||||
|
ListEmptyComponent={renderEmpty}
|
||||||
|
ListFooterComponent={isLoadingMore ? <Loading size="sm" /> : null}
|
||||||
|
drawDistance={250}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -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,
|
||||||
{
|
paddingBottom: 80 + responsivePadding,
|
||||||
paddingHorizontal: listHorizontalPadding,
|
}}
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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,133 +208,150 @@ const CreateGroupScreen: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||||
<ScrollView
|
<KeyboardAvoidingView
|
||||||
style={styles.scrollView}
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
contentContainerStyle={styles.scrollContent}
|
style={styles.keyboardView}
|
||||||
keyboardShouldPersistTaps="handled"
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
>
|
>
|
||||||
{/* 群头像和名称区域 */}
|
<ScrollView
|
||||||
<View style={styles.headerSection}>
|
style={styles.scrollView}
|
||||||
<View style={styles.avatarContainer}>
|
contentContainerStyle={styles.scrollContent}
|
||||||
<TouchableOpacity style={styles.avatarWrapper} onPress={handleSelectAvatar} disabled={uploadingAvatar}>
|
keyboardShouldPersistTaps="handled"
|
||||||
{uploadingAvatar ? (
|
showsVerticalScrollIndicator={false}
|
||||||
<View style={[styles.avatarPlaceholder, { width: 80, height: 80 }]}>
|
|
||||||
<Loading />
|
|
||||||
</View>
|
|
||||||
) : groupAvatar ? (
|
|
||||||
<Image source={{ uri: groupAvatar }} style={styles.avatarImage} />
|
|
||||||
) : (
|
|
||||||
<Avatar
|
|
||||||
source={undefined}
|
|
||||||
size={80}
|
|
||||||
name={groupName || '群'}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<View style={styles.avatarBadge}>
|
|
||||||
<MaterialCommunityIcons name="camera" size={14} color={colors.background.paper} />
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.nameInputContainer}>
|
|
||||||
<Text variant="label" style={styles.inputLabel}>
|
|
||||||
群名称 <Text color={colors.error.main}>*</Text>
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
|
||||||
style={styles.nameInput}
|
|
||||||
value={groupName}
|
|
||||||
onChangeText={setGroupName}
|
|
||||||
placeholder="请输入群名称"
|
|
||||||
placeholderTextColor={colors.text.hint}
|
|
||||||
maxLength={50}
|
|
||||||
cursorColor={colors.primary.main}
|
|
||||||
selectionColor={`${colors.primary.main}40`}
|
|
||||||
/>
|
|
||||||
<Text variant="caption" color={colors.text.hint} style={styles.charCount}>
|
|
||||||
{groupName.length}/50
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 群描述输入 */}
|
|
||||||
<View style={styles.section}>
|
|
||||||
<Text variant="label" style={styles.sectionTitle}>
|
|
||||||
群描述
|
|
||||||
</Text>
|
|
||||||
<View style={styles.textAreaContainer}>
|
|
||||||
<TextInput
|
|
||||||
style={styles.textArea}
|
|
||||||
value={groupDescription}
|
|
||||||
onChangeText={setGroupDescription}
|
|
||||||
placeholder="介绍一下这个群聊吧..."
|
|
||||||
placeholderTextColor={colors.text.hint}
|
|
||||||
maxLength={500}
|
|
||||||
multiline
|
|
||||||
numberOfLines={4}
|
|
||||||
textAlignVertical="top"
|
|
||||||
cursorColor={colors.primary.main}
|
|
||||||
selectionColor={`${colors.primary.main}40`}
|
|
||||||
/>
|
|
||||||
<Text variant="caption" color={colors.text.hint} style={styles.textAreaCharCount}>
|
|
||||||
{groupDescription.length}/500
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 已选成员 */}
|
|
||||||
{selectedMembers.length > 0 && (
|
|
||||||
<View style={styles.section}>
|
|
||||||
<View style={styles.sectionHeader}>
|
|
||||||
<Text variant="label" style={styles.sectionTitle}>
|
|
||||||
已选成员
|
|
||||||
</Text>
|
|
||||||
<Text variant="caption" color={colors.primary.main}>
|
|
||||||
{selectedMembers.length}人
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<FlatList
|
|
||||||
data={selectedMembers}
|
|
||||||
renderItem={renderSelectedMember}
|
|
||||||
keyExtractor={item => item.id}
|
|
||||||
horizontal
|
|
||||||
showsHorizontalScrollIndicator={false}
|
|
||||||
contentContainerStyle={styles.selectedMembersList}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 邀请成员按钮 */}
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.inviteButton}
|
|
||||||
onPress={() => setInviteModalVisible(true)}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
>
|
>
|
||||||
<View style={styles.inviteIconContainer}>
|
<Animated.View
|
||||||
<MaterialCommunityIcons name="account-plus" size={24} color={colors.primary.main} />
|
style={[
|
||||||
</View>
|
styles.content,
|
||||||
<View style={styles.inviteTextContainer}>
|
{
|
||||||
<Text variant="body" style={styles.inviteTitle}>邀请成员</Text>
|
opacity: fadeAnim,
|
||||||
<Text variant="caption" color={colors.text.secondary}>
|
transform: [{ translateY: slideAnim }],
|
||||||
仅可从互关好友中选择成员加入群聊
|
},
|
||||||
</Text>
|
]}
|
||||||
</View>
|
>
|
||||||
<MaterialCommunityIcons name="chevron-right" size={24} color={colors.text.hint} />
|
{/* 标题区域 */}
|
||||||
</TouchableOpacity>
|
<View style={styles.titleSection}>
|
||||||
</ScrollView>
|
<Text style={styles.title}>创建群聊</Text>
|
||||||
|
<View style={styles.underline} />
|
||||||
|
<Text style={styles.subtitle}>填写群信息并邀请成员</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* 创建按钮 */}
|
{/* 群头像区域 */}
|
||||||
<View style={styles.footer}>
|
<View style={styles.avatarSection}>
|
||||||
<Button
|
<TouchableOpacity style={styles.avatarWrapper} onPress={handleSelectAvatar} disabled={uploadingAvatar}>
|
||||||
title="创建群聊"
|
{uploadingAvatar ? (
|
||||||
onPress={handleCreateGroup}
|
<View style={styles.avatarPlaceholder}>
|
||||||
loading={submitting}
|
<Loading />
|
||||||
disabled={!groupName.trim() || submitting}
|
</View>
|
||||||
fullWidth
|
) : groupAvatar ? (
|
||||||
size="lg"
|
<Image source={{ uri: groupAvatar }} style={styles.avatarImage} />
|
||||||
/>
|
) : (
|
||||||
</View>
|
<View style={styles.avatarPlaceholder}>
|
||||||
|
<MaterialCommunityIcons name="camera" size={28} color={colors.text.hint} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View style={styles.avatarBadge}>
|
||||||
|
<MaterialCommunityIcons name="pencil" size={12} color={colors.background.paper} />
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.avatarHint}>点击设置群头像</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 群名称输入 */}
|
||||||
|
<View style={styles.inputContainer}>
|
||||||
|
<Text style={styles.inputLabel}>群名称 <Text style={styles.requiredMark}>*</Text></Text>
|
||||||
|
<View style={styles.inputWrapper}>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={groupName}
|
||||||
|
onChangeText={setGroupName}
|
||||||
|
placeholder="请输入群名称"
|
||||||
|
placeholderTextColor={colors.text.hint}
|
||||||
|
maxLength={50}
|
||||||
|
cursorColor={colors.primary.main}
|
||||||
|
selectionColor={`${colors.primary.main}40`}
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
{groupName.length > 0 && (
|
||||||
|
<MaterialCommunityIcons name="check-circle" size={20} color={colors.primary.main} style={styles.checkIcon} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text style={styles.charCount}>{groupName.length}/50</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 群描述输入 */}
|
||||||
|
<View style={styles.inputContainer}>
|
||||||
|
<Text style={styles.inputLabel}>群描述</Text>
|
||||||
|
<View style={[styles.inputWrapper, styles.textAreaWrapper]}>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, styles.textArea]}
|
||||||
|
value={groupDescription}
|
||||||
|
onChangeText={setGroupDescription}
|
||||||
|
placeholder="介绍一下这个群聊吧..."
|
||||||
|
placeholderTextColor={colors.text.hint}
|
||||||
|
maxLength={500}
|
||||||
|
multiline
|
||||||
|
numberOfLines={4}
|
||||||
|
textAlignVertical="top"
|
||||||
|
cursorColor={colors.primary.main}
|
||||||
|
selectionColor={`${colors.primary.main}40`}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.charCount}>{groupDescription.length}/500</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 已选成员 */}
|
||||||
|
{selectedMembers.length > 0 && (
|
||||||
|
<View style={styles.membersSection}>
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>已选成员</Text>
|
||||||
|
<Text style={styles.memberCount}>{selectedMembers.length}人</Text>
|
||||||
|
</View>
|
||||||
|
<FlatList
|
||||||
|
data={selectedMembers}
|
||||||
|
renderItem={renderSelectedMember}
|
||||||
|
keyExtractor={item => item.id}
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.selectedMembersList}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 邀请成员按钮 */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.inviteButton}
|
||||||
|
onPress={() => setInviteModalVisible(true)}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<View style={styles.inviteIconContainer}>
|
||||||
|
<MaterialCommunityIcons name="account-plus" size={22} color={colors.primary.main} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.inviteTextContainer}>
|
||||||
|
<Text style={styles.inviteTitle}>邀请成员</Text>
|
||||||
|
<Text style={styles.inviteSubtitle}>仅可从互关好友中选择成员加入群聊</Text>
|
||||||
|
</View>
|
||||||
|
<MaterialCommunityIcons name="chevron-right" size={22} color={colors.text.hint} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* 创建按钮 */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.createButton,
|
||||||
|
(!groupName.trim() || submitting) && styles.createButtonDisabled,
|
||||||
|
]}
|
||||||
|
onPress={handleCreateGroup}
|
||||||
|
disabled={!groupName.trim() || submitting}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}>
|
||||||
暂无搜索结果,请检查群ID后重试
|
<Text style={styles.sectionTitle}>搜索结果</Text>
|
||||||
</Text>
|
<Text style={styles.emptyText}>
|
||||||
|
暂无搜索结果,请检查群ID后重试
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,91 +449,113 @@ 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}
|
||||||
</View>
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
<View style={styles.formCard}>
|
refreshing={isRefreshing}
|
||||||
<Text variant="caption" color={colors.text.secondary} style={styles.label}>
|
onRefresh={refresh}
|
||||||
搜索群聊(群ID)
|
colors={[colors.primary.main]}
|
||||||
</Text>
|
tintColor={colors.primary.main}
|
||||||
<View style={styles.searchRow}>
|
/>
|
||||||
<TextInput
|
}
|
||||||
value={keyword}
|
>
|
||||||
onChangeText={setKeyword}
|
<Animated.View
|
||||||
placeholder="例如:7391234567890"
|
style={[
|
||||||
placeholderTextColor={colors.text.hint}
|
styles.content,
|
||||||
style={styles.input}
|
{
|
||||||
editable={!searching && !joiningGroupId}
|
opacity: fadeAnim,
|
||||||
autoCapitalize="none"
|
transform: [{ translateY: slideAnim }],
|
||||||
cursorColor={colors.primary.main}
|
},
|
||||||
selectionColor={`${colors.primary.main}40`}
|
]}
|
||||||
/>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.searchBtn, (!keyword.trim() || searching || !!joiningGroupId) && styles.submitBtnDisabled]}
|
|
||||||
onPress={handleSearch}
|
|
||||||
disabled={!keyword.trim() || searching || !!joiningGroupId}
|
|
||||||
>
|
>
|
||||||
{searching ? (
|
{/* 标题区域 */}
|
||||||
<ActivityIndicator color="#fff" />
|
<View style={styles.titleSection}>
|
||||||
) : (
|
<Text style={styles.title}>加入群聊</Text>
|
||||||
<MaterialCommunityIcons name="magnify" size={20} color="#fff" />
|
<View style={styles.underline} />
|
||||||
)}
|
<Text style={styles.subtitle}>搜索群ID或浏览推荐群组</Text>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 搜索结果 */}
|
{/* 搜索区域 */}
|
||||||
{renderSearchResult()}
|
<View style={styles.searchSection}>
|
||||||
|
<Text style={styles.inputLabel}>搜索群聊(群ID)</Text>
|
||||||
{/* 群组列表 */}
|
<View style={styles.searchRow}>
|
||||||
<View style={styles.listSection}>
|
<View style={styles.inputWrapper}>
|
||||||
<Text variant="caption" color={colors.text.secondary} style={styles.sectionTitle}>
|
<TextInput
|
||||||
推荐群组
|
value={keyword}
|
||||||
</Text>
|
onChangeText={setKeyword}
|
||||||
<FlatList
|
placeholder="例如:7391234567890"
|
||||||
data={groups}
|
placeholderTextColor={colors.text.hint}
|
||||||
renderItem={renderGroupItem}
|
style={styles.input}
|
||||||
keyExtractor={(item) => String(item.id)}
|
editable={!searching && !joiningGroupId}
|
||||||
refreshControl={
|
autoCapitalize="none"
|
||||||
<RefreshControl
|
cursorColor={colors.primary.main}
|
||||||
refreshing={isRefreshing}
|
selectionColor={`${colors.primary.main}40`}
|
||||||
onRefresh={refresh}
|
/>
|
||||||
colors={[colors.primary.main]}
|
|
||||||
tintColor={colors.primary.main}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
onEndReached={loadMore}
|
|
||||||
onEndReachedThreshold={0.3}
|
|
||||||
ListEmptyComponent={renderEmptyList}
|
|
||||||
ListFooterComponent={
|
|
||||||
isLoading ? (
|
|
||||||
<View style={styles.loadingFooter}>
|
|
||||||
<ActivityIndicator color={colors.primary.main} />
|
|
||||||
</View>
|
</View>
|
||||||
) : hasMore ? (
|
<TouchableOpacity
|
||||||
<TouchableOpacity style={styles.loadMoreBtn} onPress={loadMore}>
|
style={[
|
||||||
<Text variant="caption" color={colors.primary.main}>
|
styles.searchBtn,
|
||||||
加载更多
|
(!keyword.trim() || searching || !!joiningGroupId) && styles.searchBtnDisabled,
|
||||||
</Text>
|
]}
|
||||||
|
onPress={handleSearch}
|
||||||
|
disabled={!keyword.trim() || searching || !!joiningGroupId}
|
||||||
|
>
|
||||||
|
{searching ? (
|
||||||
|
<ActivityIndicator color={colors.text.inverse} />
|
||||||
|
) : (
|
||||||
|
<MaterialCommunityIcons name="magnify" size={22} color={colors.text.inverse} />
|
||||||
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : groups.length > 0 ? (
|
</View>
|
||||||
<Text variant="caption" color={colors.text.hint} style={styles.noMoreText}>
|
</View>
|
||||||
没有更多群组了
|
|
||||||
</Text>
|
{/* 搜索结果 */}
|
||||||
) : null
|
{renderSearchResult()}
|
||||||
}
|
|
||||||
showsVerticalScrollIndicator={false}
|
{/* 推荐群组 */}
|
||||||
/>
|
<View style={styles.recommendSection}>
|
||||||
</View>
|
<Text style={styles.sectionTitle}>推荐群组</Text>
|
||||||
</View>
|
{groups.length === 0 && !isLoading ? (
|
||||||
</View>
|
renderEmptyList()
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{groups.map((group) => (
|
||||||
|
<View key={String(group.id)}>
|
||||||
|
{renderGroupItem({ item: group })}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
{isLoading && (
|
||||||
|
<View style={styles.loadingFooter}>
|
||||||
|
<ActivityIndicator color={colors.primary.main} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{hasMore && !isLoading && (
|
||||||
|
<TouchableOpacity style={styles.loadMoreBtn} onPress={loadMore}>
|
||||||
|
<Text style={styles.loadMoreText}>
|
||||||
|
加载更多
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
{!hasMore && groups.length > 0 && (
|
||||||
|
<Text style={styles.noMoreText}>
|
||||||
|
没有更多群组了
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,13 +464,15 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
|
|||||||
|
|
||||||
// 使用 SwipeableMessageBubble 包裹消息内容
|
// 使用 SwipeableMessageBubble 包裹消息内容
|
||||||
return (
|
return (
|
||||||
<SwipeableMessageBubble
|
<View style={{ width: '100%' }} key={messageKey}>
|
||||||
isMe={isMe}
|
<SwipeableMessageBubble
|
||||||
onReply={handleSwipeReply}
|
isMe={isMe}
|
||||||
enabled={!!onReply}
|
onReply={handleSwipeReply}
|
||||||
>
|
enabled={!!onReply}
|
||||||
{messageContent}
|
>
|
||||||
</SwipeableMessageBubble>
|
{messageContent}
|
||||||
|
</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;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user