Files
frontend/src/screens/message/components/ChatScreen/GroupInfoPanel.tsx
lafay 6b91a7ead1
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 53s
Frontend CI / ota-android (push) Has been cancelled
Frontend CI / build-android-apk (push) Has been cancelled
fix(message): add robust NaN validation for date handling across components
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.
2026-04-25 15:25:42 +08:00

604 lines
18 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.
/**
* 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;