fix: message scheduling 404 and date/time picker UX
CI / Build & Quality Checks (push) Successful in 10m28s
CI / Build & Quality Checks (push) Successful in 10m28s
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:
@@ -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<string>(() =>
|
||||
toLocalDatetimeValue(defaultDate()),
|
||||
);
|
||||
const def = 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 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<HTMLFormElement> = 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({
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Datetime picker */}
|
||||
{/* Date + Time pickers */}
|
||||
<Box direction="Column" gap="100">
|
||||
<Text as="label" htmlFor="schedule-datetime" size="L400">
|
||||
Send at
|
||||
</Text>
|
||||
<input
|
||||
id="schedule-datetime"
|
||||
type="datetime-local"
|
||||
value={datetimeValue}
|
||||
onChange={(e) => 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',
|
||||
}}
|
||||
/>
|
||||
<Text size="L400">Send at</Text>
|
||||
<Box gap="200">
|
||||
<Box direction="Column" gap="100" style={{ flex: 1 }}>
|
||||
<Text as="label" htmlFor="schedule-date" size="T200" style={{ opacity: 0.7 }}>
|
||||
Date
|
||||
</Text>
|
||||
<input
|
||||
id="schedule-date"
|
||||
type="date"
|
||||
value={dateValue}
|
||||
onChange={(e) => setDateValue(e.target.value)}
|
||||
style={pickerInputStyle(color, config)}
|
||||
/>
|
||||
</Box>
|
||||
<Box direction="Column" gap="100" style={{ flex: 1 }}>
|
||||
<Text as="label" htmlFor="schedule-time" size="T200" style={{ opacity: 0.7 }}>
|
||||
Time
|
||||
</Text>
|
||||
<input
|
||||
id="schedule-time"
|
||||
type="time"
|
||||
value={timeValue}
|
||||
onChange={(e) => setTimeValue(e.target.value)}
|
||||
style={pickerInputStyle(color, config)}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Preview */}
|
||||
{preview ? (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="T300" style={{ opacity: 0.7 }}>
|
||||
{preview.label}
|
||||
</Text>
|
||||
<Text size="T200" style={{ opacity: 0.5 }}>
|
||||
({preview.relative})
|
||||
</Text>
|
||||
<Box
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
style={{
|
||||
padding: `${config.space.S100} ${config.space.S200}`,
|
||||
borderRadius: config.radii.R300,
|
||||
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>
|
||||
) : (
|
||||
datetimeValue && (
|
||||
(dateValue || timeValue) && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
Must be at least 1 minute in the future
|
||||
</Text>
|
||||
|
||||
@@ -14,11 +14,12 @@ export async function scheduleMessage(
|
||||
content: IContent,
|
||||
sendAtMs: number,
|
||||
): 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 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<void> {
|
||||
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<void> {
|
||||
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',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user