From 25e47604b3442ffabf2ce7732dd70d4be92c686a Mon Sep 17 00:00:00 2001 From: Alois Date: Mon, 1 Jun 2026 11:13:13 +0200 Subject: [PATCH] (feat): add message context menu --- packages/chat/src/components/message.tsx | 121 ++++----- .../src/components/messageContextMenu.tsx | 242 ++++++++++++++++++ packages/chat/todo.md | 4 +- 3 files changed, 300 insertions(+), 67 deletions(-) create mode 100644 packages/chat/src/components/messageContextMenu.tsx diff --git a/packages/chat/src/components/message.tsx b/packages/chat/src/components/message.tsx index 18cf792..60afa75 100644 --- a/packages/chat/src/components/message.tsx +++ b/packages/chat/src/components/message.tsx @@ -12,14 +12,9 @@ import { TooltipContent, TooltipTrigger, } from "@tensamin/ui"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, -} from "@tensamin/ui"; import Wrapper from "@tensamin/user/wrapper"; import { useChat } from "../context"; +import MessageContextMenu from "./messageContextMenu"; function MessageComponent({ grouped, @@ -38,72 +33,66 @@ function MessageComponent({ // pt-3 is to get a gap between messages className={`${grouped ? "" : "pt-3"} w-full flex justify-start transition-opacity duration-150 ${actuallyFailed || message.message_state === "awaiting" ? "opacity-50" : ""}`} > - - - ( - <> - {grouped ? ( -

+ +

+ ( + <> + {grouped ? ( +

+ {new Date(message.send_time).toLocaleString([], { + hour: "2-digit", + minute: "2-digit", + })} +

+ ) : ( + + + + {user.display.slice(0, 2).toUpperCase()} + + + )} + {message.failed && message.message_state === "awaiting" && ( + + +

Failed to send message

+
+ } /> +
+ )} +
+ {!grouped && ( +
+

{user.display}

+

{new Date(message.send_time).toLocaleString([], { hour: "2-digit", minute: "2-digit", })}

- ) : ( - - - - {user.display.slice(0, 2).toUpperCase()} - - - )} - {message.failed && message.message_state === "awaiting" && ( - - -

Failed to send message

-
- } /> -
- )} -
- {!grouped && ( -
-

{user.display}

-

- {new Date(message.send_time).toLocaleString([], { - hour: "2-digit", - minute: "2-digit", - })} -

-
- )} -
- - )} - /> -
- } - /> - - - - - - + )} + +
+ + )} + /> +
+ ); } diff --git a/packages/chat/src/components/messageContextMenu.tsx b/packages/chat/src/components/messageContextMenu.tsx new file mode 100644 index 0000000..dcbc707 --- /dev/null +++ b/packages/chat/src/components/messageContextMenu.tsx @@ -0,0 +1,242 @@ +import { + cn, + ContextMenu, + ContextMenuContent, + ContextMenuGroup, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, + Drawer, + DrawerContent, + DrawerTrigger, + useIsMobile, +} from "@tensamin/ui"; +import { Pin, Clipboard, Pen, Reply, Forward, Trash } from "lucide-react"; +import { useMemo, useState } from "react"; +import type { ReactElement, ReactNode } from "react"; + +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; +}; + +const desktopMenuComponents: MenuComponents = { + Content: ContextMenuContent, + Group: ContextMenuGroup, + Item: ContextMenuItem, + Separator: ContextMenuSeparator, + Sub: ContextMenuSub, + SubTrigger: ContextMenuSubTrigger, + SubContent: ContextMenuSubContent, +}; + +function getMobileMenuComponents(onClose: () => void): MenuComponents { + return { + Content: ({ className, children }) => ( + +
{children}
+
+ ), + Group: ({ children }) =>
{children}
, + Item: ({ children, className, disabled, onClick, variant = "default" }) => { + async function handleClick() { + await onClick?.(); + onClose(); + } + + return ( + + ); + }, + Separator: ({ className }) => ( +
+ ), + Sub: ({ children }) =>
{children}
, + SubTrigger: ({ children, onClick }) => { + async function handleClick() { + await onClick?.(); + onClose(); + } + + return ( + + ); + }, + SubContent: ({ children }) =>
{children}
, + }; +} + +function ReactionItems({ Item }: { Item: MenuComponents["Item"] }) { + return Cool item; +} + +function MessageMenuContent({ + components, + content, + devEnabled, + messageId, + onAddReaction, + showReactionItems = true, +}: { + components: MenuComponents; + content: string; + devEnabled: boolean; + messageId: number; + onAddReaction?: () => void | Promise; + showReactionItems?: boolean; +}) { + const { Content, Group, Item, Separator, Sub, SubContent, SubTrigger } = + components; + + return ( + + + + Add Reaction + {showReactionItems && ( + + + + )} + + +

Pin Message

+
+ copyText(content)} + > +

Copy Raw

+
+
+ + + +

Edit Message

+
+ +

Reply

+
+ +

Forward

+
+
+ + + +

Delete Message

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

Copy ID

+
+
+ + )} +
+ ); +} + +export default function MessageContextMenu({ + children, + content, + messageId, +}: { + children: ReactElement; + content: string; + messageId: number; +}) { + const isMobile = useIsMobile(); + const devEnabled = useMemo( + () => Number(localStorage.getItem("log_level")) >= 3, + [], + ); + const [mainDrawerOpen, setMainDrawerOpen] = useState(false); + const [reactionDrawerOpen, setReactionDrawerOpen] = useState(false); + const mainDrawerComponents = getMobileMenuComponents(() => + setMainDrawerOpen(false), + ); + const reactionDrawerComponents = getMobileMenuComponents(() => + setReactionDrawerOpen(false), + ); + + if (isMobile) { + return ( + <> + + {children} + setReactionDrawerOpen(true)} + showReactionItems={false} + /> + + + + + + + + ); + } + + return ( + + + + + ); +} diff --git a/packages/chat/todo.md b/packages/chat/todo.md index 122f14e..4bd036a 100644 --- a/packages/chat/todo.md +++ b/packages/chat/todo.md @@ -1 +1,3 @@ -- Add context menu to messages +- Implement context menu features +- Add default-emoji-hotkey in settings +- On mobile, turn ContextMenu into Drawer