fix(room): replace manual emoji picker positioning with Popover

Manual getBoundingClientRect positioning caused the picker to appear at
the far right of the room and shift content. Replaced with shadcn
Popover which handles anchor positioning, flipping, and portal rendering
automatically.
This commit is contained in:
ZhenYi 2026-04-17 21:50:50 +08:00
parent 4767e1d692
commit ef1adb663d

View File

@ -6,9 +6,10 @@ import { parseFunctionCalls, type FunctionCall } from '@/lib/functionCallParser'
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { AlertCircle, AlertTriangle, ChevronDown, ChevronUp, Copy, Edit2, Reply as ReplyIcon, Trash2, History, MoreHorizontal, MessageSquare } from 'lucide-react'; 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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { SmilePlus } from 'lucide-react'; import { SmilePlus } from 'lucide-react';
import { useUser, useRoom } from '@/contexts'; import { useUser, useRoom } from '@/contexts';
import { memo, useMemo, useState, useCallback, useRef } from 'react'; import { memo, useMemo, useCallback, useRef } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { ModelIcon } from './icon-match'; import { ModelIcon } from './icon-match';
import { FunctionCallBadge } from './FunctionCallBadge'; import { FunctionCallBadge } from './FunctionCallBadge';
@ -79,10 +80,7 @@ export const RoomMessageBubble = memo(function RoomMessageBubble({
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [editContent, setEditContent] = useState(message.content); const [editContent, setEditContent] = useState(message.content);
const [isSavingEdit, setIsSavingEdit] = useState(false); const [isSavingEdit, setIsSavingEdit] = useState(false);
const [showReactionPicker, setShowReactionPicker] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const reactionButtonRef = useRef<HTMLButtonElement>(null);
const [reactionPickerPosition, setReactionPickerPosition] = useState<{ top: number; left: number } | null>(null);
const isAi = ['ai', 'system', 'tool'].includes(message.sender_type); const isAi = ['ai', 'system', 'tool'].includes(message.sender_type);
const isSystem = message.sender_type === 'system'; const isSystem = message.sender_type === 'system';
@ -121,20 +119,8 @@ export const RoomMessageBubble = memo(function RoomMessageBubble({
} catch (err) { } catch (err) {
console.warn('[RoomMessage] Failed to update reaction:', err); console.warn('[RoomMessage] Failed to update reaction:', err);
} }
setShowReactionPicker(false);
}, [roomId, message.id, message.reactions, wsClient]); }, [roomId, message.id, message.reactions, wsClient]);
const handleOpenReactionPicker = useCallback(() => {
if (reactionButtonRef.current) {
const rect = reactionButtonRef.current.getBoundingClientRect();
setReactionPickerPosition({
top: rect.bottom + 8, // 8px below the button
left: rect.left + rect.width / 2,
});
}
setShowReactionPicker(true);
}, []);
const functionCalls = useMemo<FunctionCall[]>( const functionCalls = useMemo<FunctionCall[]>(
() => () =>
message.content_type === 'text' || message.content_type === 'Text' message.content_type === 'text' || message.content_type === 'Text'
@ -408,16 +394,37 @@ export const RoomMessageBubble = memo(function RoomMessageBubble({
{!isEditing && !isRevoked && !isPending && ( {!isEditing && !isRevoked && !isPending && (
<div className="flex items-start gap-0.5 opacity-0 transition-opacity group-hover:opacity-100"> <div className="flex items-start gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
{/* Add reaction */} {/* Add reaction */}
<Button <Popover>
variant="ghost" <PopoverTrigger
size="sm" render={
ref={reactionButtonRef} <Button
className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground" variant="ghost"
onClick={handleOpenReactionPicker} size="sm"
title="Add reaction" className="size-7 p-0 text-muted-foreground hover:bg-accent hover:text-foreground"
> title="Add reaction"
<SmilePlus className="size-3.5" /> >
</Button> <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 */} {/* Reply */}
{onReply && ( {onReply && (
<Button <Button
@ -493,28 +500,6 @@ export const RoomMessageBubble = memo(function RoomMessageBubble({
</div> </div>
)} )}
{/* Emoji picker overlay - positioned relative to the reaction button */}
{showReactionPicker && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowReactionPicker(false)} />
<div
className="fixed z-50"
style={{
top: reactionPickerPosition?.top ?? '50%',
left: reactionPickerPosition?.left ?? '50%',
transform: reactionPickerPosition ? 'translateX(-50%)' : 'translate(-50%, -50%)',
}}
>
<div className="rounded-lg border border-border bg-popover p-3 shadow-xl">
<p className="mb-2 text-xs font-medium text-muted-foreground">Select emoji</p>
<EmojiPicker onEmojiSelect={(emoji) => {
handleReaction(emoji);
setShowReactionPicker(false);
}} />
</div>
</div>
</>
)}
</div> </div>
); );
}); });