mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
feat: notify from the local store's new mail and take it offline when the machine sleeps
This commit is contained in:
@@ -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') });
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user