import React, { useCallback, useEffect, useState } from 'react'; import { ActivityIndicator, Platform, StyleSheet, Text, TextInput, View, } from 'react-native'; import { CheckCircle2, RefreshCw, XCircle } from '../icons'; import Button from '../Button'; import Dialog from '../Dialog'; import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { useAuthStore } from '../../stores/auth-store'; import { useLocaleStore } from '../../stores/locale-store'; import { useSettingsStore } from '../../stores/settings-store'; import { DEFAULT_RELAY_BASE_URL, disablePushForAccount, getStoredRelayBaseUrl, isPushEnabledForAccount, isPushSupported, isValidRelayUrl, listPushDevices, PushSetupError, readPushAccountIds, revokePushDevice, setStoredRelayBaseUrl, setupPushNotifications, getStoredPushTransport, setStoredPushTransport, type PushDevice, type PushSetupPhase, type PushTransport, } from '../../lib/push-notifications'; import { getSavedUnifiedPushDistributor, getUnifiedPushDistributors, isUnifiedPushSupported, saveUnifiedPushDistributor, } from '../../lib/unified-push'; import { canBadgeAppIcon } from '../../lib/app-badge'; type PushStatus = | { kind: 'idle' } | { kind: 'busy'; message: string } | { kind: 'enabled' } | { kind: 'error'; message: string }; type DevicesState = | { kind: 'idle' } | { kind: 'loading' } | { kind: 'loaded'; devices: PushDevice[] } | { kind: 'error' }; const PHASE_KEYS: Record = { platform: { key: 'settings.notifications.push.phase_platform', fallback: 'Platform' }, permission: { key: 'settings.notifications.push.phase_permission', fallback: 'Notification permission' }, token: { key: 'settings.notifications.push.phase_token', fallback: 'Device token' }, distributor: { key: 'settings.notifications.push.phase_distributor', fallback: 'UnifiedPush distributor' }, account: { key: 'settings.notifications.push.phase_account', fallback: 'Account' }, relay: { key: 'settings.notifications.push.phase_relay', fallback: 'Relay registration' }, jmap: { key: 'settings.notifications.push.phase_jmap', fallback: 'Mail server subscription' }, verify: { key: 'settings.notifications.push.phase_verify', fallback: 'Verification' }, }; export function NotificationSettings() { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); const username = useAuthStore((s) => s.username); const activeAccountId = useAuthStore((s) => s.activeAccountId); const client = useAuthStore((s) => s.client); const hydrated = useSettingsStore((s) => s.hydrated); const hydrate = useSettingsStore((s) => s.hydrate); const update = useSettingsStore((s) => s.updateSetting); const emailEnabled = useSettingsStore((s) => s.emailNotificationsEnabled); const calEnabled = useSettingsStore((s) => s.calendarNotificationsEnabled); const invitationParsing = useSettingsStore((s) => s.calendarInvitationParsingEnabled); const appIconBadge = useSettingsStore((s) => s.appIconUnreadBadge); const supported = isPushSupported(); const upSupported = isUnifiedPushSupported(); const [relayUrl, setRelayUrl] = useState(DEFAULT_RELAY_BASE_URL); const [transport, setTransport] = useState('fcm'); const [distributors, setDistributors] = useState([]); const [savedDistributor, setSavedDistributor] = useState(null); const [pushEnabled, setPushEnabled] = useState(false); const [pushStatus, setPushStatus] = useState({ kind: 'idle' }); const [confirmDisable, setConfirmDisable] = useState(false); const [devices, setDevices] = useState({ kind: 'idle' }); const [revokeTarget, setRevokeTarget] = useState(null); const [revokingId, setRevokingId] = useState(null); useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]); useEffect(() => { let cancelled = false; void (async () => { const stored = await getStoredRelayBaseUrl(); const enabled = activeAccountId ? await isPushEnabledForAccount(activeAccountId) : false; if (cancelled) return; if (stored) setRelayUrl(stored); setPushEnabled(enabled); setPushStatus(enabled ? { kind: 'enabled' } : { kind: 'idle' }); const storedTransport = await getStoredPushTransport(); const upDistributors = upSupported ? await getUnifiedPushDistributors() : []; const upSaved = upSupported ? await getSavedUnifiedPushDistributor() : null; if (cancelled) return; setTransport(storedTransport ?? 'fcm'); setDistributors(upDistributors); setSavedDistributor(upSaved); })(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeAccountId]); const trimmed = relayUrl.trim().replace(/\/+$/, ''); const relayValid = isValidRelayUrl(trimmed); const busy = pushStatus.kind === 'busy'; const refreshDevices = useCallback(async () => { if (!activeAccountId || !client) return; setDevices({ kind: 'loading' }); try { const list = await listPushDevices({ accountId: activeAccountId, relayBaseUrl: trimmed }); setDevices({ kind: 'loaded', devices: list }); } catch { setDevices({ kind: 'error' }); } }, [activeAccountId, client, trimmed]); useEffect(() => { if (!supported) return; void refreshDevices(); // Only on mount / account change - the relay URL field should not refetch // on every keystroke. // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeAccountId, supported]); const describeError = (error: unknown): string => { if (error instanceof PushSetupError) { const phase = PHASE_KEYS[error.phase]; return `${t(phase.key, phase.fallback)}: ${error.message}`; } return error instanceof Error ? error.message : t('settings.notifications.push.setup_failed', 'Setup failed'); }; const handleEnable = async (forceRecreate = false) => { if (!relayValid) { setPushStatus({ kind: 'error', message: t('settings.notifications.push.relay_invalid', 'Enter a valid https:// URL'), }); return; } setPushStatus({ kind: 'busy', message: t('settings.notifications.push.status_busy', 'Working…'), }); try { await setStoredRelayBaseUrl(trimmed); await setupPushNotifications({ relayBaseUrl: trimmed, accountLabel: username ?? undefined, forceRecreate, }); setPushEnabled(true); setPushStatus({ kind: 'enabled' }); } catch (error) { setPushStatus({ kind: 'error', message: describeError(error) }); } finally { void refreshDevices(); } }; // Switching the transport while push is on re-registers immediately so the // relay record flips to the new delivery path; while off it just records // the preference for the next enable. const handleSelectTransport = async (next: PushTransport) => { if (next === transport || busy) return; setTransport(next); await setStoredPushTransport(next); if (pushEnabled) void handleEnable(true); }; const handleSelectDistributor = async (distributor: string) => { if (distributor === savedDistributor || busy) return; setSavedDistributor(distributor); try { await saveUnifiedPushDistributor(distributor); } catch { return; } if (pushEnabled && transport === 'unifiedpush') void handleEnable(true); }; const performDisable = async () => { setConfirmDisable(false); setPushStatus({ kind: 'busy', message: t('settings.notifications.push.status_busy', 'Working…'), }); try { if (activeAccountId) { await disablePushForAccount(activeAccountId); } // The relay base URL is a device-wide setting shared with any other // accounts that are still using push. Only clear it when no accounts // are using push any more. const remaining = await readPushAccountIds(); if (remaining.length === 0) { await setStoredRelayBaseUrl(null); setRelayUrl(DEFAULT_RELAY_BASE_URL); } setPushEnabled(false); setPushStatus({ kind: 'idle' }); } catch (error) { setPushStatus({ kind: 'error', message: describeError(error) }); } finally { void refreshDevices(); } }; const performRevoke = async () => { const device = revokeTarget; setRevokeTarget(null); if (!device || !activeAccountId) return; setRevokingId(device.id); try { await revokePushDevice({ accountId: activeAccountId, device, relayBaseUrl: trimmed }); if (device.isThisDevice) { setPushEnabled(false); setPushStatus({ kind: 'idle' }); } } catch (error) { setPushStatus({ kind: 'error', message: describeError(error) }); } finally { setRevokingId(null); void refreshDevices(); } }; const relayStatusLabel = (status: PushDevice['relayStatus']) => { if (status === 'active') return t('settings.notifications.push.device_status_active', 'Delivering notifications'); if (status === 'inactive') return t('settings.notifications.push.device_status_inactive', 'Never delivered a notification'); return t('settings.notifications.push.device_status_unknown', 'Not known to this relay'); }; const statusDescription = !supported ? t('settings.notifications.push.status_android_only', 'Background notifications are available on Android only.') : pushStatus.kind === 'enabled' ? t('settings.notifications.push.status_active', 'Active on this device') : pushStatus.kind === 'busy' ? pushStatus.message : t('settings.notifications.push.status_inactive', 'Not enabled on this device'); return ( {pushStatus.kind === 'busy' && } {pushStatus.kind === 'enabled' && } { if (checked) void handleEnable(); else setConfirmDisable(true); }} disabled={!supported || busy || !client} /> {upSupported && ( <> {transport === 'unifiedpush' && distributors.length === 0 && ( {t( 'settings.notifications.push.up_no_distributor', 'No UnifiedPush distributor app is installed on this device. Install one (for example ntfy) and try again.', )} )} {transport === 'unifiedpush' && distributors.length > 1 && ( {distributors.map((d) => ( ))} )} )} {!relayValid && ( {t('settings.notifications.push.relay_invalid', 'Enter a valid https:// URL')} )} {pushEnabled && ( )} {pushStatus.kind === 'error' && ( {pushStatus.message} )} {supported && client && ( {devices.kind === 'loading' && t('settings.notifications.push.devices_loading', 'Loading registered devices…')} {devices.kind === 'error' && t('settings.notifications.push.devices_error', 'Could not load registered devices.')} {devices.kind === 'loaded' && devices.devices.length === 0 && t('settings.notifications.push.devices_empty', 'No devices are registered for background notifications.')} {devices.kind === 'loaded' && devices.devices.length > 0 && ( {devices.devices.map((device, index) => ( 0 && styles.deviceRowBorder]} > {device.isThisDevice ? t('settings.notifications.push.device_this', 'This device') : t('settings.notifications.push.device_other', 'Other device')} {device.deviceClientId.slice(0, 8)} {relayStatusLabel(device.relayStatus)} {device.expires ? ` · ${t('settings.notifications.push.device_expires', 'Expires {date}', { date: new Date(device.expires).toLocaleDateString(), })}` : ''} ))} )} )} update('emailNotificationsEnabled', v)} /> {canBadgeAppIcon() && ( update('appIconUnreadBadge', v)} /> )} {Platform.OS === 'android' && ( {t( 'settings.notifications.email.sound_channel_hint', 'Sound and vibration for mail alerts are controlled by the Android notification channel: long-press a notification or open the system app settings.', )} )} update('calendarNotificationsEnabled', v)} /> update('calendarInvitationParsingEnabled', v)} /> setConfirmDisable(false)} onConfirm={performDisable} /> setRevokeTarget(null)} onConfirm={performRevoke} /> ); } function makeStyles(c: ThemePalette) { return StyleSheet.create({ container: { gap: spacing.xxxl }, row: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm }, fieldDescription: { ...typography.caption, color: c.mutedForeground, flexShrink: 1, }, urlInput: { ...typography.body, color: c.text, backgroundColor: c.surface, borderWidth: 1, borderColor: c.border, borderRadius: radius.sm, paddingHorizontal: spacing.md, paddingVertical: 8, alignSelf: 'stretch', }, urlInputInvalid: { borderColor: c.error }, distributorList: { flexDirection: 'column', alignItems: 'flex-end', gap: spacing.sm }, errorRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 6, marginTop: spacing.sm }, errorText: { ...typography.caption, color: c.error }, actions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginTop: spacing.sm }, devicesHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: spacing.sm, }, deviceList: { borderWidth: 1, borderColor: c.border, borderRadius: radius.md, }, deviceRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.md, paddingHorizontal: spacing.md, paddingVertical: spacing.sm + 2, }, deviceRowBorder: { borderTopWidth: 1, borderTopColor: c.border }, deviceTitle: { ...typography.bodyMedium, color: c.text }, deviceId: { ...typography.caption, color: c.mutedForeground, fontWeight: '400' }, }); }