diff --git a/locales/rn/en.json b/locales/rn/en.json index afca6df3..643b9180 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -1,11 +1,16 @@ { + "calendar": { + "event_modal": { + "participant_placeholder": "Add participant by email" + } + }, "context_menu": { "color_tag": "Tag", "remove_color": "Remove tag" }, "email_composer": { - "attachment_reminder_title": "Forgot an attachment?", "attachment_reminder_body": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?", + "attachment_reminder_title": "Forgot an attachment?", "editor_unavailable": "Could not read the message content. Copy your text, then close and reopen the composer.", "save_draft": "Save Draft", "schedule_custom": "Pick date & time…", @@ -13,13 +18,15 @@ "schedule_in_3h": "In 3 hours", "schedule_past_body": "The scheduled time must be in the future.", "schedule_past_title": "Pick a future time", + "schedule_tomorrow": "Tomorrow, 8:00 AM", "schedule_too_late_body": "That is later than this server allows. Pick an earlier time.", "schedule_too_late_title": "Too far ahead", - "schedule_tomorrow": "Tomorrow, 8:00 AM", "schedule_unsupported_body": "This mail server does not support scheduled send.", "schedule_unsupported_title": "Scheduling unavailable", "scheduled_body": "Your message will be sent at {time}.", "scheduled_title": "Scheduled", + "send_timeout_body": "The message may already have gone out. Check your Sent folder before sending it again.", + "send_timeout_title": "No answer from the server", "validation": { "subject_required": "Add a subject" } @@ -31,33 +38,77 @@ "no_trash_folder": "Could not find a Trash folder on the server. Please check your mailbox configuration." }, "email_viewer": { - "set_color": "Set tag", - "remove_color": "Remove tag", "color_tag": { - "title": "Color Tag", - "red": "Red", - "orange": "Orange", - "yellow": "Yellow", - "green": "Green", "blue": "Blue", - "purple": "Purple", + "green": "Green", + "none": "None", + "orange": "Orange", "pink": "Pink", - "none": "None" - } - }, - "settings": { - "appearance": { - "language": { - "system_default": "System default", - "restart_for_direction": "Restart the app to apply the new text direction." - } + "purple": "Purple", + "red": "Red", + "title": "Color Tag", + "yellow": "Yellow" }, - "keywords": { - "reset_defaults": "Reset to Defaults" - } + "remove_color": "Remove tag", + "set_color": "Set tag" + }, + "files": { + "done": "Done", + "share_add_people": "Add people", + "share_empty": "Not shared with anyone yet", + "share_error": "Failed to update sharing", + "share_folder_hint": "Sharing a folder shares everything inside it.", + "share_no_matches": "No matches", + "share_no_users": "No other users found", + "share_remove": "Remove access", + "share_role_custom": "Custom", + "share_search_placeholder": "Search by name or email", + "shared_with": "Shared with" }, "offline": { "banner": "You are offline", "queued_changes": "{count, plural, one {# change} other {# changes}} will sync when you reconnect" + }, + "push_prompt": { + "body": "Turn on background notifications so new messages reach you while the app is closed.", + "dismiss": "Not now", + "enable": "Enable", + "title": "Get notified about new mail" + }, + "settings": { + "appearance": { + "language": { + "restart_for_direction": "Restart the app to apply the new text direction.", + "system_default": "System default" + } + }, + "keywords": { + "reset_defaults": "Reset to Defaults" + }, + "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." + }, + "push": { + "confirm_disable_message_mobile": "This device will stop receiving new-mail alerts for this account.", + "description_mobile": "Receive system notifications for new mail when the app is closed. Delivered via the Bulwark push relay; the relay never sees mail content.", + "phase_account": "Account", + "phase_jmap": "Mail server subscription", + "phase_permission": "Notification permission", + "phase_platform": "Platform", + "phase_relay": "Relay registration", + "phase_token": "Device token (Firebase)", + "phase_verify": "Verification", + "relay_desc_mobile": "The server that delivers your notifications. Defaults to the hosted Bulwark relay; change only if you self-host. Must use https.", + "relay_invalid": "Enter a valid https:// URL", + "setup_failed": "Setup failed", + "status_android_only": "Background notifications are available on Android only." + } + }, + "vacation": { + "warnings": { + "date_format": "Dates must be \"YYYY-MM-DD\" or \"YYYY-MM-DD HH:MM\"" + } + } } } diff --git a/package.json b/package.json index 46afc0ed..5886d0cf 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ "test:watch": "vitest", "typecheck": "tsc --noEmit", "bump": "node scripts/bump-version.mjs", - "sync-locales": "node scripts/sync-locales.mjs" + "sync-locales": "node scripts/sync-locales.mjs", + "i18n:harvest": "node scripts/harvest-rn-keys.mjs", + "i18n:check": "node scripts/harvest-rn-keys.mjs --check" }, "dependencies": { "@react-native-async-storage/async-storage": "2.2.0", @@ -27,6 +29,7 @@ "expo-crypto": "~15.0.9", "expo-document-picker": "~14.0.8", "expo-file-system": "~19.0.21", + "expo-image-manipulator": "~14.0.8", "expo-image-picker": "~17.0.10", "expo-intent-launcher": "~13.0.8", "expo-localization": "~17.0.8", diff --git a/scripts/harvest-rn-keys.mjs b/scripts/harvest-rn-keys.mjs new file mode 100644 index 00000000..15831ab8 --- /dev/null +++ b/scripts/harvest-rn-keys.mjs @@ -0,0 +1,99 @@ +// Collect t('key', 'English fallback') calls whose key exists in neither the +// vendored webmail catalog nor the RN overlay, and add them to +// locales/rn/en.json using the inline fallback as the English text. +// Run from the RN repo root: `node scripts/harvest-rn-keys.mjs [--check]`. +// `--check` only reports (exit 1 when something is missing) - used by the +// translation coverage test message. +import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { join, dirname, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..'); +const SRC = join(ROOT, 'src'); +const EN = join(ROOT, 'locales', 'en', 'common.json'); +const OVERLAY = join(ROOT, 'locales', 'rn', 'en.json'); +const CHECK_ONLY = process.argv.includes('--check'); + +function flatten(obj, prefix = '', out = new Map()) { + for (const [k, v] of Object.entries(obj)) { + const key = prefix ? `${prefix}.${k}` : k; + if (v && typeof v === 'object' && !Array.isArray(v)) flatten(v, key, out); + else out.set(key, v); + } + return out; +} + +function walk(dir, out = []) { + for (const entry of readdirSync(dir)) { + const p = join(dir, entry); + if (statSync(p).isDirectory()) { + if (entry === '__tests__' || entry === 'node_modules') continue; + walk(p, out); + } else if (/\.tsx?$/.test(entry)) { + out.push(p); + } + } + return out; +} + +function setDeep(obj, key, value) { + const parts = key.split('.'); + let cur = obj; + for (let i = 0; i < parts.length - 1; i++) { + if (!cur[parts[i]] || typeof cur[parts[i]] !== 'object') cur[parts[i]] = {}; + cur = cur[parts[i]]; + } + if (cur[parts[parts.length - 1]] === undefined) cur[parts[parts.length - 1]] = value; +} + +function sortDeep(obj) { + if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return obj; + return Object.keys(obj).sort().reduce((acc, k) => { + acc[k] = sortDeep(obj[k]); + return acc; + }, {}); +} + +const en = flatten(JSON.parse(readFileSync(EN, 'utf8'))); +const overlay = JSON.parse(readFileSync(OVERLAY, 'utf8')); +const overlayFlat = flatten(overlay); + +// t('key', 'fallback') / t('key', "fallback") / t('key', `fallback`) - the +// fallback may span lines; keep it simple: single-quoted or double-quoted, +// non-nested strings. +const CALL_RE = /\bt\(\s*'([^']+)'\s*,\s*(?:'((?:[^'\\]|\\.)*)'|"((?:[^"\\]|\\.)*)")/g; +const KEY_ONLY_RE = /\bt\(\s*'([^']+)'/g; + +const added = []; +const missingNoFallback = []; +for (const file of walk(SRC)) { + const text = readFileSync(file, 'utf8'); + const withFallback = new Map(); + for (const m of text.matchAll(CALL_RE)) { + const value = (m[2] ?? m[3] ?? '').replace(/\\(['"])/g, '$1'); + if (value) withFallback.set(m[1], value); + } + for (const m of text.matchAll(KEY_ONLY_RE)) { + const key = m[1]; + if (en.has(key) || overlayFlat.has(key)) continue; + const fallback = withFallback.get(key); + if (!fallback) { + missingNoFallback.push(`${key} (${relative(ROOT, file)})`); + continue; + } + if (!added.some((a) => a.key === key)) added.push({ key, fallback, file: relative(ROOT, file) }); + } +} + +if (added.length === 0 && missingNoFallback.length === 0) { + console.log('All t() keys are covered.'); + process.exit(0); +} +for (const a of added) console.log(`${CHECK_ONLY ? 'missing' : 'added'}: ${a.key} = ${JSON.stringify(a.fallback)} (${a.file})`); +for (const m of missingNoFallback) console.log(`missing (no inline fallback, add by hand): ${m}`); + +if (CHECK_ONLY) process.exit(1); +for (const a of added) setDeep(overlay, a.key, a.fallback); +writeFileSync(OVERLAY, JSON.stringify(sortDeep(overlay), null, 2) + '\n'); +console.log(`Wrote ${added.length} key(s) to locales/rn/en.json`); diff --git a/src/components/PushOnboardingPrompt.tsx b/src/components/PushOnboardingPrompt.tsx new file mode 100644 index 00000000..50c24984 --- /dev/null +++ b/src/components/PushOnboardingPrompt.tsx @@ -0,0 +1,158 @@ +import React from 'react'; +import { View, Text, StyleSheet, Pressable } from 'react-native'; +import { BellRing, X } from 'lucide-react-native'; +import Button from './Button'; +import { spacing, radius, 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, + dismissPushPrompt, + getEffectiveRelayBaseUrl, + isPushEnabledForAccount, + isPushSupported, + PushSetupError, + setStoredRelayBaseUrl, + setupPushNotifications, + wasPushPromptDismissed, +} from '../lib/push-notifications'; + +// Mirrors the webmail's push-notification-prompt: a short delay after login +// so the inbox renders first, and never again for an account once dismissed. +const PROMPT_DELAY_MS = 1500; + +/** + * Once-per-account invitation to turn on background notifications. Shown + * only where push can actually work (Android with Play services), when the + * user has not disabled mail notifications, and until either "Enable" or + * "Not now" has been tapped for the active account. + */ +export function PushOnboardingPrompt(): React.ReactElement | null { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const client = useAuthStore((s) => s.client); + const activeAccountId = useAuthStore((s) => s.activeAccountId); + const username = useAuthStore((s) => s.username); + const emailNotificationsEnabled = useSettingsStore((s) => s.emailNotificationsEnabled); + + const [visible, setVisible] = React.useState(false); + const [busy, setBusy] = React.useState(false); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + setVisible(false); + setError(null); + if (!isAuthenticated || !client || !activeAccountId || !emailNotificationsEnabled) return; + if (!isPushSupported()) return; + + let cancelled = false; + const timer = setTimeout(() => { + void (async () => { + if (await wasPushPromptDismissed(activeAccountId)) return; + if (await isPushEnabledForAccount(activeAccountId)) return; + if (!cancelled) setVisible(true); + })(); + }, PROMPT_DELAY_MS); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [isAuthenticated, client, activeAccountId, emailNotificationsEnabled]); + + if (!visible || !activeAccountId) return null; + + const handleDismiss = () => { + setVisible(false); + void dismissPushPrompt(activeAccountId); + }; + + const handleEnable = async () => { + setBusy(true); + setError(null); + try { + const relayBaseUrl = (await getEffectiveRelayBaseUrl()) || DEFAULT_RELAY_BASE_URL; + await setStoredRelayBaseUrl(relayBaseUrl); + await setupPushNotifications({ relayBaseUrl, accountLabel: username ?? undefined }); + await dismissPushPrompt(activeAccountId); + setVisible(false); + } catch (err) { + const message = err instanceof PushSetupError || err instanceof Error + ? err.message + : t('settings.notifications.push.setup_failed', 'Setup failed'); + setError(message); + } finally { + setBusy(false); + } + }; + + return ( + + + + + + + {t('push_prompt.title', 'Get notified about new mail')} + + + {t( + 'push_prompt.body', + 'Turn on background notifications so new messages reach you while the app is closed.', + )} + + {error && {error}} + + + + + + + + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + card: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: spacing.md, + marginHorizontal: spacing.md, + marginTop: spacing.sm, + padding: spacing.md, + borderRadius: radius.md, + borderWidth: 1, + borderColor: c.border, + backgroundColor: c.card, + }, + iconWrap: { + width: 36, + height: 36, + borderRadius: radius.full, + backgroundColor: c.primaryBg, + alignItems: 'center', + justifyContent: 'center', + }, + title: { ...typography.bodyMedium, color: c.text }, + body: { ...typography.caption, color: c.mutedForeground, marginTop: 2 }, + error: { ...typography.caption, color: c.error, marginTop: spacing.xs }, + actions: { flexDirection: 'row', gap: spacing.sm, marginTop: spacing.sm }, + close: { padding: 2 }, + }); +} diff --git a/src/components/settings/NotificationSettings.tsx b/src/components/settings/NotificationSettings.tsx index 92631df1..bdb84ffb 100644 --- a/src/components/settings/NotificationSettings.tsx +++ b/src/components/settings/NotificationSettings.tsx @@ -1,247 +1,392 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { ActivityIndicator, + Platform, StyleSheet, Text, TextInput, View, } from 'react-native'; -import { CheckCircle2, Volume2, XCircle } from 'lucide-react-native'; +import { CheckCircle2, RefreshCw, XCircle } from 'lucide-react-native'; import Button from '../Button'; import Dialog from '../Dialog'; -import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section'; +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 { useSettingsStore, type NotificationSound } from '../../stores/settings-store'; +import { useLocaleStore } from '../../stores/locale-store'; +import { useSettingsStore } from '../../stores/settings-store'; import { DEFAULT_RELAY_BASE_URL, getStoredRelayBaseUrl, + isPushEnabledForAccount, + isPushSupported, + isValidRelayUrl, + listPushDevices, + PushSetupError, readPushAccountIds, + revokePushDevice, setStoredRelayBaseUrl, setupPushNotifications, teardownPushNotificationsForAccount, + type PushDevice, + type PushSetupPhase, } from '../../lib/push-notifications'; -const SOUNDS: { value: NotificationSound; label: string }[] = [ - { value: 'default', label: 'Default' }, - { value: 'chime', label: 'Chime' }, - { value: 'ping', label: 'Ping' }, - { value: 'pop', label: 'Pop' }, - { value: 'none', label: 'Silent' }, -]; - 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 (Firebase)' }, + 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 sound = useSettingsStore((s) => s.notificationSoundChoice); const emailEnabled = useSettingsStore((s) => s.emailNotificationsEnabled); - const emailSound = useSettingsStore((s) => s.emailNotificationSound); const calEnabled = useSettingsStore((s) => s.calendarNotificationsEnabled); - const calSound = useSettingsStore((s) => s.calendarNotificationSound); const invitationParsing = useSettingsStore((s) => s.calendarInvitationParsingEnabled); + const supported = isPushSupported(); const [relayUrl, setRelayUrl] = useState(DEFAULT_RELAY_BASE_URL); - const [hasSaved, setHasSaved] = useState(false); + 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(); - if (stored) { - setRelayUrl(stored); - setHasSaved(true); - setPushStatus({ kind: 'enabled' }); - } + const enabled = activeAccountId ? await isPushEnabledForAccount(activeAccountId) : false; + if (cancelled) return; + if (stored) setRelayUrl(stored); + setPushEnabled(enabled); + setPushStatus(enabled ? { kind: 'enabled' } : { kind: 'idle' }); })(); - }, []); + return () => { cancelled = true; }; + }, [activeAccountId]); const trimmed = relayUrl.trim().replace(/\/+$/, ''); - const isValidUrl = /^https?:\/\/.+/i.test(trimmed); + const relayValid = isValidRelayUrl(trimmed); const busy = pushStatus.kind === 'busy'; - const canEnable = isValidUrl && !busy; - const handleEnable = async () => { - if (!isValidUrl) { - setPushStatus({ kind: 'error', message: 'Enter a valid https:// URL' }); + 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: 'Registering device…' }); + setPushStatus({ + kind: 'busy', + message: t('settings.notifications.push.status_busy', 'Working…'), + }); try { await setStoredRelayBaseUrl(trimmed); await setupPushNotifications({ relayBaseUrl: trimmed, accountLabel: username ?? undefined, + forceRecreate, }); - setHasSaved(true); + setPushEnabled(true); setPushStatus({ kind: 'enabled' }); } catch (error) { - setPushStatus({ - kind: 'error', - message: error instanceof Error ? error.message : 'Setup failed', - }); + setPushStatus({ kind: 'error', message: describeError(error) }); + } finally { + void refreshDevices(); } }; - const handleDisable = () => { - setConfirmDisable(true); - }; - const performDisable = async () => { setConfirmDisable(false); - setPushStatus({ kind: 'busy', message: 'Disabling…' }); + setPushStatus({ + kind: 'busy', + message: t('settings.notifications.push.status_busy', 'Working…'), + }); try { if (activeAccountId) { await teardownPushNotificationsForAccount(activeAccountId); } // The relay base URL is a device-wide setting shared with any other - // accounts that are still using push. Only clear it (and reset the UI - // to "not configured") when no accounts are using push any more — - // otherwise the remaining accounts would lose their relay URL and - // their next setup attempt would no-op. + // 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); - setHasSaved(false); } + setPushEnabled(false); setPushStatus({ kind: 'idle' }); } catch (error) { - setPushStatus({ - kind: 'error', - message: error instanceof Error ? error.message : 'Teardown failed', - }); + 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 ( - - - Relay base URL - - {pushStatus.kind === 'busy' && ( - <> - - {pushStatus.message} - - )} - {pushStatus.kind === 'enabled' && ( - <> - - Active - - )} - {pushStatus.kind === 'error' && ( - <> - - - {pushStatus.message} - - - )} - {pushStatus.kind === 'idle' && ( - - Not configured - - )} - + + + {pushStatus.kind === 'busy' && } + {pushStatus.kind === 'enabled' && } + { + if (checked) void handleEnable(); + else setConfirmDisable(true); + }} + disabled={!supported || busy || !client} + /> - - Defaults to the hosted Bulwark relay. Change only if you self-host. + + + + + + + {!relayValid && ( + + {t('settings.notifications.push.relay_invalid', 'Enter a valid https:// URL')} - + )} + {pushEnabled && ( - {hasSaved && ( - - )} - + )} + {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.')} +