From a802411cddfa6d2855b3590ca1bedd1508dc54de Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:31:58 +0100 Subject: [PATCH] feat: fire calendar reminders from the local store, with native snooze and join buttons and one summary of what was missed --- .../calendar/missed-reminders-dialog.tsx | 70 ++++ components/desktop/desktop-os-host.tsx | 19 - .../providers/calendar-alert-provider.tsx | 4 + .../ui/__tests__/toast-extra-actions.test.tsx | 33 ++ components/ui/toast.tsx | 3 + .../use-calendar-alerts-hooks.test.tsx | 48 +++ hooks/__tests__/use-store-reminders.test.tsx | 380 ++++++++++++++++++ hooks/use-calendar-alerts.ts | 136 ++++--- hooks/use-store-reminders.ts | 278 +++++++++++++ .../__tests__/claims.test.ts | 98 +++++ .../__tests__/runner.test.ts | 268 ++++++++++++ lib/calendar-reminders/channel.ts | 32 ++ lib/calendar-reminders/claims.ts | 70 ++++ lib/calendar-reminders/runner.ts | 156 +++++++ lib/calendar-reminders/state.ts | 55 +++ lib/calendar-reminders/system-notification.ts | 70 ++++ .../__tests__/reminder-notifications.test.ts | 239 +++++++++++ lib/desktop/os-commands.ts | 2 + lib/desktop/reminder-notifications.ts | 218 ++++++++++ lib/desktop/types.ts | 2 + lib/offline/offline-mode.ts | 2 + lib/offline/store-events.ts | 18 + lib/sign-out-cleanup.ts | 1 + locales/ar/common.json | 14 + locales/ca/common.json | 14 + locales/cs/common.json | 14 + locales/da/common.json | 14 + locales/de/common.json | 14 + locales/en/common.json | 14 + locales/es/common.json | 14 + locales/et/common.json | 14 + locales/fa/common.json | 14 + locales/fr/common.json | 14 + locales/he/common.json | 14 + locales/hu/common.json | 14 + locales/it/common.json | 14 + locales/ja/common.json | 14 + locales/ko/common.json | 14 + locales/lv/common.json | 14 + locales/mn/common.json | 14 + locales/nb/common.json | 14 + locales/nl/common.json | 14 + locales/pl/common.json | 14 + locales/pt/common.json | 14 + locales/ro/common.json | 14 + locales/ru/common.json | 14 + locales/sk/common.json | 14 + locales/th/common.json | 14 + locales/tr/common.json | 14 + locales/uk/common.json | 14 + locales/vi/common.json | 14 + locales/zh-TW/common.json | 14 + locales/zh/common.json | 14 + public/sw.js | 1 + stores/calendar-notification-store.ts | 2 + 55 files changed, 2555 insertions(+), 70 deletions(-) create mode 100644 components/calendar/missed-reminders-dialog.tsx create mode 100644 components/ui/__tests__/toast-extra-actions.test.tsx create mode 100644 hooks/__tests__/use-store-reminders.test.tsx create mode 100644 hooks/use-store-reminders.ts create mode 100644 lib/calendar-reminders/__tests__/claims.test.ts create mode 100644 lib/calendar-reminders/__tests__/runner.test.ts create mode 100644 lib/calendar-reminders/channel.ts create mode 100644 lib/calendar-reminders/claims.ts create mode 100644 lib/calendar-reminders/runner.ts create mode 100644 lib/calendar-reminders/state.ts create mode 100644 lib/calendar-reminders/system-notification.ts create mode 100644 lib/desktop/__tests__/reminder-notifications.test.ts create mode 100644 lib/desktop/reminder-notifications.ts create mode 100644 lib/offline/store-events.ts diff --git a/components/calendar/missed-reminders-dialog.tsx b/components/calendar/missed-reminders-dialog.tsx new file mode 100644 index 000000000..6309994fd --- /dev/null +++ b/components/calendar/missed-reminders-dialog.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useLocale, useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { Dialog } from "@/components/ui/dialog"; +import { getPathPrefix } from "@/lib/browser-navigation"; +import { reminderTimeZone, useMissedReminders } from "@/lib/calendar-reminders/state"; +import { dismissReminders, joinMeeting } from "@/lib/desktop/reminder-notifications"; +import { useSettingsStore } from "@/stores/settings-store"; + +/** What came due while the app was closed, the machine slept or notifications were paused. */ +export function MissedRemindersDialog() { + const t = useTranslations("desktopReminders"); + const tAlert = useTranslations("calendar.notifications"); + const locale = useLocale(); + const timeZone = useSettingsStore((s) => s.timeZone); + const account = useMissedReminders((s) => s.account); + const alerts = useMissedReminders((s) => s.alerts); + const close = useMissedReminders((s) => s.close); + + if (!account || alerts.length === 0) return null; + + const format = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short", timeZone: reminderTimeZone(timeZone) }); + const finish = () => { + dismissReminders(account, alerts.map((alert) => alert.key)); + close(); + }; + + return ( + + { + finish(); + window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`; + }} + > + {t("open_calendar")} + + {t("done")} + > + )} + > + + {alerts.map((alert) => ( + + + {alert.title || tAlert("alert_title")} + + {format.format(new Date(alert.startMs ?? alert.fireTimeMs))} + {alert.calendarName ? ` · ${alert.calendarName}` : ""} + + + {alert.joinUrl && ( + joinMeeting(alert.joinUrl!)}> + {t("join")} + + )} + + ))} + + + ); +} diff --git a/components/desktop/desktop-os-host.tsx b/components/desktop/desktop-os-host.tsx index c8d2755d8..df31098c0 100644 --- a/components/desktop/desktop-os-host.tsx +++ b/components/desktop/desktop-os-host.tsx @@ -9,11 +9,8 @@ import { startOsSummary } from "@/lib/desktop/os-summary"; import { startPowerWatch } from "@/lib/desktop/power"; import { startWindowFiles } from "@/lib/desktop/window-files"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; -import { calendarHooks } from "@/lib/plugin-hooks"; -import type { CalendarAlertView } from "@/lib/plugin-types"; import { useAccountStore } from "@/stores/account-store"; import { useAuthStore } from "@/stores/auth-store"; -import { useSettingsStore } from "@/stores/settings-store"; /** * The main window's end of the desktop app's OS integration: new mail as native @@ -25,7 +22,6 @@ export function DesktopOsHost() { const t = useTranslations("desktopOs"); const tNotify = useTranslations("notifications"); const tPush = useTranslations("pwaNotify"); - const tCalendar = useTranslations("calendar.notifications"); const locale = useLocale(); const archiveEnabled = useArchiveActionEnabled(); const clientKey = useAccountStore((s) => s.accounts.map((a) => a.id).join("\n")); @@ -56,29 +52,14 @@ export function DesktopOsHost() { summaryRef.current = summary; const stopPower = startPowerWatch(); const stopFiles = startWindowFiles(); - // Reminders' own native notification (snooze, join) comes with the reminders work. - // Until then a reminder must not fire into a hidden window unseen. - const reminders = calendarHooks.onCalendarAlert.register("desktop-os", (view: CalendarAlertView) => { - const { calendarNotificationsEnabled } = useSettingsStore.getState(); - if (!calendarNotificationsEnabled) return; - const event = "eventId" in view; - void getDesktopBridge()?.os?.notify({ - id: `reminder:${event ? view.eventId : view.taskId}:${view.id}:${view.triggerTime}`, - title: (event ? view.eventTitle : view.taskTitle) || tCalendar("alert_title"), - body: "", - context: "{}", - }); - }); return () => { mail.stop(); summary.stop(); stopPower(); stopFiles(); - reminders.dispose(); mailRef.current = null; summaryRef.current = null; }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { diff --git a/components/providers/calendar-alert-provider.tsx b/components/providers/calendar-alert-provider.tsx index cb0b72b6a..561586746 100644 --- a/components/providers/calendar-alert-provider.tsx +++ b/components/providers/calendar-alert-provider.tsx @@ -1,12 +1,15 @@ "use client"; +import { MissedRemindersDialog } from '@/components/calendar/missed-reminders-dialog'; import { useCalendarAlerts } from '@/hooks/use-calendar-alerts'; +import { useStoreReminders } from '@/hooks/use-store-reminders'; import { ToastContainer } from '@/components/ui/toast'; import { LiveRegions } from '@/components/ui/live-regions'; import { useToastStore } from '@/stores/toast-store'; export function CalendarAlertProvider({ children }: { children: React.ReactNode }) { useCalendarAlerts(); + useStoreReminders(); const toasts = useToastStore((s) => s.toasts); const removeToast = useToastStore((s) => s.removeToast); @@ -15,6 +18,7 @@ export function CalendarAlertProvider({ children }: { children: React.ReactNode <> {children} + > ); diff --git a/components/ui/__tests__/toast-extra-actions.test.tsx b/components/ui/__tests__/toast-extra-actions.test.tsx new file mode 100644 index 000000000..10354d4f3 --- /dev/null +++ b/components/ui/__tests__/toast-extra-actions.test.tsx @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { ToastItem } from '../toast'; + +describe('a toast with more than three actions', () => { + it('shows them in order, and each closes it', () => { + vi.useFakeTimers(); + const clicked = vi.fn(); + const onDismiss = vi.fn(); + const action = (label: string) => ({ label, onClick: () => clicked(label) }); + render( + , + ); + expect(screen.getAllByRole('button').map((button) => button.textContent).filter(Boolean)).toEqual(['five', 'ten', 'fifteen', 'join', 'open']); + fireEvent.click(screen.getByText('join')); + expect(clicked).toHaveBeenCalledWith('join'); + expect(onDismiss).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); +}); diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx index 28bb16c0a..a3d96a2be 100644 --- a/components/ui/toast.tsx +++ b/components/ui/toast.tsx @@ -36,6 +36,8 @@ export interface Toast { * that has one lays its actions out on a row of their own. */ tertiaryAction?: ToastAction; + /** More actions at the end of that row. Needs a tertiaryAction. */ + extraActions?: ToastAction[]; /** * Work in progress, e.g. an upload: 0..1, or null while the amount is * unknown. The bottom line then shows progress instead of a countdown, @@ -291,6 +293,7 @@ export function ToastItem({ toast, onClose, held = false, onFocusLost }: ToastPr {actionButton(toast.tertiaryAction)} {toast.secondaryAction && actionButton(toast.secondaryAction)} {toast.action && actionButton(toast.action)} + {toast.extraActions?.map((action) => {actionButton(action)})} )} diff --git a/hooks/__tests__/use-calendar-alerts-hooks.test.tsx b/hooks/__tests__/use-calendar-alerts-hooks.test.tsx index 331953d64..0f3ca9527 100644 --- a/hooks/__tests__/use-calendar-alerts-hooks.test.tsx +++ b/hooks/__tests__/use-calendar-alerts-hooks.test.tsx @@ -76,6 +76,54 @@ afterEach(() => { vi.restoreAllMocks(); }); +describe('the 24-hour fetch', () => { + it('fires the occurrence of a series the server answered with its master', async () => { + const now = Date.now(); + const occurrenceStart = new Date(now + 5 * 60_000); + const stamp = (date: Date) => { + const pad = (value: number) => String(value).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:00`; + }; + // The same weekday and time, years ago: only the expansion finds today's. + const first = new Date(occurrenceStart.getTime() - 52 * 7 * 24 * 3_600_000); + const master = { + id: 'series', + uid: 'uid-series', + title: 'Weekly', + start: stamp(first), + duration: 'PT30M', + calendarIds: { c1: true }, + useDefaultAlerts: false, + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' }], + alerts: { + a1: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT6M', relativeTo: 'start' } }, + }, + } as unknown as CalendarEvent; + useCalendarStore.setState({ events: [], calendars: [], supportsCalendar: true }); + useAuthStore.setState({ isAuthenticated: true, client: { queryCalendarEvents: vi.fn(async () => [master]) } as never }); + + renderHook(() => useCalendarAlerts()); + + await waitFor(() => expect(useToastStore.getState().toasts.map((toast) => toast.title)).toEqual(['Weekly'])); + }); +}); + +describe('two tabs', () => { + it('rings in one, also when an id changed between the two', async () => { + const synthetic = { ...event, id: 'synthetic-2' } as CalendarEvent; + renderHook(() => useCalendarAlerts()); + await waitFor(() => expect(useToastStore.getState().toasts).toHaveLength(1)); + + // The other tab has the same reminder under the id the server gives it now. + useCalendarStore.setState({ events: [synthetic] }); + renderHook(() => useCalendarAlerts()); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 700)); + }); + expect(useToastStore.getState().toasts).toHaveLength(1); + }); +}); + describe('reminder hooks', () => { it('reports a fired reminder once, and its dismissal when the user closes it', async () => { const fired = vi.fn(); diff --git a/hooks/__tests__/use-store-reminders.test.tsx b/hooks/__tests__/use-store-reminders.test.tsx new file mode 100644 index 000000000..afcad0f8a --- /dev/null +++ b/hooks/__tests__/use-store-reminders.test.tsx @@ -0,0 +1,380 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, renderHook, screen } from '@testing-library/react'; +import { MissedRemindersDialog } from '@/components/calendar/missed-reminders-dialog'; +import { ToastContainer } from '@/components/ui/toast'; +import { useCalendarAlerts } from '../use-calendar-alerts'; +import { useStoreReminders } from '../use-store-reminders'; +import { useMissedReminders, useReminderSource } from '@/lib/calendar-reminders/state'; +import { setAppLockCheck } from '@/lib/desktop/app-lock-state'; +import type { BulwarkDesktopBridge, DesktopOsNotification, DesktopOsNotifyResult } from '@/lib/desktop/types'; +import { calendarHooks, clearAllHooks } from '@/lib/plugin-hooks'; +import { useOfflineStore } from '@/lib/offline/state'; +import { emitStoreEvent } from '@/lib/offline/store-events'; +import { CALENDAR_SESSION, FakeCalendarJmap } from '@/lib/store/__tests__/helpers/fake-calendar'; +import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store'; +import { setStoreBackend } from '@/lib/store/client/api'; +import { useAuthStore } from '@/stores/auth-store'; +import { useCalendarNotificationStore } from '@/stores/calendar-notification-store'; +import { useCalendarStore } from '@/stores/calendar-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useToastStore } from '@/stores/toast-store'; + +const sounds = vi.hoisted(() => ({ play: vi.fn() })); +vi.mock('@/lib/notification-sound', () => ({ playNotificationSound: sounds.play })); +vi.mock('@/lib/title-alert', () => ({ startTitleAlert: vi.fn() })); +vi.mock('@/lib/offline/offline-mode', () => ({ offlineActive: () => true })); +const popout = vi.hoisted(() => ({ value: false })); +vi.mock('@/lib/popout/window-role', async (original) => ({ + ...await original(), + isPopoutWindow: () => popout.value, +})); + +const MIN = 60_000; +const ACCOUNT = 'acc'; +const START = Date.UTC(2026, 10, 10, 9); +const FIRE = START - 15 * MIN; +const ALERT = { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } }; +const LINK = { virtualLocations: { v: { '@type': 'VirtualLocation', uri: 'https://meet.jit.si/standup' } } }; + +function event(partial: Record = {}) { + return { id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', duration: 'PT30M', alerts: { a: ALERT }, ...partial }; +} + +let store: InProcessStore; +let server: FakeCalendarJmap; +let notifyResult: DesktopOsNotifyResult; +const notify = vi.fn(async (_notification: DesktopOsNotification) => notifyResult); +const openExternal = vi.fn(); + +function desktop() { + window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { focus: vi.fn() }, shell: { openExternal }, os: { notify } } as unknown as BulwarkDesktopBridge; +} + +function setVisibility(state: 'visible' | 'hidden') { + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state }); +} + +function Toasts() { + const toasts = useToastStore((s) => s.toasts); + const removeToast = useToastStore((s) => s.removeToast); + return ; +} + +async function advance(ms: number) { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms); + }); +} + +async function syncedWith(...events: Record[]) { + for (const partial of events) server.addEvent(partial); + await store.sync(ACCOUNT); +} + +const toastTitles = () => useToastStore.getState().toasts.map((toast) => toast.title); + +beforeEach(() => { + vi.useFakeTimers({ now: FIRE - 30 * MIN, toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] }); + vi.clearAllMocks(); + popout.value = false; + clearAllHooks(); + localStorage.clear(); + setAppLockCheck(null); + setVisibility('visible'); + notifyResult = { shown: true, playSound: false }; + useToastStore.setState({ toasts: [] }); + useReminderSource.setState({ driven: {} }); + useMissedReminders.getState().close(); + useCalendarNotificationStore.getState().clearAll(); + useOfflineStore.setState({ enabled: [ACCOUNT] }); + useAuthStore.setState({ isAuthenticated: true, client: null, activeAccountId: ACCOUNT }); + useCalendarStore.setState({ events: [], calendars: [] }); + useSettingsStore.setState({ + calendarNotificationsEnabled: true, calendarNotificationSound: true, enableCalendarTasks: true, + doNotDisturb: false, visualAlerts: false, timeZone: 'Europe/Berlin', + }); + server = new FakeCalendarJmap('1.0'); + store = new InProcessStore(server, CALENDAR_SESSION); + setStoreBackend(store); +}); + +afterEach(() => { + setStoreBackend(null); + window.bulwarkDesktop = undefined; + setAppLockCheck(null); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe('in the desktop app', () => { + beforeEach(desktop); + + it('fires a native notification with the window hidden, at the time, with the network gone', async () => { + await syncedWith(event(LINK)); + await store.setOnline(false); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now() - 1000); + expect(notify).not.toHaveBeenCalled(); + await advance(1000); + expect(notify).toHaveBeenCalledTimes(1); + const sent = notify.mock.calls[0][0]; + expect(sent.title).toBe('Standup'); + expect(sent.actions!.map((action) => action.id)).toEqual(['join', 'open', 'snooze-5', 'snooze-10', 'snooze-15']); + expect(toastTitles()).toEqual([]); + }); + + it('shows no Join for an event without a link', async () => { + await syncedWith(event()); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + expect(notify.mock.calls[0][0].actions!.map((action) => action.id)).not.toContain('join'); + }); + + it('plays the sound main asks for, and asks for none in Do not disturb', async () => { + await syncedWith(event()); + notifyResult = { shown: true, playSound: true }; + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + expect(notify.mock.calls[0][0].sound).toBe(true); + expect(sounds.play).toHaveBeenCalledTimes(1); + + useSettingsStore.setState({ doNotDisturb: true }); + await syncedWith(event({ id: 'e2', uid: 'uid-2', start: '2026-11-10T10:01:00' })); + emitStoreEvent({ type: 'changed', account: ACCOUNT, collections: ['CalendarEvent'] }); + await advance(MIN); + expect(notify.mock.calls[1][0].sound).toBe(false); + }); + + it('shows it in the app when the window is in front, with the same choices', async () => { + await syncedWith(event(LINK)); + notifyResult = { shown: false, reason: 'focused', playSound: true }; + render(); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + expect(toastTitles()).toEqual(['Standup']); + expect(sounds.play).toHaveBeenCalledTimes(1); + expect(screen.getAllByRole('button').map((button) => button.textContent).filter(Boolean)).toEqual(['snooze', 'snooze', 'snooze', 'join', 'open']); + fireEvent.click(screen.getByText('join')); + expect(openExternal).toHaveBeenCalledWith('https://meet.jit.si/standup'); + }); + + it('snoozing from the in-app toast brings it back, and closing it does not', async () => { + await syncedWith(event()); + notifyResult = { shown: false, reason: 'focused', playSound: false }; + render(); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + fireEvent.click(screen.getAllByText('snooze')[0]); + await advance(5 * MIN); + expect(notify).toHaveBeenCalledTimes(2); + + await advance(1000); + const again = useToastStore.getState().toasts.at(-1)!; + act(() => again.onDismiss!()); + await advance(60 * MIN); + expect(notify).toHaveBeenCalledTimes(2); + }); + + it('holds what fires while paused or in quiet hours for the summary afterwards', async () => { + await syncedWith(event()); + notifyResult = { shown: false, reason: 'quiet-hours', playSound: false }; + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + expect(notify).toHaveBeenCalledTimes(1); + expect(toastTitles()).toEqual([]); + + // Still quiet: the summary is tried and put back. + await advance(5 * MIN); + const tried = notify.mock.calls.length; + expect(notify.mock.calls.at(-1)![0].id).toMatch(/^reminder-missed:/); + + notifyResult = { shown: true, playSound: false }; + await advance(5 * MIN); + expect(notify.mock.calls.length).toBe(tried + 1); + expect(notify.mock.calls.at(-1)![0].body).toBe('Standup'); + + await advance(30 * MIN); + expect(notify.mock.calls.length).toBe(tried + 1); + }); + + it('says nothing of the event while locked, and tells afterwards', async () => { + await syncedWith(event()); + let locked = true; + setAppLockCheck(() => locked); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + expect(notify).toHaveBeenCalledTimes(1); + expect(JSON.stringify(notify.mock.calls[0][0])).not.toContain('Standup'); + expect(notify.mock.calls[0][0].actions).toBeUndefined(); + expect(toastTitles()).toEqual([]); + + await advance(10 * MIN); + expect(notify).toHaveBeenCalledTimes(1); + + locked = false; + await advance(5 * MIN); + expect(notify).toHaveBeenCalledTimes(2); + expect(notify.mock.calls[1][0].body).toBe('Standup'); + }); + + it('summarises what was missed once, with a native notification, across a restart', async () => { + await syncedWith(event(), event({ id: 'e2', uid: 'uid-2', title: 'Review' })); + const before = renderHook(() => useStoreReminders()); + await advance(0); + before.unmount(); + vi.setSystemTime(FIRE + 3 * 60 * MIN); + + const after = renderHook(() => useStoreReminders()); + await advance(20_000); + expect(notify).toHaveBeenCalledTimes(1); + expect(notify.mock.calls[0][0]).toMatchObject({ title: 'missed_title', body: 'Standup, Review' }); + after.unmount(); + + renderHook(() => useStoreReminders()); + await advance(60 * MIN); + expect(notify).toHaveBeenCalledTimes(1); + }); + + it('summarises in the app when the window is in front, and the dialog lists them', async () => { + await syncedWith(event(LINK), event({ id: 'e2', uid: 'uid-2', title: 'Review' })); + notifyResult = { shown: false, reason: 'focused', playSound: false }; + const before = renderHook(() => useStoreReminders()); + await advance(0); + before.unmount(); + vi.setSystemTime(FIRE + 3 * 60 * MIN); + render(<>>); + renderHook(() => useStoreReminders()); + await advance(20_000); + expect(toastTitles()).toEqual(['missed_title']); + fireEvent.click(screen.getByText('missed_show')); + await advance(0); + const list = screen.getByTestId('missed-reminders'); + expect(list.textContent).toContain('Standup'); + expect(list.textContent).toContain('Review'); + expect(screen.getAllByText('join')).toHaveLength(1); + fireEvent.click(screen.getByText('done')); + await advance(0); + expect(screen.queryByTestId('missed-reminders')).toBeNull(); + }); + + it('tells plugins, and lets one take the reminder over', async () => { + await syncedWith(event()); + const fired = vi.fn(); + calendarHooks.onCalendarAlert.register('t', fired); + calendarHooks.onBeforeCalendarAlert.register('t', () => false); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + expect(fired).toHaveBeenCalledWith(expect.objectContaining({ id: 'a', eventId: 'e1', eventTitle: 'Standup' })); + expect(notify).not.toHaveBeenCalled(); + }); + + function calendarStoreHas(uid: string, title: string) { + useCalendarStore.setState({ + events: [{ id: 'e1', uid, title, start: '2026-11-10T10:00:00', utcStart: new Date(START).toISOString(), duration: 'PT30M', calendarIds: { c: true }, useDefaultAlerts: false, alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'AbsoluteTrigger', when: new Date(FIRE).toISOString() } } } }] as never, + }); + } + + it('makes the old path stand down once the store has this account', async () => { + await syncedWith(event()); + // Not the same event as far as the claim can tell: only standing down keeps it quiet. + calendarStoreHas('another-uid', 'Old path'); + renderHook(() => { + useCalendarAlerts(); + useStoreReminders(); + }); + await advance(FIRE - Date.now() + 2 * MIN); + expect(notify).toHaveBeenCalledTimes(1); + expect(toastTitles()).toEqual([]); + }); + + it('lets the two paths tell each other what fired, for the moment the store takes over', async () => { + await syncedWith(event()); + calendarStoreHas('uid-1', 'Standup'); + const { claimKey, claimNow } = await import('@/lib/calendar-reminders/claims'); + // The old path got there just before the store was ready. + expect(claimNow(claimKey(ACCOUNT, 'uid-1', 'a', FIRE), FIRE)).toBe(true); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now() + 2 * MIN); + expect(notify).not.toHaveBeenCalled(); + }); +}); + +describe('in a browser', () => { + function grantNotifications(showNotification = vi.fn(async () => {})) { + vi.stubGlobal('Notification', Object.assign(vi.fn(), { permission: 'granted' })); + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: { getRegistration: async () => ({ showNotification, getNotifications: async () => [] }) }, + }); + return showNotification; + } + + afterEach(() => { + Reflect.deleteProperty(navigator, 'serviceWorker'); + }); + + it('keeps the in-app toast, without desktop buttons', async () => { + await syncedWith(event(LINK)); + render(); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + const toast = useToastStore.getState().toasts[0]; + expect(toast.title).toBe('Standup'); + expect(toast.action).toBeUndefined(); + expect(toast.extraActions).toBeUndefined(); + expect(sounds.play).toHaveBeenCalledTimes(1); + }); + + it('adds a system notification only where permission is granted and the tab is out of view', async () => { + await syncedWith(event()); + const shown = grantNotifications(); + setVisibility('hidden'); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now() + 3000); + expect(toastTitles()).toEqual(['Standup']); + expect(shown).toHaveBeenCalledTimes(1); + expect(shown).toHaveBeenCalledWith('Standup', expect.objectContaining({ data: { kind: 'calendar-reminder' }, silent: false })); + }); + + it('asks for no permission and shows none when it was not granted', async () => { + await syncedWith(event()); + const request = vi.fn(); + vi.stubGlobal('Notification', Object.assign(vi.fn(), { permission: 'default', requestPermission: request })); + setVisibility('hidden'); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now() + 3000); + expect(toastTitles()).toEqual(['Standup']); + expect(request).not.toHaveBeenCalled(); + }); + + it('fires in one of two tabs', async () => { + await syncedWith(event()); + renderHook(() => useStoreReminders()); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now() + MIN); + expect(toastTitles()).toEqual(['Standup']); + }); + + it('closes the toast in this window when another acknowledges the reminder', async () => { + await syncedWith(event()); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + expect(toastTitles()).toEqual(['Standup']); + const key = `${Object.keys(CALENDAR_SESSION.accounts)[0]}:uid-1:a:${FIRE}`; + const other = new BroadcastChannel('bulwark-calendar-reminders'); + other.postMessage({ kind: 'acknowledged', keys: [key] }); + other.close(); + vi.useRealTimers(); + await vi.waitFor(() => expect(toastTitles()).toEqual([])); + }); + + it('gives nothing to a pop-out', async () => { + popout.value = true; + await syncedWith(event()); + const area = vi.spyOn(store, 'area'); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now() + MIN); + expect(area).not.toHaveBeenCalled(); + expect(toastTitles()).toEqual([]); + }); +}); diff --git a/hooks/use-calendar-alerts.ts b/hooks/use-calendar-alerts.ts index 8c65c12ee..365647480 100644 --- a/hooks/use-calendar-alerts.ts +++ b/hooks/use-calendar-alerts.ts @@ -2,7 +2,7 @@ import { useEffect, useRef, useCallback } from 'react'; import { useTranslations, useLocale } from 'next-intl'; -import { parseISO } from 'date-fns'; +import { format, parseISO } from 'date-fns'; import { isPopoutWindow } from '@/lib/popout/window-role'; import { useAuthStore } from '@/stores/auth-store'; import { useCalendarStore } from '@/stores/calendar-store'; @@ -11,10 +11,16 @@ import { useTaskStore } from '@/stores/task-store'; import { useCalendarNotificationStore } from '@/stores/calendar-notification-store'; import { useToastStore } from '@/stores/toast-store'; import { getPendingAlerts, getPendingTaskAlerts, getNextAlertFireTime, buildAlertKey } from '@/lib/calendar-alerts'; +import { claimAlert, claimKey } from '@/lib/calendar-reminders/claims'; +import { useReminderSource } from '@/lib/calendar-reminders/state'; +import { showSystemReminder } from '@/lib/calendar-reminders/system-notification'; +import { notifyWithoutStore } from '@/lib/desktop/reminder-notifications'; +import { expandRecurringEvents } from '@/lib/recurrence-expansion'; import { playNotificationSound } from '@/lib/notification-sound'; import { lastingToastDuration } from '@/lib/toast-duration'; import { startTitleAlert } from '@/lib/title-alert'; import { getPathPrefix } from '@/lib/browser-navigation'; +import { getDesktopBridge } from '@/lib/desktop/bridge'; import type { CalendarEvent } from '@/lib/jmap/types'; import type { CalendarAlertView } from '@/lib/plugin-types'; import { calendarHooks } from '@/lib/plugin-hooks'; @@ -23,6 +29,8 @@ import { changesAnyType, subscribePushStateChanges } from '@/lib/push-events'; const CHECK_INTERVAL_MS = 60 * 1000; const PROACTIVE_FETCH_HOURS = 24; const PROACTIVE_THROTTLE_MS = CHECK_INTERVAL_MS * 5; +// Wall clocks: an occurrence's can be a day off the instant it's at. +const EXPAND_MARGIN_MS = 27 * 60 * 60 * 1000; // Longest single wait for the next reminder; the periodic check covers the rest. const MAX_WAKE_DELAY_MS = 6 * 60 * 60 * 1000; /** How long a reminder stays at the shortest "Notification duration". */ @@ -42,7 +50,7 @@ export function reminderToastDuration(): number { * Whether a reminder plays its sound. Do not disturb keeps reminders on * screen (they were asked for) but silent. */ -function reminderSoundOn(soundSetting: boolean): boolean { +export function reminderSoundOn(soundSetting: boolean): boolean { return soundSetting && !useSettingsStore.getState().doNotDisturb; } @@ -51,7 +59,7 @@ function reminderSoundOn(soundSetting: boolean): boolean { * is in the background. Reminders were asked for, so Do not disturb keeps * this too. */ -function reminderTitleAlert(text: string): void { +export function reminderTitleAlert(text: string): void { if (useSettingsStore.getState().visualAlerts) startTitleAlert(text); } @@ -61,7 +69,7 @@ function reminderTitleAlert(text: string): void { * itself does. onCalendarAlert fires either way. With no plugin asked, the * reminder is shown at once. */ -function remind(view: CalendarAlertView, show: () => void): void { +export function remind(view: CalendarAlertView, show: () => void): void { void calendarHooks.onCalendarAlert.emit(view); if (calendarHooks.onBeforeCalendarAlert.size === 0) { show(); @@ -73,9 +81,11 @@ function remind(view: CalendarAlertView, show: () => void): void { } export function useCalendarAlerts() { - const { isAuthenticated: signedIn, client } = useAuthStore(); + const { isAuthenticated: signedIn, client, activeAccountId } = useAuthStore(); + // Reminders of an account with a local store come from there: useStoreReminders. + const fromStore = useReminderSource((s) => !!activeAccountId && !!s.driven[activeAccountId]); // The main window rings; a pop-out would ring the same alert again. - const isAuthenticated = signedIn && !isPopoutWindow(); + const isAuthenticated = signedIn && !isPopoutWindow() && !fromStore; const { events, calendars, supportsCalendar } = useCalendarStore(); const { calendarNotificationsEnabled, calendarNotificationSound, enableCalendarTasks, notificationSoundChoice } = useSettingsStore(); const { tasks: storeTasks } = useTaskStore(); @@ -91,6 +101,15 @@ export function useCalendarAlerts() { const wakeTimerRef = useRef | null>(null); const checkAlertsRef = useRef<() => void>(() => {}); + // Beside the toast: the desktop app's native notification, a browser's system one. + const tellOutsideTheWindow = useCallback((key: string, title: string, body: string, silent: boolean) => { + if (getDesktopBridge()?.os) { + notifyWithoutStore(`reminder:${key}`, title, body, { generic: t('alert_title') }, activeAccountId); + return; + } + void showSystemReminder({ key, title, body, silent }, `${getPathPrefix(locale)}/${locale}/calendar`); + }, [t, locale, activeAccountId]); + const checkAlerts = useCallback(() => { if (wakeTimerRef.current) { clearTimeout(wakeTimerRef.current); @@ -127,27 +146,33 @@ export function useCalendarAlerts() { ? `${timeLabel} · ${alert.calendarName}` : timeLabel; - remind({ - id: alert.alertId, - eventId: alert.eventId, - eventTitle: alert.event.title || '', - triggerTime: new Date(alert.fireTimeMs).toISOString(), - }, () => { - if (reminderSoundOn(calendarNotificationSound)) { - playNotificationSound(notificationSoundChoice); - } - reminderTitleAlert(t('title_alert', { title: alert.event.title || t('alert_title') })); - addToast({ - type: 'info', - title: alert.event.title || t('alert_title'), - message, - duration: reminderToastDuration(), - onClick: () => { - window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`; - }, - onDismiss: () => { - void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: alert.alertId, eventId: alert.eventId }); - }, + const uid = alert.event.uid || alert.event.originalId || alert.eventId; + claimAlert(claimKey(activeAccountId, uid, alert.alertId, alert.fireTimeMs), alert.fireTimeMs, () => { + remind({ + id: alert.alertId, + eventId: alert.eventId, + eventTitle: alert.event.title || '', + triggerTime: new Date(alert.fireTimeMs).toISOString(), + }, () => { + const sound = reminderSoundOn(calendarNotificationSound); + if (sound) { + playNotificationSound(notificationSoundChoice); + } + const title = alert.event.title || t('alert_title'); + reminderTitleAlert(t('title_alert', { title })); + addToast({ + type: 'info', + title, + message, + duration: reminderToastDuration(), + onClick: () => { + window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`; + }, + onDismiss: () => { + void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: alert.alertId, eventId: alert.eventId }); + }, + }); + tellOutsideTheWindow(key, title, message, !sound); }); }); } @@ -166,27 +191,33 @@ export function useCalendarAlerts() { ? `${t('task_due')} · ${taskAlert.calendarName}` : t('task_due'); - remind({ - id: taskAlert.alertId, - taskId: taskAlert.taskId, - taskTitle: taskAlert.task.title || '', - triggerTime: new Date(taskAlert.fireTimeMs).toISOString(), - }, () => { - if (reminderSoundOn(calendarNotificationSound)) { - playNotificationSound(notificationSoundChoice); - } - reminderTitleAlert(t('title_alert', { title: taskAlert.task.title || t('alert_title') })); - addToast({ - type: 'info', - title: taskAlert.task.title || t('alert_title'), - message: taskMsg, - duration: reminderToastDuration(), - onClick: () => { - window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`; - }, - onDismiss: () => { - void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: taskAlert.alertId, taskId: taskAlert.taskId }); - }, + const uid = taskAlert.task.uid || taskAlert.taskId; + claimAlert(claimKey(activeAccountId, uid, taskAlert.alertId, taskAlert.fireTimeMs), taskAlert.fireTimeMs, () => { + remind({ + id: taskAlert.alertId, + taskId: taskAlert.taskId, + taskTitle: taskAlert.task.title || '', + triggerTime: new Date(taskAlert.fireTimeMs).toISOString(), + }, () => { + const sound = reminderSoundOn(calendarNotificationSound); + if (sound) { + playNotificationSound(notificationSoundChoice); + } + const title = taskAlert.task.title || t('alert_title'); + reminderTitleAlert(t('title_alert', { title })); + addToast({ + type: 'info', + title, + message: taskMsg, + duration: reminderToastDuration(), + onClick: () => { + window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`; + }, + onDismiss: () => { + void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: taskAlert.alertId, taskId: taskAlert.taskId }); + }, + }); + tellOutsideTheWindow(key, title, taskMsg, !sound); }); }); } @@ -196,8 +227,8 @@ export function useCalendarAlerts() { } }, [ calendarNotificationsEnabled, calendarNotificationSound, notificationSoundChoice, - isAuthenticated, events, calendars, acknowledgedAlerts, - acknowledgeAlert, addToast, t, locale, enableCalendarTasks, storeTasks, + isAuthenticated, activeAccountId, events, calendars, acknowledgedAlerts, + acknowledgeAlert, addToast, t, locale, enableCalendarTasks, storeTasks, tellOutsideTheWindow, ]); checkAlertsRef.current = checkAlerts; @@ -211,7 +242,10 @@ export function useCalendarAlerts() { const start = new Date(now - 10 * 60 * 1000).toISOString(); const end = new Date(now + PROACTIVE_FETCH_HOURS * 60 * 60 * 1000).toISOString(); const fetched = await client.queryCalendarEvents({ after: start, before: end }); - proactiveEventsRef.current = fetched; + // Without synthetic ids the server answers with a series' master, whose start can + // be years ago: only its occurrences have a fire time. Passes real occurrences through. + const stamp = (ms: number) => format(new Date(ms), "yyyy-MM-dd'T'HH:mm:ss"); + proactiveEventsRef.current = expandRecurringEvents(fetched, stamp(now - EXPAND_MARGIN_MS), stamp(now + PROACTIVE_FETCH_HOURS * 60 * 60 * 1000 + EXPAND_MARGIN_MS)); lastProactiveFetchRef.current = Date.now(); checkAlertsRef.current(); } catch { diff --git a/hooks/use-store-reminders.ts b/hooks/use-store-reminders.ts new file mode 100644 index 000000000..adeb0468b --- /dev/null +++ b/hooks/use-store-reminders.ts @@ -0,0 +1,278 @@ +"use client"; + +import { useEffect, useRef } from 'react'; +import { useLocale, useTranslations } from 'next-intl'; +import { remind, reminderSoundOn, reminderTitleAlert, reminderToastDuration } from '@/hooks/use-calendar-alerts'; +import { getPathPrefix } from '@/lib/browser-navigation'; +import { onAcknowledged } from '@/lib/calendar-reminders/channel'; +import { claimKey, claimNow } from '@/lib/calendar-reminders/claims'; +import { createReminderRunner } from '@/lib/calendar-reminders/runner'; +import { reminderTimeZone, setReminderPoke, useMissedReminders, useReminderSource } from '@/lib/calendar-reminders/state'; +import { closeSystemReminders, showSystemReminder } from '@/lib/calendar-reminders/system-notification'; +import { isAppLocked } from '@/lib/desktop/app-lock-state'; +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { + buildMissedNotification, + buildReminderNotification, + dismissReminders, + joinMeeting, + missedBody, + SNOOZE_MINUTES, + snoozeReminder, + type ReminderLabels, +} from '@/lib/desktop/reminder-notifications'; +import { addWakeHandler } from '@/lib/desktop/system-events'; +import { playNotificationSound } from '@/lib/notification-sound'; +import { offlineActive } from '@/lib/offline/offline-mode'; +import { visibleAccounts, useOfflineStore } from '@/lib/offline/state'; +import { addStoreEventListener } from '@/lib/offline/store-events'; +import { calendarHooks } from '@/lib/plugin-hooks'; +import type { CalendarAlertView } from '@/lib/plugin-types'; +import { isPopoutWindow } from '@/lib/popout/window-role'; +import { getStore } from '@/lib/store/client/api'; +import type { MissedBatch, ReminderAlert } from '@/lib/store/reminders/ledger'; +import { useAuthStore } from '@/stores/auth-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useToastStore } from '@/stores/toast-store'; + +function viewOf(alert: ReminderAlert): CalendarAlertView { + const triggerTime = new Date(alert.fireTimeMs).toISOString(); + return alert.kind === 'task' + ? { id: alert.alertId, taskId: alert.baseEventId, taskTitle: alert.title, triggerTime } + : { id: alert.alertId, eventId: alert.baseEventId, eventTitle: alert.title, triggerTime }; +} + +function acknowledgeView(alert: ReminderAlert) { + return alert.kind === 'task' + ? { alertId: alert.alertId, taskId: alert.baseEventId } + : { alertId: alert.alertId, eventId: alert.baseEventId }; +} + +/** + * Reminders from the local store: on time with no network, and in the desktop app with + * the window hidden. For accounts without a store useCalendarAlerts carries on as before. + */ +export function useStoreReminders(): void { + const signedIn = useAuthStore((s) => s.isAuthenticated); + const t = useTranslations('desktopReminders'); + const tAlert = useTranslations('calendar.notifications'); + const locale = useLocale(); + const addToast = useToastStore((s) => s.addToast); + const removeToast = useToastStore((s) => s.removeToast); + const enabledKey = useOfflineStore((s) => s.enabled.join('\n')); + + const labels: ReminderLabels = { + generic: t('generic_title'), + untitled: tAlert('alert_title'), + when: (alert) => { + const minutes = alert.startMs === null ? 0 : Math.round((alert.startMs - Date.now()) / 60_000); + const time = alert.kind === 'task' + ? tAlert('task_due') + : minutes <= 0 ? tAlert('alert_now') : tAlert('alert_in_minutes', { count: minutes }); + return alert.calendarName ? `${time} · ${alert.calendarName}` : time; + }, + snooze: (minutes) => t('snooze', { count: minutes }), + snoozeShort: (minutes) => t('snooze_short', { count: minutes }), + open: t('open'), + join: t('join'), + missedTitle: (count) => t('missed_title', { count }), + missedMore: (count) => t('missed_more', { count }), + }; + const labelsRef = useRef(labels); + labelsRef.current = labels; + const calendarUrl = `${getPathPrefix(locale)}/${locale}/calendar`; + const calendarUrlRef = useRef(calendarUrl); + calendarUrlRef.current = calendarUrl; + const runnerRef = useRef | null>(null); + + useEffect(() => { + if (!signedIn || isPopoutWindow()) return; + const toastsByKey = new Map(); + + const inAppReminder = (account: string, alert: ReminderAlert) => { + const current = labelsRef.current; + const desktop = !!getDesktopBridge()?.os; + // Set when a button says what happens next, so closing the toast doesn't undo it. + let handled = false; + const snoozeAction = (minutes: number) => ({ + label: current.snooze(minutes), + onClick: () => { + handled = true; + void snoozeReminder(account, alert.key, minutes).catch(() => {}); + }, + }); + const [five, ten, fifteen] = SNOOZE_MINUTES.map(snoozeAction); + const id = addToast({ + type: 'info', + title: alert.title || current.untitled, + message: current.when(alert), + duration: reminderToastDuration(), + onClick: () => { + window.location.href = calendarUrlRef.current; + }, + onDismiss: () => { + toastsByKey.delete(alert.key); + void calendarHooks.onCalendarAlertAcknowledge.emit(acknowledgeView(alert)); + if (!handled) dismissReminders(account, [alert.key]); + }, + ...(desktop ? { + tertiaryAction: five, + secondaryAction: ten, + action: fifteen, + extraActions: [ + ...(alert.joinUrl ? [{ label: current.join, onClick: () => joinMeeting(alert.joinUrl!) }] : []), + { label: current.open, onClick: () => { window.location.href = calendarUrlRef.current; } }, + ], + } : {}), + }); + toastsByKey.set(alert.key, id); + }; + + const showReminder = async (account: string, alert: ReminderAlert) => { + const settings = useSettingsStore.getState(); + const sound = reminderSoundOn(settings.calendarNotificationSound); + const os = getDesktopBridge()?.os; + const hold = () => getStore().area(account, 'reminders.hold', { keys: [alert.key] }).catch(() => {}); + if (os) { + const locked = isAppLocked(); + const result = await os.notify(await buildReminderNotification(account, alert, labelsRef.current, sound)); + // Paused, quiet hours, or nothing may be said now: the summary says it later. + if (locked || result.reason === 'paused' || result.reason === 'quiet-hours') { + await hold(); + return; + } + if (result.reason === 'duplicate') return; + if (result.playSound) playNotificationSound(settings.notificationSoundChoice); + if (result.shown) return; + inAppReminder(account, alert); + return; + } + if (sound) playNotificationSound(settings.notificationSoundChoice); + reminderTitleAlert(tAlert('title_alert', { title: alert.title || labelsRef.current.untitled })); + inAppReminder(account, alert); + void showSystemReminder({ + key: alert.key, + title: alert.title || labelsRef.current.untitled, + body: labelsRef.current.when(alert), + silent: !sound, + }, calendarUrlRef.current); + }; + + const showMissed = async (account: string) => { + if (isAppLocked()) return; + const timeZone = reminderTimeZone(useSettingsStore.getState().timeZone); + const batch = await getStore().area(account, 'reminders.takeMissed', { timeZone }); + if (!batch) return; + const current = labelsRef.current; + const os = getDesktopBridge()?.os; + if (os) { + const result = await os.notify(await buildMissedNotification(account, batch, current)); + if (result.shown) return; + // Told nobody, so it has to wait for a time when it can. + if (result.reason === 'paused' || result.reason === 'quiet-hours' || result.reason === 'duplicate') { + await getStore().area(account, 'reminders.releaseMissed', { batch: batch.batch }); + return; + } + } + const keys = batch.alerts.map((alert) => alert.key); + let opened = false; + const body = missedBody(batch.alerts, current); + const id = addToast({ + type: 'info', + title: current.missedTitle(batch.alerts.length), + message: body, + duration: 0, + action: { + label: t('missed_show'), + onClick: () => { + opened = true; + useMissedReminders.getState().show(account, batch.alerts); + }, + }, + onDismiss: () => { + for (const key of keys) toastsByKey.delete(key); + if (!opened) dismissReminders(account, keys); + }, + }); + for (const key of keys) toastsByKey.set(key, id); + if (!os) { + void showSystemReminder({ + key: `missed:${batch.batch}`, + title: current.missedTitle(batch.alerts.length), + body, + silent: true, + }, calendarUrlRef.current); + } + }; + + const runner = createReminderRunner({ + accounts: () => visibleAccounts().filter((account) => offlineActive(account)), + store: () => getStore(), + params: () => { + const settings = useSettingsStore.getState(); + return { + timeZone: reminderTimeZone(settings.timeZone), + tasks: settings.enableCalendarTasks, + muted: !settings.calendarNotificationsEnabled, + }; + }, + hidden: () => document.visibilityState === 'hidden', + offline: () => navigator.onLine === false, + onDue: (account, alerts) => { + for (const alert of alerts) { + // Whichever of the two paths got there first: one comes back from a snooze. + const key = claimKey(account, alert.uid, alert.alertId, alert.fireTimeMs); + if (!claimNow(key, alert.fireTimeMs) && !alert.snoozedUntil) continue; + remind(viewOf(alert), () => { + void showReminder(account, alert).catch((error) => { + console.error('Failed to show a reminder:', error); + }); + }); + } + }, + onMissed: showMissed, + onSource: (account, driven) => useReminderSource.getState().setDriven(account, driven), + }); + runnerRef.current = runner; + setReminderPoke(() => runner.poke()); + runner.start(); + + const stopEvents = addStoreEventListener((event) => { + if (event.type === 'synced') runner.synced(event.account); + else if (event.type === 'changed' && event.collections.some((name) => name === 'CalendarEvent' || name === 'Calendar')) runner.poke(); + }); + const stopWake = addWakeHandler(() => runner.wake()); + const onOnline = () => runner.wake(); + const onVisible = () => { + if (document.visibilityState === 'visible') runner.poke(); + }; + window.addEventListener('online', onOnline); + document.addEventListener('visibilitychange', onVisible); + const stopAcks = onAcknowledged((keys) => { + for (const key of keys) { + const id = toastsByKey.get(key); + if (id) removeToast(id); + toastsByKey.delete(key); + } + void closeSystemReminders(keys); + }); + + return () => { + runner.stop(); + runnerRef.current = null; + setReminderPoke(null); + stopEvents(); + stopWake(); + stopAcks(); + window.removeEventListener('online', onOnline); + document.removeEventListener('visibilitychange', onVisible); + }; + // Labels go through a ref: a language change mustn't restart the check. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [signedIn]); + + // An account just turned on or off. + useEffect(() => { + runnerRef.current?.poke(); + }, [enabledKey]); +} diff --git a/lib/calendar-reminders/__tests__/claims.test.ts b/lib/calendar-reminders/__tests__/claims.test.ts new file mode 100644 index 000000000..862738746 --- /dev/null +++ b/lib/calendar-reminders/__tests__/claims.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { claimAlert, claimKey, claimNow, CLAIMS_STORAGE_KEY, HIDDEN_GRACE_MS } from '../claims'; + +const FIRE = Date.UTC(2026, 10, 10, 9); + +function setVisibility(state: 'visible' | 'hidden') { + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state }); +} + +/** A Web Lock: one holder at a time, across "tabs". */ +function fakeLocks() { + let tail: Promise = Promise.resolve(); + return { + request: vi.fn((_name: string, callback: () => T | Promise) => { + const run = tail.then(callback); + tail = run.catch(() => {}); + return run; + }), + }; +} + +beforeEach(() => { + localStorage.clear(); + setVisibility('visible'); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + Reflect.deleteProperty(navigator, 'locks'); +}); + +describe('claimNow', () => { + it('says yes once', () => { + const key = claimKey('acc', 'uid-1', 'a', FIRE); + expect(claimNow(key, FIRE)).toBe(true); + expect(claimNow(key, FIRE)).toBe(false); + }); + + it('tells occurrences and accounts apart', () => { + expect(claimNow(claimKey('acc', 'uid-1', 'a', FIRE), FIRE)).toBe(true); + expect(claimNow(claimKey('acc', 'uid-1', 'a', FIRE + 1000), FIRE + 1000)).toBe(true); + expect(claimNow(claimKey('other', 'uid-1', 'a', FIRE), FIRE)).toBe(true); + }); + + it('forgets what is a day old', () => { + vi.useFakeTimers({ now: FIRE }); + claimNow('old', FIRE - 25 * 3_600_000); + claimNow('new', FIRE); + expect(Object.keys(JSON.parse(localStorage.getItem(CLAIMS_STORAGE_KEY)!))).toEqual(['new']); + }); + + it('holds only the key, nothing of the event', () => { + claimNow(claimKey('acc', 'uid-1', 'a', FIRE), FIRE); + expect(localStorage.getItem(CLAIMS_STORAGE_KEY)).toBe(JSON.stringify({ [`acc:uid-1:a:${FIRE}`]: FIRE })); + }); +}); + +describe('claimAlert', () => { + it('lets one of two tabs through when their timers run out together', async () => { + Object.defineProperty(navigator, 'locks', { configurable: true, value: fakeLocks() }); + const won = vi.fn(); + claimAlert('k', FIRE, () => won('tab 1')); + claimAlert('k', FIRE, () => won('tab 2')); + await vi.waitFor(() => expect(won).toHaveBeenCalled()); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(won.mock.calls).toEqual([['tab 1']]); + }); + + it('runs at once where there is no Web Lock', () => { + const won = vi.fn(); + claimAlert('k', FIRE, won); + expect(won).toHaveBeenCalledTimes(1); + }); + + it('lets a tab in view go first', () => { + vi.useFakeTimers({ now: FIRE }); + setVisibility('hidden'); + const hidden = vi.fn(); + claimAlert('k', FIRE, hidden); + setVisibility('visible'); + const visible = vi.fn(); + claimAlert('k', FIRE, visible); + expect(visible).toHaveBeenCalledTimes(1); + vi.advanceTimersByTime(HIDDEN_GRACE_MS); + expect(hidden).not.toHaveBeenCalled(); + }); + + it('still fires in a hidden tab when no other took it', () => { + vi.useFakeTimers({ now: FIRE }); + setVisibility('hidden'); + const won = vi.fn(); + claimAlert('k', FIRE, won); + expect(won).not.toHaveBeenCalled(); + vi.advanceTimersByTime(HIDDEN_GRACE_MS); + expect(won).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/calendar-reminders/__tests__/runner.test.ts b/lib/calendar-reminders/__tests__/runner.test.ts new file mode 100644 index 000000000..fd45f0ad4 --- /dev/null +++ b/lib/calendar-reminders/__tests__/runner.test.ts @@ -0,0 +1,268 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CALENDAR_SESSION, FakeCalendarJmap } from '@/lib/store/__tests__/helpers/fake-calendar'; +import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store'; +import type { MissedBatch, ReminderAlert } from '@/lib/store/reminders/ledger'; +import { HIDDEN_GRACE_MS } from '../claims'; +import { createReminderRunner, SUMMARY_WAIT_MS, type ReminderRunner, type ReminderRunnerEnv } from '../runner'; + +const MIN = 60_000; +const ACCOUNT = 'acc'; +const START = Date.UTC(2026, 10, 10, 9); +const FIRE = START - 15 * MIN; +const ALERT = { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } }; + +function event(partial: Record = {}) { + return { id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', duration: 'PT30M', alerts: { a: ALERT }, ...partial }; +} + +interface Tab { + runner: ReminderRunner; + due: ReminderAlert[]; + firedAt: number[]; + missed: MissedBatch[]; + source: boolean[]; + env: ReminderRunnerEnv; + hidden: boolean; + offline: boolean; + muted: boolean; +} + +let store: InProcessStore; +let server: FakeCalendarJmap; +const tabs: Tab[] = []; + +function tab(options: Partial> = {}): Tab { + const made = { due: [], firedAt: [], missed: [], source: [], hidden: false, offline: false, muted: false, ...options } as unknown as Tab; + made.env = { + accounts: () => [ACCOUNT], + store: () => store, + params: () => ({ timeZone: 'Europe/Berlin', tasks: true, muted: made.muted }), + hidden: () => made.hidden, + offline: () => made.offline, + onDue: (_account, alerts) => { + made.due.push(...alerts); + made.firedAt.push(...alerts.map(() => Date.now())); + }, + onMissed: async (account) => { + const batch = await store.area(account, 'reminders.takeMissed', { timeZone: 'Europe/Berlin' }); + if (batch) made.missed.push(batch); + }, + onSource: (_account, driven) => { + made.source.push(driven); + }, + }; + made.runner = createReminderRunner(made.env); + tabs.push(made); + return made; +} + +async function syncedStore(...events: Record[]): Promise { + for (const partial of events) server.addEvent(partial); + await store.sync(ACCOUNT); +} + +beforeEach(() => { + vi.useFakeTimers({ now: FIRE - 30 * MIN, toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] }); + server = new FakeCalendarJmap('1.0'); + store = new InProcessStore(server, CALENDAR_SESSION); +}); + +afterEach(() => { + for (const made of tabs.splice(0)) made.runner.stop(); + vi.useRealTimers(); +}); + +describe('firing from the store', () => { + it('fires at the moment it is due, with the network gone', async () => { + await syncedStore(event()); + await store.setOnline(false); + server.request = (() => { + throw new Error('A reminder needs no server'); + }) as unknown as FakeCalendarJmap['request']; + const one = tab(); + one.runner.start(); + await vi.advanceTimersByTimeAsync(FIRE - Date.now() - 1000); + expect(one.due).toEqual([]); + await vi.advanceTimersByTimeAsync(1000); + expect(one.due.map((alert) => alert.title)).toEqual(['Standup']); + expect(one.firedAt).toEqual([FIRE]); + }); + + it('takes over from the old path once the store has synced, and gives it back when the store fails', async () => { + const one = tab(); + one.runner.start(); + await vi.advanceTimersByTimeAsync(0); + expect(one.source.at(-1)).toBe(false); + await syncedStore(event()); + one.runner.poke(); + await vi.advanceTimersByTimeAsync(0); + expect(one.source.at(-1)).toBe(true); + store.failArea = () => true; + one.runner.poke(); + await vi.advanceTimersByTimeAsync(0); + expect(one.source.at(-1)).toBe(false); + }); + + it('fires in one tab only, when both ask at the same moment', async () => { + await syncedStore(event()); + const [one, two] = [tab(), tab()]; + one.runner.start(); + two.runner.start(); + await vi.advanceTimersByTimeAsync(FIRE - Date.now() + MIN); + expect(one.due.length + two.due.length).toBe(1); + }); + + it('leaves it to the tab in view when another is hidden', async () => { + await syncedStore(event()); + const hidden = tab({ hidden: true }); + const visible = tab(); + hidden.runner.start(); + await vi.advanceTimersByTimeAsync(0); + visible.runner.start(); + await vi.advanceTimersByTimeAsync(FIRE - Date.now() + HIDDEN_GRACE_MS + MIN); + expect(visible.due).toHaveLength(1); + expect(hidden.due).toHaveLength(0); + }); + + it('still fires when only a hidden tab is left', async () => { + await syncedStore(event()); + const hidden = tab({ hidden: true }); + hidden.runner.start(); + await vi.advanceTimersByTimeAsync(FIRE - Date.now() + HIDDEN_GRACE_MS); + expect(hidden.due).toHaveLength(1); + }); + + it('notices an event that just arrived, and a snooze just set', async () => { + await syncedStore(); + const one = tab(); + one.runner.start(); + await vi.advanceTimersByTimeAsync(FIRE - Date.now() - 2 * MIN); + await syncedStore(event()); + one.runner.poke(); + await vi.advanceTimersByTimeAsync(2 * MIN); + expect(one.firedAt).toEqual([FIRE]); + + await store.area(ACCOUNT, 'reminders.snooze', { key: one.due[0].key, minutes: 5 }); + one.runner.poke(); + await vi.advanceTimersByTimeAsync(5 * MIN); + expect(one.firedAt).toEqual([FIRE, FIRE + 5 * MIN]); + }); + + it('brings a snoozed reminder back after a restart', async () => { + await syncedStore(event()); + const before = tab(); + before.runner.start(); + await vi.advanceTimersByTimeAsync(FIRE - Date.now()); + await store.area(ACCOUNT, 'reminders.snooze', { key: before.due[0].key, minutes: 10 }); + before.runner.stop(); + await vi.advanceTimersByTimeAsync(MIN); + + const after = tab(); + after.runner.start(); + await vi.advanceTimersByTimeAsync(10 * MIN); + expect(before.due).toHaveLength(1); + expect(after.firedAt).toEqual([FIRE + 10 * MIN]); + expect(after.due[0].snoozedUntil).toBe(FIRE + 10 * MIN); + }); + + it('stays quiet while reminders are off', async () => { + await syncedStore(event()); + const one = tab(); + one.muted = true; + one.runner.start(); + await vi.advanceTimersByTimeAsync(FIRE - Date.now() + MIN); + expect(one.due).toEqual([]); + }); +}); + +describe('after being away', () => { + async function awayFromBothAlerts(): Promise { + await syncedStore(event(), event({ id: 'e2', uid: 'uid-2', title: 'Review' })); + const before = tab(); + before.runner.start(); + await vi.advanceTimersByTimeAsync(0); + before.runner.stop(); + vi.setSystemTime(FIRE + 60 * MIN); + return before; + } + + it('waits a moment for the first sync, then summarises once, not as a burst', async () => { + await awayFromBothAlerts(); + const one = tab(); + one.runner.start(); + await vi.advanceTimersByTimeAsync(SUMMARY_WAIT_MS - 1000); + expect(one.missed).toEqual([]); + await vi.advanceTimersByTimeAsync(1000); + expect(one.due).toEqual([]); + expect(one.missed.map((batch) => batch.alerts.map((alert) => alert.title))).toEqual([['Standup', 'Review']]); + + await vi.advanceTimersByTimeAsync(30 * MIN); + one.runner.wake(); + await vi.advanceTimersByTimeAsync(SUMMARY_WAIT_MS); + expect(one.missed).toHaveLength(1); + }); + + it('does not wait once the sync is done, and has nothing to wait for offline', async () => { + await awayFromBothAlerts(); + const synced = tab(); + synced.runner.start(); + await vi.advanceTimersByTimeAsync(100); + synced.runner.synced(ACCOUNT); + await vi.advanceTimersByTimeAsync(0); + expect(synced.missed).toHaveLength(1); + }); + + it('gives the summary at once with no network', async () => { + await awayFromBothAlerts(); + const offline = tab({ offline: true }); + offline.runner.start(); + await vi.advanceTimersByTimeAsync(0); + expect(offline.missed).toHaveLength(1); + }); + + it('gives the summary to one window of two, and neither again after a restart', async () => { + await awayFromBothAlerts(); + const [one, two] = [tab({ offline: true }), tab({ offline: true })]; + one.runner.start(); + two.runner.start(); + await vi.advanceTimersByTimeAsync(0); + expect(one.missed.length + two.missed.length).toBe(1); + one.runner.stop(); + two.runner.stop(); + await vi.advanceTimersByTimeAsync(MIN); + const next = tab({ offline: true }); + next.runner.start(); + await vi.advanceTimersByTimeAsync(SUMMARY_WAIT_MS); + expect(next.missed).toEqual([]); + }); + + it('sees a jump of the clock without being told, as after a sleep', async () => { + await syncedStore(event(), event({ id: 'e2', uid: 'uid-2', title: 'Review' })); + const one = tab(); + one.runner.start(); + await vi.advanceTimersByTimeAsync(MIN); + // Asleep: timers stood still and the clock did not. + vi.setSystemTime(FIRE + 60 * MIN); + await vi.advanceTimersByTimeAsync(MIN + SUMMARY_WAIT_MS); + expect(one.due).toEqual([]); + expect(one.missed.map((batch) => batch.alerts.length)).toEqual([1 + 1]); + }); + + it('tries a summary again that could not be shown', async () => { + await awayFromBothAlerts(); + const one = tab({ offline: true }); + let refuse = true; + const plain = one.env.onMissed; + one.env.onMissed = async (account) => { + if (!refuse) return plain(account); + const batch = await store.area(account, 'reminders.takeMissed', { timeZone: 'Europe/Berlin' }); + await store.area(account, 'reminders.releaseMissed', { batch: batch.batch }); + }; + one.runner.start(); + await vi.advanceTimersByTimeAsync(0); + expect(one.missed).toEqual([]); + refuse = false; + await vi.advanceTimersByTimeAsync(5 * MIN); + expect(one.missed).toHaveLength(1); + }); +}); diff --git a/lib/calendar-reminders/channel.ts b/lib/calendar-reminders/channel.ts new file mode 100644 index 000000000..9e32f74ac --- /dev/null +++ b/lib/calendar-reminders/channel.ts @@ -0,0 +1,32 @@ +/** + * Telling the other windows that a reminder has been dealt with, so its toast and system + * notification go there too. What's done is recorded in the store; this only clears what's + * on screen. Only keys cross it, and they say nothing about the event but its uid. + */ + +const CHANNEL_NAME = 'bulwark-calendar-reminders'; + +interface AcknowledgedMessage { + kind: 'acknowledged'; + keys: string[]; +} + +function open(): BroadcastChannel | null { + return typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(CHANNEL_NAME); +} + +export function announceAcknowledged(keys: readonly string[]): void { + const channel = open(); + if (!channel) return; + channel.postMessage({ kind: 'acknowledged', keys: [...keys] } satisfies AcknowledgedMessage); + channel.close(); +} + +export function onAcknowledged(listener: (keys: string[]) => void): () => void { + const channel = open(); + if (!channel) return () => {}; + channel.onmessage = (event: MessageEvent) => { + if (event.data?.kind === 'acknowledged' && Array.isArray(event.data.keys)) listener(event.data.keys); + }; + return () => channel.close(); +} diff --git a/lib/calendar-reminders/claims.ts b/lib/calendar-reminders/claims.ts new file mode 100644 index 000000000..069660aea --- /dev/null +++ b/lib/calendar-reminders/claims.ts @@ -0,0 +1,70 @@ +/** + * One tab fires a reminder when there's no local store to decide it. The list sits in + * localStorage and holds no more than the event's uid, and the Web Lock makes reading + * and writing it one step across tabs: their timers run out at the same moment, so a + * plain read and write would let both through. + * + * Keys are the series' uid, never the id: synthetic ids reshuffle when an override + * changes, and a reminder fired again inside its ten minutes. + */ + +const STORAGE_KEY = 'calendar-alert-claims'; +const LOCK = 'bulwark-calendar-alert-claim'; +const KEEP_MS = 24 * 60 * 60 * 1000; +/** A tab out of view waits, so the one in view is who shows the reminder. */ +export const HIDDEN_GRACE_MS = 2000; + +export function claimKey(account: string | null | undefined, uid: string, alertId: string, fireTimeMs: number): string { + return `${account ?? ''}:${uid}:${alertId}:${fireTimeMs}`; +} + +function readClaims(): Record { + try { + const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as unknown; + return parsed && typeof parsed === 'object' ? parsed as Record : {}; + } catch { + return {}; + } +} + +/** True for the first to ask. Also what the store path tells the other path with. */ +export function claimNow(key: string, fireTimeMs: number): boolean { + const claims = readClaims(); + if (key in claims) return false; + const now = Date.now(); + const kept = Object.fromEntries(Object.entries(claims).filter(([, fire]) => now - fire < KEEP_MS)); + kept[key] = fireTimeMs; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(kept)); + } catch { + // Without storage each tab is on its own, as it always was. + } + return true; +} + +/** Runs `won` in the one tab that gets the reminder. At once, when nothing makes it wait. */ +export function claimAlert(key: string, fireTimeMs: number, won: () => void): void { + const attempt = () => { + const locks = typeof navigator === 'undefined' ? undefined : navigator.locks; + if (!locks) { + if (claimNow(key, fireTimeMs)) won(); + return; + } + void locks.request(LOCK, () => claimNow(key, fireTimeMs)).then((claimed) => { + if (claimed) won(); + }); + }; + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') setTimeout(attempt, HIDDEN_GRACE_MS); + else attempt(); +} + +/** A sign-out: the next person starts with a clean slate. */ +export function clearClaims(): void { + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + // Nothing was kept, then. + } +} + +export const CLAIMS_STORAGE_KEY = STORAGE_KEY; diff --git a/lib/calendar-reminders/runner.ts b/lib/calendar-reminders/runner.ts new file mode 100644 index 000000000..26bb9a569 --- /dev/null +++ b/lib/calendar-reminders/runner.ts @@ -0,0 +1,156 @@ +/** + * Asks the local store what's due, at the moment it's due. Runs in the page: in the + * desktop app that's the main window, which keeps its timers while hidden in the tray, + * and the store's worker answers with no network. Every tab or window may run one: the + * store hands each alert to whichever asks first. + * + * Between checks it sleeps until the store's next fire time, and looks in every few + * minutes in case something changed with nobody saying so. + */ + +import type { StoreBackend } from '@/lib/store/client/api'; +import type { ReminderAlert, TickParams, TickResult } from '@/lib/store/reminders/ledger'; +import { HIDDEN_GRACE_MS } from './claims'; + +const REFRESH_MS = 5 * 60_000; +/** Timers stand still while a machine sleeps. The clock doesn't. */ +const CLOCK_WATCH_MS = 60_000; +const CLOCK_JUMP_MS = 2 * CLOCK_WATCH_MS; +/** + * After a start or a wake, how long a summary waits for the first sync, so it leaves out + * what was deleted elsewhere in the meantime. Reminders that are due don't wait. + */ +export const SUMMARY_WAIT_MS = 20_000; + +export interface ReminderRunnerEnv { + /** The local accounts with a store right now. */ + accounts(): string[]; + store(): Pick; + params(): TickParams; + /** Out of view: another tab in view should get to go first. */ + hidden(): boolean; + offline(): boolean; + /** Runs in the one tab that got these. */ + onDue(account: string, alerts: ReminderAlert[]): void; + /** There's a summary to give. Resolves when that's been tried. */ + onMissed(account: string): Promise; + /** Whether this account's reminders come from the store now, or the app's old path keeps them. */ + onSource(account: string, driven: boolean): void; +} + +export interface ReminderRunner { + start(): void; + stop(): void; + /** Check now: the calendar changed, a snooze was set. */ + poke(): void; + /** Back from sleep, or back online. */ + wake(): void; + /** The store finished a sync for this account. */ + synced(account: string): void; +} + +export function createReminderRunner(env: ReminderRunnerEnv): ReminderRunner { + let timer: ReturnType | null = null; + let clockWatch: ReturnType | null = null; + let lastWatch = 0; + let running = false; + let again = false; + let stopped = true; + let summaryAt = 0; + const presenting = new Set(); + const known = new Set(); + + const schedule = (at: number) => { + if (timer) clearTimeout(timer); + const wait = Math.max(at - Date.now(), 0) + (env.hidden() ? HIDDEN_GRACE_MS : 0); + timer = setTimeout(() => void run(), wait); + }; + + const check = async (account: string): Promise => { + let result: TickResult; + try { + result = await env.store().area(account, 'reminders.tick', env.params()); + } catch { + // The store is out of reach: the old path takes these over until it's back. + env.onSource(account, false); + return null; + } + env.onSource(account, result.ready); + if (result.due.length > 0) env.onDue(account, result.due); + if (result.missed === 0) return result.nextAt; + const wait = env.offline() ? 0 : summaryAt - Date.now(); + if (wait > 0) return result.nextAt === null ? summaryAt : Math.min(result.nextAt, summaryAt); + if (!presenting.has(account)) { + presenting.add(account); + void env.onMissed(account) + .catch(() => {}) + .finally(() => presenting.delete(account)); + } + return result.nextAt; + }; + + const run = async () => { + if (stopped) return; + if (running) { + again = true; + return; + } + running = true; + let next = Date.now() + REFRESH_MS; + const accounts = env.accounts(); + for (const gone of [...known]) { + if (!accounts.includes(gone)) { + known.delete(gone); + env.onSource(gone, false); + } + } + for (const account of accounts) { + known.add(account); + const at = await check(account); + if (at !== null) next = Math.min(next, at); + } + running = false; + if (stopped) return; + if (again) { + again = false; + void run(); + return; + } + schedule(next); + }; + + return { + start() { + stopped = false; + summaryAt = Date.now() + SUMMARY_WAIT_MS; + lastWatch = Date.now(); + clockWatch = setInterval(() => { + const now = Date.now(); + const jumped = now - lastWatch > CLOCK_JUMP_MS; + lastWatch = now; + if (jumped) this.wake(); + }, CLOCK_WATCH_MS); + void run(); + }, + stop() { + stopped = true; + if (timer) clearTimeout(timer); + if (clockWatch) clearInterval(clockWatch); + timer = null; + clockWatch = null; + for (const account of known) env.onSource(account, false); + known.clear(); + }, + poke() { + void run(); + }, + wake() { + summaryAt = Date.now() + SUMMARY_WAIT_MS; + void run(); + }, + synced() { + summaryAt = 0; + void run(); + }, + }; +} diff --git a/lib/calendar-reminders/state.ts b/lib/calendar-reminders/state.ts new file mode 100644 index 000000000..109df52ee --- /dev/null +++ b/lib/calendar-reminders/state.ts @@ -0,0 +1,55 @@ +/** + * What the reminder code shares across the page, kept free of the rest of the app so + * useCalendarAlerts can read it without pulling in the local store. + */ + +import { create } from 'zustand'; +import type { ReminderAlert } from '@/lib/store/reminders/ledger'; + +interface ReminderSourceState { + /** Local accounts whose reminders come from the store: useCalendarAlerts leaves those alone. */ + driven: Record; + setDriven: (account: string, driven: boolean) => void; +} + +export const useReminderSource = create()((set) => ({ + driven: {}, + setDriven: (account, driven) => set((state) => { + if (!!state.driven[account] === driven) return state; + const next = { ...state.driven }; + if (driven) next[account] = true; + else delete next[account]; + return { driven: next }; + }), +})); + +interface MissedRemindersState { + account: string | null; + alerts: ReminderAlert[]; + show: (account: string, alerts: ReminderAlert[]) => void; + close: () => void; +} + +/** The summary dialog's contents. Empty: it's closed. */ +export const useMissedReminders = create()((set) => ({ + account: null, + alerts: [], + show: (account, alerts) => set({ account, alerts }), + close: () => set({ account: null, alerts: [] }), +})); + +let poke: (() => void) | null = null; + +/** The running check, so something done from a toast takes effect at once. */ +export function setReminderPoke(next: (() => void) | null): void { + poke = next; +} + +export function pokeReminders(): void { + poke?.(); +} + +/** The zone that reads a floating event: the setting's, or the system's on `auto`. */ +export function reminderTimeZone(setting: string | undefined): string { + return setting && setting !== 'auto' ? setting : Intl.DateTimeFormat().resolvedOptions().timeZone; +} diff --git a/lib/calendar-reminders/system-notification.ts b/lib/calendar-reminders/system-notification.ts new file mode 100644 index 000000000..538669c1b --- /dev/null +++ b/lib/calendar-reminders/system-notification.ts @@ -0,0 +1,70 @@ +/** + * A browser's system notification for a reminder, where the user has already granted the + * permission. This never asks for it. Through the service worker where there is one, since + * Chrome on Android refuses `new Notification`. The tag keeps one per reminder, whichever + * tab asks. + */ + +export interface SystemReminder { + key: string; + title: string; + body: string; + /** Do not disturb: on screen, but quiet. */ + silent: boolean; +} + +function tagOf(key: string): string { + return `calendar-reminder:${key}`; +} + +function permitted(): boolean { + return typeof Notification !== 'undefined' && Notification.permission === 'granted'; +} + +async function registration(): Promise { + try { + return await navigator.serviceWorker?.getRegistration(); + } catch { + return undefined; + } +} + +/** In front of the user the in-app toast says it already. */ +export function wantsSystemNotification(): boolean { + if (!permitted() || typeof document === 'undefined') return false; + return document.visibilityState === 'hidden' || !document.hasFocus(); +} + +export async function showSystemReminder(reminder: SystemReminder, openUrl: string): Promise { + if (!wantsSystemNotification()) return; + const options = { body: reminder.body, tag: tagOf(reminder.key), silent: reminder.silent }; + try { + const worker = await registration(); + if (worker) { + await worker.showNotification(reminder.title, { ...options, data: { kind: 'calendar-reminder' } }); + return; + } + const shown = new Notification(reminder.title, options); + shown.onclick = () => { + window.focus(); + window.location.href = openUrl; + shown.close(); + }; + } catch (error) { + console.warn('A system notification could not be shown:', error); + } +} + +/** Dealt with elsewhere: it shouldn't stay in the notification list. */ +export async function closeSystemReminders(keys: readonly string[]): Promise { + if (!permitted() || keys.length === 0) return; + const worker = await registration(); + if (!worker) return; + try { + for (const key of keys) { + for (const shown of await worker.getNotifications({ tag: tagOf(key) })) shown.close(); + } + } catch { + // Nothing that matters is left behind. + } +} diff --git a/lib/desktop/__tests__/reminder-notifications.test.ts b/lib/desktop/__tests__/reminder-notifications.test.ts new file mode 100644 index 000000000..1a19e29d2 --- /dev/null +++ b/lib/desktop/__tests__/reminder-notifications.test.ts @@ -0,0 +1,239 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useMissedReminders } from '@/lib/calendar-reminders/state'; +import { CALENDAR_SESSION, FakeCalendarJmap } from '@/lib/store/__tests__/helpers/fake-calendar'; +import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store'; +import { setStoreBackend } from '@/lib/store/client/api'; +import type { ReminderAlert, TickResult } from '@/lib/store/reminders/ledger'; +import { setAppLockCheck } from '../app-lock-state'; +import { + buildMissedNotification, + buildReminderNotification, + handleReminderCommand, + missedBody, + notifyWithoutStore, + type ReminderLabels, +} from '../reminder-notifications'; +import type { BulwarkDesktopBridge } from '../types'; + +const ACCOUNT = 'alice@one'; +const accounts = [{ id: ACCOUNT, cookieSlot: 3, serverUrl: 'https://one.example', username: 'alice@example.org' }]; +let activeAccountId: string | null = ACCOUNT; +vi.mock('@/stores/account-store', () => ({ useAccountStore: { getState: () => ({ accounts }) } })); +vi.mock('@/stores/auth-store', () => ({ useAuthStore: { getState: () => ({ activeAccountId }) } })); + +const policy = vi.hoisted(() => ({ pushWithoutContent: false })); +vi.mock('@/lib/device-policy', () => ({ devicesForAccount: () => ({ pushWithoutContent: policy.pushWithoutContent }) })); + +const MIN = 60_000; +const START = Date.UTC(2026, 10, 10, 9); +const FIRE = START - 15 * MIN; + +const labels: ReminderLabels = { + generic: 'GENERIC', + when: () => 'In 15 min · Personal', + untitled: 'UNTITLED', + snooze: (minutes) => `Snooze ${minutes} min`, + snoozeShort: (minutes) => `${minutes} min`, + open: 'Open', + join: 'Join', + missedTitle: (count) => `${count} missed`, + missedMore: (count) => `and ${count} more`, +}; + +function alert(partial: Partial = {}): ReminderAlert { + return { + key: `j1:uid-1:a:${FIRE}`, jmap: 'j1', uid: 'uid-1', kind: 'event', baseEventId: 'e1', recurrenceId: null, alertId: 'a', fireTimeMs: FIRE, + title: 'Secret board meeting', start: '2026-11-10T10:00:00', startMs: START, joinUrl: null, timeZone: 'Europe/Berlin', calendarId: 'cal1', calendarName: 'Personal', + ...partial, + }; +} + +const focus = vi.fn(); +const openExternal = vi.fn(); +const notify = vi.fn(async () => ({ shown: true, playSound: false })); + +function setPlatform(platform: string) { + window.bulwarkDesktop = { version: 1, platform, windows: { focus }, shell: { openExternal }, os: { notify } } as unknown as BulwarkDesktopBridge; +} + +let store: InProcessStore; +let server: FakeCalendarJmap; +const env = { leaveFor: vi.fn(), push: vi.fn() }; + +beforeEach(() => { + vi.clearAllMocks(); + policy.pushWithoutContent = false; + activeAccountId = ACCOUNT; + setAppLockCheck(null); + setPlatform('win32'); + useMissedReminders.getState().close(); + server = new FakeCalendarJmap('1.0'); + store = new InProcessStore(server, CALENDAR_SESSION); + setStoreBackend(store); +}); + +afterEach(() => { + setStoreBackend(null); + window.bulwarkDesktop = undefined; + setAppLockCheck(null); + vi.useRealTimers(); +}); + +async function contextFor(reminder: ReminderAlert): Promise { + return (await buildReminderNotification(ACCOUNT, reminder, labels, false)).context; +} + +describe('what a reminder says', () => { + it('offers Join only when the event has a link', async () => { + const without = await buildReminderNotification(ACCOUNT, alert(), labels, false); + expect(without.actions!.map((action) => action.id)).toEqual(['open', 'snooze-5', 'snooze-10', 'snooze-15']); + const withLink = await buildReminderNotification(ACCOUNT, alert({ joinUrl: 'https://meet.jit.si/x' }), labels, false); + expect(withLink.actions!.map((action) => action.id)).toEqual(['join', 'open', 'snooze-5', 'snooze-10', 'snooze-15']); + }); + + it('uses short snooze labels where five buttons share a row', async () => { + const windows = await buildReminderNotification(ACCOUNT, alert(), labels, false); + expect(windows.actions!.find((action) => action.id === 'snooze-5')!.label).toBe('5 min'); + setPlatform('darwin'); + const mac = await buildReminderNotification(ACCOUNT, alert(), labels, false); + expect(mac.actions!.find((action) => action.id === 'snooze-5')!.label).toBe('Snooze 5 min'); + }); + + it('sends nothing of the event while the app is locked: no title, no time, no key, no buttons', async () => { + setAppLockCheck(() => true); + const locked = await buildReminderNotification(ACCOUNT, alert({ joinUrl: 'https://meet.jit.si/x' }), labels, true); + expect(locked).toMatchObject({ title: 'GENERIC', body: '', context: '', kind: 'reminder' }); + expect(locked.actions).toBeUndefined(); + expect(JSON.stringify(locked)).not.toContain('Secret'); + expect(JSON.stringify(locked)).not.toContain('uid-1'); + }); + + it('keeps the title off when the organisation says so, the buttons stay', async () => { + policy.pushWithoutContent = true; + const hidden = await buildReminderNotification(ACCOUNT, alert(), labels, false); + expect(hidden).toMatchObject({ title: 'GENERIC', body: '' }); + expect(hidden.actions).toHaveLength(4); + }); + + it('gets a new id when it comes back from a snooze, since main drops a repeat', async () => { + const first = await buildReminderNotification(ACCOUNT, alert(), labels, false); + const back = await buildReminderNotification(ACCOUNT, alert({ snoozedUntil: FIRE + 5 * MIN }), labels, false); + expect(back.id).not.toBe(first.id); + }); + + it('falls back on a neutral title for an event without one', async () => { + expect((await buildReminderNotification(ACCOUNT, alert({ title: '' }), labels, false)).title).toBe('UNTITLED'); + }); + + it('keeps titles out of a summary when locked or forbidden, and names three at most', async () => { + const alerts = ['A', 'B', 'C', 'D', 'E'].map((title) => alert({ title, key: title })); + expect(missedBody(alerts, labels)).toBe('A, B, C, and 2 more'); + const open = await buildMissedNotification(ACCOUNT, { batch: 7, alerts }, labels); + expect(open).toMatchObject({ title: '5 missed', body: 'A, B, C, and 2 more' }); + setAppLockCheck(() => true); + expect((await buildMissedNotification(ACCOUNT, { batch: 7, alerts }, labels)).body).toBe(''); + }); + + it('without a store says what it can, and nothing when locked', () => { + notifyWithoutStore('id', 'Dentist', 'In 5 min', { generic: 'GENERIC' }, ACCOUNT); + expect(notify).toHaveBeenLastCalledWith(expect.objectContaining({ title: 'Dentist', body: 'In 5 min', context: '{"cal":true}' })); + setAppLockCheck(() => true); + notifyWithoutStore('id2', 'Dentist', 'In 5 min', { generic: 'GENERIC' }, ACCOUNT); + expect(notify).toHaveBeenLastCalledWith(expect.objectContaining({ title: 'GENERIC', body: '' })); + }); +}); + +describe('what its buttons do', () => { + /** A reminder really on record in the store. */ + async function fired(partial: Record = {}): Promise { + vi.useFakeTimers({ now: FIRE - MIN, toFake: ['Date'] }); + server.addEvent({ id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } }, ...partial }); + await store.sync(ACCOUNT); + const params = { timeZone: 'Europe/Berlin', tasks: true }; + await store.area(ACCOUNT, 'reminders.tick', params); + vi.setSystemTime(FIRE); + return (await store.area(ACCOUNT, 'reminders.tick', params)).due[0]; + } + + it('snoozes from the store with the window left hidden', async () => { + const reminder = await fired(); + expect(await handleReminderCommand('snooze-10', await contextFor(reminder), env)).toBe(true); + expect(focus).not.toHaveBeenCalled(); + expect(env.leaveFor).not.toHaveBeenCalled(); + const params = { timeZone: 'Europe/Berlin', tasks: true }; + vi.setSystemTime(FIRE + 9 * MIN); + expect((await store.area(ACCOUNT, 'reminders.tick', params)).due).toEqual([]); + vi.setSystemTime(FIRE + 10 * MIN); + expect((await store.area(ACCOUNT, 'reminders.tick', params)).due).toHaveLength(1); + }); + + it('refuses a snooze length no button offers', async () => { + const reminder = await fired(); + await handleReminderCommand('snooze-999', await contextFor(reminder), env); + expect(focus).toHaveBeenCalled(); + const params = { timeZone: 'Europe/Berlin', tasks: true }; + vi.setSystemTime(FIRE + 16 * MIN); + expect((await store.area(ACCOUNT, 'reminders.tick', params)).due).toEqual([]); + }); + + it('joins through the OS from what the store holds, with the window left hidden', async () => { + const reminder = await fired({ virtualLocations: { v: { '@type': 'VirtualLocation', uri: 'https://meet.jit.si/standup' } } }); + await handleReminderCommand('join', await contextFor(reminder), env); + expect(openExternal).toHaveBeenCalledWith('https://meet.jit.si/standup'); + expect(focus).not.toHaveBeenCalled(); + }); + + it('opens the event in the account on screen', async () => { + const reminder = await fired(); + await handleReminderCommand('open', await contextFor(reminder), env); + expect(focus).toHaveBeenCalled(); + expect(env.leaveFor).toHaveBeenCalledWith('/calendar'); + expect(env.push).toHaveBeenCalledWith('/calendar/event/e1?calendar=cal1'); + }); + + it('opens only the calendar for an account that is not on screen', async () => { + const reminder = await fired(); + const context = await contextFor(reminder); + activeAccountId = 'someone-else'; + await handleReminderCommand('open', context, env); + expect(env.leaveFor).toHaveBeenCalledWith('/calendar'); + expect(env.push).not.toHaveBeenCalled(); + }); + + it('touches nothing when the slot has gone to another login since', async () => { + const reminder = await fired(); + const context = JSON.parse(await contextFor(reminder)) as { login: string }; + context.login = 'not-this-login'; + await handleReminderCommand('snooze-5', JSON.stringify(context), env); + expect(env.leaveFor).toHaveBeenCalledWith('/calendar'); + const params = { timeZone: 'Europe/Berlin', tasks: true }; + vi.setSystemTime(FIRE + 5 * MIN); + expect((await store.area(ACCOUNT, 'reminders.tick', params)).due).toEqual([]); + }); + + it('opens the summary from a click on its toast, even after a restart', async () => { + vi.useFakeTimers({ now: FIRE - MIN, toFake: ['Date'] }); + server.addEvent({ id: 'e1', uid: 'uid-1', title: 'One', alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } } }); + server.addEvent({ id: 'e2', uid: 'uid-2', title: 'Two', alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } } }); + await store.sync(ACCOUNT); + const params = { timeZone: 'Europe/Berlin', tasks: true }; + await store.area(ACCOUNT, 'reminders.tick', params); + vi.setSystemTime(FIRE + 60 * MIN); + await store.area(ACCOUNT, 'reminders.tick', params); + const batch = await store.area<{ batch: number; alerts: ReminderAlert[] }>(ACCOUNT, 'reminders.takeMissed', params); + const toast = await buildMissedNotification(ACCOUNT, batch, labels); + + await handleReminderCommand('open', toast.context, env); + expect(focus).toHaveBeenCalled(); + expect(useMissedReminders.getState().alerts.map((a) => a.title)).toEqual(['One', 'Two']); + expect(useMissedReminders.getState().account).toBe(ACCOUNT); + }); + + it('goes to the calendar for a store-less reminder, and leaves mail toasts alone', async () => { + expect(await handleReminderCommand('open', JSON.stringify({ cal: true }), env)).toBe(true); + expect(env.leaveFor).toHaveBeenCalledWith('/calendar'); + expect(await handleReminderCommand('open', JSON.stringify({ emailId: 'm1', slot: 3 }), env)).toBe(false); + expect(await handleReminderCommand('open', '', env)).toBe(false); + expect(await handleReminderCommand('open', '{broken', env)).toBe(false); + }); +}); diff --git a/lib/desktop/os-commands.ts b/lib/desktop/os-commands.ts index 8c7332225..3a6fa8703 100644 --- a/lib/desktop/os-commands.ts +++ b/lib/desktop/os-commands.ts @@ -8,6 +8,7 @@ import { getDesktopBridge } from '@/lib/desktop/bridge'; import type { DesktopOsCommand } from '@/lib/desktop/types'; import { openMailLink } from '@/lib/desktop/mail-links'; +import { handleReminderCommand } from '@/lib/desktop/reminder-notifications'; import type { MailNotificationContext } from '@/lib/desktop/mail-notifications'; import { handOffDraft, handOffFiles, handOffNewMessage, kindForFile } from '@/lib/launch-handoff'; import { parseMailto } from '@/lib/protocol-handlers/mailto'; @@ -89,6 +90,7 @@ function runAction(request: NotificationActionRequest): Promise { + if (await handleReminderCommand(action, rawContext, env)) return; const context = parseContext(rawContext); if (action === 'reply') { await openFromNotification(context, env, { text: reply ?? '' }); diff --git a/lib/desktop/reminder-notifications.ts b/lib/desktop/reminder-notifications.ts new file mode 100644 index 000000000..3979c1d96 --- /dev/null +++ b/lib/desktop/reminder-notifications.ts @@ -0,0 +1,218 @@ +/** + * Calendar reminders as native notifications, and what their buttons do. + * + * A toast may be clicked a day later and after a restart, so what it hands back says + * which login and which reminder and no more: the rest comes from the store. Main keeps + * pause, quiet hours and the OS's Do Not Disturb. Nothing about the event goes to main at + * all while the app is locked, or when the organisation says so for that login + * (pushWithoutContent, the rule new mail follows). + */ + +import { announceAcknowledged } from '@/lib/calendar-reminders/channel'; +import { pokeReminders, useMissedReminders } from '@/lib/calendar-reminders/state'; +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { isAppLocked } from '@/lib/desktop/app-lock-state'; +import type { DesktopOsNotification } from '@/lib/desktop/types'; +import { devicesForAccount } from '@/lib/device-policy'; +import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; +import { getStore } from '@/lib/store/client/api'; +import type { MissedBatch, ReminderAlert } from '@/lib/store/reminders/ledger'; +import { useAccountStore } from '@/stores/account-store'; +import { useAuthStore } from '@/stores/auth-store'; + +export const SNOOZE_MINUTES = [5, 10, 15] as const; +const SNOOZE_PREFIX = 'snooze-'; +const JOIN = 'join'; +const OPEN = 'open'; +/** How many titles a summary names. */ +const SUMMARY_TITLES = 3; + +export interface ReminderLabels { + /** Says nothing about the event. */ + generic: string; + /** "In 10 min · Personal". */ + when: (alert: ReminderAlert) => string; + untitled: string; + snooze: (minutes: number) => string; + /** Five buttons in a row on a Windows toast: three "Snooze..." would all cut off. */ + snoozeShort: (minutes: number) => string; + open: string; + join: string; + missedTitle: (count: number) => string; + missedMore: (count: number) => string; +} + +export interface ReminderToastContext { + /** The reminder. */ + r?: string; + /** The summary. */ + rb?: number; + /** No store to ask: the click only goes to the calendar. */ + cal?: boolean; + slot?: number; + login?: string; +} + +async function loginOf(account: string): Promise> { + const entry = useAccountStore.getState().accounts.find((candidate) => candidate.id === account); + if (!entry) return {}; + return { slot: entry.cookieSlot, login: await loginFingerprint(entry.serverUrl, entry.username) }; +} + +function hideContent(account: string): boolean { + if (isAppLocked()) return true; + try { + return devicesForAccount(account).pushWithoutContent; + } catch { + return true; + } +} + +function reminderId(alert: ReminderAlert): string { + // Main drops an id it saw in the last few minutes, and a snooze can be shorter. + return `reminder:${alert.key}${alert.snoozedUntil ? `:${alert.snoozedUntil}` : ''}`; +} + +export async function buildReminderNotification(account: string, alert: ReminderAlert, labels: ReminderLabels, sound: boolean): Promise { + const base = { group: 'reminders', kind: 'reminder' as const, sound }; + // Not even the uid. Two at the same moment are one "you have a reminder". + if (isAppLocked()) return { ...base, id: `reminder-locked:${alert.fireTimeMs}`, title: labels.generic, body: '', context: '' }; + const context: ReminderToastContext = { r: alert.key, ...(await loginOf(account)) }; + const short = getDesktopBridge()?.platform === 'win32'; + const actions = [ + ...(alert.joinUrl ? [{ id: JOIN, label: labels.join }] : []), + { id: OPEN, label: labels.open }, + ...SNOOZE_MINUTES.map((minutes) => ({ id: `${SNOOZE_PREFIX}${minutes}`, label: short ? labels.snoozeShort(minutes) : labels.snooze(minutes) })), + ]; + const hidden = hideContent(account); + return { + ...base, + id: reminderId(alert), + title: hidden ? labels.generic : alert.title || labels.untitled, + body: hidden ? '' : labels.when(alert), + context: JSON.stringify(context), + actions, + }; +} + +/** The first few titles of a summary, for a place too small for all of them. */ +export function missedBody(alerts: readonly ReminderAlert[], labels: Pick): string { + const named = alerts.slice(0, SUMMARY_TITLES).map((alert) => alert.title || labels.untitled).join(', '); + const more = alerts.length - SUMMARY_TITLES; + return more > 0 ? `${named}, ${labels.missedMore(more)}` : named; +} + +export async function buildMissedNotification(account: string, batch: MissedBatch, labels: ReminderLabels): Promise { + const context: ReminderToastContext = { rb: batch.batch, ...(await loginOf(account)) }; + return { + id: `reminder-missed:${batch.batch}`, + group: 'reminders', + kind: 'reminder', + title: labels.missedTitle(batch.alerts.length), + body: hideContent(account) ? '' : missedBody(batch.alerts, labels), + context: JSON.stringify(context), + }; +} + +/** Without a store: says so, with nothing to snooze. A click goes to the calendar. */ +export function notifyWithoutStore(id: string, title: string, body: string, labels: { generic: string }, account: string | null): void { + const hidden = isAppLocked() || !account || hideContent(account); + void getDesktopBridge()?.os?.notify({ + id, + group: 'reminders', + kind: 'reminder', + title: hidden ? labels.generic : title, + body: hidden ? '' : body, + context: JSON.stringify({ cal: true } satisfies ReminderToastContext), + }); +} + +async function accountOf(context: ReminderToastContext): Promise { + if (context.slot === undefined || !context.login) return null; + const entry = useAccountStore.getState().accounts.find((account) => account.cookieSlot === context.slot); + if (!entry || (await loginFingerprint(entry.serverUrl, entry.username)) !== context.login) return null; + return entry.id; +} + +/** Goes to the event when it's in the account on screen, otherwise to the calendar. */ +export function calendarPathFor(account: string, alert: ReminderAlert | null): string | null { + if (!alert || alert.kind !== 'event' || useAuthStore.getState().activeAccountId !== account) return null; + const query = alert.calendarId ? `?calendar=${encodeURIComponent(alert.calendarId)}` : ''; + return `/calendar/event/${encodeURIComponent(alert.baseEventId)}${query}`; +} + +export function joinMeeting(url: string): void { + const shell = getDesktopBridge()?.shell; + if (shell) shell.openExternal(url); + else window.open(url, '_blank', 'noopener,noreferrer'); +} + +export function dismissReminders(account: string, keys: string[]): void { + announceAcknowledged(keys); + void getStore().area(account, 'reminders.dismiss', { keys }).catch(() => {}); +} + +export async function snoozeReminder(account: string, key: string, minutes: number): Promise { + announceAcknowledged([key]); + await getStore().area(account, 'reminders.snooze', { key, minutes }); + pokeReminders(); +} + +export interface ReminderCommandEnv { + leaveFor: (path: '/calendar') => void | Promise; + push: (path: string) => void; +} + +function parse(raw: string): ReminderToastContext | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as ReminderToastContext; + return parsed && typeof parsed === 'object' && (parsed.r !== undefined || parsed.rb !== undefined || parsed.cal) ? parsed : null; + } catch { + return null; + } +} + +function snoozeMinutes(action: string): number | null { + if (!action.startsWith(SNOOZE_PREFIX)) return null; + const minutes = Number(action.slice(SNOOZE_PREFIX.length)); + return (SNOOZE_MINUTES as readonly number[]).includes(minutes) ? minutes : null; +} + +/** Whether this click was a reminder's. Buttons that only do something don't bring the window up. */ +export async function handleReminderCommand(action: string, rawContext: string, env: ReminderCommandEnv): Promise { + const context = parse(rawContext); + if (!context) return false; + const account = await accountOf(context); + const show = () => getDesktopBridge()?.windows?.focus?.(); + + if (context.rb !== undefined) { + show(); + const alerts = account ? await getStore().area(account, 'reminders.batch', { batch: context.rb }).catch(() => []) : []; + if (account && alerts.length > 0) useMissedReminders.getState().show(account, alerts); + else await env.leaveFor('/calendar'); + return true; + } + + const key = context.r; + const minutes = snoozeMinutes(action); + if (minutes !== null && account && key) { + await snoozeReminder(account, key, minutes); + return true; + } + + const alert = account && key ? await getStore().area(account, 'reminders.get', { key }).catch(() => null) : null; + if (action === JOIN && alert?.joinUrl) { + joinMeeting(alert.joinUrl); + dismissReminders(account!, [alert.key]); + return true; + } + + // Open, a click on the toast, or a button that can't be carried out any more. + show(); + await env.leaveFor('/calendar'); + const path = account ? calendarPathFor(account, alert) : null; + if (path) env.push(path); + if (account && alert) dismissReminders(account, [alert.key]); + return true; +} diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index 696ab3183..249a45cba 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -235,6 +235,8 @@ export interface DesktopOsNotification { sound?: boolean; /** New mail: flash the taskbar or bounce the dock if that's on. */ attention?: boolean; + /** What a locked app says instead: new mail unless this says otherwise. */ + kind?: 'mail' | 'reminder'; } export interface DesktopOsNotifyResult { diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index d71993589..77122dd0e 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -30,6 +30,7 @@ import { labelsPolicyFor } from '@/lib/outgoing/classification'; import { getSendGuardSource, guardState, guardsSending } from '@/lib/plugin-send-guards'; import { emitPushStateChange } from '@/lib/push-events'; import { requestOutboxSync } from '@/lib/pwa-notify/outbox-sync'; +import { emitStoreEvent } from '@/lib/offline/store-events'; import { STORAGE_ERROR_AREA } from '@/lib/store/area'; import type { DraftError } from '@/lib/store/drafts/pending'; import { configureStore, getStore, type StorePageHooks } from '@/lib/store/client/api'; @@ -197,6 +198,7 @@ const storeHooks: StorePageHooks = { notify({ kind: 'store-reset' }); }, onEvent(event) { + emitStoreEvent(event); if (event.type === 'created' && event.collection === 'Email') { const client = onlineClientFor(event.account); if (client) reportStoreCreatedMail(client, event.jmap, event.ids); diff --git a/lib/offline/store-events.ts b/lib/offline/store-events.ts new file mode 100644 index 000000000..b857ec351 --- /dev/null +++ b/lib/offline/store-events.ts @@ -0,0 +1,18 @@ +/** The store's events, for the parts of the page that aren't offline mode itself. */ + +import type { StoreEvent } from '@/lib/store/area'; + +type Listener = (event: StoreEvent) => void; + +const listeners = new Set(); + +export function addStoreEventListener(listener: Listener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function emitStoreEvent(event: StoreEvent): void { + for (const listener of [...listeners]) listener(event); +} diff --git a/lib/sign-out-cleanup.ts b/lib/sign-out-cleanup.ts index 531c99f7f..2400d48f3 100644 --- a/lib/sign-out-cleanup.ts +++ b/lib/sign-out-cleanup.ts @@ -21,6 +21,7 @@ const SIGNED_OUT_STORAGE_KEYS = [ 'contact-storage', 'calendar-storage', 'calendar-notification-storage', + 'calendar-alert-claims', // Open tabs name messages and their subjects. 'pro-tabs', 'search-history-storage', diff --git a/locales/ar/common.json b/locales/ar/common.json index 169c04ec5..05c769ae3 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "نسخ كملف", "copied": "تم نسخ الملف. يمكنك لصقه في مجلد.", "copy_failed": "تعذّر نسخ الملف." + }, + "desktopReminders": { + "generic_title": "تذكير", + "snooze": "تأجيل {count} د", + "snooze_short": "{count} د", + "open": "فتح", + "join": "انضمام", + "missed_title": "{count, plural, zero {لا تذكيرات فائتة} one {تذكير فائت واحد} two {تذكيران فائتان} few {# تذكيرات فائتة} many {# تذكيرًا فائتًا} other {# تذكير فائت}}", + "missed_more": "و{count} أخرى", + "missed_show": "عرض", + "missed_dialog_title": "التذكيرات الفائتة", + "missed_dialog_description": "حان موعد هذه التذكيرات بينما كان التطبيق مغلقًا أو الكمبيوتر في وضع السكون أو الإشعارات متوقفة مؤقتًا.", + "open_calendar": "فتح التقويم", + "done": "تم" } } diff --git a/locales/ca/common.json b/locales/ca/common.json index 5dd448d8e..b3a56b969 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Copia com a fitxer", "copied": "Fitxer copiat. El podeu enganxar en una carpeta.", "copy_failed": "No s'ha pogut copiar el fitxer." + }, + "desktopReminders": { + "generic_title": "Recordatori", + "snooze": "Posposa {count} min", + "snooze_short": "{count} min", + "open": "Obre", + "join": "Uneix-te", + "missed_title": "{count, plural, one {# recordatori perdut} other {# recordatoris perduts}}", + "missed_more": "i {count} més", + "missed_show": "Mostra", + "missed_dialog_title": "Recordatoris perduts", + "missed_dialog_description": "Van vèncer mentre l'aplicació estava tancada, l'ordinador en repòs o les notificacions en pausa.", + "open_calendar": "Obre el calendari", + "done": "Fet" } } diff --git a/locales/cs/common.json b/locales/cs/common.json index 567020087..626c9d150 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Kopírovat jako soubor", "copied": "Soubor zkopírován. Můžete ho vložit do složky.", "copy_failed": "Soubor se nepodařilo zkopírovat." + }, + "desktopReminders": { + "generic_title": "Připomenutí", + "snooze": "Odložit o {count} min", + "snooze_short": "{count} min", + "open": "Otevřít", + "join": "Připojit se", + "missed_title": "{count, plural, one {# zmeškané připomenutí} few {# zmeškaná připomenutí} many {# zmeškaného připomenutí} other {# zmeškaných připomenutí}}", + "missed_more": "a další ({count})", + "missed_show": "Zobrazit", + "missed_dialog_title": "Zmeškaná připomenutí", + "missed_dialog_description": "Nastala, když byla aplikace zavřená, počítač v režimu spánku nebo oznámení pozastavená.", + "open_calendar": "Otevřít kalendář", + "done": "Hotovo" } } diff --git a/locales/da/common.json b/locales/da/common.json index 352cd2805..5c9d9a580 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Kopiér som fil", "copied": "Filen er kopieret. Du kan indsætte den i en mappe.", "copy_failed": "Filen kunne ikke kopieres." + }, + "desktopReminders": { + "generic_title": "Påmindelse", + "snooze": "Udsæt {count} min.", + "snooze_short": "{count} min.", + "open": "Åbn", + "join": "Deltag", + "missed_title": "{count, plural, one {# overset påmindelse} other {# oversete påmindelser}}", + "missed_more": "og {count} mere", + "missed_show": "Vis", + "missed_dialog_title": "Oversete påmindelser", + "missed_dialog_description": "De forfaldt, mens appen var lukket, computeren sov, eller notifikationer var sat på pause.", + "open_calendar": "Åbn kalender", + "done": "Færdig" } } diff --git a/locales/de/common.json b/locales/de/common.json index 65fa8c8dd..ea62fa855 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Als Datei kopieren", "copied": "Datei kopiert. Sie können sie in einen Ordner einfügen.", "copy_failed": "Die Datei konnte nicht kopiert werden." + }, + "desktopReminders": { + "generic_title": "Erinnerung", + "snooze": "{count} Min. schlummern", + "snooze_short": "{count} Min.", + "open": "Öffnen", + "join": "Teilnehmen", + "missed_title": "{count, plural, one {# verpasste Erinnerung} other {# verpasste Erinnerungen}}", + "missed_more": "und {count} weitere", + "missed_show": "Anzeigen", + "missed_dialog_title": "Verpasste Erinnerungen", + "missed_dialog_description": "Diese wurden fällig, während die App geschlossen war, der Computer im Ruhezustand war oder Benachrichtigungen pausiert waren.", + "open_calendar": "Kalender öffnen", + "done": "Fertig" } } diff --git a/locales/en/common.json b/locales/en/common.json index 4512e406e..7857501d3 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Copy as file", "copied": "File copied. You can paste it into a folder.", "copy_failed": "The file could not be copied." + }, + "desktopReminders": { + "generic_title": "Reminder", + "snooze": "Snooze {count} min", + "snooze_short": "{count} min", + "open": "Open", + "join": "Join", + "missed_title": "{count, plural, one {# reminder you missed} other {# reminders you missed}}", + "missed_more": "and {count} more", + "missed_show": "Show", + "missed_dialog_title": "Reminders you missed", + "missed_dialog_description": "These came due while the app was closed, the computer was asleep or notifications were paused.", + "open_calendar": "Open calendar", + "done": "Done" } } diff --git a/locales/es/common.json b/locales/es/common.json index 6ed72a0f4..7542536a1 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Copiar como archivo", "copied": "Archivo copiado. Puedes pegarlo en una carpeta.", "copy_failed": "No se pudo copiar el archivo." + }, + "desktopReminders": { + "generic_title": "Recordatorio", + "snooze": "Posponer {count} min", + "snooze_short": "{count} min", + "open": "Abrir", + "join": "Unirse", + "missed_title": "{count, plural, one {# recordatorio perdido} other {# recordatorios perdidos}}", + "missed_more": "y {count} más", + "missed_show": "Mostrar", + "missed_dialog_title": "Recordatorios perdidos", + "missed_dialog_description": "Vencieron mientras la aplicación estaba cerrada, el ordenador en reposo o las notificaciones en pausa.", + "open_calendar": "Abrir calendario", + "done": "Listo" } } diff --git a/locales/et/common.json b/locales/et/common.json index d0d6356b6..40abb6d20 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Kopeeri failina", "copied": "Fail on kopeeritud. Saad selle kausta kleepida.", "copy_failed": "Faili ei saanud kopeerida." + }, + "desktopReminders": { + "generic_title": "Meeldetuletus", + "snooze": "Lükka {count} min edasi", + "snooze_short": "{count} min", + "open": "Ava", + "join": "Liitu", + "missed_title": "{count, plural, one {# kasutamata meeldetuletus} other {# kasutamata meeldetuletust}}", + "missed_more": "ja veel {count}", + "missed_show": "Näita", + "missed_dialog_title": "Kasutamata meeldetuletused", + "missed_dialog_description": "Need saabusid, kui rakendus oli suletud, arvuti puhkas või teavitused olid peatatud.", + "open_calendar": "Ava kalender", + "done": "Valmis" } } diff --git a/locales/fa/common.json b/locales/fa/common.json index de207bb10..8d5df86ea 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "رونوشت بهصورت پرونده", "copied": "پرونده رونوشت شد. میتوانید آن را در یک پوشه بچسبانید.", "copy_failed": "رونوشت پرونده ممکن نشد." + }, + "desktopReminders": { + "generic_title": "یادآوری", + "snooze": "تعویق {count} دقیقه", + "snooze_short": "{count} دقیقه", + "open": "باز کردن", + "join": "پیوستن", + "missed_title": "{count, plural, one {# یادآوری از دسترفته} other {# یادآوری از دسترفته}}", + "missed_more": "و {count} مورد دیگر", + "missed_show": "نمایش", + "missed_dialog_title": "یادآوریهای از دسترفته", + "missed_dialog_description": "این یادآوریها زمانی رسیدند که برنامه بسته بود، رایانه در خواب بود یا اعلانها متوقف بودند.", + "open_calendar": "باز کردن تقویم", + "done": "تمام" } } diff --git a/locales/fr/common.json b/locales/fr/common.json index b51367064..93edc4bd6 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Copier en tant que fichier", "copied": "Fichier copié. Vous pouvez le coller dans un dossier.", "copy_failed": "Le fichier n'a pas pu être copié." + }, + "desktopReminders": { + "generic_title": "Rappel", + "snooze": "Reporter de {count} min", + "snooze_short": "{count} min", + "open": "Ouvrir", + "join": "Rejoindre", + "missed_title": "{count, plural, one {# rappel manqué} other {# rappels manqués}}", + "missed_more": "et {count} de plus", + "missed_show": "Afficher", + "missed_dialog_title": "Rappels manqués", + "missed_dialog_description": "Ils sont arrivés à échéance alors que l'application était fermée, l'ordinateur en veille ou les notifications en pause.", + "open_calendar": "Ouvrir le calendrier", + "done": "Terminé" } } diff --git a/locales/he/common.json b/locales/he/common.json index 613e36392..6d227dd04 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -6592,5 +6592,19 @@ "copy_as_file": "העתקה כקובץ", "copied": "הקובץ הועתק. אפשר להדביק אותו בתיקייה.", "copy_failed": "לא ניתן להעתיק את הקובץ." + }, + "desktopReminders": { + "generic_title": "תזכורת", + "snooze": "דחייה ב-{count} דק'", + "snooze_short": "{count} דק'", + "open": "פתיחה", + "join": "הצטרפות", + "missed_title": "{count, plural, one {תזכורת אחת שהוחמצה} two {# תזכורות שהוחמצו} other {# תזכורות שהוחמצו}}", + "missed_more": "ועוד {count}", + "missed_show": "הצגה", + "missed_dialog_title": "תזכורות שהוחמצו", + "missed_dialog_description": "הן הגיעו כשהאפליקציה הייתה סגורה, המחשב היה במצב שינה או שההתראות הושהו.", + "open_calendar": "פתיחת היומן", + "done": "סיום" } } diff --git a/locales/hu/common.json b/locales/hu/common.json index b72bcfc0d..1794a8340 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Másolás fájlként", "copied": "Fájl másolva. Beillesztheti egy mappába.", "copy_failed": "A fájlt nem sikerült másolni." + }, + "desktopReminders": { + "generic_title": "Emlékeztető", + "snooze": "Halasztás {count} perccel", + "snooze_short": "{count} perc", + "open": "Megnyitás", + "join": "Csatlakozás", + "missed_title": "{count, plural, one {# elmulasztott emlékeztető} other {# elmulasztott emlékeztető}}", + "missed_more": "és még {count}", + "missed_show": "Megjelenítés", + "missed_dialog_title": "Elmulasztott emlékeztetők", + "missed_dialog_description": "Akkor jártak le, amikor az alkalmazás be volt zárva, a számítógép alvó állapotban volt, vagy az értesítések szüneteltek.", + "open_calendar": "Naptár megnyitása", + "done": "Kész" } } diff --git a/locales/it/common.json b/locales/it/common.json index 421714d43..aaa5bc1ed 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Copia come file", "copied": "File copiato. Puoi incollarlo in una cartella.", "copy_failed": "Impossibile copiare il file." + }, + "desktopReminders": { + "generic_title": "Promemoria", + "snooze": "Rimanda di {count} min", + "snooze_short": "{count} min", + "open": "Apri", + "join": "Partecipa", + "missed_title": "{count, plural, one {# promemoria perso} other {# promemoria persi}}", + "missed_more": "e altri {count}", + "missed_show": "Mostra", + "missed_dialog_title": "Promemoria persi", + "missed_dialog_description": "Sono scattati mentre l'app era chiusa, il computer in sospensione o le notifiche in pausa.", + "open_calendar": "Apri calendario", + "done": "Fatto" } } diff --git a/locales/ja/common.json b/locales/ja/common.json index 1b72f2fb8..b072304ec 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "ファイルとしてコピー", "copied": "ファイルをコピーしました。フォルダーに貼り付けられます。", "copy_failed": "ファイルをコピーできませんでした。" + }, + "desktopReminders": { + "generic_title": "リマインダー", + "snooze": "{count} 分後に再通知", + "snooze_short": "{count} 分", + "open": "開く", + "join": "参加", + "missed_title": "{count, plural, other {見逃したリマインダー # 件}}", + "missed_more": "ほか {count} 件", + "missed_show": "表示", + "missed_dialog_title": "見逃したリマインダー", + "missed_dialog_description": "アプリが閉じていた間、コンピューターがスリープしていた間、または通知を一時停止していた間に期限が来たものです。", + "open_calendar": "カレンダーを開く", + "done": "完了" } } diff --git a/locales/ko/common.json b/locales/ko/common.json index ce7ac48d8..9b06f3d24 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "파일로 복사", "copied": "파일을 복사했습니다. 폴더에 붙여넣을 수 있습니다.", "copy_failed": "파일을 복사할 수 없습니다." + }, + "desktopReminders": { + "generic_title": "알림", + "snooze": "{count}분 후 다시 알림", + "snooze_short": "{count}분", + "open": "열기", + "join": "참가", + "missed_title": "{count, plural, other {놓친 알림 #개}}", + "missed_more": "외 {count}개", + "missed_show": "보기", + "missed_dialog_title": "놓친 알림", + "missed_dialog_description": "앱이 닫혀 있거나 컴퓨터가 절전 상태이거나 알림이 일시 중지된 동안 시간이 된 알림입니다.", + "open_calendar": "캘린더 열기", + "done": "완료" } } diff --git a/locales/lv/common.json b/locales/lv/common.json index b45fabbb4..891aeb6e2 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Kopēt kā failu", "copied": "Fails nokopēts. Varat to ielīmēt mapē.", "copy_failed": "Failu neizdevās nokopēt." + }, + "desktopReminders": { + "generic_title": "Atgādinājums", + "snooze": "Atlikt par {count} min", + "snooze_short": "{count} min", + "open": "Atvērt", + "join": "Pievienoties", + "missed_title": "{count, plural, zero {# nokavētu atgādinājumu} one {# nokavēts atgādinājums} other {# nokavēti atgādinājumi}}", + "missed_more": "un vēl {count}", + "missed_show": "Rādīt", + "missed_dialog_title": "Nokavētie atgādinājumi", + "missed_dialog_description": "Tie pienāca, kamēr lietotne bija aizvērta, dators bija miega režīmā vai paziņojumi bija apturēti.", + "open_calendar": "Atvērt kalendāru", + "done": "Gatavs" } } diff --git a/locales/mn/common.json b/locales/mn/common.json index 243f02639..2a182da0e 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Файл болгож хуулах", "copied": "Файлыг хууллаа. Хавтсанд буулгаж болно.", "copy_failed": "Файлыг хуулж чадсангүй." + }, + "desktopReminders": { + "generic_title": "Сануулга", + "snooze": "{count} минут хойшлуулах", + "snooze_short": "{count} мин", + "open": "Нээх", + "join": "Нэгдэх", + "missed_title": "{count, plural, one {Алдсан # сануулга} other {Алдсан # сануулга}}", + "missed_more": "бас {count}", + "missed_show": "Харуулах", + "missed_dialog_title": "Алдсан сануулгууд", + "missed_dialog_description": "Апп хаалттай, компьютер унтсан эсвэл мэдэгдэл түр зогссон үед хугацаа нь болсон.", + "open_calendar": "Хуанли нээх", + "done": "Болсон" } } diff --git a/locales/nb/common.json b/locales/nb/common.json index f2f0c0de7..3573c7773 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Kopier som fil", "copied": "Filen er kopiert. Du kan lime den inn i en mappe.", "copy_failed": "Filen kunne ikke kopieres." + }, + "desktopReminders": { + "generic_title": "Påminnelse", + "snooze": "Utsett {count} min", + "snooze_short": "{count} min", + "open": "Åpne", + "join": "Bli med", + "missed_title": "{count, plural, one {# tapt påminnelse} other {# tapte påminnelser}}", + "missed_more": "og {count} til", + "missed_show": "Vis", + "missed_dialog_title": "Tapte påminnelser", + "missed_dialog_description": "De forfalt mens appen var lukket, datamaskinen sov eller varsler var satt på pause.", + "open_calendar": "Åpne kalender", + "done": "Ferdig" } } diff --git a/locales/nl/common.json b/locales/nl/common.json index 6ab31ef9b..17329f729 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Kopiëren als bestand", "copied": "Bestand gekopieerd. Je kunt het in een map plakken.", "copy_failed": "Het bestand kon niet worden gekopieerd." + }, + "desktopReminders": { + "generic_title": "Herinnering", + "snooze": "{count} min uitstellen", + "snooze_short": "{count} min", + "open": "Openen", + "join": "Deelnemen", + "missed_title": "{count, plural, one {# gemiste herinnering} other {# gemiste herinneringen}}", + "missed_more": "en nog {count}", + "missed_show": "Tonen", + "missed_dialog_title": "Gemiste herinneringen", + "missed_dialog_description": "Deze kwamen op terwijl de app gesloten was, de computer sliep of meldingen gepauzeerd waren.", + "open_calendar": "Agenda openen", + "done": "Klaar" } } diff --git a/locales/pl/common.json b/locales/pl/common.json index 4590d48b3..6b50e2feb 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Kopiuj jako plik", "copied": "Plik skopiowany. Możesz wkleić go do folderu.", "copy_failed": "Nie udało się skopiować pliku." + }, + "desktopReminders": { + "generic_title": "Przypomnienie", + "snooze": "Odłóż o {count} min", + "snooze_short": "{count} min", + "open": "Otwórz", + "join": "Dołącz", + "missed_title": "{count, plural, one {# pominięte przypomnienie} few {# pominięte przypomnienia} many {# pominiętych przypomnień} other {# pominiętego przypomnienia}}", + "missed_more": "i jeszcze {count}", + "missed_show": "Pokaż", + "missed_dialog_title": "Pominięte przypomnienia", + "missed_dialog_description": "Pojawiły się, gdy aplikacja była zamknięta, komputer był uśpiony lub powiadomienia były wstrzymane.", + "open_calendar": "Otwórz kalendarz", + "done": "Gotowe" } } diff --git a/locales/pt/common.json b/locales/pt/common.json index 97ed21d66..0b67e7311 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Copiar como ficheiro", "copied": "Ficheiro copiado. Pode colá-lo numa pasta.", "copy_failed": "Não foi possível copiar o ficheiro." + }, + "desktopReminders": { + "generic_title": "Lembrete", + "snooze": "Adiar {count} min", + "snooze_short": "{count} min", + "open": "Abrir", + "join": "Entrar", + "missed_title": "{count, plural, one {# lembrete perdido} other {# lembretes perdidos}}", + "missed_more": "e mais {count}", + "missed_show": "Mostrar", + "missed_dialog_title": "Lembretes perdidos", + "missed_dialog_description": "Venceram enquanto a aplicação estava fechada, o computador em suspensão ou as notificações em pausa.", + "open_calendar": "Abrir calendário", + "done": "Concluído" } } diff --git a/locales/ro/common.json b/locales/ro/common.json index 0936d12f4..f03956657 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Copiază ca fișier", "copied": "Fișier copiat. Îl poți lipi într-un dosar.", "copy_failed": "Fișierul nu a putut fi copiat." + }, + "desktopReminders": { + "generic_title": "Memento", + "snooze": "Amână {count} min", + "snooze_short": "{count} min", + "open": "Deschide", + "join": "Participă", + "missed_title": "{count, plural, one {# memento ratat} few {# mementouri ratate} other {# de mementouri ratate}}", + "missed_more": "și încă {count}", + "missed_show": "Afișează", + "missed_dialog_title": "Mementouri ratate", + "missed_dialog_description": "Au venit la scadență cât timp aplicația era închisă, computerul în repaus sau notificările în pauză.", + "open_calendar": "Deschide calendarul", + "done": "Gata" } } diff --git a/locales/ru/common.json b/locales/ru/common.json index eb1641895..32c3a46a1 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Копировать как файл", "copied": "Файл скопирован. Его можно вставить в папку.", "copy_failed": "Не удалось скопировать файл." + }, + "desktopReminders": { + "generic_title": "Напоминание", + "snooze": "Отложить на {count} мин", + "snooze_short": "{count} мин", + "open": "Открыть", + "join": "Подключиться", + "missed_title": "{count, plural, one {# пропущенное напоминание} few {# пропущенных напоминания} many {# пропущенных напоминаний} other {# пропущенного напоминания}}", + "missed_more": "и ещё {count}", + "missed_show": "Показать", + "missed_dialog_title": "Пропущенные напоминания", + "missed_dialog_description": "Они сработали, пока приложение было закрыто, компьютер спал или уведомления были приостановлены.", + "open_calendar": "Открыть календарь", + "done": "Готово" } } diff --git a/locales/sk/common.json b/locales/sk/common.json index 4431aa2a7..cb16abd32 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Kopírovať ako súbor", "copied": "Súbor skopírovaný. Môžete ho vložiť do priečinka.", "copy_failed": "Súbor sa nepodarilo skopírovať." + }, + "desktopReminders": { + "generic_title": "Pripomienka", + "snooze": "Odložiť o {count} min", + "snooze_short": "{count} min", + "open": "Otvoriť", + "join": "Pripojiť sa", + "missed_title": "{count, plural, one {# zmeškaná pripomienka} few {# zmeškané pripomienky} many {# zmeškanej pripomienky} other {# zmeškaných pripomienok}}", + "missed_more": "a ďalšie ({count})", + "missed_show": "Zobraziť", + "missed_dialog_title": "Zmeškané pripomienky", + "missed_dialog_description": "Nastali, keď bola aplikácia zatvorená, počítač v režime spánku alebo oznámenia pozastavené.", + "open_calendar": "Otvoriť kalendár", + "done": "Hotovo" } } diff --git a/locales/th/common.json b/locales/th/common.json index 47156ac85..594e81712 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -6599,5 +6599,19 @@ "copy_as_file": "คัดลอกเป็นไฟล์", "copied": "คัดลอกไฟล์แล้ว คุณวางลงในโฟลเดอร์ได้", "copy_failed": "คัดลอกไฟล์ไม่ได้" + }, + "desktopReminders": { + "generic_title": "การแจ้งเตือน", + "snooze": "เลื่อน {count} นาที", + "snooze_short": "{count} นาที", + "open": "เปิด", + "join": "เข้าร่วม", + "missed_title": "{count, plural, other {การแจ้งเตือนที่พลาด # รายการ}}", + "missed_more": "และอีก {count} รายการ", + "missed_show": "แสดง", + "missed_dialog_title": "การแจ้งเตือนที่พลาด", + "missed_dialog_description": "ถึงเวลาแจ้งเตือนขณะที่แอปปิดอยู่ คอมพิวเตอร์อยู่ในโหมดพัก หรือหยุดการแจ้งเตือนชั่วคราว", + "open_calendar": "เปิดปฏิทิน", + "done": "เสร็จ" } } diff --git a/locales/tr/common.json b/locales/tr/common.json index 391215501..946a1ab7b 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Dosya olarak kopyala", "copied": "Dosya kopyalandı. Bir klasöre yapıştırabilirsiniz.", "copy_failed": "Dosya kopyalanamadı." + }, + "desktopReminders": { + "generic_title": "Anımsatıcı", + "snooze": "{count} dk ertele", + "snooze_short": "{count} dk", + "open": "Aç", + "join": "Katıl", + "missed_title": "{count, plural, one {Kaçırılan # anımsatıcı} other {Kaçırılan # anımsatıcı}}", + "missed_more": "ve {count} tane daha", + "missed_show": "Göster", + "missed_dialog_title": "Kaçırılan anımsatıcılar", + "missed_dialog_description": "Uygulama kapalıyken, bilgisayar uykudayken veya bildirimler duraklatılmışken zamanı geldi.", + "open_calendar": "Takvimi aç", + "done": "Bitti" } } diff --git a/locales/uk/common.json b/locales/uk/common.json index ebed90c4e..45a4898b1 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "Копіювати як файл", "copied": "Файл скопійовано. Його можна вставити в папку.", "copy_failed": "Не вдалося скопіювати файл." + }, + "desktopReminders": { + "generic_title": "Нагадування", + "snooze": "Відкласти на {count} хв", + "snooze_short": "{count} хв", + "open": "Відкрити", + "join": "Приєднатися", + "missed_title": "{count, plural, one {# пропущене нагадування} few {# пропущені нагадування} many {# пропущених нагадувань} other {# пропущеного нагадування}}", + "missed_more": "і ще {count}", + "missed_show": "Показати", + "missed_dialog_title": "Пропущені нагадування", + "missed_dialog_description": "Вони спрацювали, коли застосунок було закрито, комп'ютер спав або сповіщення були призупинені.", + "open_calendar": "Відкрити календар", + "done": "Готово" } } diff --git a/locales/vi/common.json b/locales/vi/common.json index 86de8e353..51222b054 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -6603,5 +6603,19 @@ "copy_as_file": "Sao chép dưới dạng tệp", "copied": "Đã sao chép tệp. Bạn có thể dán vào một thư mục.", "copy_failed": "Không thể sao chép tệp." + }, + "desktopReminders": { + "generic_title": "Lời nhắc", + "snooze": "Báo lại sau {count} phút", + "snooze_short": "{count} phút", + "open": "Mở", + "join": "Tham gia", + "missed_title": "{count, plural, other {# lời nhắc đã bỏ lỡ}}", + "missed_more": "và {count} lời nhắc khác", + "missed_show": "Hiển thị", + "missed_dialog_title": "Lời nhắc đã bỏ lỡ", + "missed_dialog_description": "Những lời nhắc này đến hạn khi ứng dụng đóng, máy tính ngủ hoặc thông báo bị tạm dừng.", + "open_calendar": "Mở lịch", + "done": "Xong" } } diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index f3fb5a71a..8004dc647 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "複製為檔案", "copied": "檔案已複製。您可以將其貼到資料夾中。", "copy_failed": "無法複製檔案。" + }, + "desktopReminders": { + "generic_title": "提醒", + "snooze": "{count} 分鐘後再提醒", + "snooze_short": "{count} 分鐘", + "open": "開啟", + "join": "加入", + "missed_title": "{count, plural, other {# 則錯過的提醒}}", + "missed_more": "還有 {count} 則", + "missed_show": "顯示", + "missed_dialog_title": "錯過的提醒", + "missed_dialog_description": "這些提醒在應用程式關閉、電腦睡眠或通知暫停時到期。", + "open_calendar": "開啟日曆", + "done": "完成" } } diff --git a/locales/zh/common.json b/locales/zh/common.json index 3058a98c8..58a804589 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -6591,5 +6591,19 @@ "copy_as_file": "复制为文件", "copied": "文件已复制。你可以将其粘贴到文件夹中。", "copy_failed": "无法复制文件。" + }, + "desktopReminders": { + "generic_title": "提醒", + "snooze": "{count} 分钟后再提醒", + "snooze_short": "{count} 分钟", + "open": "打开", + "join": "加入", + "missed_title": "{count, plural, other {# 条错过的提醒}}", + "missed_more": "还有 {count} 条", + "missed_show": "显示", + "missed_dialog_title": "错过的提醒", + "missed_dialog_description": "这些提醒在应用关闭、电脑睡眠或通知暂停时到期。", + "open_calendar": "打开日历", + "done": "完成" } } diff --git a/public/sw.js b/public/sw.js index 22e4174ba..581108f36 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1096,6 +1096,7 @@ function getReusableClientScore(state) { function buildClickUrl(data, reply) { if (!data) return `${BASE_PATH}/`; + if (data.kind === "calendar-reminder") return `${BASE_PATH}/calendar`; const params = new URLSearchParams(); if (Number.isInteger(data.slot)) params.set("slot", String(data.slot)); if (typeof data.accountId === "string" && data.accountId) params.set("owner", data.accountId); diff --git a/stores/calendar-notification-store.ts b/stores/calendar-notification-store.ts index da0a266ea..8e7d41983 100644 --- a/stores/calendar-notification-store.ts +++ b/stores/calendar-notification-store.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; +import { clearClaims } from '@/lib/calendar-reminders/claims'; const RETENTION_THRESHOLD_MS = 24 * 60 * 60 * 1000; @@ -37,6 +38,7 @@ export const useCalendarNotificationStore = create()( clearAll: () => { set({ acknowledgedAlerts: {} }); + clearClaims(); }, }), {
{alert.title || tAlert("alert_title")}
+ {format.format(new Date(alert.startMs ?? alert.fireTimeMs))} + {alert.calendarName ? ` · ${alert.calendarName}` : ""} +