"use client"; import { useCallback, useEffect, useState } from 'react'; import { useTranslations } from 'next-intl'; import { useSettingsStore } from '@/stores/settings-store'; 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 'lucide-react'; import { usePolicyStore } from '@/stores/policy-store'; import { useAuthStore } from '@/stores/auth-store'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { useConfirmDialog } from '@/hooks/use-confirm-dialog'; import { WebPushUnsupportedError, disableWebPush, enableWebPush, isWebPushEnabled, isWebPushSupported, listPushDevices, revokePushDevice, } from '@/lib/web-push'; import type { PushDevice } from '@/lib/web-push'; 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 { emailNotificationsEnabled, emailNotificationSound, notificationSoundChoice, calendarNotificationsEnabled, calendarNotificationSound, calendarInvitationParsingEnabled, 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(); 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; useEffect(() => { if (!supported) return; if (!client) return; const accountId = client.getAccountId(); if (!accountId) return; void (async () => { const enabled = await isWebPushEnabled(accountId); setPushStatus(enabled ? { kind: 'enabled' } : { kind: 'idle' }); })(); }, [supported, client]); // 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 busy = pushStatus.kind === 'busy'; const pushEnabled = pushStatus.kind === 'enabled'; const statusDescription = pushStatus.kind === 'unsupported' ? `${t('push.status_unsupported')} ${t('push.ios_hint')}` : busy ? t('push.status_busy') : pushEnabled ? t('push.status_active') : t('push.status_inactive'); // `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, }); setPushStatus({ kind: 'enabled' }); } catch (err) { if (err instanceof WebPushUnsupportedError) { setPushStatus({ kind: 'unsupported' }); return; } setPushStatus({ kind: 'error', message: err instanceof Error ? err.message : 'Failed to enable push', }); } finally { await refreshDevices(); } }; 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 (
{pushEnabled && ( )} void (checked ? handleEnablePush() : handleDisablePush())} disabled={busy || pushStatus.kind === 'unsupported' || !client} />
{relayChoiceFixed ? ( {activeRelayLabel} ) : ( { const choice = value as NotificationSoundChoice; updateSetting('notificationSoundChoice', choice); playNotificationSound(choice); }} options={soundOptions} />
{!isSettingHidden('emailNotificationsEnabled') && ( updateSetting('emailNotificationsEnabled', checked)} /> )} updateSetting('emailNotificationSound', checked)} disabled={!emailNotificationsEnabled} /> {!isSettingHidden('calendarNotificationsEnabled') && ( updateSetting('calendarNotificationsEnabled', checked)} /> )} updateSetting('calendarNotificationSound', checked)} disabled={!calendarNotificationsEnabled} /> updateSetting('calendarInvitationParsingEnabled', checked)} /> ); }