import { ArrowBendUpLeft, ArrowBendUpRight, Copy, DotsThree, Link, PencilSimple, PushPin, Smiley, Trash, } from '@phosphor-icons/react'; import { useEffect, useLayoutEffect, useRef, useState, type MouseEvent } from 'react'; import { createPortal } from 'react-dom'; import { getTwemojiUrl } from '../../utils/twemoji'; import styles from './MessageActionBar.module.css'; // Default quick-reaction emojis shown as the first three icons on the // hover bar. Matches the new UI's set. const QUICK_EMOJIS: Array<{ emoji: string; name: string }> = [ { emoji: '😄', name: 'smile' }, { emoji: '👍', name: 'thumbsup' }, { emoji: '👌', name: 'ok_hand' }, ]; interface MessageActionBarProps { isOwnMessage: boolean; onReply?: () => void; onEdit?: () => void; onDelete?: () => void; onReact?: (e?: MouseEvent) => void; onQuickReact?: (emoji: string) => void; onPin?: () => void; onCopyText?: () => void; onCopyLink?: () => void; onForward?: () => void; /** * External trigger for opening the More dropdown (e.g. right-click * on a message). When set, the dropdown opens at `{ x, y }` and the * hover action bar stays visible via the `.actionBarForceVisible` * class on the parent. Pass null to close. */ externalMenuAt?: { x: number; y: number } | null; onExternalMenuClose?: () => void; /** * Fires when the local More menu opens / closes so the parent * (MessageGroup) can keep the hover bar pinned to visible while * the dropdown is on screen. Without this the bar disappears as * soon as the pointer leaves the message row. */ onMenuOpenChange?: (isOpen: boolean) => void; } /** * Hover quick-action bar + right-click context menu. * * Appears via `:global(.messageHoverable):hover > .container` — must be * rendered as a direct child of an element with the `messageHoverable` * class. * * The "More" button opens a portal-rendered dropdown with additional * actions (edit/delete/pin/copy/copy link). */ export function MessageActionBar({ isOwnMessage, onReply, onEdit, onDelete, onReact, onQuickReact, onPin, onCopyText, onCopyLink, onForward, externalMenuAt, onExternalMenuClose, onMenuOpenChange, }: MessageActionBarProps) { const moreButtonRef = useRef(null); const menuRef = useRef(null); const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null); // Adjusted position after we measure the menu's actual size and // clamp it to the viewport. Falls back to the requested coords // while the measurement is in flight (one frame at most). const [adjustedPos, setAdjustedPos] = useState< { top: number; left: number } | null >(null); // Merge external right-click menu state with local more-button state. const effectiveMenuAt = externalMenuAt ?? menuAt; // Notify the parent whenever the menu open state flips, so the // message row can pin the hover bar visible while the dropdown is // up. Triggered for both local and external menus. useEffect(() => { onMenuOpenChange?.(!!effectiveMenuAt); }, [effectiveMenuAt, onMenuOpenChange]); useEffect(() => { if (!effectiveMenuAt) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') closeMenu(); }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }, [effectiveMenuAt]); // Clamp the menu inside the viewport. When the requested top/left // would push the menu off-screen, flip vertically (open upwards) // or shift horizontally so it stays fully visible. useLayoutEffect(() => { if (!effectiveMenuAt) { setAdjustedPos(null); return; } const measure = () => { const el = menuRef.current; if (!el) return; const rect = el.getBoundingClientRect(); const margin = 8; const vw = window.innerWidth; const vh = window.innerHeight; let top = effectiveMenuAt.y; let left = effectiveMenuAt.x - 180; // Vertical: flip upwards if the menu would overflow the // bottom of the viewport. Use the menu's measured height // so the flip lines its bottom up with the requested y. if (top + rect.height + margin > vh) { top = Math.max(margin, effectiveMenuAt.y - rect.height); } top = Math.max(margin, Math.min(top, vh - rect.height - margin)); // Horizontal: shift left if it would overflow the right // edge, then clamp at the left margin. if (left + rect.width + margin > vw) { left = vw - rect.width - margin; } left = Math.max(margin, left); setAdjustedPos({ top, left }); }; // One frame to let the menu mount, one fallback in case // requestAnimationFrame fires before layout settles. const raf = requestAnimationFrame(measure); return () => cancelAnimationFrame(raf); }, [effectiveMenuAt]); const closeMenu = () => { setMenuAt(null); onExternalMenuClose?.(); }; const openMoreMenu = () => { const rect = moreButtonRef.current?.getBoundingClientRect(); if (!rect) return; setMenuAt({ x: rect.right, y: rect.bottom + 4 }); }; const runAndClose = (handler?: () => void) => () => { handler?.(); closeMenu(); }; return (
{onQuickReact && QUICK_EMOJIS.map((qe) => ( ))} {onReply && ( )} {isOwnMessage && onEdit && ( )} {effectiveMenuAt && createPortal( <>
{ e.preventDefault(); closeMenu(); }} />
e.stopPropagation()} > {onReply && ( )} {onForward && ( )} {onPin && ( )} {onCopyText && ( )} {onCopyLink && ( )} {isOwnMessage && onEdit && ( )} {isOwnMessage && onDelete && ( <>
)}
, document.body, )}
); }