diff --git a/LOTUS_FEATURES.md b/LOTUS_FEATURES.md index cf23ba7a2..85d75d845 100644 --- a/LOTUS_FEATURES.md +++ b/LOTUS_FEATURES.md @@ -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) diff --git a/src/app/features/room/ScheduleMessageModal.tsx b/src/app/features/room/ScheduleMessageModal.tsx index a523ffce9..a922a02da 100644 --- a/src/app/features/room/ScheduleMessageModal.tsx +++ b/src/app/features/room/ScheduleMessageModal.tsx @@ -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(() => toLocalDate(def)); const [timeValue, setTimeValue] = useState(() => toLocalTime(def)); @@ -199,7 +206,7 @@ export function ScheduleMessageModal({ - Schedule Message + {title} diff --git a/src/app/features/room/ScheduledMessagesTray.tsx b/src/app/features/room/ScheduledMessagesTray.tsx index d00c4a85e..43d072ee0 100644 --- a/src/app/features/room/ScheduledMessagesTray.tsx +++ b/src/app/features/room/ScheduledMessagesTray.tsx @@ -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>(new Set()); const [cancelErrors, setCancelErrors] = useState>(new Set()); + const [editing, setEditing] = useState(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 ( - + <> + {editing && ( + + handleEdit(editing, newDelayId, sendAt, content) + } + onClose={() => setEditing(null)} + /> + )} + {/* Tray header */}