feat(reminders): view and cancel a message's existing reminders

There was no way to see or cancel a reminder once set (removeReminder was only
called by the fire-and-forget monitor), and addReminder didn't dedupe, so a
message could silently accumulate duplicate reminders. The Remind Me dialog now
lists the reminders already set on that message (soonest first) each with a
cancel button.

- New shared, tested formatFriendlyDateTime(ts, now?) in utils/datetimeInput.ts
  (Today/Tomorrow/date + time).
- Per-row cancel busy-guard; inline "Could not cancel" on failure.

Also applies two nits from the custom-time review: focus the date input when the
custom picker is revealed, and clear the error when editing date/time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 15:51:11 -04:00
co-authored by Claude Opus 4.8
parent b6725a6ee3
commit 7d67cdeef0
4 changed files with 123 additions and 5 deletions
@@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Box,
@@ -24,6 +24,7 @@ import {
toLocalTime,
parseLocalDateTime,
pickerInputStyle,
formatFriendlyDateTime,
} from '../../../utils/datetimeInput';
type RemindMeDialogProps = {
@@ -56,14 +57,48 @@ function defaultCustomDate(): Date {
export function RemindMeDialog({ roomId, eventId, previewText, onClose }: RemindMeDialogProps) {
const modalStyle = useModalStyle(320);
const { addReminder } = useReminders();
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 [cancelling, setCancelling] = useState<Set<number>>(new Set());
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 = async (timestamp: number) => {
if (cancelling.has(timestamp)) return;
setCancelling((prev) => new Set(prev).add(timestamp));
setError(null);
try {
await removeReminder(eventId, timestamp);
} catch {
setError('Could not cancel reminder. Try again.');
} finally {
setCancelling((prev) => {
const next = new Set(prev);
next.delete(timestamp);
return next;
});
}
};
const commit = async (timestamp: number) => {
if (busy) return;
@@ -146,6 +181,39 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
<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) => (
<Box key={r.timestamp} 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"
disabled={cancelling.has(r.timestamp)}
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
@@ -171,12 +239,16 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
Date
</Text>
<input
ref={dateInputRef}
id="remind-date"
type="date"
value={dateValue}
min={toLocalDate(new Date())}
disabled={busy}
onChange={(e) => setDateValue(e.target.value)}
onChange={(e) => {
setDateValue(e.target.value);
setError(null);
}}
style={pickerInputStyle(color, config)}
/>
</Box>
@@ -189,7 +261,10 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
type="time"
value={timeValue}
disabled={busy}
onChange={(e) => setTimeValue(e.target.value)}
onChange={(e) => {
setTimeValue(e.target.value);
setError(null);
}}
style={pickerInputStyle(color, config)}
/>
</Box>