gitdataai/src/components/room/RoomMessageBubble.tsx

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>
);
});