diff --git a/packages/core/src/matrix/client/providers/matrix-provider.tsx b/packages/core/src/matrix/client/providers/matrix-provider.tsx index 9985666a78..41d81d55f7 100644 --- a/packages/core/src/matrix/client/providers/matrix-provider.tsx +++ b/packages/core/src/matrix/client/providers/matrix-provider.tsx @@ -52,6 +52,10 @@ export interface EditRoomMessageInput { message: string; } +export interface RedactRoomEventInput { + roomId: string; + eventId: string; +} /** * Thrown when some attachment events were committed but a later send step failed. * Callers should restore only `attachments.slice(sentAttachmentCount)` and optionally caption text. @@ -179,6 +183,7 @@ interface MatrixContextType { createRoom: (title: string) => Promise<{ roomId: string }>; sendMessage: (params: SendMessageInput) => Promise; editRoomMessage: (params: EditRoomMessageInput) => Promise; + redactRoomEvent: (params: RedactRoomEventInput) => Promise; toggleReaction: (params: ToggleReactionInput) => Promise; getRoomMessages: (roomId: string) => Message[] | null; getPinnedMessageIds: (roomId: string) => string[]; @@ -665,7 +670,7 @@ export const MatrixProvider: React.FC = ({ children }) => { const sender = targetEv.getSender(); const uid = client.getUserId(); if (!sender || !uid || sender !== uid) { - throw new Error('You can only edit your own messages'); + throw new Error('Cannot edit events you do not own'); } const originalContent = targetEv.getContent() as { @@ -684,7 +689,7 @@ export const MatrixProvider: React.FC = ({ children }) => { if (replyToId?.trim()) { const { - eventId: resolvedTargetId, + eventId: resolvedReplyTargetId, sender: replyTargetSender, body: targetBody, } = await resolveReplyTargetForSend(client, roomId, replyToId); @@ -698,7 +703,7 @@ export const MatrixProvider: React.FC = ({ children }) => { ...rich, 'm.relates_to': { 'm.in_reply_to': { - event_id: resolvedTargetId, + event_id: resolvedReplyTargetId, }, }, } as RoomMessageEventContent; @@ -726,6 +731,35 @@ export const MatrixProvider: React.FC = ({ children }) => { [client], ); + const redactRoomEvent = React.useCallback( + async ({ roomId, eventId }: RedactRoomEventInput) => { + if (!client) { + throw new Error('Client should be specified'); + } + if (!roomId?.trim() || !eventId?.trim()) { + return; + } + const room = client.getRoom(roomId); + if (!room) { + throw new Error('Room not found'); + } + const ev = + room.findEventById(eventId) ?? + (typeof room.getPendingEvent === 'function' + ? room.getPendingEvent(eventId) + : null); + if (!ev) { + throw new Error('Message not found'); + } + const uid = client.getUserId(); + const sender = ev.getSender(); + if (!uid || !sender || sender !== uid) { + throw new Error('Cannot redact events you do not own'); + } + await client.redactEvent(roomId, eventId); + }, + [client], + ); const getPinnedMessageIds = React.useCallback( (roomId: string): string[] => { if (!client) { @@ -1079,6 +1113,7 @@ export const MatrixProvider: React.FC = ({ children }) => { createRoom, sendMessage, editRoomMessage, + redactRoomEvent, toggleReaction, getRoomMessages, getPinnedMessageIds, @@ -1107,6 +1142,9 @@ const noopMatrixContext: MatrixContextType = { editRoomMessage: async () => { throw new Error('Matrix unavailable'); }, + redactRoomEvent: async () => { + throw new Error('Matrix unavailable'); + }, toggleReaction: async () => { throw new Error('Matrix unavailable'); }, diff --git a/packages/epics/src/common/human-chat-panel/human-chat-panel-chat-bar.tsx b/packages/epics/src/common/human-chat-panel/human-chat-panel-chat-bar.tsx index 9d5d1e2464..aab6edde4c 100644 --- a/packages/epics/src/common/human-chat-panel/human-chat-panel-chat-bar.tsx +++ b/packages/epics/src/common/human-chat-panel/human-chat-panel-chat-bar.tsx @@ -281,29 +281,35 @@ export function HumanChatPanelChatBar({ }); }, [colonOpen]); + const autoResize = useCallback(() => { + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = + Math.min(textareaRef.current.scrollHeight, 160) + 'px'; + } + }, []); + useEffect(() => { const isOpen = Boolean(replyPreview); - if (isOpen && !replyPreviewWasOpenRef.current) { - textareaRef.current?.focus(); + if (isOpen) { + if (!replyPreviewWasOpenRef.current) { + textareaRef.current?.focus(); + } + autoResize(); } replyPreviewWasOpenRef.current = isOpen; - }, [replyPreview]); + }, [replyPreview, autoResize]); useEffect(() => { const isOpen = Boolean(editPreview); - if (isOpen && !editPreviewWasOpenRef.current) { - textareaRef.current?.focus(); + if (isOpen) { + if (!editPreviewWasOpenRef.current) { + textareaRef.current?.focus(); + } + autoResize(); } editPreviewWasOpenRef.current = isOpen; - }, [editPreview]); - - const autoResize = useCallback(() => { - if (textareaRef.current) { - textareaRef.current.style.height = 'auto'; - textareaRef.current.style.height = - Math.min(textareaRef.current.scrollHeight, 160) + 'px'; - } - }, []); + }, [editPreview, autoResize]); const syncColonState = useCallback((val: string, cursor: number) => { const requestId = ++colonRequestIdRef.current; @@ -343,6 +349,10 @@ export function HumanChatPanelChatBar({ syncColonState(value, el.selectionStart ?? value.length); }, [value, syncColonState]); + useEffect(() => { + autoResize(); + }, [value, autoResize]); + const applyColonChoice = useCallback( (entry: EmojiIndexEntry) => { const el = textareaRef.current; diff --git a/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx b/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx index 3103914370..127e25fb4c 100644 --- a/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx +++ b/packages/epics/src/common/human-chat-panel/human-chat-panel-message-bubble.tsx @@ -1,6 +1,7 @@ 'use client'; import { Fragment, useMemo, useRef, useState } from 'react'; +import type { ReactNode } from 'react'; import { useFormatter, useTranslations } from 'next-intl'; import type { TranslationValues } from 'next-intl'; import { @@ -8,7 +9,6 @@ import { SmilePlus, Pencil, Reply, - MoreHorizontal, FileIcon, ExternalLink, Image as ImageIcon, @@ -20,6 +20,10 @@ import { useMatrix } from '@hypha-platform/core/client'; import { PersonAvatar } from '../../people/components/person-avatar'; import { HumanChatPanelEmojiPicker } from './human-chat-panel-emoji-picker'; +import { + HumanChatPanelMessageOverflow, + pushRecentChatReaction, +} from './human-chat-panel-message-overflow'; import { ChatMessageRichText } from './parse-simple-matrix-html'; import { type ChatPanelAttachmentMedia, @@ -490,12 +494,18 @@ type HumanChatPanelMessageBubbleProps = { onRowPointerLeave?: () => void; /** Notify when the hover-bar emoji picker opens/closes (parent may lock visibility). */ onHoverReactPickerOpenChange?: (open: boolean) => void; + /** Active Matrix room (for message link + overflow). */ + roomId?: string | null; + /** Logged-in Matrix user id (delete permission + recent reactions). */ + currentUserId?: string | null; message: { id: string; role: 'user' | 'member'; isSynthetic?: boolean; parts?: UIMessagePart[]; senderName?: string; + /** Author MXID when known (overflow delete). */ + senderMatrixId?: string; avatarUrl?: string; timestamp?: Date; reactions?: Reaction[]; @@ -520,6 +530,7 @@ type HumanChatPanelMessageBubbleProps = { onReply?: () => void; /** When set, Edit is enabled (own text messages only; parent omits otherwise). */ onEdit?: () => void; + onDeleteMessage?: (messageId: string) => void | Promise; /** When set, user can open react picker (omit for welcome). */ onReact?: (emoji: string) => void | Promise; }; @@ -708,8 +719,11 @@ export function HumanChatPanelMessageBubble({ onHoverReactPickerOpenChange, message, isStreaming, + roomId, + currentUserId, onReply, onEdit, + onDeleteMessage, onReact, }: HumanChatPanelMessageBubbleProps) { const t = useTranslations('HumanChatPanel'); @@ -799,7 +813,7 @@ export function HumanChatPanelMessageBubble({ reactions.length - MAX_VISIBLE_REACTIONS, ); - return ( + const row = (moreSlot: ReactNode | null) => (
{ + pushRecentChatReaction(native); void onReact(native); }} ariaLabel={t('addReactionButton')} @@ -1264,7 +1279,10 @@ export function HumanChatPanelMessageBubble({ onHoverReactPickerOpenChange?.(open); }} onEmojiSelect={(native) => { - if (onReact) void onReact(native); + if (onReact) { + pushRecentChatReaction(native); + void onReact(native); + } }} ariaLabel={t('emojiPickerReactToMessage')} align="end" @@ -1300,16 +1318,36 @@ export function HumanChatPanelMessageBubble({ > - + {moreSlot}
); + + if (message.isSynthetic) { + return row(null); + } + + return ( + + {row} + + ); } diff --git a/packages/epics/src/common/human-chat-panel/human-chat-panel-message-overflow.tsx b/packages/epics/src/common/human-chat-panel/human-chat-panel-message-overflow.tsx new file mode 100644 index 0000000000..80241e3152 --- /dev/null +++ b/packages/epics/src/common/human-chat-panel/human-chat-panel-message-overflow.tsx @@ -0,0 +1,506 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import type { ReactNode } from 'react'; +import { + ChevronRight, + Copy, + Link2, + MoreHorizontal, + Pencil, + Reply, + Trash2, + Volume2, +} from 'lucide-react'; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Button, + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@hypha-platform/ui'; +import { cn } from '@hypha-platform/ui-utils'; +import { stripMatrixReplyFallback } from '@hypha-platform/core/client'; + +import { HumanChatPanelEmojiPicker } from './human-chat-panel-emoji-picker'; +import type { ChatPanelAttachmentMedia } from './chat-panel-media-types'; + +const RECENT_REACTIONS_STORAGE_KEY = 'hypha-chat-recent-reactions'; +const RECENT_REACTIONS_BUMP_EVENT = 'hypha-chat-recent-reactions-bump'; +const DEFAULT_QUICK_REACTIONS = ['👍', '🎵', '🙏', '✅'] as const; + +type UIMessagePart = + | { type: 'text'; text: string } + | { type: string; [k: string]: unknown }; + +function readRecentReactions(): string[] { + try { + const raw = localStorage.getItem(RECENT_REACTIONS_STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return []; + return parsed.filter( + (x): x is string => typeof x === 'string' && x.length > 0, + ); + } catch { + return []; + } +} + +export function pushRecentChatReaction(emoji: string) { + try { + const prev = readRecentReactions().filter((e) => e !== emoji); + const next = [emoji, ...prev].slice(0, 32); + localStorage.setItem(RECENT_REACTIONS_STORAGE_KEY, JSON.stringify(next)); + globalThis.dispatchEvent?.(new Event(RECENT_REACTIONS_BUMP_EVENT)); + } catch { + // ignore + } +} + +/** Plain message body for copy / TTS only (no attachment filename fallback). */ +function getMessagePlainTextBody(m: { parts?: UIMessagePart[] }): string { + const textParts = + m.parts?.filter( + (p): p is { type: 'text'; text: string } => p.type === 'text', + ) ?? []; + return stripMatrixReplyFallback(textParts.map((p) => p.text).join('')); +} + +export type HumanChatPanelMessageOverflowProps = { + roomId: string | null; + messageId: string; + /** True for welcome / system rows — no menu. */ + disabled?: boolean; + canReact?: boolean; + onReact?: (emoji: string) => void | Promise; + onEdit?: () => void; + onReply?: () => void; + /** Match hover bar: disable Edit in overflow when false even if `onEdit` is set. */ + menuCanEdit?: boolean; + /** Match hover bar: disable Reply in overflow when false even if `onReply` is set. */ + menuCanReply?: boolean; + onDeleteMessage?: (messageId: string) => void | Promise; + /** Current Matrix user id; used for delete permission. */ + currentUserId?: string | null; + /** Message author MXID when known (for delete permission). */ + senderMatrixId?: string; + message: { + parts?: UIMessagePart[]; + media?: ChatPanelAttachmentMedia; + mediaSlots?: ChatPanelAttachmentMedia[]; + }; + /** + * Row content; pass a function to receive the ⋯ dropdown trigger node + * (place it inside the floating action bar). + */ + children: ReactNode | ((moreMenuTrigger: ReactNode) => ReactNode); +}; + +function useQuickReactions(): string[] { + const [recent, setRecent] = useState([]); + + useEffect(() => { + const refresh = () => setRecent(readRecentReactions()); + refresh(); + const onStorage = (e: StorageEvent) => { + if (e.key === RECENT_REACTIONS_STORAGE_KEY) { + refresh(); + } + }; + globalThis.addEventListener?.('storage', onStorage); + globalThis.addEventListener?.(RECENT_REACTIONS_BUMP_EVENT, refresh); + return () => { + globalThis.removeEventListener?.('storage', onStorage); + globalThis.removeEventListener?.(RECENT_REACTIONS_BUMP_EVENT, refresh); + }; + }, []); + + return useMemo(() => { + const out: string[] = []; + for (const e of recent) { + if (out.length >= 4) break; + if (!out.includes(e)) out.push(e); + } + for (const e of DEFAULT_QUICK_REACTIONS) { + if (out.length >= 4) break; + if (!out.includes(e)) out.push(e); + } + return out.slice(0, 4); + }, [recent]); +} + +function MenuSections({ + t, + quickEmojis, + addReactionOpen, + setAddReactionOpen, + canReact, + onReact, + onEdit, + onReply, + canEdit, + canReply, + canCopy, + onCopyText, + onCopyLink, + matrixToLink, + onSpeak, + canDelete, + onRequestDelete, + Item, + Separator, +}: { + t: (key: string, values?: Record) => string; + quickEmojis: string[]; + addReactionOpen: boolean; + setAddReactionOpen: (o: boolean) => void; + canReact: boolean; + onReact?: (emoji: string) => void | Promise; + onEdit?: () => void; + onReply?: () => void; + canEdit: boolean; + canReply: boolean; + canCopy: boolean; + onCopyText: () => void; + onCopyLink: () => void; + matrixToLink: string; + onSpeak: () => void; + canDelete: boolean; + onRequestDelete: () => void; + Item: typeof ContextMenuItem | typeof DropdownMenuItem; + Separator: typeof ContextMenuSeparator | typeof DropdownMenuSeparator; +}) { + const handleQuickReact = (emoji: string) => { + if (!onReact) return; + pushRecentChatReaction(emoji); + void onReact(emoji); + }; + + return ( + <> +
+ {quickEmojis.map((emoji) => ( + + ))} +
+ { + pushRecentChatReaction(native); + if (onReact) void onReact(native); + }} + ariaLabel={t('addReactionButton')} + align="start" + > + { + e.preventDefault(); + setAddReactionOpen(true); + }} + > + {t('addReactionButton')} + + + + + { + if (canEdit) onEdit?.(); + }} + > + {t('contextEditMessage')} + + + { + if (canReply) onReply?.(); + }} + > + {t('contextReply')} + + + + { + if (canCopy) void onCopyText(); + }} + > + {t('contextCopyText')} + + + { + if (matrixToLink) void onCopyLink(); + }} + > + {t('contextCopyMessageLink')} + + + { + if (canCopy) onSpeak(); + }} + > + {t('contextSpeakMessage')} + + + + { + if (canDelete) onRequestDelete(); + }} + > + {t('contextDeleteMessage')} + + + + ); +} + +export function HumanChatPanelMessageOverflow({ + roomId, + messageId, + disabled = false, + canReact = false, + onReact, + onEdit, + onReply, + menuCanEdit, + menuCanReply, + onDeleteMessage, + currentUserId, + senderMatrixId, + message, + children, +}: HumanChatPanelMessageOverflowProps) { + const t = useTranslations('HumanChatPanel'); + const quickEmojis = useQuickReactions(); + const [addReactionOpen, setAddReactionOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const [dropdownOpen, setDropdownOpen] = useState(false); + const [deleteBusy, setDeleteBusy] = useState(false); + const [deleteError, setDeleteError] = useState(null); + + const plain = useMemo(() => getMessagePlainTextBody(message), [message]); + const canCopy = plain.trim().length > 0; + const canEdit = menuCanEdit ?? Boolean(onEdit); + const canReply = menuCanReply ?? Boolean(onReply); + const canDelete = + Boolean(onDeleteMessage) && + Boolean(roomId) && + Boolean(currentUserId) && + Boolean(senderMatrixId) && + senderMatrixId === currentUserId && + !messageId.startsWith('hypha-send-pending') && + messageId !== 'welcome'; + + const matrixToLink = useMemo(() => { + if (!roomId || !messageId || messageId === 'welcome') return ''; + const encRoom = encodeURIComponent(roomId); + const encEv = encodeURIComponent(messageId); + return `https://matrix.to/#/${encRoom}/${encEv}`; + }, [roomId, messageId]); + + const onCopyText = useCallback(async () => { + if (!canCopy) return; + try { + await navigator.clipboard.writeText(plain); + } catch { + // ignore + } + }, [canCopy, plain]); + + const onCopyLink = useCallback(async () => { + if (!matrixToLink) return; + try { + await navigator.clipboard.writeText(matrixToLink); + } catch { + // ignore + } + }, [matrixToLink]); + + const onSpeak = useCallback(() => { + if (!plain.trim() || typeof globalThis.speechSynthesis === 'undefined') { + return; + } + globalThis.speechSynthesis.cancel(); + const u = new SpeechSynthesisUtterance(plain); + u.lang = document.documentElement.lang || 'en'; + globalThis.speechSynthesis.speak(u); + }, [plain]); + + const confirmDelete = useCallback(async () => { + if (!canDelete || !onDeleteMessage || deleteBusy) return; + setDeleteError(null); + setDeleteBusy(true); + try { + await onDeleteMessage(messageId); + setDeleteOpen(false); + setDropdownOpen(false); + } catch { + setDeleteError(t('messageDeleteFailed')); + } finally { + setDeleteBusy(false); + } + }, [canDelete, deleteBusy, messageId, onDeleteMessage, t]); + + if (disabled) { + return typeof children === 'function' ? ( + <>{children(null)} + ) : ( + <>{children} + ); + } + + const menuProps = { + t, + quickEmojis, + addReactionOpen, + setAddReactionOpen, + canReact, + onReact, + onEdit, + onReply, + canEdit, + canReply, + canCopy, + onCopyText, + onCopyLink, + matrixToLink, + onSpeak, + canDelete, + onRequestDelete: () => setDeleteOpen(true), + } as const; + + const contextMenu = ( + + ); + + const dropdownMenu = ( + + ); + + const moreSlot = ( + + + + + + {dropdownMenu} + + + ); + + const rowInner = + typeof children === 'function' ? children(moreSlot) : children; + + return ( + <> + + {rowInner} + + {contextMenu} + + + + { + setDeleteOpen(open); + if (!open) { + setDeleteError(null); + setDeleteBusy(false); + } + }} + > + + + + {t('contextDeleteConfirmTitle')} + + + {t('contextDeleteConfirmDescription')} + + + {deleteError ? ( +

+ {deleteError} +

+ ) : null} + + + {t('contextDeleteCancel')} + + + +
+
+ + ); +} diff --git a/packages/epics/src/common/human-chat-panel/human-chat-panel-messages.tsx b/packages/epics/src/common/human-chat-panel/human-chat-panel-messages.tsx index 0134a12f10..31921852fb 100644 --- a/packages/epics/src/common/human-chat-panel/human-chat-panel-messages.tsx +++ b/packages/epics/src/common/human-chat-panel/human-chat-panel-messages.tsx @@ -23,6 +23,8 @@ type UIMessage = { /** Multiple attachments in one Matrix event (`org.hypha.media_bundle`). */ mediaSlots?: ChatPanelAttachmentMedia[]; senderName?: string; + /** Matrix author MXID when known. */ + senderMatrixId?: string; avatarUrl?: string; timestamp?: Date; formattedContentHtml?: string; @@ -41,8 +43,11 @@ type UIMessage = { type HumanChatPanelMessagesProps = { messages: UIMessage[]; isStreaming?: boolean; + roomId?: string | null; + currentUserId?: string | null; onReply?: (messageId: string) => void; onEditMessage?: (messageId: string) => void; + onDeleteMessage?: (messageId: string) => void | Promise; onToggleReaction?: (messageId: string, emoji: string) => void; /** Map Matrix user id to display name for reaction hover tooltips. */ resolveReactionReactorLabel?: (userId: string) => string; @@ -51,8 +56,11 @@ type HumanChatPanelMessagesProps = { export function HumanChatPanelMessages({ messages, isStreaming = false, + roomId, + currentUserId, onReply, onEditMessage, + onDeleteMessage, onToggleReaction, resolveReactionReactorLabel, }: HumanChatPanelMessagesProps) { @@ -102,6 +110,8 @@ export function HumanChatPanelMessages({ { @@ -148,6 +158,9 @@ export function HumanChatPanelMessages({ ? () => onEditMessage(msg.id) : undefined } + onDeleteMessage={ + canInteract && onDeleteMessage ? onDeleteMessage : undefined + } onReact={ canInteract && onToggleReaction ? (emoji: string) => onToggleReaction(msg.id, emoji) diff --git a/packages/epics/src/common/human-right-panel.tsx b/packages/epics/src/common/human-right-panel.tsx index 8fae4829b8..fafbc7c6c6 100644 --- a/packages/epics/src/common/human-right-panel.tsx +++ b/packages/epics/src/common/human-right-panel.tsx @@ -279,6 +279,7 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { const [error, setError] = useState(null); const [reactionError, setReactionError] = useState(null); const [composerError, setComposerError] = useState(null); + const [deleteError, setDeleteError] = useState(null); const [activeTab, setActiveTab] = useState('chat'); /** Shown in timeline after a short delay while large attachment sends run. */ const [sendingPending, setSendingPending] = useState { + if (!roomId) return; + setDeleteError(null); + try { + await matrixRef.current.redactRoomEvent({ roomId, eventId: messageId }); + if (editDraft?.messageId === messageId) { + setEditDraft(null); + setInput(''); + } + if (replyDraft?.messageId === messageId) { + setReplyDraft(null); + } + } catch (err) { + console.error('[HumanRightPanel] Failed to delete message:', err); + setDeleteError(t('messageDeleteFailed')); + } + }, + [roomId, editDraft?.messageId, replyDraft?.messageId, t], + ); + const handleSend = useCallback(async () => { if (!roomId) return; const trimmed = input.trim(); @@ -964,6 +986,14 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { {reactionError} )} + {deleteError && ( +
+ {deleteError} +
+ )} {isJoining ? (
@@ -973,8 +1003,11 @@ export function HumanRightPanel({ useMembers }: HumanRightPanelProps) { ) : ( resolveMemberLabel(userId) diff --git a/packages/i18n/src/messages/de.json b/packages/i18n/src/messages/de.json index 3bd3e765e0..6a6115dc2e 100644 --- a/packages/i18n/src/messages/de.json +++ b/packages/i18n/src/messages/de.json @@ -1635,6 +1635,18 @@ "editingMessage": "Nachricht bearbeiten", "editDismiss": "Bearbeitung abbrechen", "editAttachmentsNotSupported": "Anhänge werden beim Bearbeiten einer Nachricht nicht unterstützt", + "contextReactWith": "Reagieren mit {emoji}", + "contextEditMessage": "Nachricht bearbeiten", + "contextReply": "Antworten", + "contextCopyText": "Text kopieren", + "contextCopyMessageLink": "Nachrichtenlink kopieren", + "contextSpeakMessage": "Nachricht laut vorlesen", + "contextDeleteMessage": "Nachricht löschen", + "contextDeleteConfirmTitle": "Diese Nachricht löschen?", + "contextDeleteConfirmDescription": "Die Nachricht wird für alle im Chat entfernt. Dies kann nicht rückgängig gemacht werden.", + "contextDeleteCancel": "Abbrechen", + "contextDeleteConfirm": "Löschen", + "messageDeleteFailed": "Nachricht konnte nicht gelöscht werden. Bitte erneut versuchen.", "replyingTo": "Antwort an {author}", "replyDismiss": "Antwort abbrechen", "replyOriginalUnavailable": "Ursprüngliche Nachricht nicht verfügbar", diff --git a/packages/i18n/src/messages/en.json b/packages/i18n/src/messages/en.json index c5691430e5..d2897070ef 100644 --- a/packages/i18n/src/messages/en.json +++ b/packages/i18n/src/messages/en.json @@ -1636,6 +1636,18 @@ "editingMessage": "Editing message", "editDismiss": "Cancel edit", "editAttachmentsNotSupported": "Attachments are not supported when editing a message", + "contextReactWith": "React with {emoji}", + "contextEditMessage": "Edit message", + "contextReply": "Reply", + "contextCopyText": "Copy text", + "contextCopyMessageLink": "Copy message link", + "contextSpeakMessage": "Read message aloud", + "contextDeleteMessage": "Delete message", + "contextDeleteConfirmTitle": "Delete this message?", + "contextDeleteConfirmDescription": "This removes the message for everyone in the chat. You cannot undo this.", + "contextDeleteCancel": "Cancel", + "contextDeleteConfirm": "Delete", + "messageDeleteFailed": "Could not delete message. Try again.", "replyingTo": "Replying to {author}", "replyDismiss": "Cancel reply", "replyOriginalUnavailable": "Original message unavailable", diff --git a/packages/i18n/src/messages/es.json b/packages/i18n/src/messages/es.json index 49d57e2e74..fabd0b07e2 100644 --- a/packages/i18n/src/messages/es.json +++ b/packages/i18n/src/messages/es.json @@ -1635,6 +1635,18 @@ "editingMessage": "Editando mensaje", "editDismiss": "Cancelar edición", "editAttachmentsNotSupported": "No se admiten archivos adjuntos al editar un mensaje", + "contextReactWith": "Reaccionar con {emoji}", + "contextEditMessage": "Editar mensaje", + "contextReply": "Responder", + "contextCopyText": "Copiar texto", + "contextCopyMessageLink": "Copiar enlace del mensaje", + "contextSpeakMessage": "Leer en voz alta", + "contextDeleteMessage": "Eliminar mensaje", + "contextDeleteConfirmTitle": "¿Eliminar este mensaje?", + "contextDeleteConfirmDescription": "Se eliminará el mensaje para todos en el chat. No se puede deshacer.", + "contextDeleteCancel": "Cancelar", + "contextDeleteConfirm": "Eliminar", + "messageDeleteFailed": "No se pudo eliminar el mensaje. Inténtalo de nuevo.", "replyingTo": "Respondiendo a {author}", "replyDismiss": "Cancelar respuesta", "replyOriginalUnavailable": "Mensaje original no disponible", diff --git a/packages/i18n/src/messages/fr.json b/packages/i18n/src/messages/fr.json index 4679c1a175..9ac205627e 100644 --- a/packages/i18n/src/messages/fr.json +++ b/packages/i18n/src/messages/fr.json @@ -1635,6 +1635,18 @@ "editingMessage": "Modification du message", "editDismiss": "Annuler la modification", "editAttachmentsNotSupported": "Les pièces jointes ne sont pas prises en charge lors de la modification d’un message", + "contextReactWith": "Réagir avec {emoji}", + "contextEditMessage": "Modifier le message", + "contextReply": "Répondre", + "contextCopyText": "Copier le texte", + "contextCopyMessageLink": "Copier le lien du message", + "contextSpeakMessage": "Lire à voix haute", + "contextDeleteMessage": "Supprimer le message", + "contextDeleteConfirmTitle": "Supprimer ce message ?", + "contextDeleteConfirmDescription": "Le message sera supprimé pour tout le monde dans le chat. Cette action est irréversible.", + "contextDeleteCancel": "Annuler", + "contextDeleteConfirm": "Supprimer", + "messageDeleteFailed": "Impossible de supprimer le message. Réessayez.", "replyingTo": "Réponse à {author}", "replyDismiss": "Annuler la réponse", "replyOriginalUnavailable": "Message d’origine indisponible", diff --git a/packages/i18n/src/messages/pt.json b/packages/i18n/src/messages/pt.json index 3cec609753..251d020609 100644 --- a/packages/i18n/src/messages/pt.json +++ b/packages/i18n/src/messages/pt.json @@ -1635,6 +1635,18 @@ "editingMessage": "Editando mensagem", "editDismiss": "Cancelar edição", "editAttachmentsNotSupported": "Anexos não são suportados ao editar uma mensagem", + "contextReactWith": "Reagir com {emoji}", + "contextEditMessage": "Editar mensagem", + "contextReply": "Responder", + "contextCopyText": "Copiar texto", + "contextCopyMessageLink": "Copiar link da mensagem", + "contextSpeakMessage": "Ler em voz alta", + "contextDeleteMessage": "Excluir mensagem", + "contextDeleteConfirmTitle": "Excluir esta mensagem?", + "contextDeleteConfirmDescription": "A mensagem será removida para todos no chat. Não é possível desfazer.", + "contextDeleteCancel": "Cancelar", + "contextDeleteConfirm": "Excluir", + "messageDeleteFailed": "Não foi possível excluir a mensagem. Tente novamente.", "replyingTo": "Respondendo a {author}", "replyDismiss": "Cancelar resposta", "replyOriginalUnavailable": "Mensagem original indisponível", diff --git a/packages/ui/package.json b/packages/ui/package.json index dac52603ea..d19ef92f03 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -14,7 +14,9 @@ "@hypha-platform/ui-utils": "workspace:*", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-context-menu": "^2.2.4", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-scroll-area": "^1.2.2", "@radix-ui/react-separator": "^1.1.6", diff --git a/packages/ui/src/context-menu.tsx b/packages/ui/src/context-menu.tsx new file mode 100644 index 0000000000..588e7c9684 --- /dev/null +++ b/packages/ui/src/context-menu.tsx @@ -0,0 +1,230 @@ +'use client'; + +import * as React from 'react'; +import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'; +import { Check, ChevronRight, Circle } from 'lucide-react'; + +import { cn } from '@hypha-platform/ui-utils'; + +const ContextMenu = ContextMenuPrimitive.Root; + +const ContextMenuTrigger = ContextMenuPrimitive.Trigger; + +const ContextMenuGroup = ContextMenuPrimitive.Group; + +const ContextMenuPortal = ContextMenuPrimitive.Portal; + +const ContextMenuSub = ContextMenuPrimitive.Sub; + +const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; + +export interface ContextMenuSubTriggerProps + extends React.ComponentPropsWithoutRef< + typeof ContextMenuPrimitive.SubTrigger + > { + inset?: boolean; +} + +const ContextMenuSubTrigger = React.forwardRef< + React.ElementRef, + ContextMenuSubTriggerProps +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName; + +export type ContextMenuSubContentProps = React.ComponentPropsWithoutRef< + typeof ContextMenuPrimitive.SubContent +>; + +const ContextMenuSubContent = React.forwardRef< + React.ElementRef, + ContextMenuSubContentProps +>(({ className, ...props }, ref) => ( + +)); +ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName; + +export type ContextMenuContentProps = React.ComponentPropsWithoutRef< + typeof ContextMenuPrimitive.Content +>; + +const ContextMenuContent = React.forwardRef< + React.ElementRef, + ContextMenuContentProps +>(({ className, ...props }, ref) => ( + + + +)); +ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName; + +export interface ContextMenuItemProps + extends React.ComponentPropsWithoutRef { + inset?: boolean; +} + +const ContextMenuItem = React.forwardRef< + React.ElementRef, + ContextMenuItemProps +>(({ className, inset, ...props }, ref) => ( + +)); +ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName; + +export type ContextMenuCheckboxItemProps = React.ComponentPropsWithoutRef< + typeof ContextMenuPrimitive.CheckboxItem +>; + +const ContextMenuCheckboxItem = React.forwardRef< + React.ElementRef, + ContextMenuCheckboxItemProps +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +ContextMenuCheckboxItem.displayName = + ContextMenuPrimitive.CheckboxItem.displayName; + +export type ContextMenuRadioItemProps = React.ComponentPropsWithoutRef< + typeof ContextMenuPrimitive.RadioItem +>; + +const ContextMenuRadioItem = React.forwardRef< + React.ElementRef, + ContextMenuRadioItemProps +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName; + +export interface ContextMenuLabelProps + extends React.ComponentPropsWithoutRef { + inset?: boolean; +} + +const ContextMenuLabel = React.forwardRef< + React.ElementRef, + ContextMenuLabelProps +>(({ className, inset, ...props }, ref) => ( + +)); +ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName; + +export type ContextMenuSeparatorProps = React.ComponentPropsWithoutRef< + typeof ContextMenuPrimitive.Separator +>; + +const ContextMenuSeparator = React.forwardRef< + React.ElementRef, + ContextMenuSeparatorProps +>(({ className, ...props }, ref) => ( + +)); +ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName; + +export type ContextMenuShortcutProps = React.HTMLAttributes; + +const ContextMenuShortcut = ({ + className, + ...props +}: ContextMenuShortcutProps) => { + return ( + + ); +}; +ContextMenuShortcut.displayName = 'ContextMenuShortcut'; + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +}; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 3ad6b92dc9..962d91120e 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -23,6 +23,7 @@ export * from './container'; export * from './date-picker'; export * from './disposable-label'; export * from './dropdown-menu'; +export * from './context-menu'; export * from './error-alert'; export * from './form'; export * from './image'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce3b5d6024..7b55f8eb00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1168,9 +1168,15 @@ importers: '@radix-ui/react-checkbox': specifier: ^1.3.3 version: 1.3.3(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-context-menu': + specifier: ^2.2.4 + version: 2.2.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) '@radix-ui/react-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.4 + version: 2.1.7(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) '@radix-ui/react-radio-group': specifier: ^1.3.8 version: 1.3.8(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)