fix(composer): scheduling keeps formatting, mentions and the reply relation
The schedule modal took only a plain body and rebuilt {body, msgtype},
dropping formatted_body, m.mentions and m.relates_to. It now receives
the full IContent; an unedited body is sent verbatim, an edited body
drops the now-stale formatted_body but keeps mentions and the reply/
thread relation. Reschedule from the tray preserves them too.
Unit-tested (mergeScheduledBody).
Fixes #36
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -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 <b>bold</b> <a href="https://matrix.to/#/@alice:x">alice</a>',
|
||||||
|
'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' });
|
||||||
|
});
|
||||||
@@ -31,8 +31,13 @@ import {
|
|||||||
|
|
||||||
interface ScheduleMessageModalProps {
|
interface ScheduleMessageModalProps {
|
||||||
roomId: string;
|
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. */
|
/** Pre-fill the date/time pickers (Unix ms) — used when editing/rescheduling. */
|
||||||
initialSendAt?: number;
|
initialSendAt?: number;
|
||||||
/** Header title; defaults to "Schedule Message". */
|
/** Header title; defaults to "Schedule Message". */
|
||||||
@@ -43,6 +48,23 @@ interface ScheduleMessageModalProps {
|
|||||||
onClose: () => void;
|
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 {
|
function formatRelativeTime(ms: number): string {
|
||||||
const totalSeconds = Math.floor(ms / 1000);
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
const hours = Math.floor(totalSeconds / 3600);
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
@@ -55,7 +77,7 @@ function formatRelativeTime(ms: number): string {
|
|||||||
|
|
||||||
export function ScheduleMessageModal({
|
export function ScheduleMessageModal({
|
||||||
roomId,
|
roomId,
|
||||||
initialBody,
|
initialContent,
|
||||||
initialSendAt,
|
initialSendAt,
|
||||||
title = 'Schedule Message',
|
title = 'Schedule Message',
|
||||||
submitLabel = 'Schedule',
|
submitLabel = 'Schedule',
|
||||||
@@ -64,7 +86,9 @@ export function ScheduleMessageModal({
|
|||||||
}: ScheduleMessageModalProps) {
|
}: ScheduleMessageModalProps) {
|
||||||
const modalStyle = useModalStyle(400);
|
const modalStyle = useModalStyle(400);
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const [messageText, setMessageText] = useState(initialBody ?? '');
|
const [messageText, setMessageText] = useState(
|
||||||
|
typeof initialContent?.body === 'string' ? initialContent.body : '',
|
||||||
|
);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -128,7 +152,7 @@ export function ScheduleMessageModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const content: IContent = { body: messageText.trim(), msgtype: 'm.text' };
|
const content = mergeScheduledBody(initialContent, messageText);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
|||||||
{editing && (
|
{editing && (
|
||||||
<ScheduleMessageModal
|
<ScheduleMessageModal
|
||||||
roomId={roomId}
|
roomId={roomId}
|
||||||
initialBody={typeof editing.content.body === 'string' ? editing.content.body : ''}
|
initialContent={editing.content}
|
||||||
initialSendAt={editing.sendAt}
|
initialSendAt={editing.sendAt}
|
||||||
title="Edit scheduled message"
|
title="Edit scheduled message"
|
||||||
submitLabel="Reschedule"
|
submitLabel="Reschedule"
|
||||||
|
|||||||
Reference in New Issue
Block a user