feat(notifications): apply inbox-only push filter immediately on toggle ✨

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.
This commit is contained in:
Aaron Guise
2026-09-23 11:07:58 +12:00
parent 405f6b78db
commit c89fdcbf0c
2 changed files with 124 additions and 1 deletions
@@ -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<typeof import('../settings-section')>()),
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
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(<NotificationSettings />);
// 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(<NotificationSettings />);
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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce(false);
render(<NotificationSettings />);
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();
});
});
+38 -1
View File
@@ -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({