fix: keep push notifications off screen with email notifications off or do not disturb on

This commit is contained in:
Linus Rath
2026-11-04 03:13:56 +01:00
parent 7fb0242a7b
commit fc6a209727
6 changed files with 82 additions and 18 deletions
+8 -8
View File
@@ -17,18 +17,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 });
await writePushPreferences({ sound: false, show: true });
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 });
expect(await response.json()).toEqual({ sound: false, show: true });
});
it('does nothing without a Cache store', async () => {
vi.stubGlobal('caches', undefined);
const { writePushPreferences } = await import('../push-preferences');
await expect(writePushPreferences({ sound: true })).resolves.toBeUndefined();
await expect(writePushPreferences({ sound: true, show: true })).resolves.toBeUndefined();
expect(put).not.toHaveBeenCalled();
});
@@ -42,7 +42,7 @@ describe('writePushPreferences', () => {
},
});
const { writePushPreferences } = await import('../push-preferences');
await expect(writePushPreferences({ sound: true })).resolves.toBeUndefined();
await expect(writePushPreferences({ sound: true, show: true })).resolves.toBeUndefined();
expect(put).not.toHaveBeenCalled();
});
});
@@ -54,21 +54,21 @@ describe('settings store push preference mirror', () => {
vi.doMock('@/lib/push-preferences', () => ({ writePushPreferences }));
const { useSettingsStore } = await import('@/stores/settings-store');
expect(writePushPreferences).toHaveBeenCalledWith({ sound: true });
expect(writePushPreferences).toHaveBeenCalledWith({ sound: true, show: true });
writePushPreferences.mockClear();
useSettingsStore.getState().updateSetting('emailNotificationSound', false);
expect(writePushPreferences).toHaveBeenCalledWith({ sound: false });
expect(writePushPreferences).toHaveBeenCalledWith({ sound: false, show: true });
writePushPreferences.mockClear();
// Unrelated settings do not rewrite it.
useSettingsStore.getState().updateSetting('emailNotificationsEnabled', true);
expect(writePushPreferences).not.toHaveBeenCalled();
// Email notifications off means no sound for push either.
// 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 });
expect(writePushPreferences).toHaveBeenLastCalledWith({ sound: false, show: false });
vi.doUnmock('@/lib/push-preferences');
});
});
+30
View File
@@ -143,4 +143,34 @@ 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 } });
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.
await push(delivery('a', 'e1'));
expect(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 } });
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'));
expect(shade.size).toBe(1);
});
});
// -- end of cases --
+2
View File
@@ -13,6 +13,8 @@ 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. */
+20
View File
@@ -622,6 +622,14 @@ async function notifyAccount(accountId, emailIds, accountLabel) {
return;
}
// 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 (freshIds.length > 0) await writeNotifiedIds(accountId, notified.concat(freshIds));
await showWithoutAlert(groupTag);
return;
}
// Best effort: ask the webmail to look up the email so we can build a useful
// notification. With ids from the server we ask for exactly the newest of
// them; otherwise the API falls back to the newest unread Inbox message. If
@@ -880,6 +888,18 @@ async function soundPreference() {
}
}
/** The "Email notifications" switch and do not disturb, mirrored the same way. Nothing stored: show. */
async function showPreference() {
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;
} catch (_) {
return true;
}
}
/** True when another account alerted audibly less than QUIET_WINDOW_MS ago; otherwise records this one. */
async function withinQuietWindow(accountId) {
const now = Date.now();
+13 -3
View File
@@ -6,12 +6,22 @@ vi.mock('@/lib/push-preferences', () => ({ writePushPreferences }));
import { useSettingsStore } from '../settings-store';
describe('do not disturb and push notifications (CO-13)', () => {
it('keeps pushed notifications silent while do not disturb is on', () => {
it('keeps pushed notifications off screen 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 });
expect(writePushPreferences).toHaveBeenLastCalledWith({ sound: false, show: false });
useSettingsStore.getState().updateSetting('doNotDisturb', false);
expect(writePushPreferences).toHaveBeenLastCalledWith({ sound: true });
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 });
});
});
+9 -7
View File
@@ -2276,17 +2276,19 @@ if (typeof window !== 'undefined') {
applyInterfaceLayout(store.interfaceLayout, store.density);
// The service worker shows push notifications without this store, so it
// reads the sound preference from its own Cache store (#1289).
// Do not disturb can't keep a push from showing (browsers require one per
// push), but it keeps it silent.
const pushSound = (s: SettingsState) => s.emailNotificationsEnabled && s.emailNotificationSound && !s.doNotDisturb;
// reads these from its own Cache store (#1289).
const pushShow = (s: SettingsState) => s.emailNotificationsEnabled && !s.doNotDisturb;
const pushSound = (s: SettingsState) => pushShow(s) && s.emailNotificationSound;
let lastPushSound = pushSound(store);
void writePushPreferences({ sound: lastPushSound });
let lastPushShow = pushShow(store);
void writePushPreferences({ sound: lastPushSound, show: lastPushShow });
useSettingsStore.subscribe((state) => {
const sound = pushSound(state);
if (sound === lastPushSound) return;
const show = pushShow(state);
if (sound === lastPushSound && show === lastPushShow) return;
lastPushSound = sound;
void writePushPreferences({ sound });
lastPushShow = show;
void writePushPreferences({ sound, show });
});
// The policy arrives after this store has rehydrated (and again with the