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
+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,