import { Button, Card, cn, ContextMenu, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, Drawer, DrawerContent, DrawerDescription, DrawerTitle, Popover, PopoverContent, PopoverTrigger, Separator, Tooltip, TooltipContent, TooltipTrigger, useIsMobile, } from "@methanium/ui"; import { Clipboard, Ellipsis, Forward, Laugh, Plus, Pen, Pin, Reply, Trash, } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import { cloneElement, useMemo, useState, useSyncExternalStore } from "react"; import type { MouseEvent as ReactMouseEvent, ReactElement, ReactNode, Ref, } from "react"; import { useChat } from "../context"; import Emoji from "@tensamin/markdown/emoji"; import EmojiPicker from "./emoji/emojiPicker"; import { getRecentEmojis, useEmojiRanks } from "./emoji/emojiRanks"; async function copyText(text: string) { await navigator.clipboard.writeText(text); } type MenuComponents = { Content: (props: { className?: string; children: ReactNode }) => ReactElement; Group: (props: { children: ReactNode }) => ReactElement; Item: (props: { children: ReactNode; className?: string; disabled?: boolean; onClick?: () => void | Promise; variant?: "default" | "destructive"; }) => ReactElement; Separator: (props: { className?: string }) => ReactElement; Sub: (props: { children: ReactNode }) => ReactElement; SubTrigger: (props: { children: ReactNode; onClick?: () => void | Promise; }) => ReactElement; SubContent: (props: { children: ReactNode }) => ReactElement; }; function blurActiveElement() { if (document.activeElement instanceof HTMLElement) { document.activeElement.blur(); } } const desktopMenuComponents: MenuComponents = { Content: ContextMenuContent, Group: ContextMenuGroup, Item: ContextMenuItem, Separator: ContextMenuSeparator, Sub: ContextMenuSub, SubTrigger: ContextMenuSubTrigger, SubContent: ContextMenuSubContent, }; function getMobileMenuComponents({ description, onClose, title, }: { description: string; onClose: () => void; title: string; }): MenuComponents { return { Content: ({ className, children }) => ( {title} {description}
{children}
), Group: ({ children }) =>
{children}
, Item: ({ children, className, disabled, onClick, variant = "default" }) => { async function handleClick() { await onClick?.(); blurActiveElement(); onClose(); } return ( ); }, Separator: ({ className }) => (
), Sub: ({ children }) =>
{children}
, SubTrigger: ({ children, onClick }) => { async function handleClick() { blurActiveElement(); onClose(); await onClick?.(); } return ( ); }, SubContent: ({ children }) =>
{children}
, }; } function ReactionItems({ Item, emojis, onMore, onSelect, }: { Item: MenuComponents["Item"]; emojis: string[]; onMore: () => void; onSelect: (emoji: string) => void; }) { return ( <> {emojis.map((emoji) => ( onSelect(emoji)}> {emoji} ))} More reactions ); } let shiftPressed = false; const shiftListeners = new Set<() => void>(); function setShiftPressed(value: boolean) { if (shiftPressed === value) return; shiftPressed = value; shiftListeners.forEach((listener) => listener()); } function subscribeToShift(listener: () => void) { shiftListeners.add(listener); if (shiftListeners.size === 1) { window.addEventListener("keydown", handleShiftKey); window.addEventListener("keyup", handleShiftKey); window.addEventListener("blur", handleWindowBlur); } return () => { shiftListeners.delete(listener); if (shiftListeners.size === 0) { window.removeEventListener("keydown", handleShiftKey); window.removeEventListener("keyup", handleShiftKey); window.removeEventListener("blur", handleWindowBlur); shiftPressed = false; } }; } function handleShiftKey(event: KeyboardEvent) { setShiftPressed(event.shiftKey); } function handleWindowBlur() { setShiftPressed(false); } function useShiftPressed() { return useSyncExternalStore( subscribeToShift, () => shiftPressed, () => false, ); } let activeMiniMenuId: number | null = null; let clearMiniMenuTimeout: ReturnType | undefined; const miniMenuListeners = new Set<() => void>(); function setActiveMiniMenu(messageId: number | null) { if (clearMiniMenuTimeout !== undefined) { clearTimeout(clearMiniMenuTimeout); clearMiniMenuTimeout = undefined; } if (activeMiniMenuId === messageId) return; activeMiniMenuId = messageId; miniMenuListeners.forEach((listener) => listener()); } function scheduleMiniMenuClose() { clearMiniMenuTimeout = setTimeout(() => { clearMiniMenuTimeout = undefined; setActiveMiniMenu(null); }, 80); } function useActiveMiniMenuId() { return useSyncExternalStore( (listener) => { miniMenuListeners.add(listener); return () => miniMenuListeners.delete(listener); }, () => activeMiniMenuId, () => null, ); } function MiniMenuTooltip({ children, label, }: { children: ReactElement<{ onClick?: (event: ReactMouseEvent) => void; ref?: Ref; }>; label: string; }) { return ( cloneElement(children, { ref, onClick: (event) => { onClick?.(event); children.props.onClick?.(event); }, }) } /> {label} ); } function MiniMessageMenu({ fadeOut, isOwnMessage, onDelete, onEdit, onOpenMenu, onOpenPicker, usePickerTrigger, onReact, quickReactions, onReply, shiftIsPressed, }: { fadeOut: boolean; isOwnMessage: boolean; onDelete: () => void; onEdit: () => void; onOpenMenu: (event: ReactMouseEvent) => void; onOpenPicker: () => void; usePickerTrigger: boolean; onReact: (emoji: string) => void; quickReactions: string[]; onReply: () => void; shiftIsPressed: boolean; }) { return ( {quickReactions.map((emoji) => ( onReact(emoji)} > } /> ))} ( )} /> ) : ( ) } /> {isOwnMessage ? : } } /> } /> {isOwnMessage && shiftIsPressed ? ( } /> ) : ( } /> )} ); } function MessageMenuContent({ components, content, devEnabled, isOwnMessage, messageId, onAddReaction, reactionEmojis, onReact, showReactionItems = true, onSetEditing, }: { components: MenuComponents; content: string; devEnabled: boolean; isOwnMessage: boolean; messageId: number; onAddReaction?: () => void | Promise; reactionEmojis: string[]; onReact: (emoji: string) => void; showReactionItems?: boolean; onSetEditing: (value: boolean) => void; }) { const { Content, Group, Item, Separator, Sub, SubContent, SubTrigger } = components; const { deleteMessage, setReplyTo } = useChat(); return ( Add Reaction {showReactionItems && ( void onAddReaction?.()} onSelect={onReact} /> )}

Pin Message

copyText(content)} >

Copy Raw

{isOwnMessage && ( { onSetEditing(true); }} >

Edit Message

)} { setReplyTo(messageId); }} >

Reply

Forward

{isOwnMessage && ( <> deleteMessage(messageId)} >

Delete Message

)} {devEnabled && ( <> copyText(String(messageId))} >

Copy ID

)}
); } export default function MessageContextMenu({ children, content, hideMiniMenu = false, isOwnMessage, messageId, onReact, onSetEditing, }: { children: ReactElement; content: string; hideMiniMenu?: boolean; isOwnMessage: boolean; messageId: number; onReact: (emoji: string) => void | Promise; onSetEditing: (value: boolean) => void; }) { const isMobile = useIsMobile(); const shiftIsPressed = useShiftPressed(); const activeMenuId = useActiveMiniMenuId(); const { deleteMessage, setReplyTo } = useChat(); const devEnabled = useMemo( () => Number(localStorage.getItem("log_level")) >= 3, [], ); const [mainDrawerOpen, setMainDrawerOpen] = useState(false); const [contextMenuOpen, setContextMenuOpen] = useState(false); const [reactionDrawerOpen, setReactionDrawerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false); const [pickerClosing, setPickerClosing] = useState(false); const [fadeMiniMenu, setFadeMiniMenu] = useState(false); const { ranks } = useEmojiRanks(); const quickReactions = getRecentEmojis(ranks, 3); const menuReactions = getRecentEmojis(ranks, 5); function selectReaction(emoji: string) { void onReact(emoji); setActiveMiniMenu(null); setReactionDrawerOpen(false); setPickerOpen(false); setPickerClosing(false); } function setPickerVisibility(open: boolean) { setPickerOpen(open); setPickerClosing(!isMobile && !open); } const mainDrawerComponents = useMemo( () => getMobileMenuComponents({ description: "Actions available for this message.", onClose: () => setMainDrawerOpen(false), title: "Message actions", }), [], ); const reactionDrawerComponents = useMemo( () => getMobileMenuComponents({ description: "Choose a reaction to add to this message.", onClose: () => setReactionDrawerOpen(false), title: "Add reaction", }), [], ); function renderMessage( openMenu: (event: ReactMouseEvent) => void, ) { return (
setActiveMiniMenu(messageId)} onPointerLeave={scheduleMiniMenuClose} > {children} setFadeMiniMenu(false)}> {!isMobile && !hideMiniMenu && (activeMenuId === messageId || contextMenuOpen || pickerOpen || pickerClosing) && ( deleteMessage(messageId)} onEdit={() => onSetEditing(true)} onOpenMenu={openMenu} onOpenPicker={() => setPickerVisibility(true)} usePickerTrigger={!isMobile} onReact={selectReaction} quickReactions={quickReactions} onReply={() => setReplyTo(messageId)} shiftIsPressed={shiftIsPressed} /> )}
); } if (isMobile) { const child = renderMessage(() => setMainDrawerOpen(true)); return ( <> {cloneElement(child, { onContextMenu: (event: ReactMouseEvent) => { child.props.onContextMenu?.(event); if (event.defaultPrevented) return; event.preventDefault(); setMainDrawerOpen(true); }, })} setReactionDrawerOpen(true)} onReact={selectReaction} reactionEmojis={menuReactions} showReactionItems={false} onSetEditing={onSetEditing} /> Add reaction Choose a reaction to add to this message.
{ setReactionDrawerOpen(false); setPickerVisibility(true); }} onSelect={selectReaction} />
Choose an emoji Choose an emoji to react with.
); } const child = renderMessage((event) => { event.preventDefault(); event.stopPropagation(); event.currentTarget.dispatchEvent( new MouseEvent("contextmenu", { bubbles: true, button: 2, clientX: event.clientX, clientY: event.clientY, }), ); }); return ( { setPickerVisibility(open); setFadeMiniMenu(!open && eventDetails.reason === "outside-press"); }} onOpenChangeComplete={(open) => { if (!open) setPickerClosing(false); }} > setPickerVisibility(true)} onReact={selectReaction} reactionEmojis={menuReactions} onSetEditing={onSetEditing} /> ); }