diff --git a/desktop/src/app/AppShell.helpers.ts b/desktop/src/app/AppShell.helpers.ts index 0a10e72db..8ce0b8ca1 100644 --- a/desktop/src/app/AppShell.helpers.ts +++ b/desktop/src/app/AppShell.helpers.ts @@ -5,7 +5,6 @@ export type AppView = | "home" | "channel" | "agents" - | "reminders" | "workflows" | "pulse" | "projects"; @@ -86,13 +85,6 @@ export function deriveShellRoute(pathname: string): { }; } - if (pathname === "/reminders") { - return { - selectedChannelId: null, - selectedView: "reminders", - }; - } - return { selectedChannelId: null, selectedView: "home", diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 99eccf410..cca89f471 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -66,6 +66,7 @@ import { } from "@/features/settings/ui/SettingsPanels"; import { HuddleBar, HuddleProvider } from "@/features/huddle"; import { RemindMeLaterProvider } from "@/features/reminders/ui/RemindMeLaterProvider"; +import { useReminderNotifications } from "@/features/reminders/useReminderNotifications"; import { AppSidebar } from "@/features/sidebar/ui/AppSidebar"; import { useChannelMutes } from "@/features/sidebar/lib/useChannelMutes"; import { useChannelStars } from "@/features/sidebar/lib/useChannelStars"; @@ -115,7 +116,6 @@ export function AppShell() { goChannel, goHome, goProjects, - goReminders, goPulse, goSettings, goWorkflows, @@ -163,6 +163,10 @@ export function AppShell() { const setUserStatusMutation = useSetUserStatusMutation(deferredPubkey); const { feedProfilesQuery, homeFeedQuery, notificationSettings } = useHomeFeedNotifications(identityQuery.data?.pubkey); + useReminderNotifications( + identityQuery.data?.pubkey, + notificationSettings.settings, + ); const refetchHomeFeedOnLiveMention = React.useEffectEvent(() => { void homeFeedQuery.refetch(); }); @@ -714,7 +718,7 @@ export function AppShell() { }} > - +
void goHome()} onSelectProjects={() => void goProjects()} onSelectPulse={() => void goPulse()} - onSelectReminders={() => void goReminders()} onSelectSettings={handleOpenSettings} onSelectWorkflows={() => void goWorkflows()} onSetPresenceStatus={(status) => diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 2062b1dd3..0788bea19 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -90,17 +90,6 @@ export function useAppNavigation() { [commitNavigation], ); - const goReminders = React.useCallback( - (behavior?: NavigationBehavior) => - commitNavigation( - { - to: "/reminders", - }, - behavior, - ), - [commitNavigation], - ); - const goProject = React.useCallback( (projectId: string, behavior?: NavigationBehavior) => commitNavigation( @@ -272,7 +261,6 @@ export function useAppNavigation() { goProject, goProjects, goPulse, - goReminders, goSettings, goWorkflow, goWorkflows, diff --git a/desktop/src/app/routes/reminders.tsx b/desktop/src/app/routes/reminders.tsx index 37599762b..83f01e45e 100644 --- a/desktop/src/app/routes/reminders.tsx +++ b/desktop/src/app/routes/reminders.tsx @@ -1,19 +1,11 @@ -import * as React from "react"; -import { createFileRoute } from "@tanstack/react-router"; - -const RemindersScreen = React.lazy(async () => { - const module = await import("@/features/reminders/ui/RemindersScreen"); - return { default: module.RemindersScreen }; -}); +import { createFileRoute, redirect } from "@tanstack/react-router"; +// Reminders is now a filter option inside the inbox dropdown, selected via +// local state rather than the URL. This redirect preserves existing history +// entries and bookmarks pointing at `/reminders` so they land in the inbox +// instead of dead-ending; the user re-selects Reminders from the filter. export const Route = createFileRoute("/reminders")({ - component: RemindersRouteComponent, + beforeLoad: () => { + throw redirect({ to: "/" }); + }, }); - -function RemindersRouteComponent() { - return ( - - - - ); -} diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts index 3e40b187a..99d4fe31b 100644 --- a/desktop/src/features/home/lib/inbox.ts +++ b/desktop/src/features/home/lib/inbox.ts @@ -17,7 +17,8 @@ export type InboxFilter = | "mention" | "needs_action" | "activity" - | "agent_activity"; + | "agent_activity" + | "reminders"; export type InboxItem = { avatarUrl: string | null; diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index f28e15901..fd5085bb3 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -37,6 +37,10 @@ import { import { splitOutgoingTags } from "@/features/messages/lib/imetaMediaMarkdown"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import { resolveUserLabel } from "@/features/profile/lib/identity"; +import { + countDueReminders, + useRemindersQuery, +} from "@/features/reminders/hooks"; import { deleteMessage, sendChannelMessage } from "@/shared/api/tauri"; import type { HomeFeedResponse } from "@/shared/api/types"; import { KIND_REACTION } from "@/shared/constants/kinds"; @@ -81,7 +85,14 @@ export function HomeView({ // background data loads must never trigger navigations. const { applyPatch: applyInboxSearchPatch, values: inboxSearchValues } = useHistorySearchState(INBOX_SEARCH_KEYS); - const urlSelectedItemId = inboxSearchValues.item; + const isReminders = filter === "reminders"; + const isMessagesMode = !isReminders; + const remindersQuery = useRemindersQuery(currentPubkey); + const dueReminderCount = countDueReminders(remindersQuery.data ?? []); + // `?item=` is Messages-mode-only machinery: a reminder never enters the + // FeedItem selection model, so reload while in Reminders mode keeps a stale + // `?item=` unconsumed and does not snap back to a feed-item detail view. + const urlSelectedItemId = isMessagesMode ? inboxSearchValues.item : null; const [selectedItemId, setSelectedItemId] = React.useState( urlSelectedItemId, ); @@ -251,6 +262,14 @@ export function HomeView({ return localReplies.filter((reply) => !contextIds.has(reply.id)); }, [contextMessages, localRepliesByItemId, selectedItem]); React.useEffect(() => { + // Auto-selection is Messages-mode-only: in Reminders mode no FeedItem is + // ever selected, so default-selecting one behind the reminders list would + // be wasted work and could drive narrow-viewport detail off a stale feed + // selection. + if (!isMessagesMode) { + return; + } + // While the feed is loading (e.g. a reload restoring `?item=` from the // URL) the selected item simply hasn't arrived yet — don't clobber it. if (isLoading || !feed) { @@ -278,6 +297,7 @@ export function HomeView({ filteredItems, homeInboxWidthPx, isLoading, + isMessagesMode, isNarrowHomeViewport, selectedItemId, ]); @@ -347,8 +367,12 @@ export function HomeView({ selectedItem.item.pubkey.trim().toLowerCase(); const isSinglePanelDetailView = isNarrowHomeViewport && selectedItemId !== null; + // Reminders mode is single-pane: the reminders list renders inline row + // actions and never drives the FeedItem detail pane, so the detail column is + // not rendered at all (no empty pane on wide viewports). const showListPane = !isSinglePanelDetailView; - const showDetailPane = !isNarrowHomeViewport || isSinglePanelDetailView; + const showDetailPane = + isMessagesMode && (!isNarrowHomeViewport || isSinglePanelDetailView); const maxEffectiveInboxListWidthPx = homeInboxWidthPx > 0 ? Math.max( @@ -384,6 +408,7 @@ export function HomeView({ {showListPane ? ( diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index c4131d8be..670c95b46 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -1,4 +1,4 @@ -import { ChevronDown, Inbox } from "lucide-react"; +import { ChevronDown } from "lucide-react"; import * as React from "react"; import { @@ -6,6 +6,7 @@ import { type InboxFilter, type InboxItem, } from "@/features/home/lib/inbox"; +import { RemindersPanel } from "@/features/reminders/ui/RemindersPanel"; import { topChromeInset } from "@/shared/layout/chromeLayout"; import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader"; import { cn } from "@/shared/lib/cn"; @@ -27,6 +28,7 @@ const FILTER_OPTIONS: Array<{ label: string; value: InboxFilter }> = [ { value: "needs_action", label: "Needs Action" }, { value: "activity", label: "Activity" }, { value: "agent_activity", label: "Agents" }, + { value: "reminders", label: "Reminders" }, ]; type InboxListPaneProps = { @@ -37,6 +39,8 @@ type InboxListPaneProps = { onSelect: (itemId: string) => void; selectedId: string | null; showRightDivider?: boolean; + dueReminderCount: number; + reminderPubkey?: string; }; export function InboxListPane({ @@ -47,8 +51,11 @@ export function InboxListPane({ onSelect, selectedId, showRightDivider = false, + dueReminderCount, + reminderPubkey, }: InboxListPaneProps) { const activeFilter = FILTER_OPTIONS.find((option) => option.value === filter); + const isReminders = filter === "reminders"; const scrollRef = React.useRef(null); const renderItem = (item: InboxItem) => { @@ -144,22 +151,27 @@ export function InboxListPane({ >
-
-
- -

- Inbox -

-
+ {/* Cap to the list-column width so the right-aligned dropdown stays + put when the pane goes full-width in reminders mode. */} +
@@ -175,7 +187,18 @@ export function InboxListPane({ key={option.value} value={option.value} > - {option.label} + + {option.label} + {option.value === "reminders" && + dueReminderCount > 0 ? ( + + {dueReminderCount} + + ) : null} + ))} @@ -185,32 +208,43 @@ export function InboxListPane({
-
- {items.length === 0 ? ( -
-
-

- No messages found -

-

- Switch back to all mail to see more messages. -

+ {isReminders ? ( +
+ {reminderPubkey ? ( + + ) : null} +
+ ) : ( +
+ {items.length === 0 ? ( +
+
+

+ No messages found +

+

+ Switch back to all mail to see more messages. +

+
-
- ) : ( - item.id} - items={items} - renderItem={renderItem} - scrollRef={scrollRef} - /> - )} -
+ ) : ( + item.id} + items={items} + renderItem={renderItem} + scrollRef={scrollRef} + /> + )} +
+ )} ); } diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 83ece19c7..869ef0cb4 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -90,7 +90,8 @@ export const MessageRow = React.memo( errorMessage: reactionErrorMessage, select: handleReactionSelect, } = useReactionHandler(message, onToggleReaction); - const { openReminder } = useRemindLater(); + const { openReminder, activeReminderEventIds } = useRemindLater(); + const hasActiveReminder = activeReminderEventIds.has(message.id); const mentionNames = React.useMemo( () => resolveMentionNames(message.tags, profiles), [profiles, message.tags], @@ -392,6 +393,7 @@ export const MessageRow = React.memo( ? "mx-1 px-2 hover:bg-muted/50 focus-within:bg-muted/50" : "px-2", "flex items-start gap-2.5", + hasActiveReminder ? "bg-blue-500/10" : "", highlighted ? "-mx-4 rounded-none px-6 before:absolute before:-inset-y-1.5 before:inset-x-0 before:animate-[route-target-highlight-fade_2s_ease-out_forwards] before:bg-primary/10 before:content-[''] motion-reduce:before:animate-none sm:-mx-6 sm:px-8" : "", diff --git a/desktop/src/features/reminders/hooks.ts b/desktop/src/features/reminders/hooks.ts new file mode 100644 index 000000000..879015e89 --- /dev/null +++ b/desktop/src/features/reminders/hooks.ts @@ -0,0 +1,69 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + cancelReminder, + completeReminder, + createReminder, + fetchReminders, + snoozeReminder, +} from "@/features/reminders/lib/reminderService"; +import { countDue } from "@/features/reminders/lib/reminderFilters"; +import type { + Reminder, + ReminderTarget, +} from "@/features/reminders/lib/reminderTypes"; + +export const remindersQueryKey = (pubkey: string) => + ["reminders", pubkey] as const; + +/** Re-exported so the inbox badge has one import for the due count. */ +export const countDueReminders = countDue; + +/** + * The single source of truth for a user's reminders. Badge, channel overlay, + * panel, and fire-on-due detection all read this one query, so invalidating it + * (see {@link useReminderMutations}) keeps every surface consistent. + */ +export function useRemindersQuery(pubkey: string | undefined) { + return useQuery({ + enabled: Boolean(pubkey), + queryKey: remindersQueryKey(pubkey ?? ""), + queryFn: () => fetchReminders(pubkey ?? ""), + staleTime: 30_000, + }); +} + +/** + * Wraps every reminder write so the shared query is invalidated on success — + * the consistency spine the panel/badge/overlay all depend on. A mutation that + * skipped invalidation would leave those surfaces stale until the next refetch. + */ +export function useReminderMutations(pubkey: string) { + const queryClient = useQueryClient(); + const invalidate = () => + queryClient.invalidateQueries({ queryKey: remindersQueryKey(pubkey) }); + + const create = useMutation({ + mutationFn: (input: { + target: ReminderTarget; + notBefore: number; + note?: string; + }) => createReminder(input.target, input.notBefore, input.note), + onSuccess: invalidate, + }); + const complete = useMutation({ + mutationFn: (reminder: Reminder) => completeReminder(pubkey, reminder), + onSuccess: invalidate, + }); + const snooze = useMutation({ + mutationFn: (input: { reminder: Reminder; notBefore: number }) => + snoozeReminder(pubkey, input.reminder, input.notBefore), + onSuccess: invalidate, + }); + const cancel = useMutation({ + mutationFn: (reminder: Reminder) => cancelReminder(pubkey, reminder), + onSuccess: invalidate, + }); + + return { create, complete, snooze, cancel }; +} diff --git a/desktop/src/features/reminders/lib/reminderFilters.test.mjs b/desktop/src/features/reminders/lib/reminderFilters.test.mjs new file mode 100644 index 000000000..6a1770a8d --- /dev/null +++ b/desktop/src/features/reminders/lib/reminderFilters.test.mjs @@ -0,0 +1,181 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + countDue, + dueSince, + groupReminders, + isDue, +} from "./reminderFilters.ts"; + +/** + * Build a Reminder fixture. `notBefore` and `status` are the only fields the + * filters read besides `createdAt` (for done-group ordering). + */ +function reminder({ id = "r", notBefore, status = "pending", createdAt = 0 }) { + return { + id, + eventId: `${id}-evt`, + notBefore, + createdAt, + content: { status }, + }; +} + +const NOW = 1_000; + +test("isDue_pending_reminder_at_now_is_due", () => { + assert.equal(isDue(reminder({ notBefore: NOW }), NOW), true); +}); + +test("isDue_pending_reminder_in_future_is_not_due", () => { + assert.equal(isDue(reminder({ notBefore: NOW + 1 }), NOW), false); +}); + +test("isDue_done_reminder_in_past_is_not_due", () => { + assert.equal( + isDue(reminder({ notBefore: NOW - 100, status: "done" }), NOW), + false, + ); +}); + +test("isDue_reminder_without_notBefore_is_not_due", () => { + assert.equal(isDue(reminder({ notBefore: undefined }), NOW), false); +}); + +test("countDue_counts_only_due_pending_reminders", () => { + const reminders = [ + reminder({ id: "a", notBefore: NOW - 1 }), + reminder({ id: "b", notBefore: NOW }), + reminder({ id: "c", notBefore: NOW + 1 }), + reminder({ id: "d", notBefore: NOW - 1, status: "done" }), + reminder({ id: "e", notBefore: NOW - 1, status: "cancelled" }), + ]; + assert.equal(countDue(reminders, NOW), 2); +}); + +test("countDue_empty_list_returns_zero", () => { + assert.equal(countDue([], NOW), 0); +}); + +// dueSince — the watermark fire window. Strict lower bound `>`, inclusive `<=`. +test("dueSince_fires_reminder_crossing_window_since_watermark", () => { + const due = dueSince([reminder({ notBefore: 50 })], 0, 100); + assert.equal(due.length, 1); +}); + +test("dueSince_excludes_reminder_at_exactly_the_watermark", () => { + // notBefore === watermark fails the strict `>`: the seed-to-now first-launch + // case must not replay an already-due reminder as a toast. + const due = dueSince([reminder({ notBefore: 100 })], 100, 200); + assert.equal(due.length, 0); +}); + +test("dueSince_includes_reminder_at_exactly_now", () => { + const due = dueSince([reminder({ notBefore: 100 })], 0, 100); + assert.equal(due.length, 1); +}); + +test("dueSince_excludes_future_reminder_past_now", () => { + const due = dueSince([reminder({ notBefore: 150 })], 0, 100); + assert.equal(due.length, 0); +}); + +test("dueSince_excludes_non_pending_in_window", () => { + const due = dueSince( + [ + reminder({ id: "done", notBefore: 50, status: "done" }), + reminder({ id: "cancel", notBefore: 50, status: "cancelled" }), + ], + 0, + 100, + ); + assert.equal(due.length, 0); +}); + +test("dueSince_excludes_reminder_without_notBefore", () => { + const due = dueSince([reminder({ notBefore: undefined })], 0, 100); + assert.equal(due.length, 0); +}); + +test("dueSince_empty_watermark_window_returns_empty", () => { + // watermark === now: no new reminder can have crossed since last check. + const due = dueSince([reminder({ notBefore: 50 })], 100, 100); + assert.equal(due.length, 0); +}); + +// groupReminders — bucketing. It reads the real wall clock for "now", so +// fixtures are anchored to real time. "Today" = up to local end-of-day. +const realNow = Math.floor(Date.now() / 1_000); +const realEndOfTodaySecs = (() => { + const d = new Date(); + d.setHours(23, 59, 59, 999); + return Math.floor(d.getTime() / 1_000); +})(); + +test("groupReminders_buckets_overdue_today_upcoming", () => { + // A timestamp strictly between now and end-of-day, robust to running near + // midnight: the midpoint can never coincide with either boundary. + const todaySecs = Math.floor((realNow + realEndOfTodaySecs) / 2); + const groups = groupReminders([ + reminder({ id: "over", notBefore: realNow - 100 }), + reminder({ id: "today", notBefore: todaySecs }), + reminder({ id: "soon", notBefore: realEndOfTodaySecs + 86_400 }), + ]); + const labels = groups.map((g) => g.label); + assert.deepEqual(labels, ["Overdue", "Today", "Upcoming"]); +}); + +test("groupReminders_omits_empty_buckets", () => { + const groups = groupReminders([reminder({ notBefore: realNow - 100 })]); + assert.deepEqual( + groups.map((g) => g.label), + ["Overdue"], + ); +}); + +test("groupReminders_excludes_done_when_includeDone_false", () => { + const groups = groupReminders([ + reminder({ notBefore: realNow - 100, status: "done" }), + ]); + assert.equal(groups.length, 0); +}); + +test("groupReminders_appends_completed_group_when_includeDone_true", () => { + const groups = groupReminders( + [ + reminder({ id: "over", notBefore: realNow - 100 }), + reminder({ id: "d1", status: "done", createdAt: 1 }), + reminder({ id: "d2", status: "done", createdAt: 2 }), + ], + true, + ); + const completed = groups.find((g) => g.label === "Completed"); + assert.ok(completed); + // Done reminders are sorted newest-first by createdAt. + assert.deepEqual( + completed.reminders.map((r) => r.id), + ["d2", "d1"], + ); +}); + +test("groupReminders_never_surfaces_cancelled_reminders", () => { + const groups = groupReminders( + [reminder({ notBefore: realNow - 100, status: "cancelled" })], + true, + ); + assert.equal(groups.length, 0); +}); + +test("groupReminders_empty_list_returns_empty", () => { + assert.deepEqual(groupReminders([]), []); +}); + +test("groupReminders_buckets_epoch_zero_notBefore_as_overdue", () => { + // Guards on `notBefore !== undefined`, matching isDue/dueSince: 0 is kept. + const groups = groupReminders([reminder({ notBefore: 0 })]); + assert.deepEqual( + groups.map((g) => g.label), + ["Overdue"], + ); +}); diff --git a/desktop/src/features/reminders/lib/reminderFilters.ts b/desktop/src/features/reminders/lib/reminderFilters.ts new file mode 100644 index 000000000..57474f244 --- /dev/null +++ b/desktop/src/features/reminders/lib/reminderFilters.ts @@ -0,0 +1,95 @@ +import type { Reminder } from "@/features/reminders/lib/reminderTypes"; + +const nowSeconds = () => Math.floor(Date.now() / 1_000); + +/** A pending reminder whose `notBefore` has arrived (`<= now`). */ +export function isDue(reminder: Reminder, now: number): boolean { + return ( + reminder.content.status === "pending" && + reminder.notBefore !== undefined && + reminder.notBefore <= now + ); +} + +/** + * Count pending reminders that are due or overdue — the single definition + * shared by the inbox badge and the fire-on-due hook so the two surfaces can + * never disagree on what "due" means. + */ +export function countDue( + reminders: readonly Reminder[], + now: number = nowSeconds(), +): number { + return reminders.filter((r) => isDue(r, now)).length; +} + +/** + * Pending reminders that newly crossed `notBefore` since `watermark` — the + * fire-on-due window. The strict `>` lower bound is deliberate: a reminder + * already past at the seeded watermark (first launch) never fires a toast, so + * history is not replayed. The upper bound (`<= now`) excludes future ones. + */ +export function dueSince( + reminders: readonly Reminder[], + watermark: number, + now: number, +): Reminder[] { + return reminders.filter( + (r) => + r.content.status === "pending" && + r.notBefore !== undefined && + r.notBefore > watermark && + r.notBefore <= now, + ); +} + +export type ReminderGroup = { + label: string; + reminders: Reminder[]; +}; + +/** + * Bucket pending reminders into Overdue/Today/Upcoming, and — when + * `includeDone` is set — append a Completed group of done reminders. Cancelled + * reminders are never surfaced. + */ +export function groupReminders( + reminders: Reminder[], + includeDone = false, +): ReminderGroup[] { + const now = nowSeconds(); + const endOfToday = new Date(); + endOfToday.setHours(23, 59, 59, 999); + const endOfTodaySecs = Math.floor(endOfToday.getTime() / 1_000); + + const overdue: Reminder[] = []; + const today: Reminder[] = []; + const upcoming: Reminder[] = []; + const done: Reminder[] = []; + + for (const r of reminders) { + if (r.content.status === "done") { + if (includeDone) done.push(r); + continue; + } + if (r.content.status !== "pending") continue; + if (r.notBefore === undefined) continue; + if (r.notBefore <= now) { + overdue.push(r); + } else if (r.notBefore <= endOfTodaySecs) { + today.push(r); + } else { + upcoming.push(r); + } + } + + done.sort((a, b) => b.createdAt - a.createdAt); + + const groups: ReminderGroup[] = []; + if (overdue.length > 0) groups.push({ label: "Overdue", reminders: overdue }); + if (today.length > 0) groups.push({ label: "Today", reminders: today }); + if (upcoming.length > 0) + groups.push({ label: "Upcoming", reminders: upcoming }); + if (done.length > 0) groups.push({ label: "Completed", reminders: done }); + return groups; +} diff --git a/desktop/src/features/reminders/lib/timePresets.test.mjs b/desktop/src/features/reminders/lib/timePresets.test.mjs new file mode 100644 index 000000000..b11b7ada7 --- /dev/null +++ b/desktop/src/features/reminders/lib/timePresets.test.mjs @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + TIME_PRESETS, + parseCustomDateTime, + todayDateString, +} from "./timePresets.ts"; + +const nowSeconds = () => Math.floor(Date.now() / 1_000); + +test("TIME_PRESETS_every_preset_returns_strictly_future_timestamp", () => { + const now = nowSeconds(); + for (const preset of TIME_PRESETS) { + assert.ok( + preset.getTimestamp() > now, + `${preset.label} must be strictly in the future`, + ); + } +}); + +test("TIME_PRESETS_relative_offsets_match_their_labels", () => { + const before = nowSeconds(); + const byLabel = Object.fromEntries( + TIME_PRESETS.map((p) => [p.label, p.getTimestamp()]), + ); + // Allow a 2s window for clock drift across the getTimestamp calls. + assert.ok(Math.abs(byLabel["In 30 minutes"] - (before + 30 * 60)) <= 2); + assert.ok(Math.abs(byLabel["In 1 hour"] - (before + 60 * 60)) <= 2); + assert.ok(Math.abs(byLabel["In 3 hours"] - (before + 3 * 60 * 60)) <= 2); +}); + +test("TIME_PRESETS_9am_presets_land_on_a_9am_boundary", () => { + for (const label of ["Tomorrow at 9am", "Next Monday at 9am"]) { + const preset = TIME_PRESETS.find((p) => p.label === label); + const d = new Date(preset.getTimestamp() * 1_000); + assert.equal(d.getHours(), 9); + assert.equal(d.getMinutes(), 0); + } +}); + +test("TIME_PRESETS_next_monday_lands_on_a_monday", () => { + const preset = TIME_PRESETS.find((p) => p.label === "Next Monday at 9am"); + const d = new Date(preset.getTimestamp() * 1_000); + assert.equal(d.getDay(), 1); // Monday +}); + +test("parseCustomDateTime_future_datetime_returns_timestamp", () => { + const future = new Date(Date.now() + 24 * 60 * 60 * 1_000); + const date = `${future.getFullYear()}-${String(future.getMonth() + 1).padStart(2, "0")}-${String(future.getDate()).padStart(2, "0")}`; + const result = parseCustomDateTime(date, "14:30"); + assert.ok(result !== null); + assert.ok(result > nowSeconds()); +}); + +test("parseCustomDateTime_past_datetime_returns_null", () => { + // One year ago — unambiguously past regardless of run time. + const past = new Date(Date.now() - 365 * 24 * 60 * 60 * 1_000); + const date = `${past.getFullYear()}-${String(past.getMonth() + 1).padStart(2, "0")}-${String(past.getDate()).padStart(2, "0")}`; + assert.equal(parseCustomDateTime(date, "09:00"), null); +}); + +test("parseCustomDateTime_empty_inputs_return_null", () => { + assert.equal(parseCustomDateTime("", "09:00"), null); + assert.equal(parseCustomDateTime("2099-01-01", ""), null); + assert.equal(parseCustomDateTime("", ""), null); +}); + +test("parseCustomDateTime_malformed_inputs_return_null", () => { + assert.equal(parseCustomDateTime("not-a-date", "09:00"), null); + assert.equal(parseCustomDateTime("2099-01-01", "99:99"), null); +}); + +test("todayDateString_returns_today_in_YYYY_MM_DD_local", () => { + const now = new Date(); + const expected = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; + assert.equal(todayDateString(), expected); +}); diff --git a/desktop/src/features/reminders/lib/timePresets.ts b/desktop/src/features/reminders/lib/timePresets.ts new file mode 100644 index 000000000..90400e2d0 --- /dev/null +++ b/desktop/src/features/reminders/lib/timePresets.ts @@ -0,0 +1,66 @@ +/** + * Shared reminder time presets — the single source of truth for both the + * create dialog and the snooze dropdown. Each preset returns a Unix timestamp + * (seconds) strictly in the future. + */ +export type TimePreset = { + label: string; + getTimestamp: () => number; +}; + +function nowSeconds(): number { + return Math.floor(Date.now() / 1_000); +} + +/** + * Next occurrence of `dayOffset` days from now at 9am local time. If that + * instant is already past (e.g. it is after 9am and offset is 0), roll to the + * following day so the result is always in the future. + */ +function nextDayAt9am(dayOffset: number): number { + const now = new Date(); + const target = new Date(now); + target.setDate(target.getDate() + dayOffset); + target.setHours(9, 0, 0, 0); + if (target.getTime() <= now.getTime()) { + target.setDate(target.getDate() + 1); + } + return Math.floor(target.getTime() / 1_000); +} + +export const TIME_PRESETS: TimePreset[] = [ + { label: "In 30 minutes", getTimestamp: () => nowSeconds() + 30 * 60 }, + { label: "In 1 hour", getTimestamp: () => nowSeconds() + 60 * 60 }, + { label: "In 3 hours", getTimestamp: () => nowSeconds() + 3 * 60 * 60 }, + { label: "Tomorrow at 9am", getTimestamp: () => nextDayAt9am(1) }, + { + label: "Next Monday at 9am", + getTimestamp: () => { + const daysUntilMonday = (8 - new Date().getDay()) % 7 || 7; + return nextDayAt9am(daysUntilMonday); + }, + }, +]; + +/** Today as `YYYY-MM-DD` in local time, for the custom date input `min`. */ +export function todayDateString(): string { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +/** + * Parse a `YYYY-MM-DD` + `HH:MM` pair into a future Unix timestamp (seconds), + * or null if the inputs are malformed or not strictly in the future. The shared + * guard for both create and snooze custom surfaces: the native time input has + * no `min`, so a past time would otherwise fire immediately. + */ +export function parseCustomDateTime(date: string, time: string): number | null { + if (!date || !time) return null; + const timestamp = Math.floor(new Date(`${date}T${time}`).getTime() / 1_000); + if (Number.isNaN(timestamp)) return null; + if (timestamp <= nowSeconds()) return null; + return timestamp; +} diff --git a/desktop/src/features/reminders/ui/RemindMeLaterDialog.tsx b/desktop/src/features/reminders/ui/RemindMeLaterDialog.tsx index f17ea2793..83e4cc7c1 100644 --- a/desktop/src/features/reminders/ui/RemindMeLaterDialog.tsx +++ b/desktop/src/features/reminders/ui/RemindMeLaterDialog.tsx @@ -2,10 +2,15 @@ import { CalendarClock, Clock } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; -import { createReminder } from "@/features/reminders/lib/reminderService"; +import { useReminderMutations } from "@/features/reminders/hooks"; +import { + parseCustomDateTime, + TIME_PRESETS, + todayDateString, +} from "@/features/reminders/lib/timePresets"; import type { ReminderTarget } from "@/features/reminders/lib/reminderTypes"; +import { useIdentityQuery } from "@/shared/api/hooks"; import { Button } from "@/shared/ui/button"; -import { Input } from "@/shared/ui/input"; import { Dialog, DialogContent, @@ -14,59 +19,9 @@ import { DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; import { Textarea } from "@/shared/ui/textarea"; -type TimePreset = { - label: string; - getTimestamp: () => number; -}; - -function getNextWeekday9am(dayOffset: number): number { - const now = new Date(); - const target = new Date(now); - target.setDate(target.getDate() + dayOffset); - target.setHours(9, 0, 0, 0); - if (target.getTime() <= now.getTime()) { - target.setDate(target.getDate() + 1); - } - return Math.floor(target.getTime() / 1_000); -} - -const TIME_PRESETS: TimePreset[] = [ - { - label: "In 30 minutes", - getTimestamp: () => Math.floor(Date.now() / 1_000) + 30 * 60, - }, - { - label: "In 1 hour", - getTimestamp: () => Math.floor(Date.now() / 1_000) + 60 * 60, - }, - { - label: "In 3 hours", - getTimestamp: () => Math.floor(Date.now() / 1_000) + 3 * 60 * 60, - }, - { - label: "Tomorrow at 9am", - getTimestamp: () => getNextWeekday9am(1), - }, - { - label: "Next Monday at 9am", - getTimestamp: () => { - const now = new Date(); - const daysUntilMonday = (8 - now.getDay()) % 7 || 7; - return getNextWeekday9am(daysUntilMonday); - }, - }, -]; - -function todayDateString(): string { - const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, "0"); - const day = String(now.getDate()).padStart(2, "0"); - return `${year}-${month}-${day}`; -} - export function RemindMeLaterDialog({ open, onOpenChange, @@ -76,48 +31,26 @@ export function RemindMeLaterDialog({ onOpenChange: (open: boolean) => void; target: ReminderTarget | null; }) { + const pubkey = useIdentityQuery().data?.pubkey ?? ""; + const { create } = useReminderMutations(pubkey); const [note, setNote] = React.useState(""); - const [isSubmitting, setIsSubmitting] = React.useState(false); const [customDate, setCustomDate] = React.useState(todayDateString); const [customTime, setCustomTime] = React.useState("09:00"); + const customTimestamp = parseCustomDateTime(customDate, customTime); - const handleSelect = async (preset: TimePreset) => { - if (!target || isSubmitting) return; - setIsSubmitting(true); - try { - await createReminder(target, preset.getTimestamp(), note || undefined); - toast.success("Reminder set"); - onOpenChange(false); - setNote(""); - } catch (error) { - toast.error("Failed to create reminder"); - console.error("[RemindMeLaterDialog] create failed:", error); - } finally { - setIsSubmitting(false); - } - }; - - const handleCustomSubmit = async () => { - if (!target || isSubmitting || !customDate || !customTime) return; - setIsSubmitting(true); - try { - const timestamp = Math.floor( - new Date(`${customDate}T${customTime}`).getTime() / 1_000, - ); - if (Number.isNaN(timestamp)) { - toast.error("Invalid date or time"); - return; - } - await createReminder(target, timestamp, note || undefined); - toast.success("Reminder set"); - onOpenChange(false); - setNote(""); - } catch (error) { - toast.error("Failed to create reminder"); - console.error("[RemindMeLaterDialog] custom create failed:", error); - } finally { - setIsSubmitting(false); - } + const submit = (notBefore: number) => { + if (!target || create.isPending) return; + create.mutate( + { target, notBefore, note: note || undefined }, + { + onSuccess: () => { + toast.success("Reminder set"); + onOpenChange(false); + setNote(""); + }, + onError: () => toast.error("Failed to create reminder"), + }, + ); }; return ( @@ -139,8 +72,8 @@ export function RemindMeLaterDialog({ key={preset.label} variant="outline" className="justify-start" - disabled={isSubmitting} - onClick={() => void handleSelect(preset)} + disabled={create.isPending} + onClick={() => submit(preset.getTimestamp())} > {preset.label} @@ -148,32 +81,35 @@ export function RemindMeLaterDialog({
-

+

Custom date & time

setCustomDate(e.target.value)} type="date" value={customDate} - onChange={(e) => setCustomDate(e.target.value)} - min={todayDateString()} - className="flex-1" - aria-label="Reminder date" /> setCustomTime(e.target.value)} type="time" value={customTime} - onChange={(e) => setCustomTime(e.target.value)} - className="w-[120px]" - aria-label="Reminder time" />
@@ -200,7 +136,7 @@ export function RemindMeLaterDialog({ diff --git a/desktop/src/features/reminders/ui/RemindMeLaterProvider.tsx b/desktop/src/features/reminders/ui/RemindMeLaterProvider.tsx index 4aa4483f4..61c884ef1 100644 --- a/desktop/src/features/reminders/ui/RemindMeLaterProvider.tsx +++ b/desktop/src/features/reminders/ui/RemindMeLaterProvider.tsx @@ -1,14 +1,18 @@ import * as React from "react"; +import { useRemindersQuery } from "@/features/reminders/hooks"; import type { ReminderTarget } from "@/features/reminders/lib/reminderTypes"; import { RemindMeLaterDialog } from "./RemindMeLaterDialog"; type RemindMeLaterContextValue = { openReminder: (target: ReminderTarget) => void; + /** Event IDs of messages with a pending reminder, for channel tinting. */ + activeReminderEventIds: ReadonlySet; }; const RemindMeLaterContext = React.createContext({ openReminder: () => {}, + activeReminderEventIds: new Set(), }); export function useRemindLater() { @@ -16,8 +20,10 @@ export function useRemindLater() { } export function RemindMeLaterProvider({ + pubkey, children, }: { + pubkey?: string; children: React.ReactNode; }) { const [open, setOpen] = React.useState(false); @@ -28,7 +34,25 @@ export function RemindMeLaterProvider({ setOpen(true); }, []); - const contextValue = React.useMemo(() => ({ openReminder }), [openReminder]); + const remindersQuery = useRemindersQuery(pubkey); + const reminders = remindersQuery.data; + const activeReminderEventIds = React.useMemo(() => { + const ids = new Set(); + for (const reminder of reminders ?? []) { + if ( + reminder.content.status === "pending" && + reminder.content.target?.eventId + ) { + ids.add(reminder.content.target.eventId); + } + } + return ids; + }, [reminders]); + + const contextValue = React.useMemo( + () => ({ openReminder, activeReminderEventIds }), + [openReminder, activeReminderEventIds], + ); return ( diff --git a/desktop/src/features/reminders/ui/RemindersPanel.tsx b/desktop/src/features/reminders/ui/RemindersPanel.tsx index 57aab4171..aff838cc9 100644 --- a/desktop/src/features/reminders/ui/RemindersPanel.tsx +++ b/desktop/src/features/reminders/ui/RemindersPanel.tsx @@ -1,14 +1,14 @@ -import { Bell, Check, Clock, RotateCcw, X } from "lucide-react"; +import { Bell, Check, Clock, X } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; import { - cancelReminder, - completeReminder, - fetchReminders, - snoozeReminder, -} from "@/features/reminders/lib/reminderService"; + useRemindersQuery, + useReminderMutations, +} from "@/features/reminders/hooks"; +import { groupReminders } from "@/features/reminders/lib/reminderFilters"; import type { Reminder } from "@/features/reminders/lib/reminderTypes"; +import { SnoozeMenu } from "@/features/reminders/ui/SnoozeMenu"; import { Button } from "@/shared/ui/button"; function formatRelativeTime(timestamp: number): string { @@ -29,205 +29,118 @@ function formatRelativeTime(timestamp: number): string { return `in ${Math.floor(diff / 86400)}d`; } -type ReminderGroup = { - label: string; - reminders: Reminder[]; -}; - -function groupReminders(reminders: Reminder[]): ReminderGroup[] { - const now = Math.floor(Date.now() / 1_000); - const endOfToday = new Date(); - endOfToday.setHours(23, 59, 59, 999); - const endOfTodaySecs = Math.floor(endOfToday.getTime() / 1_000); - - const overdue: Reminder[] = []; - const today: Reminder[] = []; - const upcoming: Reminder[] = []; - - for (const r of reminders) { - if (r.content.status !== "pending") continue; - if (!r.notBefore) continue; - if (r.notBefore <= now) { - overdue.push(r); - } else if (r.notBefore <= endOfTodaySecs) { - today.push(r); - } else { - upcoming.push(r); - } - } - - const groups: ReminderGroup[] = []; - if (overdue.length > 0) groups.push({ label: "Overdue", reminders: overdue }); - if (today.length > 0) groups.push({ label: "Today", reminders: today }); - if (upcoming.length > 0) - groups.push({ label: "Upcoming", reminders: upcoming }); - return groups; -} - function ReminderRow({ reminder, pubkey, - onUpdate, }: { reminder: Reminder; pubkey: string; - onUpdate: () => void; }) { - const [isActing, setIsActing] = React.useState(false); - - const handleComplete = async () => { - setIsActing(true); - try { - await completeReminder(pubkey, reminder); - toast.success("Reminder completed"); - onUpdate(); - } catch { - toast.error("Failed to complete reminder"); - } finally { - setIsActing(false); - } + const { complete, snooze, cancel } = useReminderMutations(pubkey); + const isDone = reminder.content.status === "done"; + const isActing = complete.isPending || snooze.isPending || cancel.isPending; + + const handleComplete = () => { + complete.mutate(reminder, { + onSuccess: () => toast.success("Reminder completed"), + onError: () => toast.error("Failed to complete reminder"), + }); }; - const handleSnooze = async () => { - setIsActing(true); - try { - const newNotBefore = Math.floor(Date.now() / 1_000) + 3600; - await snoozeReminder(pubkey, reminder, newNotBefore); - toast.success("Snoozed for 1 hour"); - onUpdate(); - } catch { - toast.error("Failed to snooze reminder"); - } finally { - setIsActing(false); - } + const handleSnooze = (notBefore: number) => { + snooze.mutate( + { reminder, notBefore }, + { + onSuccess: () => toast.success("Reminder snoozed"), + onError: () => toast.error("Failed to snooze reminder"), + }, + ); }; - const handleCancel = async () => { - setIsActing(true); - try { - await cancelReminder(pubkey, reminder); - toast.success("Reminder cancelled"); - onUpdate(); - } catch { - toast.error("Failed to cancel reminder"); - } finally { - setIsActing(false); - } + const handleCancel = () => { + cancel.mutate(reminder, { + onSuccess: () => toast.success("Reminder cancelled"), + onError: () => toast.error("Failed to cancel reminder"), + }); }; - const isOverdue = reminder.notBefore - ? reminder.notBefore <= Math.floor(Date.now() / 1_000) - : false; + const isOverdue = + !isDone && reminder.notBefore + ? reminder.notBefore <= Math.floor(Date.now() / 1_000) + : false; return (
-
-

+

+

{reminder.content.target?.preview || reminder.content.note || "Reminder"}

{reminder.content.target && reminder.content.note ? ( -

+

{reminder.content.note}

) : null} {reminder.notBefore ? (

- + {formatRelativeTime(reminder.notBefore)}

) : null}
-
- - - -
+ {isDone ? null : ( +
+ + + +
+ )}
); } -export function RemindersPanel({ pubkey }: { pubkey: string }) { - const [reminders, setReminders] = React.useState([]); - const [isLoading, setIsLoading] = React.useState(true); - - const loadReminders = React.useCallback(async () => { - try { - const fetched = await fetchReminders(pubkey); - setReminders(fetched); - } catch (error) { - console.error("[RemindersPanel] fetch failed:", error); - } finally { - setIsLoading(false); - } - }, [pubkey]); - - React.useEffect(() => { - void loadReminders(); - }, [loadReminders]); - - // Due-detection: check every 60s and show toast for newly due reminders. - const lastDueCheckRef = React.useRef(Math.floor(Date.now() / 1_000)); - React.useEffect(() => { - const interval = window.setInterval(() => { - const now = Math.floor(Date.now() / 1_000); - const lastCheck = lastDueCheckRef.current; - lastDueCheckRef.current = now; - - for (const r of reminders) { - if (r.content.status !== "pending") continue; - if (!r.notBefore) continue; - if (r.notBefore > lastCheck && r.notBefore <= now) { - toast("Reminder due", { - description: - r.content.target?.preview || - r.content.note || - "A reminder is waiting", - icon: , - }); - } - } - }, 60_000); - - return () => window.clearInterval(interval); - }, [reminders]); - - const groups = groupReminders(reminders); - const pendingCount = reminders.filter( - (r) => r.content.status === "pending", - ).length; +/** + * Renders a user's reminders as grouped rows. `includeDone` adds a Completed + * group (used by the inbox Reminders view); omit it for pending-only surfaces. + */ +export function RemindersPanel({ + pubkey, + includeDone = false, +}: { + pubkey: string; + includeDone?: boolean; +}) { + const remindersQuery = useRemindersQuery(pubkey); + const reminders = remindersQuery.data; + const groups = React.useMemo( + () => groupReminders(reminders ?? [], includeDone), + [reminders, includeDone], + ); - if (isLoading) { + if (remindersQuery.isLoading) { return (

Loading reminders...

@@ -235,11 +148,11 @@ export function RemindersPanel({ pubkey }: { pubkey: string }) { ); } - if (pendingCount === 0) { + if (groups.length === 0) { return (
-

No pending reminders

+

No reminders

Use "Remind me later" on any message to create one.

@@ -248,33 +161,17 @@ export function RemindersPanel({ pubkey }: { pubkey: string }) { } return ( -
-
-

- - Reminders - - ({pendingCount}) - -

-
-
- {groups.map((group) => ( -
-

- {group.label} -

- {group.reminders.map((r) => ( - void loadReminders()} - /> - ))} -
- ))} -
+
+ {groups.map((group) => ( +
+

+ {group.label} +

+ {group.reminders.map((r) => ( + + ))} +
+ ))}
); } diff --git a/desktop/src/features/reminders/ui/RemindersScreen.tsx b/desktop/src/features/reminders/ui/RemindersScreen.tsx deleted file mode 100644 index 69d269542..000000000 --- a/desktop/src/features/reminders/ui/RemindersScreen.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { useIdentityQuery } from "@/shared/api/hooks"; -import { RemindersPanel } from "./RemindersPanel"; - -export function RemindersScreen() { - const identityQuery = useIdentityQuery(); - - if (!identityQuery.data?.pubkey) { - return null; - } - - return ; -} diff --git a/desktop/src/features/reminders/ui/SnoozeMenu.tsx b/desktop/src/features/reminders/ui/SnoozeMenu.tsx new file mode 100644 index 000000000..f6b4b5799 --- /dev/null +++ b/desktop/src/features/reminders/ui/SnoozeMenu.tsx @@ -0,0 +1,111 @@ +import { Clock } from "lucide-react"; +import * as React from "react"; + +import { + parseCustomDateTime, + TIME_PRESETS, + todayDateString, +} from "@/features/reminders/lib/timePresets"; +import { Button } from "@/shared/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; +import { Input } from "@/shared/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; + +/** + * Clock-icon dropdown of snooze presets plus a "Custom…" popover with a native + * date/time picker. Calls `onSnooze` with a future Unix timestamp (seconds). + * The custom surface uses the shared {@link parseCustomDateTime} guard so a + * past time is rejected rather than firing immediately. + */ +export function SnoozeMenu({ + disabled, + onSnooze, +}: { + disabled?: boolean; + onSnooze: (notBefore: number) => void; +}) { + const [customOpen, setCustomOpen] = React.useState(false); + const [customDate, setCustomDate] = React.useState(todayDateString); + const [customTime, setCustomTime] = React.useState("09:00"); + + const customTimestamp = parseCustomDateTime(customDate, customTime); + + return ( + + + + + + {TIME_PRESETS.map((preset) => ( + onSnooze(preset.getTimestamp())} + > + {preset.label} + + ))} + + + + { + // Keep the dropdown logic from closing the popover trigger. + event.preventDefault(); + setCustomOpen(true); + }} + > + Custom… + + + +

Snooze until

+
+ setCustomDate(event.target.value)} + type="date" + value={customDate} + /> + setCustomTime(event.target.value)} + type="time" + value={customTime} + /> +
+ +
+
+
+
+ ); +} diff --git a/desktop/src/features/reminders/useReminderNotifications.ts b/desktop/src/features/reminders/useReminderNotifications.ts new file mode 100644 index 000000000..c5ba04baf --- /dev/null +++ b/desktop/src/features/reminders/useReminderNotifications.ts @@ -0,0 +1,118 @@ +import * as React from "react"; + +import { useRemindersQuery } from "@/features/reminders/hooks"; +import { dueSince } from "@/features/reminders/lib/reminderFilters"; +import type { Reminder } from "@/features/reminders/lib/reminderTypes"; +import { + requestDockBounce, + sendDesktopNotification, +} from "@/features/notifications/lib/desktop"; +import type { NotificationSettings } from "@/features/notifications/hooks"; +import { + playNotificationSound, + resolveSlotSound, +} from "@/features/notifications/lib/sound"; + +const WATERMARK_STORAGE_PREFIX = "buzz:lastReminderCheck:"; +const POLL_INTERVAL_MS = 30_000; + +function watermarkStorageKey(pubkey: string): string { + return `${WATERMARK_STORAGE_PREFIX}${pubkey.trim().toLowerCase()}`; +} + +/** + * Read the persisted watermark, seeding it to `now` on first-ever launch. + * Seeding to `now` (not 0) is deliberate: a 0 seed would replay the user's + * entire reminder history as toasts. A reminder already due at first launch + * fails the strict `notBefore > watermark` test and surfaces only in the + * panel/badge, never as a toast — see the plan's behavioral note. + */ +function readWatermark(pubkey: string): number { + const key = watermarkStorageKey(pubkey); + const stored = window.localStorage.getItem(key); + if (stored !== null) { + const parsed = Number(stored); + if (Number.isFinite(parsed)) return parsed; + } + const now = Math.floor(Date.now() / 1_000); + window.localStorage.setItem(key, String(now)); + return now; +} + +/** + * App-level fire-on-due detection. On launch and every {@link POLL_INTERVAL_MS} + * it fires reminders newly crossing their `not_before` since the persisted + * watermark, coalescing multiple due reminders into one toast, then advances + * the watermark. The toast respects `desktopEnabled` + the `needs_action` + * alert slot. This hook is the sole detector — mount it once at app level. + */ +export function useReminderNotifications( + pubkey: string | undefined, + settings: NotificationSettings, +): void { + const reminders = useRemindersQuery(pubkey).data; + const remindersRef = React.useRef([]); + remindersRef.current = reminders ?? []; + const settingsRef = React.useRef(settings); + settingsRef.current = settings; + + // Track whether the query has resolved at least once. On mount, + // useRemindersQuery is still loading (data === undefined), so + // remindersRef.current is []. Without this guard, check() would advance + // the watermark past any reminders that came due while the app was closed. + const queryResolvedRef = React.useRef(false); + if (reminders !== undefined) queryResolvedRef.current = true; + + const fire = React.useEffectEvent((due: Reminder[]) => { + const current = settingsRef.current; + if ( + !current.desktopEnabled || + !current.slotAlertsEnabled.needs_action || + due.length === 0 + ) { + return; + } + + const body = + due.length === 1 + ? (due[0].content.target?.preview ?? + due[0].content.note ?? + "A reminder is waiting") + : `${due.length} reminders are due`; + + void sendDesktopNotification({ + title: "Reminder due", + body, + }).then((didSend) => { + if (!didSend) return; + playNotificationSound(resolveSlotSound(current, "needs_action")); + void requestDockBounce(); + }); + }); + + React.useEffect(() => { + if (!pubkey) return; + + const check = () => { + // Defer until the query has resolved at least once — an empty array from + // an unresolved query must not advance the watermark past reminders that + // came due while the app was closed (the "missed-while-asleep" window). + if (remindersRef.current.length === 0 && !queryResolvedRef.current) + return; + + const watermark = readWatermark(pubkey); + const now = Math.floor(Date.now() / 1_000); + const due = dueSince(remindersRef.current, watermark, now); + fire(due); + // Advance unconditionally, even when fire() suppressed the toast + // (notifications off or needs_action slot muted). Re-enabling later must + // not backlog-replay reminders that came due while muted — same no-replay + // rationale as seed-to-now. Suppressed reminders still show in panel/badge. + window.localStorage.setItem(watermarkStorageKey(pubkey), String(now)); + }; + + check(); + const interval = window.setInterval(check, POLL_INTERVAL_MS); + return () => window.clearInterval(interval); + }, [pubkey]); +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index d0c51d310..9262d27e3 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -3,7 +3,6 @@ import { Activity, ArrowDown, ArrowUp, - Bell, Bot, FolderGit2, Home, @@ -101,7 +100,6 @@ type AppSidebarProps = { | "home" | "channel" | "agents" - | "reminders" | "workflows" | "pulse" | "projects"; @@ -142,7 +140,6 @@ type AppSidebarProps = { onSelectAgents: () => void; onSelectProjects: () => void; onSelectPulse: () => void; - onSelectReminders: () => void; onSelectWorkflows: () => void; onSelectHome: () => void; onSelectChannel: (channelId: string) => void; @@ -204,7 +201,6 @@ export function AppSidebar({ onSelectAgents, onSelectProjects, onSelectPulse, - onSelectReminders, onSelectWorkflows, onSelectHome, onSelectChannel, @@ -539,18 +535,6 @@ export function AppSidebar({ ) : null} - - - - Reminders - - unknown; __BUZZ_E2E_SEED_MOCK_REMINDERS__?: (reminders: RelayEvent[]) => void; + __BUZZ_E2E_QUERY_CLIENT__?: { + invalidateQueries: (filters: { queryKey: readonly unknown[] }) => unknown; + }; } } diff --git a/desktop/tests/e2e/reminders-screenshots.spec.ts b/desktop/tests/e2e/reminders-screenshots.spec.ts index a2ace1abc..90fd224f2 100644 --- a/desktop/tests/e2e/reminders-screenshots.spec.ts +++ b/desktop/tests/e2e/reminders-screenshots.spec.ts @@ -6,6 +6,39 @@ import { installMockBridge } from "../helpers/bridge"; const SHOTS = "test-results/reminders"; const MOCK_PUBKEY = "deadbeef".repeat(8); +// The inbox filter dropdown lives in the home pane, not the chat view. Land on +// home and wait for the inbox before reaching for the filter trigger. +async function gotoInboxHome(page: import("@playwright/test").Page) { + await page.goto("/"); + await expect(page.getByTestId("home-inbox")).toBeVisible(); +} + +// Reminders is reached by opening the inbox filter dropdown and selecting the +// "Reminders" option — there is no standalone nav entry or view-mode slider. +async function openRemindersFilter(page: import("@playwright/test").Page) { + await page.getByTestId("inbox-filter-trigger").click(); + await page.getByRole("menuitemradio", { name: "Reminders" }).click(); +} + +// The reminders query mounts (for the badge) before tests seed events, so a +// bare seed lands behind its cached empty result. Invalidate after seeding to +// force the refetch that picks up the mock events. +async function seedReminders( + page: import("@playwright/test").Page, + events: unknown[], +) { + await page.evaluate((seeded) => { + window.__BUZZ_E2E_SEED_MOCK_REMINDERS__?.( + seeded as Parameters< + NonNullable + >[0], + ); + window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["reminders"], + }); + }, events); +} + function mockReminderEvent(opts: { id: string; dTag: string; @@ -32,18 +65,21 @@ test.describe("reminders screenshots", () => { await installMockBridge(page); }); - test("01 — sidebar shows Reminders nav item", async ({ page }) => { - await page.goto("/"); - await page.getByTestId("channel-general").click(); - await expect(page.getByTestId("chat-title")).toHaveText("general"); + test("01 — inbox filter dropdown shows Reminders option", async ({ + page, + }) => { + await gotoInboxHome(page); - const remindersNav = page.getByTestId("open-reminders-view"); - await expect(remindersNav).toBeVisible(); + await page.getByTestId("inbox-filter-trigger").click(); + const remindersOption = page.getByRole("menuitemradio", { + name: "Reminders", + }); + await expect(remindersOption).toBeVisible(); await waitForAnimations(page); await page.screenshot({ - path: `${SHOTS}/01-sidebar-reminders-nav.png`, - clip: { x: 0, y: 0, width: 256, height: 720 }, + path: `${SHOTS}/01-inbox-filter-reminders-option.png`, + clip: { x: 0, y: 0, width: 900, height: 720 }, }); }); @@ -106,12 +142,10 @@ test.describe("reminders screenshots", () => { }); test("04 — Reminders panel empty state", async ({ page }) => { - await page.goto("/"); - await page.getByTestId("channel-general").click(); - await expect(page.getByTestId("chat-title")).toHaveText("general"); + await gotoInboxHome(page); - await page.getByTestId("open-reminders-view").click(); - await expect(page.getByText("No pending reminders")).toBeVisible(); + await openRemindersFilter(page); + await expect(page.getByText("No reminders")).toBeVisible(); await waitForAnimations(page); await page.screenshot({ @@ -123,9 +157,7 @@ test.describe("reminders screenshots", () => { test("05 — Reminders panel with active pending reminder", async ({ page, }) => { - await page.goto("/"); - await page.getByTestId("channel-general").click(); - await expect(page.getByTestId("chat-title")).toHaveText("general"); + await gotoInboxHome(page); // Seed a pending reminder due in the future const futureTimestamp = Math.floor(Date.now() / 1000) + 3600; @@ -140,21 +172,16 @@ test.describe("reminders screenshots", () => { status: "pending", }); - await page.evaluate( - ({ event }) => { - window.__BUZZ_E2E_SEED_MOCK_REMINDERS__?.([event]); - }, - { - event: mockReminderEvent({ - id: "reminder-active-01", - dTag: "rem-active-01", - content: reminderContent, - notBefore: futureTimestamp, - }), - }, - ); + await seedReminders(page, [ + mockReminderEvent({ + id: "reminder-active-01", + dTag: "rem-active-01", + content: reminderContent, + notBefore: futureTimestamp, + }), + ]); - await page.getByTestId("open-reminders-view").click(); + await openRemindersFilter(page); await expect(page.getByText("Follow up on this message")).toBeVisible(); await waitForAnimations(page); @@ -165,9 +192,7 @@ test.describe("reminders screenshots", () => { }); test("06 — Reminders panel with fired/overdue reminder", async ({ page }) => { - await page.goto("/"); - await page.getByTestId("channel-general").click(); - await expect(page.getByTestId("chat-title")).toHaveText("general"); + await gotoInboxHome(page); // Seed a reminder that has already fired (notBefore in the past) const pastTimestamp = Math.floor(Date.now() / 1000) - 7200; @@ -195,29 +220,22 @@ test.describe("reminders screenshots", () => { status: "pending", }); - await page.evaluate( - ({ events }) => { - window.__BUZZ_E2E_SEED_MOCK_REMINDERS__?.(events); - }, - { - events: [ - mockReminderEvent({ - id: "reminder-overdue-01", - dTag: "rem-overdue-01", - content: overdueContent, - notBefore: pastTimestamp, - }), - mockReminderEvent({ - id: "reminder-upcoming-01", - dTag: "rem-upcoming-01", - content: activeContent, - notBefore: futureTimestamp, - }), - ], - }, - ); - - await page.getByTestId("open-reminders-view").click(); + await seedReminders(page, [ + mockReminderEvent({ + id: "reminder-overdue-01", + dTag: "rem-overdue-01", + content: overdueContent, + notBefore: pastTimestamp, + }), + mockReminderEvent({ + id: "reminder-upcoming-01", + dTag: "rem-upcoming-01", + content: activeContent, + notBefore: futureTimestamp, + }), + ]); + + await openRemindersFilter(page); await expect(page.getByText("Reply to Alice")).toBeVisible(); await expect(page.getByRole("heading", { name: "Overdue" })).toBeVisible(); await waitForAnimations(page); diff --git a/desktop/tests/helpers/animations.ts b/desktop/tests/helpers/animations.ts index 2572ae113..72985a69f 100644 --- a/desktop/tests/helpers/animations.ts +++ b/desktop/tests/helpers/animations.ts @@ -1,15 +1,31 @@ import type { Page } from "@playwright/test"; /** - * Wait for all in-flight CSS/Web animations on the page to finish. + * Wait for finishing CSS/Web animations on the page to complete, bounded by a + * short timeout. * * Radix UI components animate in via CSS transitions. Playwright's * `toBeVisible()` resolves mid-animation, producing greyed-out or * partially-rendered screenshots. Call this before any `page.screenshot()` * or `locator.screenshot()` to guarantee a fully-rendered frame. + * + * Looping animations (spinners, pulsing presence dots) never settle, and + * animations can also start or restart between sampling and awaiting their + * `.finished` promise — so an unbounded `Promise.all` can hang until + * Playwright aborts the `evaluate`. Race the settle against a short ceiling: + * once the in-flight transitions have had time to land, take the frame. */ -export async function waitForAnimations(page: Page): Promise { - await page.evaluate(() => - Promise.all(document.getAnimations().map((a) => a.finished)), - ); +export async function waitForAnimations( + page: Page, + timeoutMs = 1000, +): Promise { + await page.evaluate((ceiling) => { + const settled = Promise.all( + document.getAnimations().map((a) => a.finished.catch(() => undefined)), + ); + const ceilingHit = new Promise((resolve) => + setTimeout(resolve, ceiling), + ); + return Promise.race([settled.then(() => undefined), ceilingHit]); + }, timeoutMs); }