feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
- Implemented Button component with various props for customization. - Created Modal component with header, content, and footer subcomponents. - Added Spinner component for loading indicators. - Developed Toast component for displaying notifications. - Introduced Tooltip component for contextual hints with keyboard shortcuts. - Added corresponding CSS modules for styling each component. - Updated index file to export new components. - Configured TypeScript settings for the UI package.
This commit is contained in:
326
packages/shared/src/components/channel/MessageActionBar.tsx
Normal file
326
packages/shared/src/components/channel/MessageActionBar.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user