check:prettier was not part of my gate routine, so formatting drift accumulated across the session's touched files (and a few older ones). Run prettier --write to bring the repo back to 'All matched files use Prettier code style!'. Formatting only — no logic changes. tsc/tests/build all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
308 lines
11 KiB
TypeScript
308 lines
11 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
import FocusTrap from 'focus-trap-react';
|
|
import {
|
|
Box,
|
|
Button,
|
|
color,
|
|
config,
|
|
Dialog,
|
|
Header,
|
|
Icon,
|
|
IconButton,
|
|
Icons,
|
|
Line,
|
|
Overlay,
|
|
OverlayBackdrop,
|
|
OverlayCenter,
|
|
Text,
|
|
} from 'folds';
|
|
import { stopPropagation } from '../../../utils/keyboard';
|
|
import { useReminders } from '../../../hooks/useReminders';
|
|
import { useModalStyle } from '../../../hooks/useModalStyle';
|
|
import {
|
|
toLocalDate,
|
|
toLocalTime,
|
|
parseLocalDateTime,
|
|
pickerInputStyle,
|
|
formatFriendlyDateTime,
|
|
} from '../../../utils/datetimeInput';
|
|
|
|
type RemindMeDialogProps = {
|
|
roomId: string;
|
|
eventId: string;
|
|
previewText: string;
|
|
onClose: () => void;
|
|
};
|
|
|
|
function getPresets(): Array<{ label: string; ms: number }> {
|
|
const tomorrow = new Date();
|
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
tomorrow.setHours(9, 0, 0, 0);
|
|
const timeLabel = tomorrow.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
return [
|
|
{ label: 'In 20 minutes', ms: 20 * 60_000 },
|
|
{ label: 'In 1 hour', ms: 60 * 60_000 },
|
|
{ label: 'In 3 hours', ms: 3 * 60 * 60_000 },
|
|
{ label: `Tomorrow at ${timeLabel}`, ms: tomorrow.getTime() - Date.now() },
|
|
];
|
|
}
|
|
|
|
// Default custom pick: 1 hour from now, rounded up to the nearest 5 minutes.
|
|
function defaultCustomDate(): Date {
|
|
const d = new Date(Date.now() + 60 * 60 * 1000);
|
|
d.setSeconds(0, 0);
|
|
d.setMinutes(Math.ceil(d.getMinutes() / 5) * 5);
|
|
return d;
|
|
}
|
|
|
|
export function RemindMeDialog({ roomId, eventId, previewText, onClose }: RemindMeDialogProps) {
|
|
const modalStyle = useModalStyle(320);
|
|
const { addReminder, removeReminder, reminders } = useReminders();
|
|
const presets = useMemo(() => getPresets(), []);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [customOpen, setCustomOpen] = useState(false);
|
|
const def = useMemo(() => defaultCustomDate(), []);
|
|
const [dateValue, setDateValue] = useState(() => toLocalDate(def));
|
|
const [timeValue, setTimeValue] = useState(() => toLocalTime(def));
|
|
const dateInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Reminders already set on this message (soonest first) — so the user can see
|
|
// and cancel them instead of silently stacking duplicates.
|
|
const existing = useMemo(
|
|
() => reminders.filter((r) => r.eventId === eventId).sort((a, b) => a.timestamp - b.timestamp),
|
|
[reminders, eventId],
|
|
);
|
|
|
|
// Move focus into the revealed date input for keyboard/SR users.
|
|
useEffect(() => {
|
|
if (customOpen) dateInputRef.current?.focus();
|
|
}, [customOpen]);
|
|
|
|
const handleCancelExisting = (timestamp: number) => {
|
|
// Optimistic, matching removeBookmark: the shared account-data store drops
|
|
// the reminder locally at once (no rollback) and re-syncs from the server.
|
|
// We deliberately show no inline error — the store has no rollback path, so a
|
|
// failed write simply reappears on the next sync rather than leaving a stale
|
|
// "couldn't cancel" message beside an already-vanished row.
|
|
removeReminder(eventId, timestamp).catch(() => undefined);
|
|
};
|
|
|
|
const commit = async (timestamp: number) => {
|
|
if (busy) return;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
await addReminder({
|
|
roomId,
|
|
eventId,
|
|
timestamp,
|
|
message: previewText || 'Reminder',
|
|
});
|
|
onClose();
|
|
} catch {
|
|
setBusy(false);
|
|
setError('Could not set reminder. Try again.');
|
|
}
|
|
};
|
|
|
|
const handlePick = (ms: number) => commit(Date.now() + ms);
|
|
|
|
const customDate = parseLocalDateTime(dateValue, timeValue);
|
|
const customValid = !!customDate && customDate.getTime() - Date.now() >= 60_000;
|
|
|
|
const handleCustom = () => {
|
|
if (!customDate) {
|
|
setError('Please select a valid date and time.');
|
|
return;
|
|
}
|
|
if (customDate.getTime() - Date.now() < 60_000) {
|
|
setError('Reminder time must be at least 1 minute in the future.');
|
|
return;
|
|
}
|
|
commit(customDate.getTime());
|
|
};
|
|
|
|
return (
|
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
|
<OverlayCenter>
|
|
<FocusTrap
|
|
focusTrapOptions={{
|
|
initialFocus: false,
|
|
onDeactivate: onClose,
|
|
clickOutsideDeactivates: true,
|
|
escapeDeactivates: stopPropagation,
|
|
}}
|
|
>
|
|
<Dialog
|
|
variant="Surface"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="remind-me-title"
|
|
style={modalStyle}
|
|
>
|
|
<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="remind-me-title" size="H4">
|
|
Remind Me
|
|
</Text>
|
|
</Box>
|
|
<IconButton size="300" radii="300" onClick={onClose} aria-label="Close">
|
|
<Icon src={Icons.Cross} />
|
|
</IconButton>
|
|
</Header>
|
|
{previewText && (
|
|
<>
|
|
<Box style={{ padding: `${config.space.S200} ${config.space.S400}` }}>
|
|
<Text size="T200" priority="300" truncate>
|
|
{previewText}
|
|
</Text>
|
|
</Box>
|
|
<Line size="300" />
|
|
</>
|
|
)}
|
|
{existing.length > 0 && (
|
|
<>
|
|
<Box
|
|
direction="Column"
|
|
gap="100"
|
|
style={{ padding: `${config.space.S200} ${config.space.S200} 0` }}
|
|
>
|
|
<Text size="L400" priority="300" style={{ paddingLeft: config.space.S200 }}>
|
|
{existing.length === 1 ? 'Reminder set' : 'Reminders set'}
|
|
</Text>
|
|
{existing.map((r, idx) => (
|
|
// Composite key: two custom reminders on one message can share
|
|
// a minute-precision timestamp; index keeps React keys unique.
|
|
<Box key={`${r.timestamp}-${idx}`} alignItems="Center" gap="200">
|
|
<Icon src={Icons.Clock} size="100" style={{ flexShrink: 0 }} />
|
|
<Text size="T200" style={{ flexGrow: 1, minWidth: 0 }} truncate>
|
|
{formatFriendlyDateTime(r.timestamp)}
|
|
</Text>
|
|
<IconButton
|
|
size="300"
|
|
radii="300"
|
|
variant="SurfaceVariant"
|
|
fill="None"
|
|
onClick={() => handleCancelExisting(r.timestamp)}
|
|
aria-label={`Cancel reminder for ${formatFriendlyDateTime(r.timestamp)}`}
|
|
>
|
|
<Icon src={Icons.Cross} size="100" />
|
|
</IconButton>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
<Line size="300" style={{ marginTop: config.space.S200 }} />
|
|
</>
|
|
)}
|
|
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
|
|
{presets.map((p) => (
|
|
<Button
|
|
key={p.label}
|
|
size="300"
|
|
variant="Secondary"
|
|
fill="Soft"
|
|
radii="300"
|
|
disabled={busy}
|
|
onClick={() => handlePick(p.ms)}
|
|
>
|
|
<Text size="B300" truncate>
|
|
{p.label}
|
|
</Text>
|
|
</Button>
|
|
))}
|
|
|
|
{customOpen ? (
|
|
<Box direction="Column" gap="200" style={{ paddingTop: config.space.S100 }}>
|
|
<Box gap="200">
|
|
<Box direction="Column" gap="100" style={{ flex: 1 }}>
|
|
<Text as="label" htmlFor="remind-date" size="T200" priority="400">
|
|
Date
|
|
</Text>
|
|
<input
|
|
ref={dateInputRef}
|
|
id="remind-date"
|
|
type="date"
|
|
value={dateValue}
|
|
min={toLocalDate(new Date())}
|
|
disabled={busy}
|
|
onChange={(e) => {
|
|
setDateValue(e.target.value);
|
|
setError(null);
|
|
}}
|
|
style={pickerInputStyle(color, config)}
|
|
/>
|
|
</Box>
|
|
<Box direction="Column" gap="100" style={{ flex: 1 }}>
|
|
<Text as="label" htmlFor="remind-time" size="T200" priority="400">
|
|
Time
|
|
</Text>
|
|
<input
|
|
id="remind-time"
|
|
type="time"
|
|
value={timeValue}
|
|
disabled={busy}
|
|
onChange={(e) => {
|
|
setTimeValue(e.target.value);
|
|
setError(null);
|
|
}}
|
|
style={pickerInputStyle(color, config)}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
{!customValid && (dateValue || timeValue) && (
|
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
|
Must be at least 1 minute in the future
|
|
</Text>
|
|
)}
|
|
<Button
|
|
size="300"
|
|
variant="Primary"
|
|
radii="300"
|
|
disabled={busy || !customValid}
|
|
onClick={handleCustom}
|
|
>
|
|
<Text size="B300">Set reminder</Text>
|
|
</Button>
|
|
</Box>
|
|
) : (
|
|
<Button
|
|
size="300"
|
|
variant="Secondary"
|
|
fill="None"
|
|
radii="300"
|
|
disabled={busy}
|
|
onClick={() => {
|
|
setError(null);
|
|
setCustomOpen(true);
|
|
}}
|
|
before={<Icon src={Icons.Clock} size="100" />}
|
|
>
|
|
<Text size="B300">Custom time…</Text>
|
|
</Button>
|
|
)}
|
|
|
|
{error && (
|
|
<Text
|
|
size="T200"
|
|
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
|
|
>
|
|
{error}
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
</Dialog>
|
|
</FocusTrap>
|
|
</OverlayCenter>
|
|
</Overlay>
|
|
);
|
|
}
|