PC端的部分适配

This commit is contained in:
2026-03-16 17:47:10 +08:00
parent 798dd7c9a0
commit cb2087f779
30 changed files with 4242 additions and 438 deletions

View File

@@ -22,10 +22,15 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
onBack,
onTitlePress,
onMorePress,
onGroupInfoPress,
isWideScreen: propIsWideScreen,
}) => {
// 响应式布局
const { width } = useResponsive();
const isWideScreen = useBreakpointGTE('lg');
// 使用 768px 作为大屏幕和小屏幕的分界线
const isWideScreenHook = useBreakpointGTE('lg');
// 优先使用 props 传入的 isWideScreen否则使用 hook
const isWideScreen = propIsWideScreen !== undefined ? propIsWideScreen : isWideScreenHook;
// 合并样式
const styles = useMemo(() => {
@@ -67,12 +72,17 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
return (
<SafeAreaView edges={['top']} style={styles.headerContainer}>
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={onBack}
>
<MaterialCommunityIcons name="arrow-left" size={22} color={colors.primary.dark} />
</TouchableOpacity>
{/* 大屏幕(>= 768px时隐藏返回按钮 */}
{!isWideScreen ? (
<TouchableOpacity
style={styles.backButton}
onPress={onBack}
>
<MaterialCommunityIcons name="arrow-left" size={22} color={colors.primary.dark} />
</TouchableOpacity>
) : (
<View style={styles.backButton} />
)}
<View style={styles.headerCenter}>
<TouchableOpacity
@@ -115,12 +125,22 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
</TouchableOpacity>
</View>
<TouchableOpacity
style={styles.moreButton}
onPress={onMorePress}
>
<MaterialCommunityIcons name="dots-horizontal" size={22} color="#667085" />
</TouchableOpacity>
{/* 大屏幕 + 群聊时显示群信息按钮,否则显示三点菜单 */}
{isWideScreen && isGroupChat && onGroupInfoPress ? (
<TouchableOpacity
style={styles.moreButton}
onPress={onGroupInfoPress}
>
<MaterialCommunityIcons name="dots-horizontal" size={22} color="#667085" />
</TouchableOpacity>
) : (
<TouchableOpacity
style={styles.moreButton}
onPress={onMorePress}
>
<MaterialCommunityIcons name="dots-horizontal" size={22} color="#667085" />
</TouchableOpacity>
)}
</View>
</SafeAreaView>
);

View File

@@ -134,6 +134,9 @@ export const ChatInput: React.FC<ChatInputProps & {
blurOnSubmit={false}
editable={!isDisabled}
onFocus={onFocus}
// 确保光标可见
cursorColor={colors.primary.main}
selectionColor={`${colors.primary.main}40`}
/>
</View>

View File

@@ -0,0 +1,593 @@
/**
* GroupInfoPanel.tsx
* 群信息侧边栏组件
* 大屏幕模式下从右侧滑入显示
* 实现手机端群信息界面的核心功能
*/
import React, { useEffect, useState, useCallback } 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 { colors, spacing, fontSizes, shadows } from '../../../../theme';
import { GroupMemberResponse, GroupResponse, GroupAnnouncementResponse } from '../../../../types/dto';
import { useBreakpointGTE } from '../../../../hooks/useResponsive';
import { groupManager } from '../../../../stores/groupManager';
import { groupService } from '../../../../services/groupService';
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 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}>
{new Date(announcements[0].created_at).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
? new Date(groupInfo.created_at).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>
</>
);
};
const styles = 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: '#FFF',
zIndex: 101,
...shadows.lg,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
borderBottomWidth: 1,
borderBottomColor: '#E8E8E8',
},
headerTitle: {
fontSize: fontSizes.xl,
fontWeight: '600',
color: '#333',
},
closeButton: {
padding: spacing.xs,
},
content: {
flex: 1,
},
groupInfoSection: {
alignItems: 'center',
paddingVertical: spacing.xl,
},
groupName: {
fontSize: fontSizes.xl,
fontWeight: '600',
color: '#333',
marginTop: spacing.md,
},
memberCount: {
fontSize: fontSizes.md,
color: '#999',
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: '#E8E8E8',
marginHorizontal: spacing.md,
},
section: {
padding: spacing.md,
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.sm,
},
sectionTitle: {
fontSize: fontSizes.md,
fontWeight: '600',
color: '#333',
marginLeft: spacing.xs,
},
noticeBox: {
backgroundColor: '#FFF9E6',
padding: spacing.md,
borderRadius: 8,
borderLeftWidth: 3,
borderLeftColor: colors.warning.main,
},
noticeText: {
fontSize: fontSizes.sm,
color: '#666',
lineHeight: 20,
},
noticeTime: {
fontSize: fontSizes.xs,
color: '#999',
marginTop: spacing.xs,
textAlign: 'right',
},
descriptionBox: {
backgroundColor: '#F5F7FA',
padding: spacing.md,
borderRadius: 8,
},
description: {
fontSize: fontSizes.sm,
color: '#666',
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: '#333',
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: '#999',
textAlign: 'center',
paddingVertical: spacing.sm,
},
infoRow: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: '#F0F0F0',
},
infoLabel: {
fontSize: fontSizes.sm,
color: '#999',
},
infoValue: {
fontSize: fontSizes.sm,
color: '#333',
},
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;

View File

@@ -130,9 +130,22 @@ const ImageSegment: React.FC<{
}> = ({ data, isMe, onImagePress, onImageLongPress }) => {
const pressPositionRef = useRef({ x: 0, y: 0 });
const [dimensions, setDimensions] = useState<{ width: number; height: number } | null>(null);
const [loadError, setLoadError] = useState(false);
const imageUrl = data.thumbnail_url || data.url;
// 如果没有有效的图片URL显示图片占位符
const hasValidUrl = !!imageUrl && imageUrl.trim() !== '';
useEffect(() => {
// 重置状态
setLoadError(false);
// 如果没有有效的图片URL设置默认尺寸用于显示占位符
if (!hasValidUrl) {
setDimensions(IMAGE_FALLBACK_SIZE);
return;
}
// 如果已经有宽高信息,直接使用
if (data.width && data.height) {
const aspectRatio = data.width / data.height;
@@ -191,7 +204,7 @@ const ImageSegment: React.FC<{
);
return () => clearTimeout(timeoutId);
}, [imageUrl, data.width, data.height]);
}, [imageUrl, data.width, data.height, hasValidUrl]);
// 记录按压位置
const handlePressIn = (event: GestureResponderEvent) => {
@@ -204,6 +217,35 @@ const ImageSegment: React.FC<{
onImageLongPress?.(pressPositionRef.current);
};
// 没有有效URL或加载失败时显示占位符
if (!hasValidUrl || loadError) {
return (
<TouchableOpacity
key={`image-${data.url || Math.random()}`}
style={[styles.imageContainer, isMe ? styles.imageMe : styles.imageOther]}
onPressIn={handlePressIn}
onPress={() => hasValidUrl && onImagePress?.(data.url)}
onLongPress={handleLongPress}
delayLongPress={500}
activeOpacity={0.9}
>
<View
style={{
width: IMAGE_FALLBACK_SIZE.width,
height: IMAGE_FALLBACK_SIZE.height,
borderRadius: 8,
backgroundColor: 'rgba(0,0,0,0.1)',
justifyContent: 'center',
alignItems: 'center',
}}
>
<MaterialCommunityIcons name="image" size={32} color="#999" />
<Text style={{ color: '#999', fontSize: 12, marginTop: 4 }}></Text>
</View>
</TouchableOpacity>
);
}
// 初始加载时显示占位
if (!dimensions) {
return (
@@ -248,6 +290,9 @@ const ImageSegment: React.FC<{
contentFit="cover"
cachePolicy="disk"
priority="normal"
onError={() => {
setLoadError(true);
}}
/>
</TouchableOpacity>
);

View File

@@ -19,6 +19,7 @@ export { LongPressMenu } from './LongPressMenu';
export { ChatHeader } from './ChatHeader';
export { MessageBubble } from './MessageBubble';
export { ChatInput } from './ChatInput';
export { GroupInfoPanel } from './GroupInfoPanel';
// Segment 渲染组件
export {

View File

@@ -322,13 +322,18 @@ export const chatScreenStyles = StyleSheet.create({
inputContainer: {
backgroundColor: colors.background.paper,
borderWidth: 1,
borderColor: `${colors.divider}88`,
borderColor: colors.divider,
borderRadius: 24,
marginHorizontal: 14,
marginBottom: 12,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
...shadows.lg,
// 移除明显的阴影效果,改用更微妙的边框
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
},
inputInner: {
flexDirection: 'row',
@@ -338,6 +343,12 @@ export const chatScreenStyles = StyleSheet.create({
paddingHorizontal: spacing.xs,
paddingVertical: 4,
minHeight: 44,
// 移除阴影效果
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
},
inputInnerMuted: {
backgroundColor: '#F0F0F0',
@@ -395,6 +406,12 @@ export const chatScreenStyles = StyleSheet.create({
paddingHorizontal: spacing.xs,
maxHeight: 100,
justifyContent: 'center',
// 移除阴影
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
},
input: {
fontSize: 16,

View File

@@ -163,6 +163,10 @@ export interface ChatHeaderProps {
onBack: () => void;
onTitlePress: () => void;
onMorePress: () => void;
/** 大屏幕下显示群信息面板的回调 */
onGroupInfoPress?: () => void;
/** 是否为大屏幕模式 */
isWideScreen?: boolean;
}
// 回复预览 Props

View File

@@ -14,26 +14,40 @@ import {
Platform,
TextInput,
Dimensions,
Animated,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { Image as ExpoImage } from 'expo-image';
import { colors, spacing, fontSizes, shadows } from '../../../theme';
import { ConversationResponse, MessageResponse, MessageSegment } from '../../../types/dto';
import { Avatar, Text } from '../../../components/common';
import { ConversationResponse, MessageResponse, MessageSegment, GroupMemberResponse, ImageSegmentData } from '../../../types/dto';
import { Avatar, Text, ImageGallery } from '../../../components/common';
import { useAuthStore, messageManager, MessageEvent, MessageSubscriber } from '../../../stores';
import { extractTextFromSegments } from '../../../types/dto';
import { useBreakpointGTE } from '../../../hooks/useResponsive';
import { useMarkAsRead } from '../../../stores/messageManagerHooks';
import { messageService } from '../../../services';
import { GroupInfoPanel } from './ChatScreen/GroupInfoPanel';
import { LongPressMenu, MenuPosition, GroupMessage } from './ChatScreen';
const { width: screenWidth } = Dimensions.get('window');
const PANEL_WIDTH = 360;
interface EmbeddedChatProps {
conversation: ConversationResponse;
onBack: () => void;
}
const { width: screenWidth } = Dimensions.get('window');
export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack }) => {
const navigation = useNavigation();
const currentUser = useAuthStore(state => state.currentUser);
// 响应式布局 - 使用 768px 作为大屏幕和小屏幕的分界线
const isWideScreen = useBreakpointGTE('lg');
// 使用 markAsRead hook
const { markAsRead } = useMarkAsRead(String(conversation.id));
// 状态
const [messages, setMessages] = useState<MessageResponse[]>([]);
const [loading, setLoading] = useState(true);
@@ -42,6 +56,61 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
const flatListRef = useRef<FlatList>(null);
const inputRef = useRef<TextInput>(null);
// 图片预览状态
const [imageGalleryVisible, setImageGalleryVisible] = useState(false);
const [currentImageIndex, setCurrentImageIndex] = useState(0);
const [galleryImages, setGalleryImages] = useState<{ url: string }[]>([]);
// 右键菜单状态
const [contextMenuVisible, setContextMenuVisible] = useState(false);
const [contextMenuPosition, setContextMenuPosition] = useState<MenuPosition | undefined>();
const [selectedMessageForMenu, setSelectedMessageForMenu] = useState<GroupMessage | null>(null);
// 回复状态
const [replyingToMessage, setReplyingToMessage] = useState<GroupMessage | null>(null);
// 群信息面板动画
const slideAnim = useRef(new Animated.Value(PANEL_WIDTH)).current;
const fadeAnim = useRef(new Animated.Value(0)).current;
const [showGroupInfoPanel, setShowGroupInfoPanel] = useState(false);
// 群成员列表
const [groupMembers, setGroupMembers] = useState<GroupMemberResponse[]>([]);
// 打开/关闭群信息面板
const toggleGroupInfoPanel = useCallback(() => {
if (showGroupInfoPanel) {
Animated.parallel([
Animated.timing(slideAnim, {
toValue: PANEL_WIDTH,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(fadeAnim, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}),
]).start(() => {
setShowGroupInfoPanel(false);
});
} else {
setShowGroupInfoPanel(true);
Animated.parallel([
Animated.timing(slideAnim, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(fadeAnim, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
]).start();
}
}, [showGroupInfoPanel, slideAnim, fadeAnim]);
// 获取会话信息
const isGroupChat = conversation.type === 'group';
const chatTitle = isGroupChat
@@ -88,6 +157,18 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
};
}, [conversation.id, loadMessages]);
// 自动标记已读 - 当有新消息时
useEffect(() => {
if (messages.length === 0) return;
const maxSeq = Math.max(...messages.map(m => m.seq), 0);
if (maxSeq > 0) {
markAsRead(maxSeq).catch(error => {
console.error('[EmbeddedChat] 自动标记已读失败:', error);
});
}
}, [messages, markAsRead]);
// 发送消息
const handleSend = useCallback(async () => {
if (!inputText.trim() || sending) return;
@@ -127,32 +208,193 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
}
};
// 处理图片点击预览
const handleImagePress = (images: { url: string }[], index: number) => {
setGalleryImages(images);
setCurrentImageIndex(index);
setImageGalleryVisible(true);
};
// 处理右键点击消息
const handleMessageRightPress = (message: MessageResponse, event: any) => {
// 获取鼠标右键位置
const pageX = event?.nativeEvent?.pageX || 0;
const pageY = event?.nativeEvent?.pageY || 0;
setSelectedMessageForMenu(message as GroupMessage);
setContextMenuPosition({
x: pageX,
y: pageY,
width: 0,
height: 0,
pressX: pageX,
pressY: pageY,
});
setContextMenuVisible(true);
};
// 关闭右键菜单
const handleContextMenuClose = () => {
setContextMenuVisible(false);
setSelectedMessageForMenu(null);
setContextMenuPosition(undefined);
};
// 处理回复消息
const handleReply = (message: GroupMessage) => {
setReplyingToMessage(message);
handleContextMenuClose();
};
// 处理撤回消息
const handleRecall = async (messageId: string) => {
try {
await messageService.recallMessage(messageId);
handleContextMenuClose();
} catch (error) {
console.error('[EmbeddedChat] 撤回消息失败:', error);
}
};
// 处理删除消息
const handleDeleteMessage = async (messageId: string) => {
try {
await messageService.deleteMessage(messageId);
handleContextMenuClose();
} catch (error) {
console.error('[EmbeddedChat] 删除消息失败:', error);
}
};
// 渲染消息内容
const renderMessageContent = (item: MessageResponse) => {
// 撤回消息
if (item.status === 'recalled') {
return (
<Text style={[styles.messageText, styles.messageTextRecalled]}>
</Text>
);
}
// 获取消息 segments
const segments = item.segments || [];
// 检查是否有图片消息
const imageSegments = segments
.filter(s => s.type === 'image')
.map(s => s.data as ImageSegmentData);
// 提取文本内容
const textContent = extractTextFromSegments(segments);
// 渲染多个内容(文本 + 图片)
return (
<View>
{/* 文本内容 */}
{textContent && textContent !== '[图片]' && (
<Text style={[styles.messageText, item.sender_id === String(currentUser?.id) ? styles.messageTextMe : styles.messageTextOther]}>
{textContent}
</Text>
)}
{/* 图片内容 */}
{imageSegments.length > 0 && (
<View style={styles.imageGrid}>
{imageSegments.map((imgData, idx) => {
const imageUrl = imgData.thumbnail_url || imgData.url;
const hasValidUrl = !!imageUrl && imageUrl.trim() !== '';
if (!hasValidUrl) {
// 无效URL显示占位符
return (
<View key={`img-${item.id}-${idx}`} style={styles.imagePlaceholder}>
<MaterialCommunityIcons name="image" size={24} color="#999" />
<Text style={styles.imagePlaceholderText}></Text>
</View>
);
}
// 计算图片尺寸
let width = 180;
let height = 180;
if (imgData.width && imgData.height) {
const aspectRatio = imgData.width / imgData.height;
if (aspectRatio > 1) {
height = width / aspectRatio;
} else {
width = height * aspectRatio;
}
}
return (
<TouchableOpacity
key={`img-${item.id}-${idx}`}
onPress={() => handleImagePress(imageSegments.map(s => ({ url: s.thumbnail_url || s.url || '' })), idx)}
activeOpacity={0.8}
>
<ExpoImage
source={{ uri: imageUrl }}
style={[styles.messageImage, { width, height: Math.round(height) }]}
contentFit="cover"
cachePolicy="disk"
/>
</TouchableOpacity>
);
})}
</View>
)}
{/* 如果没有文本也没有图片,显示空消息提示 */}
{!textContent && imageSegments.length === 0 && (
<Text style={[styles.messageText, styles.messageTextRecalled, { opacity: 0.5 }]}>
[]
</Text>
)}
</View>
);
};
// 渲染消息
const renderMessage = ({ item }: { item: MessageResponse }) => {
const isMe = String(item.sender_id) === String(currentUser?.id);
// 处理指针事件(鼠标右键)- 使用 onPointerDown 检测鼠标右键
const handlePointerDown = (e: any) => {
// e.nativeEvent.button: 0=左键, 1=中键, 2=右键
const isRightClick = e.nativeEvent?.button === 2;
if (isRightClick) {
// 阻止默认右键菜单
e.preventDefault?.();
e.stopPropagation?.();
handleMessageRightPress(item, e);
}
};
return (
<View style={[styles.messageRow, isMe ? styles.messageRowRight : styles.messageRowLeft]}>
<View
style={[styles.messageRow, isMe ? styles.messageRowRight : styles.messageRowLeft]}
// @ts-ignore - React Native for Web 支持 pointer events
onPointerDown={handlePointerDown}
>
{!isMe && (
<Avatar
source={item.sender?.avatar}
size={36}
name={item.sender?.nickname || item.sender?.username}
<Avatar
source={item.sender?.avatar}
size={36}
name={item.sender?.nickname || item.sender?.username}
/>
)}
<View style={[styles.messageBubble, isMe ? styles.messageBubbleMe : styles.messageBubbleOther]}>
{isGroupChat && !isMe && (
<Text style={styles.senderName}>{item.sender?.nickname || item.sender?.username}</Text>
)}
<Text style={[styles.messageText, isMe ? styles.messageTextMe : styles.messageTextOther]}>
{item.status === 'recalled' ? '消息已撤回' : extractTextFromSegments(item.segments)}
</Text>
{renderMessageContent(item)}
</View>
{isMe && (
<Avatar
source={currentUser?.avatar}
size={36}
name={currentUser?.nickname || currentUser?.username}
<Avatar
source={currentUser?.avatar}
size={36}
name={currentUser?.nickname || currentUser?.username}
/>
)}
</View>
@@ -163,9 +405,14 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
<View style={styles.container}>
{/* 头部 */}
<View style={styles.header}>
<TouchableOpacity onPress={onBack} style={styles.headerButton}>
<MaterialCommunityIcons name="arrow-left" size={24} color="#666" />
</TouchableOpacity>
{/* 大屏幕(>= 768px时隐藏返回按钮 */}
{!isWideScreen ? (
<TouchableOpacity onPress={onBack} style={styles.headerButton}>
<MaterialCommunityIcons name="arrow-left" size={24} color="#666" />
</TouchableOpacity>
) : (
<View style={styles.headerButton} />
)}
<View style={styles.headerCenter}>
<Avatar source={chatAvatar} size={36} name={chatTitle} />
@@ -174,9 +421,16 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
</Text>
</View>
<TouchableOpacity onPress={handleNavigateToFullChat} style={styles.headerButton}>
<MaterialCommunityIcons name="arrow-expand" size={22} color="#666" />
</TouchableOpacity>
{/* 大屏幕 + 群聊时显示群信息按钮,否则显示放大按钮 */}
{isWideScreen && isGroupChat ? (
<TouchableOpacity onPress={toggleGroupInfoPanel} style={styles.headerButton}>
<MaterialCommunityIcons name="dots-horizontal" size={22} color="#666" />
</TouchableOpacity>
) : (
<TouchableOpacity onPress={handleNavigateToFullChat} style={styles.headerButton}>
<MaterialCommunityIcons name="arrow-expand" size={22} color="#666" />
</TouchableOpacity>
)}
</View>
{/* 消息列表 */}
@@ -231,6 +485,9 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
returnKeyType="send"
onSubmitEditing={handleSend}
blurOnSubmit={false}
// 确保光标可见
cursorColor={colors.primary.main}
selectionColor={`${colors.primary.main}40`}
/>
</View>
@@ -252,6 +509,96 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
</View>
</View>
</KeyboardAvoidingView>
{/* 群信息侧边栏面板 - 仅大屏幕显示 */}
{isWideScreen && isGroupChat && (
<>
{/* 遮罩层 */}
{showGroupInfoPanel && (
<Animated.View
style={[
StyleSheet.absoluteFillObject,
{
backgroundColor: 'rgba(0, 0, 0, 0.3)',
opacity: fadeAnim,
zIndex: 100,
},
]}
>
<TouchableOpacity
style={StyleSheet.absoluteFillObject}
onPress={toggleGroupInfoPanel}
activeOpacity={1}
/>
</Animated.View>
)}
{/* 侧边栏面板 */}
<Animated.View
style={[
{
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: PANEL_WIDTH,
backgroundColor: '#FFF',
zIndex: 101,
...shadows.lg,
transform: [{ translateX: slideAnim }],
},
]}
>
<GroupInfoPanel
visible={showGroupInfoPanel}
groupId={conversation.group ? String(conversation.group.id) : undefined}
groupInfo={conversation.group || null}
currentUserId={currentUser?.id}
onClose={toggleGroupInfoPanel}
onTogglePin={(pinned) => {
// TODO: 实现置顶功能
console.log('Toggle pin:', pinned);
}}
onLeaveGroup={() => {
// TODO: 实现退出群聊功能
console.log('Leave group');
}}
onInviteMembers={() => {
// TODO: 实现邀请新成员功能
console.log('Invite members');
}}
onViewAllMembers={() => {
// TODO: 实现查看全部成员功能
console.log('View all members');
}}
/>
</Animated.View>
</>
)}
{/* 右键菜单 */}
<LongPressMenu
visible={contextMenuVisible}
message={selectedMessageForMenu}
currentUserId={currentUser?.id || ''}
position={contextMenuPosition}
onClose={handleContextMenuClose}
onReply={handleReply}
onRecall={handleRecall}
onDelete={handleDeleteMessage}
/>
{/* 图片查看器 */}
<ImageGallery
visible={imageGalleryVisible}
images={galleryImages.map((img, idx) => ({
id: String(idx),
url: img.url || ''
}))}
initialIndex={currentImageIndex}
onClose={() => setImageGalleryVisible(false)}
enableSave
/>
</View>
);
};
@@ -328,7 +675,7 @@ const styles = StyleSheet.create({
},
messageRowRight: {
alignSelf: 'flex-end',
flexDirection: 'row-reverse',
justifyContent: 'flex-end',
},
messageBubble: {
maxWidth: screenWidth * 0.5,
@@ -387,6 +734,12 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md,
minHeight: 40,
justifyContent: 'center',
// 移除阴影
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
},
textInput: {
fontSize: 15,
@@ -406,4 +759,33 @@ const styles = StyleSheet.create({
sendButtonDisabled: {
backgroundColor: '#E0E0E0',
},
messageTextRecalled: {
fontStyle: 'italic',
color: '#999',
},
imageGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
marginTop: 4,
},
messageImage: {
borderRadius: 8,
marginRight: 4,
marginBottom: 4,
},
imagePlaceholder: {
width: 120,
height: 120,
borderRadius: 8,
backgroundColor: 'rgba(0,0,0,0.1)',
justifyContent: 'center',
alignItems: 'center',
marginRight: 4,
marginBottom: 4,
},
imagePlaceholderText: {
color: '#999',
fontSize: 12,
marginTop: 4,
},
});