mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
feat: fire calendar reminders from the local store, with native snooze and join buttons and one summary of what was missed
This commit is contained in:
@@ -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 (
|
||||
<Dialog
|
||||
isOpen
|
||||
onClose={finish}
|
||||
title={t("missed_dialog_title")}
|
||||
description={t("missed_dialog_description")}
|
||||
footer={(
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
finish();
|
||||
window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
|
||||
}}
|
||||
>
|
||||
{t("open_calendar")}
|
||||
</Button>
|
||||
<Button onClick={finish}>{t("done")}</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<ul className="max-h-80 overflow-y-auto divide-y divide-border" data-testid="missed-reminders">
|
||||
{alerts.map((alert) => (
|
||||
<li key={alert.key} className="flex items-center gap-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">{alert.title || tAlert("alert_title")}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{format.format(new Date(alert.startMs ?? alert.fireTimeMs))}
|
||||
{alert.calendarName ? ` · ${alert.calendarName}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
{alert.joinUrl && (
|
||||
<Button variant="outline" size="sm" onClick={() => joinMeeting(alert.joinUrl!)}>
|
||||
{t("join")}
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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}
|
||||
<ToastContainer toasts={toasts} onClose={removeToast} />
|
||||
<MissedRemindersDialog />
|
||||
<LiveRegions />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
<ToastItem
|
||||
onClose={vi.fn()}
|
||||
toast={{
|
||||
id: 't',
|
||||
type: 'info',
|
||||
title: 'Standup',
|
||||
duration: 0,
|
||||
onDismiss,
|
||||
tertiaryAction: action('five'),
|
||||
secondaryAction: action('ten'),
|
||||
action: action('fifteen'),
|
||||
extraActions: [action('join'), action('open')],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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) => <span key={action.label} className="contents">{actionButton(action)}</span>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<typeof import('@/lib/popout/window-role')>(),
|
||||
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<string, unknown> = {}) {
|
||||
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 <ToastContainer toasts={toasts} onClose={removeToast} />;
|
||||
}
|
||||
|
||||
async function advance(ms: number) {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(ms);
|
||||
});
|
||||
}
|
||||
|
||||
async function syncedWith(...events: Record<string, unknown>[]) {
|
||||
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(<Toasts />);
|
||||
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(<Toasts />);
|
||||
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(<><Toasts /><MissedRemindersDialog /></>);
|
||||
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(<Toasts />);
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<ReturnType<typeof setTimeout> | 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 {
|
||||
|
||||
@@ -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<ReturnType<typeof createReminderRunner> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!signedIn || isPopoutWindow()) return;
|
||||
const toastsByKey = new Map<string, string>();
|
||||
|
||||
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<MissedBatch | null>(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]);
|
||||
}
|
||||
@@ -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<unknown> = Promise.resolve();
|
||||
return {
|
||||
request: vi.fn(<T,>(_name: string, callback: () => T | Promise<T>) => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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<Pick<Tab, 'hidden' | 'offline'>> = {}): 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<MissedBatch | null>(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<string, unknown>[]): Promise<void> {
|
||||
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<Tab> {
|
||||
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<MissedBatch>(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);
|
||||
});
|
||||
});
|
||||
@@ -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<AcknowledgedMessage>) => {
|
||||
if (event.data?.kind === 'acknowledged' && Array.isArray(event.data.keys)) listener(event.data.keys);
|
||||
};
|
||||
return () => channel.close();
|
||||
}
|
||||
@@ -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<string, number> {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as unknown;
|
||||
return parsed && typeof parsed === 'object' ? parsed as Record<string, number> : {};
|
||||
} 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;
|
||||
@@ -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<StoreBackend, 'area'>;
|
||||
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<void>;
|
||||
/** 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<typeof setTimeout> | null = null;
|
||||
let clockWatch: ReturnType<typeof setInterval> | null = null;
|
||||
let lastWatch = 0;
|
||||
let running = false;
|
||||
let again = false;
|
||||
let stopped = true;
|
||||
let summaryAt = 0;
|
||||
const presenting = new Set<string>();
|
||||
const known = new Set<string>();
|
||||
|
||||
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<number | null> => {
|
||||
let result: TickResult;
|
||||
try {
|
||||
result = await env.store().area<TickResult>(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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string, true>;
|
||||
setDriven: (account: string, driven: boolean) => void;
|
||||
}
|
||||
|
||||
export const useReminderSource = create<ReminderSourceState>()((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<MissedRemindersState>()((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;
|
||||
}
|
||||
@@ -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<ServiceWorkerRegistration | undefined> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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.
|
||||
}
|
||||
}
|
||||
@@ -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> = {}): 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<string> {
|
||||
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<string, unknown> = {}): Promise<ReminderAlert> {
|
||||
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<TickResult>(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<TickResult>(ACCOUNT, 'reminders.tick', params)).due).toEqual([]);
|
||||
vi.setSystemTime(FIRE + 10 * MIN);
|
||||
expect((await store.area<TickResult>(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<TickResult>(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<TickResult>(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);
|
||||
});
|
||||
});
|
||||
@@ -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<NotificationActi
|
||||
}
|
||||
|
||||
async function handleNotification(action: string, rawContext: string, reply: string | undefined, env: OsCommandEnv): Promise<void> {
|
||||
if (await handleReminderCommand(action, rawContext, env)) return;
|
||||
const context = parseContext(rawContext);
|
||||
if (action === 'reply') {
|
||||
await openFromNotification(context, env, { text: reply ?? '' });
|
||||
|
||||
@@ -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<Pick<ReminderToastContext, 'slot' | 'login'>> {
|
||||
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<DesktopOsNotification> {
|
||||
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<ReminderLabels, 'untitled' | 'missedMore'>): 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<DesktopOsNotification> {
|
||||
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<string | null> {
|
||||
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<void> {
|
||||
announceAcknowledged([key]);
|
||||
await getStore().area<boolean>(account, 'reminders.snooze', { key, minutes });
|
||||
pokeReminders();
|
||||
}
|
||||
|
||||
export interface ReminderCommandEnv {
|
||||
leaveFor: (path: '/calendar') => void | Promise<void>;
|
||||
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<boolean> {
|
||||
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<ReminderAlert[]>(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<ReminderAlert | null>(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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Listener>();
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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": "تم"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "تمام"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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é"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "סיום"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "完了"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "완료"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Болсон"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Готово"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "เสร็จ"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Готово"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "完成"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "完成"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<CalendarNotificationStore>()(
|
||||
|
||||
clearAll: () => {
|
||||
set({ acknowledgedAlerts: {} });
|
||||
clearClaims();
|
||||
},
|
||||
}),
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user