mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
fix: apply email notifications off and do not disturb per login, not to every login's mail
This commit is contained in:
@@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl';
|
||||
import { useArchiveActionEnabled } from '@/lib/archive-gate';
|
||||
import { scheduleFlushAll } from '@/lib/offline/offline-mode';
|
||||
import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
|
||||
import { writeLoginQuiet } from '@/lib/push-preferences';
|
||||
import { isNotificationActionRequest, runNotificationAction } from '@/lib/pwa-notify/page-actions';
|
||||
import { pluginsMayVeto } from '@/lib/pwa-notify/plugin-veto';
|
||||
import { writeNotificationActionPrefs } from '@/lib/pwa-notify/worker-state';
|
||||
@@ -42,6 +43,16 @@ export function NotificationActionsHost() {
|
||||
});
|
||||
}, [isAuthenticated, archiveEnabled, archiveMode, mayVeto, archive, markRead, reply, replyPlaceholder]);
|
||||
|
||||
// Email notifications off or do not disturb on, for the login on screen: the
|
||||
// worker shows nothing for that login's mail. The label a push carries is this
|
||||
// name (lib/web-push.ts), and each login has its own settings.
|
||||
const username = useAuthStore((s) => s.username);
|
||||
const quiet = useSettingsStore((s) => !s.emailNotificationsEnabled || s.doNotDisturb);
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !username) return;
|
||||
void writeLoginQuiet(username, quiet);
|
||||
}, [isAuthenticated, username, quiet]);
|
||||
|
||||
// Which logins this tab holds: if it stays silent on a click for one of them,
|
||||
// the worker opens the message rather than go round its plugins.
|
||||
const loginKey = useMemo(() => accounts.map((a) => `${a.serverUrl}\n${a.username}`).join('\n\n'), [accounts]);
|
||||
|
||||
@@ -3,6 +3,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
// The page mirrors the notification sound setting into the service worker's
|
||||
// Cache store, where sw.js reads it before showing a push notification (#1289).
|
||||
|
||||
describe('writeLoginQuiet', () => {
|
||||
it('keeps one answer per login', async () => {
|
||||
const stored = new Map<string, string>();
|
||||
vi.stubGlobal('caches', {
|
||||
open: async () => ({
|
||||
match: async (key: string) => (stored.has(key) ? new Response(stored.get(key)) : undefined),
|
||||
put: async (key: string, res: Response) => { stored.set(key, await res.text()); },
|
||||
}),
|
||||
});
|
||||
const { writeLoginQuiet } = await import('../push-preferences');
|
||||
await writeLoginQuiet('a@example.org', true);
|
||||
await writeLoginQuiet('b@example.org', false);
|
||||
await writeLoginQuiet('a@example.org', true);
|
||||
const value = JSON.parse(stored.get(`${window.location.origin}/__push-state/quiet-logins`) ?? 'null');
|
||||
expect(value).toEqual({ 'a@example.org': true, 'b@example.org': false });
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
describe('writePushPreferences', () => {
|
||||
const put = vi.fn();
|
||||
|
||||
@@ -17,18 +36,18 @@ describe('writePushPreferences', () => {
|
||||
|
||||
it('stores the preference under the key the worker reads', async () => {
|
||||
const { writePushPreferences, pushPreferencesKey } = await import('../push-preferences');
|
||||
await writePushPreferences({ sound: false, show: true });
|
||||
await writePushPreferences({ sound: false });
|
||||
expect(put).toHaveBeenCalledTimes(1);
|
||||
const [key, response] = put.mock.calls[0] as [string, Response];
|
||||
expect(key).toBe(pushPreferencesKey(window.location.origin));
|
||||
expect(key.endsWith('/__push-state/prefs')).toBe(true);
|
||||
expect(await response.json()).toEqual({ sound: false, show: true });
|
||||
expect(await response.json()).toEqual({ sound: false });
|
||||
});
|
||||
|
||||
it('does nothing without a Cache store', async () => {
|
||||
vi.stubGlobal('caches', undefined);
|
||||
const { writePushPreferences } = await import('../push-preferences');
|
||||
await expect(writePushPreferences({ sound: true, show: true })).resolves.toBeUndefined();
|
||||
await expect(writePushPreferences({ sound: true })).resolves.toBeUndefined();
|
||||
expect(put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -42,7 +61,7 @@ describe('writePushPreferences', () => {
|
||||
},
|
||||
});
|
||||
const { writePushPreferences } = await import('../push-preferences');
|
||||
await expect(writePushPreferences({ sound: true, show: true })).resolves.toBeUndefined();
|
||||
await expect(writePushPreferences({ sound: true })).resolves.toBeUndefined();
|
||||
expect(put).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -54,11 +73,11 @@ describe('settings store push preference mirror', () => {
|
||||
vi.doMock('@/lib/push-preferences', () => ({ writePushPreferences }));
|
||||
const { useSettingsStore } = await import('@/stores/settings-store');
|
||||
|
||||
expect(writePushPreferences).toHaveBeenCalledWith({ sound: true, show: true });
|
||||
expect(writePushPreferences).toHaveBeenCalledWith({ sound: true });
|
||||
writePushPreferences.mockClear();
|
||||
|
||||
useSettingsStore.getState().updateSetting('emailNotificationSound', false);
|
||||
expect(writePushPreferences).toHaveBeenCalledWith({ sound: false, show: true });
|
||||
expect(writePushPreferences).toHaveBeenCalledWith({ sound: false });
|
||||
writePushPreferences.mockClear();
|
||||
|
||||
// Unrelated settings do not rewrite it.
|
||||
@@ -68,7 +87,7 @@ describe('settings store push preference mirror', () => {
|
||||
// Email notifications off means no notification, so no sound either.
|
||||
useSettingsStore.getState().updateSetting('emailNotificationSound', true);
|
||||
useSettingsStore.getState().updateSetting('emailNotificationsEnabled', false);
|
||||
expect(writePushPreferences).toHaveBeenLastCalledWith({ sound: false, show: false });
|
||||
expect(writePushPreferences).toHaveBeenLastCalledWith({ sound: false });
|
||||
vi.doUnmock('@/lib/push-preferences');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,12 +9,13 @@ const SW_SOURCE = readFileSync(join(process.cwd(), 'public', 'sw.js'), 'utf8');
|
||||
type Preview = Record<string, unknown> | null;
|
||||
type Shown = { title: string; body?: unknown; data?: Record<string, unknown>; tag: string; options: Record<string, unknown>; close: () => void };
|
||||
|
||||
function loadWorker(preview: (url: URL) => Preview, extra: { clients?: unknown[]; globals?: Record<string, unknown>; prefs?: unknown; openWindow?: (url: string) => Promise<unknown> } = {}) {
|
||||
function loadWorker(preview: (url: URL) => Preview, extra: { clients?: unknown[]; globals?: Record<string, unknown>; prefs?: unknown; quiet?: Record<string, boolean>; openWindow?: (url: string) => Promise<unknown> } = {}) {
|
||||
const listeners = new Map<string, (event: unknown) => void>();
|
||||
const shade = new Map<string, Shown>();
|
||||
const previews: URL[] = [];
|
||||
const cacheStore = new Map<string, string>();
|
||||
if (extra.prefs) cacheStore.set('https://mail.example.com/__push-state/prefs', JSON.stringify(extra.prefs));
|
||||
if (extra.quiet) cacheStore.set('https://mail.example.com/__push-state/quiet-logins', JSON.stringify(extra.quiet));
|
||||
const self = {
|
||||
location: { href: 'https://mail.example.com/sw.js', origin: 'https://mail.example.com' },
|
||||
addEventListener: (type: string, fn: (event: unknown) => void) => listeners.set(type, fn),
|
||||
@@ -144,31 +145,37 @@ describe('what a notification counts', () => {
|
||||
});
|
||||
|
||||
describe('with notifications off or do not disturb on', () => {
|
||||
it('announces nothing: shows and removes at once, without asking for the message', async () => {
|
||||
const { push, shade, previews } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1', 'Secret subject') }), { prefs: { sound: false, show: false } });
|
||||
const quiet = { 'me@example.org': true };
|
||||
|
||||
it('announces nothing for that login: shows and removes at once, without asking for the message', async () => {
|
||||
const { push, shade, previews } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1', 'Secret subject') }), { quiet });
|
||||
await push(delivery('a', 'e1'));
|
||||
expect(shade.size).toBe(0);
|
||||
expect(previews).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not bring the message up later when turned on again', async () => {
|
||||
const off = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }), { prefs: { sound: false, show: false } });
|
||||
await off.push(delivery('a', 'e1'));
|
||||
const { push, shade } = off;
|
||||
// Same store, now switched on: a redelivery stays quiet.
|
||||
it('still announces mail for another login', async () => {
|
||||
const { push, shade } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }), { quiet: { 'someone-else@example.org': true } });
|
||||
await push(delivery('a', 'e1'));
|
||||
expect(shade.size).toBe(0);
|
||||
expect(shade.size).toBe(1);
|
||||
});
|
||||
|
||||
it('does not bring the message up later', async () => {
|
||||
const off = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }), { quiet });
|
||||
await off.push(delivery('a', 'e1'));
|
||||
await off.push(delivery('a', 'e1'));
|
||||
expect(off.shade.size).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps showing with only the sound off', async () => {
|
||||
const { push, shade } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }), { prefs: { sound: false, show: true } });
|
||||
const { push, shade } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }), { prefs: { sound: false } });
|
||||
await push(delivery('a', 'e1'));
|
||||
expect(shade.get('bulwark-mail:a')!.options).toMatchObject({ silent: true, renotify: false });
|
||||
});
|
||||
|
||||
it('shows as before for prefs written by an older page', async () => {
|
||||
const { push, shade } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }), { prefs: { sound: true } });
|
||||
await push(delivery('a', 'e1'));
|
||||
it('shows for a push without a label, or with nothing stored', async () => {
|
||||
const { push, shade } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }), { quiet });
|
||||
await push({ kind: 'jmap-email-push', accountId: 'a', emailIds: ['e1'] });
|
||||
expect(shade.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
+17
-2
@@ -13,8 +13,6 @@ export const PUSH_STATE_CACHE = 'bulwark-push-state-v1';
|
||||
export interface PushPreferences {
|
||||
/** False: show push notifications for new mail without a sound. */
|
||||
sound: boolean;
|
||||
/** False: "Email notifications" is off or do not disturb is on. */
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
/** The cache key sw.js reads the preferences from. */
|
||||
@@ -22,6 +20,23 @@ export function pushPreferencesKey(origin: string): string {
|
||||
return `${origin}${BASE_PATH}/__push-state/prefs`;
|
||||
}
|
||||
|
||||
/**
|
||||
* True for a login with "Email notifications" off or do not disturb on. The worker
|
||||
* shows nothing for its mail. Per login, as settings are: the store only holds the
|
||||
* active login's, and the other logins' mail must not go quiet because of them.
|
||||
*/
|
||||
export async function writeLoginQuiet(login: string, quiet: boolean): Promise<void> {
|
||||
try {
|
||||
if (typeof caches === 'undefined') return;
|
||||
const cache = await caches.open(PUSH_STATE_CACHE);
|
||||
const key = `${window.location.origin}${BASE_PATH}/__push-state/quiet-logins`;
|
||||
const stored = (await (await cache.match(key))?.json().catch(() => null)) as Record<string, boolean> | null;
|
||||
await cache.put(key, new Response(JSON.stringify({ ...stored, [login]: quiet }), { headers: { 'Content-Type': 'application/json' } }));
|
||||
} catch {
|
||||
// Without the store the worker shows every push.
|
||||
}
|
||||
}
|
||||
|
||||
export async function writePushPreferences(prefs: PushPreferences): Promise<void> {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
|
||||
+11
-6
@@ -636,7 +636,7 @@ async function notifyAccount(accountId, emailIds, accountLabel) {
|
||||
|
||||
// Email notifications off, or do not disturb on: a browser still wants a
|
||||
// notification for the push, so one comes and goes with nothing on it.
|
||||
if (!(await showPreference())) {
|
||||
if (!(await showPreference(accountLabel))) {
|
||||
if (freshIds.length > 0) await writeNotifiedIds(accountId, notified.concat(freshIds));
|
||||
await showWithoutAlert(groupTag);
|
||||
return;
|
||||
@@ -903,13 +903,18 @@ async function soundPreference() {
|
||||
}
|
||||
}
|
||||
|
||||
/** The "Email notifications" switch and do not disturb, mirrored the same way. Nothing stored: show. */
|
||||
async function showPreference() {
|
||||
/**
|
||||
* The "Email notifications" switch and do not disturb. Each login has its own
|
||||
* settings, so the page leaves one answer per login (the push's label names it).
|
||||
* Nothing stored for it: show.
|
||||
*/
|
||||
async function showPreference(accountLabel) {
|
||||
if (!accountLabel) return true;
|
||||
try {
|
||||
const cache = await caches.open(PUSH_STATE_CACHE);
|
||||
const res = await cache.match(pushPreferencesKey());
|
||||
const prefs = res ? await res.json() : null;
|
||||
return !prefs || prefs.show !== false;
|
||||
const res = await cache.match(`${self.location.origin}${BASE_PATH}/__push-state/quiet-logins`);
|
||||
const quiet = res ? await res.json() : null;
|
||||
return !quiet || quiet[accountLabel] !== true;
|
||||
} catch (_) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -6,22 +6,12 @@ vi.mock('@/lib/push-preferences', () => ({ writePushPreferences }));
|
||||
import { useSettingsStore } from '../settings-store';
|
||||
|
||||
describe('do not disturb and push notifications (CO-13)', () => {
|
||||
it('keeps pushed notifications off screen while do not disturb is on', () => {
|
||||
it('keeps pushed notifications silent while do not disturb is on', () => {
|
||||
useSettingsStore.setState({ emailNotificationsEnabled: true, emailNotificationSound: true, doNotDisturb: false });
|
||||
writePushPreferences.mockClear();
|
||||
useSettingsStore.getState().updateSetting('doNotDisturb', true);
|
||||
expect(writePushPreferences).toHaveBeenLastCalledWith({ sound: false, show: false });
|
||||
expect(writePushPreferences).toHaveBeenLastCalledWith({ sound: false });
|
||||
useSettingsStore.getState().updateSetting('doNotDisturb', false);
|
||||
expect(writePushPreferences).toHaveBeenLastCalledWith({ sound: true, show: true });
|
||||
});
|
||||
|
||||
it('keeps them off screen with email notifications off, and only silent with the sound off', () => {
|
||||
useSettingsStore.setState({ emailNotificationsEnabled: true, emailNotificationSound: true, doNotDisturb: false });
|
||||
writePushPreferences.mockClear();
|
||||
useSettingsStore.getState().updateSetting('emailNotificationsEnabled', false);
|
||||
expect(writePushPreferences).toHaveBeenLastCalledWith({ sound: false, show: false });
|
||||
useSettingsStore.getState().updateSetting('emailNotificationsEnabled', true);
|
||||
useSettingsStore.getState().updateSetting('emailNotificationSound', false);
|
||||
expect(writePushPreferences).toHaveBeenLastCalledWith({ sound: false, show: true });
|
||||
expect(writePushPreferences).toHaveBeenLastCalledWith({ sound: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2276,19 +2276,16 @@ if (typeof window !== 'undefined') {
|
||||
applyInterfaceLayout(store.interfaceLayout, store.density);
|
||||
|
||||
// The service worker shows push notifications without this store, so it
|
||||
// reads these from its own Cache store (#1289).
|
||||
const pushShow = (s: SettingsState) => s.emailNotificationsEnabled && !s.doNotDisturb;
|
||||
const pushSound = (s: SettingsState) => pushShow(s) && s.emailNotificationSound;
|
||||
// reads the sound preference from its own Cache store (#1289). Whether they
|
||||
// show at all goes per login (NotificationActionsHost).
|
||||
const pushSound = (s: SettingsState) => s.emailNotificationsEnabled && s.emailNotificationSound && !s.doNotDisturb;
|
||||
let lastPushSound = pushSound(store);
|
||||
let lastPushShow = pushShow(store);
|
||||
void writePushPreferences({ sound: lastPushSound, show: lastPushShow });
|
||||
void writePushPreferences({ sound: lastPushSound });
|
||||
useSettingsStore.subscribe((state) => {
|
||||
const sound = pushSound(state);
|
||||
const show = pushShow(state);
|
||||
if (sound === lastPushSound && show === lastPushShow) return;
|
||||
if (sound === lastPushSound) return;
|
||||
lastPushSound = sound;
|
||||
lastPushShow = show;
|
||||
void writePushPreferences({ sound, show });
|
||||
void writePushPreferences({ sound });
|
||||
});
|
||||
|
||||
// The policy arrives after this store has rehydrated (and again with the
|
||||
|
||||
Reference in New Issue
Block a user