diff --git a/src/app/features/room/ScheduleMessageModal.tsx b/src/app/features/room/ScheduleMessageModal.tsx index d2f179b5f..937e08d14 100644 --- a/src/app/features/room/ScheduleMessageModal.tsx +++ b/src/app/features/room/ScheduleMessageModal.tsx @@ -57,14 +57,32 @@ function formatSendAt(sendAt: Date): string { return `${sendAt.toLocaleDateString()} at ${timeStr}`; } -function toLocalDatetimeValue(date: Date): string { +function toLocalDate(date: Date): string { const pad = (n: number) => String(n).padStart(2, '0'); - return ( - `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` + - `T${pad(date.getHours())}:${pad(date.getMinutes())}` - ); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; } +function toLocalTime(date: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); + return `${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +// Shared style for date/time inputs — dark-mode calendar/clock popup via colorScheme. +const pickerInputStyle = (c: typeof color, cfg: typeof config): React.CSSProperties => ({ + background: c.SurfaceVariant.Container, + color: c.SurfaceVariant.OnContainer, + border: `${cfg.borderWidth.B300} solid ${c.SurfaceVariant.ContainerLine}`, + borderRadius: cfg.radii.R300, + padding: `${cfg.space.S200} ${cfg.space.S300}`, + fontSize: '0.875rem', + width: '100%', + boxSizing: 'border-box', + outline: 'none', + fontFamily: 'inherit', + // Hint browser to render the calendar/clock popup in dark mode + colorScheme: 'dark', +}); + export function ScheduleMessageModal({ roomId, initialBody, @@ -84,42 +102,43 @@ export function ScheduleMessageModal({ return d; }; - const [datetimeValue, setDatetimeValue] = useState(() => - toLocalDatetimeValue(defaultDate()), - ); + const def = defaultDate(); + const [dateValue, setDateValue] = useState(() => toLocalDate(def)); + const [timeValue, setTimeValue] = useState(() => toLocalTime(def)); + + const getSendAt = useCallback((): Date | null => { + if (!dateValue || !timeValue) return null; + const dt = new Date(`${dateValue}T${timeValue}:00`); + return Number.isNaN(dt.getTime()) ? null : dt; + }, [dateValue, timeValue]); const [preview, setPreview] = useState<{ label: string; relative: string } | null>(null); - const updatePreview = useCallback((value: string) => { - if (!value) { + const updatePreview = useCallback(() => { + const sendAt = getSendAt(); + if (!sendAt) { setPreview(null); return; } - const sendAt = new Date(value); - const now = Date.now(); - const diffMs = sendAt.getTime() - now; - if (Number.isNaN(sendAt.getTime()) || diffMs < 60_000) { + const diffMs = sendAt.getTime() - Date.now(); + if (diffMs < 60_000) { setPreview(null); return; } setPreview({ label: formatSendAt(sendAt), relative: formatRelativeTime(diffMs) }); - }, []); + }, [getSendAt]); useEffect(() => { - updatePreview(datetimeValue); - }, [datetimeValue, updatePreview]); + updatePreview(); + }, [updatePreview]); const handleSubmit: FormEventHandler = async (e) => { e.preventDefault(); if (submitting) return; - if (!datetimeValue) { - setError('Please select a date and time.'); - return; - } - const sendAt = new Date(datetimeValue); - if (Number.isNaN(sendAt.getTime())) { - setError('Invalid date/time.'); + const sendAt = getSendAt(); + if (!sendAt) { + setError('Please select a valid date and time.'); return; } const diffMs = sendAt.getTime() - Date.now(); @@ -127,7 +146,6 @@ export function ScheduleMessageModal({ setError('Scheduled time must be at least 1 minute in the future.'); return; } - if (!messageText.trim()) { setError('Please enter a message to schedule.'); return; @@ -222,42 +240,65 @@ export function ScheduleMessageModal({ /> - {/* Datetime picker */} + {/* Date + Time pickers */} - - Send at - - setDatetimeValue(e.target.value)} - style={{ - background: color.SurfaceVariant.Container, - color: color.SurfaceVariant.OnContainer, - border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`, - borderRadius: config.radii.R300, - padding: `${config.space.S200} ${config.space.S300}`, - fontSize: '0.875rem', - width: '100%', - boxSizing: 'border-box', - outline: 'none', - }} - /> + Send at + + + + Date + + setDateValue(e.target.value)} + style={pickerInputStyle(color, config)} + /> + + + + Time + + setTimeValue(e.target.value)} + style={pickerInputStyle(color, config)} + /> + + {/* Preview */} {preview ? ( - - - {preview.label} - - - ({preview.relative}) - + + + + + {preview.label} + + + {preview.relative} + + ) : ( - datetimeValue && ( + (dateValue || timeValue) && ( Must be at least 1 minute in the future diff --git a/src/app/utils/scheduledMessages.ts b/src/app/utils/scheduledMessages.ts index 701e67782..b1aefd05d 100644 --- a/src/app/utils/scheduledMessages.ts +++ b/src/app/utils/scheduledMessages.ts @@ -14,11 +14,12 @@ export async function scheduleMessage( content: IContent, sendAtMs: number, ): Promise { - const delayMs = sendAtMs - Date.now(); + const delayMs = Math.max(1000, Math.round(sendAtMs - Date.now())); const txnId = `sched_${Date.now()}_${Math.random().toString(36).slice(2)}`; - const path = `/_matrix/client/unstable/org.matrix.msc4140/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}?delay=${Math.max(1000, Math.round(delayMs))}`; - const res = (await mx.http.authedRequest(Method.Put, path, undefined, content, { - prefix: '', + // Use the path relative to the MSC4140 prefix — authedRequest prepends prefix to path. + const path = `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`; + const res = (await mx.http.authedRequest(Method.Put, path, { delay: delayMs }, content, { + prefix: '/_matrix/client/unstable/org.matrix.msc4140', })) as { delay_id: string }; return res.delay_id; } @@ -29,17 +30,27 @@ export async function scheduleMessage( * @param delayId - The delay_id from scheduleMessage */ export async function cancelScheduledMessage(mx: MatrixClient, delayId: string): Promise { - const path = `/_matrix/client/unstable/org.matrix.msc4140/delayed_events/${encodeURIComponent(delayId)}`; - await mx.http.authedRequest(Method.Post, path, undefined, { action: 'cancel' }, { prefix: '' }); + const path = `/delayed_events/${encodeURIComponent(delayId)}`; + await mx.http.authedRequest( + Method.Post, + path, + undefined, + { action: 'cancel' }, + { + prefix: '/_matrix/client/unstable/org.matrix.msc4140', + }, + ); } -/** - * Restart (refresh heartbeat) a scheduled message via MSC4140. - * Resets the delay timer from now. - * @param mx - Matrix client instance - * @param delayId - The delay_id from scheduleMessage - */ export async function restartScheduledMessage(mx: MatrixClient, delayId: string): Promise { - const path = `/_matrix/client/unstable/org.matrix.msc4140/delayed_events/${encodeURIComponent(delayId)}`; - await mx.http.authedRequest(Method.Post, path, undefined, { action: 'restart' }, { prefix: '' }); + const path = `/delayed_events/${encodeURIComponent(delayId)}`; + await mx.http.authedRequest( + Method.Post, + path, + undefined, + { action: 'restart' }, + { + prefix: '/_matrix/client/unstable/org.matrix.msc4140', + }, + ); }