feat(captions): edit image/video captions after sending

Captions could be attached at upload but never changed — canEditEvent
only allowed m.text/emote/notice, so a typo in an image caption meant
delete + re-upload. Add caption editing for image/video messages.

- utils/room.ts: canEditCaption (own image/video RoomMessage, no
  non-thread relation) + canEditEventOrCaption. canEditEvent unchanged.
- Message.tsx: gate the Edit affordance (quick-actions + menu) on
  canEditEventOrCaption; label it "Edit caption" for media; keep the media
  rendered above the editor while editing.
- MessageEditor.tsx: for a media message, seed the editor from the caption
  (not the filename), allow an empty caption (removes it), and build the
  m.replace so m.new_content spreads the original media content
  (url/info/encrypted file/filename/msgtype) and only sets body +
  format/formatted_body. Outer content is the full media (not a "* text"
  fallback) so non-edit-aware clients still render the media. No-op guard
  when the caption is unchanged. Placeholder "Add a caption…".

Rendering + Edit History need no changes: getEditedEvent's m.new_content
flows to renderCaption, and the word-diff already diffs body (the
caption). Encrypted media keeps its file/key (no re-upload).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 12:50:42 -04:00
co-authored by Claude Opus 4.8
parent 9a4796c167
commit 125af8446f
4 changed files with 143 additions and 25 deletions
+3 -1
View File
@@ -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
+22 -16
View File
@@ -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(
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
{reply}
{edit && onEditId ? (
<MessageEditor
style={{
maxWidth: '100%',
width: '100vw',
}}
roomId={room.roomId}
room={room}
mEvent={mEvent}
imagePackRooms={imagePackRooms}
onCancel={() => onEditId()}
/>
<>
{/* Editing a media caption: keep the media visible for context; the
editor below edits only the caption. */}
{canEditCaption(mx, mEvent) && children}
<MessageEditor
style={{
maxWidth: '100%',
width: '100vw',
}}
roomId={room.roomId}
room={room}
mEvent={mEvent}
imagePackRooms={imagePackRooms}
onCancel={() => onEditId()}
/>
</>
) : (
children
)}
@@ -1072,13 +1078,13 @@ export const Message = React.memo(
<Icon src={Icons.ThreadPlus} size="100" />
</IconButton>
)}
{canEditEvent(mx, mEvent) && onEditId && (
{canEditEventOrCaption(mx, mEvent) && onEditId && (
<IconButton
onClick={() => onEditId(mEvent.getId())}
variant="SurfaceVariant"
size="300"
radii="300"
aria-label="Edit message"
aria-label={canEditCaption(mx, mEvent) ? 'Edit caption' : 'Edit message'}
>
<Icon src={Icons.Pencil} size="100" />
</IconButton>
@@ -1247,7 +1253,7 @@ export const Message = React.memo(
</Text>
</MenuItem>
)}
{canEditEvent(mx, mEvent) && onEditId && (
{canEditEventOrCaption(mx, mEvent) && onEditId && (
<MenuItem
size="300"
after={<Icon size="100" src={Icons.Pencil} />}
@@ -1264,7 +1270,7 @@ export const Message = React.memo(
size="T300"
truncate
>
Edit Message
{canEditCaption(mx, mEvent) ? 'Edit Caption' : 'Edit Message'}
</Text>
</MenuItem>
)}
@@ -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>(
)}
<CustomEditor
editor={editor}
placeholder="Edit message..."
ariaLabel={editSenderId ? `Editing message from ${editSenderName}` : 'Edit message'}
placeholder={isMediaCaption ? 'Add a caption…' : 'Edit message...'}
ariaLabel={
isMediaCaption
? `Editing caption for ${editSenderName}'s attachment`
: editSenderId
? `Editing message from ${editSenderName}`
: 'Edit message'
}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
bottom={
+21
View File
@@ -507,6 +507,27 @@ export const canEditEvent = (mx: MatrixClient, mEvent: MatrixEvent) => {
);
};
/**
* 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,