Files
frontend/src/screens/message/JoinGroupScreen.tsx
lafay a6a4198ac5
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
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
2026-04-25 15:09:00 +08:00

563 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import {
View,
StyleSheet,
TextInput,
TouchableOpacity,
Alert,
ActivityIndicator,
Clipboard,
FlatList,
RefreshControl,
Animated,
KeyboardAvoidingView,
Platform,
ScrollView,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme';
import Avatar from '../../components/common/Avatar';
import Text from '../../components/common/Text';
import { groupService } from '@/services/message';
import { groupManager } from '../../stores/group';
import { GroupResponse, JoinType } from '../../types/dto';
import { useCursorPagination } from '../../hooks/useCursorPagination';
import { EmptyState } from '../../components/common';
function createJoinGroupStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.paper,
},
keyboardView: {
flex: 1,
},
scrollContent: {
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',
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,
},
// 搜索区域
searchSection: {
marginBottom: 24,
},
inputLabel: {
fontSize: 14,
fontWeight: '500',
color: colors.text.secondary,
marginBottom: 10,
},
searchRow: {
flexDirection: 'row',
alignItems: 'center',
},
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: {
width: 56,
height: 56,
borderRadius: 14,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
marginLeft: spacing.sm,
},
searchBtnDisabled: {
opacity: 0.5,
},
// 搜索结果区域
searchResultSection: {
marginBottom: 24,
},
sectionTitle: {
fontSize: 14,
fontWeight: '500',
color: colors.text.secondary,
marginBottom: 12,
},
emptyText: {
textAlign: 'center',
fontSize: 14,
color: colors.text.hint,
fontWeight: '500',
paddingVertical: spacing.lg,
},
// 群卡片
groupCard: {
backgroundColor: colors.background.default,
borderRadius: 16,
padding: spacing.lg,
marginBottom: spacing.md,
},
groupHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.sm,
},
groupMeta: {
marginLeft: spacing.md,
flex: 1,
},
groupName: {
fontWeight: '700',
fontSize: 16,
color: colors.text.primary,
marginBottom: 2,
},
groupDesc: {
fontSize: 13,
color: colors.text.secondary,
lineHeight: 20,
marginTop: spacing.sm,
},
groupInfoRow: {
marginTop: spacing.sm,
marginBottom: spacing.xs,
flexDirection: 'row',
justifyContent: 'space-between',
},
groupInfoText: {
fontSize: 12,
color: colors.text.secondary,
},
groupNoRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: spacing.md,
},
groupNoText: {
fontSize: 12,
color: colors.text.secondary,
},
copyBtn: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: borderRadius.sm,
backgroundColor: colors.primary.light + '15',
},
copyBtnText: {
marginLeft: 4,
fontSize: 12,
fontWeight: '600',
color: colors.primary.main,
},
submitBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 14,
backgroundColor: colors.primary.main,
height: 48,
},
submitBtnDisabled: {
opacity: 0.5,
},
submitText: {
marginLeft: spacing.xs,
fontWeight: '700',
fontSize: 15,
color: colors.text.inverse,
},
// 推荐区域
recommendSection: {
flex: 1,
},
loadingFooter: {
paddingVertical: spacing.md,
alignItems: 'center',
},
loadMoreBtn: {
paddingVertical: spacing.md,
alignItems: 'center',
},
loadMoreText: {
fontSize: 14,
fontWeight: '500',
color: colors.primary.main,
},
noMoreText: {
textAlign: 'center',
paddingVertical: spacing.md,
fontWeight: '500',
fontSize: 13,
color: colors.text.hint,
},
});
}
const JoinGroupScreen: React.FC = () => {
const colors = useAppColors();
const styles = useMemo(() => createJoinGroupStyles(colors), [colors]);
const router = useRouter();
const [keyword, setKeyword] = useState('');
const [searching, setSearching] = useState(false);
const [joiningGroupId, setJoiningGroupId] = useState<string | null>(null);
const [searchedGroup, setSearchedGroup] = useState<GroupResponse | null>(null);
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 管理群组列表
const {
list: groups,
isLoading,
isRefreshing,
hasMore,
loadMore,
refresh,
error,
} = useCursorPagination(
async ({ cursor, pageSize }) => {
return await groupService.getGroupsCursor({
cursor,
page_size: pageSize,
});
},
{ pageSize: 20 }
);
const getJoinTypeText = (joinType: JoinType) => {
if (joinType === 0) return '允许加入';
if (joinType === 1) return '需要审批';
return '禁止加入';
};
const handleSearch = async () => {
const trimmed = keyword.trim();
if (!trimmed) {
Alert.alert('提示', '请输入群ID进行搜索');
return;
}
setSearching(true);
setSearched(true);
try {
const result = await groupManager.getGroup(trimmed, true);
setSearchedGroup(result);
} catch (error: any) {
setSearchedGroup(null);
const message = error?.response?.data?.message || error?.message || '';
if (String(message).includes('不存在') || error?.response?.status === 404) {
Alert.alert('未找到', '未搜索到该群聊请确认群ID是否正确');
} else {
Alert.alert('搜索失败', '请稍后重试');
}
} finally {
setSearching(false);
}
};
const handleJoin = async (group: GroupResponse) => {
if (!group?.id) return;
setJoiningGroupId(String(group.id));
try {
await groupService.joinGroup(group.id);
Alert.alert('成功', '操作已提交', [
{
text: '确定',
onPress: () => router.back(),
},
]);
} catch (error: any) {
const message =
error?.response?.data?.message ||
error?.message ||
'操作失败,请稍后重试';
Alert.alert('操作失败', String(message));
} finally {
setJoiningGroupId(null);
}
};
const handleCopyGroupId = (groupId: string) => {
Clipboard.setString(groupId);
Alert.alert('已复制', '群号已复制到剪贴板');
};
const formatGroupNo = (id: string) => {
const raw = id;
if (raw.length <= 12) return raw;
return `${raw.slice(0, 6)}...${raw.slice(-4)}`;
};
const renderGroupItem = ({ item: group }: { item: GroupResponse }) => {
const isJoining = joiningGroupId === String(group.id);
return (
<View style={styles.groupCard}>
<View style={styles.groupHeader}>
<Avatar source={group.avatar} size={52} name={group.name} />
<View style={styles.groupMeta}>
<Text style={styles.groupName} numberOfLines={1}>
{group.name}
</Text>
</View>
</View>
{!!group.description && (
<Text style={styles.groupDesc}>
{group.description}
</Text>
)}
<View style={styles.groupInfoRow}>
<Text style={styles.groupInfoText}>
{group.member_count}/{group.max_members}
</Text>
<Text style={styles.groupInfoText}>
{getJoinTypeText(group.join_type)}
</Text>
</View>
<View style={styles.groupNoRow}>
<Text style={styles.groupNoText}>
{formatGroupNo(group.id)}
</Text>
<TouchableOpacity style={styles.copyBtn} onPress={() => handleCopyGroupId(group.id)}>
<MaterialCommunityIcons name="content-copy" size={14} color={colors.primary.main} />
<Text style={styles.copyBtnText}>
</Text>
</TouchableOpacity>
</View>
<TouchableOpacity
style={[styles.submitBtn, isJoining && styles.submitBtnDisabled]}
onPress={() => handleJoin(group)}
disabled={isJoining}
>
{isJoining ? (
<ActivityIndicator color={colors.text.inverse} />
) : (
<>
<MaterialCommunityIcons name="send-outline" size={18} color={colors.text.inverse} />
<Text style={styles.submitText}>
</Text>
</>
)}
</TouchableOpacity>
</View>
);
};
const renderEmptyList = () => {
if (isLoading) return null;
return (
<EmptyState
title="暂无群组"
description="还没有可加入的群组"
icon="account-group-outline"
/>
);
};
const renderSearchResult = () => {
if (!searched) return null;
if (searchedGroup) {
return (
<View style={styles.searchResultSection}>
<Text style={styles.sectionTitle}></Text>
{renderGroupItem({ item: searchedGroup })}
</View>
);
}
if (!searching) {
return (
<View style={styles.searchResultSection}>
<Text style={styles.sectionTitle}></Text>
<Text style={styles.emptyText}>
ID后重试
</Text>
</View>
);
}
return null;
};
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
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 style={styles.searchSection}>
<Text style={styles.inputLabel}>ID</Text>
<View style={styles.searchRow}>
<View style={styles.inputWrapper}>
<TextInput
value={keyword}
onChangeText={setKeyword}
placeholder="例如7391234567890"
placeholderTextColor={colors.text.hint}
style={styles.input}
editable={!searching && !joiningGroupId}
autoCapitalize="none"
cursorColor={colors.primary.main}
selectionColor={`${colors.primary.main}40`}
/>
</View>
<TouchableOpacity
style={[
styles.searchBtn,
(!keyword.trim() || searching || !!joiningGroupId) && styles.searchBtnDisabled,
]}
onPress={handleSearch}
disabled={!keyword.trim() || searching || !!joiningGroupId}
>
{searching ? (
<ActivityIndicator color={colors.text.inverse} />
) : (
<MaterialCommunityIcons name="magnify" size={22} color={colors.text.inverse} />
)}
</TouchableOpacity>
</View>
</View>
{/* 搜索结果 */}
{renderSearchResult()}
{/* 推荐群组 */}
<View style={styles.recommendSection}>
<Text style={styles.sectionTitle}></Text>
{groups.length === 0 && !isLoading ? (
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>
);
};
export default JoinGroupScreen;