2026-06-04 10:26:08 -04:00
|
|
|
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
|
|
|
|
|
import FocusTrap from 'focus-trap-react';
|
|
|
|
|
import {
|
|
|
|
|
Box,
|
|
|
|
|
Button,
|
|
|
|
|
Header,
|
|
|
|
|
Icon,
|
|
|
|
|
IconButton,
|
|
|
|
|
Icons,
|
|
|
|
|
Overlay,
|
|
|
|
|
OverlayBackdrop,
|
|
|
|
|
OverlayCenter,
|
|
|
|
|
Spinner,
|
|
|
|
|
Text,
|
|
|
|
|
color,
|
|
|
|
|
config,
|
|
|
|
|
} from 'folds';
|
|
|
|
|
import { IContent } from 'matrix-js-sdk';
|
|
|
|
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
|
|
|
import { stopPropagation } from '../../utils/keyboard';
|
|
|
|
|
import { scheduleMessage } from '../../utils/scheduledMessages';
|
|
|
|
|
|
|
|
|
|
interface ScheduleMessageModalProps {
|
|
|
|
|
roomId: string;
|
2026-06-04 12:07:12 -04:00
|
|
|
/** Pre-fill the message body from the composer. Pass null/undefined to open blank. */
|
|
|
|
|
initialBody?: string;
|
2026-06-04 10:26:08 -04:00
|
|
|
onScheduled: (delayId: string, sendAt: number, content: IContent) => void;
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatRelativeTime(ms: number): string {
|
|
|
|
|
const totalSeconds = Math.floor(ms / 1000);
|
|
|
|
|
const hours = Math.floor(totalSeconds / 3600);
|
|
|
|
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
|
|
|
if (hours > 0 && minutes > 0) return `in ${hours}h ${minutes}m`;
|
|
|
|
|
if (hours > 0) return `in ${hours}h`;
|
|
|
|
|
if (minutes > 0) return `in ${minutes}m`;
|
|
|
|
|
return 'in less than a minute';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatSendAt(sendAt: Date): string {
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const isToday =
|
|
|
|
|
sendAt.getFullYear() === now.getFullYear() &&
|
|
|
|
|
sendAt.getMonth() === now.getMonth() &&
|
|
|
|
|
sendAt.getDate() === now.getDate();
|
|
|
|
|
const tomorrow = new Date(now);
|
|
|
|
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
|
|
|
const isTomorrow =
|
|
|
|
|
sendAt.getFullYear() === tomorrow.getFullYear() &&
|
|
|
|
|
sendAt.getMonth() === tomorrow.getMonth() &&
|
|
|
|
|
sendAt.getDate() === tomorrow.getDate();
|
|
|
|
|
|
|
|
|
|
const timeStr = sendAt.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
|
|
|
|
if (isToday) return `Today at ${timeStr}`;
|
|
|
|
|
if (isTomorrow) return `Tomorrow at ${timeStr}`;
|
|
|
|
|
return `${sendAt.toLocaleDateString()} at ${timeStr}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toLocalDatetimeValue(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())}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function ScheduleMessageModal({
|
|
|
|
|
roomId,
|
2026-06-04 12:07:12 -04:00
|
|
|
initialBody,
|
2026-06-04 10:26:08 -04:00
|
|
|
onScheduled,
|
|
|
|
|
onClose,
|
|
|
|
|
}: ScheduleMessageModalProps) {
|
|
|
|
|
const mx = useMatrixClient();
|
2026-06-04 12:07:12 -04:00
|
|
|
const [messageText, setMessageText] = useState(initialBody ?? '');
|
2026-06-04 10:26:08 -04:00
|
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
// Default: 1 hour from now, rounded to nearest 5 minutes
|
|
|
|
|
const defaultDate = () => {
|
|
|
|
|
const d = new Date(Date.now() + 60 * 60 * 1000);
|
|
|
|
|
d.setSeconds(0, 0);
|
|
|
|
|
d.setMinutes(Math.ceil(d.getMinutes() / 5) * 5);
|
|
|
|
|
return d;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const [datetimeValue, setDatetimeValue] = useState<string>(() =>
|
|
|
|
|
toLocalDatetimeValue(defaultDate()),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const [preview, setPreview] = useState<{ label: string; relative: string } | null>(null);
|
|
|
|
|
|
|
|
|
|
const updatePreview = useCallback((value: string) => {
|
|
|
|
|
if (!value) {
|
|
|
|
|
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) {
|
|
|
|
|
setPreview(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setPreview({ label: formatSendAt(sendAt), relative: formatRelativeTime(diffMs) });
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
updatePreview(datetimeValue);
|
|
|
|
|
}, [datetimeValue, 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.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const diffMs = sendAt.getTime() - Date.now();
|
|
|
|
|
if (diffMs < 60_000) {
|
|
|
|
|
setError('Scheduled time must be at least 1 minute in the future.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-04 12:07:12 -04:00
|
|
|
if (!messageText.trim()) {
|
|
|
|
|
setError('Please enter a message to schedule.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const content: IContent = { body: messageText.trim(), msgtype: 'm.text' };
|
2026-06-04 10:26:08 -04:00
|
|
|
setError(null);
|
|
|
|
|
setSubmitting(true);
|
|
|
|
|
try {
|
|
|
|
|
const delayId = await scheduleMessage(mx, roomId, content, sendAt.getTime());
|
|
|
|
|
onScheduled(delayId, sendAt.getTime(), content);
|
|
|
|
|
onClose();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError(err instanceof Error ? err.message : 'Failed to schedule message.');
|
|
|
|
|
setSubmitting(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
|
|
|
|
<OverlayCenter>
|
|
|
|
|
<FocusTrap
|
|
|
|
|
focusTrapOptions={{
|
|
|
|
|
initialFocus: false,
|
|
|
|
|
onDeactivate: onClose,
|
|
|
|
|
clickOutsideDeactivates: true,
|
|
|
|
|
escapeDeactivates: stopPropagation,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Box
|
|
|
|
|
as="form"
|
|
|
|
|
role="dialog"
|
|
|
|
|
aria-modal="true"
|
|
|
|
|
aria-labelledby="schedule-message-title"
|
|
|
|
|
onSubmit={handleSubmit}
|
|
|
|
|
direction="Column"
|
|
|
|
|
style={{
|
|
|
|
|
background: color.Surface.Container,
|
|
|
|
|
borderRadius: config.radii.R400,
|
|
|
|
|
boxShadow: color.Other.Shadow,
|
|
|
|
|
width: '100vw',
|
|
|
|
|
maxWidth: 400,
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{/* Header */}
|
|
|
|
|
<Header
|
|
|
|
|
variant="Surface"
|
|
|
|
|
size="500"
|
|
|
|
|
style={{
|
|
|
|
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
|
|
|
|
borderBottomWidth: config.borderWidth.B300,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Box grow="Yes" alignItems="Center" gap="200">
|
|
|
|
|
<Icon src={Icons.Clock} size="100" />
|
|
|
|
|
<Text id="schedule-message-title" size="H4">
|
|
|
|
|
Schedule Message
|
|
|
|
|
</Text>
|
|
|
|
|
</Box>
|
|
|
|
|
<IconButton size="300" radii="300" onClick={onClose} aria-label="Close">
|
|
|
|
|
<Icon src={Icons.Cross} />
|
|
|
|
|
</IconButton>
|
|
|
|
|
</Header>
|
|
|
|
|
|
|
|
|
|
{/* Body */}
|
|
|
|
|
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
|
2026-06-04 12:07:12 -04:00
|
|
|
{/* Message input */}
|
|
|
|
|
<Box direction="Column" gap="100">
|
|
|
|
|
<Text as="label" htmlFor="schedule-message-body" size="L400">
|
|
|
|
|
Message
|
|
|
|
|
</Text>
|
|
|
|
|
<textarea
|
|
|
|
|
id="schedule-message-body"
|
|
|
|
|
rows={3}
|
|
|
|
|
placeholder="Type your message here…"
|
|
|
|
|
value={messageText}
|
|
|
|
|
onChange={(e) => setMessageText(e.target.value)}
|
2026-06-04 10:26:08 -04:00
|
|
|
style={{
|
|
|
|
|
background: color.SurfaceVariant.Container,
|
2026-06-04 12:07:12 -04:00
|
|
|
color: color.SurfaceVariant.OnContainer,
|
|
|
|
|
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
2026-06-04 10:26:08 -04:00
|
|
|
borderRadius: config.radii.R300,
|
2026-06-04 12:07:12 -04:00
|
|
|
padding: `${config.space.S200} ${config.space.S300}`,
|
|
|
|
|
fontSize: '0.875rem',
|
|
|
|
|
width: '100%',
|
|
|
|
|
boxSizing: 'border-box',
|
|
|
|
|
outline: 'none',
|
|
|
|
|
resize: 'vertical',
|
|
|
|
|
fontFamily: 'inherit',
|
2026-06-04 10:26:08 -04:00
|
|
|
}}
|
2026-06-04 12:07:12 -04:00
|
|
|
/>
|
|
|
|
|
</Box>
|
2026-06-04 10:26:08 -04:00
|
|
|
|
|
|
|
|
{/* Datetime picker */}
|
|
|
|
|
<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',
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</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>
|
|
|
|
|
) : (
|
|
|
|
|
datetimeValue && (
|
2026-06-04 12:07:12 -04:00
|
|
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
2026-06-04 10:26:08 -04:00
|
|
|
Must be at least 1 minute in the future
|
|
|
|
|
</Text>
|
|
|
|
|
)
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Error */}
|
|
|
|
|
{error && (
|
2026-06-04 12:07:12 -04:00
|
|
|
<Text size="T300" style={{ color: color.Critical.Main }}>
|
2026-06-04 10:26:08 -04:00
|
|
|
{error}
|
|
|
|
|
</Text>
|
|
|
|
|
)}
|
|
|
|
|
</Box>
|
|
|
|
|
|
|
|
|
|
{/* Footer */}
|
|
|
|
|
<Box
|
|
|
|
|
gap="300"
|
|
|
|
|
justifyContent="End"
|
|
|
|
|
style={{
|
|
|
|
|
padding: `${config.space.S200} ${config.space.S400} ${config.space.S400}`,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="Secondary"
|
|
|
|
|
fill="None"
|
|
|
|
|
radii="300"
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
disabled={submitting}
|
|
|
|
|
>
|
|
|
|
|
<Text size="B400">Cancel</Text>
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
type="submit"
|
|
|
|
|
variant="Primary"
|
|
|
|
|
radii="300"
|
|
|
|
|
disabled={submitting || !preview}
|
|
|
|
|
before={submitting ? <Spinner variant="Primary" size="100" /> : undefined}
|
|
|
|
|
>
|
|
|
|
|
<Text size="B400">Schedule</Text>
|
|
|
|
|
</Button>
|
|
|
|
|
</Box>
|
|
|
|
|
</Box>
|
|
|
|
|
</FocusTrap>
|
|
|
|
|
</OverlayCenter>
|
|
|
|
|
</Overlay>
|
|
|
|
|
);
|
|
|
|
|
}
|