feat: notify from the local store's new mail and take it offline when the machine sleeps

This commit is contained in:
Linus Rath
2026-11-03 10:34:29 +01:00
parent d1acddb3fd
commit 8e46957188
7 changed files with 155 additions and 43 deletions
+2 -1
View File
@@ -311,7 +311,8 @@ describe('from arrival to toast and sound', () => {
});
it("the store's created event reaches a running watch, and nothing else", async () => {
const { noteStoreCreated, startMailNotifications, MAIL_BATCH_MS } = await import('../mail-notifications');
const { startMailNotifications, MAIL_BATCH_MS } = await import('../mail-notifications');
const { reportStoreCreatedMail: noteStoreCreated } = await import('../system-events');
const notify = vi.fn(async () => ({ shown: true, playSound: false }));
setBridge({ version: 1, platform: 'win32', os: { notify } as never });
client = fakeClient({ a: email('a') });
+76
View File
@@ -0,0 +1,76 @@
import 'fake-indexeddb/auto';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { StorePageHooks } from '@/lib/store/client/api';
/** The local store and the OS integration: mail it found, and a machine going to sleep. */
const configured = vi.hoisted(() => ({ hooks: null as StorePageHooks | null }));
const setOnline = vi.hoisted(() => vi.fn(async () => {}));
vi.mock('@/lib/store/client/api', async (original) => ({
...(await original<typeof import('@/lib/store/client/api')>()),
configureStore: (hooks: StorePageHooks | null) => {
if (hooks) configured.hooks = hooks;
},
getStore: () => ({
supported: () => true,
setOnline,
ensureOpen: async () => {},
refresh: () => {},
sync: async () => {},
stop: () => {},
}),
}));
describe('the store and the OS integration', () => {
const client = { isOfflineOnly: () => false, getAccountId: () => 'acc' };
beforeEach(async () => {
vi.clearAllMocks();
const { useAuthStore } = await import('@/stores/auth-store');
useAuthStore.setState({ getClientForAccount: () => client as never } as never);
const { registerOfflineHooks } = await import('@/lib/offline/offline-mode');
registerOfflineHooks();
});
afterEach(async () => {
const { setStoreCreatedListener } = await import('../system-events');
setStoreCreatedListener(null);
});
it("hands the store's new mail to the notification path, with the login's client", async () => {
const { setStoreCreatedListener } = await import('../system-events');
const listener = vi.fn();
setStoreCreatedListener(listener);
configured.hooks?.onEvent({ type: 'created', account: 'alice@one', jmap: 'acc', collection: 'Email', ids: ['e1', 'e2'] });
expect(listener).toHaveBeenCalledWith(client, 'acc', ['e1', 'e2']);
});
it('other collections are not mail', async () => {
const { setStoreCreatedListener } = await import('../system-events');
const listener = vi.fn();
setStoreCreatedListener(listener);
configured.hooks?.onEvent({ type: 'created', account: 'alice@one', jmap: 'acc', collection: 'CalendarEvent', ids: ['c1'] });
expect(listener).not.toHaveBeenCalled();
});
it('with nothing listening, as in a browser, it does nothing', () => {
expect(() => configured.hooks?.onEvent({ type: 'created', account: 'alice@one', jmap: 'acc', collection: 'Email', ids: ['e1'] })).not.toThrow();
});
it('a sleep takes the engine offline; waking brings it back and syncs through the events it already listens to', async () => {
const { startOfflineMode, offlineSupported } = await import('@/lib/offline/offline-mode');
expect(offlineSupported()).toBe(true);
const { runSleepHandlers } = await import('../system-events');
const stop = startOfflineMode();
runSleepHandlers();
expect(setOnline).toHaveBeenLastCalledWith(false);
window.bulwarkDesktop = { version: 1, platform: 'win32', os: { onPower: (listener: (event: string) => void) => { listener('resume'); return () => {}; } } } as never;
const { startPowerWatch } = await import('../power');
const stopPower = startPowerWatch();
expect(setOnline).toHaveBeenLastCalledWith(true);
stopPower();
stop();
window.bulwarkDesktop = undefined;
});
});
+2 -1
View File
@@ -271,7 +271,8 @@ describe('waking up', () => {
let fire: (event: DesktopOsPowerEvent) => void = () => {};
setBridge({ onPower: (listener: (event: DesktopOsPowerEvent) => void) => { fire = listener; return () => {}; }, ...os });
const power = await import('../power');
return { fire: (event: DesktopOsPowerEvent) => fire(event), power, stop: power.startPowerWatch() };
const events = await import('../system-events');
return { fire: (event: DesktopOsPowerEvent) => fire(event), power: events, stop: power.startPowerWatch() };
}
it('a resume checks for changes at once, the way a tab coming back does, and only once with the unlock', async () => {
+3 -13
View File
@@ -14,6 +14,7 @@ import { getDesktopBridge } from '@/lib/desktop/bridge';
import type { DesktopOsNotification } from '@/lib/desktop/types';
import { isAppLocked } from '@/lib/desktop/app-lock-state';
import { startNewMailWatch, type NewMail, type NewMailWatch } from '@/lib/desktop/new-mail';
import { setStoreCreatedListener } from '@/lib/desktop/system-events';
import { devicesForAccount } from '@/lib/device-policy';
import { playNotificationSound } from '@/lib/notification-sound';
import { isPopoutWindow } from '@/lib/popout/window-role';
@@ -130,17 +131,6 @@ export interface MailNotifications extends NewMailWatch {
setLabels(labels: MailNotificationLabels): void;
}
let current: MailNotifications | null = null;
/**
* The local store's `created` event, from lib/offline/offline-mode.ts's onEvent, once
* an account lives in the store: `noteStoreCreated(client, event.jmap, event.ids)`.
* Does nothing where notifications don't run: in a browser, in a pop-out.
*/
export function noteStoreCreated(client: IJMAPClient, accountId: string, ids: readonly string[]): void {
current?.noteCreated(client, accountId, ids);
}
export function startMailNotifications(initial: MailNotificationLabels): MailNotifications {
let labels = initial;
let batch: NewMail[] = [];
@@ -176,9 +166,9 @@ export function startMailNotifications(initial: MailNotificationLabels): MailNot
watch.stop();
if (timer) clearTimeout(timer);
timer = null;
if (current === handle) current = null;
setStoreCreatedListener(null);
},
};
current = handle;
setStoreCreatedListener((client, accountId, ids) => watch.noteCreated(client, accountId, ids));
return handle;
}
+6 -28
View File
@@ -4,46 +4,24 @@
* changes and replaces a dead stream, the identities and policy refresh. Those are the
* handlers already listening, so this only gives them the word. The hidden main window
* of the desktop app never goes out of view, so without this they wouldn't hear it.
*
* The local store registers with addWakeHandler and addSleepHandler (setOnline, sync).
* The local store hangs on the same two: `online` is setOnline(true) and a flush,
* `visibilitychange` is a sync per account. It takes itself offline for a sleep through
* addSleepHandler, in lib/desktop/system-events.ts.
*/
import { getDesktopBridge } from '@/lib/desktop/bridge';
import { runSleepHandlers, runWakeHandlers } from '@/lib/desktop/system-events';
import { usePolicyStore } from '@/stores/policy-store';
type Handler = () => void;
const wakeHandlers = new Set<Handler>();
const sleepHandlers = new Set<Handler>();
/** A resume and an unlock usually come together. */
const WAKE_REPEAT_MS = 2000;
export function addWakeHandler(handler: Handler): () => void {
wakeHandlers.add(handler);
return () => wakeHandlers.delete(handler);
}
export function addSleepHandler(handler: Handler): () => void {
sleepHandlers.add(handler);
return () => sleepHandlers.delete(handler);
}
function runAll(handlers: Set<Handler>): void {
for (const handler of [...handlers]) {
try {
handler();
} catch (error) {
console.error('A wake or sleep handler failed:', error);
}
}
}
function wake(): void {
document.dispatchEvent(new Event('visibilitychange'));
window.dispatchEvent(new Event('online'));
const policy = usePolicyStore.getState();
if (policy.loaded && !policy.fetching) void policy.fetchPolicy();
runAll(wakeHandlers);
runWakeHandlers();
}
export function startPowerWatch(): () => void {
@@ -58,7 +36,7 @@ export function startPowerWatch(): () => void {
};
const stopPower = bridge.onPower((event) => {
if (event === 'resume' || event === 'unlock-screen') wakeOnce();
else if (event === 'suspend') runAll(sleepHandlers);
else if (event === 'suspend') runSleepHandlers();
});
// Wi-Fi to cable is a new network with no offline moment in between.
const connection = (navigator as unknown as { connection?: EventTarget }).connection;
+56
View File
@@ -0,0 +1,56 @@
/**
* What the local store and the OS integration say to each other, with nothing imported
* either way: lib/offline runs in every browser, and the notification code pulls in half
* the app's stores.
*/
import type { IJMAPClient } from '@/lib/jmap/client-interface';
type Handler = () => void;
type CreatedListener = (client: IJMAPClient, accountId: string, ids: readonly string[]) => void;
const wakeHandlers = new Set<Handler>();
const sleepHandlers = new Set<Handler>();
let createdListener: CreatedListener | null = null;
export function addWakeHandler(handler: Handler): () => void {
wakeHandlers.add(handler);
return () => {
wakeHandlers.delete(handler);
};
}
export function addSleepHandler(handler: Handler): () => void {
sleepHandlers.add(handler);
return () => {
sleepHandlers.delete(handler);
};
}
function runAll(handlers: Set<Handler>): void {
for (const handler of [...handlers]) {
try {
handler();
} catch (error) {
console.error('A wake or sleep handler failed:', error);
}
}
}
export function runWakeHandlers(): void {
runAll(wakeHandlers);
}
export function runSleepHandlers(): void {
runAll(sleepHandlers);
}
/** The notification path, while it runs. */
export function setStoreCreatedListener(listener: CreatedListener | null): void {
createdListener = listener;
}
/** The store's `created` event for mail. Does nothing in a browser or a pop-out. */
export function reportStoreCreatedMail(client: IJMAPClient, accountId: string, ids: readonly string[]): void {
createdListener?.(client, accountId, ids);
}
+10
View File
@@ -60,6 +60,7 @@ import { flushQueue, LOOKUP_WAIT_MS, SENDING_STALE_MS, settleStaleSends } from '
import { LEGACY_DB_NAME, moveLegacyOutbox, strandedLegacyMessages } from './migrate-legacy';
import { deleteShellCaches, tellServiceWorker } from './shell';
import { rememberLost } from './lost';
import { addSleepHandler, reportStoreCreatedMail } from '@/lib/desktop/system-events';
export type OfflineNotice =
| { kind: 'queued'; subject: string }
@@ -180,6 +181,10 @@ const storeHooks: StorePageHooks = {
notify({ kind: 'store-reset' });
},
onEvent(event) {
if (event.type === 'created' && event.collection === 'Email') {
const client = onlineClientFor(event.account);
if (client) reportStoreCreatedMail(client, event.jmap, event.ids);
}
if (event.type === 'synced') {
useOfflineStore.setState((s) => ({ lastSyncedAt: { ...s.lastSyncedAt, [event.account]: event.at } }));
forgetMailSnapshot(event.account);
@@ -906,6 +911,11 @@ export function startOfflineMode(): () => void {
window.removeEventListener('offline', onOffline);
});
// The desktop app going to sleep. Waking comes with the two events above and below.
cleanups.push(addSleepHandler(() => {
void getStore().setOnline(false).catch(() => {});
}));
// Back in front: catch up now rather than at the next interval.
const onVisible = () => {
if (document.visibilityState !== 'visible') return;