diff --git a/src/app/features/room/ScheduleMessageModal.test.ts b/src/app/features/room/ScheduleMessageModal.test.ts new file mode 100644 index 000000000..806151bfb --- /dev/null +++ b/src/app/features/room/ScheduleMessageModal.test.ts @@ -0,0 +1,52 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { IContent } from 'matrix-js-sdk'; +import { mergeScheduledBody } from './ScheduleMessageModal'; + +// [Gitea #36] Pure merge of the modal textarea back into the composer-built +// content: verbatim when unedited, body-only replacement (rich HTML dropped, +// mentions + relation kept) when edited. + +const richContent: IContent = { + msgtype: 'm.text', + body: 'hello **bold** @alice', + format: 'org.matrix.custom.html', + formatted_body: 'hello bold alice', + 'm.mentions': { user_ids: ['@alice:x'] }, + 'm.relates_to': { + 'm.in_reply_to': { event_id: '$reply' }, + rel_type: 'm.thread', + event_id: '$root', + is_falling_back: false, + }, +}; + +test('mergeScheduledBody returns the content verbatim when the body is unedited', () => { + assert.equal(mergeScheduledBody(richContent, richContent.body), richContent); + // Whitespace-only differences (the textarea value is trimmed) still count as unedited. + assert.equal(mergeScheduledBody(richContent, ` ${richContent.body}\n`), richContent); +}); + +test('mergeScheduledBody drops stale formatting but keeps mentions and relation on edit', () => { + const merged = mergeScheduledBody(richContent, 'edited text '); + assert.deepEqual(merged, { + msgtype: 'm.text', + body: 'edited text', + 'm.mentions': { user_ids: ['@alice:x'] }, + 'm.relates_to': richContent['m.relates_to'], + }); + assert.equal('format' in merged, false); + assert.equal('formatted_body' in merged, false); + // Input is not mutated. + assert.equal(richContent.formatted_body !== undefined, true); +}); + +test('mergeScheduledBody builds a bare text content when opened blank', () => { + assert.deepEqual(mergeScheduledBody(null, ' hi '), { body: 'hi', msgtype: 'm.text' }); + assert.deepEqual(mergeScheduledBody(undefined, 'hi'), { body: 'hi', msgtype: 'm.text' }); +}); + +test('mergeScheduledBody preserves a non-text msgtype on edit', () => { + const merged = mergeScheduledBody({ msgtype: 'm.emote', body: 'waves' }, 'nods'); + assert.deepEqual(merged, { msgtype: 'm.emote', body: 'nods' }); +}); diff --git a/src/app/features/room/ScheduleMessageModal.tsx b/src/app/features/room/ScheduleMessageModal.tsx index 3874d3ce6..9cc125110 100644 --- a/src/app/features/room/ScheduleMessageModal.tsx +++ b/src/app/features/room/ScheduleMessageModal.tsx @@ -31,8 +31,13 @@ import { interface ScheduleMessageModalProps { roomId: string; - /** Pre-fill the message body from the composer. Pass null/undefined to open blank. */ - initialBody?: string; + /** + * Pre-fill from the composer (or the message being rescheduled). The textarea + * edits only `body`; the rest of the content (mentions, reply/thread relation, + * formatting) rides along — see `mergeScheduledBody`. Pass null/undefined to + * open blank. + */ + initialContent?: IContent | null; /** Pre-fill the date/time pickers (Unix ms) — used when editing/rescheduling. */ initialSendAt?: number; /** Header title; defaults to "Schedule Message". */ @@ -43,6 +48,23 @@ interface ScheduleMessageModalProps { onClose: () => void; } +/** + * [Gitea #36] Build the content to schedule from the pre-filled composer content + * and the (possibly edited) textarea body. Previously the modal rebuilt a bare + * `{ body, msgtype }`, silently dropping `formatted_body`, `m.mentions` and the + * reply/thread relation. Now: an unedited body sends the content verbatim; an + * edited body replaces `body` and drops `format`/`formatted_body` (they would be + * stale) but keeps mentions and the relation. + */ +export function mergeScheduledBody(initial: IContent | null | undefined, body: string): IContent { + const trimmed = body.trim(); + if (!initial) return { body: trimmed, msgtype: 'm.text' }; + const initialBody = typeof initial.body === 'string' ? initial.body : ''; + if (trimmed === initialBody.trim()) return initial; + const { format: _format, formatted_body: _formattedBody, ...rest } = initial; + return { ...rest, msgtype: initial.msgtype ?? 'm.text', body: trimmed }; +} + function formatRelativeTime(ms: number): string { const totalSeconds = Math.floor(ms / 1000); const hours = Math.floor(totalSeconds / 3600); @@ -55,7 +77,7 @@ function formatRelativeTime(ms: number): string { export function ScheduleMessageModal({ roomId, - initialBody, + initialContent, initialSendAt, title = 'Schedule Message', submitLabel = 'Schedule', @@ -64,7 +86,9 @@ export function ScheduleMessageModal({ }: ScheduleMessageModalProps) { const modalStyle = useModalStyle(400); const mx = useMatrixClient(); - const [messageText, setMessageText] = useState(initialBody ?? ''); + const [messageText, setMessageText] = useState( + typeof initialContent?.body === 'string' ? initialContent.body : '', + ); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -128,7 +152,7 @@ export function ScheduleMessageModal({ return; } - const content: IContent = { body: messageText.trim(), msgtype: 'm.text' }; + const content = mergeScheduledBody(initialContent, messageText); setError(null); setSubmitting(true); try { diff --git a/src/app/features/room/ScheduledMessagesTray.tsx b/src/app/features/room/ScheduledMessagesTray.tsx index aa36db2f0..658fb1352 100644 --- a/src/app/features/room/ScheduledMessagesTray.tsx +++ b/src/app/features/room/ScheduledMessagesTray.tsx @@ -225,7 +225,7 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) { {editing && (