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>
  );
};

本章练习

  1. 消息功能

    • 实现消息的编辑和删除
    • 添加消息反应功能
    • 实现消息回复和引用
  2. 文件分享

    • 完善文件上传组件
    • 添加文件预览功能
    • 实现文件下载和分享
  3. 搜索功能

    • 实现全文搜索
    • 添加搜索过滤器
    • 优化搜索性能
  4. 用户体验

    • 添加输入指示器
    • 实现消息状态显示
    • 优化界面响应性

本章总结

本章继续完善了实时聊天应用的功能:

  1. 消息组件:实现了完整的消息显示和交互功能
  2. 文件分享:添加了文件上传、预览和下载功能
  3. 搜索功能:实现了全文消息搜索和结果高亮
  4. 用户体验:优化了界面交互和用户反馈

下一章将介绍TypeScript在企业级应用中的最佳实践和架构设计。