Files
frontend/src/screens/message/GroupMembersScreen.tsx
lafay 2dc6936aac
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 2m42s
Frontend CI / build-android-apk (push) Has been cancelled
Frontend CI / ota-android (push) Has been cancelled
feat(post): add post reference functionality with inline cards and suggestion search
Add support for referencing/quoting other posts within post content. Includes:
- New `post_ref` segment type with `PostRefSegmentData` interface for post references
- New `PostRefCard` component to display referenced posts inline
- Post suggestion search triggered by `#` in `PostMentionInput`
- Updated `PostContentRenderer` to render post reference segments
- Add `suggestPosts` and `recordRefClick` API methods in postService

Additional improvements:
- Add image gallery preview for user profile covers and avatars
- Make text selectable across CommentItem, PostContentRenderer, and PostDetailScreen
- Migrate FlatList to FlashList in PostDetailScreen and UserProfileScreen
- Add nestedScrollEnabled to CreatePostScreen
- Add clickable member avatars in GroupMembersScreen
2026-04-26 01:11:30 +08:00

850 lines
25 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.
/**
* GroupMembersScreen 群成员管理界面
* 显示群成员列表,支持管理员管理成员
* 支持响应式网格布局
* 使用游标分页
*/
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import {
View,
StyleSheet,
FlatList,
TouchableOpacity,
Alert,
RefreshControl,
ListRenderItem,
Modal,
TextInput,
Dimensions,
Platform,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { hrefUserProfile } from '../../navigation/hrefs';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { blurActiveElement } from '../../infrastructure/platform';
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
import { useAuthStore } from '../../stores';
import { groupService } from '@/services/message';
import {
createCursorGroupMemberListSource,
GroupMemberListSourceKind,
ICursorGroupMemberListPagedSource,
} from '../../stores/group';
import { enrichGroupMembersWithUserProfiles } from '../../stores/group';
import { Avatar, Text, Button, Loading, EmptyState, Divider, ResponsiveContainer, AppBackButton } from '../../components/common';
import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive';
import { useCursorPagination } from '../../hooks/useCursorPagination';
import {
GroupMemberResponse,
GroupRole,
} from '../../types/dto';
import { firstRouteParam } from '../../navigation/paramUtils';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
const GROUP_MEMBER_REMOTE_LIST_KIND: GroupMemberListSourceKind = 'cursor';
// 网格布局配置
const GRID_CONFIG = {
mobile: { columns: 1, itemWidth: '100%' },
tablet: { columns: 2, itemWidth: '48%' },
desktop: { columns: 3, itemWidth: '31%' },
};
// 成员分组
interface MemberGroup {
title: string;
data: GroupMemberResponse[];
}
const GroupMembersScreen: React.FC = () => {
const colors = useAppColors();
const styles = useMemo(() => createGroupMembersStyles(colors), [colors]);
const router = useRouter();
const { groupId: groupIdParam } = useLocalSearchParams<{ groupId?: string | string[] }>();
const groupId = firstRouteParam(groupIdParam) ?? '';
const { currentUser } = useAuthStore();
// 响应式布局
const { isDesktop, isTablet, width } = useResponsive();
const isWideScreen = useBreakpointGTE('lg');
// 计算网格列数
const gridConfig = useMemo(() => {
if (width >= 1024) return GRID_CONFIG.desktop;
if (width >= 768) return GRID_CONFIG.tablet;
return GRID_CONFIG.mobile;
}, [width]);
const memberListSource = useMemo<ICursorGroupMemberListPagedSource>(() => {
return createCursorGroupMemberListSource(GROUP_MEMBER_REMOTE_LIST_KIND, groupId, 50);
}, [groupId]);
// 使用统一数据源 + 游标 Hook 管理成员列表
const {
list: members,
isLoading,
isRefreshing,
hasMore,
loadMore,
refresh,
error,
} = useCursorPagination(
async ({ cursor }) => {
// cursor 为空表示第一页/刷新,此时重置数据源状态
if (!cursor) {
memberListSource.restart();
}
return await memberListSource.loadNext();
},
{ pageSize: 50 }
);
// 本地成员状态(用于乐观更新)
const [localMembers, setLocalMembers] = useState<GroupMemberResponse[]>([]);
const [loading, setLoading] = useState(true);
// 同步分页数据到本地状态
useEffect(() => {
let active = true;
const syncMembers = async () => {
const enrichedMembers = await enrichGroupMembersWithUserProfiles(members);
if (!active) return;
setLocalMembers(enrichedMembers);
setLoading(false);
};
syncMembers();
return () => {
active = false;
};
}, [members]);
// 当前用户的成员信息
const [currentMember, setCurrentMember] = useState<GroupMemberResponse | null>(null);
// 操作模态框状态
const [actionModalVisible, setActionModalVisible] = useState(false);
const [selectedMember, setSelectedMember] = useState<GroupMemberResponse | null>(null);
const [actionLoading, setActionLoading] = useState(false);
// 设置群昵称模态框
const [nicknameModalVisible, setNicknameModalVisible] = useState(false);
const [newNickname, setNewNickname] = useState('');
useEffect(() => {
if (actionModalVisible || nicknameModalVisible) {
blurActiveElement();
}
}, [actionModalVisible, nicknameModalVisible]);
// 计算当前用户的角色
const isOwner = currentMember?.role === 'owner';
const isAdmin = currentMember?.role === 'admin' || isOwner;
// 查找当前用户的成员信息
useEffect(() => {
const myMember = localMembers.find(m => m.user_id === currentUser?.id);
if (myMember) {
setCurrentMember(myMember);
}
}, [localMembers, currentUser]);
// 下拉刷新
const onRefresh = useCallback(async () => {
setLoading(true);
await refresh();
setLoading(false);
}, [refresh]);
// 初始加载 - 使用 ref 避免依赖变化导致的无限循环
const initialLoadRef = React.useRef(false);
useEffect(() => {
if (!groupId || initialLoadRef.current) return;
initialLoadRef.current = true;
refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [groupId]);
// 按角色分组
const groupMembers = useCallback((): MemberGroup[] => {
const owners = localMembers.filter(m => m.role === 'owner');
const admins = localMembers.filter(m => m.role === 'admin');
const normalMembers = localMembers.filter(m => m.role === 'member');
const groups: MemberGroup[] = [];
if (owners.length > 0) {
groups.push({ title: '群主', data: owners });
}
if (admins.length > 0) {
groups.push({ title: '管理员', data: admins });
}
if (normalMembers.length > 0) {
groups.push({ title: '成员', data: normalMembers });
}
return groups;
}, [localMembers]);
// 打开操作菜单
const openActionModal = (member: GroupMemberResponse) => {
// 不能操作群主
if (member.role === 'owner') return;
// 普通成员不能操作其他人
if (!isAdmin) return;
// 管理员不能操作其他管理员(只有群主可以)
if (!isOwner && member.role === 'admin') return;
setSelectedMember(member);
setActionModalVisible(true);
};
// 设置/取消管理员
const handleToggleAdmin = async () => {
if (!selectedMember) return;
const newRole: GroupRole = selectedMember.role === 'admin' ? 'member' : 'admin';
const actionText = newRole === 'admin' ? '设为管理员' : '取消管理员';
Alert.alert(
actionText,
`确定要${actionText} "${selectedMember.user?.nickname || selectedMember.nickname}" 吗?`,
[
{ text: '取消', style: 'cancel' },
{
text: '确定',
onPress: async () => {
setActionLoading(true);
try {
await groupService.setMemberRole(groupId, selectedMember.user_id, {
role: newRole as 'admin' | 'member',
});
// 更新本地数据
setLocalMembers(prev => prev.map(m => {
if (m.user_id === selectedMember.user_id) {
return { ...m, role: newRole };
}
return m;
}));
setActionModalVisible(false);
Alert.alert('成功', `${actionText}`);
} catch (error: any) {
console.error('设置角色失败:', error);
Alert.alert('错误', error.message || '操作失败');
} finally {
setActionLoading(false);
}
},
},
]
);
};
// 禁言/解禁成员
const handleToggleMute = async () => {
if (!selectedMember) return;
const newMuted = !selectedMember.muted;
const actionText = newMuted ? '禁言' : '解禁';
Alert.alert(
`${actionText}成员`,
`确定要${actionText} "${selectedMember.user?.nickname || selectedMember.nickname}" 吗?`,
[
{ text: '取消', style: 'cancel' },
{
text: '确定',
onPress: async () => {
setActionLoading(true);
try {
// duration: 0 表示解除禁言,-1 表示永久禁言
await groupService.muteMember(groupId, selectedMember.user_id, newMuted ? -1 : 0);
// 更新本地数据
setLocalMembers(prev => prev.map(m => {
if (m.user_id === selectedMember.user_id) {
return { ...m, muted: newMuted };
}
return m;
}));
// 强制刷新远端状态,避免命中旧缓存导致解禁后仍显示禁言
await refresh();
setActionModalVisible(false);
Alert.alert('成功', `${actionText}`);
} catch (error: any) {
console.error('禁言操作失败:', error);
Alert.alert('错误', error.message || '操作失败');
} finally {
setActionLoading(false);
}
},
},
]
);
};
// 移除成员
const handleRemoveMember = () => {
if (!selectedMember) return;
Alert.alert(
'移除成员',
`确定要将 "${selectedMember.user?.nickname || selectedMember.nickname}" 移出群聊吗?`,
[
{ text: '取消', style: 'cancel' },
{
text: '移除',
style: 'destructive',
onPress: async () => {
setActionLoading(true);
try {
await groupService.removeMember(groupId, selectedMember.user_id);
// 更新本地数据
setLocalMembers(prev => prev.filter(m => m.user_id !== selectedMember.user_id));
setActionModalVisible(false);
Alert.alert('成功', '已移除成员');
} catch (error: any) {
console.error('移除成员失败:', error);
Alert.alert('错误', error.message || '操作失败');
} finally {
setActionLoading(false);
}
},
},
]
);
};
// 打开设置群昵称模态框
const openNicknameModal = (member: GroupMemberResponse) => {
setSelectedMember(member);
setNewNickname(member.nickname || member.user?.nickname || '');
setNicknameModalVisible(true);
};
// 设置群昵称(仅限自己)
const handleSetNickname = async () => {
if (!selectedMember || selectedMember.user_id !== currentUser?.id) return;
setActionLoading(true);
try {
await groupService.setNickname(groupId, {
nickname: newNickname.trim() || selectedMember.user?.nickname || '',
});
// 更新本地数据
setLocalMembers(prev => prev.map(m => {
if (m.user_id === selectedMember.user_id) {
return { ...m, nickname: newNickname.trim() };
}
return m;
}));
setNicknameModalVisible(false);
Alert.alert('成功', '群昵称已更新');
} catch (error: any) {
console.error('设置群昵称失败:', error);
Alert.alert('错误', error.message || '操作失败');
} finally {
setActionLoading(false);
}
};
// 获取角色标签颜色
const getRoleBadgeColor = (role: GroupRole): string => {
switch (role) {
case 'owner':
return colors.warning.main;
case 'admin':
return colors.primary.main;
default:
return colors.text.hint;
}
};
// 获取角色标签文本
const getRoleBadgeText = (role: GroupRole): string => {
switch (role) {
case 'owner':
return '群主';
case 'admin':
return '管理员';
default:
return '';
}
};
// 点击头像进入对方主页
const handleAvatarPress = (member: GroupMemberResponse) => {
if (member.user_id) {
router.push(hrefUserProfile(String(member.user_id)));
}
};
// 渲染成员项
const renderMember: ListRenderItem<GroupMemberResponse> = ({ item }) => {
const isSelf = item.user_id === currentUser?.id;
const canManage = isAdmin && item.role !== 'owner' && (isOwner || item.role === 'member');
return (
<View style={styles.memberItem}>
<TouchableOpacity
onPress={() => handleAvatarPress(item)}
activeOpacity={0.7}
>
<Avatar
source={item.user?.avatar}
size={48}
name={item.user?.nickname || item.nickname}
/>
</TouchableOpacity>
<TouchableOpacity
style={styles.memberInfo}
onPress={() => isSelf ? openNicknameModal(item) : handleAvatarPress(item)}
onLongPress={() => isSelf ? openNicknameModal(item) : null}
activeOpacity={0.7}
>
<View style={styles.memberNameRow}>
<Text variant="body" style={styles.memberName}>
{item.nickname || item.user?.nickname || '未知用户'}
</Text>
{item.role !== 'member' && (
<View style={[styles.roleBadge, { backgroundColor: getRoleBadgeColor(item.role) }]}>
<Text variant="label" color={colors.background.paper}>
{getRoleBadgeText(item.role)}
</Text>
</View>
)}
</View>
<Text variant="caption" color={colors.text.secondary}>
@{item.user?.username || 'unknown'}
</Text>
{item.muted && (
<View style={styles.mutedBadge}>
<MaterialCommunityIcons name="microphone-off" size={12} color={colors.error.main} />
<Text variant="label" color={colors.error.main}> </Text>
</View>
)}
</TouchableOpacity>
{canManage && (
<TouchableOpacity
onPress={() => openActionModal(item)}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<MaterialCommunityIcons name="dots-vertical" size={20} color={colors.text.hint} />
</TouchableOpacity>
)}
</View>
);
};
// 渲染分组头部
const renderSectionHeader = (title: string, count: number) => (
<View style={styles.sectionHeader}>
<Text variant="label" color={colors.text.secondary}>
{title}
</Text>
<Text variant="caption" color={colors.text.hint}>
{count}
</Text>
</View>
);
// 渲染空状态
const renderEmpty = () => {
if (loading || isLoading) return <Loading />;
return (
<EmptyState
title="暂无成员"
description="群组还没有成员"
icon="account-group-outline"
/>
);
};
// 渲染操作菜单
const renderActionModal = () => {
if (!selectedMember) return null;
return (
<Modal
visible={actionModalVisible}
animationType="slide"
transparent
onRequestClose={() => setActionModalVisible(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<Avatar
source={selectedMember.user?.avatar}
size={60}
name={selectedMember.user?.nickname || selectedMember.nickname}
/>
<Text variant="h3" style={styles.modalTitle}>
{selectedMember.user?.nickname || selectedMember.nickname}
</Text>
<Text variant="caption" color={colors.text.secondary}>
@{selectedMember.user?.username}
</Text>
</View>
<Divider />
{/* 设置/取消管理员(仅群主) */}
{isOwner && selectedMember.role !== 'owner' && (
<TouchableOpacity
style={styles.actionItem}
onPress={handleToggleAdmin}
disabled={actionLoading}
>
<MaterialCommunityIcons
name={selectedMember.role === 'admin' ? 'account-remove' : 'account-plus'}
size={22}
color={colors.text.primary}
/>
<Text variant="body" style={styles.actionText}>
{selectedMember.role === 'admin' ? '取消管理员' : '设为管理员'}
</Text>
</TouchableOpacity>
)}
{/* 禁言/解禁 */}
<TouchableOpacity
style={styles.actionItem}
onPress={handleToggleMute}
disabled={actionLoading}
>
<MaterialCommunityIcons
name={selectedMember.muted ? 'microphone' : 'microphone-off'}
size={22}
color={selectedMember.muted ? colors.success.main : colors.error.main}
/>
<Text
variant="body"
color={selectedMember.muted ? colors.success.main : colors.error.main}
style={styles.actionText}
>
{selectedMember.muted ? '解除禁言' : '禁言'}
</Text>
</TouchableOpacity>
{/* 移除成员 */}
<TouchableOpacity
style={styles.actionItem}
onPress={handleRemoveMember}
disabled={actionLoading}
>
<MaterialCommunityIcons name="account-remove" size={22} color={colors.error.main} />
<Text variant="body" color={colors.error.main} style={styles.actionText}>
</Text>
</TouchableOpacity>
<Divider />
<Button
title="取消"
variant="outline"
onPress={() => setActionModalVisible(false)}
fullWidth
disabled={actionLoading}
/>
</View>
</View>
</Modal>
);
};
// 渲染设置群昵称模态框
const renderNicknameModal = () => {
if (!selectedMember) return null;
return (
<Modal
visible={nicknameModalVisible}
animationType="slide"
transparent
onRequestClose={() => setNicknameModalVisible(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<Text variant="h3" style={styles.modalTitle}></Text>
<Text variant="caption" color={colors.text.secondary} style={styles.inputLabel}>
</Text>
<TextInput
style={styles.input}
value={newNickname}
onChangeText={setNewNickname}
placeholder={selectedMember.user?.nickname || '请输入群昵称'}
placeholderTextColor={colors.text.hint}
maxLength={20}
/>
<View style={styles.modalButtons}>
<Button
title="取消"
variant="outline"
onPress={() => setNicknameModalVisible(false)}
style={styles.modalButton}
disabled={actionLoading}
/>
<Button
title="保存"
onPress={handleSetNickname}
loading={actionLoading}
style={styles.modalButton}
/>
</View>
</View>
</View>
</Modal>
);
};
// 渲染分组列表
const renderGroupedList = () => {
const groups = groupMembers();
if (groups.length === 0) {
return renderEmpty();
}
return (
<FlatList
data={groups}
keyExtractor={(item) => item.title}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={onRefresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
onEndReached={loadMore}
onEndReachedThreshold={0.3}
showsVerticalScrollIndicator={false}
ListFooterComponent={
isLoading ? (
<View style={styles.loadingFooter}>
<Loading size="sm" />
</View>
) : hasMore ? (
<TouchableOpacity style={styles.loadMoreBtn} onPress={loadMore}>
<Text variant="caption" color={colors.primary.main}>
</Text>
</TouchableOpacity>
) : localMembers.length > 0 ? (
<Text variant="caption" color={colors.text.hint} style={styles.noMoreText}>
</Text>
) : null
}
renderItem={({ item: group }) => (
<View style={styles.section}>
{renderSectionHeader(group.title, group.data.length)}
{group.data.map((member, index) => (
<View key={member.id}>
{renderMember({ item: member, index } as any)}
</View>
))}
</View>
)}
/>
);
};
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
{/* Header */}
<View style={styles.pageHeader}>
<AppBackButton onPress={() => router.back()} />
<Text variant="h3" style={styles.pageTitle}></Text>
<View style={styles.pageHeaderPlaceholder} />
</View>
{loading ? <Loading /> : renderGroupedList()}
{renderActionModal()}
{renderNicknameModal()}
</SafeAreaView>
);
};
function createGroupMembersStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
},
// Header 样式 - 扁平化
pageHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
backgroundColor: colors.background.paper,
borderBottomWidth: 0.5,
borderBottomColor: colors.divider + '60',
},
pageTitle: {
fontWeight: '800',
fontSize: fontSizes.xl + 1,
letterSpacing: 0.5,
},
pageHeaderPlaceholder: {
width: 40,
height: 40,
},
section: {
marginBottom: spacing.md,
},
sectionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
backgroundColor: colors.background.default,
borderTopWidth: 0.5,
borderTopColor: colors.divider + '40',
},
memberItem: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
backgroundColor: colors.background.paper,
borderBottomWidth: 0.5,
borderBottomColor: colors.divider + '40',
},
memberInfo: {
flex: 1,
marginLeft: spacing.md,
},
memberNameRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 2,
},
memberName: {
fontWeight: '700',
fontSize: fontSizes.md + 1,
letterSpacing: 0.3,
},
roleBadge: {
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: borderRadius.sm,
marginLeft: spacing.sm,
fontWeight: '800',
fontSize: fontSizes.xs,
color: colors.background.paper,
},
mutedBadge: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 2,
},
// 模态框样式 - 更现代
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.45)',
justifyContent: 'flex-end',
},
modalContent: {
backgroundColor: colors.background.paper,
borderTopLeftRadius: borderRadius['2xl'],
borderTopRightRadius: borderRadius['2xl'],
padding: spacing.lg,
maxHeight: '80%',
},
modalHeader: {
alignItems: 'center',
marginBottom: spacing.lg,
paddingBottom: spacing.md,
borderBottomWidth: 0.5,
borderBottomColor: colors.divider + '60',
},
modalTitle: {
fontWeight: '800',
fontSize: fontSizes.xl + 1,
marginTop: spacing.sm,
marginBottom: spacing.xs,
letterSpacing: 0.5,
},
actionItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.md,
paddingHorizontal: spacing.sm,
marginLeft: -spacing.sm,
marginRight: -spacing.sm,
borderRadius: borderRadius.md,
borderBottomWidth: 0.5,
borderBottomColor: colors.divider + '40',
},
actionText: {
marginLeft: spacing.md,
fontWeight: '600',
fontSize: fontSizes.md,
},
inputLabel: {
marginBottom: spacing.xs,
fontWeight: '700',
fontSize: fontSizes.sm + 1,
},
input: {
backgroundColor: colors.background.default,
borderRadius: borderRadius.lg,
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
fontSize: fontSizes.md,
color: colors.text.primary,
borderWidth: 1.5,
borderColor: colors.divider + '80',
marginBottom: spacing.md,
},
modalButtons: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: spacing.md,
},
modalButton: {
flex: 1,
marginHorizontal: spacing.xs,
},
// 分页加载样式
loadingFooter: {
paddingVertical: spacing.md,
alignItems: 'center',
},
loadMoreBtn: {
paddingVertical: spacing.md,
alignItems: 'center',
},
noMoreText: {
textAlign: 'center',
paddingVertical: spacing.md,
fontWeight: '500',
color: colors.text.hint,
},
});
}
export default GroupMembersScreen;