fix(captions): harden caption editing after review

Address findings from 2 review agents (core edit path was verified
correct — media preservation incl. encrypted, threading, no-op guard):

- Require a filename (utils/room.ts): canEditCaption now also checks the
  MSC2530 `filename` exists. Fixes media from clients that omit filename,
  where the editor prefilled the filename as a caption and clearing it
  wrote an empty body. Such media simply isn't caption-editable (matches
  renderCaption never showing a caption for it).

- Carry m.mentions (MessageEditor): a caption edit now unions typed
  @-mentions with prior mentions like the text-edit path, so mentioning
  someone in a caption edit notifies them.

- Double caption: revert to the editor replacing the content while editing
  (as text edits do) instead of rendering the media + its caption above an
  editor prefilled with the same caption — removes the confusing duplicate.

- Removed-caption "(edited)" marker (RenderMessageContent): when a media
  message is edited but has no caption (e.g. the caption was removed),
  render the standalone "(edited)" affordance so Edit History stays
  reachable (previously it lived only inside the caption and vanished).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 13:01:02 -04:00
co-authored by Claude Opus 4.8
parent 125af8446f
commit 87b1c1a702
5 changed files with 39 additions and 20 deletions
+11 -1
View File
@@ -2,7 +2,7 @@ import React from 'react';
import { MsgType } from 'matrix-js-sdk';
import { HTMLReactParserOptions } from 'html-react-parser';
import { Opts } from 'linkifyjs';
import { config } from 'folds';
import { config, Text } from 'folds';
import {
AudioContent,
DownloadFile,
@@ -11,6 +11,7 @@ import {
MAudio,
MBadEncrypted,
MEmote,
MessageEditedContent,
MFile,
MImage,
MLocation,
@@ -119,6 +120,15 @@ export function RenderMessageContent({
/>
);
}
// No caption, but the media was edited (e.g. a caption was removed): keep the
// "(edited)" affordance so Edit History stays reachable.
if (edited) {
return (
<Text style={{ marginTop: config.space.S200 }} size="T200">
<MessageEditedContent onEditHistoryClick={onEditHistoryClick} />
</Text>
);
}
return null;
};
+11 -16
View File
@@ -904,22 +904,17 @@ export const Message = React.memo(
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
{reply}
{edit && 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()}
/>
</>
<MessageEditor
style={{
maxWidth: '100%',
width: '100vw',
}}
roomId={room.roomId}
room={room}
mEvent={mEvent}
imagePackRooms={imagePackRooms}
onCancel={() => onEditId()}
/>
) : (
children
)}
@@ -162,7 +162,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
delete mediaContent.formatted_body;
}
// No-op guard: nothing changed.
// No-op guard: caption text/markup unchanged.
if (
mediaContent.body === orig.body &&
mediaContent.formatted_body === orig.formatted_body
@@ -170,6 +170,16 @@ export const MessageEditor = as<'div', MessageEditorProps>(
return undefined;
}
// Carry mentions typed into the caption (union with prior mentions), so
// an @-mention in a caption edit notifies — mirrors the text path.
const [, , prevMentions] = getPrevBodyAndFormattedBody();
const mentionData = getMentions(mx, roomId, editor);
prevMentions?.user_ids?.forEach((id) => mentionData.users.add(id));
mediaContent['m.mentions'] = getMentionContent(
Array.from(mentionData.users),
mentionData.room,
);
const content: IContent = {
...mediaContent,
'm.new_content': mediaContent,
+5 -1
View File
@@ -520,7 +520,11 @@ export const canEditCaption = (mx: MatrixClient, mEvent: MatrixEvent) => {
mEvent.getSender() === mx.getUserId() &&
(!relationType || relationType === RelationType.Thread) &&
mEvent.getType() === MessageEvent.RoomMessage &&
(content.msgtype === MsgType.Image || content.msgtype === MsgType.Video)
(content.msgtype === MsgType.Image || content.msgtype === MsgType.Video) &&
// Require the MSC2530 filename so a caption has a well-defined empty state
// (body === filename) — media from clients that omit filename isn't caption-
// editable (and renderCaption never shows a caption for it anyway).
typeof content.filename === 'string'
);
};