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
View File
@@ -750,6 +750,7 @@ Redacted events display "This message has been deleted" along with the redaction
- Message context menu → **Remind Me** sets a personal reminder to revisit a message; reminders are stored in `io.lotus.reminders` account data (sync across devices) via `useReminders`, and fire from `ClientNonUIFeatures`.
- `RemindMeDialog.tsx` offers quick presets (in 20 min / 1 hour / 3 hours / tomorrow 9am) **plus a "Custom time…" option** that reveals date + time pickers for an arbitrary reminder time (validated to be ≥ 1 minute in the future).
- **Manage existing reminders**: opening the dialog on a message that already has reminders lists them (soonest first, friendly time via `formatFriendlyDateTime`) each with a cancel (×) button, so you can see and remove pending reminders instead of silently stacking duplicates.
- The date/time input helpers (`toLocalDate`, `toLocalTime`, `parseLocalDateTime`, `pickerInputStyle`) are shared, pure, and unit-tested in `src/app/utils/datetimeInput.ts` (`datetimeInput.test.ts`) — also used by `ScheduleMessageModal` (deduped from a prior inline copy).
### File Upload Compression (opt-in)
@@ -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>
+26 -1
View File
@@ -1,6 +1,11 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { toLocalDate, toLocalTime, parseLocalDateTime } from './datetimeInput';
import {
toLocalDate,
toLocalTime,
parseLocalDateTime,
formatFriendlyDateTime,
} from './datetimeInput';
// All helpers work in the LOCAL timezone. We construct Dates with the local
// component constructor so the assertions hold regardless of where tests run.
@@ -43,3 +48,23 @@ test('parseLocalDateTime: valid input yields the local wall-clock time', () => {
assert.equal(parsed.getHours(), 9);
assert.equal(parsed.getMinutes(), 3);
});
test('formatFriendlyDateTime: uses Today/Tomorrow/date prefixes', () => {
const now = new Date(2026, 0, 5, 10, 0).getTime();
const laterToday = new Date(2026, 0, 5, 15, 30).getTime();
const tomorrow = new Date(2026, 0, 6, 9, 0).getTime();
const nextWeek = new Date(2026, 0, 12, 9, 0).getTime();
assert.ok(formatFriendlyDateTime(laterToday, now).startsWith('Today at '));
assert.ok(formatFriendlyDateTime(tomorrow, now).startsWith('Tomorrow at '));
const other = formatFriendlyDateTime(nextWeek, now);
assert.ok(!other.startsWith('Today'));
assert.ok(!other.startsWith('Tomorrow'));
assert.ok(other.includes(' at '));
});
test('formatFriendlyDateTime: Tomorrow rolls over month/year boundaries', () => {
const nye = new Date(2026, 11, 31, 23, 0).getTime();
const jan1 = new Date(2027, 0, 1, 9, 0).getTime();
assert.ok(formatFriendlyDateTime(jan1, nye).startsWith('Tomorrow at '));
});
+17
View File
@@ -23,6 +23,23 @@ export function parseLocalDateTime(dateValue: string, timeValue: string): Date |
return Number.isNaN(dt.getTime()) ? null : dt;
}
// Human-friendly absolute time: "Today at 3:00 PM", "Tomorrow at 9:00 AM", or
// "1/5/2026 at 3:00 PM". `now` is injectable so the relative-day logic is testable.
export function formatFriendlyDateTime(ts: number, now: number = Date.now()): string {
const date = new Date(ts);
const nowDate = new Date(now);
const sameDay = (a: Date, b: Date): boolean =>
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate();
const tomorrow = new Date(nowDate);
tomorrow.setDate(tomorrow.getDate() + 1);
const timeStr = date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
if (sameDay(date, nowDate)) return `Today at ${timeStr}`;
if (sameDay(date, tomorrow)) return `Tomorrow at ${timeStr}`;
return `${date.toLocaleDateString()} at ${timeStr}`;
}
// Shared style for date/time <input>s — matches the app's surface tokens and
// hints a dark-mode calendar/clock popup via colorScheme.
export function pickerInputStyle(