'use client'; /** * Message bubble with sender info, content, and actions. * Discord-styled for the Phase 2 redesign. */ import type { MessageWithMeta } from '@/contexts'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; import { parseFunctionCalls, type FunctionCall } from '@/lib/functionCallParser'; import { formatMessageTime } from '../shared/formatters'; import { cn } from '@/lib/utils'; import { useUser, useRoom, useTheme } from '@/contexts'; import { memo, useMemo, useState, useCallback, useRef } from 'react'; import { ModelIcon } from '../icon-match'; import { FunctionCallBadge } from '../FunctionCallBadge'; import { MessageContent } from './MessageContent'; import { ThreadIndicator } from '../RoomThreadPanel'; import { getSenderDisplayName, getSenderModelId, getAvatarFromUiMessage, getSenderUserUid, isUserSender } from '../sender'; import { MessageReactions } from './MessageReactions'; import { ReactionPicker } from './ReactionPicker'; // Sender colors — AI Studio clean palette const SENDER_COLORS: Record = { system: '#9ca3af', ai: '#1c7ded', tool: '#6b7280', }; const DEFAULT_SENDER_COLOR = '#6b7280'; function getSenderColor(senderType: string): string { return SENDER_COLORS[senderType] ?? DEFAULT_SENDER_COLOR; } const TEXT_COLLAPSE_LINE_COUNT = 5; interface MessageBubbleProps { 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; } export const MessageBubble = memo(function MessageBubble({ roomId, message, replyMessage, grouped = false, showDate = true, onInlineEdit, onRevoke, onReply, onOpenUserCard, onOpenThread, }: MessageBubbleProps) { const [showFullText, setShowFullText] = useState(false); const [isEditing, setIsEditing] = useState(false); const [editContent, setEditContent] = useState(message.content); const [isSavingEdit, setIsSavingEdit] = 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; useTheme(); const { user } = useUser(); const { wsClient, streamingMessages, members } = useRoom(); const isOwner = user?.uid === getSenderUserUid(message); const isRevoked = !!message.revoked; const isFailed = message.isOptimisticError === true; const isPending = message.isOptimistic === true || message.id.startsWith('temp-') || message.id.startsWith('optimistic-'); const displayContent = isStreaming && streamingMessages?.has(message.id) ? streamingMessages.get(message.id)! : message.content; const handleMentionClick = useCallback( (type: string, id: string, label: string) => { if (!onOpenUserCard || type !== 'user') return; // Find member by id const member = members.find((m) => m.user === id); if (!member) return; // Open user card near the message const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return; onOpenUserCard({ username: label, displayName: member.user_info?.username ?? label, avatarUrl: member.user_info?.avatar_url ?? null, userId: id, point: { x: rect.left + 40, y: rect.top + 20 }, }); }, [onOpenUserCard, members], ); 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); } }, [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)) return; if (message.sender_id) { onOpenUserCard({ username: displayName, avatarUrl: avatarUrl ?? null, userId: message.sender_id, point: { x: event.clientX, y: event.clientY }, }); } }; 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]); const senderColor = getSenderColor(message.sender_type); return (
{/* Avatar — Discord style, circular, 40px */} {!grouped ? ( ) : ( /* Timestamp column for grouped messages */
{showDate && ( {formatMessageTime(message.send_at).split(':').slice(0, 2).join(':')} )}
)} {/* Message body */}
{/* Header: name + time */} {!grouped && (
{displayName} {formatMessageTime(message.send_at)} {(isFailed || isPending) && ( {isFailed ? 'Failed' : 'Sending...'} )} {isEdited && !isEditing && ( (edited) )}
)} {/* Inline edit mode */} {isEditing ? (