From 725313673b81a16e73db3fd9e99c551a249fa1c7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 12 Apr 2026 22:43:07 +0000 Subject: [PATCH 1/6] feat(chat): add matrix message edit from hover toolbar Wire m.replace edits for own plain text messages: pencil between react and reply, composer edit preview, and editRoomMessage on the Matrix provider. Co-authored-by: webguru-hypha --- .../client/providers/matrix-provider.tsx | 73 ++++++++++ .../human-chat-panel-chat-bar.tsx | 42 ++++++ .../human-chat-panel-message-bubble.tsx | 15 +++ .../human-chat-panel-messages.tsx | 12 ++ .../epics/src/common/human-right-panel.tsx | 125 ++++++++++++++---- packages/i18n/src/messages/de.json | 3 + packages/i18n/src/messages/en.json | 3 + packages/i18n/src/messages/es.json | 3 + packages/i18n/src/messages/fr.json | 3 + packages/i18n/src/messages/pt.json | 3 + 10 files changed, 256 insertions(+), 26 deletions(-) diff --git a/packages/core/src/matrix/client/providers/matrix-provider.tsx b/packages/core/src/matrix/client/providers/matrix-provider.tsx index 80ffdb381f..b8ef21f8bb 100644 --- a/packages/core/src/matrix/client/providers/matrix-provider.tsx +++ b/packages/core/src/matrix/client/providers/matrix-provider.tsx @@ -43,6 +43,13 @@ interface SendMessageInput { onUploadProgress?: (p: SendMessageUploadProgress) => void; } +export interface EditRoomMessageInput { + roomId: string; + /** Timeline id of the `m.room.message` to replace (not an edit event id). */ + targetEventId: string; + message: 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. @@ -169,6 +176,7 @@ interface MatrixContextType { isAuthenticated: boolean; createRoom: (title: string) => Promise<{ roomId: string }>; sendMessage: (params: SendMessageInput) => Promise; + editRoomMessage: (params: EditRoomMessageInput) => Promise; toggleReaction: (params: ToggleReactionInput) => Promise; getRoomMessages: (roomId: string) => Message[] | null; getPinnedMessageIds: (roomId: string) => string[]; @@ -615,6 +623,67 @@ export const MatrixProvider: React.FC = ({ children }) => { [client], ); + const editRoomMessage = React.useCallback( + async ({ roomId, targetEventId, message }: EditRoomMessageInput) => { + if (!client) { + throw new Error('Client should be specified'); + } + const trimmed = message.trim(); + if (!trimmed || !targetEventId?.trim()) { + return; + } + + const room = client.getRoom(roomId); + if (!room) { + throw new Error('Room not found'); + } + + const ev = room.findEventById(targetEventId); + if (!ev) { + throw new Error('Message not found'); + } + + const wireRel = ev.getWireContent()?.['m.relates_to'] as + | { rel_type?: string } + | undefined; + if (wireRel?.rel_type === MatrixSdk.RelationType.Replace) { + throw new Error('Cannot edit an edit event'); + } + + if (ev.getType() !== MatrixSdk.EventType.RoomMessage) { + throw new Error('Only room messages can be edited'); + } + + const orig = ev.getOriginalContent() as { + msgtype?: string; + }; + const msgtype = orig.msgtype; + if ( + msgtype !== MatrixSdk.MsgType.Text && + msgtype !== MatrixSdk.MsgType.Emote && + msgtype !== MatrixSdk.MsgType.Notice + ) { + throw new Error('Only text messages can be edited'); + } + + const newInner = { + msgtype: MatrixSdk.MsgType.Text, + ...matrixTextEventContentWithOptionalFormatting(message), + }; + + await client.sendEvent(roomId, MatrixSdk.EventType.RoomMessage, { + body: `* ${trimmed}`, + msgtype: MatrixSdk.MsgType.Text, + 'm.new_content': newInner, + 'm.relates_to': { + rel_type: MatrixSdk.RelationType.Replace, + event_id: targetEventId, + }, + } as RoomMessageEventContent); + }, + [client], + ); + const getPinnedMessageIds = React.useCallback( (roomId: string): string[] => { if (!client) { @@ -905,6 +974,7 @@ export const MatrixProvider: React.FC = ({ children }) => { isAuthenticated, createRoom, sendMessage, + editRoomMessage, toggleReaction, getRoomMessages, getPinnedMessageIds, @@ -930,6 +1000,9 @@ const noopMatrixContext: MatrixContextType = { sendMessage: async () => { throw new Error('Matrix unavailable'); }, + editRoomMessage: 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 6f7a889018..9d5d1e2464 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 @@ -40,6 +40,11 @@ type ReplyPreview = { onDismiss: () => void; }; +type EditPreview = { + excerpt: string; + onDismiss: () => void; +}; + export type ChatDraftAttachment = { id: string; file: File; @@ -56,6 +61,8 @@ type HumanChatPanelChatBarProps = { channelName?: string; /** Rich reply: composer preview above the textarea */ replyPreview?: ReplyPreview; + /** Editing an existing own message (Matrix `m.replace`). */ + editPreview?: EditPreview; draftAttachments?: ChatDraftAttachment[]; onDraftAttachmentsChange?: (next: ChatDraftAttachment[]) => void; }; @@ -217,6 +224,7 @@ export function HumanChatPanelChatBar({ placeholder, channelName, replyPreview, + editPreview, draftAttachments = [], onDraftAttachmentsChange, }: HumanChatPanelChatBarProps) { @@ -228,6 +236,7 @@ export function HumanChatPanelChatBar({ const textareaRef = useRef(null); const composerShellRef = useRef(null); const replyPreviewWasOpenRef = useRef(false); + const editPreviewWasOpenRef = useRef(false); const [emojiPickerOpen, setEmojiPickerOpen] = useState(false); const [colonOpen, setColonOpen] = useState(false); const [colonSuggestions, setColonSuggestions] = useState( @@ -280,6 +289,14 @@ export function HumanChatPanelChatBar({ replyPreviewWasOpenRef.current = isOpen; }, [replyPreview]); + useEffect(() => { + const isOpen = Boolean(editPreview); + if (isOpen && !editPreviewWasOpenRef.current) { + textareaRef.current?.focus(); + } + editPreviewWasOpenRef.current = isOpen; + }, [editPreview]); + const autoResize = useCallback(() => { if (textareaRef.current) { textareaRef.current.style.height = 'auto'; @@ -794,6 +811,31 @@ export function HumanChatPanelChatBar({ )} + {editPreview && ( +
+
+

+ + {t('editingMessage')} + + + {editPreview.excerpt} +

+
+ +
+ )} {colonOpen && colonSuggestions.length > 0 && (
void; + /** When set, Edit is enabled (own text messages only; parent omits otherwise). */ + onEdit?: () => void; /** When set, user can open react picker (omit for welcome). */ onReact?: (emoji: string) => void | Promise; }; @@ -706,6 +709,7 @@ export function HumanChatPanelMessageBubble({ message, isStreaming, onReply, + onEdit, onReact, }: HumanChatPanelMessageBubbleProps) { const t = useTranslations('HumanChatPanel'); @@ -758,6 +762,7 @@ export function HumanChatPanelMessageBubble({ const reactions = message.reactions ?? []; const isSendPendingRow = Boolean(message.sendPending); const canReply = Boolean(onReply) && !isSendPendingRow; + const canEdit = Boolean(onEdit) && !isSendPendingRow; const canReact = Boolean(onReact) && !isSendPendingRow; const sendPendingMainLabel = (() => { @@ -1275,6 +1280,16 @@ export function HumanChatPanelMessageBubble({ +