feat(scheduling): edit / reschedule a scheduled message
CI / Build & Quality Checks (push) Successful in 11m43s
CI / Trigger Desktop Build (push) Successful in 8s

The scheduled-messages tray was cancel-only. Add an inline edit button
that re-opens ScheduleMessageModal seeded with the existing body and
send-time, letting the user change the text and/or when it sends.

MSC4140 has no in-place edit, so an edit is schedule-new + cancel-old.
Order matters: the modal schedules the new delayed event first, then we
cancel the old one and only prune it from local state once the server
confirms. A failed cancel therefore leaves a visible, retriable copy in
the tray instead of silently letting the stale message fire or losing
the edit. Edits go through the plain-text composer, so rich content
collapses to m.text (acceptable for v1).

ScheduleMessageModal gains optional initialSendAt (seed the pickers) and
title props so it is reusable for both scheduling and editing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 22:47:43 -04:00
co-authored by Claude Opus 4.8
parent 7d02f4e538
commit 5d5ae0ee70
3 changed files with 78 additions and 12 deletions
+2 -1
View File
@@ -733,7 +733,8 @@ Redacted events display "This message has been deleted" along with the redaction
- Implements MSC4140 delayed events for scheduling messages to be sent at a future time
- `ScheduleMessageModal.tsx` provides the date/time picker UI
- A collapsible "Scheduled" tray in the room shows all pending scheduled messages with individual cancel buttons
- A collapsible "Scheduled" tray in the room shows all pending scheduled messages with individual edit and cancel buttons
- **Edit / reschedule**: the tray's edit button re-opens `ScheduleMessageModal` (seeded with the existing body + send-time) to change the text and/or time. Since MSC4140 has no in-place edit, this is implemented as schedule-new-then-cancel-old; the old copy is only removed once the server confirms cancellation, so a failed cancel leaves a visible, cancellable copy rather than losing the message. Edits go through the plain-text composer (rich content becomes `m.text`).
- Utilities in `src/app/utils/scheduledMessages.ts`
### File Upload Compression (opt-in)
@@ -26,6 +26,10 @@ interface ScheduleMessageModalProps {
roomId: string;
/** Pre-fill the message body from the composer. Pass null/undefined to open blank. */
initialBody?: string;
/** Pre-fill the date/time pickers (Unix ms) — used when editing/rescheduling. */
initialSendAt?: number;
/** Header title; defaults to "Schedule Message". */
title?: string;
onScheduled: (delayId: string, sendAt: number, content: IContent) => void;
onClose: () => void;
}
@@ -88,6 +92,8 @@ const pickerInputStyle = (c: typeof color, cfg: typeof config): React.CSSPropert
export function ScheduleMessageModal({
roomId,
initialBody,
initialSendAt,
title = 'Schedule Message',
onScheduled,
onClose,
}: ScheduleMessageModalProps) {
@@ -105,7 +111,8 @@ export function ScheduleMessageModal({
return d;
};
const def = defaultDate();
// When editing, seed the pickers from the existing send-time; else default to +1h.
const def = initialSendAt ? new Date(initialSendAt) : defaultDate();
const [dateValue, setDateValue] = useState<string>(() => toLocalDate(def));
const [timeValue, setTimeValue] = useState<string>(() => toLocalTime(def));
@@ -199,7 +206,7 @@ export function ScheduleMessageModal({
<Box grow="Yes" alignItems="Center" gap="200">
<Icon src={Icons.Clock} size="100" />
<Text id="schedule-message-title" size="H4">
Schedule Message
{title}
</Text>
</Box>
<IconButton size="300" radii="300" onClick={onClose} aria-label="Close">
@@ -1,9 +1,11 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useAtom } from 'jotai';
import { IContent } from 'matrix-js-sdk';
import { Box, Button, Icon, IconButton, Icons, Text, color, config } from 'folds';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { scheduledMessagesAtom, ScheduledMessage } from '../../state/scheduledMessages';
import { cancelScheduledMessage } from '../../utils/scheduledMessages';
import { ScheduleMessageModal } from './ScheduleMessageModal';
interface ScheduledMessagesTrayProps {
roomId: string;
@@ -34,6 +36,7 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
const [expanded, setExpanded] = useState(false);
const [cancelling, setCancelling] = useState<Set<string>>(new Set());
const [cancelErrors, setCancelErrors] = useState<Set<string>>(new Set());
const [editing, setEditing] = useState<ScheduledMessage | null>(null);
const messages = useMemo(() => scheduledMessages.get(roomId) ?? [], [scheduledMessages, roomId]);
@@ -106,16 +109,57 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
[mx, roomId, cancelling, setScheduledMessages],
);
if (messages.length === 0) return null;
// Editing = cancel-old + schedule-new (MSC4140 has no in-place edit). The modal has
// already scheduled the NEW message by the time this fires; add it, then cancel the
// old one — removing the old from state only once the server confirms, so a failed
// cancel leaves it visible (and retriable) instead of letting it silently fire.
const handleEdit = useCallback(
(oldMsg: ScheduledMessage, newDelayId: string, sendAt: number, content: IContent) => {
setScheduledMessages((prev) => {
const next = new Map(prev);
const current = (next.get(roomId) ?? []).filter((m) => m.delayId !== newDelayId);
next.set(roomId, [{ delayId: newDelayId, roomId, content, sendAt }, ...current]);
return next;
});
cancelScheduledMessage(mx, oldMsg.delayId)
.then(() => {
setScheduledMessages((prev) => {
const next = new Map(prev);
const remaining = (next.get(roomId) ?? []).filter((m) => m.delayId !== oldMsg.delayId);
if (remaining.length === 0) next.delete(roomId);
else next.set(roomId, remaining);
return next;
});
})
.catch(() => setCancelErrors((prev) => new Set(prev).add(oldMsg.delayId)));
setEditing(null);
},
[mx, roomId, setScheduledMessages],
);
if (messages.length === 0 && !editing) return null;
return (
<Box
direction="Column"
style={{
borderBottom: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
background: color.SurfaceVariant.Container,
}}
>
<>
{editing && (
<ScheduleMessageModal
roomId={roomId}
initialBody={typeof editing.content.body === 'string' ? editing.content.body : ''}
initialSendAt={editing.sendAt}
title="Edit scheduled message"
onScheduled={(newDelayId, sendAt, content) =>
handleEdit(editing, newDelayId, sendAt, content)
}
onClose={() => setEditing(null)}
/>
)}
<Box
direction="Column"
style={{
borderBottom: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
background: color.SurfaceVariant.Container,
}}
>
{/* Tray header */}
<Button
variant="Secondary"
@@ -166,6 +210,19 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
{formatSendAt(msg.sendAt)}
</Text>
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
aria-label="Edit scheduled message"
disabled={cancelling.has(msg.delayId)}
onClick={(e) => {
e.stopPropagation();
setEditing(msg);
}}
>
<Icon src={Icons.Pencil} size="50" />
</IconButton>
<IconButton
size="300"
radii="300"
@@ -192,6 +249,7 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
))}
</Box>
)}
</Box>
</Box>
</>
);
}