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:
+2
-1
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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' },
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user