Files
cinny/src/app/features/room/ScheduleMessageModal.tsx
T
jaredandClaude Opus 5 4d4a76214a
CI / Build & Quality Checks (push) Successful in 1m30s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
refactor(time): one timestamp formatter honouring the clock/date settings (#139)
Audit of every rendered time found five families of ad-hoc formatting:
the shared Time component + copies of its today/yesterday branch
(forwarded header, thread summary, read receipts, device tile, moderation
alerts, edit history), locale-default toLocale*String calls that ignored
the user's 12/24 h and date-format settings (scheduled tray, reminders,
schedule preview, notification snooze, bookmarks, threads list, search
cache line, room insights, media gallery), a hard-coded en-US date in the
activity log, and three relative-age variants.

utils/formatTimestamp.ts now holds the rules — today → time; yesterday /
tomorrow → day word + time; last 6 days → weekday + time; older → date +
time in dateFormatString — plus autoDate / time / date / dateTime styles,
formatDayDivider (full weekday), formatShortAge (room list) and
formatRelativeAge (list rows). useTimestampFormatter binds them to the
settings. 11 unit tests with an injected 'now'.

Visible changes are limited to consistency: 12 h times keep the existing
zero-padded hh:mm A; the a11y label and Created-by line use the user's
date format instead of a fixed long month.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 00:28:47 -04:00

346 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 { scheduleMessage } 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();
if (diffMs < 60_000) {
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 (!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(err instanceof Error ? err.message : 'Failed to schedule message.');
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 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"
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) && (
<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>
);
}