From c89fdcbf0c2ce1020c7b33daead40e18f6cbf0db Mon Sep 17 00:00:00 2001 From: Aaron Guise Date: Mon, 7 Sep 2026 15:21:35 +1200 Subject: [PATCH] feat(notifications): apply inbox-only push filter immediately on toggle :sparkles: Flipping the setting only wrote to the store; the server-side JMAP push filter stayed stale until the next app launch ran resyncWebPush. When push is already enabled on the device, re-run enableWebPush on change so the toggle takes effect without a restart. --- .../__tests__/notification-settings.test.tsx | 86 +++++++++++++++++++ components/settings/notification-settings.tsx | 39 ++++++++- 2 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 components/settings/__tests__/notification-settings.test.tsx diff --git a/components/settings/__tests__/notification-settings.test.tsx b/components/settings/__tests__/notification-settings.test.tsx new file mode 100644 index 000000000..3477523e0 --- /dev/null +++ b/components/settings/__tests__/notification-settings.test.tsx @@ -0,0 +1,86 @@ +import { render, waitFor } from '@testing-library/react'; +import { act } from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NotificationSettings } from '../notification-settings'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import * as webPush from '@/lib/web-push'; + +// next-intl's useTranslations -> identity so labels are their keys. +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})); + +// Keep the section wrapper out of the way. +vi.mock('../settings-section', async (importOriginal) => ({ + ...(await importOriginal()), + SettingsSection: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('@/lib/web-push', () => ({ + WebPushUnsupportedError: class WebPushUnsupportedError extends Error {}, + isWebPushSupported: () => true, + isWebPushEnabled: vi.fn(async () => true), + enableWebPush: vi.fn(async () => ({ subscriptionId: 'sub-1' })), + disableWebPush: vi.fn(async () => undefined), + listPushDevices: vi.fn(async () => []), + revokePushDevice: vi.fn(async () => undefined), +})); + +const fakeClient = { getAccountId: () => 'acct-1' } as unknown as ReturnType< + typeof useAuthStore.getState +>['client']; + +describe('NotificationSettings - pushNotifyInboxOnly', () => { + beforeEach(() => { + vi.clearAllMocks(); + useSettingsStore.setState({ emailNotificationsEnabled: true, pushNotifyInboxOnly: false }); + useAuthStore.setState({ client: fakeClient, username: 'me@example.com' }); + usePolicyStore.setState({ + ...usePolicyStore.getState(), + policy: {}, + isSettingLocked: () => false, + isSettingHidden: () => false, + } as never); + }); + + it('does not re-sync push on mount', async () => { + render(); + // Let the "is push enabled?" effect settle. + await waitFor(() => expect(webPush.isWebPushEnabled).toHaveBeenCalled()); + expect(webPush.enableWebPush).not.toHaveBeenCalled(); + }); + + it('re-runs enableWebPush with the new flag when the toggle flips while push is on', async () => { + render(); + await waitFor(() => expect(webPush.isWebPushEnabled).toHaveBeenCalled()); + + act(() => { + useSettingsStore.getState().updateSetting('pushNotifyInboxOnly', true); + }); + + await waitFor(() => expect(webPush.enableWebPush).toHaveBeenCalledTimes(1)); + expect(webPush.enableWebPush).toHaveBeenCalledWith( + expect.objectContaining({ client: fakeClient, inboxOnly: true }), + ); + // forceRecreate stays off - a filter PATCH, not a destroy/recreate. + expect( + (webPush.enableWebPush as ReturnType).mock.calls[0][0], + ).not.toHaveProperty('forceRecreate', true); + }); + + it('does not re-sync when push is disabled on this device', async () => { + (webPush.isWebPushEnabled as ReturnType).mockResolvedValueOnce(false); + render(); + await waitFor(() => expect(webPush.isWebPushEnabled).toHaveBeenCalled()); + + act(() => { + useSettingsStore.getState().updateSetting('pushNotifyInboxOnly', true); + }); + + // Give any effect a chance to fire. + await Promise.resolve(); + expect(webPush.enableWebPush).not.toHaveBeenCalled(); + }); +}); diff --git a/components/settings/notification-settings.tsx b/components/settings/notification-settings.tsx index d55cb0676..9adaef737 100644 --- a/components/settings/notification-settings.tsx +++ b/components/settings/notification-settings.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslations } from 'next-intl'; import { useSettingsStore } from '@/stores/settings-store'; import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section'; @@ -148,6 +148,43 @@ export function NotificationSettings() { } }; + // Flipping "Inbox only" only writes the setting; the server-side JMAP push + // filter stays stale until the next app launch runs resyncWebPush. When push + // is already on for this device, re-run the enable flow now so the toggle + // takes effect immediately instead of "on next restart". The ref seeds to the + // mounted value so this never fires on mount or on an unrelated re-render. + const lastSyncedInboxOnly = useRef(pushNotifyInboxOnly); + useEffect(() => { + if (lastSyncedInboxOnly.current === pushNotifyInboxOnly) return; + lastSyncedInboxOnly.current = pushNotifyInboxOnly; + // Push off on this device: enabling it later builds the filter from the + // current setting, so there's nothing to re-sync now. + if (!client || pushStatus.kind !== 'enabled') return; + + let cancelled = false; + setPushStatus({ kind: 'busy' }); + void enableWebPush({ + client, + relayBaseUrl: activeRelayUrl, + accountLabel: username ?? undefined, + inboxOnly: pushNotifyInboxOnly, + }) + .then(() => { if (!cancelled) setPushStatus({ kind: 'enabled' }); }) + .catch((err) => { + if (cancelled) return; + if (err instanceof WebPushUnsupportedError) { + setPushStatus({ kind: 'unsupported' }); + return; + } + setPushStatus({ + kind: 'error', + message: err instanceof Error ? err.message : 'Failed to update push filter', + }); + }) + .finally(() => { if (!cancelled) void refreshDevices(); }); + return () => { cancelled = true; }; + }, [pushNotifyInboxOnly, client, pushStatus.kind, activeRelayUrl, username, refreshDevices]); + const handleDisablePush = async () => { if (!client) return; const confirmed = await confirmDialog({