12.4 消息组件与交互
12.4.1 消息显示组件
// src/components/MessageList/MessageList.tsx - 消息列表组件
import React, { useEffect, useRef, useCallback } from 'react';
import {
Box,
VStack,
Spinner,
Text,
useColorModeValue
} from '@chakra-ui/react';
import { useChatStore } from '../../stores/chatStore';
import { MessageItem } from './MessageItem';
import { TypingIndicator } from './TypingIndicator';
import { useInfiniteScroll } from '../../hooks/useInfiniteScroll';
interface MessageListProps {
chatId: string;
}
export const MessageList: React.FC<MessageListProps> = ({ chatId }) => {
const {
messages,
typingIndicators,
isLoading,
loadMessages
} = useChatStore();
const messagesEndRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const bgColor = useColorModeValue('gray.50', 'gray.900');
const chatMessages = messages[chatId] || [];
const chatTypingIndicators = typingIndicators[chatId] || [];
// 滚动到底部
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, []);
// 加载更多消息
const loadMoreMessages = useCallback(async () => {
if (chatMessages.length > 0) {
const page = Math.ceil(chatMessages.length / 50) + 1;
await loadMessages(chatId, page);
}
}, [chatId, chatMessages.length, loadMessages]);
// 无限滚动
const { isLoadingMore } = useInfiniteScroll({
containerRef,
onLoadMore: loadMoreMessages,
threshold: 100,
direction: 'up'
});
// 初始加载消息
useEffect(() => {
if (chatId) {
loadMessages(chatId);
}
}, [chatId, loadMessages]);
// 新消息时滚动到底部
useEffect(() => {
if (chatMessages.length > 0) {
const lastMessage = chatMessages[chatMessages.length - 1];
const isRecentMessage = Date.now() - new Date(lastMessage.createdAt).getTime() < 5000;
if (isRecentMessage) {
setTimeout(scrollToBottom, 100);
}
}
}, [chatMessages.length, scrollToBottom]);
if (isLoading && chatMessages.length === 0) {
return (
<Box
flex={1}
display="flex"
alignItems="center"
justifyContent="center"
bg={bgColor}
>
<VStack spacing={3}>
<Spinner size="lg" />
<Text>Loading messages...</Text>
</VStack>
</Box>
);
}
return (
<Box
ref={containerRef}
flex={1}
bg={bgColor}
overflowY="auto"
p={4}
>
{/* 加载更多指示器 */}
{isLoadingMore && (
<Box textAlign="center" py={4}>
<Spinner size="sm" />
<Text fontSize="sm" color="gray.500" mt={2}>
Loading more messages...
</Text>
</Box>
)}
{/* 消息列表 */}
<VStack spacing={3} align="stretch">
{chatMessages.length === 0 ? (
<Box textAlign="center" py={8}>
<Text color="gray.500">
No messages yet. Start the conversation!
</Text>
</Box>
) : (
chatMessages.map((message, index) => {
const prevMessage = index > 0 ? chatMessages[index - 1] : null;
const showAvatar = !prevMessage ||
prevMessage.senderId !== message.senderId ||
new Date(message.createdAt).getTime() - new Date(prevMessage.createdAt).getTime() > 300000; // 5 minutes
return (
<MessageItem
key={message.id}
message={message}
showAvatar={showAvatar}
isGrouped={!showAvatar}
/>
);
})
)}
{/* 输入指示器 */}
{chatTypingIndicators.map(indicator => (
<TypingIndicator
key={indicator.userId}
user={indicator.user}
/>
))}
</VStack>
{/* 滚动锚点 */}
<div ref={messagesEndRef} />
</Box>
);
};
// src/components/MessageList/MessageItem.tsx - 消息项组件
import React, { useState } from 'react';
import {
Box,
HStack,
VStack,
Text,
Avatar,
IconButton,
Menu,
MenuButton,
MenuList,
MenuItem,
useColorModeValue,
Tooltip
} from '@chakra-ui/react';
import { ChevronDownIcon, EditIcon, DeleteIcon, CopyIcon } from '@chakra-ui/icons';
import { Message, MessageType } from '../../types/models';
import { useUserStore } from '../../stores/userStore';
import { useChatStore } from '../../stores/chatStore';
import { MessageReactions } from './MessageReactions';
import { FileAttachment } from './FileAttachment';
import { formatDistanceToNow } from 'date-fns';
interface MessageItemProps {
message: Message;
showAvatar: boolean;
isGrouped: boolean;
}
export const MessageItem: React.FC<MessageItemProps> = ({
message,
showAvatar,
isGrouped
}) => {
const { currentUser } = useUserStore();
const { editMessage, deleteMessage, reactToMessage } = useChatStore();
const [isEditing, setIsEditing] = useState(false);
const [editContent, setEditContent] = useState(message.content.text || '');
const isOwnMessage = message.senderId === currentUser?.id;
const bgColor = useColorModeValue(
isOwnMessage ? 'blue.500' : 'white',
isOwnMessage ? 'blue.600' : 'gray.700'
);
const textColor = useColorModeValue(
isOwnMessage ? 'white' : 'gray.800',
isOwnMessage ? 'white' : 'gray.100'
);
const handleEdit = async () => {
if (editContent.trim() && editContent !== message.content.text) {
await editMessage(message.id, editContent.trim());
}
setIsEditing(false);
};
const handleDelete = async () => {
await deleteMessage(message.id);
};
const handleReaction = (emoji: string) => {
reactToMessage(message.id, emoji);
};
const handleCopy = () => {
if (message.content.text) {
navigator.clipboard.writeText(message.content.text);
}
};
const renderMessageContent = () => {
switch (message.type) {
case MessageType.TEXT:
return (
<Text fontSize="sm" whiteSpace="pre-wrap">
{message.content.text}
</Text>
);
case MessageType.IMAGE:
case MessageType.FILE:
case MessageType.AUDIO:
case MessageType.VIDEO:
return (
<FileAttachment
file={message.content.file!}
type={message.type}
/>
);
case MessageType.SYSTEM:
return (
<Text fontSize="sm" fontStyle="italic" color="gray.500">
{message.content.system?.data.text || 'System message'}
</Text>
);
default:
return null;
}
};
if (message.type === MessageType.SYSTEM) {
return (
<Box textAlign="center" py={2}>
<Text fontSize="sm" color="gray.500" fontStyle="italic">
{renderMessageContent()}
</Text>
</Box>
);
}
return (
<HStack
align="start"
spacing={3}
justify={isOwnMessage ? 'flex-end' : 'flex-start'}
pl={isGrouped && !isOwnMessage ? 12 : 0}
pr={isGrouped && isOwnMessage ? 12 : 0}
>
{/* 头像 */}
{showAvatar && !isOwnMessage && (
<Avatar
size="sm"
src={message.sender.avatar}
name={message.sender.displayName}
/>
)}
{/* 消息内容 */}
<VStack align={isOwnMessage ? 'flex-end' : 'flex-start'} spacing={1} maxW="70%">
{/* 发送者信息 */}
{showAvatar && (
<HStack spacing={2}>
<Text fontSize="xs" fontWeight="semibold" color="gray.600">
{isOwnMessage ? 'You' : message.sender.displayName}
</Text>
<Text fontSize="xs" color="gray.500">
{formatDistanceToNow(new Date(message.createdAt), { addSuffix: true })}
</Text>
</HStack>
)}
{/* 消息气泡 */}
<Box position="relative" group>
<Box
bg={bgColor}
color={textColor}
px={3}
py={2}
borderRadius="lg"
borderTopLeftRadius={isOwnMessage || !showAvatar ? 'lg' : 'sm'}
borderTopRightRadius={!isOwnMessage || !showAvatar ? 'lg' : 'sm'}
maxW="100%"
wordBreak="break-word"
>
{isEditing ? (
<VStack spacing={2}>
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleEdit();
}
if (e.key === 'Escape') {
setIsEditing(false);
setEditContent(message.content.text || '');
}
}}
style={{
width: '100%',
minHeight: '60px',
background: 'transparent',
border: 'none',
outline: 'none',
color: 'inherit',
fontSize: '14px',
resize: 'vertical'
}}
/>
<HStack spacing={2}>
<button onClick={handleEdit}>Save</button>
<button onClick={() => setIsEditing(false)}>Cancel</button>
</HStack>
</VStack>
) : (
renderMessageContent()
)}
{message.isEdited && (
<Text fontSize="xs" color="gray.400" mt={1}>
(edited)
</Text>
)}
</Box>
{/* 消息操作菜单 */}
{isOwnMessage && !isEditing && (
<Box
position="absolute"
top={0}
right={-8}
opacity={0}
_groupHover={{ opacity: 1 }}
transition="opacity 0.2s"
>
<Menu>
<MenuButton
as={IconButton}
icon={<ChevronDownIcon />}
size="xs"
variant="ghost"
colorScheme="gray"
/>
<MenuList>
{message.type === MessageType.TEXT && (
<MenuItem icon={<EditIcon />} onClick={() => setIsEditing(true)}>
Edit
</MenuItem>
)}
<MenuItem icon={<CopyIcon />} onClick={handleCopy}>
Copy
</MenuItem>
<MenuItem icon={<DeleteIcon />} onClick={handleDelete} color="red.500">
Delete
</MenuItem>
</MenuList>
</Menu>
</Box>
)}
</Box>
{/* 消息反应 */}
{message.reactions.length > 0 && (
<MessageReactions
reactions={message.reactions}
onReact={handleReaction}
/>
)}
</VStack>
{/* 自己消息的头像 */}
{showAvatar && isOwnMessage && (
<Avatar
size="sm"
src={message.sender.avatar}
name={message.sender.displayName}
/>
)}
</HStack>
);
};
12.4.2 消息输入组件
// src/components/MessageInput/MessageInput.tsx - 消息输入组件
import React, { useState, useRef, useCallback } from 'react';
import {
Box,
HStack,
VStack,
Textarea,
IconButton,
Button,
useColorModeValue,
Tooltip,
Progress
} from '@chakra-ui/react';
import {
AttachmentIcon,
ArrowUpIcon
} from '@chakra-ui/icons';
import { useChatStore } from '../../stores/chatStore';
import { socketService } from '../../services/socket';
import { MessageType } from '../../types/models';
import { FileUpload } from './FileUpload';
import { EmojiPicker } from './EmojiPicker';
import { useTypingIndicator } from '../../hooks/useTypingIndicator';
interface MessageInputProps {
chatId: string;
}
export const MessageInput: React.FC<MessageInputProps> = ({ chatId }) => {
const { sendMessage } = useChatStore();
const [message, setMessage] = useState('');
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [showFileUpload, setShowFileUpload] = useState(false);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const bgColor = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
// 输入指示器
useTypingIndicator(chatId, message.length > 0);
// 发送消息
const handleSendMessage = useCallback(async () => {
const trimmedMessage = message.trim();
if (!trimmedMessage || isUploading) return;
try {
await sendMessage(chatId, { text: trimmedMessage }, MessageType.TEXT);
setMessage('');
// 重置文本框高度
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
} catch (error) {
console.error('Failed to send message:', error);
}
}, [chatId, message, isUploading, sendMessage]);
// 处理键盘事件
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
// 自动调整文本框高度
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
setMessage(value);
// 自动调整高度
const textarea = e.target;
textarea.style.height = 'auto';
const scrollHeight = textarea.scrollHeight;
const maxHeight = 120; // 最大高度
textarea.style.height = `${Math.min(scrollHeight, maxHeight)}px`;
};
// 文件上传
const handleFileUpload = async (files: FileList) => {
if (files.length === 0) return;
setIsUploading(true);
setUploadProgress(0);
try {
for (const file of Array.from(files)) {
// 模拟上传进度
const uploadPromise = new Promise<string>((resolve) => {
let progress = 0;
const interval = setInterval(() => {
progress += Math.random() * 30;
if (progress >= 100) {
clearInterval(interval);
setUploadProgress(100);
resolve(`/uploads/${file.name}`);
} else {
setUploadProgress(progress);
}
}, 200);
});
const fileUrl = await uploadPromise;
// 发送文件消息
const fileAttachment = {
id: Date.now().toString(),
name: file.name,
size: file.size,
mimeType: file.type,
url: fileUrl
};
let messageType = MessageType.FILE;
if (file.type.startsWith('image/')) {
messageType = MessageType.IMAGE;
} else if (file.type.startsWith('audio/')) {
messageType = MessageType.AUDIO;
} else if (file.type.startsWith('video/')) {
messageType = MessageType.VIDEO;
}
await sendMessage(chatId, { file: fileAttachment }, messageType);
}
} catch (error) {
console.error('File upload failed:', error);
} finally {
setIsUploading(false);
setUploadProgress(0);
setShowFileUpload(false);
}
};
// 添加表情
const handleEmojiSelect = (emoji: string) => {
setMessage(prev => prev + emoji);
setShowEmojiPicker(false);
textareaRef.current?.focus();
};
return (
<Box
bg={bgColor}
borderTop="1px"
borderColor={borderColor}
p={4}
>
{/* 上传进度 */}
{isUploading && (
<Box mb={3}>
<Progress value={uploadProgress} size="sm" colorScheme="blue" />
</Box>
)}
{/* 文件上传区域 */}
{showFileUpload && (
<FileUpload
onFileSelect={handleFileUpload}
onClose={() => setShowFileUpload(false)}
/>
)}
{/* 表情选择器 */}
{showEmojiPicker && (
<Box mb={3}>
<EmojiPicker
onEmojiSelect={handleEmojiSelect}
onClose={() => setShowEmojiPicker(false)}
/>
</Box>
)}
{/* 输入区域 */}
<HStack spacing={3} align="end">
{/* 附件按钮 */}
<Tooltip label="Attach file">
<IconButton
icon={<AttachmentIcon />}
variant="ghost"
colorScheme="gray"
onClick={() => setShowFileUpload(!showFileUpload)}
aria-label="Attach file"
/>
</Tooltip>
{/* 消息输入框 */}
<Box flex={1} position="relative">
<Textarea
ref={textareaRef}
value={message}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
resize="none"
minH="40px"
maxH="120px"
border="1px"
borderColor={borderColor}
_focus={{
borderColor: 'blue.500',
boxShadow: '0 0 0 1px blue.500'
}}
disabled={isUploading}
/>
{/* 表情按钮 */}
<IconButton
position="absolute"
right={2}
bottom={2}
size="sm"
variant="ghost"
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
aria-label="Add emoji"
>
😊
</IconButton>
</Box>
{/* 发送按钮 */}
<Tooltip label="Send message">
<IconButton
icon={<ArrowUpIcon />}
colorScheme="blue"
onClick={handleSendMessage}
disabled={!message.trim() || isUploading}
aria-label="Send message"
/>
</Tooltip>
</HStack>
{/* 隐藏的文件输入 */}
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={(e) => {
if (e.target.files) {
handleFileUpload(e.target.files);
}
}}
/>
</Box>
);
};
12.5 高级功能实现
12.5.1 文件分享功能
// src/components/FileUpload/FileUpload.tsx - 文件上传组件
import React, { useCallback } from 'react';
import {
Box,
VStack,
HStack,
Text,
Button,
IconButton,
useColorModeValue,
Alert,
AlertIcon
} from '@chakra-ui/react';
import { CloseIcon, AttachmentIcon } from '@chakra-ui/icons';
import { useDropzone } from 'react-dropzone';
interface FileUploadProps {
onFileSelect: (files: FileList) => void;
onClose: () => void;
maxSize?: number; // bytes
acceptedTypes?: string[];
}
export const FileUpload: React.FC<FileUploadProps> = ({
onFileSelect,
onClose,
maxSize = 10 * 1024 * 1024, // 10MB
acceptedTypes = ['image/*', 'video/*', 'audio/*', '.pdf', '.doc', '.docx', '.txt']
}) => {
const bgColor = useColorModeValue('gray.50', 'gray.700');
const borderColor = useColorModeValue('gray.300', 'gray.600');
const onDrop = useCallback((acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
const fileList = new DataTransfer();
acceptedFiles.forEach(file => fileList.items.add(file));
onFileSelect(fileList.files);
}
}, [onFileSelect]);
const {
getRootProps,
getInputProps,
isDragActive,
fileRejections
} = useDropzone({
onDrop,
maxSize,
accept: acceptedTypes.reduce((acc, type) => {
acc[type] = [];
return acc;
}, {} as Record<string, string[]>)
});
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
return (
<Box
bg={bgColor}
border="1px"
borderColor={borderColor}
borderRadius="md"
p={4}
mb={3}
>
<HStack justify="space-between" mb={3}>
<Text fontWeight="semibold">Upload Files</Text>
<IconButton
icon={<CloseIcon />}
size="sm"
variant="ghost"
onClick={onClose}
aria-label="Close"
/>
</HStack>
{/* 拖拽区域 */}
<Box
{...getRootProps()}
border="2px"
borderStyle="dashed"
borderColor={isDragActive ? 'blue.500' : 'gray.300'}
borderRadius="md"
p={8}
textAlign="center"
cursor="pointer"
transition="all 0.2s"
_hover={{
borderColor: 'blue.500',
bg: useColorModeValue('blue.50', 'blue.900')
}}
>
<input {...getInputProps()} />
<VStack spacing={3}>
<AttachmentIcon boxSize={8} color="gray.400" />
<VStack spacing={1}>
<Text fontWeight="semibold">
{isDragActive ? 'Drop files here' : 'Drag & drop files here'}
</Text>
<Text fontSize="sm" color="gray.500">
or click to select files
</Text>
</VStack>
<Text fontSize="xs" color="gray.400">
Max file size: {formatFileSize(maxSize)}
</Text>
</VStack>
</Box>
{/* 错误信息 */}
{fileRejections.length > 0 && (
<Alert status="error" mt={3}>
<AlertIcon />
<VStack align="start" spacing={1}>
{fileRejections.map(({ file, errors }) => (
<Text key={file.name} fontSize="sm">
{file.name}: {errors.map(e => e.message).join(', ')}
</Text>
))}
</VStack>
</Alert>
)}
{/* 支持的文件类型 */}
<Text fontSize="xs" color="gray.500" mt={3}>
Supported: Images, Videos, Audio, PDF, Documents
</Text>
</Box>
);
};
// src/components/FileAttachment/FileAttachment.tsx - 文件附件组件
import React from 'react';
import {
Box,
HStack,
VStack,
Text,
Image,
IconButton,
useColorModeValue
} from '@chakra-ui/react';
import { DownloadIcon, ExternalLinkIcon } from '@chakra-ui/icons';
import { FileAttachment as FileAttachmentType, MessageType } from '../../types/models';
interface FileAttachmentProps {
file: FileAttachmentType;
type: MessageType;
}
export const FileAttachment: React.FC<FileAttachmentProps> = ({ file, type }) => {
const bgColor = useColorModeValue('gray.100', 'gray.600');
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const handleDownload = () => {
const link = document.createElement('a');
link.href = file.url;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const handleOpen = () => {
window.open(file.url, '_blank');
};
// 图片附件
if (type === MessageType.IMAGE) {
return (
<Box maxW="300px">
<Image
src={file.url}
alt={file.name}
borderRadius="md"
cursor="pointer"
onClick={handleOpen}
fallback={
<Box
w="300px"
h="200px"
bg={bgColor}
display="flex"
alignItems="center"
justifyContent="center"
borderRadius="md"
>
<Text>Failed to load image</Text>
</Box>
}
/>
<HStack justify="space-between" mt={2}>
<Text fontSize="xs" color="gray.500" noOfLines={1}>
{file.name}
</Text>
<HStack spacing={1}>
<IconButton
icon={<ExternalLinkIcon />}
size="xs"
variant="ghost"
onClick={handleOpen}
aria-label="Open"
/>
<IconButton
icon={<DownloadIcon />}
size="xs"
variant="ghost"
onClick={handleDownload}
aria-label="Download"
/>
</HStack>
</HStack>
</Box>
);
}
// 视频附件
if (type === MessageType.VIDEO) {
return (
<Box maxW="400px">
<video
controls
style={{
width: '100%',
maxHeight: '300px',
borderRadius: '8px'
}}
>
<source src={file.url} type={file.mimeType} />
Your browser does not support the video tag.
</video>
<HStack justify="space-between" mt={2}>
<VStack align="start" spacing={0}>
<Text fontSize="sm" fontWeight="semibold" noOfLines={1}>
{file.name}
</Text>
<Text fontSize="xs" color="gray.500">
{formatFileSize(file.size)}
{file.duration && ` • ${Math.floor(file.duration / 60)}:${(file.duration % 60).toString().padStart(2, '0')}`}
</Text>
</VStack>
<IconButton
icon={<DownloadIcon />}
size="sm"
variant="ghost"
onClick={handleDownload}
aria-label="Download"
/>
</HStack>
</Box>
);
}
// 音频附件
if (type === MessageType.AUDIO) {
return (
<Box maxW="300px">
<audio controls style={{ width: '100%' }}>
<source src={file.url} type={file.mimeType} />
Your browser does not support the audio tag.
</audio>
<HStack justify="space-between" mt={2}>
<VStack align="start" spacing={0}>
<Text fontSize="sm" fontWeight="semibold" noOfLines={1}>
{file.name}
</Text>
<Text fontSize="xs" color="gray.500">
{formatFileSize(file.size)}
{file.duration && ` • ${Math.floor(file.duration / 60)}:${(file.duration % 60).toString().padStart(2, '0')}`}
</Text>
</VStack>
<IconButton
icon={<DownloadIcon />}
size="sm"
variant="ghost"
onClick={handleDownload}
aria-label="Download"
/>
</HStack>
</Box>
);
}
// 普通文件附件
return (
<Box
bg={bgColor}
p={3}
borderRadius="md"
maxW="300px"
>
<HStack spacing={3}>
<Box
w={10}
h={10}
bg="blue.500"
borderRadius="md"
display="flex"
alignItems="center"
justifyContent="center"
color="white"
fontSize="xs"
fontWeight="bold"
>
{file.name.split('.').pop()?.toUpperCase() || 'FILE'}
</Box>
<VStack align="start" spacing={0} flex={1} minW={0}>
<Text fontSize="sm" fontWeight="semibold" noOfLines={1}>
{file.name}
</Text>
<Text fontSize="xs" color="gray.500">
{formatFileSize(file.size)}
</Text>
</VStack>
<VStack spacing={1}>
<IconButton
icon={<ExternalLinkIcon />}
size="xs"
variant="ghost"
onClick={handleOpen}
aria-label="Open"
/>
<IconButton
icon={<DownloadIcon />}
size="xs"
variant="ghost"
onClick={handleDownload}
aria-label="Download"
/>
</VStack>
</HStack>
</Box>
);
};
12.5.2 消息搜索功能
// src/components/MessageSearch/MessageSearch.tsx - 消息搜索组件
import React, { useState, useEffect, useMemo } from 'react';
import {
Box,
VStack,
HStack,
Input,
InputGroup,
InputLeftElement,
Text,
Spinner,
Badge,
useColorModeValue,
Divider
} from '@chakra-ui/react';
import { SearchIcon } from '@chakra-ui/icons';
import { useChatStore } from '../../stores/chatStore';
import { useUserStore } from '../../stores/userStore';
import { Message, MessageType } from '../../types/models';
import { SearchResult } from './SearchResult';
import { useDebounce } from '../../hooks/useDebounce';
interface MessageSearchProps {
isOpen: boolean;
onClose: () => void;
}
export const MessageSearch: React.FC<MessageSearchProps> = ({ isOpen, onClose }) => {
const { messages, chats } = useChatStore();
const { users } = useUserStore();
const [query, setQuery] = useState('');
const [isSearching, setIsSearching] = useState(false);
const [searchResults, setSearchResults] = useState<SearchResultItem[]>([]);
const debouncedQuery = useDebounce(query, 300);
const bgColor = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
interface SearchResultItem {
message: Message;
chatName: string;
matchType: 'content' | 'sender' | 'file';
highlightedContent: string;
}
// 搜索消息
const searchMessages = useMemo(() => {
if (!debouncedQuery.trim()) {
return [];
}
const results: SearchResultItem[] = [];
const searchTerm = debouncedQuery.toLowerCase();
// 遍历所有聊天的消息
Object.entries(messages).forEach(([chatId, chatMessages]) => {
const chat = chats.find(c => c.id === chatId);
if (!chat) return;
const chatName = chat.name ||
chat.participants
.filter(p => p.userId !== localStorage.getItem('userId'))
.map(p => users[p.userId]?.displayName || 'Unknown')
.join(', ');
chatMessages.forEach(message => {
if (message.isDeleted) return;
let matchType: SearchResultItem['matchType'] | null = null;
let highlightedContent = '';
// 搜索消息内容
if (message.type === MessageType.TEXT && message.content.text) {
const content = message.content.text.toLowerCase();
if (content.includes(searchTerm)) {
matchType = 'content';
highlightedContent = highlightText(message.content.text, debouncedQuery);
}
}
// 搜索文件名
if (message.content.file && message.content.file.name.toLowerCase().includes(searchTerm)) {
matchType = 'file';
highlightedContent = highlightText(message.content.file.name, debouncedQuery);
}
// 搜索发送者
const sender = users[message.senderId];
if (sender && (
sender.displayName.toLowerCase().includes(searchTerm) ||
sender.username.toLowerCase().includes(searchTerm)
)) {
matchType = 'sender';
highlightedContent = message.content.text || message.content.file?.name || 'File';
}
if (matchType) {
results.push({
message,
chatName,
matchType,
highlightedContent
});
}
});
});
// 按时间倒序排列
return results.sort((a, b) =>
new Date(b.message.createdAt).getTime() - new Date(a.message.createdAt).getTime()
);
}, [debouncedQuery, messages, chats, users]);
// 高亮文本
const highlightText = (text: string, highlight: string) => {
if (!highlight.trim()) return text;
const regex = new RegExp(`(${highlight.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
return text.replace(regex, '<mark>$1</mark>');
};
// 执行搜索
useEffect(() => {
if (debouncedQuery.trim()) {
setIsSearching(true);
// 模拟搜索延迟
const timer = setTimeout(() => {
setSearchResults(searchMessages);
setIsSearching(false);
}, 200);
return () => clearTimeout(timer);
} else {
setSearchResults([]);
setIsSearching(false);
}
}, [debouncedQuery, searchMessages]);
if (!isOpen) return null;
return (
<Box
position="fixed"
top={0}
left={0}
right={0}
bottom={0}
bg="blackAlpha.600"
zIndex={1000}
display="flex"
alignItems="center"
justifyContent="center"
onClick={onClose}
>
<Box
bg={bgColor}
borderRadius="lg"
border="1px"
borderColor={borderColor}
w="600px"
maxH="80vh"
onClick={(e) => e.stopPropagation()}
overflow="hidden"
>
{/* 搜索头部 */}
<Box p={4} borderBottom="1px" borderColor={borderColor}>
<InputGroup>
<InputLeftElement pointerEvents="none">
<SearchIcon color="gray.300" />
</InputLeftElement>
<Input
placeholder="Search messages..."
value={query}
onChange={(e) => setQuery(e.target.value)}
autoFocus
/>
</InputGroup>
</Box>
{/* 搜索结果 */}
<Box maxH="60vh" overflowY="auto">
{isSearching ? (
<Box p={8} textAlign="center">
<Spinner size="lg" />
<Text mt={2}>Searching...</Text>
</Box>
) : query.trim() && searchResults.length === 0 ? (
<Box p={8} textAlign="center">
<Text color="gray.500">No messages found</Text>
</Box>
) : searchResults.length > 0 ? (
<VStack spacing={0} align="stretch">
<Box p={3} bg={useColorModeValue('gray.50', 'gray.700')}>
<Text fontSize="sm" color="gray.600">
{searchResults.length} result{searchResults.length !== 1 ? 's' : ''} found
</Text>
</Box>
{searchResults.map((result, index) => (
<React.Fragment key={`${result.message.id}-${index}`}>
<SearchResult
result={result}
query={debouncedQuery}
onSelect={() => {
// 跳转到消息
onClose();
// 这里可以添加跳转到具体消息的逻辑
}}
/>
{index < searchResults.length - 1 && <Divider />}
</React.Fragment>
))}
</VStack>
) : (
<Box p={8} textAlign="center">
<Text color="gray.500">
Type to search messages, files, and people
</Text>
</Box>
)}
</Box>
</Box>
</Box>
);
};
// src/components/MessageSearch/SearchResult.tsx - 搜索结果项
import React from 'react';
import {
Box,
HStack,
VStack,
Text,
Avatar,
Badge,
useColorModeValue
} from '@chakra-ui/react';
import { formatDistanceToNow } from 'date-fns';
interface SearchResultProps {
result: {
message: Message;
chatName: string;
matchType: 'content' | 'sender' | 'file';
highlightedContent: string;
};
query: string;
onSelect: () => void;
}
export const SearchResult: React.FC<SearchResultProps> = ({
result,
query,
onSelect
}) => {
const { message, chatName, matchType, highlightedContent } = result;
const hoverBgColor = useColorModeValue('gray.50', 'gray.700');
const getMatchTypeBadge = () => {
switch (matchType) {
case 'content':
return <Badge colorScheme="blue" size="sm">Message</Badge>;
case 'sender':
return <Badge colorScheme="green" size="sm">Sender</Badge>;
case 'file':
return <Badge colorScheme="purple" size="sm">File</Badge>;
default:
return null;
}
};
return (
<Box
p={3}
cursor="pointer"
_hover={{ bg: hoverBgColor }}
onClick={onSelect}
>
<HStack spacing={3} align="start">
<Avatar
size="sm"
src={message.sender.avatar}
name={message.sender.displayName}
/>
<VStack align="start" spacing={1} flex={1} minW={0}>
<HStack spacing={2} w="100%">
<Text fontSize="sm" fontWeight="semibold" noOfLines={1}>
{message.sender.displayName}
</Text>
<Text fontSize="xs" color="gray.500">
in {chatName}
</Text>
<Text fontSize="xs" color="gray.400">
{formatDistanceToNow(new Date(message.createdAt), { addSuffix: true })}
</Text>
{getMatchTypeBadge()}
</HStack>
<Text
fontSize="sm"
color="gray.600"
noOfLines={2}
dangerouslySetInnerHTML={{ __html: highlightedContent }}
sx={{
'mark': {
backgroundColor: 'yellow.200',
color: 'black',
padding: '0 2px',
borderRadius: '2px'
}
}}
/>
</VStack>
</HStack>
</Box>
);
};
本章练习
消息功能:
- 实现消息的编辑和删除
- 添加消息反应功能
- 实现消息回复和引用
文件分享:
- 完善文件上传组件
- 添加文件预览功能
- 实现文件下载和分享
搜索功能:
- 实现全文搜索
- 添加搜索过滤器
- 优化搜索性能
用户体验:
- 添加输入指示器
- 实现消息状态显示
- 优化界面响应性
本章总结
本章继续完善了实时聊天应用的功能:
- 消息组件:实现了完整的消息显示和交互功能
- 文件分享:添加了文件上传、预览和下载功能
- 搜索功能:实现了全文消息搜索和结果高亮
- 用户体验:优化了界面交互和用户反馈
下一章将介绍TypeScript在企业级应用中的最佳实践和架构设计。