Files
DiscordClone/packages/shared/src/components/channel/MessageActionBar.tsx
Bryan1029384756 b83360db35
Some checks failed
Build and Release / build-and-release (push) Has been cancelled
bump
2026-04-20 17:09:04 -05:00

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