- 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
414 lines
16 KiB
TypeScript
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>
|
|
);
|
|
});
|