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}`;
|
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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user