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:
@@ -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>
|
||||
|
||||
@@ -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 '));
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user