fix: message scheduling 404 and date/time picker UX

API fix: delay was embedded in the path string causing 404 — moved to
proper query param object; prefix changed from '' to the full MSC4140
unstable prefix so authedRequest builds the correct URL. Cancel and
restart endpoints fixed the same way.

Date/time picker: replaced single datetime-local (hard to use time
portion) with separate date + time inputs side by side; colorScheme:'dark'
hints the browser to render calendar/clock popups in dark mode to match
the app. Preview row now shows as a styled Primary.Container chip with
clock icon. Relative time ("in 2h 15m") shown below the label.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 15:05:09 -04:00
parent c80f8c6427
commit fbdd0e7083
2 changed files with 120 additions and 68 deletions
+95 -54
View File
@@ -57,14 +57,32 @@ function formatSendAt(sendAt: Date): string {
return `${sendAt.toLocaleDateString()} at ${timeStr}`; return `${sendAt.toLocaleDateString()} at ${timeStr}`;
} }
function toLocalDatetimeValue(date: Date): string { function toLocalDate(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0'); const pad = (n: number) => String(n).padStart(2, '0');
return ( return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
`T${pad(date.getHours())}:${pad(date.getMinutes())}`
);
} }
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({ export function ScheduleMessageModal({
roomId, roomId,
initialBody, initialBody,
@@ -84,42 +102,43 @@ export function ScheduleMessageModal({
return d; return d;
}; };
const [datetimeValue, setDatetimeValue] = useState<string>(() => const def = defaultDate();
toLocalDatetimeValue(defaultDate()), const [dateValue, setDateValue] = useState<string>(() => toLocalDate(def));
); const [timeValue, setTimeValue] = useState<string>(() => 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 [preview, setPreview] = useState<{ label: string; relative: string } | null>(null);
const updatePreview = useCallback((value: string) => { const updatePreview = useCallback(() => {
if (!value) { const sendAt = getSendAt();
if (!sendAt) {
setPreview(null); setPreview(null);
return; return;
} }
const sendAt = new Date(value); const diffMs = sendAt.getTime() - Date.now();
const now = Date.now(); if (diffMs < 60_000) {
const diffMs = sendAt.getTime() - now;
if (Number.isNaN(sendAt.getTime()) || diffMs < 60_000) {
setPreview(null); setPreview(null);
return; return;
} }
setPreview({ label: formatSendAt(sendAt), relative: formatRelativeTime(diffMs) }); setPreview({ label: formatSendAt(sendAt), relative: formatRelativeTime(diffMs) });
}, []); }, [getSendAt]);
useEffect(() => { useEffect(() => {
updatePreview(datetimeValue); updatePreview();
}, [datetimeValue, updatePreview]); }, [updatePreview]);
const handleSubmit: FormEventHandler<HTMLFormElement> = async (e) => { const handleSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
e.preventDefault(); e.preventDefault();
if (submitting) return; if (submitting) return;
if (!datetimeValue) { const sendAt = getSendAt();
setError('Please select a date and time.'); if (!sendAt) {
return; setError('Please select a valid date and time.');
}
const sendAt = new Date(datetimeValue);
if (Number.isNaN(sendAt.getTime())) {
setError('Invalid date/time.');
return; return;
} }
const diffMs = sendAt.getTime() - Date.now(); 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.'); setError('Scheduled time must be at least 1 minute in the future.');
return; return;
} }
if (!messageText.trim()) { if (!messageText.trim()) {
setError('Please enter a message to schedule.'); setError('Please enter a message to schedule.');
return; return;
@@ -222,42 +240,65 @@ export function ScheduleMessageModal({
/> />
</Box> </Box>
{/* Datetime picker */} {/* Date + Time pickers */}
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text as="label" htmlFor="schedule-datetime" size="L400"> <Text size="L400">Send at</Text>
Send at <Box gap="200">
</Text> <Box direction="Column" gap="100" style={{ flex: 1 }}>
<input <Text as="label" htmlFor="schedule-date" size="T200" style={{ opacity: 0.7 }}>
id="schedule-datetime" Date
type="datetime-local" </Text>
value={datetimeValue} <input
onChange={(e) => setDatetimeValue(e.target.value)} id="schedule-date"
style={{ type="date"
background: color.SurfaceVariant.Container, value={dateValue}
color: color.SurfaceVariant.OnContainer, onChange={(e) => setDateValue(e.target.value)}
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`, style={pickerInputStyle(color, config)}
borderRadius: config.radii.R300, />
padding: `${config.space.S200} ${config.space.S300}`, </Box>
fontSize: '0.875rem', <Box direction="Column" gap="100" style={{ flex: 1 }}>
width: '100%', <Text as="label" htmlFor="schedule-time" size="T200" style={{ opacity: 0.7 }}>
boxSizing: 'border-box', Time
outline: 'none', </Text>
}} <input
/> id="schedule-time"
type="time"
value={timeValue}
onChange={(e) => setTimeValue(e.target.value)}
style={pickerInputStyle(color, config)}
/>
</Box>
</Box>
</Box> </Box>
{/* Preview */} {/* Preview */}
{preview ? ( {preview ? (
<Box direction="Column" gap="100"> <Box
<Text size="T300" style={{ opacity: 0.7 }}> alignItems="Center"
{preview.label} gap="200"
</Text> style={{
<Text size="T200" style={{ opacity: 0.5 }}> padding: `${config.space.S100} ${config.space.S200}`,
({preview.relative}) borderRadius: config.radii.R300,
</Text> background: color.Primary.Container,
border: `1px solid ${color.Primary.ContainerLine}`,
}}
>
<Icon
src={Icons.Clock}
size="100"
style={{ color: color.Primary.OnContainer, flexShrink: 0 }}
/>
<Box direction="Column">
<Text size="T300" style={{ color: color.Primary.OnContainer }}>
{preview.label}
</Text>
<Text size="T200" style={{ color: color.Primary.OnContainer, opacity: 0.7 }}>
{preview.relative}
</Text>
</Box>
</Box> </Box>
) : ( ) : (
datetimeValue && ( (dateValue || timeValue) && (
<Text size="T200" style={{ color: color.Critical.Main }}> <Text size="T200" style={{ color: color.Critical.Main }}>
Must be at least 1 minute in the future Must be at least 1 minute in the future
</Text> </Text>
+25 -14
View File
@@ -14,11 +14,12 @@ export async function scheduleMessage(
content: IContent, content: IContent,
sendAtMs: number, sendAtMs: number,
): Promise<string> { ): Promise<string> {
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 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))}`; // Use the path relative to the MSC4140 prefix — authedRequest prepends prefix to path.
const res = (await mx.http.authedRequest(Method.Put, path, undefined, content, { const path = `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`;
prefix: '', const res = (await mx.http.authedRequest(Method.Put, path, { delay: delayMs }, content, {
prefix: '/_matrix/client/unstable/org.matrix.msc4140',
})) as { delay_id: string }; })) as { delay_id: string };
return res.delay_id; return res.delay_id;
} }
@@ -29,17 +30,27 @@ export async function scheduleMessage(
* @param delayId - The delay_id from scheduleMessage * @param delayId - The delay_id from scheduleMessage
*/ */
export async function cancelScheduledMessage(mx: MatrixClient, delayId: string): Promise<void> { export async function cancelScheduledMessage(mx: MatrixClient, delayId: string): Promise<void> {
const path = `/_matrix/client/unstable/org.matrix.msc4140/delayed_events/${encodeURIComponent(delayId)}`; const path = `/delayed_events/${encodeURIComponent(delayId)}`;
await mx.http.authedRequest(Method.Post, path, undefined, { action: 'cancel' }, { prefix: '' }); 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<void> { export async function restartScheduledMessage(mx: MatrixClient, delayId: string): Promise<void> {
const path = `/_matrix/client/unstable/org.matrix.msc4140/delayed_events/${encodeURIComponent(delayId)}`; const path = `/delayed_events/${encodeURIComponent(delayId)}`;
await mx.http.authedRequest(Method.Post, path, undefined, { action: 'restart' }, { prefix: '' }); await mx.http.authedRequest(
Method.Post,
path,
undefined,
{ action: 'restart' },
{
prefix: '/_matrix/client/unstable/org.matrix.msc4140',
},
);
} }