Files
cinny/src/app/features/room/ScheduleMessageModal.tsx
T
jared c8b2de4a08
CI / Build & Quality Checks (push) Successful in 10m24s
fix: schedule button, compression visibility, activity log, insights overflow, bookmarks UI
Schedule message: modal now always opens (even with empty composer);
includes its own message textarea pre-filled from editor content;
removed null-content early-return guard from handleScheduleClick;
fixed error text to use color.Critical.Main not CSS var

Image compression: removed 200KB size threshold — checkbox now shows
for all JPEG/PNG uploads (not just large ones); 'no significant saving'
message handles already-small files gracefully

Activity log: auto-paginate on mount — state events are absent from
initial sync window, so the log was always empty until Load More was
clicked manually

Insights summary: Text size H4→H5 with toLocaleString() formatting and
overflow:ellipsis so large numbers don't push tiles off screen

Bookmarks panel: replaced var(--bg-*) CSS vars (undefined in folds
themes) with color.Surface/SurfaceVariant/Primary folds tokens; added
left accent border on message preview block, count badge, clear button
in search, improved empty state, cleaner button hierarchy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 12:07:12 -04:00

309 lines
10 KiB
TypeScript

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;
/** Pre-fill the message body from the composer. Pass null/undefined to open blank. */
initialBody?: string;
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,
initialBody,
onScheduled,
onClose,
}: ScheduleMessageModalProps) {
const mx = useMatrixClient();
const [messageText, setMessageText] = useState(initialBody ?? '');
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;
}
if (!messageText.trim()) {
setError('Please enter a message to schedule.');
return;
}
const content: IContent = { body: messageText.trim(), msgtype: 'm.text' };
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 }}>
{/* 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>
{/* 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 && (
<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">Schedule</Text>
</Button>
</Box>
</Box>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}