From b6725a6ee3160e4e3f8441a1c371873a4117fe4c Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 10 Jul 2026 15:45:43 -0400 Subject: [PATCH] feat(reminders): custom date/time option in Remind Me MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Remind Me dialog only offered four fixed presets, so you couldn't set a reminder for an arbitrary time. Add a "Custom time…" option that reveals date + time pickers (validated >= 1 minute in the future) and sets the reminder at that absolute timestamp. Also extract the local date/time helpers (toLocalDate, toLocalTime, parseLocalDateTime, pickerInputStyle) into a shared, unit-tested utils/datetimeInput.ts and reuse them in ScheduleMessageModal (deduped from an inline copy) — identical output, now covered by tests. Documents the previously-undocumented Message Reminders feature in LOTUS_FEATURES.md. Co-Authored-By: Claude Opus 4.8 --- LOTUS_FEATURES.md | 6 + .../features/room/ScheduleMessageModal.tsx | 36 +----- .../features/room/message/RemindMeDialog.tsx | 103 +++++++++++++++++- src/app/utils/datetimeInput.test.ts | 45 ++++++++ src/app/utils/datetimeInput.ts | 45 ++++++++ 5 files changed, 202 insertions(+), 33 deletions(-) create mode 100644 src/app/utils/datetimeInput.test.ts create mode 100644 src/app/utils/datetimeInput.ts diff --git a/LOTUS_FEATURES.md b/LOTUS_FEATURES.md index 19de1fef8..4b91bfe0c 100644 --- a/LOTUS_FEATURES.md +++ b/LOTUS_FEATURES.md @@ -746,6 +746,12 @@ Redacted events display "This message has been deleted" along with the redaction - **Edit / reschedule**: the tray's edit button re-opens `ScheduleMessageModal` (seeded with the existing body + send-time) to change the text and/or time. Since MSC4140 has no in-place edit, this is implemented as schedule-new-then-cancel-old; the old copy is only removed once the server confirms cancellation, so a failed cancel leaves a visible, cancellable copy rather than losing the message. Edits go through the plain-text composer (rich content becomes `m.text`). - Utilities in `src/app/utils/scheduledMessages.ts` +### Message Reminders + +- 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). +- 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) - Implemented in `UploadCardRenderer.tsx` diff --git a/src/app/features/room/ScheduleMessageModal.tsx b/src/app/features/room/ScheduleMessageModal.tsx index 78669a035..253eac0a6 100644 --- a/src/app/features/room/ScheduleMessageModal.tsx +++ b/src/app/features/room/ScheduleMessageModal.tsx @@ -21,6 +21,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient'; import { stopPropagation } from '../../utils/keyboard'; import { scheduleMessage } from '../../utils/scheduledMessages'; import { useModalStyle } from '../../hooks/useModalStyle'; +import { toLocalDate, toLocalTime, parseLocalDateTime, pickerInputStyle } from '../../utils/datetimeInput'; interface ScheduleMessageModalProps { roomId: string; @@ -65,32 +66,6 @@ function formatSendAt(sendAt: Date): string { return `${sendAt.toLocaleDateString()} at ${timeStr}`; } -function toLocalDate(date: Date): string { - const pad = (n: number) => String(n).padStart(2, '0'); - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; -} - -function toLocalTime(date: Date): string { - const pad = (n: number) => String(n).padStart(2, '0'); - return `${pad(date.getHours())}:${pad(date.getMinutes())}`; -} - -// Shared style for date/time inputs — dark-mode calendar/clock popup via colorScheme. -const pickerInputStyle = (c: typeof color, cfg: typeof config): React.CSSProperties => ({ - background: c.SurfaceVariant.Container, - color: c.SurfaceVariant.OnContainer, - border: `${cfg.borderWidth.B300} solid ${c.SurfaceVariant.ContainerLine}`, - borderRadius: cfg.radii.R300, - padding: `${cfg.space.S200} ${cfg.space.S300}`, - fontSize: '0.875rem', - width: '100%', - boxSizing: 'border-box', - outline: 'none', - fontFamily: 'inherit', - // Hint browser to render the calendar/clock popup in dark mode - colorScheme: 'dark', -}); - export function ScheduleMessageModal({ roomId, initialBody, @@ -119,11 +94,10 @@ export function ScheduleMessageModal({ const [dateValue, setDateValue] = useState(() => toLocalDate(def)); const [timeValue, setTimeValue] = useState(() => toLocalTime(def)); - const getSendAt = useCallback((): Date | null => { - if (!dateValue || !timeValue) return null; - const dt = new Date(`${dateValue}T${timeValue}:00`); - return Number.isNaN(dt.getTime()) ? null : dt; - }, [dateValue, timeValue]); + const getSendAt = useCallback( + (): Date | null => parseLocalDateTime(dateValue, timeValue), + [dateValue, timeValue], + ); const [preview, setPreview] = useState<{ label: string; relative: string } | null>(null); diff --git a/src/app/features/room/message/RemindMeDialog.tsx b/src/app/features/room/message/RemindMeDialog.tsx index edfa31cd2..dab5dd855 100644 --- a/src/app/features/room/message/RemindMeDialog.tsx +++ b/src/app/features/room/message/RemindMeDialog.tsx @@ -19,6 +19,12 @@ import { import { stopPropagation } from '../../../utils/keyboard'; import { useReminders } from '../../../hooks/useReminders'; import { useModalStyle } from '../../../hooks/useModalStyle'; +import { + toLocalDate, + toLocalTime, + parseLocalDateTime, + pickerInputStyle, +} from '../../../utils/datetimeInput'; type RemindMeDialogProps = { roomId: string; @@ -40,14 +46,26 @@ function getPresets(): Array<{ label: string; ms: number }> { ]; } +// 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 } = useReminders(); const presets = useMemo(() => getPresets(), []); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const [customOpen, setCustomOpen] = useState(false); + const def = useMemo(() => defaultCustomDate(), []); + const [dateValue, setDateValue] = useState(() => toLocalDate(def)); + const [timeValue, setTimeValue] = useState(() => toLocalTime(def)); - const handlePick = async (ms: number) => { + const commit = async (timestamp: number) => { if (busy) return; setBusy(true); setError(null); @@ -55,7 +73,7 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind await addReminder({ roomId, eventId, - timestamp: Date.now() + ms, + timestamp, message: previewText || 'Reminder', }); onClose(); @@ -65,6 +83,23 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind } }; + 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 ( }> @@ -127,6 +162,70 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind ))} + + {customOpen ? ( + + + + + Date + + setDateValue(e.target.value)} + style={pickerInputStyle(color, config)} + /> + + + + Time + + setTimeValue(e.target.value)} + style={pickerInputStyle(color, config)} + /> + + + {!customValid && (dateValue || timeValue) && ( + + Must be at least 1 minute in the future + + )} + + + ) : ( + + )} + {error && ( { + assert.equal(toLocalDate(new Date(2026, 0, 5, 9, 3)), '2026-01-05'); + assert.equal(toLocalDate(new Date(2026, 11, 25, 0, 0)), '2026-12-25'); +}); + +test('toLocalTime: zero-pads hours and minutes (24h)', () => { + assert.equal(toLocalTime(new Date(2026, 0, 5, 9, 3)), '09:03'); + assert.equal(toLocalTime(new Date(2026, 0, 5, 23, 59)), '23:59'); + assert.equal(toLocalTime(new Date(2026, 0, 5, 0, 0)), '00:00'); +}); + +test('toLocalDate/toLocalTime round-trip through parseLocalDateTime', () => { + const d = new Date(2026, 5, 15, 14, 30, 0, 0); + const parsed = parseLocalDateTime(toLocalDate(d), toLocalTime(d)); + assert.ok(parsed); + assert.equal(parsed.getTime(), d.getTime()); +}); + +test('parseLocalDateTime: returns null for missing parts', () => { + assert.equal(parseLocalDateTime('', '09:00'), null); + assert.equal(parseLocalDateTime('2026-01-05', ''), null); + assert.equal(parseLocalDateTime('', ''), null); +}); + +test('parseLocalDateTime: returns null for an invalid combination', () => { + assert.equal(parseLocalDateTime('not-a-date', '09:00'), null); + assert.equal(parseLocalDateTime('2026-13-40', '09:00'), null); +}); + +test('parseLocalDateTime: valid input yields the local wall-clock time', () => { + const parsed = parseLocalDateTime('2026-01-05', '09:03'); + assert.ok(parsed); + assert.equal(parsed.getFullYear(), 2026); + assert.equal(parsed.getMonth(), 0); + assert.equal(parsed.getDate(), 5); + assert.equal(parsed.getHours(), 9); + assert.equal(parsed.getMinutes(), 3); +}); diff --git a/src/app/utils/datetimeInput.ts b/src/app/utils/datetimeInput.ts new file mode 100644 index 000000000..3eb22bc51 --- /dev/null +++ b/src/app/utils/datetimeInput.ts @@ -0,0 +1,45 @@ +import { CSSProperties } from 'react'; +import { color as foldsColor, config as foldsConfig } from 'folds'; + +const pad = (n: number): string => String(n).padStart(2, '0'); + +// Format a Date as the value string expected by in the +// user's LOCAL timezone. (toISOString would shift to UTC and can land on the +// wrong calendar day.) +export function toLocalDate(date: Date): string { + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +} + +// Format a Date as the value string expected by (local, HH:mm). +export function toLocalTime(date: Date): string { + return `${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +// Parse the local date + time values back into a Date. Returns null when +// either value is missing or the combination is invalid (e.g. a partial entry). +export function parseLocalDateTime(dateValue: string, timeValue: string): Date | null { + if (!dateValue || !timeValue) return null; + const dt = new Date(`${dateValue}T${timeValue}:00`); + return Number.isNaN(dt.getTime()) ? null : dt; +} + +// Shared style for date/time s — matches the app's surface tokens and +// hints a dark-mode calendar/clock popup via colorScheme. +export function pickerInputStyle( + c: typeof foldsColor = foldsColor, + cfg: typeof foldsConfig = foldsConfig, +): CSSProperties { + return { + background: c.SurfaceVariant.Container, + color: c.SurfaceVariant.OnContainer, + border: `${cfg.borderWidth.B300} solid ${c.SurfaceVariant.ContainerLine}`, + borderRadius: cfg.radii.R300, + padding: `${cfg.space.S200} ${cfg.space.S300}`, + fontSize: '0.875rem', + width: '100%', + boxSizing: 'border-box', + outline: 'none', + fontFamily: 'inherit', + colorScheme: 'dark', + }; +}