"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslations } from 'next-intl'; import { useSettingsStore } from '@/stores/settings-store'; import { useEmailStore } from '@/stores/email-store'; import { useIdentityStore } from '@/stores/identity-store'; import { jmapErrorType } from '@/lib/jmap/jmap-error'; import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section'; import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-sound'; import type { NotificationSoundChoice } from '@/lib/notification-sound'; import { Button } from '@/components/ui/button'; import { Loader2, RefreshCw, Volume2, XCircle } from '@/components/icons'; import { usePolicyStore } from '@/stores/policy-store'; import { useAuthStore } from '@/stores/auth-store'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { MultiAccountPushSettings } from './multi-account-push'; import { useConfirmDialog } from '@/hooks/use-confirm-dialog'; import { WebPushUnsupportedError, disableWebPush, enableWebPush, isWebPushEnabled, isWebPushSupported, listPushDevices, mailAccountsWithoutPush, revokePushDevice, serverSupportsEmailPush, } from '@/lib/web-push'; import type { PushDevice } from '@/lib/web-push'; import { IS_LITE } from '@/lib/lite'; import { resolveActiveRelayUrl, resolvePushRelayOptions, } from '@/lib/push-relays'; type PushStatus = | { kind: 'idle' } | { kind: 'busy' } | { kind: 'enabled' } | { kind: 'unsupported' } | { kind: 'error'; message: string }; type DevicesState = | { kind: 'idle' } | { kind: 'loading' } | { kind: 'loaded'; devices: PushDevice[] } | { kind: 'error' }; export function NotificationSettings() { const t = useTranslations('settings.notifications'); const tAppearance = useTranslations('settings.appearance'); const { emailNotificationsEnabled, emailNotificationSound, notificationSoundChoice, pushNotifyInboxOnly, calendarNotificationsEnabled, calendarNotificationSound, faviconUnreadBadge, pushRelayUrl, updateSetting, } = useSettingsStore(); const { isSettingLocked, isSettingHidden } = usePolicyStore(); const policy = usePolicyStore((s) => s.policy); const pushRelayLocked = policy.pushRelayUrlLocked === true; const client = useAuthStore((s) => s.client); const username = useAuthStore((s) => s.username); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const supported = typeof window !== 'undefined' && isWebPushSupported(); // "Inbox only" is enforced by the server's emailPush filter; without that // capability (Stalwart < 0.16.16) the toggle would do nothing. const inboxOnlyAvailable = supported && !!client && serverSupportsEmailPush(client); const [pushStatus, setPushStatus] = useState( supported ? { kind: 'idle' } : { kind: 'unsupported' }, ); const [devices, setDevices] = useState({ kind: 'idle' }); const [revokingId, setRevokingId] = useState(null); // Relay URLs come from admin policy only - users pick one of the offered // relays, they never type a URL. const relayOptions = resolvePushRelayOptions(policy); const activeRelayUrl = resolveActiveRelayUrl(policy, pushRelayUrl); const relayChoiceFixed = pushRelayLocked || relayOptions.length < 2; const activeRelayLabel = relayOptions.find((option) => option.url === activeRelayUrl)?.label ?? activeRelayUrl; const busy = pushStatus.kind === 'busy'; useEffect(() => { if (!supported) return; if (!client) return; const accountId = client.getAccountId(); if (!accountId) return; let cancelled = false; const refreshStatus = async () => { try { const enabled = await isWebPushEnabled(accountId); if (!cancelled) { setPushStatus(enabled ? { kind: 'enabled' } : { kind: 'idle' }); } } catch { if (!cancelled) setPushStatus({ kind: 'idle' }); } }; const onVisible = () => { if (document.visibilityState === 'visible') void refreshStatus(); }; if (pushStatus.kind === 'busy' || pushStatus.kind === 'error') return; void refreshStatus(); window.addEventListener('focus', onVisible); document.addEventListener('visibilitychange', onVisible); return () => { cancelled = true; window.removeEventListener('focus', onVisible); document.removeEventListener('visibilitychange', onVisible); }; }, [supported, client, pushStatus.kind]); // The device list is worth loading even when push is off on this browser - // revoking a stale registration left on another device is exactly what you // come here for. const refreshDevices = useCallback(async () => { if (!client) return; setDevices({ kind: 'loading' }); try { setDevices({ kind: 'loaded', devices: await listPushDevices({ client, relayBaseUrl: activeRelayUrl }) }); } catch { setDevices({ kind: 'error' }); } }, [client, activeRelayUrl]); useEffect(() => { void refreshDevices(); }, [refreshDevices]); const pushEnabled = pushStatus.kind === 'enabled'; // In the static Lite build push is off by design (no service worker, see // components/service-worker-registration.tsx), not a browser limitation. const statusDescription = pushStatus.kind === 'unsupported' ? (IS_LITE ? t('push.status_lite') : `${t('push.status_unsupported')} ${t('push.ios_hint')}`) : busy ? t('push.status_busy') : pushEnabled ? t('push.status_active') : t('push.status_inactive'); // Stalwart pushes only the user's own mailboxes and their groups', so // mailboxes shared by other users stay silent in the background (they still // update while the app is open). Say so instead of leaving users to wonder. const mailboxes = useEmailStore((s) => s.mailboxes); // On 0.16 the user's identities tell their groups from mailboxes shared by // ACL (see mailAccountsWithoutPush). const identities = useIdentityStore((s) => s.identities); const sharedMailboxesWithoutPush = useMemo(() => { // Re-read after each enable run too: it may have added refused accounts. if (!client || pushStatus.kind === 'unsupported') return false; const accountIds = mailboxes.flatMap((m) => (m.isShared && m.accountId ? [m.accountId] : [])); return mailAccountsWithoutPush(client, accountIds, identities.map((identity) => identity.email)).length > 0; }, [client, mailboxes, identities, pushStatus.kind]); // The refusals a user can do something about get readable text; anything // else keeps the error's own message. const pushErrorMessage = (err: unknown, fallback: string) => { switch (jmapErrorType(err)) { case 'forbidden': return t('push.error_forbidden'); case 'overQuota': return t('push.error_over_quota'); default: return err instanceof Error ? err.message : fallback; } }; // `forceRecreate` is what the Re-register button is for: without it the enable // path just refreshes the existing subscription's expiry, so a registration // holding stale shared-mailbox access survives the round trip (#841). const handleEnablePush = async (forceRecreate = false) => { if (!client) { setPushStatus({ kind: 'error', message: 'Sign in first' }); return; } setPushStatus({ kind: 'busy' }); try { await enableWebPush({ client, relayBaseUrl: activeRelayUrl, accountLabel: username ?? undefined, forceRecreate, inboxOnly: pushNotifyInboxOnly, mutes: useSettingsStore.getState().notificationMutes, }); setPushStatus({ kind: 'enabled' }); } catch (err) { if (err instanceof WebPushUnsupportedError) { setPushStatus({ kind: 'unsupported' }); return; } setPushStatus({ kind: 'error', message: pushErrorMessage(err, 'Failed to enable push') }); } finally { await refreshDevices(); } }; // 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, mutes: useSettingsStore.getState().notificationMutes, }) .then(() => { if (!cancelled) setPushStatus({ kind: 'enabled' }); }) .catch((err) => { if (cancelled) return; if (err instanceof WebPushUnsupportedError) { setPushStatus({ kind: 'unsupported' }); return; } setPushStatus({ kind: 'error', message: pushErrorMessage(err, 'Failed to update push filter') }); }) .finally(() => { if (!cancelled) void refreshDevices(); }); return () => { cancelled = true; }; // Only the setting flip should trigger a re-sync. client / relay / username // are read live from the recreated closure; depending on pushStatus.kind // here would re-fire the effect on the busy->enabled transition it causes // and cancel its own in-flight call (leaving the row stuck on "Working"). // eslint-disable-next-line react-hooks/exhaustive-deps }, [pushNotifyInboxOnly]); const handleDisablePush = async () => { if (!client) return; const confirmed = await confirmDialog({ title: t('push.confirm_disable_title'), message: t('push.confirm_disable_message'), confirmText: t('push.disable'), variant: 'destructive', }); if (!confirmed) return; setPushStatus({ kind: 'busy' }); try { await disableWebPush({ client, relayBaseUrl: activeRelayUrl }); setPushStatus({ kind: 'idle' }); } catch (err) { setPushStatus({ kind: 'error', message: err instanceof Error ? err.message : 'Failed to disable push', }); } finally { await refreshDevices(); } }; const handleRevokeDevice = async (device: PushDevice) => { if (!client) return; const confirmed = await confirmDialog({ title: t('push.confirm_revoke_title'), message: device.isThisDevice ? t('push.confirm_revoke_message_this') : t('push.confirm_revoke_message'), confirmText: t('push.revoke'), variant: 'destructive', }); if (!confirmed) return; setRevokingId(device.id); try { await revokePushDevice({ client, device, relayBaseUrl: activeRelayUrl }); if (device.isThisDevice) setPushStatus({ kind: 'idle' }); } catch (err) { setPushStatus({ kind: 'error', message: err instanceof Error ? err.message : 'Failed to revoke device', }); } finally { setRevokingId(null); await refreshDevices(); } }; // Spelled out rather than interpolated so the translation-coverage test can // see every key that reaches next-intl. const relayStatusLabel = (status: PushDevice['relayStatus']) => { if (status === 'active') return t('push.device_status_active'); if (status === 'inactive') return t('push.device_status_inactive'); return t('push.device_status_unknown'); }; const soundOptions = NOTIFICATION_SOUNDS.map((s) => ({ value: s.id, label: t(`sounds.${s.id}`), })); return (
{!isSettingHidden('emailNotificationsEnabled') && ( updateSetting('emailNotificationsEnabled', checked)} /> )} updateSetting('emailNotificationSound', checked)} disabled={!emailNotificationsEnabled} /> {inboxOnlyAvailable && !isSettingHidden('pushNotifyInboxOnly') && ( updateSetting('pushNotifyInboxOnly', checked)} disabled={!emailNotificationsEnabled} /> )} {/* The tab/app icon badge is a new-mail signal too; people look for it here. */} updateSetting('faviconUnreadBadge', checked)} /> {!isSettingHidden('calendarNotificationsEnabled') && ( updateSetting('calendarNotificationsEnabled', checked)} /> )} updateSetting('calendarNotificationSound', checked)} disabled={!calendarNotificationsEnabled} />
updateSetting('pushRelayUrl', value)} options={relayOptions.map((option) => ({ value: option.url, label: option.label }))} disabled={busy || pushStatus.kind === 'unsupported'} /> )} {pushStatus.kind === 'error' && (

{pushStatus.message}

)} {client && (

{devices.kind === 'loading' && t('push.devices_loading')} {devices.kind === 'error' && t('push.devices_error')} {devices.kind === 'loaded' && devices.devices.length === 0 && t('push.devices_empty')}

{devices.kind === 'loaded' && devices.devices.length > 0 && (
    {devices.devices.map((device) => (
  • {device.isThisDevice ? t('push.device_this') : t('push.device_other')} {device.deviceClientId.slice(0, 8)}

    {relayStatusLabel(device.relayStatus)} {device.expires ? ` ยท ${t('push.device_expires', { date: new Date(device.expires).toLocaleDateString() })}` : ''}

  • ))}
)}
)}
); }