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
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
311 lines
11 KiB
TypeScript
311 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 { TimestampPrefs, formatTime } from '../../../utils/formatTimestamp';
|
|
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
|
|
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(prefs: TimestampPrefs): Array<{ label: string; ms: number }> {
|
|
const tomorrow = new Date();
|
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
tomorrow.setHours(9, 0, 0, 0);
|
|
const timeLabel = formatTime(tomorrow.getTime(), prefs);
|
|
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 { prefs } = useTimestampFormatter();
|
|
const presets = useMemo(() => getPresets(prefs), [prefs]);
|
|
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, prefs)}
|
|
</Text>
|
|
<IconButton
|
|
size="300"
|
|
radii="300"
|
|
variant="SurfaceVariant"
|
|
fill="None"
|
|
onClick={() => handleCancelExisting(r.timestamp)}
|
|
aria-label={`Cancel reminder for ${formatFriendlyDateTime(r.timestamp, prefs)}`}
|
|
>
|
|
<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>
|
|
);
|
|
}
|