diff --git a/lib/__tests__/push-preferences.test.ts b/lib/__tests__/push-preferences.test.ts index 47a08b510..80dd45065 100644 --- a/lib/__tests__/push-preferences.test.ts +++ b/lib/__tests__/push-preferences.test.ts @@ -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'); }); }); diff --git a/lib/__tests__/sw-push-delivery.test.ts b/lib/__tests__/sw-push-delivery.test.ts index 0acba2790..96b1d737f 100644 --- a/lib/__tests__/sw-push-delivery.test.ts +++ b/lib/__tests__/sw-push-delivery.test.ts @@ -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 -- diff --git a/lib/push-preferences.ts b/lib/push-preferences.ts index a148e34a9..237b45d40 100644 --- a/lib/push-preferences.ts +++ b/lib/push-preferences.ts @@ -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. */ diff --git a/public/sw.js b/public/sw.js index 7681b4ac5..0b9ebc9e2 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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(); diff --git a/stores/__tests__/settings-push-dnd.test.ts b/stores/__tests__/settings-push-dnd.test.ts index 52df90530..87cd4c0a5 100644 --- a/stores/__tests__/settings-push-dnd.test.ts +++ b/stores/__tests__/settings-push-dnd.test.ts @@ -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 }); }); }); diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 440c3a190..dcb1e7aff 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -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