Add comprehensive date validation using Number.isNaN() checks to prevent crashes when timestamps are invalid or empty. Apply defensive formatting across CommentItem, SystemMessageItem, MessageBubble, LongPressMenu, and store utilities. Refine message bubble styling: move sender name display to both sides in group chat with distinct `mySenderName` styling, adjust bubble corner radius to top-right for consistent arrow placement, and align message rows to flex-start for improved layout. Simplify group avatar rendering in ConversationListRow.
604 lines
18 KiB
TypeScript
604 lines
18 KiB
TypeScript
/**
|
||
* GroupInfoPanel.tsx
|
||
* 群信息侧边栏组件
|
||
* 大屏幕模式下从右侧滑入显示
|
||
* 实现手机端群信息界面的核心功能
|
||
*/
|
||
|
||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||
import {
|
||
View,
|
||
StyleSheet,
|
||
TouchableOpacity,
|
||
ScrollView,
|
||
Dimensions,
|
||
Animated,
|
||
Alert,
|
||
} from 'react-native';
|
||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||
import { Avatar, Text } from '../../../../components/common';
|
||
import { useAppColors, spacing, fontSizes, shadows, type AppColors } from '../../../../theme';
|
||
import { GroupMemberResponse, GroupResponse, GroupAnnouncementResponse } from '../../../../types/dto';
|
||
import { useBreakpointGTE } from '../../../../hooks/useResponsive';
|
||
import { groupManager } from '../../../../stores/group';
|
||
import { groupService } from '@/services/message';
|
||
|
||
const { width: screenWidth } = Dimensions.get('window');
|
||
const PANEL_WIDTH = 360;
|
||
|
||
interface GroupInfoPanelProps {
|
||
visible: boolean;
|
||
groupId?: string;
|
||
groupInfo: GroupResponse | null;
|
||
conversationId?: string;
|
||
isPinned?: boolean;
|
||
currentUserId?: string;
|
||
onClose: () => void;
|
||
onTogglePin?: (pinned: boolean) => void;
|
||
onLeaveGroup?: () => void;
|
||
onInviteMembers?: () => void;
|
||
onViewAllMembers?: () => void;
|
||
}
|
||
|
||
export const GroupInfoPanel: React.FC<GroupInfoPanelProps> = ({
|
||
visible,
|
||
groupId,
|
||
groupInfo,
|
||
conversationId,
|
||
isPinned,
|
||
currentUserId,
|
||
onClose,
|
||
onTogglePin,
|
||
onLeaveGroup,
|
||
onInviteMembers,
|
||
onViewAllMembers,
|
||
}) => {
|
||
const colors = useAppColors();
|
||
const styles = useMemo(() => createGroupInfoPanelStyles(colors), [colors]);
|
||
const isWideScreen = useBreakpointGTE('lg');
|
||
const slideAnim = React.useRef(new Animated.Value(PANEL_WIDTH)).current;
|
||
const fadeAnim = React.useRef(new Animated.Value(0)).current;
|
||
const [members, setMembers] = useState<GroupMemberResponse[]>([]);
|
||
const [announcements, setAnnouncements] = useState<GroupAnnouncementResponse[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [showAllMembers, setShowAllMembers] = useState(false);
|
||
const [allMembers, setAllMembers] = useState<GroupMemberResponse[]>([]);
|
||
const [loadingMoreMembers, setLoadingMoreMembers] = useState(false);
|
||
|
||
// 加载群成员(初始只加载10个)
|
||
const loadMembers = useCallback(async () => {
|
||
if (!groupId) return;
|
||
try {
|
||
setLoading(true);
|
||
const response = await groupManager.getMembers(groupId, 1, 10);
|
||
setMembers(response.list || []);
|
||
} catch (error) {
|
||
console.error('[GroupInfoPanel] 加载成员失败:', error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [groupId]);
|
||
|
||
// 加载全部群成员
|
||
const loadAllMembers = useCallback(async () => {
|
||
if (!groupId) return;
|
||
try {
|
||
setLoadingMoreMembers(true);
|
||
const response = await groupManager.getMembers(groupId, 1, 100);
|
||
setAllMembers(response.list || []);
|
||
setShowAllMembers(true);
|
||
} catch (error) {
|
||
console.error('[GroupInfoPanel] 加载全部成员失败:', error);
|
||
} finally {
|
||
setLoadingMoreMembers(false);
|
||
}
|
||
}, [groupId]);
|
||
|
||
// 加载群公告
|
||
const loadAnnouncements = useCallback(async () => {
|
||
if (!groupId) return;
|
||
try {
|
||
const response = await groupService.getAnnouncements(groupId, 1, 10);
|
||
setAnnouncements(response.list || []);
|
||
} catch (error) {
|
||
console.error('[GroupInfoPanel] 加载公告失败:', error);
|
||
}
|
||
}, [groupId]);
|
||
|
||
// 每次打开面板时重置状态
|
||
useEffect(() => {
|
||
if (visible) {
|
||
setShowAllMembers(false);
|
||
setAllMembers([]);
|
||
}
|
||
}, [visible]);
|
||
|
||
useEffect(() => {
|
||
if (visible && groupId) {
|
||
loadMembers();
|
||
loadAnnouncements();
|
||
}
|
||
}, [visible, groupId, loadMembers, loadAnnouncements]);
|
||
|
||
useEffect(() => {
|
||
if (visible) {
|
||
Animated.parallel([
|
||
Animated.timing(slideAnim, {
|
||
toValue: 0,
|
||
duration: 300,
|
||
useNativeDriver: true,
|
||
}),
|
||
Animated.timing(fadeAnim, {
|
||
toValue: 1,
|
||
duration: 300,
|
||
useNativeDriver: true,
|
||
}),
|
||
]).start();
|
||
} else {
|
||
Animated.parallel([
|
||
Animated.timing(slideAnim, {
|
||
toValue: PANEL_WIDTH,
|
||
duration: 300,
|
||
useNativeDriver: true,
|
||
}),
|
||
Animated.timing(fadeAnim, {
|
||
toValue: 0,
|
||
duration: 300,
|
||
useNativeDriver: true,
|
||
}),
|
||
]).start();
|
||
}
|
||
}, [visible, slideAnim, fadeAnim]);
|
||
|
||
if (!isWideScreen) {
|
||
return null;
|
||
}
|
||
|
||
// 获取加群方式文本
|
||
const getJoinTypeText = (joinType?: number): string => {
|
||
switch (joinType) {
|
||
case 0:
|
||
return '允许任何人加入';
|
||
case 1:
|
||
return '需要管理员审批';
|
||
case 2:
|
||
return '不允许任何人加入';
|
||
default:
|
||
return '未知';
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
{/* 遮罩层 */}
|
||
{visible && (
|
||
<Animated.View style={[styles.overlay, { opacity: fadeAnim }]}>
|
||
<TouchableOpacity
|
||
style={styles.overlayTouchable}
|
||
onPress={onClose}
|
||
activeOpacity={1}
|
||
/>
|
||
</Animated.View>
|
||
)}
|
||
|
||
{/* 侧边栏面板 */}
|
||
<Animated.View
|
||
style={[
|
||
styles.panel,
|
||
{
|
||
transform: [{ translateX: slideAnim }],
|
||
},
|
||
]}
|
||
>
|
||
{/* 头部 */}
|
||
<View style={styles.header}>
|
||
<Text style={styles.headerTitle}>群信息</Text>
|
||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||
<MaterialCommunityIcons name="close" size={24} color="#333" />
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
||
{/* 群头像和名称 */}
|
||
<View style={styles.groupInfoSection}>
|
||
<Avatar
|
||
source={groupInfo?.avatar}
|
||
size={80}
|
||
name={groupInfo?.name || '群聊'}
|
||
/>
|
||
<Text style={styles.groupName}>{groupInfo?.name || '群聊'}</Text>
|
||
<Text style={styles.memberCount}>
|
||
{groupInfo?.member_count || members.length || 0} 位成员
|
||
</Text>
|
||
{groupInfo?.join_type !== undefined && (
|
||
<Text style={styles.joinType}>
|
||
{getJoinTypeText(groupInfo.join_type)}
|
||
</Text>
|
||
)}
|
||
{/* 邀请新成员按钮 */}
|
||
{onInviteMembers && (
|
||
<TouchableOpacity
|
||
style={styles.inviteButton}
|
||
onPress={onInviteMembers}
|
||
activeOpacity={0.7}
|
||
>
|
||
<MaterialCommunityIcons name="account-plus" size={18} color={colors.primary.main} />
|
||
<Text style={styles.inviteButtonText}>邀请新成员</Text>
|
||
</TouchableOpacity>
|
||
)}
|
||
</View>
|
||
|
||
{/* 分割线 */}
|
||
<View style={styles.divider} />
|
||
|
||
{/* 群公告 */}
|
||
{announcements.length > 0 && (
|
||
<View style={styles.section}>
|
||
<View style={styles.sectionHeader}>
|
||
<MaterialCommunityIcons name="bullhorn" size={18} color={colors.warning.main} />
|
||
<Text style={styles.sectionTitle}>群公告</Text>
|
||
</View>
|
||
<View style={styles.noticeBox}>
|
||
<Text style={styles.noticeText}>{announcements[0].content}</Text>
|
||
<Text style={styles.noticeTime}>
|
||
{(() => {
|
||
const d = new Date(announcements[0].created_at);
|
||
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString();
|
||
})()}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
)}
|
||
|
||
{/* 群简介 */}
|
||
{groupInfo?.description && (
|
||
<View style={styles.section}>
|
||
<Text style={styles.sectionTitle}>群简介</Text>
|
||
<View style={styles.descriptionBox}>
|
||
<Text style={styles.description}>{groupInfo.description}</Text>
|
||
</View>
|
||
</View>
|
||
)}
|
||
|
||
{/* 群成员列表 */}
|
||
<View style={styles.section}>
|
||
<View style={styles.memberListHeader}>
|
||
<Text style={styles.sectionTitle}>成员列表 ({groupInfo?.member_count || members.length})</Text>
|
||
{onViewAllMembers && !showAllMembers && members.length >= 10 && (
|
||
<TouchableOpacity onPress={loadAllMembers} style={styles.viewAllButton} disabled={loadingMoreMembers}>
|
||
<Text style={styles.viewAllText}>
|
||
{loadingMoreMembers ? '加载中...' : '查看全部'}
|
||
</Text>
|
||
<MaterialCommunityIcons name="chevron-right" size={16} color={colors.primary.main} />
|
||
</TouchableOpacity>
|
||
)}
|
||
{onViewAllMembers && showAllMembers && (
|
||
<TouchableOpacity onPress={() => setShowAllMembers(false)} style={styles.viewAllButton}>
|
||
<Text style={styles.viewAllText}>收起</Text>
|
||
<MaterialCommunityIcons name="chevron-up" size={16} color={colors.primary.main} />
|
||
</TouchableOpacity>
|
||
)}
|
||
</View>
|
||
<View style={styles.memberList}>
|
||
{(showAllMembers ? allMembers : members).map((member) => (
|
||
<View key={member.id || member.user_id} style={styles.memberItem}>
|
||
<Avatar
|
||
source={member.user?.avatar}
|
||
size={44}
|
||
name={member.nickname || member.user?.nickname || '用户'}
|
||
/>
|
||
<View style={styles.memberInfo}>
|
||
<Text style={styles.memberName} numberOfLines={1}>
|
||
{member.nickname || member.user?.nickname || '用户'}
|
||
</Text>
|
||
{member.role === 'owner' && (
|
||
<Text style={styles.ownerBadge}>群主</Text>
|
||
)}
|
||
{member.role === 'admin' && (
|
||
<Text style={styles.adminBadge}>管理员</Text>
|
||
)}
|
||
</View>
|
||
</View>
|
||
))}
|
||
</View>
|
||
</View>
|
||
|
||
{/* 群信息 */}
|
||
<View style={styles.section}>
|
||
<Text style={styles.sectionTitle}>群信息</Text>
|
||
<View style={styles.infoRow}>
|
||
<Text style={styles.infoLabel}>群号</Text>
|
||
<Text style={styles.infoValue}>{groupInfo?.id || '-'}</Text>
|
||
</View>
|
||
<View style={styles.infoRow}>
|
||
<Text style={styles.infoLabel}>创建时间</Text>
|
||
<Text style={styles.infoValue}>
|
||
{groupInfo?.created_at
|
||
? (() => {
|
||
const d = new Date(groupInfo.created_at);
|
||
return Number.isNaN(d.getTime()) ? '-' : d.toLocaleDateString('zh-CN');
|
||
})()
|
||
: '-'}
|
||
</Text>
|
||
</View>
|
||
<View style={styles.infoRow}>
|
||
<Text style={styles.infoLabel}>全员禁言</Text>
|
||
<Text style={styles.infoValue}>
|
||
{groupInfo?.mute_all ? '已开启' : '已关闭'}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
|
||
{/* 操作按钮 */}
|
||
<View style={styles.actionSection}>
|
||
{conversationId && onTogglePin && (
|
||
<TouchableOpacity
|
||
style={styles.actionButton}
|
||
onPress={() => onTogglePin(!isPinned)}
|
||
activeOpacity={0.7}
|
||
>
|
||
<MaterialCommunityIcons
|
||
name={isPinned ? "pin-off" : "pin"}
|
||
size={22}
|
||
color={colors.primary.main}
|
||
/>
|
||
<Text style={styles.actionButtonText}>
|
||
{isPinned ? '取消置顶' : '置顶群聊'}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
)}
|
||
|
||
{onLeaveGroup && (
|
||
<TouchableOpacity
|
||
style={[styles.actionButton, styles.leaveButton]}
|
||
onPress={onLeaveGroup}
|
||
activeOpacity={0.7}
|
||
>
|
||
<MaterialCommunityIcons
|
||
name="logout"
|
||
size={22}
|
||
color={colors.error.main}
|
||
/>
|
||
<Text style={[styles.actionButtonText, styles.leaveButtonText]}>
|
||
退出群聊
|
||
</Text>
|
||
</TouchableOpacity>
|
||
)}
|
||
</View>
|
||
</ScrollView>
|
||
</Animated.View>
|
||
</>
|
||
);
|
||
};
|
||
|
||
function createGroupInfoPanelStyles(colors: AppColors) {
|
||
return StyleSheet.create({
|
||
overlay: {
|
||
...StyleSheet.absoluteFillObject,
|
||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||
zIndex: 100,
|
||
},
|
||
overlayTouchable: {
|
||
flex: 1,
|
||
},
|
||
panel: {
|
||
position: 'absolute',
|
||
right: 0,
|
||
top: 0,
|
||
bottom: 0,
|
||
width: PANEL_WIDTH,
|
||
backgroundColor: colors.background.paper,
|
||
zIndex: 101,
|
||
...shadows.lg,
|
||
},
|
||
header: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
paddingHorizontal: spacing.md,
|
||
paddingVertical: spacing.md,
|
||
borderBottomWidth: 1,
|
||
borderBottomColor: colors.divider,
|
||
},
|
||
headerTitle: {
|
||
fontSize: fontSizes.xl,
|
||
fontWeight: '600',
|
||
color: colors.text.primary,
|
||
},
|
||
closeButton: {
|
||
padding: spacing.xs,
|
||
},
|
||
content: {
|
||
flex: 1,
|
||
},
|
||
groupInfoSection: {
|
||
alignItems: 'center',
|
||
paddingVertical: spacing.xl,
|
||
},
|
||
groupName: {
|
||
fontSize: fontSizes.xl,
|
||
fontWeight: '600',
|
||
color: colors.text.primary,
|
||
marginTop: spacing.md,
|
||
},
|
||
memberCount: {
|
||
fontSize: fontSizes.md,
|
||
color: colors.text.secondary,
|
||
marginTop: spacing.xs,
|
||
},
|
||
joinType: {
|
||
fontSize: fontSizes.sm,
|
||
color: colors.primary.main,
|
||
marginTop: spacing.xs,
|
||
},
|
||
inviteButton: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
paddingVertical: spacing.sm,
|
||
paddingHorizontal: spacing.md,
|
||
backgroundColor: colors.primary.light + '15',
|
||
borderRadius: 20,
|
||
marginTop: spacing.md,
|
||
borderWidth: 1,
|
||
borderColor: colors.primary.light,
|
||
},
|
||
inviteButtonText: {
|
||
fontSize: fontSizes.sm,
|
||
color: colors.primary.main,
|
||
marginLeft: spacing.xs,
|
||
fontWeight: '500',
|
||
},
|
||
divider: {
|
||
height: 1,
|
||
backgroundColor: colors.divider,
|
||
marginHorizontal: spacing.md,
|
||
},
|
||
section: {
|
||
padding: spacing.md,
|
||
},
|
||
sectionHeader: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
marginBottom: spacing.sm,
|
||
},
|
||
sectionTitle: {
|
||
fontSize: fontSizes.md,
|
||
fontWeight: '600',
|
||
color: colors.text.primary,
|
||
marginLeft: spacing.xs,
|
||
},
|
||
noticeBox: {
|
||
backgroundColor: colors.chat.tipBg,
|
||
padding: spacing.md,
|
||
borderRadius: 8,
|
||
borderLeftWidth: 3,
|
||
borderLeftColor: colors.warning.main,
|
||
},
|
||
noticeText: {
|
||
fontSize: fontSizes.sm,
|
||
color: colors.text.secondary,
|
||
lineHeight: 20,
|
||
},
|
||
noticeTime: {
|
||
fontSize: fontSizes.xs,
|
||
color: colors.text.hint,
|
||
marginTop: spacing.xs,
|
||
textAlign: 'right',
|
||
},
|
||
descriptionBox: {
|
||
backgroundColor: colors.chat.surfaceMuted,
|
||
padding: spacing.md,
|
||
borderRadius: 8,
|
||
},
|
||
description: {
|
||
fontSize: fontSizes.sm,
|
||
color: colors.text.secondary,
|
||
lineHeight: 20,
|
||
},
|
||
memberList: {
|
||
gap: spacing.sm,
|
||
},
|
||
memberListHeader: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
marginBottom: spacing.sm,
|
||
},
|
||
viewAllButton: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
},
|
||
viewAllText: {
|
||
fontSize: fontSizes.sm,
|
||
color: colors.primary.main,
|
||
},
|
||
memberItem: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
paddingVertical: spacing.xs,
|
||
},
|
||
memberInfo: {
|
||
flex: 1,
|
||
marginLeft: spacing.sm,
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
},
|
||
memberName: {
|
||
fontSize: fontSizes.md,
|
||
color: colors.text.primary,
|
||
flex: 1,
|
||
},
|
||
ownerBadge: {
|
||
fontSize: fontSizes.xs,
|
||
color: colors.warning.main,
|
||
marginLeft: spacing.xs,
|
||
backgroundColor: colors.warning.light + '30',
|
||
paddingHorizontal: 6,
|
||
paddingVertical: 2,
|
||
borderRadius: 4,
|
||
},
|
||
adminBadge: {
|
||
fontSize: fontSizes.xs,
|
||
color: colors.primary.main,
|
||
marginLeft: spacing.xs,
|
||
backgroundColor: colors.primary.light + '30',
|
||
paddingHorizontal: 6,
|
||
paddingVertical: 2,
|
||
borderRadius: 4,
|
||
},
|
||
moreMembers: {
|
||
fontSize: fontSizes.sm,
|
||
color: colors.text.secondary,
|
||
textAlign: 'center',
|
||
paddingVertical: spacing.sm,
|
||
},
|
||
infoRow: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
paddingVertical: spacing.sm,
|
||
borderBottomWidth: 1,
|
||
borderBottomColor: colors.chat.borderHairline,
|
||
},
|
||
infoLabel: {
|
||
fontSize: fontSizes.sm,
|
||
color: colors.text.secondary,
|
||
},
|
||
infoValue: {
|
||
fontSize: fontSizes.sm,
|
||
color: colors.text.primary,
|
||
},
|
||
actionSection: {
|
||
padding: spacing.md,
|
||
paddingBottom: spacing.xl,
|
||
},
|
||
actionButton: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
paddingVertical: spacing.md,
|
||
backgroundColor: colors.primary.light + '15',
|
||
borderRadius: 12,
|
||
marginBottom: spacing.sm,
|
||
borderWidth: 1,
|
||
borderColor: colors.primary.light,
|
||
},
|
||
actionButtonText: {
|
||
fontSize: fontSizes.md,
|
||
fontWeight: '600',
|
||
color: colors.primary.main,
|
||
marginLeft: spacing.sm,
|
||
},
|
||
leaveButton: {
|
||
backgroundColor: colors.error.light + '15',
|
||
borderColor: colors.error.light,
|
||
},
|
||
leaveButtonText: {
|
||
color: colors.error.main,
|
||
},
|
||
});
|
||
}
|
||
|
||
export default GroupInfoPanel;
|