CI / Build & Quality Checks (pull_request) Successful in 1m43s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m4s
Production Synapse caps delayed events at 24h, but the dialog let any later time be picked and then showed the raw server error. The date input now has min/max (today .. now+24h), a too-late time shows "You can schedule up to 24 hours ahead." as soon as it is picked (and blocks Schedule), and ORG.MATRIX.MSC4140_DELAY_TOO_LARGE maps to the same text. Fixes #275 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
364 lines
12 KiB
TypeScript
364 lines
12 KiB
TypeScript
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
|
|
import FocusTrap from 'focus-trap-react';
|
|
import {
|
|
Box,
|
|
Button,
|
|
Dialog,
|
|
Header,
|
|
Icon,
|
|
IconButton,
|
|
Icons,
|
|
Overlay,
|
|
OverlayBackdrop,
|
|
OverlayCenter,
|
|
Spinner,
|
|
Text,
|
|
color,
|
|
config,
|
|
} from 'folds';
|
|
import { IContent } from 'matrix-js-sdk';
|
|
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
import { stopPropagation } from '../../utils/keyboard';
|
|
import {
|
|
describeScheduleError,
|
|
MAX_SCHEDULE_AHEAD_MS,
|
|
scheduleMessage,
|
|
SCHEDULE_TOO_FAR_TEXT,
|
|
} from '../../utils/scheduledMessages';
|
|
import { useModalStyle } from '../../hooks/useModalStyle';
|
|
import {
|
|
toLocalDate,
|
|
toLocalTime,
|
|
parseLocalDateTime,
|
|
pickerInputStyle,
|
|
formatFriendlyDateTime,
|
|
} from '../../utils/datetimeInput';
|
|
|
|
interface ScheduleMessageModalProps {
|
|
roomId: string;
|
|
/**
|
|
* Pre-fill from the composer (or the message being rescheduled). The textarea
|
|
* edits only `body`; the rest of the content (mentions, reply/thread relation,
|
|
* formatting) rides along — see `mergeScheduledBody`. Pass null/undefined to
|
|
* open blank.
|
|
*/
|
|
initialContent?: IContent | null;
|
|
/** Pre-fill the date/time pickers (Unix ms) — used when editing/rescheduling. */
|
|
initialSendAt?: number;
|
|
/** Header title; defaults to "Schedule Message". */
|
|
title?: string;
|
|
/** Primary-button label; defaults to "Schedule" (e.g. "Reschedule" when editing). */
|
|
submitLabel?: string;
|
|
onScheduled: (delayId: string, sendAt: number, content: IContent) => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
/**
|
|
* [Gitea #36] Build the content to schedule from the pre-filled composer content
|
|
* and the (possibly edited) textarea body. Previously the modal rebuilt a bare
|
|
* `{ body, msgtype }`, silently dropping `formatted_body`, `m.mentions` and the
|
|
* reply/thread relation. Now: an unedited body sends the content verbatim; an
|
|
* edited body replaces `body` and drops `format`/`formatted_body` (they would be
|
|
* stale) but keeps mentions and the relation.
|
|
*/
|
|
export function mergeScheduledBody(initial: IContent | null | undefined, body: string): IContent {
|
|
const trimmed = body.trim();
|
|
if (!initial) return { body: trimmed, msgtype: 'm.text' };
|
|
const initialBody = typeof initial.body === 'string' ? initial.body : '';
|
|
if (trimmed === initialBody.trim()) return initial;
|
|
const { format: _format, formatted_body: _formattedBody, ...rest } = initial;
|
|
return { ...rest, msgtype: initial.msgtype ?? 'm.text', body: trimmed };
|
|
}
|
|
|
|
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';
|
|
}
|
|
|
|
export function ScheduleMessageModal({
|
|
roomId,
|
|
initialContent,
|
|
initialSendAt,
|
|
title = 'Schedule Message',
|
|
submitLabel = 'Schedule',
|
|
onScheduled,
|
|
onClose,
|
|
}: ScheduleMessageModalProps) {
|
|
const modalStyle = useModalStyle(400);
|
|
const mx = useMatrixClient();
|
|
const [messageText, setMessageText] = useState(
|
|
typeof initialContent?.body === 'string' ? initialContent.body : '',
|
|
);
|
|
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;
|
|
};
|
|
|
|
// When editing, seed the pickers from the existing send-time; else default to +1h.
|
|
const { prefs } = useTimestampFormatter();
|
|
const def = initialSendAt ? new Date(initialSendAt) : defaultDate();
|
|
const [dateValue, setDateValue] = useState<string>(() => toLocalDate(def));
|
|
const [timeValue, setTimeValue] = useState<string>(() => toLocalTime(def));
|
|
|
|
const getSendAt = useCallback(
|
|
(): Date | null => parseLocalDateTime(dateValue, timeValue),
|
|
[dateValue, timeValue],
|
|
);
|
|
|
|
const [preview, setPreview] = useState<{ label: string; relative: string } | null>(null);
|
|
|
|
const updatePreview = useCallback(() => {
|
|
const sendAt = getSendAt();
|
|
if (!sendAt) {
|
|
setPreview(null);
|
|
return;
|
|
}
|
|
const diffMs = sendAt.getTime() - Date.now();
|
|
// [Gitea #275] Say so as soon as a too-late time is picked, not on submit.
|
|
const tooFar = diffMs > MAX_SCHEDULE_AHEAD_MS;
|
|
setError((e) => {
|
|
if (tooFar) return SCHEDULE_TOO_FAR_TEXT;
|
|
return e === SCHEDULE_TOO_FAR_TEXT ? null : e;
|
|
});
|
|
if (diffMs < 60_000 || tooFar) {
|
|
setPreview(null);
|
|
return;
|
|
}
|
|
setPreview({
|
|
label: formatFriendlyDateTime(sendAt.getTime(), prefs),
|
|
relative: formatRelativeTime(diffMs),
|
|
});
|
|
}, [getSendAt, prefs]);
|
|
|
|
useEffect(() => {
|
|
updatePreview();
|
|
}, [updatePreview]);
|
|
|
|
const handleSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
|
|
e.preventDefault();
|
|
if (submitting) return;
|
|
|
|
const sendAt = getSendAt();
|
|
if (!sendAt) {
|
|
setError('Please select a valid date and 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;
|
|
}
|
|
if (diffMs > MAX_SCHEDULE_AHEAD_MS) {
|
|
setError(SCHEDULE_TOO_FAR_TEXT);
|
|
return;
|
|
}
|
|
if (!messageText.trim()) {
|
|
setError('Please enter a message to schedule.');
|
|
return;
|
|
}
|
|
|
|
const content = mergeScheduledBody(initialContent, messageText);
|
|
setError(null);
|
|
setSubmitting(true);
|
|
try {
|
|
const delayId = await scheduleMessage(mx, roomId, content, sendAt.getTime());
|
|
onScheduled(delayId, sendAt.getTime(), content);
|
|
onClose();
|
|
} catch (err) {
|
|
setError(describeScheduleError(err));
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
|
<OverlayCenter>
|
|
<FocusTrap
|
|
focusTrapOptions={{
|
|
initialFocus: '#schedule-message-body',
|
|
onDeactivate: onClose,
|
|
clickOutsideDeactivates: true,
|
|
escapeDeactivates: stopPropagation,
|
|
}}
|
|
>
|
|
<Dialog
|
|
as="form"
|
|
variant="Surface"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="schedule-message-title"
|
|
onSubmit={handleSubmit}
|
|
style={modalStyle}
|
|
>
|
|
{/* 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">
|
|
{title}
|
|
</Text>
|
|
</Box>
|
|
<IconButton type="button" 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 }}>
|
|
{/* 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)}
|
|
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',
|
|
resize: 'vertical',
|
|
fontFamily: 'inherit',
|
|
}}
|
|
/>
|
|
</Box>
|
|
|
|
{/* Date + Time pickers */}
|
|
<Box direction="Column" gap="100">
|
|
<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" priority="400">
|
|
Date
|
|
</Text>
|
|
<input
|
|
id="schedule-date"
|
|
type="date"
|
|
min={toLocalDate(new Date())}
|
|
max={toLocalDate(new Date(Date.now() + MAX_SCHEDULE_AHEAD_MS))}
|
|
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" priority="400">
|
|
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
|
|
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>
|
|
) : (
|
|
(dateValue || timeValue) &&
|
|
error !== SCHEDULE_TOO_FAR_TEXT && (
|
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
|
Must be at least 1 minute in the future
|
|
</Text>
|
|
)
|
|
)}
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<Text size="T300" style={{ color: color.Critical.Main }}>
|
|
{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">{submitLabel}</Text>
|
|
</Button>
|
|
</Box>
|
|
</Dialog>
|
|
</FocusTrap>
|
|
</OverlayCenter>
|
|
</Overlay>
|
|
);
|
|
}
|