diff --git a/LOTUS_FEATURES.md b/LOTUS_FEATURES.md index 5a3a31ef9..2a578161f 100644 --- a/LOTUS_FEATURES.md +++ b/LOTUS_FEATURES.md @@ -715,7 +715,9 @@ KaTeX-rendered math in messages, two paths: ### Image / Video Captions -Images and videos can be sent with a caption. The caption and media are sent as a single event. +Images and videos can be sent with a caption. The caption and media are sent as a single event (caption = the event `body` when it differs from `filename`). + +- **Edit caption**: an image/video you sent shows an **Edit caption** action (quick-actions pencil + message menu). It opens the message editor seeded with the current caption; saving sends an `m.replace` whose `m.new_content` preserves the media (`url`/`info`/encrypted `file`/`filename`) and only changes `body`/`formatted_body`. An empty caption removes it (`body` falls back to `filename`). Gated by `canEditCaption` (`utils/room.ts`) to your own image/video messages; the media stays visible above the editor, and caption edits appear in Edit History (diffed by the word-diff). No re-upload — encrypted media keeps its original file/key. ### Location Sharing diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 9a1d15f63..06a09a0a3 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -50,7 +50,8 @@ import { UsernameBold, } from '../../../components/message'; import { - canEditEvent, + canEditCaption, + canEditEventOrCaption, getEventEdits, getMemberAvatarMxc, getMemberName, @@ -903,17 +904,22 @@ export const Message = React.memo( {reply} {edit && onEditId ? ( - onEditId()} - /> + <> + {/* Editing a media caption: keep the media visible for context; the + editor below edits only the caption. */} + {canEditCaption(mx, mEvent) && children} + onEditId()} + /> + ) : ( children )} @@ -1072,13 +1078,13 @@ export const Message = React.memo( )} - {canEditEvent(mx, mEvent) && onEditId && ( + {canEditEventOrCaption(mx, mEvent) && onEditId && ( onEditId(mEvent.getId())} variant="SurfaceVariant" size="300" radii="300" - aria-label="Edit message" + aria-label={canEditCaption(mx, mEvent) ? 'Edit caption' : 'Edit message'} > @@ -1247,7 +1253,7 @@ export const Message = React.memo( )} - {canEditEvent(mx, mEvent) && onEditId && ( + {canEditEventOrCaption(mx, mEvent) && onEditId && ( } @@ -1264,7 +1270,7 @@ export const Message = React.memo( size="T300" truncate > - Edit Message + {canEditCaption(mx, mEvent) ? 'Edit Caption' : 'Edit Message'} )} diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx index f1fee7ccd..065f8dec9 100644 --- a/src/app/features/room/message/MessageEditor.tsx +++ b/src/app/features/room/message/MessageEditor.tsx @@ -21,7 +21,7 @@ import { } from 'folds'; import { Editor, Transforms } from 'slate'; import { ReactEditor } from 'slate-react'; -import { IContent, IMentions, MatrixEvent, RelationType, Room } from 'matrix-js-sdk'; +import { IContent, IMentions, MatrixEvent, MsgType, RelationType, Room } from 'matrix-js-sdk'; import { isKeyHotkey } from 'is-hotkey'; import { AUTOCOMPLETE_PREFIXES, @@ -75,6 +75,15 @@ export const MessageEditor = as<'div', MessageEditorProps>( // message is being edited (a11y, P3-4). const editSenderId = mEvent.getSender(); const editSenderName = editSenderId ? getMemberName(room, editSenderId) : ''; + // Image/video messages carry an optional caption in `body` (a caption exists + // when body !== filename). Editing such a message edits the caption while + // preserving the media, rather than replacing the content with text. + const editMsgType = mEvent.getContent().msgtype; + const isMediaCaption = editMsgType === MsgType.Image || editMsgType === MsgType.Video; + const editFilename = + typeof mEvent.getContent().filename === 'string' + ? (mEvent.getContent().filename as string) + : undefined; const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar'); const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown'); @@ -118,6 +127,61 @@ export const MessageEditor = as<'div', MessageEditorProps>( }), ); + // Media caption edit: preserve the media, change only body/formatted_body. + // An empty caption is valid (it removes the caption → body falls back to + // the filename). + if (isMediaCaption) { + const evtId = mEvent.getId()!; + const evtTimeline = room.getTimelineForEvent(evtId); + const editedEvent = + evtTimeline && getEditedEvent(evtId, mEvent, evtTimeline.getTimelineSet()); + const orig: IContent = { + ...(editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()), + }; + delete orig['m.relates_to']; + delete orig['m.new_content']; + + const filename = + typeof orig.filename === 'string' ? orig.filename : (editFilename ?? ''); + const hasFormatting = !customHtmlEqualsPlainText(customHtml, plainText); + + const mediaContent: IContent = { ...orig }; + if (plainText) { + mediaContent.body = plainText; + if (hasFormatting) { + mediaContent.format = 'org.matrix.custom.html'; + mediaContent.formatted_body = customHtml; + } else { + delete mediaContent.format; + delete mediaContent.formatted_body; + } + } else { + // Caption removed. + mediaContent.body = filename; + delete mediaContent.format; + delete mediaContent.formatted_body; + } + + // No-op guard: nothing changed. + if ( + mediaContent.body === orig.body && + mediaContent.formatted_body === orig.formatted_body + ) { + return undefined; + } + + const content: IContent = { + ...mediaContent, + 'm.new_content': mediaContent, + 'm.relates_to': { + event_id: evtId, + rel_type: RelationType.Replace, + }, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return mx.sendMessage(roomId, content as any); + } + const [prevBody, prevCustomHtml, prevMentions] = getPrevBodyAndFormattedBody(); if (plainText === '') return undefined; @@ -165,7 +229,17 @@ export const MessageEditor = as<'div', MessageEditorProps>( // eslint-disable-next-line @typescript-eslint/no-explicit-any return mx.sendMessage(roomId, content as any); - }, [mx, editor, roomId, mEvent, isMarkdown, getPrevBodyAndFormattedBody]), + }, [ + mx, + editor, + roomId, + room, + mEvent, + isMarkdown, + getPrevBodyAndFormattedBody, + isMediaCaption, + editFilename, + ]), ); const handleSave = useCallback(() => { @@ -220,10 +294,19 @@ export const MessageEditor = as<'div', MessageEditorProps>( useEffect(() => { const [body, customHtml] = getPrevBodyAndFormattedBody(); + // For media, seed from the caption only: an empty caption is `body === + // filename`, so don't prefill the filename into the editor. + let seedText = typeof body === 'string' ? body : ''; + let seedHtml = typeof customHtml === 'string' ? customHtml : undefined; + if (isMediaCaption && seedText === editFilename) { + seedText = ''; + seedHtml = undefined; + } + const initialValue = - typeof customHtml === 'string' - ? htmlToEditorInput(customHtml, isMarkdown) - : plainToEditorInput(typeof body === 'string' ? body : '', isMarkdown); + seedHtml !== undefined + ? htmlToEditorInput(seedHtml, isMarkdown) + : plainToEditorInput(seedText, isMarkdown); Transforms.select(editor, { anchor: Editor.start(editor, []), @@ -232,7 +315,7 @@ export const MessageEditor = as<'div', MessageEditorProps>( editor.insertFragment(initialValue); if (!mobileOrTablet()) ReactEditor.focus(editor); - }, [editor, getPrevBodyAndFormattedBody, isMarkdown]); + }, [editor, getPrevBodyAndFormattedBody, isMarkdown, isMediaCaption, editFilename]); useEffect(() => { if (saveState.status === AsyncStatus.Success) { @@ -268,8 +351,14 @@ export const MessageEditor = as<'div', MessageEditorProps>( )} { ); }; +/** + * Whether the current user can edit the *caption* of a media message. Captions + * are authored for image/video only (see msgContent.ts): the caption is the + * event `body` when it differs from `filename`. Editing only changes the caption + * — the media (url/info/file) is preserved. + */ +export const canEditCaption = (mx: MatrixClient, mEvent: MatrixEvent) => { + const content = mEvent.getContent(); + const relationType = content['m.relates_to']?.rel_type; + return ( + mEvent.getSender() === mx.getUserId() && + (!relationType || relationType === RelationType.Thread) && + mEvent.getType() === MessageEvent.RoomMessage && + (content.msgtype === MsgType.Image || content.msgtype === MsgType.Video) + ); +}; + +/** Editable as a text message, or has an editable media caption. */ +export const canEditEventOrCaption = (mx: MatrixClient, mEvent: MatrixEvent) => + canEditEvent(mx, mEvent) || canEditCaption(mx, mEvent); + export const getLatestEditableEvt = ( timeline: EventTimeline, canEdit: (mEvent: MatrixEvent) => boolean,