329 lines
8.9 KiB
TypeScript
329 lines
8.9 KiB
TypeScript
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<HTMLButtonElement>) => 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<HTMLButtonElement>(null);
|
|
const menuRef = useRef<HTMLDivElement>(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 (
|
|
<div className={styles.container}>
|
|
{onQuickReact &&
|
|
QUICK_EMOJIS.map((qe) => (
|
|
<button
|
|
key={qe.name}
|
|
type="button"
|
|
className={styles.button}
|
|
onClick={() => onQuickReact(qe.emoji)}
|
|
aria-label={`React with :${qe.name}:`}
|
|
title={`:${qe.name}:`}
|
|
>
|
|
<img
|
|
src={getTwemojiUrl(qe.emoji)}
|
|
alt={qe.emoji}
|
|
className={styles.quickEmoji}
|
|
draggable={false}
|
|
/>
|
|
</button>
|
|
))}
|
|
<button
|
|
type="button"
|
|
className={styles.button}
|
|
onClick={(e) => onReact?.(e)}
|
|
aria-label="Add reaction"
|
|
title="Add Reaction"
|
|
>
|
|
<Smiley size={20} />
|
|
</button>
|
|
{onReply && (
|
|
<button
|
|
type="button"
|
|
className={styles.button}
|
|
onClick={onReply}
|
|
aria-label="Reply"
|
|
title="Reply"
|
|
>
|
|
<ArrowBendUpLeft size={20} />
|
|
</button>
|
|
)}
|
|
{isOwnMessage && onEdit && (
|
|
<button
|
|
type="button"
|
|
className={styles.button}
|
|
onClick={onEdit}
|
|
aria-label="Edit"
|
|
title="Edit"
|
|
>
|
|
<PencilSimple size={20} />
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
ref={moreButtonRef}
|
|
className={styles.button}
|
|
onClick={openMoreMenu}
|
|
aria-label="More"
|
|
title="More"
|
|
>
|
|
<DotsThree size={20} weight="bold" />
|
|
</button>
|
|
|
|
{effectiveMenuAt &&
|
|
createPortal(
|
|
<>
|
|
<div
|
|
style={{ position: 'fixed', inset: 0, zIndex: 14999 }}
|
|
onClick={closeMenu}
|
|
onContextMenu={(e) => {
|
|
e.preventDefault();
|
|
closeMenu();
|
|
}}
|
|
/>
|
|
<div
|
|
ref={menuRef}
|
|
className={styles.moreMenu}
|
|
style={{
|
|
position: 'fixed',
|
|
top: adjustedPos?.top ?? effectiveMenuAt.y,
|
|
left: adjustedPos?.left ?? effectiveMenuAt.x - 180,
|
|
// Hide the menu for the first frame while we
|
|
// measure it; revealing it after the clamp
|
|
// avoids a flash at the wrong position.
|
|
visibility: adjustedPos ? 'visible' : 'hidden',
|
|
zIndex: 15000,
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{onReply && (
|
|
<button
|
|
type="button"
|
|
className={styles.menuItem}
|
|
onClick={runAndClose(onReply)}
|
|
>
|
|
<ArrowBendUpLeft size={16} />
|
|
<span>Reply</span>
|
|
</button>
|
|
)}
|
|
{onForward && (
|
|
<button
|
|
type="button"
|
|
className={styles.menuItem}
|
|
onClick={runAndClose(onForward)}
|
|
>
|
|
<ArrowBendUpRight size={16} />
|
|
<span>Forward</span>
|
|
</button>
|
|
)}
|
|
{onPin && (
|
|
<button
|
|
type="button"
|
|
className={styles.menuItem}
|
|
onClick={runAndClose(onPin)}
|
|
>
|
|
<PushPin size={16} />
|
|
<span>Pin Message</span>
|
|
</button>
|
|
)}
|
|
{onCopyText && (
|
|
<button
|
|
type="button"
|
|
className={styles.menuItem}
|
|
onClick={runAndClose(onCopyText)}
|
|
>
|
|
<Copy size={16} />
|
|
<span>Copy Text</span>
|
|
</button>
|
|
)}
|
|
{onCopyLink && (
|
|
<button
|
|
type="button"
|
|
className={styles.menuItem}
|
|
onClick={runAndClose(onCopyLink)}
|
|
>
|
|
<Link size={16} />
|
|
<span>Copy Link</span>
|
|
</button>
|
|
)}
|
|
{isOwnMessage && onEdit && (
|
|
<button
|
|
type="button"
|
|
className={styles.menuItem}
|
|
onClick={runAndClose(onEdit)}
|
|
>
|
|
<PencilSimple size={16} />
|
|
<span>Edit Message</span>
|
|
</button>
|
|
)}
|
|
{isOwnMessage && onDelete && (
|
|
<>
|
|
<div className={styles.menuDivider} />
|
|
<button
|
|
type="button"
|
|
className={`${styles.menuItem} ${styles.menuItemDanger}`}
|
|
onClick={runAndClose(onDelete)}
|
|
>
|
|
<Trash size={16} />
|
|
<span>Delete Message</span>
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</>,
|
|
document.body,
|
|
)}
|
|
</div>
|
|
);
|
|
}
|