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`.
|
- 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).
|
- `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).
|
- 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)
|
### 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 FocusTrap from 'focus-trap-react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
toLocalTime,
|
toLocalTime,
|
||||||
parseLocalDateTime,
|
parseLocalDateTime,
|
||||||
pickerInputStyle,
|
pickerInputStyle,
|
||||||
|
formatFriendlyDateTime,
|
||||||
} from '../../../utils/datetimeInput';
|
} from '../../../utils/datetimeInput';
|
||||||
|
|
||||||
type RemindMeDialogProps = {
|
type RemindMeDialogProps = {
|
||||||
@@ -56,14 +57,48 @@ function defaultCustomDate(): Date {
|
|||||||
|
|
||||||
export function RemindMeDialog({ roomId, eventId, previewText, onClose }: RemindMeDialogProps) {
|
export function RemindMeDialog({ roomId, eventId, previewText, onClose }: RemindMeDialogProps) {
|
||||||
const modalStyle = useModalStyle(320);
|
const modalStyle = useModalStyle(320);
|
||||||
const { addReminder } = useReminders();
|
const { addReminder, removeReminder, reminders } = useReminders();
|
||||||
const presets = useMemo(() => getPresets(), []);
|
const presets = useMemo(() => getPresets(), []);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [customOpen, setCustomOpen] = useState(false);
|
const [customOpen, setCustomOpen] = useState(false);
|
||||||
|
const [cancelling, setCancelling] = useState<Set<number>>(new Set());
|
||||||
const def = useMemo(() => defaultCustomDate(), []);
|
const def = useMemo(() => defaultCustomDate(), []);
|
||||||
const [dateValue, setDateValue] = useState(() => toLocalDate(def));
|
const [dateValue, setDateValue] = useState(() => toLocalDate(def));
|
||||||
const [timeValue, setTimeValue] = useState(() => toLocalTime(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) => {
|
const commit = async (timestamp: number) => {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
@@ -146,6 +181,39 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
|
|||||||
<Line size="300" />
|
<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 }}>
|
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
|
||||||
{presets.map((p) => (
|
{presets.map((p) => (
|
||||||
<Button
|
<Button
|
||||||
@@ -171,12 +239,16 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
|
|||||||
Date
|
Date
|
||||||
</Text>
|
</Text>
|
||||||
<input
|
<input
|
||||||
|
ref={dateInputRef}
|
||||||
id="remind-date"
|
id="remind-date"
|
||||||
type="date"
|
type="date"
|
||||||
value={dateValue}
|
value={dateValue}
|
||||||
min={toLocalDate(new Date())}
|
min={toLocalDate(new Date())}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onChange={(e) => setDateValue(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setDateValue(e.target.value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
style={pickerInputStyle(color, config)}
|
style={pickerInputStyle(color, config)}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -189,7 +261,10 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
|
|||||||
type="time"
|
type="time"
|
||||||
value={timeValue}
|
value={timeValue}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onChange={(e) => setTimeValue(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setTimeValue(e.target.value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
style={pickerInputStyle(color, config)}
|
style={pickerInputStyle(color, config)}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { test } from 'node:test';
|
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
|
// All helpers work in the LOCAL timezone. We construct Dates with the local
|
||||||
// component constructor so the assertions hold regardless of where tests run.
|
// 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.getHours(), 9);
|
||||||
assert.equal(parsed.getMinutes(), 3);
|
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;
|
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
|
// Shared style for date/time <input>s — matches the app's surface tokens and
|
||||||
// hints a dark-mode calendar/clock popup via colorScheme.
|
// hints a dark-mode calendar/clock popup via colorScheme.
|
||||||
export function pickerInputStyle(
|
export function pickerInputStyle(
|
||||||
|
|||||||
Reference in New Issue
Block a user