mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +00:00
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`.
This commit is contained in:
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
+97
-4
@@ -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": {
|
||||
|
||||
+94
-1
@@ -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\""
|
||||
|
||||
@@ -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`);
|
||||
@@ -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<string, string[]> = {
|
||||
'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<string, string[]> = {
|
||||
'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': [],
|
||||
|
||||
@@ -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 (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.aboutBox}>
|
||||
<View style={styles.aboutRow}>
|
||||
<View style={styles.logo}>
|
||||
<Text style={styles.logoText}>B</Text>
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.aboutTitle}>{t('settings.advanced.about.mobile_title', 'Bulwark Mobile')}</Text>
|
||||
<Text style={styles.aboutVersion}>
|
||||
v{APP_VERSION}{' '}
|
||||
<Text style={styles.aboutCommit}>({GIT_COMMIT})</Text>
|
||||
</Text>
|
||||
{updateAvailable && latestRelease && (
|
||||
<Pressable
|
||||
onPress={() => void Linking.openURL(latestRelease.htmlUrl || APP_STORE_URL)}
|
||||
accessibilityRole="link"
|
||||
style={[styles.updatePill, updateSeverity !== 'normal' && styles.updatePillSecurity]}
|
||||
>
|
||||
<Text style={[styles.updatePillText, updateSeverity !== 'normal' && styles.updatePillTextSecurity]}>
|
||||
{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')}`}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
<Pressable
|
||||
style={styles.ghLink}
|
||||
accessibilityRole="link"
|
||||
onPress={() => Linking.openURL('https://github.com/bulwarkmail/native')}
|
||||
>
|
||||
<Text style={styles.ghText}>GitHub</Text>
|
||||
<ExternalLink size={12} color={c.mutedForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.offline.title', 'Offline mail')}
|
||||
description={t(
|
||||
'settings.offline.description',
|
||||
'Download recent messages so they open instantly and remain readable without a connection. Bodies only — attachments are still fetched on demand.',
|
||||
)}
|
||||
>
|
||||
<SettingItem
|
||||
label={t('settings.offline.enabled', 'Cache recent mail')}
|
||||
description={t('settings.offline.enabled_desc', 'When on, the app keeps message bodies for the selected window on this device.')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={offlineEnabled}
|
||||
onChange={(v) => updateSetting('offlineCacheEnabled', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
label={t('settings.offline.window', 'Window')}
|
||||
description={t('settings.offline.window_desc', 'How far back to cache, measured by message receipt date.')}
|
||||
>
|
||||
<Select
|
||||
value={String(offlineDays)}
|
||||
onChange={(v) => updateSetting('offlineCacheDays', Number(v))}
|
||||
options={dayOptions}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
label={t('settings.offline.max_size', 'Maximum size')}
|
||||
description={t('settings.offline.max_size_desc', 'Oldest messages are removed when the cache grows past this.')}
|
||||
>
|
||||
<Select
|
||||
value={String(offlineMaxMB)}
|
||||
onChange={(v) => updateSetting('offlineCacheMaxMB', Number(v))}
|
||||
options={maxMbOptions}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<View style={styles.cacheStatsBox}>
|
||||
<View style={styles.cacheStatsHeader}>
|
||||
<CloudDownload size={16} color={c.textSecondary} />
|
||||
<Text style={styles.cacheStatsTitle}>
|
||||
{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)}`}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.cacheStatsSub}>
|
||||
{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) })}
|
||||
</Text>
|
||||
{queuedChanges > 0 && (
|
||||
<Text style={styles.cacheStatsSub}>
|
||||
{t('settings.offline.queued_changes', '{count, plural, one {# change} other {# changes}} waiting to sync', { count: queuedChanges })}
|
||||
</Text>
|
||||
)}
|
||||
{syncBusy && sync.total > 0 && (
|
||||
<View style={styles.progressTrack} accessibilityRole="progressbar">
|
||||
<View
|
||||
style={[
|
||||
styles.progressFill,
|
||||
{ width: `${Math.min(100, Math.round((sync.completed / sync.total) * 100))}%` },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.cacheActions}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={handleSyncNow}
|
||||
disabled={!offlineEnabled || syncBusy}
|
||||
icon={syncBusy ? <ActivityIndicator size="small" color={c.text} /> : undefined}
|
||||
>
|
||||
{syncBusy ? t('settings.offline.syncing', 'Syncing…') : t('settings.offline.sync_now', 'Sync now')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={handleClearCache}
|
||||
disabled={cacheCount === 0 || syncBusy}
|
||||
>
|
||||
{t('settings.offline.clear_cache', 'Clear cache')}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.advanced.title', 'Advanced')}
|
||||
description={t('settings.advanced.description', 'Advanced options and developer settings')}
|
||||
>
|
||||
<SettingItem
|
||||
label={t('settings.advanced.debug_mode.label', 'Debug Mode')}
|
||||
description={t('settings.advanced.debug_mode.description', 'Enable detailed logging for troubleshooting')}
|
||||
>
|
||||
<ToggleSwitch checked={debugMode} onChange={(v) => updateSetting('debugMode', v)} />
|
||||
</SettingItem>
|
||||
|
||||
{debugMode && (
|
||||
<View style={styles.debugCategoriesBox}>
|
||||
<Text style={styles.debugCategoriesHint}>
|
||||
{t(
|
||||
'settings.advanced.debug_categories.description',
|
||||
"Select which categories to log. Disable categories you don't need to reduce console noise.",
|
||||
)}
|
||||
</Text>
|
||||
{ALL_DEBUG_CATEGORIES.map((cat: DebugCategory) => (
|
||||
<SettingItem
|
||||
key={cat}
|
||||
label={t(`settings.advanced.debug_categories.${cat}`, cat)}
|
||||
description={t(`settings.advanced.debug_categories.${cat}_description`, '')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={debugCategories[cat] !== false}
|
||||
onChange={(v) => updateSetting('debugCategories', { ...debugCategories, [cat]: v })}
|
||||
/>
|
||||
</SettingItem>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<SettingItem
|
||||
label={t('settings.advanced.sender_favicons.label', 'Sender Favicons')}
|
||||
description={t('settings.advanced.sender_favicons.description', 'Show website icons as profile pictures for business senders')}
|
||||
>
|
||||
<ToggleSwitch checked={senderFavicons} onChange={setSenderFaviconsStore} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('settings.advanced.refresh_cache.label', 'Refresh cached data')}
|
||||
description={t(
|
||||
'settings.advanced.refresh_cache.description',
|
||||
'Reload contacts, calendars, and folders from the server. Keeps your accounts and sessions — fixes a stale or wrong view without signing out.',
|
||||
)}
|
||||
>
|
||||
<Button variant="outline" size="sm" onPress={() => void handleRefreshCache()} loading={refreshing}>
|
||||
{t('settings.advanced.refresh_cache.button', 'Refresh')}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('settings.advanced.export_settings.label', 'Export Settings')}
|
||||
description={t('settings.advanced.export_settings.description', 'Download your settings as JSON')}
|
||||
>
|
||||
<Button variant="outline" size="sm" onPress={() => void handleExport()}>
|
||||
{t('settings.advanced.export_settings.button', 'Export')}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('settings.advanced.import_settings.label', 'Import Settings')}
|
||||
description={t('settings.advanced.import_settings.description', 'Upload settings from JSON file')}
|
||||
>
|
||||
<Button variant="outline" size="sm" onPress={() => void handleImport()}>
|
||||
{t('settings.advanced.import_settings.button', 'Import')}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('settings.advanced.reset_settings.label', 'Reset Settings')}
|
||||
description={t('settings.advanced.reset_settings.description', 'Restore all settings to default values')}
|
||||
>
|
||||
<Button variant="outline" size="sm" onPress={() => setConfirmReset(true)}>
|
||||
{t('settings.advanced.reset_settings.button', 'Reset to Defaults')}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<Dialog
|
||||
visible={confirmReset}
|
||||
variant="destructive"
|
||||
title={t('settings.advanced.reset_settings.label', 'Reset Settings')}
|
||||
message={t('settings.reset_confirm', 'Are you sure you want to reset all settings to defaults?')}
|
||||
confirmText={t('settings.advanced.reset_settings.button', 'Reset to Defaults')}
|
||||
onCancel={() => setConfirmReset(false)}
|
||||
onConfirm={handleReset}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
}
|
||||
@@ -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 (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.card}>
|
||||
<Image
|
||||
source={scheme === 'dark' ? LOGO_WHITE : LOGO_COLOR}
|
||||
style={styles.logo}
|
||||
resizeMode="contain"
|
||||
accessibilityIgnoresInvertColors
|
||||
accessible={false}
|
||||
/>
|
||||
<View style={styles.cardText}>
|
||||
<Text style={styles.appName}>{APP_NAME}</Text>
|
||||
<Text style={styles.version}>v{APP_VERSION} ({GIT_COMMIT})</Text>
|
||||
{updateLine && (
|
||||
updateAvailable ? (
|
||||
<Pressable onPress={openUpdate} accessibilityRole="link" hitSlop={{ top: 12, bottom: 12 }}>
|
||||
<Text style={[styles.updateLine, severity === 'normal' ? styles.updateLineNew : styles.updateLineUrgent]}>
|
||||
{updateLine}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : (
|
||||
<Text style={[styles.updateLine, styles.updateLineOk]}>{updateLine}</Text>
|
||||
)
|
||||
)}
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={() => void Linking.openURL(SOURCE_URL)}
|
||||
accessibilityRole="link"
|
||||
style={styles.sourceLink}
|
||||
>
|
||||
<Text style={styles.sourceText}>{t('settings.about.source_code', 'Source code')}</Text>
|
||||
<ExternalLink size={14} color={c.mutedForeground} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.about.this_device', 'This device')}
|
||||
description={t('settings.about.this_device_desc', 'What the app is connected to and how to get help.')}
|
||||
>
|
||||
<SettingItem
|
||||
label={t('settings.about.server', 'Server')}
|
||||
description={serverUrl
|
||||
? t('settings.about.server_value', '{host} · signed in as {user}', { host: hostOf(serverUrl), user: signedInAs })
|
||||
: undefined}
|
||||
/>
|
||||
{supportsSideloadUpdates && (
|
||||
<SettingItem label={t('settings.about.updates', 'Updates')} description={lastChecked}>
|
||||
<Button variant="outline" size="sm" loading={checking} onPress={() => void checkNow({ force: true })}>
|
||||
{t('settings.about.check_now', 'Check now')}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
)}
|
||||
<SettingItem
|
||||
label={t('settings.about.licences', 'Open-source licences')}
|
||||
description={t('settings.about.licences_desc', 'AGPL-3.0 for the app, and the libraries it uses')}
|
||||
onPress={() => onOpen?.('licences')}
|
||||
/>
|
||||
<SettingItem
|
||||
label={t('settings.about.privacy', 'Privacy')}
|
||||
description={t('settings.about.privacy_desc', 'What stays on this phone and what goes to your server')}
|
||||
onPress={() => onOpen?.('privacy')}
|
||||
/>
|
||||
<SettingItem
|
||||
label={t('settings.about.report_problem', 'Report a problem')}
|
||||
description={t('settings.about.report_problem_desc', 'Opens an issue on GitHub with the version filled in')}
|
||||
onPress={reportProblem}
|
||||
link="external"
|
||||
noBorder
|
||||
/>
|
||||
</SettingsSection>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
}
|
||||
@@ -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 (
|
||||
<SettingsSection
|
||||
title={t('settings.appearance.title', "Appearance")}
|
||||
description={t('settings.appearance.description', "Customize the look and feel of your webmail")}
|
||||
description={t('settings.appearance.description_mobile', 'Customize how the app looks')}
|
||||
>
|
||||
<SettingItem label={t('settings.appearance.theme.label', "Theme")} description={t('settings.appearance.theme.description', "Choose your preferred color scheme")}>
|
||||
<RadioGroup
|
||||
@@ -152,6 +154,14 @@ export function AppearanceSettings() {
|
||||
<SettingItem label={t('settings.appearance.animations.label', "Enable Animations")} description={t('settings.appearance.animations.description', "Show smooth transitions and effects")}>
|
||||
<ToggleSwitch checked={animationsEnabled} onChange={setAnimationsEnabled} />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('settings.advanced.sender_favicons.label', 'Sender Favicons')}
|
||||
description={t('settings.advanced.sender_favicons.description', 'Show website icons as profile pictures for business senders')}
|
||||
noBorder
|
||||
>
|
||||
<ToggleSwitch checked={senderFavicons} onChange={setSenderFavicons} />
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<View style={{ gap: 24 }}>
|
||||
<SettingsSection
|
||||
title={APP_NAME}
|
||||
description={t(
|
||||
'settings.licences.app_desc',
|
||||
'The app is free software under the {licence}. You can use, study, share and change it.',
|
||||
{ licence: 'GNU Affero General Public License v3.0' },
|
||||
)}
|
||||
>
|
||||
<SettingItem
|
||||
label={licences.app.license}
|
||||
description={t('settings.licences.read_licence', 'Read the full licence text')}
|
||||
onPress={() => void Linking.openURL(`${NATIVE_REPO_URL}/blob/main/LICENSE`)}
|
||||
link="external"
|
||||
noBorder
|
||||
/>
|
||||
</SettingsSection>
|
||||
<SettingsSection
|
||||
title={t('settings.licences.libraries', 'Libraries')}
|
||||
description={t('settings.licences.libraries_desc', 'Open-source code the app is built with.')}
|
||||
>
|
||||
{licences.libraries.map((lib, i) => (
|
||||
<SettingItem
|
||||
key={lib.name}
|
||||
label={lib.name}
|
||||
description={lib.version ? `${lib.license} · ${lib.version}` : lib.license}
|
||||
onPress={lib.url ? () => void Linking.openURL(lib.url) : undefined}
|
||||
link="external"
|
||||
noBorder={i === licences.libraries.length - 1}
|
||||
/>
|
||||
))}
|
||||
</SettingsSection>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<SettingsSection
|
||||
title={t('settings.tabs.plugins', 'Plugins')}
|
||||
description={t(
|
||||
'settings.plugins.mobile_description',
|
||||
'Plugins run inside the webmail and extend it in the browser.',
|
||||
)}
|
||||
>
|
||||
<View style={styles.box}>
|
||||
<Puzzle size={20} color={c.mutedForeground} />
|
||||
<Text style={styles.text}>
|
||||
{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.',
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
}
|
||||
@@ -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 style={styles.paragraph}>{text}</Text>;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SettingsSection title={t('settings.privacy.phone_title', 'On this phone')}>
|
||||
{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.',
|
||||
))}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t('settings.privacy.server_title', 'Your mail server')}>
|
||||
{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.',
|
||||
))}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t('settings.privacy.push_title', 'Push notifications')}>
|
||||
{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'),
|
||||
},
|
||||
))}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t('settings.privacy.other_title', 'Other connections')}>
|
||||
{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.',
|
||||
))}
|
||||
</SettingsSection>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { gap: spacing.xxl },
|
||||
paragraph: { ...typography.body, color: c.text, paddingVertical: spacing.xs },
|
||||
});
|
||||
}
|
||||
@@ -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 (
|
||||
<SettingsSection
|
||||
title={t('settings.tabs.encryption', 'S/MIME Encryption')}
|
||||
description={t(
|
||||
'settings.smime.mobile_description',
|
||||
'Sign and encrypt mail with S/MIME certificates.',
|
||||
)}
|
||||
>
|
||||
<View style={styles.box}>
|
||||
<KeyRound size={20} color={c.mutedForeground} />
|
||||
<Text style={styles.text}>
|
||||
{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.',
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
}
|
||||
@@ -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 (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.usageCard}>
|
||||
<View style={styles.usageHeader}>
|
||||
<Text style={styles.usageTitle}>
|
||||
{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 })}
|
||||
</Text>
|
||||
<Text style={styles.usageSize}>
|
||||
{t('settings.offline.used_of', '{used} of {limit} MB', { used: usedMB.toFixed(1), limit: offlineMaxMB })}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={styles.progressTrack}
|
||||
accessibilityRole="progressbar"
|
||||
accessibilityValue={{ min: 0, max: 100, now: Math.round((syncBusy && sync.total > 0 ? sync.completed / sync.total : usedFraction) * 100) }}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.progressFill,
|
||||
{ width: `${Math.round((syncBusy && sync.total > 0 ? sync.completed / sync.total : usedFraction) * 100)}%` },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.usageFooter}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.usageSub}>{syncLine}</Text>
|
||||
{queuedChanges > 0 && (
|
||||
<Text style={styles.usageSub}>
|
||||
{t('settings.offline.queued_changes', '{count, plural, one {# change} other {# changes}} waiting to sync', { count: queuedChanges })}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<Button variant="outline" size="sm" onPress={handleSyncNow} disabled={!offlineEnabled} loading={syncBusy}>
|
||||
{t('settings.offline.sync_now', 'Sync now')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onPress={handleClearCache} disabled={cacheCount === 0 || syncBusy}>
|
||||
{t('settings.offline.clear', 'Clear')}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.offline.title', 'Offline mail')}
|
||||
description={t('settings.offline.description_short', 'Keep recent messages on this phone so they open without a connection.')}
|
||||
>
|
||||
<SettingItem label={t('settings.offline.enabled', 'Cache recent mail')}>
|
||||
<ToggleSwitch
|
||||
checked={offlineEnabled}
|
||||
onChange={(v) => updateSetting('offlineCacheEnabled', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t('settings.offline.keep_from', 'Keep mail from the last')}>
|
||||
<Select
|
||||
value={String(offlineDays)}
|
||||
onChange={(v) => updateSetting('offlineCacheDays', Number(v))}
|
||||
options={dayOptions}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t('settings.offline.use_at_most', 'Use at most')} noBorder>
|
||||
<Select
|
||||
value={String(offlineMaxMB)}
|
||||
onChange={(v) => updateSetting('offlineCacheMaxMB', Number(v))}
|
||||
options={maxMbOptions}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.backup.title', 'Settings backup')}
|
||||
description={t('settings.backup.description', 'Move your settings to another phone or the webmail.')}
|
||||
>
|
||||
<SettingItem
|
||||
label={t('settings.backup.export_import', 'Export or import')}
|
||||
description={t('settings.backup.export_import_desc', 'A JSON file, same format as the webmail')}
|
||||
>
|
||||
<View style={styles.buttonPair}>
|
||||
<Button variant="outline" size="sm" onPress={() => void handleExport()}>
|
||||
{t('settings.advanced.export_settings.button', 'Export')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onPress={() => void handleImport()}>
|
||||
{t('settings.advanced.import_settings.button', 'Import')}
|
||||
</Button>
|
||||
</View>
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
label={t('settings.advanced.reset_settings.label', 'Reset Settings')}
|
||||
description={t('settings.backup.reset_desc', 'Back to the defaults; mail stays')}
|
||||
>
|
||||
<Button variant="destructiveOutline" size="sm" onPress={() => setConfirmReset(true)}>
|
||||
{t('settings.backup.reset', 'Reset')}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
label={t('settings.troubleshooting.title', 'Troubleshooting')}
|
||||
description={t('settings.troubleshooting.row_desc', 'Refresh cached data, debug logging')}
|
||||
onPress={onOpenTroubleshooting}
|
||||
noBorder
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<Dialog
|
||||
visible={confirmReset}
|
||||
variant="destructive"
|
||||
title={t('settings.advanced.reset_settings.label', 'Reset Settings')}
|
||||
message={t('settings.reset_confirm', 'Are you sure you want to reset all settings to defaults?')}
|
||||
confirmText={t('settings.advanced.reset_settings.button', 'Reset to Defaults')}
|
||||
onCancel={() => setConfirmReset(false)}
|
||||
onConfirm={handleReset}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
}
|
||||
@@ -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 (
|
||||
<View style={styles.container}>
|
||||
<SettingsSection
|
||||
title={t('settings.troubleshooting.cache_title', 'Cached data')}
|
||||
description={t('settings.troubleshooting.cache_desc', 'When a list looks stale or wrong.')}
|
||||
>
|
||||
<SettingItem
|
||||
label={t('settings.advanced.refresh_cache.label', 'Refresh cached data')}
|
||||
description={t(
|
||||
'settings.advanced.refresh_cache.description',
|
||||
'Reload contacts, calendars, and folders from the server. Keeps your accounts and sessions — fixes a stale or wrong view without signing out.',
|
||||
)}
|
||||
noBorder
|
||||
>
|
||||
<Button variant="outline" size="sm" onPress={() => void handleRefreshCache()} loading={refreshing}>
|
||||
{t('settings.advanced.refresh_cache.button', 'Refresh')}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.advanced.debug_mode.label', 'Debug Mode')}
|
||||
description={t('settings.advanced.debug_mode.description', 'Enable detailed logging for troubleshooting')}
|
||||
>
|
||||
<SettingItem label={t('settings.advanced.debug_mode.label', 'Debug Mode')} noBorder={!debugMode}>
|
||||
<ToggleSwitch checked={debugMode} onChange={(v) => updateSetting('debugMode', v)} />
|
||||
</SettingItem>
|
||||
{debugMode && (
|
||||
<View style={styles.categories}>
|
||||
<Text style={styles.hint}>
|
||||
{t(
|
||||
'settings.advanced.debug_categories.description',
|
||||
"Select which categories to log. Disable categories you don't need to reduce console noise.",
|
||||
)}
|
||||
</Text>
|
||||
{ALL_DEBUG_CATEGORIES.map((cat: DebugCategory, i) => (
|
||||
<SettingItem
|
||||
key={cat}
|
||||
label={t(`settings.advanced.debug_categories.${cat}`, cat)}
|
||||
description={t(`settings.advanced.debug_categories.${cat}_description`, '')}
|
||||
noBorder={i === ALL_DEBUG_CATEGORIES.length - 1}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={debugCategories[cat] !== false}
|
||||
onChange={(v) => updateSetting('debugCategories', { ...debugCategories, [cat]: v })}
|
||||
/>
|
||||
</SettingItem>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
}
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
+28
-17
@@ -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<SettingsTabId, string[]> = {
|
||||
'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<SettingsTabId, string[]> = {
|
||||
'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<SettingsTabId, string[]> = {
|
||||
'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<SettingsTabId, string[]> = {
|
||||
'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<SettingsTabId, string> = {
|
||||
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 {
|
||||
|
||||
@@ -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<Record<SettingsTabId, [string, string]>> = {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+190
-150
@@ -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<TabGroup, string> = {
|
||||
@@ -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<Record<Tab, string>> = {
|
||||
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<Record<Tab, React.ComponentType<any>>> = {
|
||||
account: AccountSettings,
|
||||
language: LanguageSettings,
|
||||
@@ -138,23 +162,24 @@ const TAB_COMPONENTS: Partial<Record<Tab, React.ComponentType<any>>> = {
|
||||
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<Tab> | 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 (
|
||||
<SafeAreaView style={styles.container} edges={isSplit ? ['top', 'right'] : ['top', 'left', 'right']}>
|
||||
<View style={styles.header}>
|
||||
{!isSplit && (
|
||||
<View style={[styles.header, (!isSplit || tabDef.parent) && styles.headerWithBack]}>
|
||||
{(!isSplit || tabDef.parent) && (
|
||||
<Pressable
|
||||
onPress={closePane}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('common.back', 'Back')}
|
||||
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
|
||||
>
|
||||
<ArrowLeft size={20} color={c.text} />
|
||||
<ArrowLeft size={22} color={c.text} />
|
||||
</Pressable>
|
||||
)}
|
||||
<TabIcon size={20} color={c.mutedForeground} />
|
||||
<Text style={styles.headerTitle}>{tabLabel}</Text>
|
||||
<Text style={styles.headerTitle} numberOfLines={1} accessibilityRole="header">{tabLabel}</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView ref={paneScrollRef} style={styles.scrollArea} contentContainerStyle={styles.detailContent}>
|
||||
@@ -360,6 +424,10 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
||||
<FilterSettings
|
||||
onOpenVacation={hasVacation ? () => setSelectedTab('vacation') : undefined}
|
||||
/>
|
||||
) : selectedTab === 'about' ? (
|
||||
<AboutSettings onOpen={openPage} />
|
||||
) : selectedTab === 'about_data' ? (
|
||||
<StorageSettings onOpenTroubleshooting={() => openPage('debug')} />
|
||||
) : Component ? <Component /> : null}
|
||||
</SearchHighlightContext.Provider>
|
||||
</View>
|
||||
@@ -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 = (
|
||||
<SafeAreaView style={styles.container} edges={isSplit ? ['top', 'left'] : ['top', 'left', 'right']}>
|
||||
{/* 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). */}
|
||||
<View style={styles.header}>
|
||||
{onBack ? (
|
||||
<View style={[styles.header, onBack && styles.headerWithBack]}>
|
||||
{onBack && (
|
||||
<Pressable
|
||||
onPress={onBack}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('common.back', 'Back')}
|
||||
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
|
||||
>
|
||||
<ArrowLeft size={20} color={c.text} />
|
||||
<ArrowLeft size={22} color={c.text} />
|
||||
</Pressable>
|
||||
) : (
|
||||
<View style={styles.headerLeftSpacer} />
|
||||
)}
|
||||
<Settings size={20} color={c.mutedForeground} />
|
||||
<Text style={styles.headerTitle}>{t('settings.title', 'Settings')}</Text>
|
||||
<Text style={styles.headerTitle} accessibilityRole="header">{t('settings.title', 'Settings')}</Text>
|
||||
</View>
|
||||
|
||||
{/* Tab list - matches webmail mobile: flat grouped list, no cards */}
|
||||
<ScrollView
|
||||
style={styles.scrollArea}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{scopeBanner && <View style={styles.scopeBannerList}>{scopeBanner}</View>}
|
||||
{!scopedTabs && (
|
||||
<Pressable
|
||||
onPress={() => 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,
|
||||
]}
|
||||
>
|
||||
<SenderAvatar name={accountName} email={accountEmail} size={40} disableImages />
|
||||
<View style={styles.accountText}>
|
||||
<Text style={styles.accountName} numberOfLines={1}>{accountName}</Text>
|
||||
<Text style={styles.accountMeta} numberOfLines={1}>
|
||||
{[accountEmail !== accountName ? accountEmail : null, accountHost].filter(Boolean).join(' · ')}
|
||||
</Text>
|
||||
</View>
|
||||
<ChevronRight size={16} color={c.mutedForeground} />
|
||||
</Pressable>
|
||||
)}
|
||||
<View style={styles.searchBar}>
|
||||
<Search size={16} color={c.mutedForeground} />
|
||||
<TextInput
|
||||
@@ -413,7 +500,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
||||
{searchQuery.length > 0 && (
|
||||
<Pressable
|
||||
onPress={() => 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 && (
|
||||
<Text style={styles.searchEmpty}>{t('settings.search_no_results', 'No matching settings')}</Text>
|
||||
)}
|
||||
{visibleGroups.map((group, groupIndex) => (
|
||||
{visibleGroups.map((group) => (
|
||||
<View key={group.group}>
|
||||
{groupIndex > 0 && <View style={styles.groupDivider} />}
|
||||
|
||||
<View style={styles.groupHeader}>
|
||||
<Text style={styles.groupLabel}>{group.label}</Text>
|
||||
</View>
|
||||
|
||||
<SettingsGroupLabel>{group.label}</SettingsGroupLabel>
|
||||
{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
|
||||
? <Badge>{t('settings.badges.not_on_server', 'Not on this server')}</Badge>
|
||||
: tab.experimental
|
||||
? <Badge>{t('settings.badges.experimental', 'Experimental')}</Badge>
|
||||
: null;
|
||||
const row = (
|
||||
<Pressable
|
||||
<SettingsRow
|
||||
key={tab.id}
|
||||
icon={tab.icon}
|
||||
title={tab.label}
|
||||
value={disabled ? null : settingsSummary(tab.id, summaryState, t)}
|
||||
onPress={() => 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,
|
||||
]}
|
||||
>
|
||||
<View style={styles.tabItemLeft}>
|
||||
<Icon size={16} color={c.mutedForeground} />
|
||||
<Text
|
||||
style={[
|
||||
styles.tabItemLabel,
|
||||
disabled && styles.tabItemLabelDisabled,
|
||||
]}
|
||||
>
|
||||
{tab.label}
|
||||
</Text>
|
||||
{tab.experimental && !disabled && (
|
||||
<View style={styles.experimentalBadge}>
|
||||
<Text style={styles.experimentalText}>{t('settings.badges.experimental', 'Experimental')}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{badgeLabel ? (
|
||||
<View style={styles.notWorkingBadge}>
|
||||
<Text style={styles.notWorkingText}>{badgeLabel}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ChevronRight size={16} color={c.mutedForeground} />
|
||||
)}
|
||||
</Pressable>
|
||||
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]}
|
||||
>
|
||||
<Text style={styles.subResultText} numberOfLines={1}>{sub.label}</Text>
|
||||
</Pressable>
|
||||
@@ -504,14 +558,13 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Logout - matches webmail: border-t, destructive text, icon + label */}
|
||||
<View style={styles.logoutSection}>
|
||||
<Pressable
|
||||
onPress={() => { if (onLogout) confirmSignOut(t, {}, onLogout); }}
|
||||
accessibilityRole="button"
|
||||
style={({ pressed }) => [styles.logoutBtn, pressed && styles.logoutBtnPressed]}
|
||||
>
|
||||
<LogOut size={16} color={c.error} />
|
||||
<LogOut size={20} color={c.error} />
|
||||
<Text style={styles.logoutText}>{t('sidebar.sign_out', 'Sign Out')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user