Files
frontend/src/screens/message/components/EmbeddedChat.tsx
2026-03-16 17:47:10 +08:00

792 lines
24 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.
/**
* EmbeddedChat.tsx
* 嵌入式聊天组件 - 用于桌面端双栏布局右侧
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
View,
FlatList,
StyleSheet,
TouchableOpacity,
ActivityIndicator,
KeyboardAvoidingView,
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, 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;
}
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);
const [sending, setSending] = useState(false);
const [inputText, setInputText] = useState('');
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
? conversation.group?.name || '群聊'
: conversation.participants?.[0]?.nickname || conversation.participants?.[0]?.username || '聊天';
const chatAvatar = isGroupChat
? conversation.group?.avatar
: conversation.participants?.[0]?.avatar;
// 加载消息
const loadMessages = useCallback(async () => {
try {
setLoading(true);
await messageManager.fetchMessages(String(conversation.id));
setMessages(messageManager.getMessages(String(conversation.id)));
} catch (error) {
console.error('[EmbeddedChat] 加载消息失败:', error);
} finally {
setLoading(false);
}
}, [conversation.id]);
// 初始加载
useEffect(() => {
loadMessages();
// 订阅消息事件
const subscriber: MessageSubscriber = (event: MessageEvent) => {
if (
event.type === 'messages_updated' &&
String(event.payload?.conversationId) === String(conversation.id)
) {
setMessages(event.payload.messages || []);
setTimeout(() => {
flatListRef.current?.scrollToEnd({ animated: true });
}, 100);
}
};
const unsubscribe = messageManager.subscribe(subscriber);
return () => {
unsubscribe();
};
}, [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;
const text = inputText.trim();
setInputText('');
try {
setSending(true);
const segments: MessageSegment[] = [{ type: 'text', data: { text } }];
await messageManager.sendMessage(String(conversation.id), segments);
} catch (error) {
console.error('[EmbeddedChat] 发送消息失败:', error);
} finally {
setSending(false);
}
}, [inputText, sending, conversation.id]);
// 导航到完整聊天页面
const handleNavigateToFullChat = () => {
if (isGroupChat && conversation.group) {
(navigation as any).navigate('Chat', {
conversationId: String(conversation.id),
groupId: String(conversation.group.id),
groupName: conversation.group.name,
isGroupChat: true,
});
} else {
const currentUserId = currentUser?.id;
const otherUser = conversation.participants?.find(p => String(p.id) !== String(currentUserId));
(navigation as any).navigate('Chat', {
conversationId: String(conversation.id),
userId: otherUser ? String(otherUser.id) : undefined,
userName: otherUser?.nickname || otherUser?.username,
isGroupChat: false,
});
}
};
// 处理图片点击预览
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]}
// @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}
/>
)}
<View style={[styles.messageBubble, isMe ? styles.messageBubbleMe : styles.messageBubbleOther]}>
{isGroupChat && !isMe && (
<Text style={styles.senderName}>{item.sender?.nickname || item.sender?.username}</Text>
)}
{renderMessageContent(item)}
</View>
{isMe && (
<Avatar
source={currentUser?.avatar}
size={36}
name={currentUser?.nickname || currentUser?.username}
/>
)}
</View>
);
};
return (
<View style={styles.container}>
{/* 头部 */}
<View style={styles.header}>
{/* 大屏幕(>= 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} />
<Text style={styles.headerTitle} numberOfLines={1}>
{chatTitle}
</Text>
</View>
{/* 大屏幕 + 群聊时显示群信息按钮,否则显示放大按钮 */}
{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>
{/* 消息列表 */}
<View style={styles.messageList}>
{loading ? (
<View style={styles.centerContainer}>
<ActivityIndicator size="large" color={colors.primary.main} />
</View>
) : messages.length === 0 ? (
<View style={styles.centerContainer}>
<MaterialCommunityIcons name="message-text-outline" size={48} color="#DDD" />
<Text style={styles.emptyText}></Text>
<Text style={styles.emptySubtext}></Text>
</View>
) : (
<FlatList
ref={flatListRef}
data={messages}
renderItem={renderMessage}
keyExtractor={(item) => String(item.id)}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={true}
onLayout={() => {
if (messages.length > 0) {
flatListRef.current?.scrollToEnd({ animated: false });
}
}}
/>
)}
</View>
{/* 输入框区域 */}
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
>
<View style={styles.inputArea}>
<View style={styles.inputRow}>
<TouchableOpacity style={styles.iconButton}>
<MaterialCommunityIcons name="plus-circle-outline" size={28} color="#666" />
</TouchableOpacity>
<View style={styles.inputContainer}>
<TextInput
ref={inputRef}
style={styles.textInput}
placeholder="发送消息..."
placeholderTextColor="#999"
value={inputText}
onChangeText={setInputText}
multiline={false}
returnKeyType="send"
onSubmitEditing={handleSend}
blurOnSubmit={false}
// 确保光标可见
cursorColor={colors.primary.main}
selectionColor={`${colors.primary.main}40`}
/>
</View>
<TouchableOpacity style={styles.iconButton}>
<MaterialCommunityIcons name="emoticon-outline" size={28} color="#666" />
</TouchableOpacity>
<TouchableOpacity
style={[styles.sendButton, (!inputText.trim() || sending) && styles.sendButtonDisabled]}
onPress={handleSend}
disabled={!inputText.trim() || sending}
>
{sending ? (
<ActivityIndicator size="small" color="#FFF" />
) : (
<MaterialCommunityIcons name="send" size={20} color="#FFF" />
)}
</TouchableOpacity>
</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>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F5F7FA',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: '#FFF',
borderBottomWidth: 1,
borderBottomColor: '#E8E8E8',
...shadows.sm,
height: 56,
},
headerButton: {
padding: spacing.xs,
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
headerCenter: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
justifyContent: 'center',
},
headerTitle: {
fontSize: fontSizes.lg,
fontWeight: '600',
color: '#333',
marginLeft: spacing.sm,
maxWidth: 200,
},
messageList: {
flex: 1,
backgroundColor: '#F5F7FA',
},
centerContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: spacing.xl,
},
emptyText: {
marginTop: spacing.md,
fontSize: 16,
color: '#999',
},
emptySubtext: {
marginTop: spacing.xs,
fontSize: 14,
color: '#BBB',
},
listContent: {
padding: spacing.md,
paddingBottom: spacing.xl,
},
messageRow: {
flexDirection: 'row',
alignItems: 'flex-end',
marginBottom: spacing.md,
maxWidth: screenWidth * 0.7,
},
messageRowLeft: {
alignSelf: 'flex-start',
},
messageRowRight: {
alignSelf: 'flex-end',
justifyContent: 'flex-end',
},
messageBubble: {
maxWidth: screenWidth * 0.5,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: 18,
marginHorizontal: spacing.sm,
},
messageBubbleMe: {
backgroundColor: colors.primary.main,
borderBottomRightRadius: 4,
},
messageBubbleOther: {
backgroundColor: '#FFF',
borderBottomLeftRadius: 4,
...shadows.sm,
},
senderName: {
fontSize: 12,
color: '#999',
marginBottom: 2,
},
messageText: {
fontSize: 15,
lineHeight: 20,
},
messageTextMe: {
color: '#FFF',
},
messageTextOther: {
color: '#333',
},
inputArea: {
backgroundColor: '#FFF',
borderTopWidth: 1,
borderTopColor: '#E8E8E8',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
paddingBottom: Platform.OS === 'ios' ? spacing.md : spacing.sm,
},
inputRow: {
flexDirection: 'row',
alignItems: 'center',
},
iconButton: {
padding: spacing.xs,
width: 44,
height: 44,
alignItems: 'center',
justifyContent: 'center',
},
inputContainer: {
flex: 1,
backgroundColor: '#F5F5F5',
borderRadius: 20,
paddingHorizontal: spacing.md,
minHeight: 40,
justifyContent: 'center',
// 移除阴影
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
},
textInput: {
fontSize: 15,
color: '#333',
padding: 0,
maxHeight: 100,
},
sendButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
marginLeft: spacing.xs,
},
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,
},
});