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 ### 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 ### Location Sharing
+11 -5
View File
@@ -50,7 +50,8 @@ import {
UsernameBold, UsernameBold,
} from '../../../components/message'; } from '../../../components/message';
import { import {
canEditEvent, canEditCaption,
canEditEventOrCaption,
getEventEdits, getEventEdits,
getMemberAvatarMxc, getMemberAvatarMxc,
getMemberName, getMemberName,
@@ -903,6 +904,10 @@ export const Message = React.memo(
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}> <Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
{reply} {reply}
{edit && onEditId ? ( {edit && onEditId ? (
<>
{/* Editing a media caption: keep the media visible for context; the
editor below edits only the caption. */}
{canEditCaption(mx, mEvent) && children}
<MessageEditor <MessageEditor
style={{ style={{
maxWidth: '100%', maxWidth: '100%',
@@ -914,6 +919,7 @@ export const Message = React.memo(
imagePackRooms={imagePackRooms} imagePackRooms={imagePackRooms}
onCancel={() => onEditId()} onCancel={() => onEditId()}
/> />
</>
) : ( ) : (
children children
)} )}
@@ -1072,13 +1078,13 @@ export const Message = React.memo(
<Icon src={Icons.ThreadPlus} size="100" /> <Icon src={Icons.ThreadPlus} size="100" />
</IconButton> </IconButton>
)} )}
{canEditEvent(mx, mEvent) && onEditId && ( {canEditEventOrCaption(mx, mEvent) && onEditId && (
<IconButton <IconButton
onClick={() => onEditId(mEvent.getId())} onClick={() => onEditId(mEvent.getId())}
variant="SurfaceVariant" variant="SurfaceVariant"
size="300" size="300"
radii="300" radii="300"
aria-label="Edit message" aria-label={canEditCaption(mx, mEvent) ? 'Edit caption' : 'Edit message'}
> >
<Icon src={Icons.Pencil} size="100" /> <Icon src={Icons.Pencil} size="100" />
</IconButton> </IconButton>
@@ -1247,7 +1253,7 @@ export const Message = React.memo(
</Text> </Text>
</MenuItem> </MenuItem>
)} )}
{canEditEvent(mx, mEvent) && onEditId && ( {canEditEventOrCaption(mx, mEvent) && onEditId && (
<MenuItem <MenuItem
size="300" size="300"
after={<Icon size="100" src={Icons.Pencil} />} after={<Icon size="100" src={Icons.Pencil} />}
@@ -1264,7 +1270,7 @@ export const Message = React.memo(
size="T300" size="T300"
truncate truncate
> >
Edit Message {canEditCaption(mx, mEvent) ? 'Edit Caption' : 'Edit Message'}
</Text> </Text>
</MenuItem> </MenuItem>
)} )}
@@ -21,7 +21,7 @@ import {
} from 'folds'; } from 'folds';
import { Editor, Transforms } from 'slate'; import { Editor, Transforms } from 'slate';
import { ReactEditor } from 'slate-react'; 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 { isKeyHotkey } from 'is-hotkey';
import { import {
AUTOCOMPLETE_PREFIXES, AUTOCOMPLETE_PREFIXES,
@@ -75,6 +75,15 @@ export const MessageEditor = as<'div', MessageEditorProps>(
// message is being edited (a11y, P3-4). // message is being edited (a11y, P3-4).
const editSenderId = mEvent.getSender(); const editSenderId = mEvent.getSender();
const editSenderName = editSenderId ? getMemberName(room, editSenderId) : ''; 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 [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar'); const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown'); 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(); const [prevBody, prevCustomHtml, prevMentions] = getPrevBodyAndFormattedBody();
if (plainText === '') return undefined; if (plainText === '') return undefined;
@@ -165,7 +229,17 @@ export const MessageEditor = as<'div', MessageEditorProps>(
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
return mx.sendMessage(roomId, content as 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(() => { const handleSave = useCallback(() => {
@@ -220,10 +294,19 @@ export const MessageEditor = as<'div', MessageEditorProps>(
useEffect(() => { useEffect(() => {
const [body, customHtml] = getPrevBodyAndFormattedBody(); 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 = const initialValue =
typeof customHtml === 'string' seedHtml !== undefined
? htmlToEditorInput(customHtml, isMarkdown) ? htmlToEditorInput(seedHtml, isMarkdown)
: plainToEditorInput(typeof body === 'string' ? body : '', isMarkdown); : plainToEditorInput(seedText, isMarkdown);
Transforms.select(editor, { Transforms.select(editor, {
anchor: Editor.start(editor, []), anchor: Editor.start(editor, []),
@@ -232,7 +315,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
editor.insertFragment(initialValue); editor.insertFragment(initialValue);
if (!mobileOrTablet()) ReactEditor.focus(editor); if (!mobileOrTablet()) ReactEditor.focus(editor);
}, [editor, getPrevBodyAndFormattedBody, isMarkdown]); }, [editor, getPrevBodyAndFormattedBody, isMarkdown, isMediaCaption, editFilename]);
useEffect(() => { useEffect(() => {
if (saveState.status === AsyncStatus.Success) { if (saveState.status === AsyncStatus.Success) {
@@ -268,8 +351,14 @@ export const MessageEditor = as<'div', MessageEditorProps>(
)} )}
<CustomEditor <CustomEditor
editor={editor} editor={editor}
placeholder="Edit message..." placeholder={isMediaCaption ? 'Add a caption…' : 'Edit message...'}
ariaLabel={editSenderId ? `Editing message from ${editSenderName}` : 'Edit message'} ariaLabel={
isMediaCaption
? `Editing caption for ${editSenderName}'s attachment`
: editSenderId
? `Editing message from ${editSenderName}`
: 'Edit message'
}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp} onKeyUp={handleKeyUp}
bottom={ 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 = ( export const getLatestEditableEvt = (
timeline: EventTimeline, timeline: EventTimeline,
canEdit: (mEvent: MatrixEvent) => boolean, canEdit: (mEvent: MatrixEvent) => boolean,