508 lines
20 KiB
TypeScript
508 lines
20 KiB
TypeScript
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<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;
|
|
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<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)) {
|
|
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 (
|
|
<div
|
|
ref={containerRef}
|
|
className={cn(
|
|
'group relative flex gap-3 px-4 transition-colors',
|
|
grouped ? 'py-0.5' : 'py-2',
|
|
!isSystem && 'hover:bg-muted/30',
|
|
isSystem && 'border-l-2 border-amber-500/60 bg-amber-500/5',
|
|
(isPending || isFailed) && 'opacity-60',
|
|
)}
|
|
>
|
|
{/* Avatar */}
|
|
{!grouped ? (
|
|
<Avatar
|
|
className={cn('size-8 shrink-0', onMention && 'cursor-pointer')}
|
|
onClick={handleAvatarClick}
|
|
>
|
|
{avatarUrl ? <AvatarImage src={avatarUrl} alt={displayName} /> : null}
|
|
<AvatarFallback className="bg-muted text-xs text-foreground">
|
|
{isAi ? <ModelIcon modelId={senderModelId} /> : initial}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
) : (
|
|
<div className="w-8 shrink-0" />
|
|
)}
|
|
|
|
{/* Message Content */}
|
|
<div className="min-w-0 flex-1">
|
|
{/* Header */}
|
|
{!grouped && (
|
|
<div className="mb-1 flex flex-wrap items-center gap-2">
|
|
<span className="text-sm font-semibold text-foreground">{displayName}</span>
|
|
{isSystem && (
|
|
<Badge variant="outline" className="gap-1 border-amber-500/40 text-[10px] text-amber-700">
|
|
<AlertTriangle className="size-3" />
|
|
System
|
|
</Badge>
|
|
)}
|
|
{showDate && (
|
|
<span className="text-xs text-muted-foreground">{formatMessageTime(message.send_at)}</span>
|
|
)}
|
|
{(isFailed || isPending) && (
|
|
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground/60" title={isFailed ? 'Send failed' : 'Sending...'}>
|
|
<AlertCircle className={cn('size-2.5', isFailed ? 'text-destructive' : 'animate-pulse')} />
|
|
{isFailed ? 'Failed' : 'Sending...'}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Inline edit mode */}
|
|
{isEditing ? (
|
|
<div className="space-y-2">
|
|
<textarea
|
|
value={editContent}
|
|
onChange={(e) => setEditContent(e.target.value)}
|
|
className="w-full min-h-[60px] resize-none rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
|
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-xs text-muted-foreground">
|
|
Press 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"
|
|
>
|
|
{isSavingEdit ? 'Saving...' : 'Save'}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={handleCancelEdit}
|
|
className="h-7 px-3 text-xs"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Revoked message indicator */}
|
|
{isRevoked ? (
|
|
<div className="flex items-center gap-2 rounded-lg border border-border/50 bg-muted/30 px-3 py-2 text-sm italic text-muted-foreground">
|
|
<Trash2 className="size-3.5" />
|
|
<span>This message has been deleted</span>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Reply indicator */}
|
|
{replyMessage && (
|
|
<div className="mb-2 flex items-start gap-1.5 border-l-2 border-border pl-2 text-xs text-muted-foreground">
|
|
<ReplyIcon className="mt-0.5 size-3 shrink-0" />
|
|
<span className="truncate">
|
|
<span className="font-medium">{getSenderDisplayName(replyMessage)}</span>
|
|
<span className="ml-1">
|
|
{replyMessage.content_type === 'text' || replyMessage.content_type === 'Text'
|
|
? replyMessage.content.length > 64
|
|
? `${replyMessage.content.slice(0, 64)}...`
|
|
: replyMessage.content
|
|
: `[${replyMessage.content_type}]`}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Message content */}
|
|
<div
|
|
className="min-w-0 break-words text-sm text-foreground"
|
|
title={grouped ? formatMessageTime(message.send_at) : undefined}
|
|
>
|
|
{message.content_type === 'text' || message.content_type === 'Text' ? (
|
|
<>
|
|
<div className={cn('relative', isTextCollapsed && 'max-h-[5.25rem] overflow-hidden')}>
|
|
{functionCalls.length > 0 ? (
|
|
functionCalls.map((call, index) => (
|
|
<div key={index} className="my-1 rounded-md border border-border/70 bg-muted/20 p-2">
|
|
<FunctionCallBadge functionCall={call} className="w-auto" />
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="max-w-full min-w-0 overflow-hidden whitespace-pre-wrap break-words">
|
|
<MessageContentWithMentions content={displayContent} />
|
|
</div>
|
|
)}
|
|
|
|
{isStreaming && (
|
|
<span className="ml-0.5 inline-block size-1 animate-pulse align-middle rounded-full bg-primary" />
|
|
)}
|
|
|
|
{isTextCollapsed && (
|
|
<div className="pointer-events-none absolute inset-x-0 -bottom-1 h-14 bg-gradient-to-b from-transparent via-background/35 to-background/95" />
|
|
)}
|
|
</div>
|
|
|
|
{shouldCollapseText && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setShowFullText((v) => !v)}
|
|
className="mt-1 h-auto gap-1 p-0 text-xs text-muted-foreground hover:text-foreground"
|
|
>
|
|
{showFullText ? (
|
|
<><ChevronUp className="size-3" /> Show less</>
|
|
) : (
|
|
<><ChevronDown className="size-3" /> Show more</>
|
|
)}
|
|
</Button>
|
|
)}
|
|
</>
|
|
) : (
|
|
<span className="text-muted-foreground">[{message.content_type}]</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Thread indicator */}
|
|
{message.thread_id && onOpenThread && (
|
|
<ThreadIndicator
|
|
threadId={message.thread_id}
|
|
onClick={() => onOpenThread(message)}
|
|
/>
|
|
)}
|
|
|
|
{/* Edited indicator - bottom right corner */}
|
|
{isEdited && (
|
|
<div className="mt-1 flex items-center justify-end gap-0.5 text-[10px] text-muted-foreground/60">
|
|
<Edit2 className="size-2.5" />
|
|
<span>Edited</span>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Reaction badges - OUTSIDE message content div so they align with action toolbar */}
|
|
{(message.reactions?.length ?? 0) > 0 && (
|
|
<div className="flex flex-wrap items-center gap-1">
|
|
{message.reactions!.map((r) => (
|
|
<Button
|
|
key={r.emoji}
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleReaction(r.emoji)}
|
|
className={cn(
|
|
'h-6 gap-1 rounded-full px-2 text-xs transition-colors',
|
|
r.reacted_by_me
|
|
? 'border border-primary/50 bg-primary/10 text-primary hover:bg-primary/20'
|
|
: 'border border-border bg-muted/20 text-muted-foreground hover:bg-muted/40'
|
|
)}
|
|
>
|
|
<span>{r.emoji}</span>
|
|
<span className="font-medium">{r.count}</span>
|
|
</Button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Action toolbar - inline icon buttons */}
|
|
{!isEditing && !isRevoked && !isPending && (
|
|
<div className="flex items-start gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
|
{/* Add reaction */}
|
|
<Popover open={showReactionPicker} onOpenChange={setShowReactionPicker}>
|
|
<PopoverTrigger
|
|
render={
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
title="Add reaction"
|
|
>
|
|
<SmilePlus className="size-3.5" />
|
|
</Button>
|
|
}
|
|
/>
|
|
<PopoverContent className="w-auto p-2" align="start" sideOffset={4}>
|
|
<p className="mb-2 text-xs font-medium text-muted-foreground">Select emoji</p>
|
|
<div className="grid grid-cols-8 gap-1">
|
|
{COMMON_EMOJIS.map((emoji) => (
|
|
<Button
|
|
key={emoji}
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleReaction(emoji)}
|
|
className="size-7 p-0 text-base hover:bg-accent"
|
|
title={emoji}
|
|
>
|
|
{emoji}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
{/* Reply */}
|
|
{onReply && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
onClick={() => onReply(message)}
|
|
title="Reply"
|
|
>
|
|
<ReplyIcon className="size-3.5" />
|
|
</Button>
|
|
)}
|
|
{/* Copy */}
|
|
{message.content_type === 'text' && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
onClick={async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(message.content);
|
|
toast.success('Message copied');
|
|
} catch {
|
|
toast.error('Failed to copy');
|
|
}
|
|
}}
|
|
title="Copy"
|
|
>
|
|
<Copy className="size-3.5" />
|
|
</Button>
|
|
)}
|
|
{/* More menu */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
title="More"
|
|
>
|
|
<MoreHorizontal className="size-3.5" />
|
|
</Button>
|
|
}
|
|
/>
|
|
<DropdownMenuContent align="end">
|
|
{onCreateThread && !message.thread_id && (
|
|
<DropdownMenuItem onClick={() => onCreateThread(message)}>
|
|
<MessageSquare className="mr-2 size-4" />
|
|
Create thread
|
|
</DropdownMenuItem>
|
|
)}
|
|
{message.edited_at && onViewHistory && (
|
|
<DropdownMenuItem onClick={() => onViewHistory(message)}>
|
|
<History className="mr-2 size-4" />
|
|
View edit history
|
|
</DropdownMenuItem>
|
|
)}
|
|
{isOwner && message.content_type === 'text' && (
|
|
<DropdownMenuItem onClick={handleStartEdit}>
|
|
<Edit2 className="mr-2 size-4" />
|
|
Edit
|
|
</DropdownMenuItem>
|
|
)}
|
|
{isOwner && onRevoke && (
|
|
<DropdownMenuItem onClick={() => onRevoke(message)} className="text-destructive focus:text-destructive">
|
|
<Trash2 className="mr-2 size-4" />
|
|
Delete
|
|
</DropdownMenuItem>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
);
|
|
});
|