Initial frontend repository commit.
Include app source and update .gitignore to exclude local release artifacts and signing files. Made-with: Cursor
This commit is contained in:
301
src/screens/message/components/MutualFollowSelectorModal.tsx
Normal file
301
src/screens/message/components/MutualFollowSelectorModal.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Modal,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
FlatList,
|
||||
ListRenderItem,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../../theme';
|
||||
import { authService } from '../../../services/authService';
|
||||
import { useAuthStore } from '../../../stores';
|
||||
import { Avatar, EmptyState, Loading, Text } from '../../../components/common';
|
||||
import { User } from '../../../types';
|
||||
|
||||
type MutualFollowSelectorModalProps = {
|
||||
visible: boolean;
|
||||
title?: string;
|
||||
confirmText?: string;
|
||||
confirmingText?: string;
|
||||
confirmLoading?: boolean;
|
||||
excludeUserIds?: string[];
|
||||
initialSelectedIds?: string[];
|
||||
onClose: () => void;
|
||||
onConfirm: (selectedUsers: User[]) => void | Promise<void>;
|
||||
};
|
||||
|
||||
const MutualFollowSelectorModal: React.FC<MutualFollowSelectorModalProps> = ({
|
||||
visible,
|
||||
title = '邀请成员',
|
||||
confirmText = '确认',
|
||||
confirmingText = '处理中...',
|
||||
confirmLoading = false,
|
||||
excludeUserIds = [],
|
||||
initialSelectedIds = [],
|
||||
onClose,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const { currentUser } = useAuthStore();
|
||||
|
||||
const [friendList, setFriendList] = useState<User[]>([]);
|
||||
const [friendLoading, setFriendLoading] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const currentUserId = currentUser?.id;
|
||||
const excludeIdsKey = useMemo(() => excludeUserIds.join(','), [excludeUserIds]);
|
||||
const initialSelectedIdsKey = useMemo(() => initialSelectedIds.join(','), [initialSelectedIds]);
|
||||
const stableExcludeUserIds = useMemo(() => excludeUserIds, [excludeIdsKey]);
|
||||
const stableInitialSelectedIds = useMemo(() => initialSelectedIds, [initialSelectedIdsKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
if (!currentUserId) return;
|
||||
|
||||
const nextSelectedIds = new Set(stableInitialSelectedIds);
|
||||
setSelectedIds(prev => {
|
||||
if (prev.size !== nextSelectedIds.size) return nextSelectedIds;
|
||||
for (const id of prev) {
|
||||
if (!nextSelectedIds.has(id)) return nextSelectedIds;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
|
||||
let cancelled = false;
|
||||
const loadMutualFollows = async () => {
|
||||
setFriendLoading(true);
|
||||
try {
|
||||
const excludedIdSet = new Set(stableExcludeUserIds);
|
||||
const [following, followers] = await Promise.all([
|
||||
authService.getFollowingList(currentUserId, 1, 100),
|
||||
authService.getFollowersList(currentUserId, 1, 100),
|
||||
]);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
const followerIdSet = new Set(followers.map(user => user.id));
|
||||
const mutualFriends = following.filter(user => followerIdSet.has(user.id));
|
||||
const availableFriends = mutualFriends.filter(user => !excludedIdSet.has(user.id));
|
||||
setFriendList(availableFriends);
|
||||
} catch (error) {
|
||||
console.error('获取互关好友失败:', error);
|
||||
Alert.alert('错误', '获取互关好友失败');
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setFriendLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadMutualFollows();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [visible, currentUserId, stableExcludeUserIds, stableInitialSelectedIds]);
|
||||
|
||||
const toggleSelection = (user: User) => {
|
||||
const next = new Set(selectedIds);
|
||||
if (next.has(user.id)) {
|
||||
next.delete(user.id);
|
||||
} else {
|
||||
next.add(user.id);
|
||||
}
|
||||
setSelectedIds(next);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
const selectedUsers = friendList.filter(user => selectedIds.has(user.id));
|
||||
onConfirm(selectedUsers);
|
||||
};
|
||||
|
||||
const renderFriendItem: ListRenderItem<User> = ({ item }) => {
|
||||
const isSelected = selectedIds.has(item.id);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.friendItem, isSelected && styles.friendItemSelected]}
|
||||
onPress={() => toggleSelection(item)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Avatar source={item.avatar} size={48} name={item.nickname} />
|
||||
<View style={styles.friendInfo}>
|
||||
<Text variant="body" style={styles.nickname} numberOfLines={1}>
|
||||
{item.nickname}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.text.secondary} numberOfLines={1}>
|
||||
@{item.username}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.checkbox, isSelected && styles.checkboxSelected]}>
|
||||
{isSelected && (
|
||||
<MaterialCommunityIcons name="check" size={16} color={colors.background.paper} />
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
transparent
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.modalOverlay}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<View style={styles.modalContent}>
|
||||
<View style={styles.modalHeader}>
|
||||
<TouchableOpacity onPress={onClose} style={styles.modalHeaderButton}>
|
||||
<Text variant="body" color={colors.text.secondary}>取消</Text>
|
||||
</TouchableOpacity>
|
||||
<Text variant="h3" style={styles.modalTitle}>{title}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={handleConfirm}
|
||||
disabled={selectedIds.size === 0 || confirmLoading}
|
||||
style={styles.modalHeaderButton}
|
||||
>
|
||||
<Text
|
||||
variant="body"
|
||||
color={selectedIds.size === 0 || confirmLoading ? colors.text.disabled : colors.primary.main}
|
||||
style={styles.confirmButton}
|
||||
>
|
||||
{confirmLoading ? confirmingText : confirmText}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.tipContainer}>
|
||||
<Text variant="body" color={colors.primary.main} style={styles.tipText}>
|
||||
仅显示互相关注用户
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{selectedIds.size > 0 && (
|
||||
<View style={styles.selectedBadge}>
|
||||
<Text variant="caption" color={colors.primary.main}>
|
||||
已选择 {selectedIds.size} 人
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{friendLoading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<FlatList
|
||||
data={friendList}
|
||||
renderItem={renderFriendItem}
|
||||
keyExtractor={item => item.id}
|
||||
contentContainerStyle={styles.friendListContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListEmptyComponent={(
|
||||
<EmptyState
|
||||
title="暂无可选择的互关好友"
|
||||
description="互相关注后才可邀请加入群聊"
|
||||
icon="account-plus-outline"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
modalContent: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderTopLeftRadius: borderRadius['2xl'],
|
||||
borderTopRightRadius: borderRadius['2xl'],
|
||||
height: '80%',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.lg,
|
||||
paddingBottom: spacing.xl,
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
modalHeaderButton: {
|
||||
paddingVertical: spacing.sm,
|
||||
minWidth: 60,
|
||||
},
|
||||
modalTitle: {
|
||||
fontWeight: '700',
|
||||
fontSize: fontSizes.xl,
|
||||
},
|
||||
confirmButton: {
|
||||
fontWeight: '600',
|
||||
textAlign: 'right',
|
||||
},
|
||||
tipContainer: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.xs,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
tipText: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
selectedBadge: {
|
||||
backgroundColor: colors.primary.light + '20',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: borderRadius.sm,
|
||||
alignSelf: 'flex-start',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
friendListContent: {
|
||||
paddingBottom: spacing.xl,
|
||||
},
|
||||
friendItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.sm,
|
||||
borderRadius: borderRadius.lg,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
friendItemSelected: {
|
||||
backgroundColor: colors.primary.light + '15',
|
||||
},
|
||||
friendInfo: {
|
||||
flex: 1,
|
||||
marginLeft: spacing.md,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
nickname: {
|
||||
fontWeight: '500',
|
||||
marginBottom: 2,
|
||||
},
|
||||
checkbox: {
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 13,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.divider,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
checkboxSelected: {
|
||||
backgroundColor: colors.primary.main,
|
||||
borderColor: colors.primary.main,
|
||||
},
|
||||
});
|
||||
|
||||
export default MutualFollowSelectorModal;
|
||||
Reference in New Issue
Block a user