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 && (