import type { MessageWithMeta } from '@/contexts'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { parseFunctionCalls, type FunctionCall } from '@/lib/functionCallParser'; import { cn } from '@/lib/utils'; import { AlertCircle, AlertTriangle, ChevronDown, ChevronUp, Copy, Edit2, Reply as ReplyIcon, Trash2, History, MoreHorizontal, MessageSquare } from 'lucide-react'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { SmilePlus } from 'lucide-react'; import { useUser, useRoom } from '@/contexts'; import { memo, useMemo, useState, useCallback, useRef } from 'react'; import { toast } from 'sonner'; import { ModelIcon } from './icon-match'; import { FunctionCallBadge } from './FunctionCallBadge'; import { MessageContentWithMentions } from './MessageMentions'; import { ThreadIndicator } from './RoomThreadPanel'; import { getSenderDisplayName, getSenderModelId, getAvatarFromUiMessage, getSenderUserUid, isUserSender } from './sender'; const COMMON_EMOJIS = [ '👍', '👎', '❤️', '😂', '😮', '😢', '🎉', '🚀', '✅', '⭐', '🔥', '💯', '👀', '🙏', '💪', '🤔', ]; interface RoomMessageBubbleProps { message: MessageWithMeta; roomId: string; replyMessage?: MessageWithMeta | null; grouped?: boolean; showDate?: boolean; onInlineEdit?: (message: MessageWithMeta, newContent: string) => void; onViewHistory?: (message: MessageWithMeta) => void; onRevoke?: (message: MessageWithMeta) => void; onReply?: (message: MessageWithMeta) => void; onMention?: (name: string, type: 'user' | 'ai') => void; onOpenUserCard?: (payload: { username: string; displayName?: string | null; avatarUrl?: string | null; userId: string; point: { x: number; y: number }; }) => void; onOpenThread?: (message: MessageWithMeta) => void; onCreateThread?: (message: MessageWithMeta) => void; } const TEXT_COLLAPSE_LINE_COUNT = 5; function formatMessageTime(iso: string) { const d = new Date(iso); const now = new Date(); const isToday = d.toDateString() === now.toDateString(); const isYesterday = new Date(now.getTime() - 86400000).toDateString() === d.toDateString(); if (isToday) { return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); } if (isYesterday) { return `Yesterday ${d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}`; } return d.toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' }); } export const RoomMessageBubble = memo(function RoomMessageBubble({ roomId, message, replyMessage, grouped = false, showDate = true, onInlineEdit, onViewHistory, onRevoke, onReply, onMention, onOpenUserCard, onOpenThread, onCreateThread, }: RoomMessageBubbleProps) { const [showFullText, setShowFullText] = useState(false); const [isEditing, setIsEditing] = useState(false); const [editContent, setEditContent] = useState(message.content); const [isSavingEdit, setIsSavingEdit] = useState(false); const [showReactionPicker, setShowReactionPicker] = useState(false); const containerRef = useRef(null); const isAi = ['ai', 'system', 'tool'].includes(message.sender_type); const isSystem = message.sender_type === 'system'; const displayName = getSenderDisplayName(message); const senderModelId = getSenderModelId(message); const avatarUrl = getAvatarFromUiMessage(message); const initial = (displayName?.charAt(0) ?? '?').toUpperCase(); const isStreaming = !!message.is_streaming; const isEdited = !!message.edited_at; const { user } = useUser(); const { wsClient, streamingMessages } = useRoom(); const isOwner = user?.uid === getSenderUserUid(message); const isRevoked = !!message.revoked; const isFailed = message.isOptimisticError === true; // True for messages that haven't been confirmed by the server yet. // Handles both the old 'temp-' prefix and the new isOptimistic flag. const isPending = message.isOptimistic === true || message.id.startsWith('temp-') || message.id.startsWith('optimistic-'); // Get streaming content if available const displayContent = isStreaming && streamingMessages?.has(message.id) ? streamingMessages.get(message.id)! : message.content; // Detect narrow container width using CSS container query instead of ResizeObserver // The .group/narrow class on the container enables CSS container query support const handleReaction = useCallback(async (emoji: string) => { if (!wsClient) return; try { const existing = message.reactions?.find(r => r.emoji === emoji); if (existing?.reacted_by_me) { await wsClient.reactionRemove(roomId, message.id, emoji); } else { await wsClient.reactionAdd(roomId, message.id, emoji); } } catch (err) { console.warn('[RoomMessage] Failed to update reaction:', err); } setShowReactionPicker(false); }, [roomId, message.id, message.reactions, wsClient]); const functionCalls = useMemo( () => message.content_type === 'text' || message.content_type === 'Text' ? parseFunctionCalls(displayContent) : [], [displayContent, message.content_type], ); const textContent = displayContent; const estimatedLines = textContent.split(/\r?\n/).reduce((total, line) => { return total + Math.max(1, Math.ceil(line.trim().length / 90)); }, 0); const shouldCollapseText = (message.content_type === 'text' || message.content_type === 'Text') && estimatedLines > TEXT_COLLAPSE_LINE_COUNT; const isTextCollapsed = shouldCollapseText && !showFullText; const handleAvatarClick = (event: React.MouseEvent) => { if (!onOpenUserCard || isAi || !isUserSender(message)) { onMention?.(displayName, isAi ? 'ai' : 'user'); return; } if (message.sender_id) { onOpenUserCard({ username: displayName, avatarUrl: avatarUrl ?? null, userId: message.sender_id, point: { x: event.clientX, y: event.clientY }, }); } }; // Inline edit handlers const handleStartEdit = useCallback(() => { setEditContent(message.content); setIsEditing(true); }, [message.content]); const handleCancelEdit = useCallback(() => { setIsEditing(false); setEditContent(message.content); }, [message.content]); const handleSaveEdit = useCallback(async () => { if (!editContent.trim() || editContent === message.content) { handleCancelEdit(); return; } setIsSavingEdit(true); try { if (onInlineEdit) { onInlineEdit(message, editContent.trim()); } setIsEditing(false); } finally { setIsSavingEdit(false); } }, [editContent, message, onInlineEdit, handleCancelEdit]); return (
{/* Avatar */} {!grouped ? ( {avatarUrl ? : null} {isAi ? : initial} ) : (
)} {/* Message Content */}
{/* Header */} {!grouped && (
{displayName} {isSystem && ( System )} {showDate && ( {formatMessageTime(message.send_at)} )} {(isFailed || isPending) && ( {isFailed ? 'Failed' : 'Sending...'} )}
)} {/* Inline edit mode */} {isEditing ? (