From 6f515e15f74453de7a59b809abfd5e54daffadcd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:49:37 +0200 Subject: [PATCH] feat: rework settings into the picked list, About and Storage & backup pages The settings list now has the account card on top, title-case group labels and 56 dp rows that show each pane's current value. About and "About & Data" were one placeholder page with a letter logo, a fixed name and a store link that pointed at GitHub releases. - About: the real logo, the app name from app.config.js, the update state, a source link that a build can set with SOURCE_CODE_URL, and rows for the server, update checks (Android), open-source licences, privacy and a prefilled GitHub issue. iPhone updates link TestFlight. - Storage & backup: usage card, offline mail, settings export/import and reset, and a Troubleshooting page for refresh and debug logging. - Sender favicons moved to Appearance, like the webmail. - The S/MIME and Plugins stub pages are gone; both are webmail plugins. The licence list is generated by `scripts/generate-licences.mjs`. --- app.config.js | 3 + locales/rn/de.json | 101 +++- locales/rn/en.json | 95 +++- scripts/generate-licences.mjs | 46 ++ .../__tests__/settings-i18n.test.ts | 8 +- src/components/settings/AboutDataSettings.tsx | 486 ------------------ src/components/settings/AboutSettings.tsx | 202 ++++++++ .../settings/AppearanceSettings.tsx | 12 +- src/components/settings/LicencesSettings.tsx | 47 ++ src/components/settings/PluginsSettings.tsx | 55 -- src/components/settings/PrivacySettings.tsx | 81 +++ src/components/settings/SmimeSettings.tsx | 55 -- src/components/settings/StorageSettings.tsx | 299 +++++++++++ .../settings/TroubleshootingSettings.tsx | 101 ++++ src/lib/__tests__/about-links.test.ts | 41 ++ src/lib/__tests__/licences.test.ts | 25 + src/lib/__tests__/settings-search.test.ts | 4 +- src/lib/__tests__/settings-summary.test.ts | 53 ++ src/lib/about-links.ts | 69 +++ src/lib/licences.json | 218 ++++++++ src/lib/settings-search.ts | 45 +- src/lib/settings-summary.ts | 103 ++++ src/screens/SettingsScreen.tsx | 340 ++++++------ 23 files changed, 1716 insertions(+), 773 deletions(-) create mode 100644 scripts/generate-licences.mjs delete mode 100644 src/components/settings/AboutDataSettings.tsx create mode 100644 src/components/settings/AboutSettings.tsx create mode 100644 src/components/settings/LicencesSettings.tsx delete mode 100644 src/components/settings/PluginsSettings.tsx create mode 100644 src/components/settings/PrivacySettings.tsx delete mode 100644 src/components/settings/SmimeSettings.tsx create mode 100644 src/components/settings/StorageSettings.tsx create mode 100644 src/components/settings/TroubleshootingSettings.tsx create mode 100644 src/lib/__tests__/about-links.test.ts create mode 100644 src/lib/__tests__/licences.test.ts create mode 100644 src/lib/__tests__/settings-summary.test.ts create mode 100644 src/lib/about-links.ts create mode 100644 src/lib/licences.json create mode 100644 src/lib/settings-summary.ts diff --git a/app.config.js b/app.config.js index f787583c..4911cf31 100644 --- a/app.config.js +++ b/app.config.js @@ -99,6 +99,9 @@ module.exports = { ], extra: { commit: COMMIT, + // Settings → About → Source code. A modified build sets SOURCE_CODE_URL + // to where its own source is (AGPL §13); the default is upstream. + sourceCodeUrl: process.env.SOURCE_CODE_URL || 'https://github.com/bulwarkmail/native', }, }, }; diff --git a/locales/rn/de.json b/locales/rn/de.json index f6698d8c..3d6f3546 100644 --- a/locales/rn/de.json +++ b/locales/rn/de.json @@ -648,12 +648,14 @@ }, "toolbar_labels": { "description_mobile": "Textbeschriftungen neben den Symbolleistensymbolen anzeigen." - } + }, + "description_mobile": "Passe an, wie die App aussieht" }, "badges": { "experimental": "Experimentell", "not_implemented": "Nicht implementiert", - "unavailable": "Nicht verfügbar" + "unavailable": "Nicht verfügbar", + "not_on_server": "Nicht auf diesem Server" }, "contacts": { "book_count": "{count, plural, one {# Kontakt} other {# Kontakte}}", @@ -996,7 +998,15 @@ "window": "Zeitraum", "window_24h": "Letzte 24 Stunden", "window_days": "Letzte {count} Tage", - "window_desc": "Wie weit zurück zwischengespeichert wird, gemessen am Empfangsdatum der Nachricht." + "window_desc": "Wie weit zurück zwischengespeichert wird, gemessen am Empfangsdatum der Nachricht.", + "description_short": "Neue Nachrichten auf dem Telefon behalten, damit sie auch ohne Verbindung aufgehen.", + "keep_24h": "24 Stunden", + "keep_days": "{count} Tage", + "keep_from": "Mails der letzten", + "messages_offline": "{count, plural, one {# Nachricht offline} other {# Nachrichten offline}}", + "synced": "Synchronisiert {when}", + "use_at_most": "Höchstens belegen", + "used_of": "{used} von {limit} MB" }, "plugins": { "mobile_description": "Plugins laufen im Webmail und erweitern es im Browser.", @@ -1104,7 +1114,10 @@ "tabs": { "plugins": "Plugins", "themes": "Designs", - "updates": "Updates" + "updates": "Updates", + "storage": "Speicher & Sicherung", + "about": "Über", + "troubleshooting": "Fehlerbehebung" }, "templates": { "delete_confirm": "\"{name}\" endgültig löschen?", @@ -1128,6 +1141,86 @@ "warnings": { "date_format": "Datumsangaben müssen das Format \"YYYY-MM-DD\" oder \"YYYY-MM-DD HH:MM\" haben" } + }, + "about": { + "check_now": "Jetzt prüfen", + "checked_at": "Geprüft {when}", + "licences": "Open-Source-Lizenzen", + "licences_desc": "AGPL-3.0 für die App und die Bibliotheken, die sie nutzt", + "never_checked": "Noch nicht geprüft", + "privacy": "Datenschutz", + "privacy_desc": "Was auf diesem Telefon bleibt und was an deinen Server geht", + "report_problem": "Problem melden", + "report_problem_desc": "Öffnet ein Issue auf GitHub mit eingetragener Version", + "server": "Server", + "server_value": "{host} · angemeldet als {user}", + "source_code": "Quellcode", + "this_device": "Dieses Gerät", + "this_device_desc": "Womit die App verbunden ist und wo du Hilfe bekommst.", + "up_to_date": "Aktuell", + "updates": "Updates" + }, + "backup": { + "description": "Übertrage deine Einstellungen auf ein anderes Telefon oder ins Webmail.", + "export_import": "Exportieren oder importieren", + "export_import_desc": "Eine JSON-Datei im selben Format wie im Webmail", + "reset": "Zurücksetzen", + "reset_desc": "Zurück auf die Standardwerte; Mails bleiben", + "title": "Einstellungen sichern" + }, + "licences": { + "app_desc": "Die App ist freie Software unter der {licence}. Du darfst sie nutzen, untersuchen, weitergeben und ändern.", + "libraries": "Bibliotheken", + "libraries_desc": "Open-Source-Code, mit dem die App gebaut ist.", + "read_licence": "Den vollständigen Lizenztext lesen" + }, + "privacy": { + "favicons": "Absendersymbole für geschäftliche Mails kommen vom Symboldienst von DuckDuckGo, der dabei die Domain des Absenders sieht. Ausschalten kannst du sie unter Darstellung → Absender-Favicons.", + "no_tracking": "Die App hat keine Analyse und keine Werbung.", + "other_title": "Weitere Verbindungen", + "phone_backup": "Nichts davon landet in der Cloud-Sicherung von Android.", + "phone_secure": "Deine Anmeldedaten liegen im sicheren Speicher des Systems. Einstellungen, der Offline-Cache und noch nicht synchronisierte Änderungen liegen im Speicher der App.", + "phone_title": "Auf diesem Telefon", + "push": "Wenn Benachrichtigungen an sind, meldet dein Server dem Push-Relay ({relay}), dass sich etwas geändert hat, und das Relay weckt die App über {service}. Das Relay sieht ein Gerätetoken und eine Änderungsmarke, nie deine Mails. Unter Benachrichtigungen kannst du ein eigenes Relay eintragen.", + "push_title": "Push-Benachrichtigungen", + "server": "Mails, Kalender, Kontakte und Dateien werden von deinem Server geladen und dort gespeichert. Außer den Verbindungen unten spricht die App nur mit deinem Server.", + "server_title": "Dein Mailserver", + "service_apple": "den Push-Dienst von Apple", + "service_google": "den Push-Dienst von Google", + "updates": "Die App fragt bei GitHub nach der neuesten Version, um dir Updates anzuzeigen.", + "updates_android": "Die App fragt bei GitHub nach der neuesten Version, um dir Updates anzuzeigen. Ausschalten kannst du das unter Updates." + }, + "summary": { + "events_off": "Termine aus", + "events_on": "Termine an", + "mail_off": "Mail aus", + "mail_on": "Mail an", + "offline_off": "Offline-Mails aus", + "offline_on": "Offline-Mails an · {days, plural, one {# Tag} other {# Tage}}, {mb} MB", + "version": "Version {version}", + "week_starts": "Woche beginnt {day}", + "layout": "Schnellaktionen, Wischen, Reihenfolge", + "reading": "Gelesen-Status, Löschen, Anhänge", + "composing": "Signatur, Sendeverzögerung, Entwürfe", + "identities": "Adressen, von denen du sendest", + "vacation": "Automatische Antwort bei Abwesenheit", + "filters": "Regeln für eingehende Mails", + "templates": "Wiederverwendbare Nachrichten", + "folders": "Anlegen, umbenennen, Rollen", + "keywords": "Namen und Farben der Tags", + "downloads": "Dateinamen für gespeicherte Mails", + "security": "Passwort, Zwei-Faktor, App-Passwörter", + "content_senders": "Externe Bilder, vertrauenswürdige Absender", + "contacts": "Adressbücher, Sortierung", + "files": "Ansicht, Sortierung, Vorschaubilder", + "sidebar_apps": "Web-Apps im Seitenmenü", + "updates": "Neue Versionen installieren" + }, + "troubleshooting": { + "cache_desc": "Wenn eine Liste veraltet oder falsch aussieht.", + "cache_title": "Zwischengespeicherte Daten", + "row_desc": "Daten neu laden, Debug-Protokoll", + "title": "Fehlerbehebung" } }, "sidebar": { diff --git a/locales/rn/en.json b/locales/rn/en.json index 88a118da..dcb23d39 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -627,6 +627,24 @@ "send_now_failed": "Could not send now" }, "settings": { + "about": { + "check_now": "Check now", + "checked_at": "Checked {when}", + "licences": "Open-source licences", + "licences_desc": "AGPL-3.0 for the app, and the libraries it uses", + "never_checked": "Not checked yet", + "privacy": "Privacy", + "privacy_desc": "What stays on this phone and what goes to your server", + "report_problem": "Report a problem", + "report_problem_desc": "Opens an issue on GitHub with the version filled in", + "server": "Server", + "server_value": "{host} · signed in as {user}", + "source_code": "Source code", + "this_device": "This device", + "this_device_desc": "What the app is connected to and how to get help.", + "up_to_date": "Up to date", + "updates": "Updates" + }, "account": { "accounts": { "description_mobile": "Reorder how accounts appear in the account switcher, pick the default, or sign one out.", @@ -653,6 +671,7 @@ } }, "appearance": { + "description_mobile": "Customize how the app looks", "language": { "restart_for_direction": "Restart the app to apply the new text direction.", "system_default": "System default" @@ -661,9 +680,18 @@ "description_mobile": "Display text labels next to toolbar icons." } }, + "backup": { + "description": "Move your settings to another phone or the webmail.", + "export_import": "Export or import", + "export_import_desc": "A JSON file, same format as the webmail", + "reset": "Reset", + "reset_desc": "Back to the defaults; mail stays", + "title": "Settings backup" + }, "badges": { "experimental": "Experimental", "not_implemented": "Not implemented", + "not_on_server": "Not on this server", "unavailable": "Unavailable" }, "contacts": { @@ -928,6 +956,12 @@ "unified_cross_account": "Unified views across accounts", "unified_cross_account_description": "Combine every signed-in account in \"All inboxes\". Off keeps the unified views inside the active account and its shared folders." }, + "licences": { + "app_desc": "The app is free software under the {licence}. You can use, study, share and change it.", + "libraries": "Libraries", + "libraries_desc": "Open-source code the app is built with.", + "read_licence": "Read the full licence text" + }, "message_list_order": { "description": "Which messages come first in the list. Newest first is the tie-breaker within each group; the header toggle still flips oldest/newest.", "preset_chronological": "Newest first", @@ -993,23 +1027,31 @@ "clear_title": "Clear offline mail", "days_ago": "{count} d ago", "description": "Download recent messages so they open instantly and remain readable without a connection. Bodies only — attachments are still fetched on demand.", + "description_short": "Keep recent messages on this phone so they open without a connection.", "downloading": "Downloading {completed}/{total}…", "enabled": "Cache recent mail", "enabled_desc": "When on, the app keeps message bodies for the selected window on this device.", "hours_ago": "{count} h ago", "just_now": "just now", + "keep_24h": "24 hours", + "keep_days": "{count} days", + "keep_from": "Keep mail from the last", "last_sync": "Last sync {when}", "last_sync_failed": "Last sync failed: {message}", "max_size": "Maximum size", "max_size_desc": "Oldest messages are removed when the cache grows past this.", + "messages_offline": "{count, plural, one {# message offline} other {# messages offline}}", "minutes_ago": "{count} min ago", "never": "never", "nothing_cached": "Nothing cached yet", "queued_changes": "{count, plural, one {# change} other {# changes}} waiting to sync", "scanning": "Scanning recent mail…", "sync_now": "Sync now", + "synced": "Synced {when}", "syncing": "Syncing…", "title": "Offline mail", + "use_at_most": "Use at most", + "used_of": "{used} of {limit} MB", "window": "Window", "window_24h": "Last 24 hours", "window_days": "Last {count} days", @@ -1019,6 +1061,22 @@ "mobile_description": "Plugins run inside the webmail and extend it in the browser.", "mobile_explainer": "The mobile app has no plugin runtime. Install and manage plugins from the webmail under Settings → Plugins; themes and filters they provide are not applied here." }, + "privacy": { + "favicons": "Sender icons for business mail come from DuckDuckGo's icon service, which sees the sender's domain. Turn them off under Appearance → Sender favicons.", + "no_tracking": "The app has no analytics and no ads.", + "other_title": "Other connections", + "phone_backup": "None of it goes into Android's cloud backup.", + "phone_secure": "Your sign-in details are kept in the system's secure storage. Settings, the offline mail cache and changes waiting to sync are kept in the app's own storage.", + "phone_title": "On this phone", + "push": "When notifications are on, your server tells the push relay ({relay}) that something changed, and the relay wakes the app through {service}. The relay sees a device token and a change marker, never your mail. You can use your own relay under Notifications.", + "push_title": "Push notifications", + "server": "Mail, calendars, contacts and files are loaded from your server and saved back to it. Apart from the connections below, the app talks only to your server.", + "server_title": "Your mail server", + "service_apple": "Apple's push service", + "service_google": "Google's push service", + "updates": "The app asks GitHub for the latest release to tell you about updates.", + "updates_android": "The app asks GitHub for the latest release to tell you about updates. Turn that off under Updates." + }, "security": { "client_cert": { "active": "Active: {alias}", @@ -1118,10 +1176,39 @@ "mobile_description": "Sign and encrypt mail with S/MIME certificates.", "mobile_explainer": "S/MIME is not available in the mobile app yet. Manage keys and certificates in the webmail; signed messages are shown without verification here." }, + "summary": { + "events_off": "events off", + "events_on": "events on", + "mail_off": "Mail off", + "mail_on": "Mail on", + "offline_off": "Offline mail off", + "offline_on": "Offline mail on · {days, plural, one {# day} other {# days}}, {mb} MB", + "version": "Version {version}", + "week_starts": "week starts {day}", + "layout": "Quick actions, swipes, list order", + "reading": "Read status, delete, attachments", + "composing": "Signature, send delay, drafts", + "identities": "Addresses you send from", + "vacation": "Automatic reply while away", + "filters": "Rules for incoming mail", + "templates": "Reusable messages", + "folders": "Create, rename, roles", + "keywords": "Tag names and colours", + "downloads": "File names for saved mail", + "security": "Password, two-factor, app passwords", + "content_senders": "Remote images, trusted senders", + "contacts": "Address books, sorting", + "files": "View, sorting, thumbnails", + "sidebar_apps": "Web apps in the side menu", + "updates": "Install new versions" + }, "tabs": { "plugins": "Plugins", "themes": "Themes", - "updates": "Updates" + "updates": "Updates", + "storage": "Storage & backup", + "about": "About", + "troubleshooting": "Troubleshooting" }, "templates": { "delete_confirm": "Permanently delete \"{name}\"?", @@ -1141,6 +1228,12 @@ "mobile_description": "Pick a colour theme. Themes follow the light/dark setting from Appearance; custom theme packages are managed in the webmail.", "title": "Themes" }, + "troubleshooting": { + "cache_desc": "When a list looks stale or wrong.", + "cache_title": "Cached data", + "row_desc": "Refresh cached data, debug logging", + "title": "Troubleshooting" + }, "vacation": { "warnings": { "date_format": "Dates must be \"YYYY-MM-DD\" or \"YYYY-MM-DD HH:MM\"" diff --git a/scripts/generate-licences.mjs b/scripts/generate-licences.mjs new file mode 100644 index 00000000..b6698ecf --- /dev/null +++ b/scripts/generate-licences.mjs @@ -0,0 +1,46 @@ +// Writes src/lib/licences.json: the libraries the app ships (the runtime +// dependencies in package.json) with their licence, for Settings → About → +// Open-source licences. Run after adding or upgrading a dependency: +// +// node scripts/generate-licences.mjs +// +// The test in src/lib/__tests__/licences.test.ts fails when a dependency is +// missing from the list. + +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')); + +// Code that isn't an npm dependency but ships in the app. +const VENDORED = [ + { name: 'Tabler Icons', version: '', license: 'MIT', url: 'https://tabler.io/icons' }, +]; + +function repoUrl(meta) { + const repo = typeof meta.repository === 'string' ? meta.repository : meta.repository?.url; + const url = meta.homepage || repo || ''; + return url + .replace(/^git\+/, '') + .replace(/^git:\/\//, 'https://') + .replace(/^github:/, 'https://github.com/') + .replace(/\.git$/, '') + .replace(/#.*$/, ''); +} + +const libraries = Object.keys(pkg.dependencies).sort().map((name) => { + const file = join(ROOT, 'node_modules', name, 'package.json'); + if (!existsSync(file)) throw new Error(`${name} is not installed`); + const meta = JSON.parse(readFileSync(file, 'utf8')); + const license = typeof meta.license === 'string' ? meta.license : meta.license?.type ?? 'See package'; + return { name, version: meta.version, license, url: repoUrl(meta) }; +}); + +const out = { + app: { name: pkg.name, license: pkg.license }, + libraries: [...libraries, ...VENDORED], +}; +writeFileSync(join(ROOT, 'src', 'lib', 'licences.json'), `${JSON.stringify(out, null, 2)}\n`); +console.log(`${out.libraries.length} libraries`); diff --git a/src/components/__tests__/settings-i18n.test.ts b/src/components/__tests__/settings-i18n.test.ts index cfd9ce8b..e22520e3 100644 --- a/src/components/__tests__/settings-i18n.test.ts +++ b/src/components/__tests__/settings-i18n.test.ts @@ -8,7 +8,7 @@ import { findUntranslatedLiterals } from './untranslated-literals'; // Pane file (relative to src/components/) → texts that stay as they are on // purpose: product names, units, format patterns and sample data. const TRANSLATED_PANES: Record = { - 'settings/AboutDataSettings.tsx': ['MB', 'GitHub'], + 'settings/AboutSettings.tsx': [], 'settings/AccountSecuritySettings.tsx': ['AES-128', 'AES-256'], 'settings/AccountSettings.tsx': [], 'settings/AppearanceSettings.tsx': [], @@ -31,12 +31,14 @@ const TRANSLATED_PANES: Record = { 'settings/LanguageSettings.tsx': [], 'settings/LayoutSettings.tsx': [], 'settings/NotificationSettings.tsx': [], - 'settings/PluginsSettings.tsx': [], + 'settings/LicencesSettings.tsx': ['GNU Affero General Public License v3.0'], + 'settings/PrivacySettings.tsx': [], 'settings/ReadingSettings.tsx': [], 'settings/SidebarAppsSettings.tsx': ['https://example.com'], - 'settings/SmimeSettings.tsx': [], + 'settings/StorageSettings.tsx': ['MB'], 'settings/TemplateSettings.tsx': ['JSON', '{"templates": [...]}'], 'settings/ThemesSettings.tsx': [], + 'settings/TroubleshootingSettings.tsx': [], 'settings/UpdatesSettings.tsx': [], 'settings/VacationSettings.tsx': ['YYYY-MM-DD HH:MM'], 'settings/settings-section.tsx': [], diff --git a/src/components/settings/AboutDataSettings.tsx b/src/components/settings/AboutDataSettings.tsx deleted file mode 100644 index d1a340dd..00000000 --- a/src/components/settings/AboutDataSettings.tsx +++ /dev/null @@ -1,486 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { View, Text, StyleSheet, Pressable, Linking, Alert, ActivityIndicator } from 'react-native'; -import Constants from 'expo-constants'; -import * as DocumentPicker from 'expo-document-picker'; -import { File, Paths } from 'expo-file-system'; -import * as Sharing from 'expo-sharing'; -import { CloudDownload, ExternalLink } from '../icons'; -import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section'; -import Button from '../Button'; -import Dialog from '../Dialog'; -import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; -import { useColors } from '../../theme/colors'; -import { ALL_DEBUG_CATEGORIES, useSettingsStore, type DebugCategory } from '../../stores/settings-store'; -import { useLocaleStore } from '../../stores/locale-store'; -import { useOfflineCacheStore } from '../../stores/offline-cache-store'; -import { useOutboxStore } from '../../stores/outbox-store'; -import { useUpdatesStore } from '../../stores/updates-store'; -import { runOfflineSync, formatBytes } from '../../lib/offline-sync'; -import { clearCachedData } from '../../lib/clear-cached-data'; -import { supportsSideloadUpdates } from '../../lib/platform-capabilities'; - -const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0'; -const GIT_COMMIT = (Constants.expoConfig?.extra as { commit?: string } | undefined)?.commit ?? 'dev'; -const APP_STORE_URL = 'https://github.com/bulwarkmail/native/releases'; - -export function AboutDataSettings() { - const c = useColors(); - const styles = React.useMemo(() => makeStyles(c), [c]); - const t = useLocaleStore((s) => s.t); - const senderFavicons = useSettingsStore((s) => s.senderFavicons); - const setSenderFaviconsStore = useSettingsStore((s) => s.setSenderFavicons); - const hydrated = useSettingsStore((s) => s.hydrated); - const hydrate = useSettingsStore((s) => s.hydrate); - const resetToDefaults = useSettingsStore((s) => s.resetToDefaults); - const exportSettings = useSettingsStore((s) => s.exportSettings); - const importSettings = useSettingsStore((s) => s.importSettings); - const debugMode = useSettingsStore((s) => s.debugMode); - const debugCategories = useSettingsStore((s) => s.debugCategories); - - const [confirmReset, setConfirmReset] = useState(false); - const [refreshing, setRefreshing] = useState(false); - - const offlineEnabled = useSettingsStore((s) => s.offlineCacheEnabled); - const offlineDays = useSettingsStore((s) => s.offlineCacheDays); - const offlineMaxMB = useSettingsStore((s) => s.offlineCacheMaxMB); - const updateSetting = useSettingsStore((s) => s.updateSetting); - const queuedChanges = useOutboxStore((s) => s.entries.length); - const flushOutbox = useOutboxStore((s) => s.flush); - const cacheCount = useOfflineCacheStore((s) => s.totalCount()); - const cacheBytes = useOfflineCacheStore((s) => s.totalSize()); - const cacheHydrated = useOfflineCacheStore((s) => s.hydrated); - const cacheHydrate = useOfflineCacheStore((s) => s.hydrate); - const sync = useOfflineCacheStore((s) => s.sync); - const clearAllCache = useOfflineCacheStore((s) => s.clearAll); - const syncBusy = sync.phase === 'scanning' || sync.phase === 'fetching'; - - const hasUpdate = useUpdatesStore((s) => s.hasUpdate); - const latestRelease = useUpdatesStore((s) => s.cachedLatest); - const updateAvailable = hasUpdate(); - const updateSeverity = latestRelease?.severity ?? 'normal'; - - useEffect(() => { - if (!hydrated) void hydrate(); - if (!cacheHydrated) void cacheHydrate(); - }, [hydrated, hydrate, cacheHydrated, cacheHydrate]); - - const dayOptions = [1, 3, 7, 14, 30, 90].map((d) => ({ - value: String(d), - label: d === 1 - ? t('settings.offline.window_24h', 'Last 24 hours') - : t('settings.offline.window_days', 'Last {count} days', { count: d }), - })); - const maxMbOptions = [25, 50, 100, 250, 500].map((mb) => ({ value: String(mb), label: `${mb} MB` })); - - const formatRelativeTime = (ms: number | undefined): string => { - if (!ms) return t('settings.offline.never', 'never'); - const diff = Date.now() - ms; - if (diff < 60_000) return t('settings.offline.just_now', 'just now'); - if (diff < 60 * 60_000) return t('settings.offline.minutes_ago', '{count} min ago', { count: Math.round(diff / 60_000) }); - if (diff < 24 * 60 * 60_000) return t('settings.offline.hours_ago', '{count} h ago', { count: Math.round(diff / (60 * 60_000)) }); - return t('settings.offline.days_ago', '{count} d ago', { count: Math.round(diff / (24 * 60 * 60_000)) }); - }; - - const handleSyncNow = () => { - void runOfflineSync({ days: offlineDays, maxMB: offlineMaxMB }); - // Also drain any queued offline changes while we're at it. - void flushOutbox(); - }; - - const handleClearCache = () => { - Alert.alert( - t('settings.offline.clear_title', 'Clear offline mail'), - t( - 'settings.offline.clear_message', - 'Remove {count, plural, one {# cached message} other {# cached messages}} ({size})?', - { count: cacheCount, size: formatBytes(cacheBytes) }, - ), - [ - { text: t('common.cancel', 'Cancel'), style: 'cancel' }, - { text: t('settings.offline.clear', 'Clear'), style: 'destructive', onPress: () => { void clearAllCache(); } }, - ], - ); - }; - - const handleReset = () => { - setConfirmReset(false); - resetToDefaults(); - Alert.alert( - t('settings.advanced.reset_settings.label', 'Reset Settings'), - t('settings.advanced.reset_settings.done', 'All settings have been restored to their defaults.'), - ); - }; - - const handleExport = async () => { - try { - const json = exportSettings(); - const file = new File(Paths.cache, `bulwark-settings-${new Date().toISOString().slice(0, 10)}.json`); - file.write(json); - if (await Sharing.isAvailableAsync()) { - await Sharing.shareAsync(file.uri, { - mimeType: 'application/json', - dialogTitle: t('settings.advanced.export_settings.label', 'Export Settings'), - }); - } else { - Alert.alert( - t('settings.advanced.export_settings.label', 'Export Settings'), - t('settings.advanced.export_settings.saved_to', 'Saved to {path}', { path: file.uri }), - ); - } - } catch (err) { - Alert.alert( - t('settings.advanced.export_settings.label', 'Export Settings'), - err instanceof Error ? err.message : String(err), - ); - } - }; - - const handleImport = async () => { - try { - const picked = await DocumentPicker.getDocumentAsync({ - type: ['application/json', 'text/plain', '*/*'], - copyToCacheDirectory: true, - multiple: false, - }); - if (picked.canceled || !picked.assets?.[0]) return; - const json = await new File(picked.assets[0].uri).text(); - const ok = importSettings(json); - Alert.alert( - t('settings.advanced.import_settings.label', 'Import Settings'), - ok - ? t('settings.import_success', 'Settings imported successfully') - : t('settings.import_error', 'Failed to import settings'), - ); - } catch (err) { - Alert.alert( - t('settings.advanced.import_settings.label', 'Import Settings'), - err instanceof Error ? err.message : t('settings.import_error', 'Failed to import settings'), - ); - } - }; - - const handleRefreshCache = async () => { - setRefreshing(true); - try { - await clearCachedData(); - } catch (err) { - Alert.alert( - t('settings.advanced.refresh_cache.label', 'Refresh cached data'), - err instanceof Error ? err.message : String(err), - ); - } finally { - setRefreshing(false); - } - }; - - return ( - - - - - B - - - {t('settings.advanced.about.mobile_title', 'Bulwark Mobile')} - - v{APP_VERSION}{' '} - ({GIT_COMMIT}) - - {updateAvailable && latestRelease && ( - void Linking.openURL(latestRelease.htmlUrl || APP_STORE_URL)} - accessibilityRole="link" - style={[styles.updatePill, updateSeverity !== 'normal' && styles.updatePillSecurity]} - > - - {updateSeverity === 'security' - ? t('settings.advanced.about.security_update', 'Security update: {version}', { version: latestRelease.tag }) - : updateSeverity === 'deprecated' - ? t('settings.advanced.about.deprecated_update', 'Update required: {version}', { version: latestRelease.tag }) - : t('settings.advanced.about.update_available', 'Update: {version}', { version: latestRelease.tag })} - {supportsSideloadUpdates - ? '' - : ` · ${t('settings.advanced.about.update_store_hint', 'available in TestFlight / App Store')}`} - - - )} - - Linking.openURL('https://github.com/bulwarkmail/native')} - > - GitHub - - - - - - - - updateSetting('offlineCacheEnabled', v)} - /> - - - updateSetting('offlineCacheMaxMB', Number(v))} - options={maxMbOptions} - /> - - - - - - - {cacheCount === 0 - ? t('settings.offline.nothing_cached', 'Nothing cached yet') - : `${t('settings.offline.cached_count', '{count, plural, one {# message} other {# messages}}', { count: cacheCount })} • ${formatBytes(cacheBytes)}`} - - - - {sync.phase === 'fetching' - ? t('settings.offline.downloading', 'Downloading {completed}/{total}…', { completed: sync.completed, total: sync.total }) - : sync.phase === 'scanning' - ? t('settings.offline.scanning', 'Scanning recent mail…') - : sync.phase === 'error' - ? t('settings.offline.last_sync_failed', 'Last sync failed: {message}', { message: sync.message ?? t('identities.validation_errors.unknown_error', 'Unknown error') }) - : t('settings.offline.last_sync', 'Last sync {when}', { when: formatRelativeTime(sync.finishedAt) })} - - {queuedChanges > 0 && ( - - {t('settings.offline.queued_changes', '{count, plural, one {# change} other {# changes}} waiting to sync', { count: queuedChanges })} - - )} - {syncBusy && sync.total > 0 && ( - - - - )} - - - - - - - - - - updateSetting('debugMode', v)} /> - - - {debugMode && ( - - - {t( - 'settings.advanced.debug_categories.description', - "Select which categories to log. Disable categories you don't need to reduce console noise.", - )} - - {ALL_DEBUG_CATEGORIES.map((cat: DebugCategory) => ( - - updateSetting('debugCategories', { ...debugCategories, [cat]: v })} - /> - - ))} - - )} - - - - - - - - - - - - - - - - - - - - - - - setConfirmReset(false)} - onConfirm={handleReset} - /> - - ); -} - -function makeStyles(c: ThemePalette) { - return StyleSheet.create({ - container: { gap: spacing.xxxl }, - aboutBox: { - padding: spacing.xl, - borderRadius: radius.md, - borderWidth: 1, - borderColor: c.border, - backgroundColor: c.card, - }, - aboutRow: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.lg, - }, - logo: { - width: 48, - height: 48, - borderRadius: radius.md, - backgroundColor: c.primary, - alignItems: 'center', - justifyContent: 'center', - }, - logoText: { fontSize: 24, fontWeight: '700', color: c.primaryForeground }, - aboutTitle: { ...typography.bodyMedium, color: c.text }, - aboutVersion: { ...typography.caption, color: c.mutedForeground, marginTop: 2 }, - aboutCommit: { color: c.mutedForeground, opacity: 0.6 }, - updatePill: { - alignSelf: 'flex-start', - marginTop: spacing.xs, - paddingHorizontal: spacing.sm, - paddingVertical: 2, - borderRadius: radius.full, - backgroundColor: c.primaryBg, - }, - updatePillSecurity: { backgroundColor: c.errorBg }, - updatePillText: { ...typography.caption, color: c.primary }, - updatePillTextSecurity: { color: c.error }, - ghLink: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - }, - ghText: { ...typography.caption, color: c.mutedForeground }, - debugCategoriesBox: { - marginStart: spacing.lg, - paddingStart: spacing.lg, - borderStartWidth: 2, - borderStartColor: c.muted, - gap: 4, - }, - debugCategoriesHint: { - ...typography.caption, - color: c.mutedForeground, - marginBottom: spacing.sm, - }, - cacheStatsBox: { - marginTop: spacing.md, - padding: spacing.md, - borderRadius: radius.md, - borderWidth: 1, - borderColor: c.border, - backgroundColor: c.muted, - gap: spacing.sm, - }, - cacheStatsHeader: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm, - }, - cacheStatsTitle: { ...typography.bodyMedium, color: c.text, flex: 1 }, - cacheStatsSub: { ...typography.caption, color: c.mutedForeground }, - progressTrack: { - height: 4, - borderRadius: radius.full, - backgroundColor: c.border, - overflow: 'hidden', - }, - progressFill: { height: '100%', backgroundColor: c.primary }, - cacheActions: { flexDirection: 'row', gap: spacing.sm, marginTop: 4 }, -}); -} diff --git a/src/components/settings/AboutSettings.tsx b/src/components/settings/AboutSettings.tsx new file mode 100644 index 00000000..96353523 --- /dev/null +++ b/src/components/settings/AboutSettings.tsx @@ -0,0 +1,202 @@ +import React, { useEffect } from 'react'; +import { View, Text, Image, StyleSheet, Pressable, Linking, Platform } from 'react-native'; +import Constants from 'expo-constants'; +import { ExternalLink } from '../icons'; +import { SettingsSection, SettingItem } from './settings-section'; +import Button from '../Button'; +import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; +import { useColors, useResolvedTheme } from '../../theme/colors'; +import { useLocaleStore } from '../../stores/locale-store'; +import { useAuthStore } from '../../stores/auth-store'; +import { useAccountStore } from '../../stores/account-store'; +import { useUpdatesStore } from '../../stores/updates-store'; +import { supportsSideloadUpdates } from '../../lib/platform-capabilities'; +import { buildIssueUrl, sourceCodeUrl, updateSourceUrl } from '../../lib/about-links'; + +const LOGO_WHITE = require('../../../assets/logos/Bulwark Logo White.png'); +const LOGO_COLOR = require('../../../assets/logos/Bulwark Logo Color.png'); + +const extra = (Constants.expoConfig?.extra ?? {}) as { commit?: string; sourceCodeUrl?: string }; +export const APP_NAME = Constants.expoConfig?.name ?? 'Bulwark'; +export const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0'; +export const GIT_COMMIT = extra.commit ?? 'dev'; +const SOURCE_URL = sourceCodeUrl(extra.sourceCodeUrl); + +function hostOf(url: string): string { + try { + return new URL(url).host; + } catch { + return url; + } +} + +interface AboutSettingsProps { + /** Opens a page below About: Updates, the licence list, Privacy. */ + onOpen?: (page: 'updates' | 'licences' | 'privacy') => void; +} + +export function AboutSettings({ onOpen }: AboutSettingsProps) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const scheme = useResolvedTheme(); + const t = useLocaleStore((s) => s.t); + const locale = useLocaleStore((s) => s.locale); + + const authUsername = useAuthStore((s) => s.username); + const authServerUrl = useAuthStore((s) => s.serverUrl); + const activeAccountId = useAuthStore((s) => s.activeAccountId); + const account = useAccountStore((s) => s.accounts.find((a) => a.id === activeAccountId)); + const serverUrl = account?.serverUrl || authServerUrl || ''; + const signedInAs = account?.email || account?.username || authUsername || ''; + + const hydrated = useUpdatesStore((s) => s.hydrated); + const hydrate = useUpdatesStore((s) => s.hydrate); + const checking = useUpdatesStore((s) => s.checking); + const checkNow = useUpdatesStore((s) => s.checkNow); + const lastCheckedAt = useUpdatesStore((s) => s.lastCheckedAt); + const latest = useUpdatesStore((s) => s.cachedLatest); + const updateAvailable = useUpdatesStore((s) => s.hasUpdate()); + const severity = latest?.severity ?? 'normal'; + + useEffect(() => { + if (!hydrated) void hydrate(); + }, [hydrated, hydrate]); + + const updateLine = updateAvailable && latest + ? severity === 'security' + ? t('settings.advanced.about.security_update', 'Security update: {version}', { version: latest.tag }) + : severity === 'deprecated' + ? t('settings.advanced.about.deprecated_update', 'Update required: {version}', { version: latest.tag }) + : t('settings.advanced.about.update_available', 'Update: {version}', { version: latest.tag }) + : lastCheckedAt > 0 + ? t('settings.about.up_to_date', 'Up to date') + : null; + const openUpdate = () => { + if (supportsSideloadUpdates && onOpen) onOpen('updates'); + else void Linking.openURL(updateSourceUrl(Platform.OS, latest?.htmlUrl)); + }; + + const lastChecked = lastCheckedAt > 0 + ? t('settings.about.checked_at', 'Checked {when}', { + when: new Date(lastCheckedAt).toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' }), + }) + : t('settings.about.never_checked', 'Not checked yet'); + + const reportProblem = () => { + const constants = Platform.constants as { Release?: string; Model?: string }; + void Linking.openURL(buildIssueUrl( + { version: APP_VERSION, commit: GIT_COMMIT }, + { + os: Platform.OS, + osVersion: Platform.Version, + release: constants.Release, + model: constants.Model, + }, + )); + }; + + return ( + + + + + {APP_NAME} + v{APP_VERSION} ({GIT_COMMIT}) + {updateLine && ( + updateAvailable ? ( + + + {updateLine} + + + ) : ( + {updateLine} + ) + )} + + void Linking.openURL(SOURCE_URL)} + accessibilityRole="link" + style={styles.sourceLink} + > + {t('settings.about.source_code', 'Source code')} + + + + + + + {supportsSideloadUpdates && ( + + + + )} + onOpen?.('licences')} + /> + onOpen?.('privacy')} + /> + + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + container: { gap: spacing.xxl }, + card: { + flexDirection: 'row', + alignItems: 'center', + gap: 14, + padding: spacing.lg, + borderRadius: radius.md, + borderWidth: 1, + borderColor: c.border, + backgroundColor: c.card, + }, + logo: { width: 48, height: 48 }, + cardText: { flex: 1, minWidth: 0, gap: 3 }, + appName: { fontSize: 16, fontWeight: '600', lineHeight: 22, color: c.text }, + version: { ...typography.caption, fontSize: 13, lineHeight: 18, color: c.mutedForeground }, + updateLine: { ...typography.caption }, + updateLineOk: { color: c.success }, + updateLineNew: { color: c.textLink }, + updateLineUrgent: { color: c.error }, + sourceLink: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + minHeight: 48, + paddingHorizontal: 4, + }, + sourceText: { fontSize: 13, color: c.mutedForeground }, + }); +} diff --git a/src/components/settings/AppearanceSettings.tsx b/src/components/settings/AppearanceSettings.tsx index b2ac8b0f..64506bd2 100644 --- a/src/components/settings/AppearanceSettings.tsx +++ b/src/components/settings/AppearanceSettings.tsx @@ -93,6 +93,8 @@ export function AppearanceSettings() { const setShowToolbarLabels = useSettingsStore((s) => s.setShowToolbarLabels); const animationsEnabled = useSettingsStore((s) => s.animationsEnabled); const setAnimationsEnabled = useSettingsStore((s) => s.setAnimationsEnabled); + const senderFavicons = useSettingsStore((s) => s.senderFavicons); + const setSenderFavicons = useSettingsStore((s) => s.setSenderFavicons); const hydrated = useSettingsStore((s) => s.hydrated); const hydrate = useSettingsStore((s) => s.hydrate); @@ -103,7 +105,7 @@ export function AppearanceSettings() { return ( + + + + ); } diff --git a/src/components/settings/LicencesSettings.tsx b/src/components/settings/LicencesSettings.tsx new file mode 100644 index 00000000..524acaaa --- /dev/null +++ b/src/components/settings/LicencesSettings.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { View, Linking } from 'react-native'; +import { SettingsSection, SettingItem } from './settings-section'; +import { useLocaleStore } from '../../stores/locale-store'; +import { NATIVE_REPO_URL } from '../../lib/about-links'; +import licences from '../../lib/licences.json'; +import { APP_NAME } from './AboutSettings'; + +/** Settings → About → Open-source licences. The list is generated by scripts/generate-licences.mjs. */ +export function LicencesSettings() { + const t = useLocaleStore((s) => s.t); + return ( + + + void Linking.openURL(`${NATIVE_REPO_URL}/blob/main/LICENSE`)} + link="external" + noBorder + /> + + + {licences.libraries.map((lib, i) => ( + void Linking.openURL(lib.url) : undefined} + link="external" + noBorder={i === licences.libraries.length - 1} + /> + ))} + + + ); +} diff --git a/src/components/settings/PluginsSettings.tsx b/src/components/settings/PluginsSettings.tsx deleted file mode 100644 index b2dd8d59..00000000 --- a/src/components/settings/PluginsSettings.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React from 'react'; -import { View, Text, StyleSheet } from 'react-native'; -import { Puzzle } from '../icons'; -import { SettingsSection } from './settings-section'; -import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; -import { useColors } from '../../theme/colors'; -import { useLocaleStore } from '../../stores/locale-store'; - -/** - * The webmail's plugin runtime (sandboxed iframes, marketplace, signing) has - * no counterpart in the native app, so this pane only explains where plugins - * live. The tab is hidden from the settings list; the component stays so a - * deep link to `settings/plugins` still renders something sensible. - */ -export function PluginsSettings() { - const c = useColors(); - const styles = React.useMemo(() => makeStyles(c), [c]); - const t = useLocaleStore((s) => s.t); - - return ( - - - - - {t( - 'settings.plugins.mobile_explainer', - 'The mobile app has no plugin runtime. Install and manage plugins from the webmail under Settings → Plugins; themes and filters they provide are not applied here.', - )} - - - - ); -} - -function makeStyles(c: ThemePalette) { - return StyleSheet.create({ - box: { - flexDirection: 'row', - gap: spacing.md, - alignItems: 'flex-start', - padding: spacing.md, - borderRadius: radius.md, - borderWidth: 1, - borderColor: c.border, - backgroundColor: c.muted, - }, - text: { ...typography.body, color: c.mutedForeground, flex: 1 }, - }); -} diff --git a/src/components/settings/PrivacySettings.tsx b/src/components/settings/PrivacySettings.tsx new file mode 100644 index 00000000..e857babe --- /dev/null +++ b/src/components/settings/PrivacySettings.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { View, Text, StyleSheet, Platform } from 'react-native'; +import { SettingsSection } from './settings-section'; +import { spacing, typography, type ThemePalette } from '../../theme/tokens'; +import { useColors } from '../../theme/colors'; +import { useLocaleStore } from '../../stores/locale-store'; +import { DEFAULT_RELAY_BASE_URL } from '../../lib/push-notifications'; +import { supportsSideloadUpdates } from '../../lib/platform-capabilities'; + +function hostOf(url: string): string { + try { + return new URL(url).host; + } catch { + return url; + } +} + +/** Settings → About → Privacy: where the app keeps and sends data. */ +export function PrivacySettings() { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + + const paragraph = (text: string) => {text}; + + return ( + + + {paragraph(t( + 'settings.privacy.phone_secure', + 'Your sign-in details are kept in the system\'s secure storage. Settings, the offline mail cache and changes waiting to sync are kept in the app\'s own storage.', + ))} + {Platform.OS === 'android' && paragraph(t( + 'settings.privacy.phone_backup', + 'None of it goes into Android\'s cloud backup.', + ))} + + + + {paragraph(t( + 'settings.privacy.server', + 'Mail, calendars, contacts and files are loaded from your server and saved back to it. Apart from the connections below, the app talks only to your server.', + ))} + + + + {paragraph(t( + 'settings.privacy.push', + 'When notifications are on, your server tells the push relay ({relay}) that something changed, and the relay wakes the app through {service}. The relay sees a device token and a change marker, never your mail. You can use your own relay under Notifications.', + { + relay: hostOf(DEFAULT_RELAY_BASE_URL), + service: Platform.OS === 'ios' + ? t('settings.privacy.service_apple', 'Apple\'s push service') + : t('settings.privacy.service_google', 'Google\'s push service'), + }, + ))} + + + + {paragraph(t( + 'settings.privacy.favicons', + 'Sender icons for business mail come from DuckDuckGo\'s icon service, which sees the sender\'s domain. Turn them off under Appearance → Sender favicons.', + ))} + {paragraph(supportsSideloadUpdates + ? t('settings.privacy.updates_android', 'The app asks GitHub for the latest release to tell you about updates. Turn that off under Updates.') + : t('settings.privacy.updates', 'The app asks GitHub for the latest release to tell you about updates.'))} + {paragraph(t( + 'settings.privacy.no_tracking', + 'The app has no analytics and no ads.', + ))} + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + container: { gap: spacing.xxl }, + paragraph: { ...typography.body, color: c.text, paddingVertical: spacing.xs }, + }); +} diff --git a/src/components/settings/SmimeSettings.tsx b/src/components/settings/SmimeSettings.tsx deleted file mode 100644 index 58f2d50d..00000000 --- a/src/components/settings/SmimeSettings.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React from 'react'; -import { View, Text, StyleSheet } from 'react-native'; -import { KeyRound } from '../icons'; -import { SettingsSection } from './settings-section'; -import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; -import { useColors } from '../../theme/colors'; -import { useLocaleStore } from '../../stores/locale-store'; - -/** - * S/MIME needs a native crypto path (key import into the keystore, CMS - * sign/encrypt/decrypt) that the app does not have yet. The tab is marked - * `implemented: false` in SettingsScreen; this explainer is what a deep link - * lands on. - */ -export function SmimeSettings() { - const c = useColors(); - const styles = React.useMemo(() => makeStyles(c), [c]); - const t = useLocaleStore((s) => s.t); - - return ( - - - - - {t( - 'settings.smime.mobile_explainer', - 'S/MIME is not available in the mobile app yet. Manage keys and certificates in the webmail; signed messages are shown without verification here.', - )} - - - - ); -} - -function makeStyles(c: ThemePalette) { - return StyleSheet.create({ - box: { - flexDirection: 'row', - gap: spacing.md, - alignItems: 'flex-start', - padding: spacing.md, - borderRadius: radius.md, - borderWidth: 1, - borderColor: c.border, - backgroundColor: c.muted, - }, - text: { ...typography.body, color: c.mutedForeground, flex: 1 }, - }); -} diff --git a/src/components/settings/StorageSettings.tsx b/src/components/settings/StorageSettings.tsx new file mode 100644 index 00000000..beaadb80 --- /dev/null +++ b/src/components/settings/StorageSettings.tsx @@ -0,0 +1,299 @@ +import React, { useEffect, useState } from 'react'; +import { View, Text, StyleSheet, Alert } from 'react-native'; +import * as DocumentPicker from 'expo-document-picker'; +import { File, Paths } from 'expo-file-system'; +import * as Sharing from 'expo-sharing'; +import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section'; +import Button from '../Button'; +import Dialog from '../Dialog'; +import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; +import { useColors } from '../../theme/colors'; +import { useSettingsStore } from '../../stores/settings-store'; +import { useLocaleStore } from '../../stores/locale-store'; +import { useOfflineCacheStore } from '../../stores/offline-cache-store'; +import { useOutboxStore } from '../../stores/outbox-store'; +import { runOfflineSync, formatBytes } from '../../lib/offline-sync'; + +interface StorageSettingsProps { + /** Opens Troubleshooting (refresh cached data, debug logging). */ + onOpenTroubleshooting?: () => void; +} + +/** Settings → Storage & backup: offline mail, settings export/import, reset. */ +export function StorageSettings({ onOpenTroubleshooting }: StorageSettingsProps) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + const hydrated = useSettingsStore((s) => s.hydrated); + const hydrate = useSettingsStore((s) => s.hydrate); + const resetToDefaults = useSettingsStore((s) => s.resetToDefaults); + const exportSettings = useSettingsStore((s) => s.exportSettings); + const importSettings = useSettingsStore((s) => s.importSettings); + + const [confirmReset, setConfirmReset] = useState(false); + + const offlineEnabled = useSettingsStore((s) => s.offlineCacheEnabled); + const offlineDays = useSettingsStore((s) => s.offlineCacheDays); + const offlineMaxMB = useSettingsStore((s) => s.offlineCacheMaxMB); + const updateSetting = useSettingsStore((s) => s.updateSetting); + const queuedChanges = useOutboxStore((s) => s.entries.length); + const flushOutbox = useOutboxStore((s) => s.flush); + const cacheCount = useOfflineCacheStore((s) => s.totalCount()); + const cacheBytes = useOfflineCacheStore((s) => s.totalSize()); + const cacheHydrated = useOfflineCacheStore((s) => s.hydrated); + const cacheHydrate = useOfflineCacheStore((s) => s.hydrate); + const sync = useOfflineCacheStore((s) => s.sync); + const clearAllCache = useOfflineCacheStore((s) => s.clearAll); + const syncBusy = sync.phase === 'scanning' || sync.phase === 'fetching'; + + useEffect(() => { + if (!hydrated) void hydrate(); + if (!cacheHydrated) void cacheHydrate(); + }, [hydrated, hydrate, cacheHydrated, cacheHydrate]); + + const dayOptions = [1, 3, 7, 14, 30, 90].map((d) => ({ + value: String(d), + label: d === 1 + ? t('settings.offline.keep_24h', '24 hours') + : t('settings.offline.keep_days', '{count} days', { count: d }), + })); + const maxMbOptions = [25, 50, 100, 250, 500].map((mb) => ({ value: String(mb), label: `${mb} MB` })); + + const formatRelativeTime = (ms: number | undefined): string => { + if (!ms) return t('settings.offline.never', 'never'); + const diff = Date.now() - ms; + if (diff < 60_000) return t('settings.offline.just_now', 'just now'); + if (diff < 60 * 60_000) return t('settings.offline.minutes_ago', '{count} min ago', { count: Math.round(diff / 60_000) }); + if (diff < 24 * 60 * 60_000) return t('settings.offline.hours_ago', '{count} h ago', { count: Math.round(diff / (60 * 60_000)) }); + return t('settings.offline.days_ago', '{count} d ago', { count: Math.round(diff / (24 * 60 * 60_000)) }); + }; + + const handleSyncNow = () => { + void runOfflineSync({ days: offlineDays, maxMB: offlineMaxMB }); + // Also drain any queued offline changes while we're at it. + void flushOutbox(); + }; + + const handleClearCache = () => { + Alert.alert( + t('settings.offline.clear_title', 'Clear offline mail'), + t( + 'settings.offline.clear_message', + 'Remove {count, plural, one {# cached message} other {# cached messages}} ({size})?', + { count: cacheCount, size: formatBytes(cacheBytes) }, + ), + [ + { text: t('common.cancel', 'Cancel'), style: 'cancel' }, + { text: t('settings.offline.clear', 'Clear'), style: 'destructive', onPress: () => { void clearAllCache(); } }, + ], + ); + }; + + const handleReset = () => { + setConfirmReset(false); + resetToDefaults(); + Alert.alert( + t('settings.advanced.reset_settings.label', 'Reset Settings'), + t('settings.advanced.reset_settings.done', 'All settings have been restored to their defaults.'), + ); + }; + + const handleExport = async () => { + try { + const json = exportSettings(); + const file = new File(Paths.cache, `bulwark-settings-${new Date().toISOString().slice(0, 10)}.json`); + file.write(json); + if (await Sharing.isAvailableAsync()) { + await Sharing.shareAsync(file.uri, { + mimeType: 'application/json', + dialogTitle: t('settings.advanced.export_settings.label', 'Export Settings'), + }); + } else { + Alert.alert( + t('settings.advanced.export_settings.label', 'Export Settings'), + t('settings.advanced.export_settings.saved_to', 'Saved to {path}', { path: file.uri }), + ); + } + } catch (err) { + Alert.alert( + t('settings.advanced.export_settings.label', 'Export Settings'), + err instanceof Error ? err.message : String(err), + ); + } + }; + + const handleImport = async () => { + try { + const picked = await DocumentPicker.getDocumentAsync({ + type: ['application/json', 'text/plain', '*/*'], + copyToCacheDirectory: true, + multiple: false, + }); + if (picked.canceled || !picked.assets?.[0]) return; + const json = await new File(picked.assets[0].uri).text(); + const ok = importSettings(json); + Alert.alert( + t('settings.advanced.import_settings.label', 'Import Settings'), + ok + ? t('settings.import_success', 'Settings imported successfully') + : t('settings.import_error', 'Failed to import settings'), + ); + } catch (err) { + Alert.alert( + t('settings.advanced.import_settings.label', 'Import Settings'), + err instanceof Error ? err.message : t('settings.import_error', 'Failed to import settings'), + ); + } + }; + + const usedMB = cacheBytes / (1024 * 1024); + const usedFraction = offlineMaxMB > 0 ? Math.min(1, usedMB / offlineMaxMB) : 0; + const syncLine = sync.phase === 'fetching' + ? t('settings.offline.downloading', 'Downloading {completed}/{total}…', { completed: sync.completed, total: sync.total }) + : sync.phase === 'scanning' + ? t('settings.offline.scanning', 'Scanning recent mail…') + : sync.phase === 'error' + ? t('settings.offline.last_sync_failed', 'Last sync failed: {message}', { message: sync.message ?? t('identities.validation_errors.unknown_error', 'Unknown error') }) + : t('settings.offline.synced', 'Synced {when}', { when: formatRelativeTime(sync.finishedAt) }); + + return ( + + + + + {cacheCount === 0 + ? t('settings.offline.nothing_cached', 'Nothing cached yet') + : t('settings.offline.messages_offline', '{count, plural, one {# message offline} other {# messages offline}}', { count: cacheCount })} + + + {t('settings.offline.used_of', '{used} of {limit} MB', { used: usedMB.toFixed(1), limit: offlineMaxMB })} + + + 0 ? sync.completed / sync.total : usedFraction) * 100) }} + > + 0 ? sync.completed / sync.total : usedFraction) * 100)}%` }, + ]} + /> + + + + {syncLine} + {queuedChanges > 0 && ( + + {t('settings.offline.queued_changes', '{count, plural, one {# change} other {# changes}} waiting to sync', { count: queuedChanges })} + + )} + + + + + + + + + updateSetting('offlineCacheEnabled', v)} + /> + + + updateSetting('offlineCacheMaxMB', Number(v))} + options={maxMbOptions} + /> + + + + + + + + + + + + + + + + + setConfirmReset(false)} + onConfirm={handleReset} + /> + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + container: { gap: spacing.xxl }, + usageCard: { + padding: 14, + borderRadius: radius.md, + borderWidth: 1, + borderColor: c.border, + backgroundColor: c.card, + gap: 10, + }, + usageHeader: { flexDirection: 'row', justifyContent: 'space-between', gap: spacing.sm, flexWrap: 'wrap' }, + usageTitle: { ...typography.bodyMedium, color: c.text }, + usageSize: { ...typography.body, color: c.mutedForeground }, + progressTrack: { + height: 6, + borderRadius: 3, + backgroundColor: c.muted, + overflow: 'hidden', + }, + progressFill: { height: '100%', backgroundColor: c.text }, + usageFooter: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm }, + usageSub: { ...typography.caption, color: c.mutedForeground }, + buttonPair: { flexDirection: 'row', gap: spacing.sm }, + }); +} diff --git a/src/components/settings/TroubleshootingSettings.tsx b/src/components/settings/TroubleshootingSettings.tsx new file mode 100644 index 00000000..c5ecb30d --- /dev/null +++ b/src/components/settings/TroubleshootingSettings.tsx @@ -0,0 +1,101 @@ +import React, { useState } from 'react'; +import { View, Text, StyleSheet, Alert } from 'react-native'; +import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; +import Button from '../Button'; +import { spacing, typography, type ThemePalette } from '../../theme/tokens'; +import { useColors } from '../../theme/colors'; +import { ALL_DEBUG_CATEGORIES, useSettingsStore, type DebugCategory } from '../../stores/settings-store'; +import { useLocaleStore } from '../../stores/locale-store'; +import { clearCachedData } from '../../lib/clear-cached-data'; + +/** Settings → Storage & backup → Troubleshooting (webmail: the Debug page). */ +export function TroubleshootingSettings() { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + const debugMode = useSettingsStore((s) => s.debugMode); + const debugCategories = useSettingsStore((s) => s.debugCategories); + const updateSetting = useSettingsStore((s) => s.updateSetting); + const [refreshing, setRefreshing] = useState(false); + + const handleRefreshCache = async () => { + setRefreshing(true); + try { + await clearCachedData(); + } catch (err) { + Alert.alert( + t('settings.advanced.refresh_cache.label', 'Refresh cached data'), + err instanceof Error ? err.message : String(err), + ); + } finally { + setRefreshing(false); + } + }; + + return ( + + + + + + + + + + updateSetting('debugMode', v)} /> + + {debugMode && ( + + + {t( + 'settings.advanced.debug_categories.description', + "Select which categories to log. Disable categories you don't need to reduce console noise.", + )} + + {ALL_DEBUG_CATEGORIES.map((cat: DebugCategory, i) => ( + + updateSetting('debugCategories', { ...debugCategories, [cat]: v })} + /> + + ))} + + )} + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + container: { gap: spacing.xxl }, + categories: { + marginStart: spacing.xs, + paddingStart: spacing.lg, + borderStartWidth: 2, + borderStartColor: c.muted, + }, + hint: { ...typography.caption, color: c.mutedForeground, paddingTop: spacing.md }, + }); +} diff --git a/src/lib/__tests__/about-links.test.ts b/src/lib/__tests__/about-links.test.ts new file mode 100644 index 00000000..bdeb0eda --- /dev/null +++ b/src/lib/__tests__/about-links.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { + buildIssueUrl, describePlatform, sourceCodeUrl, updateSourceUrl, + IOS_TESTFLIGHT_URL, NATIVE_REPO_URL, +} from '../about-links'; + +function bodyOf(url: string): string { + return decodeURIComponent(new URL(url).searchParams.get('body') ?? ''); +} + +describe('about links', () => { + it('opens a new issue on the native repo with version and platform filled in', () => { + const url = buildIssueUrl( + { version: '0.1.76', commit: '6aaa479' }, + { os: 'android', osVersion: 34, release: '14', model: 'Pixel 7' }, + ); + expect(url.startsWith(`${NATIVE_REPO_URL}/issues/new?`)).toBe(true); + const body = bodyOf(url); + expect(body).toContain('App: v0.1.76 (6aaa479)'); + expect(body).toContain('Platform: Android 14 (API 34) · Pixel 7'); + }); + + it('describes iOS by its version', () => { + expect(describePlatform({ os: 'ios', osVersion: '18.1', model: 'iPhone' })).toBe('iOS 18.1 · iPhone'); + expect(describePlatform({ os: 'android', osVersion: 33 })).toBe('Android (API 33)'); + }); + + it('links the configured source, or upstream when none or a bad one is set', () => { + expect(sourceCodeUrl('https://git.example.org/me/bulwark')).toBe('https://git.example.org/me/bulwark'); + expect(sourceCodeUrl(undefined)).toBe(NATIVE_REPO_URL); + expect(sourceCodeUrl('')).toBe(NATIVE_REPO_URL); + expect(sourceCodeUrl('javascript:alert(1)')).toBe(NATIVE_REPO_URL); + }); + + it('sends iPhone users to TestFlight, not to the GitHub releases', () => { + expect(updateSourceUrl('ios', 'https://github.com/bulwarkmail/native/releases/tag/0.1.77')).toBe(IOS_TESTFLIGHT_URL); + expect(updateSourceUrl('android', 'https://github.com/bulwarkmail/native/releases/tag/0.1.77')) + .toBe('https://github.com/bulwarkmail/native/releases/tag/0.1.77'); + expect(updateSourceUrl('android', null)).toBe(`${NATIVE_REPO_URL}/releases`); + }); +}); diff --git a/src/lib/__tests__/licences.test.ts b/src/lib/__tests__/licences.test.ts new file mode 100644 index 00000000..88107183 --- /dev/null +++ b/src/lib/__tests__/licences.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import licences from '../licences.json'; + +const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', '..', 'package.json'), 'utf8')); + +describe('open-source licences', () => { + it('lists every library the app ships, with its licence', () => { + const listed = new Map(licences.libraries.map((l) => [l.name, l])); + const missing = Object.keys(pkg.dependencies).filter((name) => !listed.has(name)); + // Regenerate with `node scripts/generate-licences.mjs`. + expect(missing).toEqual([]); + for (const name of Object.keys(pkg.dependencies)) { + expect(listed.get(name)?.license, name).toBeTruthy(); + expect(listed.get(name)?.version, name).toBe( + JSON.parse(readFileSync(join(__dirname, '..', '..', '..', 'node_modules', name, 'package.json'), 'utf8')).version, + ); + } + }); + + it('names the app licence from package.json', () => { + expect(licences.app.license).toBe(pkg.license); + }); +}); diff --git a/src/lib/__tests__/settings-search.test.ts b/src/lib/__tests__/settings-search.test.ts index 0b08cb57..1b31344f 100644 --- a/src/lib/__tests__/settings-search.test.ts +++ b/src/lib/__tests__/settings-search.test.ts @@ -96,7 +96,9 @@ describe('settings search index (English catalog)', () => { contacts: 'ContactsSettings.tsx', files: 'FilesSettings.tsx', sidebar_apps: 'SidebarAppsSettings.tsx', - about_data: 'AboutDataSettings.tsx', + about_data: 'StorageSettings.tsx', + about: 'AboutSettings.tsx', + debug: 'TroubleshootingSettings.tsx', updates: 'UpdatesSettings.tsx', }; diff --git a/src/lib/__tests__/settings-summary.test.ts b/src/lib/__tests__/settings-summary.test.ts new file mode 100644 index 00000000..33ecca58 --- /dev/null +++ b/src/lib/__tests__/settings-summary.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { settingsSummary, type SummaryState } from '../settings-summary'; +import { formatMessage, type MessageParams } from '../../i18n/format'; + +const t = (_key: string, fallback: string, params?: MessageParams) => formatMessage(fallback, params, 'en'); + +const BASE: SummaryState = { + languageLabel: 'English', + timeFormat: '24h', + theme: 'dark', + themeName: null, + emailNotificationsEnabled: true, + calendarNotificationsEnabled: false, + calendarDefaultView: 'month', + calendarFirstDayOfWeek: 1, + offlineCacheEnabled: false, + offlineCacheDays: 7, + offlineCacheMaxMB: 50, + appVersion: '0.1.76', + update: 'current', +}; + +describe('settings list summaries', () => { + it('shows the current values of a pane', () => { + expect(settingsSummary('language', BASE, t)).toBe('English · 24-hour'); + expect(settingsSummary('language', { ...BASE, timeFormat: '12h' }, t)).toBe('English · 12-hour'); + expect(settingsSummary('appearance', BASE, t)).toBe('Dark · Bulwark'); + expect(settingsSummary('appearance', { ...BASE, theme: 'system', themeName: 'Nord' }, t)).toBe('System · Nord'); + expect(settingsSummary('notifications', BASE, t)).toBe('Mail on · events off'); + expect(settingsSummary('calendar', { ...BASE, calendarDefaultView: 'week', calendarFirstDayOfWeek: 0 }, t)) + .toBe('Week · week starts Sunday'); + }); + + it('describes offline mail on Storage & backup', () => { + expect(settingsSummary('about_data', BASE, t)).toBe('Offline mail off'); + expect(settingsSummary('about_data', { ...BASE, offlineCacheEnabled: true, offlineCacheDays: 1 }, t)) + .toBe('Offline mail on · 1 day, 50 MB'); + expect(settingsSummary('about_data', { ...BASE, offlineCacheEnabled: true, offlineCacheMaxMB: 250 }, t)) + .toBe('Offline mail on · 7 days, 250 MB'); + }); + + it('puts the version and update state on About', () => { + expect(settingsSummary('about', BASE, t)).toBe('Version 0.1.76 · Up to date'); + expect(settingsSummary('about', { ...BASE, update: 'available' }, t)).toBe('Version 0.1.76 · Update available'); + expect(settingsSummary('about', { ...BASE, update: 'security' }, t)).toBe('Version 0.1.76 · Security update available'); + expect(settingsSummary('about', { ...BASE, update: 'none' }, t)).toBe('Version 0.1.76'); + }); + + it('falls back to a short description for panes without one value', () => { + expect(settingsSummary('filters', BASE, t)).toBe('Rules for incoming mail'); + expect(settingsSummary('account', BASE, t)).toBeNull(); + }); +}); diff --git a/src/lib/about-links.ts b/src/lib/about-links.ts new file mode 100644 index 00000000..c25cca37 --- /dev/null +++ b/src/lib/about-links.ts @@ -0,0 +1,69 @@ +// Links on Settings → About: where the source is, where iPhone users get the +// app, and a prefilled GitHub issue. Kept free of React Native so it can be +// unit-tested; the screen passes in the platform details. + +export const NATIVE_REPO_URL = 'https://github.com/bulwarkmail/native'; + +/** Public TestFlight link (README). There is no App Store listing yet. */ +export const IOS_TESTFLIGHT_URL = 'https://testflight.apple.com/join/rumcjNSp'; + +export interface DeviceInfo { + os: string; + /** Platform.Version: the API level on Android, the OS version on iOS. */ + osVersion: string | number; + /** Android's release name ("14"), when known. */ + release?: string; + model?: string; +} + +/** + * Where "Source code" points. A build can set `extra.sourceCodeUrl` in + * app.config.js (SOURCE_CODE_URL at build time), like the webmail's + * sourceCodeUrl: someone who ships a modified app links their own source + * (AGPL §13). Anything that isn't an http(s) URL falls back to upstream. + */ +export function sourceCodeUrl(configured: unknown): string { + if (typeof configured === 'string' && /^https?:\/\/\S+$/i.test(configured.trim())) return configured.trim(); + return NATIVE_REPO_URL; +} + +/** "Android 14 (API 34) · Pixel 7", "iOS 18.1 · iPhone". */ +export function describePlatform(d: DeviceInfo): string { + let os: string; + if (d.os === 'android') { + os = d.release ? `Android ${d.release} (API ${d.osVersion})` : `Android (API ${d.osVersion})`; + } else if (d.os === 'ios') { + os = `iOS ${d.osVersion}`; + } else { + os = `${d.os} ${d.osVersion}`; + } + return d.model ? `${os} · ${d.model}` : os; +} + +/** A new-issue link on the native repo with the version and platform filled in. */ +export function buildIssueUrl(app: { version: string; commit: string }, device: DeviceInfo): string { + const body = [ + '**What happened?**', + '', + '', + '**What did you expect?**', + '', + '', + '**Steps to reproduce**', + '1. ', + '', + '---', + `App: v${app.version} (${app.commit})`, + `Platform: ${describePlatform(device)}`, + ].join('\n'); + return `${NATIVE_REPO_URL}/issues/new?body=${encodeURIComponent(body)}`; +} + +/** + * Where an update comes from on this platform: Android installs it in-app + * (Settings → Updates), iOS gets it from TestFlight. + */ +export function updateSourceUrl(os: string, releaseUrl: string | null | undefined): string { + if (os === 'ios') return IOS_TESTFLIGHT_URL; + return releaseUrl || `${NATIVE_REPO_URL}/releases`; +} diff --git a/src/lib/licences.json b/src/lib/licences.json new file mode 100644 index 00000000..fbf386c7 --- /dev/null +++ b/src/lib/licences.json @@ -0,0 +1,218 @@ +{ + "app": { + "name": "bulwark-mobile", + "license": "AGPL-3.0-only" + }, + "libraries": [ + { + "name": "@react-native-async-storage/async-storage", + "version": "2.2.0", + "license": "MIT", + "url": "https://github.com/react-native-async-storage/async-storage" + }, + { + "name": "@react-native-community/datetimepicker", + "version": "8.4.4", + "license": "MIT", + "url": "https://github.com/react-native-community/datetimepicker" + }, + { + "name": "@react-native-community/netinfo", + "version": "11.4.1", + "license": "MIT", + "url": "https://github.com/react-native-netinfo/react-native-netinfo" + }, + { + "name": "@react-navigation/bottom-tabs", + "version": "7.15.9", + "license": "MIT", + "url": "https://github.com/react-navigation/react-navigation" + }, + { + "name": "@react-navigation/native", + "version": "7.2.2", + "license": "MIT", + "url": "https://reactnavigation.org" + }, + { + "name": "@react-navigation/native-stack", + "version": "7.14.10", + "license": "MIT", + "url": "https://github.com/software-mansion/react-native-screens" + }, + { + "name": "date-fns", + "version": "4.1.0", + "license": "MIT", + "url": "https://github.com/date-fns/date-fns" + }, + { + "name": "expo", + "version": "54.0.33", + "license": "MIT", + "url": "https://github.com/expo/expo/tree/main/packages/expo" + }, + { + "name": "expo-camera", + "version": "17.0.10", + "license": "MIT", + "url": "https://docs.expo.dev/versions/latest/sdk/camera/" + }, + { + "name": "expo-clipboard", + "version": "8.0.8", + "license": "MIT", + "url": "https://docs.expo.dev/versions/latest/sdk/clipboard" + }, + { + "name": "expo-crypto", + "version": "15.0.9", + "license": "MIT", + "url": "https://docs.expo.dev/versions/latest/sdk/crypto/" + }, + { + "name": "expo-document-picker", + "version": "14.0.8", + "license": "MIT", + "url": "https://docs.expo.dev/versions/latest/sdk/document-picker/" + }, + { + "name": "expo-file-system", + "version": "19.0.21", + "license": "MIT", + "url": "https://docs.expo.dev/versions/latest/sdk/filesystem/" + }, + { + "name": "expo-haptics", + "version": "15.0.8", + "license": "MIT", + "url": "https://docs.expo.dev/versions/latest/sdk/haptics/" + }, + { + "name": "expo-image-manipulator", + "version": "14.0.8", + "license": "MIT", + "url": "https://docs.expo.dev/versions/latest/sdk/imagemanipulator/" + }, + { + "name": "expo-image-picker", + "version": "17.0.10", + "license": "MIT", + "url": "https://docs.expo.dev/versions/latest/sdk/imagepicker/" + }, + { + "name": "expo-intent-launcher", + "version": "13.0.8", + "license": "MIT", + "url": "https://docs.expo.dev/versions/latest/sdk/intent-launcher/" + }, + { + "name": "expo-localization", + "version": "17.0.8", + "license": "MIT", + "url": "https://docs.expo.dev/versions/latest/sdk/localization/" + }, + { + "name": "expo-notifications", + "version": "0.32.17", + "license": "MIT", + "url": "https://docs.expo.dev/versions/latest/sdk/notifications/" + }, + { + "name": "expo-secure-store", + "version": "15.0.8", + "license": "MIT", + "url": "https://docs.expo.dev/versions/latest/sdk/securestore/" + }, + { + "name": "expo-sharing", + "version": "14.0.8", + "license": "MIT", + "url": "https://docs.expo.dev/versions/latest/sdk/sharing/" + }, + { + "name": "expo-status-bar", + "version": "3.0.9", + "license": "MIT", + "url": "https://docs.expo.dev/versions/latest/sdk/status-bar/" + }, + { + "name": "expo-web-browser", + "version": "15.0.11", + "license": "MIT", + "url": "https://docs.expo.dev/versions/latest/sdk/webbrowser/" + }, + { + "name": "jszip", + "version": "3.10.1", + "license": "(MIT OR GPL-3.0-or-later)", + "url": "https://github.com/Stuk/jszip" + }, + { + "name": "postal-mime", + "version": "3.0.0", + "license": "MIT-0", + "url": "https://postal-mime.postalsys.com" + }, + { + "name": "react", + "version": "19.1.0", + "license": "MIT", + "url": "https://react.dev/" + }, + { + "name": "react-native", + "version": "0.81.5", + "license": "MIT", + "url": "https://reactnative.dev/" + }, + { + "name": "react-native-android-widget", + "version": "0.22.1", + "license": "MIT", + "url": "https://github.com/sAleksovski/react-native-android-widget" + }, + { + "name": "react-native-safe-area-context", + "version": "5.6.2", + "license": "MIT", + "url": "https://github.com/AppAndFlow/react-native-safe-area-context" + }, + { + "name": "react-native-screens", + "version": "4.16.0", + "license": "MIT", + "url": "https://github.com/software-mansion/react-native-screens" + }, + { + "name": "react-native-sse", + "version": "1.2.1", + "license": "MIT", + "url": "https://github.com/binaryminds/react-native-sse" + }, + { + "name": "react-native-svg", + "version": "15.12.1", + "license": "MIT", + "url": "https://github.com/react-native-community/react-native-svg" + }, + { + "name": "react-native-webview", + "version": "13.15.0", + "license": "MIT", + "url": "https://github.com/react-native-webview/react-native-webview" + }, + { + "name": "zustand", + "version": "5.0.12", + "license": "MIT", + "url": "https://github.com/pmndrs/zustand" + }, + { + "name": "Tabler Icons", + "version": "", + "license": "MIT", + "url": "https://tabler.io/icons" + } + ] +} diff --git a/src/lib/settings-search.ts b/src/lib/settings-search.ts index 86c0f776..58fdc75d 100644 --- a/src/lib/settings-search.ts +++ b/src/lib/settings-search.ts @@ -18,9 +18,11 @@ export type SettingsTabId = | 'appearance' | 'layout' | 'reading' | 'composing' | 'identities' | 'vacation' | 'filters' | 'templates' | 'folders' | 'keywords' | 'downloads' - | 'security' | 'encryption' | 'content_senders' + | 'security' | 'content_senders' | 'calendar' | 'contacts' | 'files' | 'sidebar_apps' - | 'about_data' | 'themes' | 'plugins' | 'updates' | 'debug'; + // about_data is Storage & backup (the id predates the split from About); + // debug, licences and privacy are pages opened from inside another one. + | 'about_data' | 'about' | 'licences' | 'privacy' | 'themes' | 'updates' | 'debug'; // Translation paths per pane. A path may point at a whole namespace, at one // setting's object ({ label, description, ...options }) or at a single label @@ -61,6 +63,7 @@ export const SETTINGS_SEARCH_PATHS: Record = { 'settings.appearance.list_density', 'settings.appearance.toolbar_labels', 'settings.appearance.animations', + 'settings.advanced.sender_favicons', ], layout: [ 'settings.layout.quick_actions', @@ -152,7 +155,6 @@ export const SETTINGS_SEARCH_PATHS: Record = { 'settings.security.encryption.encrypt_on_append', 'settings.security.encryption.allow_spam_training', ], - encryption: ['settings.smime'], content_senders: [ 'settings.email_behavior.external_content', 'settings.email_behavior.always_light_mode', @@ -198,20 +200,25 @@ export const SETTINGS_SEARCH_PATHS: Record = { 'settings.sidebar_apps.add', ], about_data: [ - 'settings.advanced.debug_mode', - 'settings.advanced.sender_favicons', - 'settings.advanced.refresh_cache', - 'settings.advanced.export_settings', - 'settings.advanced.import_settings', - 'settings.advanced.reset_settings', 'settings.offline.title', 'settings.offline.enabled', - 'settings.offline.window', - 'settings.offline.max_size', + 'settings.offline.keep_from', + 'settings.offline.use_at_most', + 'settings.backup.title', + 'settings.backup.export_import', + 'settings.advanced.reset_settings', + 'settings.troubleshooting.title', ], + about: [ + 'settings.about.server', + 'settings.about.licences', + 'settings.about.privacy', + 'settings.about.report_problem', + ], + licences: [], + privacy: [], // The theme cards are added by the caller (built-in theme names). themes: [], - plugins: [], updates: [ 'updates.current_version', 'updates.latest_version', @@ -221,7 +228,10 @@ export const SETTINGS_SEARCH_PATHS: Record = { 'updates.advisory', 'updates.release_page', ], - debug: [], + debug: [ + 'settings.advanced.refresh_cache', + 'settings.advanced.debug_mode', + ], }; // Extra English keywords per pane so common search terms hit even when the @@ -242,17 +252,18 @@ export const SETTINGS_SEARCH_KEYWORDS: Record = { keywords: 'tags labels colors', downloads: 'download filename template eml attachment save export', security: 'password 2fa two-factor totp app password mfa api key certificate encryption', - encryption: 's/mime smime certificate sign encrypt', content_senders: 'block sender remote images privacy tracking', calendar: `event schedule appointment meeting timezone${supportsDeviceSync ? ' android phone device sync' : ''}`, contacts: `address book contact${supportsDeviceSync ? ' android phone device sync' : ''}`, files: 'attachments cloud drive storage upload', sidebar_apps: 'apps webview iframe', - about_data: 'export import backup offline cache debug logs reset version', + about_data: 'export import backup offline cache storage reset', + about: 'version licence license privacy bug issue report source github', + licences: 'licence license open source', + privacy: 'privacy data relay push', themes: 'theme color colour palette skin appearance', - plugins: 'extensions addons', updates: 'update upgrade version apk install release', - debug: 'logs developer console diagnostic', + debug: 'troubleshooting refresh logs developer console diagnostic', }; export interface SubResult { diff --git a/src/lib/settings-summary.ts b/src/lib/settings-summary.ts new file mode 100644 index 00000000..49d88687 --- /dev/null +++ b/src/lib/settings-summary.ts @@ -0,0 +1,103 @@ +// The second line of each row in the Settings list: the current value where a +// pane has one worth showing ("Dark · Bulwark", "English · 24-hour"), and a +// short description of what's inside otherwise. Kept free of React so it can +// be unit-tested; SettingsScreen passes in the state. + +import type { SettingsTabId } from './settings-search'; +import type { MessageParams } from '../i18n/format'; + +type T = (key: string, fallback: string, params?: MessageParams) => string; + +export interface SummaryState { + languageLabel: string; + timeFormat: '12h' | '24h'; + theme: 'light' | 'dark' | 'system'; + /** Display name of the active built-in theme, or null for the default. */ + themeName: string | null; + emailNotificationsEnabled: boolean; + calendarNotificationsEnabled: boolean; + calendarDefaultView: 'month' | 'week' | 'day' | 'agenda'; + calendarFirstDayOfWeek: 0 | 1 | 6; + offlineCacheEnabled: boolean; + offlineCacheDays: number; + offlineCacheMaxMB: number; + appVersion: string; + /** 'none' when no check has run yet. */ + update: 'none' | 'current' | 'available' | 'security'; +} + +const WEEKDAY: Record<0 | 1 | 6, [string, string]> = { + 0: ['calendar.days.sunday', 'Sunday'], + 1: ['calendar.days.monday', 'Monday'], + 6: ['calendar.days.saturday', 'Saturday'], +}; + +const STATIC: Partial> = { + layout: ['settings.summary.layout', 'Quick actions, swipes, list order'], + reading: ['settings.summary.reading', 'Read status, delete, attachments'], + composing: ['settings.summary.composing', 'Signature, send delay, drafts'], + identities: ['settings.summary.identities', 'Addresses you send from'], + vacation: ['settings.summary.vacation', 'Automatic reply while away'], + filters: ['settings.summary.filters', 'Rules for incoming mail'], + templates: ['settings.summary.templates', 'Reusable messages'], + folders: ['settings.summary.folders', 'Create, rename, roles'], + keywords: ['settings.summary.keywords', 'Tag names and colours'], + downloads: ['settings.summary.downloads', 'File names for saved mail'], + security: ['settings.summary.security', 'Password, two-factor, app passwords'], + content_senders: ['settings.summary.content_senders', 'Remote images, trusted senders'], + contacts: ['settings.summary.contacts', 'Address books, sorting'], + files: ['settings.summary.files', 'View, sorting, thumbnails'], + sidebar_apps: ['settings.summary.sidebar_apps', 'Web apps in the side menu'], + updates: ['settings.summary.updates', 'Install new versions'], +}; + +export function settingsSummary(tab: SettingsTabId, s: SummaryState, t: T): string | null { + switch (tab) { + case 'language': + return [ + s.languageLabel, + t(`settings.language_region.time_format.${s.timeFormat}`, s.timeFormat === '12h' ? '12-hour' : '24-hour'), + ].join(' · '); + case 'notifications': + return [ + s.emailNotificationsEnabled + ? t('settings.summary.mail_on', 'Mail on') + : t('settings.summary.mail_off', 'Mail off'), + s.calendarNotificationsEnabled + ? t('settings.summary.events_on', 'events on') + : t('settings.summary.events_off', 'events off'), + ].join(' · '); + case 'appearance': + return [ + t(`settings.appearance.theme.${s.theme}`, s.theme[0].toUpperCase() + s.theme.slice(1)), + s.themeName ?? t('settings.themes.default_name', 'Bulwark'), + ].join(' · '); + case 'themes': + return s.themeName ?? t('settings.themes.default_name', 'Bulwark'); + case 'calendar': { + const [dayKey, dayFallback] = WEEKDAY[s.calendarFirstDayOfWeek] ?? WEEKDAY[1]; + const view = s.calendarDefaultView; + return [ + t(`calendar.views.${view}`, view[0].toUpperCase() + view.slice(1)), + t('settings.summary.week_starts', 'week starts {day}', { day: t(dayKey, dayFallback) }), + ].join(' · '); + } + case 'about_data': + return s.offlineCacheEnabled + ? t('settings.summary.offline_on', 'Offline mail on · {days, plural, one {# day} other {# days}}, {mb} MB', { + days: s.offlineCacheDays, mb: s.offlineCacheMaxMB, + }) + : t('settings.summary.offline_off', 'Offline mail off'); + case 'about': { + const version = t('settings.summary.version', 'Version {version}', { version: s.appVersion }); + if (s.update === 'current') return `${version} · ${t('settings.about.up_to_date', 'Up to date')}`; + if (s.update === 'available') return `${version} · ${t('updates.available', 'Update available')}`; + if (s.update === 'security') return `${version} · ${t('updates.banner.security_title', 'Security update available')}`; + return version; + } + default: { + const entry = STATIC[tab]; + return entry ? t(entry[0], entry[1]) : null; + } + } +} diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 00253935..9eea3085 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -4,11 +4,12 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { useLayout } from '../lib/use-layout'; import { confirmSignOut } from '../lib/sign-out-confirm'; import { - ArrowLeft, LogOut, Settings, ChevronRight, Search, X, + ArrowLeft, LogOut, Search, X, ChevronRight, Palette, User, Shield, UserPen, Palmtree, Calendar, Filter, FileText, FolderOpen, Tags, HardDrive, - BookUser, KeyRound, PanelLeftClose, Bell, Puzzle, RefreshCw, + BookUser, PanelLeftClose, Bell, RefreshCw, LayoutGrid, BookOpen, PenLine, EyeOff, Languages, Info, Download, + Database, Bug, Scale, type AppIcon, } from '../components/icons'; import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens'; @@ -19,7 +20,14 @@ import { AccountSettings } from '../components/settings/AccountSettings'; import { IdentitySettings } from '../components/settings/IdentitySettings'; import { VacationSettings } from '../components/settings/VacationSettings'; import { FolderSettings } from '../components/settings/FolderSettings'; -import { AboutDataSettings } from '../components/settings/AboutDataSettings'; +import { StorageSettings } from '../components/settings/StorageSettings'; +import { AboutSettings, APP_VERSION } from '../components/settings/AboutSettings'; +import { TroubleshootingSettings } from '../components/settings/TroubleshootingSettings'; +import { LicencesSettings } from '../components/settings/LicencesSettings'; +import { PrivacySettings } from '../components/settings/PrivacySettings'; +import { SettingsRow, SettingsGroupLabel } from '../components/settings/settings-section'; +import Badge from '../components/Badge'; +import SenderAvatar from '../components/SenderAvatar'; import { UpdatesSettings } from '../components/settings/UpdatesSettings'; import { AppearanceSettings } from '../components/settings/AppearanceSettings'; import { CalendarSettings } from '../components/settings/CalendarSettings'; @@ -29,10 +37,8 @@ import { FilterSettings } from '../components/settings/FilterSettings'; import { TemplateSettings } from '../components/settings/TemplateSettings'; import { KeywordSettings } from '../components/settings/KeywordSettings'; import { AccountSecuritySettings } from '../components/settings/AccountSecuritySettings'; -import { SmimeSettings } from '../components/settings/SmimeSettings'; import { SidebarAppsSettings } from '../components/settings/SidebarAppsSettings'; import { ThemesSettings } from '../components/settings/ThemesSettings'; -import { PluginsSettings } from '../components/settings/PluginsSettings'; import { ContentSendersSettings } from '../components/settings/ContentSendersSettings'; import { LanguageSettings } from '../components/settings/LanguageSettings'; import { ComposingSettings } from '../components/settings/ComposingSettings'; @@ -41,6 +47,11 @@ import { DownloadsSettings } from '../components/settings/DownloadsSettings'; import { useLocaleStore } from '../stores/locale-store'; import { useAuthStore } from '../stores/auth-store'; import { useManagedAccountStore } from '../stores/managed-account-store'; +import { useAccountStore } from '../stores/account-store'; +import { useSettingsStore } from '../stores/settings-store'; +import { useUpdatesStore } from '../stores/updates-store'; +import { SUPPORTED_LOCALES } from '../i18n'; +import { settingsSummary, type SummaryState } from '../lib/settings-summary'; import { sharedAccountSettingsTabs, useHasCalendar, useHasContacts, useHasFiles, useHasSieve, useHasVacation, } from '../lib/capabilities'; @@ -68,6 +79,8 @@ interface TabDef { implemented: boolean; // Never listed (still reachable by deep link). hidden?: boolean; + /** A page opened from inside another one; back returns there. */ + parent?: Tab; } const GROUP_LABELS: Record = { @@ -104,7 +117,6 @@ const TABS: TabDef[] = [ // Privacy & Security { id: 'security', label: 'Security', icon: Shield, group: 'privacy', implemented: true }, - { id: 'encryption', label: 'S/MIME Encryption', icon: KeyRound, group: 'privacy', implemented: false }, { id: 'content_senders', label: 'Content & Senders', icon: EyeOff, group: 'privacy', implemented: true }, // Apps @@ -113,15 +125,27 @@ const TABS: TabDef[] = [ { id: 'files', label: 'Files', icon: HardDrive, group: 'apps', implemented: true }, { id: 'sidebar_apps', label: 'Sidebar Apps', icon: PanelLeftClose, group: 'apps', implemented: true }, - // Advanced - { id: 'about_data', label: 'About & Data', icon: Info, group: 'advanced', implemented: true }, + // Advanced. S/MIME and plugins come from webmail plugins, so the app has + // no page for them. { id: 'themes', label: 'Themes', icon: Palette, group: 'advanced', implemented: true }, - // Plugins run in the webmail only; the pane is an explainer reachable by - // deep link, not from the list. Debug logging lives under About & Data. - { id: 'plugins', label: 'Plugins', icon: Puzzle, group: 'advanced', experimental: true, implemented: true, hidden: true }, + { id: 'about_data', label: 'Storage & backup', icon: Database, group: 'advanced', implemented: true }, { id: 'updates', label: 'Updates', icon: RefreshCw, group: 'advanced', implemented: true }, + { id: 'about', label: 'About', icon: Info, group: 'advanced', implemented: true }, + { id: 'debug', label: 'Troubleshooting', icon: Bug, group: 'advanced', implemented: true, hidden: true, parent: 'about_data' }, + { id: 'licences', label: 'Open-source licences', icon: Scale, group: 'advanced', implemented: true, hidden: true, parent: 'about' }, + { id: 'privacy', label: 'Privacy', icon: Shield, group: 'advanced', implemented: true, hidden: true, parent: 'about' }, ]; +// Labels of the pages that aren't in the webmail catalog under settings.tabs. +const TAB_LABEL_KEYS: Partial> = { + about_data: 'settings.tabs.storage', + about: 'settings.tabs.about', + debug: 'settings.tabs.troubleshooting', + licences: 'settings.about.licences', + privacy: 'settings.about.privacy', +}; +const tabLabelKey = (id: Tab) => TAB_LABEL_KEYS[id] ?? `settings.tabs.${id}`; + const TAB_COMPONENTS: Partial>> = { account: AccountSettings, language: LanguageSettings, @@ -138,23 +162,24 @@ const TAB_COMPONENTS: Partial>> = { keywords: KeywordSettings, downloads: DownloadsSettings, security: AccountSecuritySettings, - encryption: SmimeSettings, content_senders: ContentSendersSettings, calendar: CalendarSettings, contacts: ContactsSettings, files: FilesSettings, sidebar_apps: SidebarAppsSettings, - about_data: AboutDataSettings, themes: ThemesSettings, - plugins: PluginsSettings, updates: UpdatesSettings, + debug: TroubleshootingSettings, + licences: LicencesSettings, + privacy: PrivacySettings, }; // Tabs whose feature does not exist on this platform never appear in the list. // The "Updates" pane drives the sideload installer, which is Android-only - the -// current version and build are still shown under "About & Data". +// current version and build are still shown under "About". Account opens +// from the card above the list. const AVAILABLE_TABS: TabDef[] = TABS.filter( - (t) => !t.hidden && (t.id !== 'updates' || supportsSideloadUpdates), + (t) => !t.hidden && t.id !== 'account' && (t.id !== 'updates' || supportsSideloadUpdates), ); function groupTabs(only: ReadonlySet | null) { @@ -236,11 +261,44 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin label: t(`settings.tab_groups.${g.group}`, g.label), items: g.items.map((tab) => ({ ...tab, - label: t(`settings.tabs.${tab.id}`, tab.label), + label: t(tabLabelKey(tab.id), tab.label), })), })); }, [locale, t, scopedTabs]); + // The second line of each row: the pane's current value. + const settings = useSettingsStore(); + const updateAvailable = useUpdatesStore((s) => s.hasUpdate()); + const updateSeverity = useUpdatesStore((s) => s.cachedLatest?.severity ?? 'normal'); + const updateChecked = useUpdatesStore((s) => s.lastCheckedAt > 0); + const themeName = BUILTIN_THEMES.find((theme) => theme.id === settings.activeThemeId)?.name ?? null; + const summaryState: SummaryState = { + languageLabel: SUPPORTED_LOCALES.find((l) => l.code === locale)?.label ?? locale, + timeFormat: settings.timeFormat, + theme: settings.theme, + themeName, + emailNotificationsEnabled: settings.emailNotificationsEnabled, + calendarNotificationsEnabled: settings.calendarNotificationsEnabled, + calendarDefaultView: settings.calendarDefaultView, + calendarFirstDayOfWeek: settings.calendarFirstDayOfWeek, + offlineCacheEnabled: settings.offlineCacheEnabled, + offlineCacheDays: settings.offlineCacheDays, + offlineCacheMaxMB: settings.offlineCacheMaxMB, + appVersion: APP_VERSION, + update: updateAvailable + ? (updateSeverity === 'normal' ? 'available' : 'security') + : updateChecked ? 'current' : 'none', + }; + + // The signed-in account, shown as the card at the top of the list. + const accounts = useAccountStore((s) => s.accounts); + const authUsername = useAuthStore((s) => s.username); + const authServerUrl = useAuthStore((s) => s.serverUrl); + const account = accounts.find((a) => a.id === activeAccountId); + const accountName = account?.displayName || authUsername || ''; + const accountEmail = account?.email || authUsername || ''; + const accountHost = hostOf(account?.serverUrl || authServerUrl || ''); + // Settings search (webmail: lib/settings-search). The index is built on the // first keystroke and again when the language changes. const query = normalizeSettingsQuery(searchQuery); @@ -264,19 +322,25 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin .filter((g) => g.items.length > 0) : groupedTabs; + // Back from a page opened inside another one returns to that page. const closePane = React.useCallback(() => { - setSelectedTab(null); + setSelectedTab((tab) => TABS.find((def) => def.id === tab)?.parent ?? null); + setPendingHighlight(null); + }, []); + const openPage = React.useCallback((tab: Tab) => { + setSelectedTab(tab); setPendingHighlight(null); }, []); // Settings is a tab and stays mounted behind the other tabs, so hardware // back is only claimed while it is the focused one. + const selectedParent = TABS.find((def) => def.id === selectedTab)?.parent; const handleBack = React.useCallback(() => { - if (selectedTab && !isSplit) closePane(); + if (selectedTab && (!isSplit || selectedParent)) closePane(); else if (searchQuery) setSearchQuery(''); else leaveScope(); - }, [selectedTab, isSplit, searchQuery, closePane, leaveScope]); - useBackWhileFocused((!!selectedTab && !isSplit) || !!searchQuery || !!managedAccount, handleBack); + }, [selectedTab, selectedParent, isSplit, searchQuery, closePane, leaveScope]); + useBackWhileFocused((!!selectedTab && (!isSplit || !!selectedParent)) || !!searchQuery || !!managedAccount, handleBack); // The first row of the open pane whose label matches the tapped result // calls this once it is laid out. @@ -326,30 +390,30 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin // On a wide window the section list and the open page sit side by side; // the first page is open from the start, so the right side is never empty. - const firstTab = groupedTabs.flatMap((g) => g.items).find((tab) => tab.implemented && !unavailableTabs.has(tab.id))?.id ?? null; + const firstTab: Tab | null = scopedTabs + ? groupedTabs.flatMap((g) => g.items).find((tab) => tab.implemented && !unavailableTabs.has(tab.id))?.id ?? null + : 'account'; const shownTab = selectedTab ?? (isSplit ? firstTab : null); const renderPage = (selectedTab: Tab) => { const tabDef = TABS.find((tab) => tab.id === selectedTab)!; const Component = TAB_COMPONENTS[selectedTab]; - const TabIcon = tabDef.icon; - const tabLabel = t(`settings.tabs.${tabDef.id}`, tabDef.label); + const tabLabel = t(tabLabelKey(tabDef.id), tabDef.label); return ( - - {!isSplit && ( + + {(!isSplit || tabDef.parent) && ( [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]} > - + )} - - {tabLabel} + {tabLabel} @@ -360,6 +424,10 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin setSelectedTab('vacation') : undefined} /> + ) : selectedTab === 'about' ? ( + + ) : selectedTab === 'about_data' ? ( + openPage('debug')} /> ) : Component ? : null} @@ -368,35 +436,54 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin ); }; + // On a wide window the list marks the open page, or the page it was opened from. + const listSelected = shownTab ? (TABS.find((def) => def.id === shownTab)?.parent ?? shownTab) : null; + const list = ( - {/* Header - matches webmail mobile: h-14, border-b, back button + icon + title. - Settings is a tab root, so the back button is only rendered when a + {/* Settings is a tab root, so the back button is only rendered when a parent passes onBack (it'd otherwise lead nowhere). */} - - {onBack ? ( + + {onBack && ( [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]} > - + - ) : ( - )} - - {t('settings.title', 'Settings')} + {t('settings.title', 'Settings')} - {/* Tab list - matches webmail mobile: flat grouped list, no cards */} {scopeBanner && {scopeBanner}} + {!scopedTabs && ( + handleTabPress(TABS.find((tab) => tab.id === 'account')!)} + accessibilityRole="button" + accessibilityLabel={[accountName, accountEmail, accountHost].filter(Boolean).join(', ')} + accessibilityState={{ selected: isSplit && listSelected === 'account' }} + style={({ pressed }) => [ + styles.accountCard, + (pressed || (isSplit && listSelected === 'account')) && styles.accountCardActive, + ]} + > + + + {accountName} + + {[accountEmail !== accountName ? accountEmail : null, accountHost].filter(Boolean).join(' · ')} + + + + + )} 0 && ( setSearchQuery('')} - hitSlop={8} + hitSlop={12} accessibilityRole="button" accessibilityLabel={t('settings.search_clear', 'Clear search')} > @@ -425,61 +512,28 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin {visibleGroups.length === 0 && ( {t('settings.search_no_results', 'No matching settings')} )} - {visibleGroups.map((group, groupIndex) => ( + {visibleGroups.map((group) => ( - {groupIndex > 0 && } - - - {group.label} - - + {group.label} {group.items.map((tab) => { - const Icon = tab.icon; const unavailable = unavailableTabs.has(tab.id); const disabled = !tab.implemented || unavailable; - const badgeLabel = !tab.implemented - ? t('settings.badges.not_implemented', 'Not implemented') - : unavailable - ? t('settings.badges.unavailable', 'Unavailable') + const badge = unavailable + ? {t('settings.badges.not_on_server', 'Not on this server')} + : tab.experimental + ? {t('settings.badges.experimental', 'Experimental')} : null; const row = ( - handleTabPress(tab)} disabled={disabled} - accessibilityRole="button" - accessibilityState={{ disabled, selected: isSplit && tab.id === shownTab }} - style={({ pressed }) => [ - styles.tabItem, - disabled && styles.tabItemDisabled, - !disabled && pressed && styles.tabItemPressed, - isSplit && tab.id === shownTab && styles.tabItemActive, - ]} - > - - - - {tab.label} - - {tab.experimental && !disabled && ( - - {t('settings.badges.experimental', 'Experimental')} - - )} - - {badgeLabel ? ( - - {badgeLabel} - - ) : ( - - )} - + selected={isSplit && tab.id === listSelected} + badge={badge} + /> ); // The pane's matching settings, one tap from opening it there. const subs = searchIndex && !disabled ? subResultsForQuery(searchIndex, tab.id, query) : []; @@ -492,7 +546,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin key={sub.label} onPress={() => handleSubResultPress(tab, sub)} accessibilityRole="button" - style={({ pressed }) => [styles.subResult, pressed && styles.tabItemPressed]} + style={({ pressed }) => [styles.subResult, pressed && styles.subResultPressed]} > {sub.label} @@ -504,14 +558,13 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin ))} - {/* Logout - matches webmail: border-t, destructive text, icon + label */} { if (onLogout) confirmSignOut(t, {}, onLogout); }} accessibilityRole="button" style={({ pressed }) => [styles.logoutBtn, pressed && styles.logoutBtnPressed]} > - + {t('sidebar.sign_out', 'Sign Out')} @@ -531,6 +584,15 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin /** The section list beside an open page on a wide window. */ const SETTINGS_LIST_WIDTH = 320; +function hostOf(url: string): string { + if (!url) return ''; + try { + return new URL(url).host; + } catch { + return url; + } +} + function makeStyles(c: ThemePalette) { return StyleSheet.create({ container: { flex: 1, backgroundColor: c.background }, @@ -544,24 +606,41 @@ function makeStyles(c: ThemePalette) { paddingHorizontal: spacing.lg, borderBottomWidth: 1, borderBottomColor: c.border, - gap: spacing.sm, + gap: spacing.xs, }, + headerWithBack: { paddingHorizontal: spacing.xs }, headerBackBtn: { - width: 40, height: 40, + width: 48, height: 48, alignItems: 'center', justifyContent: 'center', borderRadius: radius.md, }, - headerBackBtnPressed: { backgroundColor: c.accent }, - headerLeftSpacer: { width: spacing.sm }, - headerTitle: { ...typography.h3, color: c.text }, + headerBackBtnPressed: { backgroundColor: c.muted }, + headerTitle: { fontSize: 18, fontWeight: '600', lineHeight: 24, color: c.text, flexShrink: 1 }, scrollArea: { flex: 1 }, - scrollContent: { paddingBottom: 40 }, + scrollContent: { paddingTop: spacing.md, paddingBottom: 40 }, // A page keeps form width on a wide pane: labels and their controls // 700 dp apart are hard to read across. detailContent: { padding: spacing.lg, paddingBottom: 40, width: '100%', maxWidth: 720, alignSelf: 'center' }, - tabList: { paddingVertical: spacing.sm }, + accountCard: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + marginHorizontal: spacing.lg, + marginBottom: spacing.md, + padding: spacing.md, + borderWidth: 1, + borderColor: c.border, + borderRadius: radius.md, + backgroundColor: c.card, + }, + accountCardActive: { backgroundColor: c.muted }, + accountText: { flex: 1, minWidth: 0, gap: 2 }, + accountName: { fontSize: 15, fontWeight: '500', lineHeight: 20, color: c.text }, + accountMeta: { fontSize: 13, lineHeight: 18, color: c.mutedForeground }, + + tabList: { paddingBottom: spacing.sm }, // Search - matches webmail mobile: full-width input above the list, // matching settings as indented rows under their pane. @@ -570,13 +649,12 @@ function makeStyles(c: ThemePalette) { alignItems: 'center', gap: spacing.sm, marginHorizontal: spacing.lg, - marginTop: spacing.md, + marginBottom: spacing.xs, paddingHorizontal: spacing.md, - height: componentSizes.inputHeight, + minHeight: componentSizes.inputHeight, borderRadius: radius.md, borderWidth: 1, borderColor: c.border, - backgroundColor: c.background, }, searchInput: { flex: 1, ...typography.body, color: c.text, paddingVertical: 0 }, searchEmpty: { @@ -586,8 +664,9 @@ function makeStyles(c: ThemePalette) { paddingHorizontal: 20, paddingVertical: spacing.xxl, }, - subResult: { paddingLeft: 48, paddingRight: 20, paddingVertical: spacing.sm }, - subResultText: { ...typography.caption, color: c.mutedForeground }, + subResult: { minHeight: 40, justifyContent: 'center', paddingLeft: 50, paddingRight: spacing.lg }, + subResultPressed: { backgroundColor: c.surfaceHover }, + subResultText: { fontSize: 13, color: c.mutedForeground }, scopeBanner: { flexDirection: 'row', @@ -602,62 +681,23 @@ function makeStyles(c: ThemePalette) { backgroundColor: c.muted, }, scopeBannerPressed: { opacity: 0.8 }, - scopeBannerList: { paddingHorizontal: spacing.lg, paddingTop: spacing.lg }, + scopeBannerList: { paddingHorizontal: spacing.lg }, scopeBack: { ...typography.body, color: c.mutedForeground }, scopeName: { ...typography.bodyMedium, color: c.text, flex: 1, textAlign: 'right' }, - groupDivider: { - height: 1, backgroundColor: c.border, - marginHorizontal: 20, marginVertical: spacing.sm, - }, - groupHeader: { paddingHorizontal: 20, paddingTop: spacing.md, paddingBottom: 6 }, - groupLabel: { - fontSize: 11, fontWeight: '600', - textTransform: 'uppercase', letterSpacing: 0.8, - color: c.mutedForeground, - }, - - tabItem: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 20, paddingVertical: 14, - }, - tabItemPressed: { backgroundColor: c.muted }, - // The page open beside the list (webmail sidebar: accent background). - tabItemActive: { backgroundColor: c.selection }, - tabItemLeft: { - flexDirection: 'row', alignItems: 'center', - gap: spacing.md, flex: 1, - }, - tabItemLabel: { ...typography.body, color: c.text }, - - experimentalBadge: { - backgroundColor: c.warningBg, - borderRadius: radius.full, - paddingHorizontal: 6, paddingVertical: 2, - }, - experimentalText: { fontSize: 10, fontWeight: '500', color: c.warning }, - - tabItemDisabled: { opacity: 0.55 }, - tabItemLabelDisabled: { color: c.mutedForeground }, - notWorkingBadge: { - backgroundColor: c.muted, - borderRadius: radius.full, - paddingHorizontal: 8, paddingVertical: 2, - }, - notWorkingText: { fontSize: 10, fontWeight: '500', color: c.mutedForeground }, - logoutSection: { - borderTopWidth: 1, borderTopColor: c.border, - paddingHorizontal: 20, paddingVertical: spacing.md, + marginHorizontal: spacing.lg, + marginTop: spacing.md, + paddingTop: spacing.xs, + borderTopWidth: 1, + borderTopColor: c.border, }, logoutBtn: { - flexDirection: 'row', alignItems: 'center', gap: spacing.md, - paddingVertical: 10, paddingHorizontal: spacing.sm, + flexDirection: 'row', alignItems: 'center', gap: 14, + minHeight: 48, borderRadius: radius.sm, }, - logoutBtnPressed: { backgroundColor: c.muted }, - logoutText: { ...typography.body, color: c.error }, + logoutBtnPressed: { backgroundColor: c.surfaceHover }, + logoutText: { fontSize: 15, lineHeight: 20, color: c.error }, }); }