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
|
### 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
|
||||||
|
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user