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
+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>
)}