gitdataai/src/components/room/message/MessageBubble.tsx
ZhenYi a09f66b779
Some checks are pending
CI / Rust Lint & Check (push) Waiting to run
CI / Rust Tests (push) Waiting to run
CI / Frontend Lint & Type Check (push) Waiting to run
CI / Frontend Build (push) Blocked by required conditions
refactor(room): WebSocket queue and message editor improvements
- Enhance ws_universal.rs with queue message support
- Add queue types and producer improvements
- Simplify MessageBubble.tsx rendering logic
- Refactor IMEditor.tsx with improved message handling
- Update DiscordChatPanel.tsx with message enhancements
2026-04-18 19:29:36 +08:00

414 lines
16 KiB
TypeScript

'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<string, string> = {
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<HTMLDivElement>(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<FunctionCall[]>(
() =>
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<HTMLSpanElement>) => {
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 (
<div
ref={containerRef}
className={cn(
'group relative flex items-start gap-4 px-4 transition-colors py-0.5',
!grouped && 'pt-2 pb-1',
isSystem && 'border-l-2 border-amber-500/60 bg-amber-500/5',
(isPending || isFailed) && 'opacity-60',
)}
>
{/* Avatar — Discord style, circular, 40px */}
{!grouped ? (
<button
className="shrink-0 cursor-pointer rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--room-accent)]"
onClick={handleAvatarClick}
title={displayName}
>
<Avatar className="size-10">
{avatarUrl ? <AvatarImage src={avatarUrl} alt={displayName} /> : null}
<AvatarFallback
className="text-sm font-semibold"
style={{ background: `${senderColor}22`, color: senderColor }}
>
{isAi ? <ModelIcon modelId={senderModelId} /> : initial}
</AvatarFallback>
</Avatar>
</button>
) : (
/* Timestamp column for grouped messages */
<div className="w-10 shrink-0 text-center">
{showDate && (
<span className="text-[10px]" style={{ color: 'var(--room-text-subtle)' }}>
{formatMessageTime(message.send_at).split(':').slice(0, 2).join(':')}
</span>
)}
</div>
)}
{/* Message body */}
<div className="min-w-0 flex-1">
{/* Header: name + time */}
{!grouped && (
<div className="mb-0.5 flex items-baseline gap-2 flex-wrap">
<span
className="text-[15px] font-semibold cursor-pointer hover:underline"
style={{ color: senderColor }}
onClick={handleAvatarClick}
role="button"
tabIndex={0}
>
{displayName}
</span>
<span style={{ color: 'var(--room-text-muted)' }} className="text-[11px]">{formatMessageTime(message.send_at)}</span>
{(isFailed || isPending) && (
<span className="flex items-center gap-1 text-[11px]" title={isFailed ? 'Send failed' : 'Sending...'}>
<span className={cn('size-2 rounded-full', isFailed ? 'bg-red-500' : 'bg-yellow-400 animate-pulse')} />
{isFailed ? 'Failed' : 'Sending...'}
</span>
)}
{isEdited && !isEditing && (
<span
className="text-[10px] hover:underline transition-colors cursor-pointer"
style={{ color: 'var(--room-text-subtle)' }}
title="Edited"
>
(edited)
</span>
)}
</div>
)}
{/* Inline edit mode */}
{isEditing ? (
<div className="mt-0.5 space-y-1.5">
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
className="w-full min-h-[52px] resize-none rounded-md border border-primary bg-background text-foreground px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary/60"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && e.ctrlKey) { e.preventDefault(); handleSaveEdit(); }
if (e.key === 'Escape') { handleCancelEdit(); }
}}
/>
<div className="flex items-center gap-2">
<span className="text-[11px]" style={{ color: 'var(--room-text-muted)' }}>Ctrl+Enter to save · Esc to cancel</span>
<Button size="sm" onClick={handleSaveEdit} disabled={isSavingEdit || !editContent.trim()}
className="ml-auto h-7 px-3 text-xs bg-primary hover:bg-primary/90 text-primary-foreground border-none">
{isSavingEdit ? 'Saving...' : 'Save'}
</Button>
<Button size="sm" variant="ghost" onClick={handleCancelEdit}
className="h-7 px-3 text-xs"
style={{ color: 'var(--room-text-muted)' }}>
Cancel
</Button>
</div>
</div>
) : (
<>
{/* Revoked message */}
{isRevoked ? (
<p className="text-sm italic" style={{ color: 'var(--room-text-subtle)' }}>This message was deleted.</p>
) : (
<>
{/* Reply indicator */}
{replyMessage && (
<div className="mb-1 flex items-center gap-2 text-[12px]" style={{ color: 'var(--room-text-muted)' }}>
<span className="h-4 w-0.5 rounded-full" style={{ background: 'var(--room-border)' }} />
<span>
<span className="font-medium" style={{ color: 'var(--room-text-secondary)' }}>{getSenderDisplayName(replyMessage)}</span>
<span className="ml-1.5">
{replyMessage.content_type === 'text' || replyMessage.content_type === 'Text'
? replyMessage.content.length > 80
? `${replyMessage.content.slice(0, 80)}`
: replyMessage.content
: `[${replyMessage.content_type}]`}
</span>
</span>
</div>
)}
{/* Message content */}
<div className="text-[15px] leading-[1.4] min-w-0" style={{ color: 'var(--room-text)' }}>
{message.content_type === 'text' || message.content_type === 'Text' ? (
<div className={cn('relative', isTextCollapsed && 'max-h-[4.5rem] overflow-hidden')}>
{functionCalls.length > 0 ? (
functionCalls.map((call, index) => (
<div key={index} className="my-1 rounded-md border bg-white/5 p-2 max-w-xl" style={{ borderColor: 'var(--room-border)' }}>
<FunctionCallBadge functionCall={call} className="w-auto" />
</div>
))
) : (
<div className="whitespace-pre-wrap break-words">
<MessageContent
content={displayContent}
onMentionClick={handleMentionClick}
/>
</div>
)}
{/* Streaming cursor */}
{isStreaming && <span className="discord-streaming-cursor" />}
{/* Collapse gradient */}
{isTextCollapsed && (
<div
className="pointer-events-none absolute inset-x-0 -bottom-2 h-10 bg-gradient-to-t"
style={{ background: `linear-gradient(to top, var(--room-bg), transparent)` }}
/>
)}
</div>
) : (
<span style={{ color: 'var(--room-text-muted)' }}>[{message.content_type}]</span>
)}
{/* Show more/less */}
{shouldCollapseText && (
<button
onClick={() => setShowFullText(v => !v)}
className="mt-0.5 text-[12px] text-primary hover:underline"
>
{showFullText ? 'Show less' : 'Show more'}
</button>
)}
</div>
{/* Thread indicator */}
{message.thread_id && onOpenThread && (
<ThreadIndicator threadId={message.thread_id} onClick={() => onOpenThread(message)} />
)}
</>
)}
</>
)}
{/* Reactions + action row */}
{!isRevoked && !isEditing && (
<div className="flex items-center gap-1 mt-0.5">
<MessageReactions message={message} onReaction={handleReaction} />
</div>
)}
</div>
{/* Discord-style hover action buttons */}
{!isEditing && !isRevoked && !isPending && (
<div
className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity absolute -top-3 right-3"
style={{ background: 'var(--card)', border: '1px solid var(--room-border)', borderRadius: 6 }}
>
<ReactionPicker onReact={handleReaction} />
{onReply && (
<button
className="flex h-7 w-7 items-center justify-center rounded-md transition-colors"
style={{ color: 'var(--room-text-muted)' }}
onClick={() => onReply(message)}
title="Reply"
>
</button>
)}
{isOwner && onRevoke && (
<button
className="flex h-7 w-7 items-center justify-center rounded-md transition-colors"
style={{ color: 'var(--room-text-muted)' }}
onClick={() => onRevoke(message)}
title="Delete"
>
🗑
</button>
)}
{isOwner && message.content_type === 'text' && handleStartEdit && (
<button
className="flex h-7 w-7 items-center justify-center rounded-md transition-colors"
style={{ color: 'var(--room-text-muted)' }}
onClick={handleStartEdit}
title="Edit"
>
</button>
)}
</div>
)}
</div>
);
});