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:
+3
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user