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 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 14:47:52 -04:00
co-authored by Claude Opus 4.8
parent b38df58b68
commit 120ad2d1b5
3 changed files with 82 additions and 2 deletions
+2 -1
View File
@@ -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`
@@ -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<Set<string>>(new Set());
const [cancelErrors, setCancelErrors] = useState<Set<string>>(new Set());
const [sendErrors, setSendErrors] = useState<Set<string>>(new Set());
const [editing, setEditing] = useState<ScheduledMessage | null>(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) {
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
{formatSendAt(msg.sendAt)}
</Text>
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
aria-label={`Send scheduled message now: ${rowDesc}`}
disabled={cancelling.has(msg.delayId)}
onClick={(e) => {
e.stopPropagation();
handleSendNow(msg);
}}
>
<Icon src={Icons.Send} size="50" />
</IconButton>
<IconButton
size="300"
radii="300"
@@ -281,6 +336,14 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
Could not cancel this message. Try again.
</Text>
)}
{sendErrors.has(msg.delayId) && (
<Text
size="T200"
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
>
Could not send now. Try again.
</Text>
)}
</Box>
);
})}
+16
View File
@@ -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<void> {
const path = `/delayed_events/${encodeURIComponent(delayId)}`;
await mx.http.authedRequest(
Method.Post,
path,
undefined,
{ action: 'send' },
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
);
}