From 120ad2d1b5af4707abb9575b8f867ce8fadc87b6 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 10 Jul 2026 14:47:52 -0400 Subject: [PATCH] feat(scheduled): add "Send now" action to the scheduled-messages tray Fire a pending scheduled message immediately via MSC4140 action:'send' (the server dispatches the stored delayed event now, as a normal timeline event) instead of having to cancel and retype. - sendScheduledMessageNow(mx, delayId) mirrors cancel/restart with action:'send' - handleSendNow reuses the per-row busy guard; prunes local state only once the server confirms; a failed send shows an inline "Could not send now" error with the message still sendable/editable/cancellable - Send-now IconButton (Icons.Send) added before Edit/Cancel in each row Co-Authored-By: Claude Opus 4.8 --- LOTUS_FEATURES.md | 3 +- .../features/room/ScheduledMessagesTray.tsx | 65 ++++++++++++++++++- src/app/utils/scheduledMessages.ts | 16 +++++ 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/LOTUS_FEATURES.md b/LOTUS_FEATURES.md index d831e7023..06b9743b9 100644 --- a/LOTUS_FEATURES.md +++ b/LOTUS_FEATURES.md @@ -740,7 +740,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 edit and cancel buttons +- A collapsible "Scheduled" tray in the room shows all pending scheduled messages with individual send-now, edit, and cancel buttons +- **Send now**: the tray's send button fires a pending message immediately via MSC4140 `action: 'send'` (the server dispatches the stored delayed event now, as a normal timeline event — no cancel+retype). The row is pruned only once the server confirms; a failed send leaves an inline "Could not send now" error with the message still sendable/editable/cancellable. - **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` diff --git a/src/app/features/room/ScheduledMessagesTray.tsx b/src/app/features/room/ScheduledMessagesTray.tsx index 38b115d95..465302c2a 100644 --- a/src/app/features/room/ScheduledMessagesTray.tsx +++ b/src/app/features/room/ScheduledMessagesTray.tsx @@ -4,7 +4,7 @@ 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 { cancelScheduledMessage, sendScheduledMessageNow } from '../../utils/scheduledMessages'; import { ScheduleMessageModal } from './ScheduleMessageModal'; interface ScheduledMessagesTrayProps { @@ -36,6 +36,7 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) { const [expanded, setExpanded] = useState(false); const [cancelling, setCancelling] = useState>(new Set()); const [cancelErrors, setCancelErrors] = useState>(new Set()); + const [sendErrors, setSendErrors] = useState>(new Set()); const [editing, setEditing] = useState(null); const messages = useMemo(() => scheduledMessages.get(roomId) ?? [], [scheduledMessages, roomId]); @@ -109,6 +110,47 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) { [mx, roomId, cancelling, setScheduledMessages], ); + const handleSendNow = useCallback( + async (msg: ScheduledMessage) => { + if (cancelling.has(msg.delayId)) return; + setCancelling((prev) => new Set(prev).add(msg.delayId)); + setSendErrors((prev) => { + if (!prev.has(msg.delayId)) return prev; + const next = new Set(prev); + next.delete(msg.delayId); + return next; + }); + try { + await sendScheduledMessageNow(mx, msg.delayId); + // Only prune once the server confirms the send. The delayed event is now + // consumed and appears in the timeline; dropping it before confirmation + // could hide a still-live event that never actually sent. + setScheduledMessages((prev) => { + const next = new Map(prev); + const current = next.get(roomId) ?? []; + const remaining = current.filter((m) => m.delayId !== msg.delayId); + if (remaining.length === 0) { + next.delete(roomId); + } else { + next.set(roomId, remaining); + } + return next; + }); + } catch { + // Keep the item (still sendable/editable/cancellable) and surface an + // inline error; the delayed event is still scheduled on the server. + setSendErrors((prev) => new Set(prev).add(msg.delayId)); + } finally { + setCancelling((prev) => { + const next = new Set(prev); + next.delete(msg.delayId); + return next; + }); + } + }, + [mx, roomId, cancelling, setScheduledMessages], + ); + // 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 @@ -246,6 +288,19 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) { {formatSendAt(msg.sendAt)} + { + e.stopPropagation(); + handleSendNow(msg); + }} + > + + )} + {sendErrors.has(msg.delayId) && ( + + Could not send now. Try again. + + )} ); })} diff --git a/src/app/utils/scheduledMessages.ts b/src/app/utils/scheduledMessages.ts index ae3e0bc4a..af0d140d4 100644 --- a/src/app/utils/scheduledMessages.ts +++ b/src/app/utils/scheduledMessages.ts @@ -53,3 +53,19 @@ export async function restartScheduledMessage(mx: MatrixClient, delayId: string) { prefix: '/_matrix/client/unstable/org.matrix.msc4140' }, ); } + +/** + * Send a scheduled message immediately via MSC4140 (`action: 'send'`) — the + * server dispatches the stored delayed event now, as a normal timeline event, + * instead of waiting for its timeout. + */ +export async function sendScheduledMessageNow(mx: MatrixClient, delayId: string): Promise { + const path = `/delayed_events/${encodeURIComponent(delayId)}`; + await mx.http.authedRequest( + Method.Post, + path, + undefined, + { action: 'send' }, + { prefix: '/_matrix/client/unstable/org.matrix.msc4140' }, + ); +}