feat(scheduling): edit / reschedule a scheduled message
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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user