mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a15334bef | ||
|
|
3aca2d80c0 | ||
|
|
7c534301ee | ||
|
|
14dc5ff2d4 | ||
|
|
14ba28c79c | ||
|
|
598adc154d | ||
|
|
3c0af52c4e | ||
|
|
7108431dbb | ||
|
|
744b17908d | ||
|
|
c7edae9ed3 | ||
|
|
c07b39a070 | ||
|
|
7e5e296271 | ||
|
|
ad6dff79ba | ||
|
|
a4ca72fb3d | ||
|
|
107eb807e3 | ||
|
|
e731357b89 | ||
|
|
9cf1d3e849 | ||
|
|
d3dcc4e830 | ||
|
|
e0d2b190d3 | ||
|
|
b048eaca03 | ||
|
|
6f515e15f7 | ||
|
|
0a176ab51c | ||
|
|
75299244cf | ||
|
|
8ce53816af | ||
|
|
767d4b9f9f |
@@ -8,7 +8,7 @@ import {
|
||||
} from '@react-navigation/native';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { createNativeStackNavigator, type NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import { Mail, Calendar, BookUser, HardDrive, Settings } from 'lucide-react-native';
|
||||
import { Mail, Calendar, BookUser, HardDrive, Settings } from './src/components/icons';
|
||||
|
||||
import { startLiveUpdates, type LiveUpdatesHandle } from './src/api/push-stream';
|
||||
import { jmapClient } from './src/api/jmap-client';
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -10,6 +10,5 @@ module.exports = function babelConfig(api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: [presetExpo],
|
||||
plugins: [require.resolve('./scripts/babel-plugin-lucide-imports')],
|
||||
};
|
||||
};
|
||||
|
||||
+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\""
|
||||
|
||||
@@ -2,19 +2,4 @@ const { getDefaultConfig } = require('expo/metro-config');
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
// babel.config.js rewrites lucide-react-native imports to per-icon modules
|
||||
// under dist/ (scripts/babel-plugin-lucide-imports.js). The package's
|
||||
// `exports` map only lists its barrel, so resolve those paths as plain files
|
||||
// instead of letting Metro warn about every one of them.
|
||||
const LUCIDE_DEEP_IMPORT = 'lucide-react-native/dist/';
|
||||
const upstreamResolveRequest = config.resolver.resolveRequest;
|
||||
|
||||
config.resolver.resolveRequest = (context, moduleName, platform) => {
|
||||
const resolve = upstreamResolveRequest ?? context.resolveRequest;
|
||||
if (moduleName.startsWith(LUCIDE_DEEP_IMPORT)) {
|
||||
return resolve({ ...context, unstable_enablePackageExports: false }, moduleName, platform);
|
||||
}
|
||||
return resolve(context, moduleName, platform);
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
|
||||
Generated
+3
-14
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.76",
|
||||
"version": "0.1.79",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.76",
|
||||
"version": "0.1.79",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -33,7 +34,6 @@
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react-native": "^1.7.0",
|
||||
"postal-mime": "^3.0.0",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
@@ -7790,17 +7790,6 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react-native": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-1.7.0.tgz",
|
||||
"integrity": "sha512-wGJY5nosSawh028jg8r1ZKqnGPDIVfIL9xvKOs4wPYFQHeJMHsADYm/lmuFYXMXXatSkHhpsCjeqIRgeFGzf8g==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-native": "*",
|
||||
"react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.76",
|
||||
"version": "0.1.79",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -43,7 +43,6 @@
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react-native": "^1.7.0",
|
||||
"postal-mime": "^3.0.0",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
diff --git a/node_modules/postal-mime/src/base64-decoder.js b/node_modules/postal-mime/src/base64-decoder.js
|
||||
index 93c2aea..ba6e3f1 100644
|
||||
--- a/node_modules/postal-mime/src/base64-decoder.js
|
||||
+++ b/node_modules/postal-mime/src/base64-decoder.js
|
||||
@@ -1,4 +1,4 @@
|
||||
-import { decodeBase64, blobToArrayBuffer } from './decode-strings.js';
|
||||
+import { decodeBase64, concatChunks } from './decode-strings.js';
|
||||
|
||||
export default class Base64Decoder {
|
||||
constructor(opts) {
|
||||
@@ -49,6 +49,6 @@ export default class Base64Decoder {
|
||||
finalize() {
|
||||
this.flushRemainder();
|
||||
|
||||
- return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
|
||||
+ return concatChunks(this.chunks);
|
||||
}
|
||||
}
|
||||
diff --git a/node_modules/postal-mime/src/decode-strings.js b/node_modules/postal-mime/src/decode-strings.js
|
||||
index a5e058e..f255e8d 100644
|
||||
--- a/node_modules/postal-mime/src/decode-strings.js
|
||||
+++ b/node_modules/postal-mime/src/decode-strings.js
|
||||
@@ -145,6 +145,23 @@ export function getDecoder(charset) {
|
||||
* @param {Blob} blob Blob to convert
|
||||
* @returns {ArrayBuffer} Converted value
|
||||
*/
|
||||
+// React Native's Blob cannot be built from ArrayBuffers or typed arrays, so
|
||||
+// the decoders join their chunks here instead of through a Blob.
|
||||
+export function concatChunks(chunks) {
|
||||
+ const parts = chunks.map(chunk => {
|
||||
+ if (typeof chunk === 'string') return textEncoder.encode(chunk);
|
||||
+ if (chunk instanceof ArrayBuffer) return new Uint8Array(chunk);
|
||||
+ return new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
||||
+ });
|
||||
+ const out = new Uint8Array(parts.reduce((n, part) => n + part.byteLength, 0));
|
||||
+ let pos = 0;
|
||||
+ for (const part of parts) {
|
||||
+ out.set(part, pos);
|
||||
+ pos += part.byteLength;
|
||||
+ }
|
||||
+ return out.buffer;
|
||||
+}
|
||||
+
|
||||
export async function blobToArrayBuffer(blob) {
|
||||
if ('arrayBuffer' in blob) {
|
||||
return await blob.arrayBuffer();
|
||||
diff --git a/node_modules/postal-mime/src/pass-through-decoder.js b/node_modules/postal-mime/src/pass-through-decoder.js
|
||||
index ad1c222..1e1d012 100644
|
||||
--- a/node_modules/postal-mime/src/pass-through-decoder.js
|
||||
+++ b/node_modules/postal-mime/src/pass-through-decoder.js
|
||||
@@ -1,4 +1,4 @@
|
||||
-import { blobToArrayBuffer } from './decode-strings.js';
|
||||
+import { concatChunks } from './decode-strings.js';
|
||||
|
||||
export default class PassThroughDecoder {
|
||||
constructor() {
|
||||
@@ -12,6 +12,6 @@ export default class PassThroughDecoder {
|
||||
|
||||
finalize() {
|
||||
// convert an array of arraybuffers into a blob and then back into a single arraybuffer
|
||||
- return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
|
||||
+ return concatChunks(this.chunks);
|
||||
}
|
||||
}
|
||||
diff --git a/node_modules/postal-mime/src/qp-decoder.js b/node_modules/postal-mime/src/qp-decoder.js
|
||||
index adee72c..3973673 100644
|
||||
--- a/node_modules/postal-mime/src/qp-decoder.js
|
||||
+++ b/node_modules/postal-mime/src/qp-decoder.js
|
||||
@@ -1,4 +1,4 @@
|
||||
-import { blobToArrayBuffer, hexNibble } from './decode-strings.js';
|
||||
+import { concatChunks, hexNibble } from './decode-strings.js';
|
||||
|
||||
const CHR_EQUALS = 0x3d;
|
||||
const CHR_LF = 0x0a;
|
||||
@@ -83,6 +83,6 @@ export default class QPDecoder {
|
||||
this.flushBuffer();
|
||||
|
||||
// convert an array of arraybuffers into a blob and then back into a single arraybuffer
|
||||
- return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
|
||||
+ return concatChunks(this.chunks);
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
// Babel plugin: rewrite named imports from the `lucide-react-native` barrel to
|
||||
// the per-icon modules they re-export, so the bundle only contains (and the
|
||||
// app only evaluates) the icons it uses. The barrel re-exports all ~1,700
|
||||
// icons, and Metro has no tree shaking, so one `import { Mail }` used to pull
|
||||
// in and run every icon at startup.
|
||||
//
|
||||
// import { Mail, Trash2 as Bin } from 'lucide-react-native';
|
||||
// becomes
|
||||
// import Mail from 'lucide-react-native/dist/esm/icons/mail.js';
|
||||
// import Bin from 'lucide-react-native/dist/esm/icons/trash-2.js';
|
||||
//
|
||||
// The name -> file table is read from the package's own ESM barrel, so icon
|
||||
// aliases (`MailIcon`, `LucideMail`, renamed icons) resolve exactly as they do
|
||||
// through the barrel. metro.config.js resolves these deep paths, which the
|
||||
// package's `exports` map does not list.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const PACKAGE = 'lucide-react-native';
|
||||
const ESM_DIR = 'dist/esm';
|
||||
|
||||
/** Map every name the barrel exports to `{ source, imported }`. */
|
||||
function loadExportMap() {
|
||||
// `main` is dist/cjs/lucide-react-native.js; the ESM tree sits next to it.
|
||||
const pkgDir = path.resolve(path.dirname(require.resolve(PACKAGE)), '..', '..');
|
||||
const barrel = fs.readFileSync(path.join(pkgDir, ESM_DIR, `${PACKAGE}.js`), 'utf8');
|
||||
const map = new Map();
|
||||
for (const m of barrel.matchAll(/export\s*\{([^}]*)\}\s*from\s*'\.\/([^']+)'/g)) {
|
||||
const source = `${PACKAGE}/${ESM_DIR}/${m[2]}`;
|
||||
for (const spec of m[1].split(',')) {
|
||||
const [imported, exported = imported] = spec.trim().split(/\s+as\s+/);
|
||||
if (imported) map.set(exported, { source, imported });
|
||||
}
|
||||
}
|
||||
if (map.size === 0) {
|
||||
throw new Error(`${PACKAGE}: could not read the export list from its ESM barrel`);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
let exportMap = null;
|
||||
function getExportMap() {
|
||||
if (!exportMap) exportMap = loadExportMap();
|
||||
return exportMap;
|
||||
}
|
||||
|
||||
// A value-less binding (only used as a type) is dropped by the TypeScript
|
||||
// transform anyway; treat it the same way instead of failing the build.
|
||||
function isOnlyUsedAsType(importPath, localName) {
|
||||
const binding = importPath.scope.getBinding(localName);
|
||||
if (!binding) return true;
|
||||
return binding.referencePaths.every((ref) => Boolean(ref.findParent((p) => p.isTSType())));
|
||||
}
|
||||
|
||||
function lucideImportsPlugin({ types: t }) {
|
||||
return {
|
||||
name: 'lucide-direct-imports',
|
||||
visitor: {
|
||||
ImportDeclaration(importPath) {
|
||||
const { node } = importPath;
|
||||
if (node.source.value !== PACKAGE || node.importKind === 'type') return;
|
||||
// Side-effect, default and namespace imports keep using the barrel.
|
||||
if (node.specifiers.length === 0) return;
|
||||
if (!node.specifiers.every((s) => t.isImportSpecifier(s))) return;
|
||||
|
||||
const map = getExportMap();
|
||||
const replacements = [];
|
||||
for (const spec of node.specifiers) {
|
||||
if (spec.importKind === 'type') continue;
|
||||
const name = t.isIdentifier(spec.imported) ? spec.imported.name : spec.imported.value;
|
||||
const target = map.get(name);
|
||||
if (!target) {
|
||||
if (isOnlyUsedAsType(importPath, spec.local.name)) continue;
|
||||
throw importPath.buildCodeFrameError(`${PACKAGE} has no export named "${name}"`);
|
||||
}
|
||||
const local = t.identifier(spec.local.name);
|
||||
const specifier = target.imported === 'default'
|
||||
? t.importDefaultSpecifier(local)
|
||||
: t.importSpecifier(local, t.identifier(target.imported));
|
||||
replacements.push(t.importDeclaration([specifier], t.stringLiteral(target.source)));
|
||||
}
|
||||
if (replacements.length === 0) importPath.remove();
|
||||
else importPath.replaceWithMultiple(replacements);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = lucideImportsPlugin;
|
||||
module.exports.loadExportMap = loadExportMap;
|
||||
module.exports.PACKAGE = PACKAGE;
|
||||
@@ -0,0 +1,241 @@
|
||||
// Writes src/components/icons/index.ts from Tabler's outline path data.
|
||||
//
|
||||
// The app draws the same icon set as the webmail (Tabler Icons, MIT) under the
|
||||
// names it used when it was on Lucide, so screens import `{ Mail, Trash2 }`
|
||||
// from components/icons. Only the glyphs listed in MAP ship in the bundle.
|
||||
//
|
||||
// node scripts/generate-icons.mjs [path/to/tabler-nodes-outline.json]
|
||||
//
|
||||
// The default path is the webmail checkout this repo usually sits in
|
||||
// (repos/react-native inside jmap-webmail), whose node_modules has
|
||||
// @tabler/icons. Add a line to MAP to make a new icon importable.
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const SOURCE = process.argv[2]
|
||||
?? join(ROOT, '..', '..', 'node_modules', '@tabler', 'icons', 'tabler-nodes-outline.json');
|
||||
|
||||
// App name -> Tabler outline name. Where the webmail (components/icons) maps
|
||||
// the same name, this uses its choice.
|
||||
const MAP = {
|
||||
AlarmClock: 'alarm',
|
||||
AlertCircle: 'alert-circle',
|
||||
AlertTriangle: 'alert-triangle',
|
||||
AlignCenter: 'align-center',
|
||||
AlignLeft: 'align-left',
|
||||
AlignRight: 'align-right',
|
||||
Archive: 'archive',
|
||||
ArrowDown: 'arrow-down',
|
||||
ArrowDownWideNarrow: 'sort-descending',
|
||||
ArrowLeft: 'arrow-left',
|
||||
ArrowRight: 'arrow-right',
|
||||
ArrowUp: 'arrow-up',
|
||||
ArrowUpDown: 'arrows-sort',
|
||||
ArrowUpNarrowWide: 'sort-ascending',
|
||||
Ban: 'ban',
|
||||
Bell: 'bell',
|
||||
BellRing: 'bell-ringing',
|
||||
Bold: 'bold',
|
||||
Book: 'book-2',
|
||||
BookOpen: 'book',
|
||||
BookUser: 'address-book',
|
||||
Bug: 'bug',
|
||||
Building: 'building',
|
||||
Cake: 'cake',
|
||||
Calendar: 'calendar',
|
||||
CalendarDays: 'calendar-month',
|
||||
CalendarPlus: 'calendar-plus',
|
||||
Camera: 'camera',
|
||||
Check: 'check',
|
||||
CheckCheck: 'checks',
|
||||
CheckCircle: 'circle-check',
|
||||
CheckCircle2: 'circle-check',
|
||||
CheckSquare: 'square-check',
|
||||
ChevronDown: 'chevron-down',
|
||||
ChevronLeft: 'chevron-left',
|
||||
ChevronRight: 'chevron-right',
|
||||
ChevronUp: 'chevron-up',
|
||||
CircleCheck: 'circle-check',
|
||||
ClipboardPaste: 'clipboard-text',
|
||||
Clock: 'clock',
|
||||
CloudDownload: 'cloud-download',
|
||||
CloudOff: 'cloud-off',
|
||||
Code: 'code',
|
||||
Columns3: 'columns-3',
|
||||
Columns4: 'layout-columns',
|
||||
Copy: 'copy',
|
||||
Database: 'database',
|
||||
Download: 'download',
|
||||
Edit2: 'pencil',
|
||||
Eraser: 'eraser',
|
||||
ExternalLink: 'external-link',
|
||||
Eye: 'eye',
|
||||
EyeOff: 'eye-off',
|
||||
File: 'file',
|
||||
FileArchive: 'file-zip',
|
||||
FileAudio: 'file-music',
|
||||
FileCode: 'file-code',
|
||||
FileCode2: 'file-code-2',
|
||||
FileImage: 'photo',
|
||||
FileInput: 'file-import',
|
||||
FileSpreadsheet: 'file-spreadsheet',
|
||||
FileText: 'file-text',
|
||||
FileVideo: 'video',
|
||||
Filter: 'filter',
|
||||
Flag: 'flag',
|
||||
FlaskConical: 'flask',
|
||||
Folder: 'folder',
|
||||
FolderArchive: 'archive',
|
||||
FolderInput: 'folder-down',
|
||||
FolderOpen: 'folder-open',
|
||||
FolderPlus: 'folder-plus',
|
||||
FolderSync: 'folders',
|
||||
Forward: 'arrow-forward-up',
|
||||
Globe: 'world',
|
||||
Grid3x3: 'grid-dots',
|
||||
GripVertical: 'grip-vertical',
|
||||
HardDrive: 'server-2',
|
||||
Heading1: 'h-1',
|
||||
Heading2: 'h-2',
|
||||
Heart: 'heart',
|
||||
HelpCircle: 'help-circle',
|
||||
Highlighter: 'highlight',
|
||||
Image: 'photo',
|
||||
Import: 'file-import',
|
||||
Inbox: 'inbox',
|
||||
Info: 'info-circle',
|
||||
Italic: 'italic',
|
||||
Key: 'key',
|
||||
KeyRound: 'key',
|
||||
Languages: 'language',
|
||||
Layers: 'stack',
|
||||
LayoutGrid: 'layout-grid',
|
||||
LayoutTemplate: 'template',
|
||||
Link2: 'link',
|
||||
Link2Off: 'unlink',
|
||||
List: 'list',
|
||||
ListChecks: 'list-check',
|
||||
ListOrdered: 'list-numbers',
|
||||
Lock: 'lock',
|
||||
LogOut: 'logout',
|
||||
Mail: 'mail',
|
||||
MailCheck: 'mail-check',
|
||||
MailMinus: 'mail-minus',
|
||||
MailOpen: 'mail-opened',
|
||||
Mails: 'inbox',
|
||||
MapPin: 'map-pin',
|
||||
Maximize2: 'maximize',
|
||||
Menu: 'menu-2',
|
||||
MessageSquare: 'message',
|
||||
Minus: 'minus',
|
||||
Monitor: 'device-desktop',
|
||||
Moon: 'moon',
|
||||
MoreHorizontal: 'dots',
|
||||
MoreVertical: 'dots-vertical',
|
||||
MoveVertical: 'arrows-vertical',
|
||||
Palette: 'palette',
|
||||
Palmtree: 'beach',
|
||||
PanelLeftClose: 'layout-sidebar-left-collapse',
|
||||
PanelRight: 'layout-sidebar-right',
|
||||
Paperclip: 'paperclip',
|
||||
PenLine: 'pencil',
|
||||
PenSquare: 'edit',
|
||||
Pencil: 'pencil',
|
||||
Phone: 'phone',
|
||||
Pin: 'pinned',
|
||||
PinOff: 'pinned-off',
|
||||
Plus: 'plus',
|
||||
Puzzle: 'puzzle',
|
||||
QrCode: 'qrcode',
|
||||
Quote: 'quote',
|
||||
Redo2: 'arrow-forward',
|
||||
RefreshCw: 'refresh',
|
||||
RemoveFormatting: 'clear-formatting',
|
||||
Repeat: 'repeat',
|
||||
Reply: 'arrow-back-up',
|
||||
ReplyAll: 'arrow-back-up-double',
|
||||
RotateCcw: 'rotate',
|
||||
Rows2: 'layout-rows',
|
||||
Rss: 'rss',
|
||||
Save: 'device-floppy',
|
||||
ScanSearch: 'zoom-scan',
|
||||
Scale: 'scale',
|
||||
Search: 'search',
|
||||
Send: 'send',
|
||||
Server: 'server',
|
||||
Settings: 'settings',
|
||||
Share2: 'share-2',
|
||||
Shield: 'shield',
|
||||
ShieldAlert: 'shield-exclamation',
|
||||
ShieldCheck: 'shield-check',
|
||||
ShieldQuestion: 'shield-question',
|
||||
Shuffle: 'arrows-shuffle',
|
||||
Smartphone: 'device-mobile',
|
||||
Square: 'square',
|
||||
SquareCheck: 'square-check',
|
||||
SquarePen: 'edit',
|
||||
Star: 'star',
|
||||
StickyNote: 'note',
|
||||
Strikethrough: 'strikethrough',
|
||||
Sun: 'sun',
|
||||
Table: 'table',
|
||||
Tag: 'tag',
|
||||
Tags: 'tags',
|
||||
Terminal: 'terminal',
|
||||
Trash: 'trash',
|
||||
Trash2: 'trash',
|
||||
Type: 'typography',
|
||||
Underline: 'underline',
|
||||
Undo2: 'arrow-back',
|
||||
Upload: 'upload',
|
||||
User: 'user',
|
||||
UserCircle: 'user-circle',
|
||||
UserMinus: 'user-minus',
|
||||
UserPen: 'user-edit',
|
||||
UserPlus: 'user-plus',
|
||||
UserRound: 'user',
|
||||
UserRoundPlus: 'user-plus',
|
||||
Users: 'users',
|
||||
Video: 'video',
|
||||
X: 'x',
|
||||
XCircle: 'circle-x',
|
||||
};
|
||||
|
||||
function main() {
|
||||
const nodes = JSON.parse(readFileSync(SOURCE, 'utf8'));
|
||||
const glyphs = new Map();
|
||||
const missing = [];
|
||||
for (const [appName, tabler] of Object.entries(MAP)) {
|
||||
const icon = nodes[tabler];
|
||||
if (!icon) { missing.push(`${appName} -> ${tabler}`); continue; }
|
||||
const paths = icon.map(([tag, attrs]) => {
|
||||
if (tag !== 'path') throw new Error(`${tabler}: unexpected <${tag}>`);
|
||||
return attrs.d;
|
||||
});
|
||||
glyphs.set(tabler, paths);
|
||||
}
|
||||
if (missing.length) throw new Error(`Not in Tabler: ${missing.join(', ')}`);
|
||||
|
||||
const lines = [
|
||||
'// Generated by scripts/generate-icons.mjs from @tabler/icons (MIT).',
|
||||
'// Do not edit by hand: add the icon to MAP in the script and run it.',
|
||||
'',
|
||||
"import { makeIcon } from './icon';",
|
||||
'',
|
||||
"export type { AppIcon, IconProps } from './icon';",
|
||||
'',
|
||||
'const G = {',
|
||||
...[...glyphs.keys()].sort().map((k) => ` '${k}': ${JSON.stringify(glyphs.get(k))},`),
|
||||
'} as const;',
|
||||
'',
|
||||
...Object.entries(MAP).map(([a, t]) => `export const ${a} = makeIcon('${a}', G['${t}']);`),
|
||||
'',
|
||||
];
|
||||
writeFileSync(join(ROOT, 'src', 'components', 'icons', 'index.ts'), lines.join('\n'));
|
||||
console.log(`${Object.keys(MAP).length} names, ${glyphs.size} glyphs`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -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`);
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'acc-1',
|
||||
request: vi.fn(),
|
||||
getMaxCallsInRequest: vi.fn(() => 16),
|
||||
getMaxObjectsInGet: vi.fn(() => 500),
|
||||
getMaxObjectsInSet: vi.fn(() => 500),
|
||||
},
|
||||
}));
|
||||
|
||||
import { jmapClient } from '../jmap-client';
|
||||
import { saveDraftVersion, type OutgoingAttachment, type OutgoingEmail } from '../email';
|
||||
import { initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs } from '../../lib/draft-part-blobs';
|
||||
|
||||
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||
|
||||
/**
|
||||
* A tiny server with Stalwart's blob semantics, as measured on 0.16.22 (2026-10-14):
|
||||
* an email's part blobIds are derived from that email and stop resolving the
|
||||
* moment it is destroyed ("blobId … does not exist on this server"); creating
|
||||
* an email copies the referenced bytes into new parts with new blobIds.
|
||||
*/
|
||||
function fakeStalwart() {
|
||||
let seq = 0;
|
||||
const emails = new Map<string, Array<{ blobId: string; name: string; size: number; cid?: string; type: string }>>();
|
||||
const live = (blobId: string) => [...emails.values()].some((parts) => parts.some((p) => p.blobId === blobId));
|
||||
const seed = (id: string, parts: OutgoingAttachment[]) => {
|
||||
emails.set(id, parts.map((p) => ({ blobId: p.blobId, name: p.name, size: p.size ?? 0, cid: p.cid, type: p.type })));
|
||||
};
|
||||
mockRequest.mockImplementation(async (calls: Array<[string, Record<string, any>, string]>) => {
|
||||
const out: Array<[string, unknown, string]> = [];
|
||||
let createdId: string | undefined;
|
||||
for (const [method, args, callId] of calls) {
|
||||
if (method === 'Email/set' && args.create) {
|
||||
const create = args.create.draft;
|
||||
const dead = (create.attachments ?? []).find((a: { blobId: string }) => !live(a.blobId));
|
||||
if (dead) {
|
||||
out.push(['Email/set', { notCreated: { draft: {
|
||||
type: 'blobNotFound', description: `blobId ${dead.blobId} does not exist on this server.`,
|
||||
} } }, callId]);
|
||||
continue;
|
||||
}
|
||||
createdId = `e${++seq}`;
|
||||
emails.set(createdId, (create.attachments ?? []).map((a: OutgoingAttachment, i: number) => ({
|
||||
blobId: `${createdId}-part${i}`, name: a.name, size: a.size ?? 0, cid: a.cid ? `<${a.cid}>` : undefined, type: a.type,
|
||||
})));
|
||||
out.push(['Email/set', { created: { draft: { id: createdId } } }, callId]);
|
||||
} else if (method === 'Email/set' && args.destroy) {
|
||||
for (const id of args.destroy) emails.delete(id);
|
||||
out.push(['Email/set', { destroyed: args.destroy }, callId]);
|
||||
} else if (method === 'Email/get') {
|
||||
if (!createdId || args.ids?.[0] !== '#draft') {
|
||||
out.push(['error', { type: 'invalidResultReference' }, callId]);
|
||||
continue;
|
||||
}
|
||||
out.push(['Email/get', { list: [{ id: createdId, attachments: emails.get(createdId) }] }, callId]);
|
||||
}
|
||||
}
|
||||
return { methodResponses: out };
|
||||
});
|
||||
return { seed, emails };
|
||||
}
|
||||
|
||||
const IMG_A: OutgoingAttachment = { blobId: 'd0-part-a', name: 'schema-a.png', size: 136574, type: 'image/png', disposition: 'inline', cid: 'a@undust.co' };
|
||||
const IMG_B: OutgoingAttachment = { blobId: 'd0-part-b', name: 'schema-b.png', size: 175155, type: 'image/png', disposition: 'inline', cid: 'b@undust.co' };
|
||||
|
||||
const draftWith = (attachments: OutgoingAttachment[]): OutgoingEmail => ({
|
||||
from: [{ email: 'me@undust.co' }],
|
||||
to: [{ email: 'test@example.invalid' }],
|
||||
subject: 'RE: draft with two inline images',
|
||||
htmlBody: '<p>x <img src="cid:a@undust.co"> <img src="cid:b@undust.co"></p>',
|
||||
textBody: 'x',
|
||||
attachments,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('saveDraftVersion', () => {
|
||||
it('reads the new version\'s parts back in the same request, by creation id', async () => {
|
||||
fakeStalwart().seed('d0', [IMG_A]);
|
||||
const saved = await saveDraftVersion(draftWith([IMG_A]), 'drafts', 'd0');
|
||||
const calls = mockRequest.mock.calls[0][0];
|
||||
expect(calls.map((c: [string]) => c[0])).toEqual(['Email/set', 'Email/get']);
|
||||
expect(calls[1][1].ids).toEqual(['#draft']);
|
||||
expect(saved.id).toBe('e1');
|
||||
expect(saved.attachments.map((a) => a.blobId)).toEqual(['e1-part0']);
|
||||
});
|
||||
|
||||
it('destroys the previous version only after the new one exists', async () => {
|
||||
fakeStalwart().seed('d0', [IMG_A]);
|
||||
await saveDraftVersion(draftWith([IMG_A]), 'drafts', 'd0');
|
||||
const destroyCall = mockRequest.mock.calls[1][0][0];
|
||||
expect(destroyCall[0]).toBe('Email/set');
|
||||
expect(destroyCall[1].destroy).toEqual(['d0']);
|
||||
});
|
||||
|
||||
it('still returns the id when the read-back fails', async () => {
|
||||
mockRequest.mockResolvedValueOnce({ methodResponses: [
|
||||
['Email/set', { created: { draft: { id: 'e9' } } }, '0'],
|
||||
['error', { type: 'serverFail' }, '1'],
|
||||
] });
|
||||
const saved = await saveDraftVersion(draftWith([]), 'drafts');
|
||||
expect(saved).toEqual({ id: 'e9', attachments: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('re-opened draft with inline images: save, save again, send', () => {
|
||||
// What ComposeScreen does: buildOutgoing maps each held blobId through the
|
||||
// state, saveDraftOnce rebases it on the parts read back.
|
||||
async function editSession(rebase: boolean) {
|
||||
const server = fakeStalwart();
|
||||
server.seed('d0', [IMG_A, IMG_B]); // the draft as deposited (IMAP APPEND or an earlier save)
|
||||
let state = initialDraftPartBlobState();
|
||||
let draftId = 'd0';
|
||||
const outgoing = () => draftWith([IMG_A, IMG_B].map((a) => ({ ...a, blobId: liveBlobId(state, a.blobId) })));
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const sent = outgoing();
|
||||
const saved = await saveDraftVersion(sent, 'drafts', draftId);
|
||||
draftId = saved.id;
|
||||
if (rebase) state = rebaseDraftPartBlobs(state, sent.attachments ?? [], saved.attachments);
|
||||
}
|
||||
return { server, draftId };
|
||||
}
|
||||
|
||||
it('reproduces the bug without the rebase: the second save references the destroyed draft\'s parts', async () => {
|
||||
await expect(editSession(false)).rejects.toThrow('blobId d0-part-a does not exist on this server.');
|
||||
});
|
||||
|
||||
it('keeps every save (and so the send, which builds the same attachments) on live blobs', async () => {
|
||||
const { server, draftId } = await editSession(true);
|
||||
expect([...server.emails.keys()]).toEqual([draftId]);
|
||||
expect(server.emails.get(draftId)!.map((p) => p.name)).toEqual(['schema-a.png', 'schema-b.png']);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
getFullEmails,
|
||||
getThreadsHeaders,
|
||||
createDraft,
|
||||
saveDraftVersion,
|
||||
destroyEmails,
|
||||
sendEmail,
|
||||
markAsSpam,
|
||||
undoSpam,
|
||||
@@ -33,6 +35,9 @@ import {
|
||||
EMAIL_FULL_PROPERTIES,
|
||||
} from '../email';
|
||||
import { getIdentities } from '../identity';
|
||||
import { uploadBytes } from '../blob';
|
||||
import { initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs } from '../../lib/draft-part-blobs';
|
||||
import type { OutgoingAttachment } from '../email';
|
||||
import { getCalendars, queryEvents, createEvent, deleteEvents } from '../calendar';
|
||||
import { getAddressBooks, createContact, deleteContacts } from '../contacts';
|
||||
|
||||
@@ -192,6 +197,44 @@ live('live Stalwart', () => {
|
||||
await destroyEmails([id]);
|
||||
}, 60_000);
|
||||
|
||||
it('keeps a re-opened draft with an inline image saveable across saves (#849 part blobs)', async () => {
|
||||
// A 1x1 PNG uploaded once; D0 stands for the draft as it sits on the server.
|
||||
const png = Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='), (c) => c.charCodeAt(0));
|
||||
const up = await uploadBytes(png, 'image/png');
|
||||
const base = {
|
||||
from: [{ email: USER }],
|
||||
to: [{ email: 'test@example.invalid' }],
|
||||
subject: '[TEST bulwark-blob — supprimable]',
|
||||
htmlBody: '<p>live <img src="cid:img1@live.test"></p>',
|
||||
textBody: 'live',
|
||||
};
|
||||
const img = (blobId: string): OutgoingAttachment => ({
|
||||
blobId, type: 'image/png', name: 'img1.png', size: png.byteLength, disposition: 'inline', cid: 'img1@live.test',
|
||||
});
|
||||
const d0 = await saveDraftVersion({ ...base, attachments: [img(up.blobId)] }, drafts);
|
||||
const created = [d0.id];
|
||||
try {
|
||||
// Re-open D0: the composer holds D0's part blobId.
|
||||
const seeded = d0.attachments[0].blobId;
|
||||
let state = initialDraftPartBlobState();
|
||||
let draftId = d0.id;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const sent = [img(liveBlobId(state, seeded))];
|
||||
const saved = await saveDraftVersion({ ...base, attachments: sent }, drafts, draftId);
|
||||
created.push(saved.id);
|
||||
draftId = saved.id;
|
||||
state = rebaseDraftPartBlobs(state, sent, saved.attachments);
|
||||
}
|
||||
// Without the rebase the seeded part blob is gone with D0.
|
||||
await expect(saveDraftVersion({ ...base, attachments: [img(seeded)] }, drafts))
|
||||
.rejects.toThrow(/does not exist|blobNotFound/);
|
||||
const [last] = await getFullEmails([draftId]);
|
||||
expect(last.attachments?.[0]?.cid).toContain('img1@live.test');
|
||||
} finally {
|
||||
await destroyEmails(created).catch(() => undefined);
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
it('sends through Drafts and files into Sent only after submission (#188)', async () => {
|
||||
const identities = await getIdentities();
|
||||
expect(identities.length).toBeGreaterThan(0);
|
||||
|
||||
+48
-5
@@ -1679,31 +1679,64 @@ export async function sendEmail(
|
||||
};
|
||||
}
|
||||
|
||||
/** A saved draft version and the attachment parts the server built for it. */
|
||||
export interface SavedDraftVersion {
|
||||
id: string;
|
||||
/**
|
||||
* Attachment and inline parts of the new version, with their blobIds. A
|
||||
* part's blobId only lives as long as this version: re-opened drafts must
|
||||
* send these (not the previous version's) on their next save or send.
|
||||
* Empty when the server did not answer the read-back.
|
||||
*/
|
||||
attachments: Attachment[];
|
||||
}
|
||||
|
||||
const DRAFT_PART_BODY_PROPERTIES = ['partId', 'blobId', 'size', 'name', 'type', 'cid', 'disposition'];
|
||||
|
||||
/**
|
||||
* Save a draft into the Drafts folder (`$draft` + `$seen`). When `previousDraftId`
|
||||
* is given it is destroyed only AFTER the replacement was created (#849): a
|
||||
* combined create+destroy would delete the last good copy when the create
|
||||
* fails (e.g. blobNotFound on a re-opened draft's attachments). Returns the
|
||||
* new draft id.
|
||||
* fails (e.g. blobNotFound on a re-opened draft's attachments). The new
|
||||
* version's attachment parts are read back in the same request, so the
|
||||
* composer can re-point its draft-part blobIds before the old version (and
|
||||
* its part blobs) is destroyed.
|
||||
*/
|
||||
export async function createDraft(
|
||||
export async function saveDraftVersion(
|
||||
email: OutgoingEmail,
|
||||
draftsMailboxId: string,
|
||||
previousDraftId?: string,
|
||||
accountIdOverride?: string,
|
||||
): Promise<string> {
|
||||
): Promise<SavedDraftVersion> {
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
const emailCreate = buildEmailCreate(email);
|
||||
emailCreate.mailboxIds = { [draftsMailboxId]: true };
|
||||
emailCreate.keywords = { $seen: true, $draft: true };
|
||||
const res = await jmapClient.request([
|
||||
['Email/set', { accountId, create: { draft: emailCreate } }, '0'],
|
||||
// `#draft` is the creation id (RFC 8620 §5.3). Stalwart 0.16 does not
|
||||
// resolve a result reference into an Email/set `created` map
|
||||
// (invalidResultReference), but it does resolve the creation id.
|
||||
['Email/get', {
|
||||
accountId,
|
||||
ids: ['#draft'],
|
||||
properties: ['id', 'attachments'],
|
||||
bodyProperties: DRAFT_PART_BODY_PROPERTIES,
|
||||
}, '1'],
|
||||
]);
|
||||
const body = requireMethodResult(res, '0', 'Email/set');
|
||||
const notCreated = body.notCreated?.draft as { description?: string; type?: string } | undefined;
|
||||
if (notCreated) throw new Error(notCreated.description || notCreated.type || 'Failed to save draft');
|
||||
const id = body.created?.draft?.id as string | undefined;
|
||||
if (!id) throw new Error('Draft save returned no id');
|
||||
let attachments: Attachment[] = [];
|
||||
try {
|
||||
const got = requireMethodResult(res, '1', 'Email/get');
|
||||
const saved = (got.list as Array<{ id: string; attachments?: Attachment[] }> | undefined)?.find((e) => e.id === id);
|
||||
attachments = (saved?.attachments ?? []).filter((a) => !!a.blobId);
|
||||
} catch (err) {
|
||||
console.warn('[email] could not read back the saved draft parts:', err);
|
||||
}
|
||||
if (previousDraftId && previousDraftId !== id) {
|
||||
try {
|
||||
await destroyEmails([previousDraftId], accountId);
|
||||
@@ -1711,7 +1744,17 @@ export async function createDraft(
|
||||
console.warn('[email] failed to destroy previous draft version:', err);
|
||||
}
|
||||
}
|
||||
return id;
|
||||
return { id, attachments };
|
||||
}
|
||||
|
||||
/** {@link saveDraftVersion} for callers that only need the new draft id. */
|
||||
export async function createDraft(
|
||||
email: OutgoingEmail,
|
||||
draftsMailboxId: string,
|
||||
previousDraftId?: string,
|
||||
accountIdOverride?: string,
|
||||
): Promise<string> {
|
||||
return (await saveDraftVersion(email, draftsMailboxId, previousDraftId, accountIdOverride)).id;
|
||||
}
|
||||
|
||||
export interface ScheduledEmail {
|
||||
|
||||
+17
-11
@@ -1,20 +1,25 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ViewStyle } from 'react-native';
|
||||
import { radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { radius, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
|
||||
interface BadgeProps {
|
||||
/** Sentence case ("Not on this server"); badges are never upper case. */
|
||||
children: string;
|
||||
variant?: 'default' | 'primary' | 'destructive' | 'outline' | 'muted';
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* A small status pill. The default is the webmail's outline badge: hairline
|
||||
* border, muted 12px text. The tinted variants keep the same size.
|
||||
*/
|
||||
export default function Badge({ children, variant = 'default', style }: BadgeProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
return (
|
||||
<View style={[styles.base, styles[`bg_${variant}` as const], style]}>
|
||||
<Text style={[styles.text, styles[`text_${variant}` as const]]}>
|
||||
<Text style={[styles.text, styles[`text_${variant}` as const]]} numberOfLines={1}>
|
||||
{children}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -25,23 +30,24 @@ function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
base: {
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: 'transparent',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
paddingVertical: 1,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
text: { ...typography.caption, fontWeight: '500' },
|
||||
text: { fontSize: 12, lineHeight: 16 },
|
||||
|
||||
bg_default: { backgroundColor: c.surface },
|
||||
bg_primary: { backgroundColor: c.primaryBg },
|
||||
bg_default: { borderColor: c.border },
|
||||
bg_outline: { borderColor: c.border },
|
||||
bg_primary: { backgroundColor: c.accent },
|
||||
bg_destructive: { backgroundColor: c.errorBg },
|
||||
bg_outline: { backgroundColor: 'transparent', borderWidth: 1, borderColor: c.border },
|
||||
bg_muted: { backgroundColor: c.muted },
|
||||
|
||||
text_default: { color: c.text },
|
||||
text_primary: { color: c.primary },
|
||||
text_default: { color: c.mutedForeground },
|
||||
text_outline: { color: c.mutedForeground },
|
||||
text_primary: { color: c.accentForeground },
|
||||
text_destructive: { color: c.error },
|
||||
text_outline: { color: c.text },
|
||||
text_muted: { color: c.mutedForeground },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+43
-27
@@ -1,9 +1,19 @@
|
||||
import React from 'react';
|
||||
import { Pressable, Text, StyleSheet, ViewStyle, ActivityIndicator } from 'react-native';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||
import { spacing, radius, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
|
||||
type ButtonVariant = 'default' | 'ghost' | 'outline' | 'destructive';
|
||||
/**
|
||||
* The webmail's standard-theme buttons (components/ui/button.tsx):
|
||||
* - default: bg-primary text-primary-foreground
|
||||
* - secondary: bg-muted text-foreground
|
||||
* - outline: border, transparent
|
||||
* - ghost: no border or fill until pressed
|
||||
* - destructive: red fill, white text
|
||||
* - destructiveOutline: red border and text, for a reset in a settings row
|
||||
* All 36 dp tall (lg 44 for a page's main action), radius 6, 14px medium.
|
||||
*/
|
||||
type ButtonVariant = 'default' | 'secondary' | 'ghost' | 'outline' | 'destructive' | 'destructiveOutline';
|
||||
type ButtonSize = 'sm' | 'md' | 'lg' | 'icon';
|
||||
|
||||
interface ButtonProps {
|
||||
@@ -19,8 +29,8 @@ interface ButtonProps {
|
||||
accessibilityLabel?: string;
|
||||
}
|
||||
|
||||
// Extra touch area so even the small sizes reach a 48 dp target.
|
||||
const HIT_SLOP: Record<ButtonSize, number> = { sm: 6, md: 4, lg: 2, icon: 4 };
|
||||
// Extra touch area so every size reaches a 48 dp target.
|
||||
const HIT_SLOP: Record<ButtonSize, number> = { sm: 6, md: 6, lg: 2, icon: 4 };
|
||||
|
||||
export default function Button({
|
||||
variant = 'default',
|
||||
@@ -36,6 +46,9 @@ export default function Button({
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const isDisabled = disabled || loading;
|
||||
const spinnerColor = variant === 'default'
|
||||
? c.primaryForeground
|
||||
: variant === 'destructive' ? '#ffffff' : c.text;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -55,15 +68,12 @@ export default function Button({
|
||||
accessibilityState={{ disabled: isDisabled, busy: loading }}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={variant === 'default' ? c.primaryForeground : c.primary}
|
||||
/>
|
||||
<ActivityIndicator size="small" color={spinnerColor} />
|
||||
) : (
|
||||
<>
|
||||
{icon}
|
||||
{typeof children === 'string' ? (
|
||||
<Text style={[styles.text, styles[`text_${variant}` as const], isDisabled && styles.textDisabled]}>
|
||||
<Text style={[styles.text, styles[`text_${variant}` as const]]} numberOfLines={1}>
|
||||
{children}
|
||||
</Text>
|
||||
) : (
|
||||
@@ -79,30 +89,36 @@ function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
base: {
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: radius.md, gap: spacing.sm,
|
||||
borderRadius: radius.sm, gap: spacing.sm,
|
||||
},
|
||||
text: { ...typography.bodyMedium },
|
||||
text: { fontSize: 14, fontWeight: '500', lineHeight: 20 },
|
||||
disabled: { opacity: 0.5 },
|
||||
textDisabled: { opacity: 0.5 },
|
||||
|
||||
size_sm: { height: componentSizes.buttonSm, paddingHorizontal: spacing.md },
|
||||
size_md: { height: componentSizes.buttonMd, paddingHorizontal: spacing.lg, paddingVertical: spacing.sm },
|
||||
size_lg: { height: componentSizes.buttonLg, paddingHorizontal: spacing.xxxl },
|
||||
// minHeight, so a larger system font grows the button instead of clipping.
|
||||
size_sm: { minHeight: componentSizes.buttonSm, paddingHorizontal: spacing.md },
|
||||
size_md: { minHeight: componentSizes.buttonSm, paddingHorizontal: 14 },
|
||||
size_lg: { minHeight: componentSizes.buttonLg, paddingHorizontal: spacing.xxl },
|
||||
size_icon: { height: componentSizes.buttonMd, width: componentSizes.buttonMd, paddingHorizontal: 0 },
|
||||
|
||||
bg_default: { backgroundColor: c.primary, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 1, elevation: 1 },
|
||||
bg_ghost: { backgroundColor: 'transparent' },
|
||||
bg_outline: { backgroundColor: c.background, borderWidth: 1, borderColor: c.border },
|
||||
bg_destructive: { backgroundColor: c.error, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 1, elevation: 1 },
|
||||
bg_default: { backgroundColor: c.primary },
|
||||
bg_secondary: { backgroundColor: c.muted },
|
||||
bg_ghost: { backgroundColor: 'transparent' },
|
||||
bg_outline: { backgroundColor: 'transparent', borderWidth: 1, borderColor: c.border },
|
||||
bg_destructive: { backgroundColor: c.error },
|
||||
bg_destructiveOutline: { backgroundColor: 'transparent', borderWidth: 1, borderColor: c.error },
|
||||
|
||||
pressed_default: { backgroundColor: c.primaryDark, shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 2, elevation: 2 },
|
||||
pressed_ghost: { backgroundColor: c.accent },
|
||||
pressed_outline: { backgroundColor: c.accent },
|
||||
pressed_destructive: { opacity: 0.9 },
|
||||
pressed_default: { opacity: 0.9 },
|
||||
pressed_secondary: { opacity: 0.8 },
|
||||
pressed_ghost: { backgroundColor: c.muted },
|
||||
pressed_outline: { backgroundColor: c.muted },
|
||||
pressed_destructive: { opacity: 0.9 },
|
||||
pressed_destructiveOutline: { backgroundColor: c.errorBg },
|
||||
|
||||
text_default: { color: c.primaryForeground },
|
||||
text_ghost: { color: c.text },
|
||||
text_outline: { color: c.text },
|
||||
text_destructive: { color: c.errorForeground },
|
||||
text_default: { color: c.primaryForeground },
|
||||
text_secondary: { color: c.text },
|
||||
text_ghost: { color: c.text },
|
||||
text_outline: { color: c.text },
|
||||
text_destructive: { color: '#ffffff' },
|
||||
text_destructiveOutline: { color: c.error },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Modal, TextInput, type TextInputProps } from 'react-native';
|
||||
import { AlertTriangle } from 'lucide-react-native';
|
||||
import { AlertTriangle } from './icons';
|
||||
import Button from './Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
@@ -72,7 +72,7 @@ export default function Dialog({
|
||||
value={input.value}
|
||||
onChangeText={input.onChangeText}
|
||||
placeholder={input.placeholder}
|
||||
placeholderTextColor={c.textMuted}
|
||||
placeholderTextColor={c.mutedForeground}
|
||||
autoFocus
|
||||
onSubmitEditing={confirmDisabled ? undefined : onConfirm}
|
||||
accessibilityLabel={input.placeholder ?? title}
|
||||
@@ -143,15 +143,17 @@ function makeStyles(c: ThemePalette) {
|
||||
color: c.mutedForeground, // text-muted-foreground
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
// The shared Input look: 40 dp, transparent, radius 6.
|
||||
input: {
|
||||
...typography.body,
|
||||
color: c.text,
|
||||
backgroundColor: c.surface,
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 10,
|
||||
paddingVertical: 0,
|
||||
minHeight: 40,
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
footer: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { View, StyleSheet, Linking, Platform, Text, Pressable, useWindowDimensio
|
||||
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { Image as ImageIcon, ShieldCheck } from 'lucide-react-native';
|
||||
import { Image as ImageIcon, ShieldCheck } from './icons';
|
||||
import type { Email } from '../api/types';
|
||||
import { extractCidRefs } from '../lib/email-html';
|
||||
import { hasTruncatedDisplayedBody, pickEmailBody, selectRenderableHtml } from '../lib/email-body';
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { X, Check, Mail } from 'lucide-react-native';
|
||||
import { X, Check, Mail } from './icons';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { useSheetDrag } from '../lib/use-sheet-drag';
|
||||
|
||||
+12
-13
@@ -14,13 +14,12 @@ interface InputProps extends TextInputProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches webmail input.tsx:
|
||||
* - h-10 w-full rounded-md border border-input bg-background
|
||||
* - px-3 py-2 text-sm text-foreground
|
||||
* - placeholder:text-muted-foreground
|
||||
* - hover:border-muted-foreground
|
||||
* - focus:ring-2 ring-ring border-ring
|
||||
* - disabled:opacity-50
|
||||
* Matches webmail input.tsx in the standard theme:
|
||||
* - h-10 rounded-md border border-input, transparent background
|
||||
* - px-3 text-sm text-foreground, placeholder in muted-foreground
|
||||
* - focus: border-ring
|
||||
* - disabled: opacity-50
|
||||
* - label above in text-sm font-medium text-foreground
|
||||
*/
|
||||
export default function Input({
|
||||
label,
|
||||
@@ -50,7 +49,7 @@ export default function Input({
|
||||
ref={ref}
|
||||
{...props}
|
||||
style={[styles.input, style]}
|
||||
placeholderTextColor={c.textMuted}
|
||||
placeholderTextColor={c.mutedForeground}
|
||||
onFocus={(e) => { setFocused(true); props.onFocus?.(e); }}
|
||||
onBlur={(e) => { setFocused(false); props.onBlur?.(e); }}
|
||||
/>
|
||||
@@ -65,8 +64,8 @@ function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
label: {
|
||||
...typography.bodyMedium,
|
||||
color: c.textSecondary,
|
||||
marginBottom: spacing.sm,
|
||||
color: c.text,
|
||||
marginBottom: 6,
|
||||
},
|
||||
inputWrapper: {
|
||||
flexDirection: 'row',
|
||||
@@ -75,12 +74,12 @@ function makeStyles(c: ThemePalette) {
|
||||
borderWidth: 1,
|
||||
borderColor: c.border, // border-input
|
||||
borderRadius: radius.sm, // rounded-md = 6px
|
||||
backgroundColor: c.background, // bg-background
|
||||
backgroundColor: 'transparent',
|
||||
paddingHorizontal: spacing.md, // px-3 = 12px
|
||||
},
|
||||
// Colour only, so focusing doesn't shift the text by a pixel.
|
||||
inputFocused: {
|
||||
borderColor: c.borderFocus, // border-ring (blue-500)
|
||||
borderWidth: 2,
|
||||
borderColor: c.borderFocus,
|
||||
},
|
||||
inputError: {
|
||||
borderColor: c.error,
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import {
|
||||
Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Folder,
|
||||
X, Check, type LucideIcon,
|
||||
} from 'lucide-react-native';
|
||||
X, Check, type AppIcon,
|
||||
} from './icons';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree';
|
||||
@@ -17,7 +17,7 @@ import type { Mailbox } from '../api/types';
|
||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||
import { useSheetInsets } from '../lib/use-layout';
|
||||
|
||||
function moveTargetIcon(role: string | null | undefined, name: string): LucideIcon {
|
||||
function moveTargetIcon(role: string | null | undefined, name: string): AppIcon {
|
||||
const lower = name.toLowerCase();
|
||||
if (role === 'inbox' || lower.includes('inbox')) return Inbox;
|
||||
if (role === 'sent' || lower.includes('sent')) return Send;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { CloudOff } from 'lucide-react-native';
|
||||
import { CloudOff } from './icons';
|
||||
import { useNetworkStore } from '../stores/network-store';
|
||||
import { useOutboxStore } from '../stores/outbox-store';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { CloudDownload, X } from 'lucide-react-native';
|
||||
import { CloudDownload, X } from './icons';
|
||||
import { useOfflineCacheStore } from '../stores/offline-cache-store';
|
||||
import { useUpdatesStore } from '../stores/updates-store';
|
||||
import { formatBytes } from '../lib/offline-sync';
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { ClipboardPaste, X } from 'lucide-react-native';
|
||||
import { ClipboardPaste, X } from './icons';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import Button from './Button';
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useFloatingCardFrame } from '../lib/use-layout';
|
||||
import { BellRing, X } from 'lucide-react-native';
|
||||
import { BellRing, X } from './icons';
|
||||
import Button from './Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { View, Text, Pressable, StyleSheet, Modal, Platform, Linking } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { CameraView, useCameraPermissions, type BarcodeScanningResult } from 'expo-camera';
|
||||
import { X } from 'lucide-react-native';
|
||||
import { X } from './icons';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors, useResolvedTheme } from '../theme/colors';
|
||||
import Button from './Button';
|
||||
|
||||
@@ -78,10 +78,16 @@ interface Props {
|
||||
onSelectionChange?: (state: RichTextSelectionState) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
/**
|
||||
* Android: the page undid a caret-reveal pan of `dy` px (dp) inside the
|
||||
* WebView. The host should scroll its ScrollView down by `dy` so the caret
|
||||
* stays visible (see `pinVisualViewport` in editor-html).
|
||||
*/
|
||||
onViewportShift?: (dy: number) => void;
|
||||
}
|
||||
|
||||
const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function RichTextEditor(
|
||||
{ initialHtml = '', placeholder = '', blockRemoteImages = false, onChange, onSelectionChange, onFocus, onBlur },
|
||||
{ initialHtml = '', placeholder = '', blockRemoteImages = false, onChange, onSelectionChange, onFocus, onBlur, onViewportShift },
|
||||
ref,
|
||||
) {
|
||||
const c = useColors();
|
||||
@@ -92,6 +98,8 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
||||
const onSelectionRef = React.useRef(onSelectionChange);
|
||||
const onFocusRef = React.useRef(onFocus);
|
||||
const onBlurRef = React.useRef(onBlur);
|
||||
const onViewportShiftRef = React.useRef(onViewportShift);
|
||||
onViewportShiftRef.current = onViewportShift;
|
||||
onChangeRef.current = onChange;
|
||||
onSelectionRef.current = onSelectionChange;
|
||||
onFocusRef.current = onFocus;
|
||||
@@ -100,7 +108,9 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
||||
// Build the source HTML once. Theming changes won't auto-rebuild the page,
|
||||
// which is fine: themes change rarely and the user can recompose if needed.
|
||||
const html = React.useMemo(
|
||||
() => buildEditorHtml({ initialHtml, placeholder, c, blockRemoteImages }),
|
||||
() => buildEditorHtml({
|
||||
initialHtml, placeholder, c, blockRemoteImages, pinVisualViewport: Platform.OS === 'android',
|
||||
}),
|
||||
// c reference changes per theme switch but we don't want to lose user
|
||||
// edits on a theme change. Bake in the initial colors only.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -224,6 +234,11 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
||||
case 'blur':
|
||||
onBlurRef.current?.();
|
||||
break;
|
||||
case 'viewportShift': {
|
||||
const dy = typeof data.payload === 'number' ? data.payload : Number(data.payload);
|
||||
if (Number.isFinite(dy) && dy !== 0) onViewportShiftRef.current?.(dy);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
Folder, FolderOpen, ChevronDown, ChevronRight, X, Settings, LogOut, Check, Plus,
|
||||
Clock, Layers, Users, Tag, Mails, MailOpen, StickyNote, AlarmClock, Flag,
|
||||
CheckCheck, Eraser, FolderPlus, Pencil, AlertTriangle, UserMinus,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react-native';
|
||||
type AppIcon,
|
||||
} from './icons';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { useAnimDuration } from '../theme/dynamic';
|
||||
@@ -65,7 +65,7 @@ function iconFor(
|
||||
name: string | undefined,
|
||||
hasChildren: boolean,
|
||||
isExpanded: boolean,
|
||||
): LucideIcon {
|
||||
): AppIcon {
|
||||
switch (role) {
|
||||
case 'inbox': return Inbox;
|
||||
case 'sent': return Send;
|
||||
@@ -224,7 +224,7 @@ function SidebarRow({
|
||||
interface SheetAction {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
icon: AppIcon;
|
||||
destructive?: boolean;
|
||||
onPress: () => void;
|
||||
}
|
||||
@@ -685,7 +685,7 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
|
||||
};
|
||||
}, [mailboxes, accountSnapshots, activeAccountId, unifiedCrossAccount, includeGroupInUnified]);
|
||||
|
||||
const unifiedIcon = (role: UnifiedRole): LucideIcon => iconFor(role, undefined, false, false);
|
||||
const unifiedIcon = (role: UnifiedRole): AppIcon => iconFor(role, undefined, false, false);
|
||||
|
||||
const slideX = React.useRef(new Animated.Value(-Dimensions.get('window').width)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
@@ -1459,10 +1459,9 @@ function makeStyles(c: ThemePalette) {
|
||||
color: c.text,
|
||||
},
|
||||
rowLabelAccount: {
|
||||
...typography.caption,
|
||||
fontWeight: '600',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
fontWeight: '500',
|
||||
color: c.textSecondary,
|
||||
},
|
||||
counts: {
|
||||
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
} from 'react-native';
|
||||
import {
|
||||
Archive, Trash2, ShieldAlert, ShieldCheck, MailOpen, Mail, Star, Pin, PinOff, FolderInput,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react-native';
|
||||
type AppIcon,
|
||||
} from './icons';
|
||||
import { typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
@@ -43,7 +43,7 @@ export interface SwipeContext {
|
||||
// Distance the row flies off-screen by before the action callback fires.
|
||||
const EXIT_DISTANCE = 600;
|
||||
|
||||
const ACTION_META: Record<Exclude<SwipeAction, 'none'>, { icon: LucideIcon; bg: string; defaultLabel: string }> = {
|
||||
const ACTION_META: Record<Exclude<SwipeAction, 'none'>, { icon: AppIcon; bg: string; defaultLabel: string }> = {
|
||||
archive: { icon: Archive, bg: '#1d4ed8', defaultLabel: 'Archive' },
|
||||
delete: { icon: Trash2, bg: '#b91c1c', defaultLabel: 'Delete' },
|
||||
spam: { icon: ShieldAlert, bg: '#a16207', defaultLabel: 'Spam' },
|
||||
@@ -68,7 +68,7 @@ function actionLabel(action: SwipeAction, context: SwipeContext, t: Translate):
|
||||
}
|
||||
}
|
||||
|
||||
function actionIcon(action: Exclude<SwipeAction, 'none'>, context: SwipeContext): LucideIcon {
|
||||
function actionIcon(action: Exclude<SwipeAction, 'none'>, context: SwipeContext): AppIcon {
|
||||
if (action === 'spam' && context.inJunk) return ShieldCheck;
|
||||
if (action === 'read' && !context.unread) return Mail;
|
||||
if (action === 'pin' && context.pinned) return PinOff;
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { X, Check, Tag } from 'lucide-react-native';
|
||||
import { X, Check, Tag } from './icons';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { useSheetDrag } from '../lib/use-sheet-drag';
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { X, Star, FileText } from 'lucide-react-native';
|
||||
import { X, Star, FileText } from './icons';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { useSheetDrag } from '../lib/use-sheet-drag';
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useFloatingCardFrame } from '../lib/use-layout';
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, Info } from 'lucide-react-native';
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, Info } from './icons';
|
||||
import { spacing, radius, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors, useResolvedTheme } from '../theme/colors';
|
||||
import { useShouldAnimate } from '../theme/dynamic';
|
||||
|
||||
@@ -12,11 +12,9 @@ interface ToggleSwitchProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches webmail ToggleSwitch:
|
||||
* - h-6 w-11 rounded-full
|
||||
* - checked: bg-primary, unchecked: bg-muted
|
||||
* - thumb: h-4 w-4 rounded-full bg-background
|
||||
* - translate-x-6 (checked) / translate-x-1 (unchecked)
|
||||
* The webmail switch in the standard theme: a 44 x 24 track, bg-primary when
|
||||
* on (knob in primary-foreground), bg-muted when off (knob in background).
|
||||
* The knob is 20 dp with 2 dp of track around it.
|
||||
*/
|
||||
export default function ToggleSwitch({ value, onValueChange, disabled = false, accessibilityLabel }: ToggleSwitchProps) {
|
||||
const c = useColors();
|
||||
@@ -38,7 +36,8 @@ export default function ToggleSwitch({ value, onValueChange, disabled = false, a
|
||||
<View
|
||||
style={[
|
||||
styles.thumb,
|
||||
{ transform: [{ translateX: value ? 24 : 4 }] },
|
||||
value ? styles.thumbOn : styles.thumbOff,
|
||||
{ transform: [{ translateX: value ? 22 : 2 }] },
|
||||
]}
|
||||
/>
|
||||
</Pressable>
|
||||
@@ -60,9 +59,14 @@ function makeStyles(c: ThemePalette) {
|
||||
backgroundColor: c.muted,
|
||||
},
|
||||
thumb: {
|
||||
width: componentSizes.toggleThumb, // h-4 w-4 = 16
|
||||
width: componentSizes.toggleThumb,
|
||||
height: componentSizes.toggleThumb,
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
thumbOn: {
|
||||
backgroundColor: c.primaryForeground,
|
||||
},
|
||||
thumbOff: {
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
disabled: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, Linking } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Download, ShieldAlert, X } from 'lucide-react-native';
|
||||
import { Download, ShieldAlert, X } from './icons';
|
||||
import { useUpdatesStore } from '../stores/updates-store';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
|
||||
@@ -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': [],
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { Check } from 'lucide-react-native';
|
||||
import { Check } from '../icons';
|
||||
import type { Calendar } from '../../api/types';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Animated, Easing, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { CalendarPlus, CircleCheck, Plus } from 'lucide-react-native';
|
||||
import { CalendarPlus, CircleCheck, Plus } from '../icons';
|
||||
import { componentSizes, radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useAnimDuration } from '../../theme/dynamic';
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { Trash2, UserPlus, Users } from 'lucide-react-native';
|
||||
import { Trash2, UserPlus, Users } from '../icons';
|
||||
import { getPrincipals, ownPrincipalId } from '../../api/files';
|
||||
import type { Calendar, CalendarRights, Principal } from '../../api/types';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import {
|
||||
Check, X, Upload, Rss, Shuffle, Star, Plus, Pencil, Share2, Eraser, Trash2, RefreshCw,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react-native';
|
||||
Check, X, Upload, Rss, Shuffle, Star, Plus, Pencil, Share2, Eraser, Trash2, RefreshCw, ArrowUp, ArrowDown,
|
||||
type AppIcon,
|
||||
} from '../icons';
|
||||
import type { Calendar } from '../../api/types';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
@@ -29,7 +29,7 @@ import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
export interface DrawerViewOption {
|
||||
key: string;
|
||||
label: string;
|
||||
Icon: LucideIcon;
|
||||
Icon: AppIcon;
|
||||
}
|
||||
|
||||
interface CalendarSidebarDrawerProps {
|
||||
@@ -55,6 +55,9 @@ interface CalendarSidebarDrawerProps {
|
||||
onShare?: (calendar: Calendar) => void;
|
||||
onClear?: (calendar: Calendar) => void;
|
||||
onDelete?: (calendar: Calendar) => void;
|
||||
// Move one of the user's own calendars next to `targetId`, a neighbour in
|
||||
// the same section (before it when moving up, after it when moving down).
|
||||
onMove?: (calendar: Calendar, targetId: string) => void;
|
||||
// Client-side iCal subscriptions are managed from the subscriptions sheet.
|
||||
isSubscriptionCalendar?: (calendarId: string) => boolean;
|
||||
}
|
||||
@@ -79,6 +82,7 @@ export function CalendarSidebarDrawer({
|
||||
onShare,
|
||||
onClear,
|
||||
onDelete,
|
||||
onMove,
|
||||
isSubscriptionCalendar,
|
||||
}: CalendarSidebarDrawerProps) {
|
||||
const c = useColors();
|
||||
@@ -148,6 +152,7 @@ export function CalendarSidebarDrawer({
|
||||
onShare,
|
||||
onClear,
|
||||
onDelete,
|
||||
onMove,
|
||||
isSubscriptionCalendar,
|
||||
};
|
||||
|
||||
@@ -284,6 +289,7 @@ function Section({
|
||||
onShare,
|
||||
onClear,
|
||||
onDelete,
|
||||
onMove,
|
||||
isSubscriptionCalendar,
|
||||
}: {
|
||||
title: string;
|
||||
@@ -299,11 +305,14 @@ function Section({
|
||||
onShare?: (calendar: Calendar) => void;
|
||||
onClear?: (calendar: Calendar) => void;
|
||||
onDelete?: (calendar: Calendar) => void;
|
||||
onMove?: (calendar: Calendar, targetId: string) => void;
|
||||
isSubscriptionCalendar?: (calendarId: string) => boolean;
|
||||
}) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
// Neighbours a calendar can move past: the server calendars of this section.
|
||||
const orderable = calendars.filter((cal) => cal.id !== BIRTHDAY_CALENDAR_ID);
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
@@ -320,7 +329,11 @@ function Section({
|
||||
const canClear = !!onClear && isOwn && isWritableCalendar(cal);
|
||||
// Like webmail: the default calendar and shared calendars can't be deleted.
|
||||
const canDelete = !!onDelete && isOwn && !cal.isDefault && (!cal.myRights || cal.myRights.mayDelete !== false);
|
||||
const hasActions = canSetDefault || canRecolor || canRename || canShare || canClear || canDelete;
|
||||
const orderIndex = orderable.indexOf(cal);
|
||||
const previousId = isOwn && orderIndex > 0 ? orderable[orderIndex - 1].id : undefined;
|
||||
const nextId = isOwn && orderIndex >= 0 ? orderable[orderIndex + 1]?.id : undefined;
|
||||
const canMove = !!onMove && (!!previousId || !!nextId);
|
||||
const hasActions = canSetDefault || canRecolor || canRename || canShare || canClear || canDelete || canMove;
|
||||
const expanded = expandedId === cal.id && hasActions;
|
||||
return (
|
||||
<View key={cal.id}>
|
||||
@@ -355,6 +368,27 @@ function Section({
|
||||
|
||||
{expanded && (
|
||||
<View style={styles.actionsPanel}>
|
||||
{/* The panel stays open so a calendar can be moved several places. */}
|
||||
{canMove && previousId && (
|
||||
<Pressable
|
||||
onPress={() => onMove!(cal, previousId)}
|
||||
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<ArrowUp size={16} color={c.textSecondary} />
|
||||
<Text style={styles.panelRowText}>{t('settings.account.accounts.move_up', 'Move up')}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{canMove && nextId && (
|
||||
<Pressable
|
||||
onPress={() => onMove!(cal, nextId)}
|
||||
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<ArrowDown size={16} color={c.textSecondary} />
|
||||
<Text style={styles.panelRowText}>{t('settings.account.accounts.move_down', 'Move down')}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{canSetDefault && (
|
||||
<Pressable
|
||||
onPress={() => { onExpand(null); onSetDefault!(cal); }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { Clock, MapPin, Users } from 'lucide-react-native';
|
||||
import { Clock, MapPin, Users } from '../icons';
|
||||
import { format } from 'date-fns';
|
||||
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
Users,
|
||||
Video,
|
||||
X,
|
||||
} from 'lucide-react-native';
|
||||
} from '../icons';
|
||||
import { format, type Locale } from 'date-fns';
|
||||
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
TextInput,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Platform,
|
||||
Alert as NativeAlert,
|
||||
} from 'react-native';
|
||||
@@ -25,7 +24,7 @@ import {
|
||||
Users,
|
||||
Trash2,
|
||||
AlignLeft,
|
||||
} from 'lucide-react-native';
|
||||
} from '../icons';
|
||||
import { addHours, addMinutes, format } from 'date-fns';
|
||||
import type {
|
||||
Alert,
|
||||
@@ -70,6 +69,7 @@ import { ParticipantInput } from './ParticipantInput';
|
||||
import { useContactNameResolver } from '../../lib/contact-name-resolver';
|
||||
import { RecurrenceEditor } from './RecurrenceEditor';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
import ToggleSwitch from '../ToggleSwitch';
|
||||
|
||||
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
|
||||
|
||||
@@ -459,7 +459,7 @@ export function EventModal({
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
placeholder={t('calendar.form.title', 'Title')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
placeholderTextColor={c.mutedForeground}
|
||||
style={styles.titleInput}
|
||||
/>
|
||||
|
||||
@@ -515,12 +515,10 @@ export function EventModal({
|
||||
<Section icon={<Clock size={16} color={c.textMuted} />} label={t('calendar.form.time', 'Time')}>
|
||||
<View style={styles.allDayRow}>
|
||||
<Text style={styles.fieldText}>{t('calendar.form.all_day_event', 'All-day event')}</Text>
|
||||
<Switch
|
||||
<ToggleSwitch
|
||||
value={allDay}
|
||||
onValueChange={setAllDay}
|
||||
accessibilityLabel={t('calendar.form.all_day_event', 'All-day event')}
|
||||
thumbColor={allDay ? c.primary : c.textMuted}
|
||||
trackColor={{ false: c.surface, true: c.primaryBg }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -673,7 +671,7 @@ export function EventModal({
|
||||
keyboardType="number-pad"
|
||||
style={styles.customAmountInput}
|
||||
placeholder="30"
|
||||
placeholderTextColor={c.textMuted}
|
||||
placeholderTextColor={c.mutedForeground}
|
||||
/>
|
||||
<View style={styles.customUnits}>
|
||||
{(['minutes', 'hours', 'days', 'weeks'] as ReminderUnit[]).map((unit) => (
|
||||
@@ -708,7 +706,7 @@ export function EventModal({
|
||||
value={location}
|
||||
onChangeText={setLocation}
|
||||
placeholder={t('calendar.form.location_placeholder', 'Place or address')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
placeholderTextColor={c.mutedForeground}
|
||||
style={styles.fieldButton}
|
||||
/>
|
||||
</Section>
|
||||
@@ -718,7 +716,7 @@ export function EventModal({
|
||||
value={videoUrl}
|
||||
onChangeText={setVideoUrl}
|
||||
placeholder="https://meet.example.com/…"
|
||||
placeholderTextColor={c.textMuted}
|
||||
placeholderTextColor={c.mutedForeground}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
@@ -734,7 +732,7 @@ export function EventModal({
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder={t('calendar.form.description_placeholder', 'Notes, agenda…')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
placeholderTextColor={c.mutedForeground}
|
||||
multiline
|
||||
style={styles.descriptionInput}
|
||||
/>
|
||||
@@ -756,12 +754,10 @@ export function EventModal({
|
||||
<Text style={[styles.fieldText, { flex: 1 }]}>
|
||||
{t('calendar.participants.send_invitations', 'Send invitations to participants')}
|
||||
</Text>
|
||||
<Switch
|
||||
<ToggleSwitch
|
||||
value={sendInvitations}
|
||||
onValueChange={setSendInvitations}
|
||||
accessibilityLabel={t('calendar.participants.send_invitations', 'Send invitations to participants')}
|
||||
thumbColor={sendInvitations ? c.primary : c.textMuted}
|
||||
trackColor={{ false: c.surface, true: c.primaryBg }}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
@@ -913,7 +909,7 @@ function makeStyles(c: ThemePalette) {
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.surface,
|
||||
backgroundColor: c.background,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
fieldText: { ...typography.body, color: c.text },
|
||||
@@ -930,7 +926,7 @@ function makeStyles(c: ThemePalette) {
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.surface,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
reminderRemove: { padding: 2 },
|
||||
customReminderRow: {
|
||||
@@ -939,7 +935,7 @@ function makeStyles(c: ThemePalette) {
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.surface,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
customAmountInput: {
|
||||
...typography.body,
|
||||
@@ -975,7 +971,7 @@ function makeStyles(c: ThemePalette) {
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.surface,
|
||||
backgroundColor: c.popover,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
popoverRow: {
|
||||
@@ -997,7 +993,7 @@ function makeStyles(c: ThemePalette) {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.surface,
|
||||
backgroundColor: c.background,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
},
|
||||
@@ -1009,7 +1005,7 @@ function makeStyles(c: ThemePalette) {
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.surface,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
dateLabel: { ...typography.body, color: c.textSecondary, flex: 1 },
|
||||
datePickers: { flexDirection: 'row', gap: spacing.sm },
|
||||
@@ -1030,7 +1026,7 @@ function makeStyles(c: ThemePalette) {
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.surface,
|
||||
backgroundColor: c.background,
|
||||
color: c.text,
|
||||
...typography.body,
|
||||
textAlignVertical: 'top',
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, Alert, ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Upload, X, Check, CalendarPlus, AlertTriangle, Clock } from 'lucide-react-native';
|
||||
import { Upload, X, Check, CalendarPlus, AlertTriangle, Clock } from '../icons';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||
import { uploadBlob } from '../../api/blob';
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput, ActivityIndicator, Alert,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { X, Plus, RefreshCw, Trash2, Rss, AlertTriangle } from 'lucide-react-native';
|
||||
import { X, Plus, RefreshCw, Trash2, Rss, AlertTriangle } from '../icons';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import Button from '../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
TextInput,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { X } from 'lucide-react-native';
|
||||
import { X } from '../icons';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useContactsStore } from '../../stores/contacts-store';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TextInput, Pressable, StyleSheet, Platform } from 'react-native';
|
||||
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||
import { ChevronDown } from 'lucide-react-native';
|
||||
import { ChevronDown } from '../icons';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import type { RecurrenceRule } from '../../api/types';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Modal, View, Text, Pressable, StyleSheet } from 'react-native';
|
||||
import { Repeat, Trash2 } from 'lucide-react-native';
|
||||
import { Repeat, Trash2 } from '../icons';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
Plus,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react-native';
|
||||
} from '../icons';
|
||||
import { format, isBefore, startOfDay } from 'date-fns';
|
||||
import type { Calendar, CalendarEvent } from '../../api/types';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
@@ -530,7 +530,7 @@ function makeStyles(c: ThemePalette) {
|
||||
borderRadius: radius.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: c.surface,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
addBtn: {
|
||||
width: 38,
|
||||
@@ -552,7 +552,7 @@ function makeStyles(c: ThemePalette) {
|
||||
borderRadius: radius.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: c.surface,
|
||||
backgroundColor: c.background,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
editorRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flexWrap: 'wrap' },
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput, Alert,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { BookUser, X, Check, Plus } from 'lucide-react-native';
|
||||
import { BookUser, X, Check, Plus } from '../icons';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { Mail as MailIcon, CalendarDays } from 'lucide-react-native';
|
||||
import { Mail as MailIcon, CalendarDays } from '../icons';
|
||||
import { format, isAfter, parseISO, type Locale } from 'date-fns';
|
||||
import type { ContactCard, Email, CalendarEvent } from '../../api/types';
|
||||
import type { RootStackParamList } from '../../navigation/types';
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import { Upload, X, Check, FileText, AlertTriangle, BookUser } from 'lucide-react-native';
|
||||
import { Upload, X, Check, FileText, AlertTriangle, BookUser } from '../icons';
|
||||
import type { ContactCard } from '../../api/types';
|
||||
import { parseVCard, detectDuplicates } from '../../lib/vcard';
|
||||
import { getContactDisplayName, getContactPrimaryEmail } from '../../lib/contact-utils';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, Image } from 'react-native';
|
||||
import { Check, Users } from 'lucide-react-native';
|
||||
import { Check, Users } from '../icons';
|
||||
import type { ContactCard } from '../../api/types';
|
||||
import {
|
||||
getContactDisplayName,
|
||||
@@ -136,6 +136,6 @@ function makeStyles(c: ThemePalette) {
|
||||
info: { flex: 1, minWidth: 0 },
|
||||
name: { ...typography.bodyMedium, color: c.text },
|
||||
subtitle: { ...typography.caption, color: c.textMuted, marginTop: 1 },
|
||||
tertiary: { ...typography.small, color: c.textSecondary, marginTop: 1 },
|
||||
tertiary: { ...typography.caption, color: c.mutedForeground, marginTop: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
Animated, Dimensions, Easing,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Search, X, Check } from 'lucide-react-native';
|
||||
import { Search, X, Check } from '../icons';
|
||||
import type { ContactCard } from '../../api/types';
|
||||
import {
|
||||
useContactsStore,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import {
|
||||
X, Users, Tag, BookUser, Inbox, ChevronDown, ChevronRight, Plus, Share2, Check,
|
||||
} from 'lucide-react-native';
|
||||
} from '../icons';
|
||||
import type { RootStackParamList } from '../../navigation/types';
|
||||
import type { AddressBook } from '../../api/types';
|
||||
import type { ContactCategory } from '../../stores/contacts-store';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { Plus, X } from 'lucide-react-native';
|
||||
import { Plus, X } from '../icons';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Tag, X, Plus } from 'lucide-react-native';
|
||||
import { Tag, X, Plus } from '../icons';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, Modal, Animated, Easing } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { X } from 'lucide-react-native';
|
||||
import { X } from '../icons';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSheetDrag } from '../../lib/use-sheet-drag';
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Alert } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { Copy, PenSquare, UserRound, UserRoundPlus } from 'lucide-react-native';
|
||||
import { Copy, PenSquare, UserRound, UserRoundPlus } from '../icons';
|
||||
import type { EmailAddress } from '../../api/types';
|
||||
import type { RootStackParamList } from '../../navigation/types';
|
||||
import { useContactsStore } from '../../stores/contacts-store';
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, ActivityIndicator, Alert } from 'react-native';
|
||||
import { Paperclip, Download, Eye, ExternalLink, Save, Share2, FolderArchive } from 'lucide-react-native';
|
||||
import type { File } from 'expo-file-system';
|
||||
import { Paperclip, Download, Eye, ExternalLink, Save, Share2, FolderArchive } from '../icons';
|
||||
import type { Attachment, Email } from '../../api/types';
|
||||
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 {
|
||||
shareAttachment, downloadAttachment, shareAttachmentViaSheet, fetchBlobBytes,
|
||||
shareBytes, shareLocalFile, cachePreviewFile, writePreviewFile, discardPreviewFile,
|
||||
shareAttachment, downloadAttachment, shareAttachmentViaSheet, fetchBlobBytes, shareBytes, attachmentSaveName,
|
||||
} from '../../lib/email-export';
|
||||
import {
|
||||
getAttachmentDisplayName, visibleAttachments, previewKindFor, formatSize,
|
||||
getAttachmentDisplayName, visibleAttachments, formatSize,
|
||||
} from '../../lib/attachment-display';
|
||||
import { emailExportFilename } from '../../lib/download-filename';
|
||||
import { ActionSheet, type ActionSheetItem } from './ActionSheet';
|
||||
import { AttachmentPreviewModal, type PreviewItem } from './AttachmentPreviewModal';
|
||||
import { emlPreviewFromBytes, type ExtractedAttachment } from './use-body-override';
|
||||
import { AttachmentPreviewModal } from './AttachmentPreviewModal';
|
||||
import type { ExtractedAttachment } from './use-body-override';
|
||||
import { previewKindOf, useFilePreview, type PreviewSource } from './use-file-preview';
|
||||
|
||||
interface Props {
|
||||
email: Email;
|
||||
@@ -65,24 +64,7 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
const [busyKey, setBusyKey] = React.useState<string | null>(null);
|
||||
const [sheetItem, setSheetItem] = React.useState<Item | null>(null);
|
||||
const [preview, setPreview] = React.useState<PreviewItem | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = React.useState(false);
|
||||
const previewSource = React.useRef<Item | null>(null);
|
||||
// The file the open preview shows, in a temp folder of its own: images are
|
||||
// cached by path, so two attachments both called "image001.png" must not
|
||||
// share one. Deleted when the preview closes, unless it went to another
|
||||
// app, which may still be reading it; the stale-export sweep gets that one.
|
||||
const previewFile = React.useRef<{ file: File; handedOff: boolean } | null>(null);
|
||||
// Bumped whenever a preview ends, so a download that finishes after its
|
||||
// preview was closed is dropped instead of reopening it.
|
||||
const previewSeq = React.useRef(0);
|
||||
const endPreview = React.useCallback(() => {
|
||||
previewSeq.current += 1;
|
||||
const open = previewFile.current;
|
||||
previewFile.current = null;
|
||||
if (open && !open.handedOff) discardPreviewFile(open.file);
|
||||
}, []);
|
||||
React.useEffect(() => endPreview, [endPreview]);
|
||||
const preview = useFilePreview();
|
||||
|
||||
const items = React.useMemo<Item[]>(() => {
|
||||
const blobs = visibleAttachments(email, { hideInlineImageAttachments, calendarBannerShown, tnefUnpacked })
|
||||
@@ -134,49 +116,29 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
|
||||
else await shareBytes(item.part.bytes, item.name, item.type, { forceSheet: true });
|
||||
}, t('email_viewer.attachment_failed', 'Could not share attachment'));
|
||||
|
||||
const showPreview = (item: Item) => {
|
||||
const kind = previewKindFor({ name: item.name, type: item.type });
|
||||
if (kind === 'none') { void open(item); return; }
|
||||
previewSource.current = item;
|
||||
endPreview();
|
||||
const seq = previewSeq.current;
|
||||
const stillOpen = () => seq === previewSeq.current;
|
||||
setPreviewLoading(true);
|
||||
setPreview(null);
|
||||
void (async () => {
|
||||
try {
|
||||
if (kind === 'text' || kind === 'eml') {
|
||||
const bytes = item.kind === 'blob'
|
||||
? await fetchBlobBytes(item.att.blobId, item.att.name, item.att.type, jmapAccountId)
|
||||
: item.part.bytes;
|
||||
const next: PreviewItem = kind === 'eml'
|
||||
? { kind, name: item.name, mimeType: item.type, eml: await emlPreviewFromBytes(bytes) }
|
||||
: { kind, name: item.name, mimeType: item.type, text: new TextDecoder('utf-8').decode(bytes) };
|
||||
if (stillOpen()) setPreview(next);
|
||||
return;
|
||||
}
|
||||
const file = item.kind === 'blob'
|
||||
? await cachePreviewFile(item.att.blobId, item.name, item.type, jmapAccountId)
|
||||
: writePreviewFile(item.part.bytes, item.name, item.type);
|
||||
if (!stillOpen()) {
|
||||
discardPreviewFile(file);
|
||||
return;
|
||||
}
|
||||
previewFile.current = { file, handedOff: false };
|
||||
setPreview({ kind, name: item.name, mimeType: item.type, fileUri: file.uri });
|
||||
} catch (e) {
|
||||
if (!stillOpen()) return;
|
||||
setPreview(null);
|
||||
Alert.alert(t('email_viewer.attachment_failed', 'Could not open attachment'), e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
if (stillOpen()) setPreviewLoading(false);
|
||||
}
|
||||
})();
|
||||
};
|
||||
const sourceOf = (item: Item): PreviewSource => (item.kind === 'blob'
|
||||
? {
|
||||
from: 'blob',
|
||||
blobId: item.att.blobId,
|
||||
accountId: jmapAccountId,
|
||||
email,
|
||||
name: item.name,
|
||||
type: item.type,
|
||||
size: item.size,
|
||||
saveName: attachmentSaveName(email, item.att.name, item.att.type),
|
||||
}
|
||||
: { from: 'bytes', bytes: item.part.bytes, name: item.name, type: item.type, size: item.size });
|
||||
|
||||
// The in-app preview where the type has one, else straight to another app.
|
||||
const showPreview = (item: Item) => run(
|
||||
item.key,
|
||||
() => preview.open(sourceOf(item)),
|
||||
t('email_viewer.attachment_failed', 'Could not open attachment'),
|
||||
);
|
||||
|
||||
const onTap = (item: Item) => {
|
||||
if (mailAttachmentAction === 'download') void save(item);
|
||||
else showPreview(item);
|
||||
else void showPreview(item);
|
||||
};
|
||||
|
||||
const downloadAll = () => run('__all__', async () => {
|
||||
@@ -204,11 +166,11 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
|
||||
|
||||
const visible = expanded ? items : items.slice(0, 3);
|
||||
const sheetActions: ActionSheetItem[] = sheetItem ? [
|
||||
...(previewKindFor({ name: sheetItem.name, type: sheetItem.type }) !== 'none' ? [{
|
||||
...(previewKindOf(sheetItem) !== 'none' ? [{
|
||||
key: 'preview',
|
||||
label: t('email_viewer.attachment_actions.preview', 'Preview'),
|
||||
icon: <Eye size={18} color={c.textSecondary} />,
|
||||
onPress: () => { const it = sheetItem; setSheetItem(null); showPreview(it); },
|
||||
onPress: () => { const it = sheetItem; setSheetItem(null); void showPreview(it); },
|
||||
}] : []),
|
||||
{
|
||||
key: 'open',
|
||||
@@ -278,36 +240,7 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
|
||||
onClose={() => setSheetItem(null)}
|
||||
/>
|
||||
|
||||
<AttachmentPreviewModal
|
||||
item={preview}
|
||||
loading={previewLoading}
|
||||
onClose={() => {
|
||||
endPreview();
|
||||
setPreview(null);
|
||||
setPreviewLoading(false);
|
||||
}}
|
||||
onOpenExternal={() => {
|
||||
const it = previewSource.current;
|
||||
if (!it) return;
|
||||
if (preview?.fileUri && it.kind === 'blob') {
|
||||
void run(it.key, () => shareAttachment(it.att.blobId, it.att.name, it.att.type, email, jmapAccountId), t('email_viewer.attachment_failed', 'Could not open attachment'));
|
||||
} else {
|
||||
void open(it);
|
||||
}
|
||||
}}
|
||||
onShare={() => {
|
||||
const it = previewSource.current;
|
||||
if (!it) return;
|
||||
const open = previewFile.current;
|
||||
if (open) {
|
||||
// The receiving app may keep reading it after the sheet closes.
|
||||
open.handedOff = true;
|
||||
void shareLocalFile(open.file, it.type, it.name, { forceSheet: true, keep: true }).catch(() => undefined);
|
||||
} else {
|
||||
void share(it);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<AttachmentPreviewModal {...preview.modalProps} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { WebView } from 'react-native-webview';
|
||||
import { X, ExternalLink, Share2, Download } from 'lucide-react-native';
|
||||
import { X, ExternalLink, Share2, Download } from '../icons';
|
||||
import type { Email } from '../../api/types';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { openExternalUrl } from '../../lib/open-url';
|
||||
import {
|
||||
CalendarPlus, Check, HelpCircle, X, MapPin, Video, Clock, CalendarDays, AlertTriangle,
|
||||
ShieldCheck, ShieldAlert, ChevronDown,
|
||||
} from 'lucide-react-native';
|
||||
} from '../icons';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import type { Calendar, Email, CalendarEvent } from '../../api/types';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { File as FileIcon, FileArchive, FileImage, FileSpreadsheet, FileText } from 'lucide-react-native';
|
||||
import { File as FileIcon, FileArchive, FileImage, FileSpreadsheet, FileText } from '../icons';
|
||||
import type { Attachment, Email, Mailbox } from '../../api/types';
|
||||
import { getEmailAttachments } from '../../api/email';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
@@ -15,9 +15,10 @@ import {
|
||||
attachmentKind, realAttachments, requestListAttachments, shortAttachmentName,
|
||||
type AttachmentKind, type LoadListAttachments,
|
||||
} from '../../lib/list-attachments';
|
||||
import { getAttachmentDisplayName, previewKindFor } from '../../lib/attachment-display';
|
||||
import { cacheBlobFile, downloadAttachment, fetchBlobBytes, shareAttachment, shareLocalFile } from '../../lib/email-export';
|
||||
import { AttachmentPreviewModal, type PreviewItem } from './AttachmentPreviewModal';
|
||||
import { getAttachmentDisplayName } from '../../lib/attachment-display';
|
||||
import { attachmentSaveName, downloadAttachment } from '../../lib/email-export';
|
||||
import { AttachmentPreviewModal } from './AttachmentPreviewModal';
|
||||
import { useFilePreview } from './use-file-preview';
|
||||
|
||||
/** How many chips a row shows before collapsing the rest into a count. */
|
||||
const MAX_CHIPS = 2;
|
||||
@@ -124,26 +125,16 @@ export interface ListAttachmentOpenerHandle {
|
||||
open: (email: Email, attachment: Attachment, accountId?: string) => void;
|
||||
}
|
||||
|
||||
interface OpenTarget {
|
||||
email: Email;
|
||||
attachment: Attachment;
|
||||
accountId?: string;
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a list-row attachment the way the reader's chips do: the "attachment
|
||||
* click action" setting either saves it or previews it in-app (images, PDFs,
|
||||
* text), and anything that can't be previewed goes to the platform viewer.
|
||||
* Holds the preview modal, so the list itself never re-renders for it.
|
||||
* click action" setting either saves it or previews it in-app, and anything
|
||||
* that can't be previewed goes to another app. Holds the preview modal, so
|
||||
* the list itself never re-renders for it.
|
||||
*/
|
||||
export const ListAttachmentOpener = React.forwardRef<ListAttachmentOpenerHandle>(
|
||||
function ListAttachmentOpener(_props, ref) {
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const [preview, setPreview] = React.useState<PreviewItem | null>(null);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const target = React.useRef<OpenTarget | null>(null);
|
||||
const preview = useFilePreview();
|
||||
const busy = React.useRef(false);
|
||||
|
||||
const run = React.useCallback(async (fn: () => Promise<void>) => {
|
||||
@@ -161,59 +152,27 @@ export const ListAttachmentOpener = React.forwardRef<ListAttachmentOpenerHandle>
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const openExternal = React.useCallback((it: OpenTarget) => run(
|
||||
() => shareAttachment(it.attachment.blobId, it.attachment.name, it.attachment.type, it.email, it.accountId),
|
||||
), [run]);
|
||||
|
||||
const { open: openPreview } = preview;
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
open: (email, attachment, accountId) => {
|
||||
if (busy.current) return;
|
||||
const name = getAttachmentDisplayName(attachment.name, attachment.type);
|
||||
const type = attachment.type || 'application/octet-stream';
|
||||
const it: OpenTarget = { email, attachment, accountId, name, type };
|
||||
target.current = it;
|
||||
|
||||
if (useSettingsStore.getState().mailAttachmentAction === 'download') {
|
||||
void run(() => downloadAttachment(attachment.blobId, attachment.name, attachment.type, email, accountId));
|
||||
return;
|
||||
}
|
||||
const kind = previewKindFor({ name, type });
|
||||
if (kind !== 'image' && kind !== 'pdf' && kind !== 'text') {
|
||||
void openExternal(it);
|
||||
return;
|
||||
}
|
||||
setPreview(null);
|
||||
setLoading(true);
|
||||
void run(async () => {
|
||||
try {
|
||||
if (kind === 'text') {
|
||||
const bytes = await fetchBlobBytes(attachment.blobId, attachment.name, attachment.type, accountId);
|
||||
setPreview({ kind, name, mimeType: type, text: new TextDecoder('utf-8').decode(bytes) });
|
||||
} else {
|
||||
const file = await cacheBlobFile(attachment.blobId, name, type, accountId);
|
||||
setPreview({ kind, name, mimeType: type, fileUri: file.uri });
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
void run(() => openPreview({
|
||||
from: 'blob',
|
||||
blobId: attachment.blobId,
|
||||
accountId,
|
||||
email,
|
||||
name: getAttachmentDisplayName(attachment.name, attachment.type),
|
||||
type: attachment.type || 'application/octet-stream',
|
||||
size: attachment.size,
|
||||
saveName: attachmentSaveName(email, attachment.name, attachment.type),
|
||||
}));
|
||||
},
|
||||
}), [run, openExternal]);
|
||||
}), [run, openPreview]);
|
||||
|
||||
return (
|
||||
<AttachmentPreviewModal
|
||||
item={preview}
|
||||
loading={loading}
|
||||
onClose={() => { setPreview(null); setLoading(false); }}
|
||||
onOpenExternal={() => { if (target.current) void openExternal(target.current); }}
|
||||
onShare={() => {
|
||||
const it = target.current;
|
||||
if (!it || !preview?.fileUri) return;
|
||||
const { File } = require('expo-file-system') as typeof import('expo-file-system');
|
||||
void shareLocalFile(new File(preview.fileUri), it.type, it.name, { forceSheet: true }).catch(() => undefined);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return <AttachmentPreviewModal {...preview.modalProps} />;
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import {
|
||||
Star, ChevronDown, ChevronUp, Reply, Forward, ShieldCheck, ShieldAlert, ShieldQuestion, Lock,
|
||||
} from 'lucide-react-native';
|
||||
} from '../icons';
|
||||
import type { Email, EmailAddress, Identity } from '../../api/types';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TextInput, Pressable, ActivityIndicator, Alert, Keyboard } from 'react-native';
|
||||
import { Send, Maximize2 } from 'lucide-react-native';
|
||||
import { Send, Maximize2 } from '../icons';
|
||||
import type { Email } from '../../api/types';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
@@ -155,7 +155,7 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent, onF
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
placeholder={t('email_viewer.quick_reply_placeholder', 'Write a quick reply...')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
placeholderTextColor={c.mutedForeground}
|
||||
style={styles.input}
|
||||
multiline
|
||||
editable={!sending}
|
||||
@@ -189,7 +189,7 @@ function makeStyles(c: ThemePalette) {
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: c.surface,
|
||||
backgroundColor: c.background,
|
||||
padding: spacing.sm,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
@@ -211,10 +211,10 @@ function makeStyles(c: ThemePalette) {
|
||||
gap: spacing.xs,
|
||||
backgroundColor: c.primary,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 6,
|
||||
borderRadius: radius.full,
|
||||
minHeight: 36,
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
sendDisabled: { opacity: 0.5 },
|
||||
sendText: { ...typography.caption, color: c.primaryForeground, fontWeight: '600' },
|
||||
sendText: { ...typography.bodyMedium, color: c.primaryForeground },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, ActivityIndicator, Alert } from 'react-native';
|
||||
import { MailCheck } from 'lucide-react-native';
|
||||
import { MailCheck } from '../icons';
|
||||
import type { Email } from '../../api/types';
|
||||
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { Paperclip, Reply, ReplyAll, Forward, Star } from 'lucide-react-native';
|
||||
import { Paperclip, Reply, ReplyAll, Forward, Star } from '../icons';
|
||||
import type { Email } from '../../api/types';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
@@ -74,16 +74,16 @@ export function ThreadMessageCard({
|
||||
<MessageContent {...content} compact={false} onToggleStar={bare ? undefined : onToggleStar} />
|
||||
{!bare && (
|
||||
<View style={[styles.actions, !replyable && styles.actionsDisabled]}>
|
||||
<Pressable style={styles.actionBtn} onPress={reply('reply')} hitSlop={9}>
|
||||
<Reply size={16} color={c.textSecondary} />
|
||||
<Pressable style={styles.actionBtn} onPress={reply('reply')} hitSlop={6}>
|
||||
<Reply size={16} color={c.text} />
|
||||
<Text style={styles.actionLabel}>{t('email_viewer.reply', 'Reply')}</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.actionBtn} onPress={reply('replyAll')} hitSlop={9}>
|
||||
<ReplyAll size={16} color={c.textSecondary} />
|
||||
<Pressable style={styles.actionBtn} onPress={reply('replyAll')} hitSlop={6}>
|
||||
<ReplyAll size={16} color={c.text} />
|
||||
<Text style={styles.actionLabel}>{t('email_viewer.reply_all', 'Reply All')}</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.actionBtn} onPress={reply('forward')} hitSlop={9}>
|
||||
<Forward size={16} color={c.textSecondary} />
|
||||
<Pressable style={styles.actionBtn} onPress={reply('forward')} hitSlop={6}>
|
||||
<Forward size={16} color={c.text} />
|
||||
<Text style={styles.actionLabel}>{t('email_viewer.forward', 'Forward')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -159,13 +159,14 @@ function makeStyles(c: ThemePalette) {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
// Webmail thread card: outline buttons, h-9, rounded-md.
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 6,
|
||||
borderRadius: radius.full,
|
||||
minHeight: 36,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.surface,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
actionLabel: { ...typography.caption, color: c.textSecondary },
|
||||
actionLabel: { ...typography.bodyMedium, color: c.text },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, ActivityIndicator, Alert } from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
import { MailMinus, CheckCircle2, AlertCircle, X } from 'lucide-react-native';
|
||||
import { MailMinus, CheckCircle2, AlertCircle, X } from '../icons';
|
||||
import type { Email } from '../../api/types';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import React from 'react';
|
||||
import { Alert, Platform } from 'react-native';
|
||||
import type { File } from 'expo-file-system';
|
||||
import type { Email } from '../../api/types';
|
||||
import {
|
||||
cachePreviewFile, copyPreviewFile, discardPreviewFile, saveLocalFileCopy, shareAttachment,
|
||||
shareBytes, shareLocalFile, writePreviewFile,
|
||||
} from '../../lib/email-export';
|
||||
import {
|
||||
exceedsPreviewLimit, inAppPreviewKind, type FilePreviewOptions,
|
||||
} from '../../lib/file-preview';
|
||||
import type { PreviewKind } from '../../lib/attachment-display';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import type { PreviewItem } from './AttachmentPreviewModal';
|
||||
import { emlPreviewFromBytes } from './use-body-override';
|
||||
|
||||
export const PREVIEW_OPTIONS: FilePreviewOptions = { inlinePdf: Platform.OS === 'ios' };
|
||||
|
||||
interface PreviewSourceBase {
|
||||
/** Display name, shown in the header and given to the cached copy. */
|
||||
name: string;
|
||||
type: string;
|
||||
size?: number | null;
|
||||
/** Name for "Download" (the attachment filename template); defaults to `name`. */
|
||||
saveName?: string;
|
||||
}
|
||||
|
||||
/** Where a previewed file comes from. */
|
||||
export type PreviewSource = PreviewSourceBase & (
|
||||
| { from: 'blob'; blobId: string; accountId?: string; email?: Email | null }
|
||||
| { from: 'bytes'; bytes: Uint8Array }
|
||||
| { from: 'local'; uri: string }
|
||||
);
|
||||
|
||||
/** The preview a tap on `source` opens, or 'none' when it goes to another app. */
|
||||
export function previewKindOf(source: Pick<PreviewSource, 'name' | 'type' | 'size'>): PreviewKind {
|
||||
return inAppPreviewKind(source, PREVIEW_OPTIONS);
|
||||
}
|
||||
|
||||
/** Copy the source into a preview folder of its own (see cachePreviewFile). */
|
||||
async function materialize(source: PreviewSource): Promise<File> {
|
||||
switch (source.from) {
|
||||
case 'blob':
|
||||
return cachePreviewFile(source.blobId, source.name, source.type, source.accountId);
|
||||
case 'bytes':
|
||||
return writePreviewFile(source.bytes, source.name, source.type);
|
||||
case 'local':
|
||||
return copyPreviewFile(source.uri, source.name, source.type);
|
||||
}
|
||||
}
|
||||
|
||||
/** Hand a file the app doesn't show to the app that owns its type ("Open with"). */
|
||||
function handOff(source: PreviewSource): Promise<void> {
|
||||
switch (source.from) {
|
||||
case 'blob':
|
||||
return shareAttachment(source.blobId, source.name, source.type, source.email, source.accountId);
|
||||
case 'bytes':
|
||||
return shareBytes(source.bytes, source.name, source.type);
|
||||
case 'local':
|
||||
// A copy, so the cleanup after the hand-off never removes the original.
|
||||
return shareLocalFile(copyPreviewFile(source.uri, source.name, source.type), source.type, source.name);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildItem(
|
||||
kind: Exclude<PreviewKind, 'none'>,
|
||||
source: PreviewSource,
|
||||
local: File,
|
||||
tooLarge: string,
|
||||
): Promise<PreviewItem> {
|
||||
const base = { name: source.name, mimeType: source.type };
|
||||
// The source may not know its size; the copy does.
|
||||
if (exceedsPreviewLimit(kind, local.size)) return { ...base, kind: 'none', notice: tooLarge };
|
||||
switch (kind) {
|
||||
case 'text':
|
||||
return { ...base, kind, text: await local.text() };
|
||||
case 'eml':
|
||||
return { ...base, kind, eml: await emlPreviewFromBytes(await local.bytes()) };
|
||||
default:
|
||||
return { ...base, kind, fileUri: local.uri };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One in-app preview: picks how a file is shown, downloads it once into a
|
||||
* temp folder of its own, and serves Open with, Share and Download from that
|
||||
* copy. Files the app can't show go straight to another app. The copy is
|
||||
* deleted when the preview closes or the next one opens, unless another app
|
||||
* got it, which may still be reading it; the stale-export sweep removes
|
||||
* those. Used by every place that opens a file: the reader's chips, the
|
||||
* list's chips, the Files tab and compose.
|
||||
*
|
||||
* Render `<AttachmentPreviewModal {...preview.modalProps} />` next to it.
|
||||
*/
|
||||
export function useFilePreview(opts: {
|
||||
/** Called when the preview closes: the close button, or a download that failed. */
|
||||
onClose?: () => void;
|
||||
} = {}) {
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const onCloseRef = React.useRef(opts.onClose);
|
||||
onCloseRef.current = opts.onClose;
|
||||
const [item, setItem] = React.useState<PreviewItem | null>(null);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [title, setTitle] = React.useState('');
|
||||
const source = React.useRef<PreviewSource | null>(null);
|
||||
const session = React.useRef<{ file: File; handedOff: boolean } | null>(null);
|
||||
// Bumped whenever a preview ends, so a download that finishes after its
|
||||
// preview was closed is dropped instead of reopening it.
|
||||
const seq = React.useRef(0);
|
||||
const busy = React.useRef(false);
|
||||
|
||||
const end = React.useCallback(() => {
|
||||
seq.current += 1;
|
||||
const open = session.current;
|
||||
session.current = null;
|
||||
if (open && !open.handedOff) discardPreviewFile(open.file);
|
||||
}, []);
|
||||
React.useEffect(() => end, [end]);
|
||||
|
||||
/** Close without telling the owner (it closed the preview itself). */
|
||||
const reset = React.useCallback(() => {
|
||||
end();
|
||||
source.current = null;
|
||||
setItem(null);
|
||||
setLoading(false);
|
||||
}, [end]);
|
||||
|
||||
const close = React.useCallback(() => {
|
||||
reset();
|
||||
onCloseRef.current?.();
|
||||
}, [reset]);
|
||||
|
||||
/**
|
||||
* Show `src` in the preview, or hand it to another app when the preview
|
||||
* can't show it. The promise settles once that hand-off is done (and
|
||||
* rejects when it fails); a preview resolves at once.
|
||||
*/
|
||||
const open = React.useCallback((src: PreviewSource): Promise<void> => {
|
||||
const kind = previewKindOf(src);
|
||||
if (kind === 'none') return handOff(src);
|
||||
end();
|
||||
source.current = src;
|
||||
const mine = seq.current;
|
||||
const current = () => mine === seq.current;
|
||||
setTitle(src.name);
|
||||
setItem(null);
|
||||
setLoading(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const local = await materialize(src);
|
||||
if (!current()) {
|
||||
discardPreviewFile(local);
|
||||
return;
|
||||
}
|
||||
session.current = { file: local, handedOff: false };
|
||||
const next = await buildItem(kind, src, local, t('files.preview_too_large', 'This file is too large to preview.'));
|
||||
if (current()) setItem(next);
|
||||
} catch (e) {
|
||||
if (!current()) return;
|
||||
close();
|
||||
Alert.alert(t('files.preview_error', 'Failed to load preview'), e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
if (current()) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return Promise.resolve();
|
||||
}, [end, close, t]);
|
||||
|
||||
// The preview's own buttons, all on the downloaded copy.
|
||||
const act = React.useCallback((run: (local: File, src: PreviewSource) => Promise<void>, handsOff: boolean, failTitle: string) => {
|
||||
const open = session.current;
|
||||
const src = source.current;
|
||||
if (!open || !src || busy.current) return;
|
||||
busy.current = true;
|
||||
// Another app may keep reading the file after the sheet or viewer returns.
|
||||
if (handsOff) open.handedOff = true;
|
||||
run(open.file, src)
|
||||
.catch((e) => Alert.alert(failTitle, e instanceof Error ? e.message : String(e)))
|
||||
.finally(() => { busy.current = false; });
|
||||
}, []);
|
||||
|
||||
const modalProps = React.useMemo(() => ({
|
||||
item,
|
||||
loading,
|
||||
title,
|
||||
onClose: close,
|
||||
onOpenExternal: () => act(
|
||||
(local, src) => shareLocalFile(local, src.type, src.name, { keep: true }),
|
||||
true,
|
||||
t('files.open_error', 'Could not open the file'),
|
||||
),
|
||||
onShare: () => act(
|
||||
(local, src) => shareLocalFile(local, src.type, src.name, { forceSheet: true, keep: true }),
|
||||
true,
|
||||
t('files.share_file_error', 'Could not share the file'),
|
||||
),
|
||||
onDownload: () => act(
|
||||
(local, src) => saveLocalFileCopy(local, src.type, src.saveName ?? src.name),
|
||||
false,
|
||||
t('files.download_error', 'Failed to download'),
|
||||
),
|
||||
}), [item, loading, title, close, act, t]);
|
||||
|
||||
return { open, close, reset, modalProps };
|
||||
}
|
||||
@@ -1,24 +1,13 @@
|
||||
import React from 'react';
|
||||
import { Alert, Platform } from 'react-native';
|
||||
import type { File } from 'expo-file-system';
|
||||
import type { FileNode } from '../../api/types';
|
||||
import {
|
||||
cachePreviewFile, discardPreviewFile, saveLocalFileCopy, shareLocalFile,
|
||||
} from '../../lib/email-export';
|
||||
import {
|
||||
exceedsPreviewLimit, inAppPreviewKind, type FilePreviewOptions,
|
||||
} from '../../lib/file-preview';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { AttachmentPreviewModal, type PreviewItem } from '../email/AttachmentPreviewModal';
|
||||
import { emlPreviewFromBytes } from '../email/use-body-override';
|
||||
|
||||
const PREVIEW_OPTIONS: FilePreviewOptions = { inlinePdf: Platform.OS === 'ios' };
|
||||
import { AttachmentPreviewModal } from '../email/AttachmentPreviewModal';
|
||||
import { previewKindOf, useFilePreview } from '../email/use-file-preview';
|
||||
|
||||
export type PreviewableFileNode = Pick<FileNode, 'name' | 'type' | 'size' | 'blobId' | 'accountId'>;
|
||||
|
||||
/** Whether a tap on this file opens the in-app preview (else the OS chooser, as before). */
|
||||
export function canPreviewInApp(node: PreviewableFileNode): boolean {
|
||||
return !!node.blobId && inAppPreviewKind(node, PREVIEW_OPTIONS) !== 'none';
|
||||
return !!node.blobId && previewKindOf({ name: node.name, type: node.type || '', size: node.size }) !== 'none';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -30,22 +19,13 @@ interface Props {
|
||||
}
|
||||
|
||||
/**
|
||||
* The Files tab's in-app preview: the reader's attachment preview fed from a
|
||||
* FileNode. The file is downloaded once, with the same authenticated
|
||||
* download as "Download" (token refresh, client certificate), into its own
|
||||
* temp folder; Open with, Share and Download then all use that copy instead
|
||||
* of fetching it again. The copy is deleted when the preview closes, unless
|
||||
* another app got it, in which case the stale-export sweep removes it.
|
||||
* The Files tab's in-app preview: the shared file preview fed from a
|
||||
* FileNode, downloaded with the same authenticated download as "Download"
|
||||
* (token refresh, client certificate).
|
||||
*/
|
||||
export function FilePreviewModal({ file, onClose, docked = false }: Props) {
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const [item, setItem] = React.useState<PreviewItem | null>(null);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
// The downloaded copy of the open preview.
|
||||
const session = React.useRef<{ file: File; handedOff: boolean } | null>(null);
|
||||
const busy = React.useRef(false);
|
||||
const onCloseRef = React.useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
const preview = useFilePreview({ onClose });
|
||||
const { open, reset } = preview;
|
||||
|
||||
const blobId = file?.blobId ?? null;
|
||||
const accountId = file?.accountId;
|
||||
@@ -55,93 +35,9 @@ export function FilePreviewModal({ file, onClose, docked = false }: Props) {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!blobId) return undefined;
|
||||
let cancelled = false;
|
||||
setItem(null);
|
||||
setLoading(true);
|
||||
void open({ from: 'blob', blobId, accountId, name, type, size });
|
||||
return reset;
|
||||
}, [blobId, accountId, name, type, size, open, reset]);
|
||||
|
||||
const build = async (local: File): Promise<PreviewItem> => {
|
||||
const kind = inAppPreviewKind({ name, type, size }, PREVIEW_OPTIONS);
|
||||
// The node may not report a size; the download does.
|
||||
if (kind === 'none' || exceedsPreviewLimit(kind, local.size)) {
|
||||
return {
|
||||
kind: 'none',
|
||||
name,
|
||||
mimeType: type,
|
||||
notice: kind === 'none' ? undefined : t('files.preview_too_large', 'This file is too large to preview.'),
|
||||
};
|
||||
}
|
||||
switch (kind) {
|
||||
case 'text':
|
||||
return { kind, name, mimeType: type, text: await local.text() };
|
||||
case 'eml':
|
||||
return { kind, name, mimeType: type, eml: await emlPreviewFromBytes(await local.bytes()) };
|
||||
default:
|
||||
return { kind, name, mimeType: type, fileUri: local.uri };
|
||||
}
|
||||
};
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const local = await cachePreviewFile(blobId, name, type, accountId);
|
||||
if (cancelled) {
|
||||
discardPreviewFile(local);
|
||||
return;
|
||||
}
|
||||
session.current = { file: local, handedOff: false };
|
||||
const next = await build(local);
|
||||
if (!cancelled) setItem(next);
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
Alert.alert(t('files.preview_error', 'Failed to load preview'), e instanceof Error ? e.message : String(e));
|
||||
onCloseRef.current();
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
const open = session.current;
|
||||
session.current = null;
|
||||
if (open && !open.handedOff) discardPreviewFile(open.file);
|
||||
setItem(null);
|
||||
setLoading(false);
|
||||
};
|
||||
}, [blobId, accountId, name, type, size, t]);
|
||||
|
||||
const act = (run: (local: File) => Promise<void>, handsOff: boolean, failTitle: string) => {
|
||||
const open = session.current;
|
||||
if (!open || busy.current) return;
|
||||
busy.current = true;
|
||||
// Another app may keep reading the file after the sheet or viewer returns.
|
||||
if (handsOff) open.handedOff = true;
|
||||
run(open.file)
|
||||
.catch((e) => Alert.alert(failTitle, e instanceof Error ? e.message : String(e)))
|
||||
.finally(() => { busy.current = false; });
|
||||
};
|
||||
|
||||
return (
|
||||
<AttachmentPreviewModal
|
||||
item={item}
|
||||
loading={loading}
|
||||
title={name}
|
||||
docked={docked}
|
||||
onClose={onClose}
|
||||
onOpenExternal={() => act(
|
||||
(local) => shareLocalFile(local, type, name, { keep: true }),
|
||||
true,
|
||||
t('files.open_error', 'Could not open the file'),
|
||||
)}
|
||||
onShare={() => act(
|
||||
(local) => shareLocalFile(local, type, name, { forceSheet: true, keep: true }),
|
||||
true,
|
||||
t('files.share_file_error', 'Could not share the file'),
|
||||
)}
|
||||
onDownload={() => act(
|
||||
(local) => saveLocalFileCopy(local, type),
|
||||
false,
|
||||
t('files.download_error', 'Failed to download'),
|
||||
)}
|
||||
/>
|
||||
);
|
||||
return <AttachmentPreviewModal {...preview.modalProps} docked={docked} />;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { Folder, FileText, Trash2, UserPlus, Users } from 'lucide-react-native';
|
||||
import { Folder, FileText, Trash2, UserPlus, Users } from '../icons';
|
||||
|
||||
import { getPrincipals, isFolder, ownPrincipalId, setFileNodeShare } from '../../api/files';
|
||||
import type { FileNode, FileNodeRights, Principal } from '../../api/types';
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
KeyboardAvoidingView, Platform,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { X, Plus, Trash2 } from 'lucide-react-native';
|
||||
import { X, Plus, Trash2 } from '../icons';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { Select, ToggleSwitch } from '../settings/settings-section';
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
KeyboardAvoidingView, Platform, ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { X, AlertTriangle, CheckCircle } from 'lucide-react-native';
|
||||
import { X, AlertTriangle, CheckCircle } from '../icons';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import Button from '../Button';
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// The app's icons are Tabler glyphs under the names the screens import.
|
||||
// These tests make sure each name draws the glyph the webmail uses for it,
|
||||
// and that every icon a screen imports exists.
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
|
||||
vi.mock('react-native-svg', () => ({ default: 'Svg', Path: 'Path' }));
|
||||
|
||||
const ROOT = join(__dirname, '..', '..', '..', '..');
|
||||
const WEBMAIL_ICONS = join(ROOT, '..', '..', 'components', 'icons', 'index.ts');
|
||||
const TABLER = join(ROOT, '..', '..', 'node_modules', '@tabler', 'icons', 'tabler-nodes-outline.json');
|
||||
|
||||
function sourceFiles(dir: string, out: string[] = []): string[] {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const p = join(dir, entry);
|
||||
if (statSync(p).isDirectory()) {
|
||||
// The widgets draw their own SVG strings (src/widgets/icons.ts).
|
||||
if (entry !== '__tests__' && entry !== 'node_modules' && entry !== 'widgets') sourceFiles(p, out);
|
||||
} else if (/\.tsx?$/.test(entry)) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Path data the generated module hands to an icon, read off its element. */
|
||||
function glyphOf(icon: unknown): string[] {
|
||||
const memo = icon as { type: (p: object) => { props: { children: { props: { d: string } }[] } } };
|
||||
return memo.type({}).props.children.map((child) => child.props.d);
|
||||
}
|
||||
|
||||
describe('icons', () => {
|
||||
it('exports every icon a screen imports', async () => {
|
||||
const icons: Record<string, unknown> = await import('..');
|
||||
const missing: string[] = [];
|
||||
const files = [...sourceFiles(join(ROOT, 'src')), join(ROOT, 'App.tsx')];
|
||||
const re = /import\s+(?:type\s+)?\{([^}]*)\}\s+from\s+'[./]*(?:components\/)?icons'/g;
|
||||
let checked = 0;
|
||||
for (const file of files) {
|
||||
for (const m of readFileSync(file, 'utf8').matchAll(re)) {
|
||||
for (const raw of m[1].split(',')) {
|
||||
const spec = raw.trim().replace(/^type\s+/, '');
|
||||
if (!spec) continue;
|
||||
const name = spec.split(/\s+as\s+/)[0];
|
||||
if (name === 'AppIcon' || name === 'IconProps') continue;
|
||||
checked++;
|
||||
if (typeof icons[name] !== 'object') missing.push(`${relative(ROOT, file)}: ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
expect(checked).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it.skipIf(!existsSync(WEBMAIL_ICONS) || !existsSync(TABLER))('draws the glyph the webmail uses for the same name', async () => {
|
||||
const icons: Record<string, unknown> = await import('..');
|
||||
const tabler: Record<string, [string, { d: string }][]> = JSON.parse(readFileSync(TABLER, 'utf8'));
|
||||
const webmail = readFileSync(WEBMAIL_ICONS, 'utf8');
|
||||
const kebab = (pascal: string) => pascal.replace(/([a-z0-9])([A-Z])/g, '$1-$2').replace(/([A-Za-z])(\d)/g, '$1-$2').toLowerCase();
|
||||
const differ: string[] = [];
|
||||
let compared = 0;
|
||||
for (const m of webmail.matchAll(/Icon(\w+) as (\w+),/g)) {
|
||||
const [, tablerPascal, name] = m;
|
||||
if (!(name in icons)) continue;
|
||||
const expected = tabler[kebab(tablerPascal)]?.map(([, a]) => a.d);
|
||||
if (!expected) continue;
|
||||
compared++;
|
||||
if (JSON.stringify(glyphOf(icons[name])) !== JSON.stringify(expected)) differ.push(`${name} (webmail: ${kebab(tablerPascal)})`);
|
||||
}
|
||||
expect(differ).toEqual([]);
|
||||
expect(compared).toBeGreaterThan(100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import type { StyleProp, ViewStyle } from 'react-native';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
|
||||
export interface IconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
/** Tabler draws at 2 on a 24 grid, like the webmail. */
|
||||
strokeWidth?: number;
|
||||
/** Fills the outline, for a starred star or a coloured tag. */
|
||||
fill?: string;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
export type AppIcon = React.ComponentType<IconProps>;
|
||||
|
||||
/** An icon component for one Tabler outline glyph (its path data). */
|
||||
export function makeIcon(name: string, paths: readonly string[]): AppIcon {
|
||||
if (!paths?.length) throw new Error(`Icon ${name} has no glyph`);
|
||||
function Icon({ size = 24, color = '#000', strokeWidth = 2, fill = 'none', style, testID }: IconProps) {
|
||||
return (
|
||||
<Svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill={fill}
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={style}
|
||||
testID={testID}
|
||||
>
|
||||
{paths.map((d, i) => <Path key={i} d={d} />)}
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
Icon.displayName = name;
|
||||
return React.memo(Icon);
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
// Generated by scripts/generate-icons.mjs from @tabler/icons (MIT).
|
||||
// Do not edit by hand: add the icon to MAP in the script and run it.
|
||||
|
||||
import { makeIcon } from './icon';
|
||||
|
||||
export type { AppIcon, IconProps } from './icon';
|
||||
|
||||
const G = {
|
||||
'address-book': ["M20 6v12a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2","M10 16h6","M11 11a2 2 0 1 0 4 0a2 2 0 1 0 -4 0","M4 8h3","M4 12h3","M4 16h3"],
|
||||
'alarm': ["M5 13a7 7 0 1 0 14 0a7 7 0 1 0 -14 0","M12 10l0 3l2 0","M7 4l-2.75 2","M17 4l2.75 2"],
|
||||
'alert-circle': ["M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0","M12 8v4","M12 16h.01"],
|
||||
'alert-triangle': ["M12 9v4","M10.363 3.591l-8.106 13.534a1.914 1.914 0 0 0 1.636 2.871h16.214a1.914 1.914 0 0 0 1.636 -2.87l-8.106 -13.536a1.914 1.914 0 0 0 -3.274 0","M12 16h.01"],
|
||||
'align-center': ["M4 6l16 0","M8 12l8 0","M6 18l12 0"],
|
||||
'align-left': ["M4 6l16 0","M4 12l10 0","M4 18l14 0"],
|
||||
'align-right': ["M4 6l16 0","M10 12l10 0","M6 18l14 0"],
|
||||
'archive': ["M3 6a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2","M5 8v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-10","M10 12l4 0"],
|
||||
'arrow-back': ["M9 11l-4 4l4 4m-4 -4h11a4 4 0 0 0 0 -8h-1"],
|
||||
'arrow-back-up': ["M9 14l-4 -4l4 -4","M5 10h11a4 4 0 1 1 0 8h-1"],
|
||||
'arrow-back-up-double': ["M13 14l-4 -4l4 -4","M8 14l-4 -4l4 -4","M9 10h7a4 4 0 1 1 0 8h-1"],
|
||||
'arrow-down': ["M12 5l0 14","M18 13l-6 6","M6 13l6 6"],
|
||||
'arrow-forward': ["M15 11l4 4l-4 4m4 -4h-11a4 4 0 0 1 0 -8h1"],
|
||||
'arrow-forward-up': ["M15 14l4 -4l-4 -4","M19 10h-11a4 4 0 1 0 0 8h1"],
|
||||
'arrow-left': ["M5 12l14 0","M5 12l6 6","M5 12l6 -6"],
|
||||
'arrow-right': ["M5 12l14 0","M13 18l6 -6","M13 6l6 6"],
|
||||
'arrow-up': ["M12 5l0 14","M18 11l-6 -6","M6 11l6 -6"],
|
||||
'arrows-shuffle': ["M18 4l3 3l-3 3","M18 20l3 -3l-3 -3","M3 7h3a5 5 0 0 1 5 5a5 5 0 0 0 5 5h5","M21 7h-5a4.978 4.978 0 0 0 -3 1m-4 8a4.984 4.984 0 0 1 -3 1h-3"],
|
||||
'arrows-sort': ["M3 9l4 -4l4 4m-4 -4v14","M21 15l-4 4l-4 -4m4 4v-14"],
|
||||
'arrows-vertical': ["M8 7l4 -4l4 4","M8 17l4 4l4 -4","M12 3l0 18"],
|
||||
'ban': ["M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0","M5.7 5.7l12.6 12.6"],
|
||||
'beach': ["M17.553 16.75a7.5 7.5 0 0 0 -10.606 0","M18 3.804a6 6 0 0 0 -8.196 2.196l10.392 6a6 6 0 0 0 -2.196 -8.196","M16.732 10c1.658 -2.87 2.225 -5.644 1.268 -6.196c-.957 -.552 -3.075 1.326 -4.732 4.196","M15 9l-3 5.196","M3 19.25a2.4 2.4 0 0 1 1 -.25a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 1 .25"],
|
||||
'bell': ["M10 5a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3h-16a4 4 0 0 0 2 -3v-3a7 7 0 0 1 4 -6","M9 17v1a3 3 0 0 0 6 0v-1"],
|
||||
'bell-ringing': ["M10 5a2 2 0 0 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3h-16a4 4 0 0 0 2 -3v-3a7 7 0 0 1 4 -6","M9 17v1a3 3 0 0 0 6 0v-1","M21 6.727a11.05 11.05 0 0 0 -2.794 -3.727","M3 6.727a11.05 11.05 0 0 1 2.792 -3.727"],
|
||||
'bold': ["M7 5h6a3.5 3.5 0 0 1 0 7h-6l0 -7","M13 12h1a3.5 3.5 0 0 1 0 7h-7v-7"],
|
||||
'book': ["M3 19a9 9 0 0 1 9 0a9 9 0 0 1 9 0","M3 6a9 9 0 0 1 9 0a9 9 0 0 1 9 0","M3 6l0 13","M12 6l0 13","M21 6l0 13"],
|
||||
'book-2': ["M19 4v16h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12","M19 16h-12a2 2 0 0 0 -2 2","M9 8h6"],
|
||||
'bug': ["M9 9v-1a3 3 0 0 1 6 0v1","M8 9h8a6 6 0 0 1 1 3v3a5 5 0 0 1 -10 0v-3a6 6 0 0 1 1 -3","M3 13l4 0","M17 13l4 0","M12 20l0 -6","M4 19l3.35 -2","M20 19l-3.35 -2","M4 7l3.75 2.4","M20 7l-3.75 2.4"],
|
||||
'building': ["M3 21l18 0","M9 8l1 0","M9 12l1 0","M9 16l1 0","M14 8l1 0","M14 12l1 0","M14 16l1 0","M5 21v-16a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v16"],
|
||||
'cake': ["M3 20h18v-8a3 3 0 0 0 -3 -3h-12a3 3 0 0 0 -3 3v8","M3 14.803c.312 .135 .654 .204 1 .197a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1c.35 .007 .692 -.062 1 -.197","M12 4l1.465 1.638a2 2 0 1 1 -3.015 .099l1.55 -1.737"],
|
||||
'calendar': ["M4 7a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12","M16 3v4","M8 3v4","M4 11h16","M11 15h1","M12 15v3"],
|
||||
'calendar-month': ["M4 7a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12","M16 3v4","M8 3v4","M4 11h16","M8 14v4","M12 14v4","M16 14v4"],
|
||||
'calendar-plus': ["M12.5 21h-6.5a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v5","M16 3v4","M8 3v4","M4 11h16","M16 19h6","M19 16v6"],
|
||||
'camera': ["M5 7h1a2 2 0 0 0 2 -2a1 1 0 0 1 1 -1h6a1 1 0 0 1 1 1a2 2 0 0 0 2 2h1a2 2 0 0 1 2 2v9a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-9a2 2 0 0 1 2 -2","M9 13a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"],
|
||||
'check': ["M5 12l5 5l10 -10"],
|
||||
'checks': ["M7 12l5 5l10 -10","M2 12l5 5m5 -5l5 -5"],
|
||||
'chevron-down': ["M6 9l6 6l6 -6"],
|
||||
'chevron-left': ["M15 6l-6 6l6 6"],
|
||||
'chevron-right': ["M9 6l6 6l-6 6"],
|
||||
'chevron-up': ["M6 15l6 -6l6 6"],
|
||||
'circle-check': ["M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0","M9 12l2 2l4 -4"],
|
||||
'circle-x': ["M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0","M10 10l4 4m0 -4l-4 4"],
|
||||
'clear-formatting': ["M17 15l4 4m0 -4l-4 4","M7 6v-1h11v1","M7 19l4 0","M13 5l-4 14"],
|
||||
'clipboard-text': ["M9 5h-2a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-12a2 2 0 0 0 -2 -2h-2","M9 5a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2a2 2 0 0 1 -2 2h-2a2 2 0 0 1 -2 -2","M9 12h6","M9 16h6"],
|
||||
'clock': ["M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0","M12 7v5l3 3"],
|
||||
'cloud-download': ["M19 18a3.5 3.5 0 0 0 0 -7h-1a5 4.5 0 0 0 -11 -2a4.6 4.4 0 0 0 -2.1 8.4","M12 13l0 9","M9 19l3 3l3 -3"],
|
||||
'cloud-off': ["M9.58 5.548c.24 -.11 .492 -.207 .752 -.286c1.88 -.572 3.956 -.193 5.444 1c1.488 1.19 2.162 3.007 1.77 4.769h.99c1.913 0 3.464 1.56 3.464 3.486c0 .957 -.383 1.824 -1.003 2.454m-2.997 1.033h-11.343c-2.572 -.004 -4.657 -2.011 -4.657 -4.487c0 -2.475 2.085 -4.482 4.657 -4.482c.13 -.582 .37 -1.128 .7 -1.62","M3 3l18 18"],
|
||||
'code': ["M7 8l-4 4l4 4","M17 8l4 4l-4 4","M14 4l-4 16"],
|
||||
'columns-3': ["M3 4a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v16a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-16","M9 3v18","M15 3v18"],
|
||||
'copy': ["M7 9.667a2.667 2.667 0 0 1 2.667 -2.667h8.666a2.667 2.667 0 0 1 2.667 2.667v8.666a2.667 2.667 0 0 1 -2.667 2.667h-8.666a2.667 2.667 0 0 1 -2.667 -2.667l0 -8.666","M4.012 16.737a2.005 2.005 0 0 1 -1.012 -1.737v-10c0 -1.1 .9 -2 2 -2h10c.75 0 1.158 .385 1.5 1"],
|
||||
'database': ["M4 6a8 3 0 1 0 16 0a8 3 0 1 0 -16 0","M4 6v6a8 3 0 0 0 16 0v-6","M4 12v6a8 3 0 0 0 16 0v-6"],
|
||||
'device-desktop': ["M3 5a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v10a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-10","M7 20h10","M9 16v4","M15 16v4"],
|
||||
'device-floppy': ["M6 4h10l4 4v10a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2","M10 14a2 2 0 1 0 4 0a2 2 0 1 0 -4 0","M14 4l0 4l-6 0l0 -4"],
|
||||
'device-mobile': ["M6 5a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2v-14","M11 4h2","M12 17v.01"],
|
||||
'dots': ["M4 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M11 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M18 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"],
|
||||
'dots-vertical': ["M11 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M11 19a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M11 5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"],
|
||||
'download': ["M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2","M7 11l5 5l5 -5","M12 4l0 12"],
|
||||
'edit': ["M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1","M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415","M16 5l3 3"],
|
||||
'eraser': ["M19 20h-10.5l-4.21 -4.3a1 1 0 0 1 0 -1.41l10 -10a1 1 0 0 1 1.41 0l5 5a1 1 0 0 1 0 1.41l-9.2 9.3","M18 13.3l-6.3 -6.3"],
|
||||
'external-link': ["M12 6h-6a2 2 0 0 0 -2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-6","M11 13l9 -9","M15 4h5v5"],
|
||||
'eye': ["M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0","M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6"],
|
||||
'eye-off': ["M10.585 10.587a2 2 0 0 0 2.829 2.828","M16.681 16.673a8.717 8.717 0 0 1 -4.681 1.327c-3.6 0 -6.6 -2 -9 -6c1.272 -2.12 2.712 -3.678 4.32 -4.674m2.86 -1.146a9.055 9.055 0 0 1 1.82 -.18c3.6 0 6.6 2 9 6c-.666 1.11 -1.379 2.067 -2.138 2.87","M3 3l18 18"],
|
||||
'file': ["M14 3v4a1 1 0 0 0 1 1h4","M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2"],
|
||||
'file-code': ["M14 3v4a1 1 0 0 0 1 1h4","M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2","M10 13l-1 2l1 2","M14 13l1 2l-1 2"],
|
||||
'file-code-2': ["M10 12h-1v5h1","M14 12h1v5h-1","M14 3v4a1 1 0 0 0 1 1h4","M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2"],
|
||||
'file-import': ["M14 3v4a1 1 0 0 0 1 1h4","M5 13v-8a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2h-5.5m-9.5 -2h7m-3 -3l3 3l-3 3"],
|
||||
'file-music': ["M14 3v4a1 1 0 0 0 1 1h4","M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2","M10 16a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M12 16l0 -5l2 1"],
|
||||
'file-spreadsheet': ["M14 3v4a1 1 0 0 0 1 1h4","M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2","M8 11h8v7h-8l0 -7","M8 15h8","M11 11v7"],
|
||||
'file-text': ["M14 3v4a1 1 0 0 0 1 1h4","M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2","M9 9l1 0","M9 13l6 0","M9 17l6 0"],
|
||||
'file-zip': ["M6 20.735a2 2 0 0 1 -1 -1.735v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2h-1","M11 17a2 2 0 0 1 2 2v2a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1v-2a2 2 0 0 1 2 -2","M11 5l-1 0","M13 7l-1 0","M11 9l-1 0","M13 11l-1 0","M11 13l-1 0","M13 15l-1 0"],
|
||||
'filter': ["M4 4h16v2.172a2 2 0 0 1 -.586 1.414l-4.414 4.414v7l-6 2v-8.5l-4.48 -4.928a2 2 0 0 1 -.52 -1.345v-2.227"],
|
||||
'flag': ["M5 5a5 5 0 0 1 7 0a5 5 0 0 0 7 0v9a5 5 0 0 1 -7 0a5 5 0 0 0 -7 0v-9","M5 21v-7"],
|
||||
'flask': ["M9 3l6 0","M10 9l4 0","M10 3v6l-4 11a.7 .7 0 0 0 .5 1h11a.7 .7 0 0 0 .5 -1l-4 -11v-6"],
|
||||
'folder': ["M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2"],
|
||||
'folder-down': ["M12 19h-7a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2h4l3 3h7a2 2 0 0 1 2 2v3.5","M19 16v6","M22 19l-3 3l-3 -3"],
|
||||
'folder-open': ["M5 19l2.757 -7.351a1 1 0 0 1 .936 -.649h12.307a1 1 0 0 1 .986 1.164l-.996 5.211a2 2 0 0 1 -1.964 1.625h-14.026a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2h4l3 3h7a2 2 0 0 1 2 2v2"],
|
||||
'folder-plus': ["M12 19h-7a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2h4l3 3h7a2 2 0 0 1 2 2v3.5","M16 19h6","M19 16v6"],
|
||||
'folders': ["M9 3h3l2 2h5a2 2 0 0 1 2 2v7a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-9a2 2 0 0 1 2 -2","M17 16v2a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-9a2 2 0 0 1 2 -2h2"],
|
||||
'grid-dots': ["M4 5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M11 5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M18 5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M4 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M11 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M18 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M4 19a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M11 19a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M18 19a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"],
|
||||
'grip-vertical': ["M8 5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M8 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M8 19a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M14 5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M14 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M14 19a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"],
|
||||
'h-1': ["M19 18v-8l-2 2","M4 6v12","M12 6v12","M11 18h2","M3 18h2","M4 12h8","M3 6h2","M11 6h2"],
|
||||
'h-2': ["M17 12a2 2 0 1 1 4 0c0 .591 -.417 1.318 -.816 1.858l-3.184 4.143l4 0","M4 6v12","M12 6v12","M11 18h2","M3 18h2","M4 12h8","M3 6h2","M11 6h2"],
|
||||
'heart': ["M19.5 12.572l-7.5 7.428l-7.5 -7.428a5 5 0 1 1 7.5 -6.566a5 5 0 1 1 7.5 6.572"],
|
||||
'help-circle': ["M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0","M12 16v.01","M12 13a2 2 0 0 0 .914 -3.782a1.98 1.98 0 0 0 -2.414 .483"],
|
||||
'highlight': ["M3 19h4l10.5 -10.5a2.828 2.828 0 1 0 -4 -4l-10.5 10.5v4","M12.5 5.5l4 4","M4.5 13.5l4 4","M21 15v4h-8l4 -4l4 0"],
|
||||
'inbox': ["M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -12","M4 13h3l3 3h4l3 -3h3"],
|
||||
'info-circle': ["M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0","M12 9h.01","M11 12h1v4h1"],
|
||||
'italic': ["M11 5l6 0","M7 19l6 0","M14 5l-4 14"],
|
||||
'key': ["M16.555 3.843l3.602 3.602a2.877 2.877 0 0 1 0 4.069l-2.643 2.643a2.877 2.877 0 0 1 -4.069 0l-.301 -.301l-6.558 6.558a2 2 0 0 1 -1.239 .578l-.175 .008h-1.172a1 1 0 0 1 -.993 -.883l-.007 -.117v-1.172a2 2 0 0 1 .467 -1.284l.119 -.13l.414 -.414h2v-2h2v-2l2.144 -2.144l-.301 -.301a2.877 2.877 0 0 1 0 -4.069l2.643 -2.643a2.877 2.877 0 0 1 4.069 0","M15 9h.01"],
|
||||
'language': ["M9 6.371c0 4.418 -2.239 6.629 -5 6.629","M4 6.371h7","M5 9c0 2.144 2.252 3.908 6 4","M12 20l4 -9l4 9","M19.1 18h-6.2","M6.694 3l.793 .582"],
|
||||
'layout-columns': ["M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -12","M12 4l0 16"],
|
||||
'layout-grid': ["M4 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4","M14 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4","M4 15a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4","M14 15a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4"],
|
||||
'layout-rows': ["M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -12","M4 12l16 0"],
|
||||
'layout-sidebar-left-collapse': ["M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -12","M9 4v16","M15 10l-2 2l2 2"],
|
||||
'layout-sidebar-right': ["M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -12","M15 4l0 16"],
|
||||
'link': ["M9 15l6 -6","M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464","M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463"],
|
||||
'list': ["M9 6l11 0","M9 12l11 0","M9 18l11 0","M5 6l0 .01","M5 12l0 .01","M5 18l0 .01"],
|
||||
'list-check': ["M3.5 5.5l1.5 1.5l2.5 -2.5","M3.5 11.5l1.5 1.5l2.5 -2.5","M3.5 17.5l1.5 1.5l2.5 -2.5","M11 6l9 0","M11 12l9 0","M11 18l9 0"],
|
||||
'list-numbers': ["M11 6h9","M11 12h9","M12 18h8","M4 16a2 2 0 1 1 4 0c0 .591 -.5 1 -1 1.5l-3 2.5h4","M6 10v-6l-2 2"],
|
||||
'lock': ["M5 13a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v6a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-6","M11 16a1 1 0 1 0 2 0a1 1 0 0 0 -2 0","M8 11v-4a4 4 0 1 1 8 0v4"],
|
||||
'logout': ["M14 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2","M9 12h12l-3 -3","M18 15l3 -3"],
|
||||
'mail': ["M3 7a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10","M3 7l9 6l9 -6"],
|
||||
'mail-check': ["M11 19h-6a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v6","M3 7l9 6l9 -6","M15 19l2 2l4 -4"],
|
||||
'mail-minus': ["M12 19h-7a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v8","M16 19h6","M3 7l9 6l9 -6"],
|
||||
'mail-opened': ["M3 9l9 6l9 -6l-9 -6l-9 6","M21 9v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10","M3 19l6 -6","M15 13l6 6"],
|
||||
'map-pin': ["M9 11a3 3 0 1 0 6 0a3 3 0 0 0 -6 0","M17.657 16.657l-4.243 4.243a2 2 0 0 1 -2.827 0l-4.244 -4.243a8 8 0 1 1 11.314 0"],
|
||||
'maximize': ["M4 8v-2a2 2 0 0 1 2 -2h2","M4 16v2a2 2 0 0 0 2 2h2","M16 4h2a2 2 0 0 1 2 2v2","M16 20h2a2 2 0 0 0 2 -2v-2"],
|
||||
'menu-2': ["M4 6l16 0","M4 12l16 0","M4 18l16 0"],
|
||||
'message': ["M8 9h8","M8 13h6","M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12"],
|
||||
'minus': ["M5 12l14 0"],
|
||||
'moon': ["M12 3c.132 0 .263 0 .393 0a7.5 7.5 0 0 0 7.92 12.446a9 9 0 1 1 -8.313 -12.454l0 .008"],
|
||||
'note': ["M13 20l7 -7","M13 20v-6a1 1 0 0 1 1 -1h6v-7a2 2 0 0 0 -2 -2h-12a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7"],
|
||||
'palette': ["M12 21a9 9 0 0 1 0 -18c4.97 0 9 3.582 9 8c0 1.06 -.474 2.078 -1.318 2.828c-.844 .75 -1.989 1.172 -3.182 1.172h-2.5a2 2 0 0 0 -1 3.75a1.3 1.3 0 0 1 -1 2.25","M7.5 10.5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M11.5 7.5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M15.5 10.5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"],
|
||||
'paperclip': ["M15 7l-6.5 6.5a1.5 1.5 0 0 0 3 3l6.5 -6.5a3 3 0 0 0 -6 -6l-6.5 6.5a4.5 4.5 0 0 0 9 9l6.5 -6.5"],
|
||||
'pencil': ["M4 20h4l10.5 -10.5a2.828 2.828 0 1 0 -4 -4l-10.5 10.5v4","M13.5 6.5l4 4"],
|
||||
'phone': ["M5 4h4l2 5l-2.5 1.5a11 11 0 0 0 5 5l1.5 -2.5l5 2v4a2 2 0 0 1 -2 2a16 16 0 0 1 -15 -15a2 2 0 0 1 2 -2"],
|
||||
'photo': ["M15 8h.01","M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12","M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5","M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3"],
|
||||
'pinned': ["M9 4v6l-2 4v2h10v-2l-2 -4v-6","M12 16l0 5","M8 4l8 0"],
|
||||
'pinned-off': ["M3 3l18 18","M15 4.5l-3.249 3.249m-2.57 1.433l-2.181 .818l-1.5 1.5l7 7l1.5 -1.5l.82 -2.186m1.43 -2.563l3.25 -3.251","M9 15l-4.5 4.5","M14.5 4l5.5 5.5"],
|
||||
'plus': ["M12 5l0 14","M5 12l14 0"],
|
||||
'puzzle': ["M4 7h3a1 1 0 0 0 1 -1v-1a2 2 0 0 1 4 0v1a1 1 0 0 0 1 1h3a1 1 0 0 1 1 1v3a1 1 0 0 0 1 1h1a2 2 0 0 1 0 4h-1a1 1 0 0 0 -1 1v3a1 1 0 0 1 -1 1h-3a1 1 0 0 1 -1 -1v-1a2 2 0 0 0 -4 0v1a1 1 0 0 1 -1 1h-3a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1h1a2 2 0 0 0 0 -4h-1a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1"],
|
||||
'qrcode': ["M4 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4","M7 17l0 .01","M14 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4","M7 7l0 .01","M4 15a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4","M17 7l0 .01","M14 14l3 0","M20 14l0 .01","M14 14l0 3","M14 20l3 0","M17 17l3 0","M20 17l0 3"],
|
||||
'quote': ["M10 11h-4a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1h3a1 1 0 0 1 1 1v6c0 2.667 -1.333 4.333 -4 5","M19 11h-4a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1h3a1 1 0 0 1 1 1v6c0 2.667 -1.333 4.333 -4 5"],
|
||||
'refresh': ["M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4","M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4"],
|
||||
'repeat': ["M4 12v-3a3 3 0 0 1 3 -3h13m-3 -3l3 3l-3 3","M20 12v3a3 3 0 0 1 -3 3h-13m3 3l-3 -3l3 -3"],
|
||||
'rotate': ["M19.95 11a8 8 0 1 0 -.5 4m.5 5v-5h-5"],
|
||||
'rss': ["M4 19a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M4 4a16 16 0 0 1 16 16","M4 11a9 9 0 0 1 9 9"],
|
||||
'scale': ["M7 20l10 0","M6 6l6 -1l6 1","M12 3l0 17","M9 12l-3 -6l-3 6a3 3 0 0 0 6 0","M21 12l-3 -6l-3 6a3 3 0 0 0 6 0"],
|
||||
'search': ["M3 10a7 7 0 1 0 14 0a7 7 0 1 0 -14 0","M21 21l-6 -6"],
|
||||
'send': ["M10 14l11 -11","M21 3l-6.5 18a.55 .55 0 0 1 -1 0l-3.5 -7l-7 -3.5a.55 .55 0 0 1 0 -1l18 -6.5"],
|
||||
'server': ["M3 7a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-2","M3 15a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3l0 -2","M7 8l0 .01","M7 16l0 .01"],
|
||||
'server-2': ["M3 7a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-2","M3 15a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3l0 -2","M7 8l0 .01","M7 16l0 .01","M11 8h6","M11 16h6"],
|
||||
'settings': ["M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065","M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"],
|
||||
'share-2': ["M8 9h-1a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-8a2 2 0 0 0 -2 -2h-1","M12 14v-11","M9 6l3 -3l3 3"],
|
||||
'shield': ["M12 3a12 12 0 0 0 8.5 3a12 12 0 0 1 -8.5 15a12 12 0 0 1 -8.5 -15a12 12 0 0 0 8.5 -3"],
|
||||
'shield-check': ["M11.46 20.846a12 12 0 0 1 -7.96 -14.846a12 12 0 0 0 8.5 -3a12 12 0 0 0 8.5 3a12 12 0 0 1 -.09 7.06","M15 19l2 2l4 -4"],
|
||||
'shield-exclamation': ["M15.04 19.745c-.942 .551 -1.964 .976 -3.04 1.255a12 12 0 0 1 -8.5 -15a12 12 0 0 0 8.5 -3a12 12 0 0 0 8.5 3a12 12 0 0 1 .195 6.015","M19 16v3","M19 22v.01"],
|
||||
'shield-question': ["M15.065 19.732c-.95 .557 -1.98 .986 -3.065 1.268a12 12 0 0 1 -8.5 -15a12 12 0 0 0 8.5 -3a12 12 0 0 0 8.5 3c.51 1.738 .617 3.55 .333 5.303","M19 22v.01","M19 19a2.003 2.003 0 0 0 .914 -3.782a1.98 1.98 0 0 0 -2.414 .483"],
|
||||
'sort-ascending': ["M4 6l7 0","M4 12l7 0","M4 18l9 0","M15 9l3 -3l3 3","M18 6l0 12"],
|
||||
'sort-descending': ["M4 6l9 0","M4 12l7 0","M4 18l7 0","M15 15l3 3l3 -3","M18 6l0 12"],
|
||||
'square': ["M3 5a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-14"],
|
||||
'square-check': ["M3 5a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-14","M9 12l2 2l4 -4"],
|
||||
'stack': ["M12 6l-8 4l8 4l8 -4l-8 -4","M4 14l8 4l8 -4"],
|
||||
'star': ["M12 17.75l-6.172 3.245l1.179 -6.873l-5 -4.867l6.9 -1l3.086 -6.253l3.086 6.253l6.9 1l-5 4.867l1.179 6.873l-6.158 -3.245"],
|
||||
'strikethrough': ["M5 12l14 0","M16 6.5a4 2 0 0 0 -4 -1.5h-1a3.5 3.5 0 0 0 0 7h2a3.5 3.5 0 0 1 0 7h-1.5a4 2 0 0 1 -4 -1.5"],
|
||||
'sun': ["M8 12a4 4 0 1 0 8 0a4 4 0 1 0 -8 0","M3 12h1m8 -9v1m8 8h1m-9 8v1m-6.4 -15.4l.7 .7m12.1 -.7l-.7 .7m0 11.4l.7 .7m-12.1 -.7l-.7 .7"],
|
||||
'table': ["M3 5a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-14","M3 10h18","M10 3v18"],
|
||||
'tag': ["M6.5 7.5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M3 6v5.172a2 2 0 0 0 .586 1.414l7.71 7.71a2.41 2.41 0 0 0 3.408 0l5.592 -5.592a2.41 2.41 0 0 0 0 -3.408l-7.71 -7.71a2 2 0 0 0 -1.414 -.586h-5.172a3 3 0 0 0 -3 3"],
|
||||
'tags': ["M3 8v4.172a2 2 0 0 0 .586 1.414l5.71 5.71a2.41 2.41 0 0 0 3.408 0l3.592 -3.592a2.41 2.41 0 0 0 0 -3.408l-5.71 -5.71a2 2 0 0 0 -1.414 -.586h-4.172a2 2 0 0 0 -2 2","M18 19l1.592 -1.592a4.82 4.82 0 0 0 0 -6.816l-4.592 -4.592","M7 10h-.01"],
|
||||
'template': ["M4 5a1 1 0 0 1 1 -1h14a1 1 0 0 1 1 1v2a1 1 0 0 1 -1 1h-14a1 1 0 0 1 -1 -1l0 -2","M4 13a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v6a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -6","M14 12l6 0","M14 16l6 0","M14 20l6 0"],
|
||||
'terminal': ["M5 7l5 5l-5 5","M12 19l7 0"],
|
||||
'trash': ["M4 7l16 0","M10 11l0 6","M14 11l0 6","M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12","M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3"],
|
||||
'typography': ["M4 20l3 0","M14 20l7 0","M6.9 15l6.9 0","M10.2 6.3l5.8 13.7","M5 20l6 -16l2 0l7 16"],
|
||||
'underline': ["M7 5v5a5 5 0 0 0 10 0v-5","M5 19h14"],
|
||||
'unlink': ["M17 22v-2","M9 15l6 -6","M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464","M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463","M20 17h2","M2 7h2","M7 2v2"],
|
||||
'upload': ["M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2","M7 9l5 -5l5 5","M12 4l0 12"],
|
||||
'user': ["M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0","M6 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2"],
|
||||
'user-circle': ["M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0","M9 10a3 3 0 1 0 6 0a3 3 0 1 0 -6 0","M6.168 18.849a4 4 0 0 1 3.832 -2.849h4a4 4 0 0 1 3.834 2.855"],
|
||||
'user-edit': ["M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0","M6 21v-2a4 4 0 0 1 4 -4h3.5","M18.42 15.61a2.1 2.1 0 0 1 2.97 2.97l-3.39 3.42h-3v-3l3.42 -3.39"],
|
||||
'user-minus': ["M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0","M6 21v-2a4 4 0 0 1 4 -4h4c.348 0 .686 .045 1.009 .128","M16 19h6"],
|
||||
'user-plus': ["M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0","M16 19h6","M19 16v6","M6 21v-2a4 4 0 0 1 4 -4h4"],
|
||||
'users': ["M5 7a4 4 0 1 0 8 0a4 4 0 1 0 -8 0","M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2","M16 3.13a4 4 0 0 1 0 7.75","M21 21v-2a4 4 0 0 0 -3 -3.85"],
|
||||
'video': ["M15 10l4.553 -2.276a1 1 0 0 1 1.447 .894v6.764a1 1 0 0 1 -1.447 .894l-4.553 -2.276v-4","M3 8a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2l0 -8"],
|
||||
'world': ["M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0","M3.6 9h16.8","M3.6 15h16.8","M11.5 3a17 17 0 0 0 0 18","M12.5 3a17 17 0 0 1 0 18"],
|
||||
'x': ["M18 6l-12 12","M6 6l12 12"],
|
||||
'zoom-scan': ["M8 11a3 3 0 1 0 6 0a3 3 0 0 0 -6 0","M16 16l-2.5 -2.5","M3 7v-2a2 2 0 0 1 2 -2h2","M3 17v2a2 2 0 0 0 2 2h2","M17 3h2a2 2 0 0 1 2 2v2","M17 21h2a2 2 0 0 0 2 -2v-2"],
|
||||
} as const;
|
||||
|
||||
export const AlarmClock = makeIcon('AlarmClock', G['alarm']);
|
||||
export const AlertCircle = makeIcon('AlertCircle', G['alert-circle']);
|
||||
export const AlertTriangle = makeIcon('AlertTriangle', G['alert-triangle']);
|
||||
export const AlignCenter = makeIcon('AlignCenter', G['align-center']);
|
||||
export const AlignLeft = makeIcon('AlignLeft', G['align-left']);
|
||||
export const AlignRight = makeIcon('AlignRight', G['align-right']);
|
||||
export const Archive = makeIcon('Archive', G['archive']);
|
||||
export const ArrowDown = makeIcon('ArrowDown', G['arrow-down']);
|
||||
export const ArrowDownWideNarrow = makeIcon('ArrowDownWideNarrow', G['sort-descending']);
|
||||
export const ArrowLeft = makeIcon('ArrowLeft', G['arrow-left']);
|
||||
export const ArrowRight = makeIcon('ArrowRight', G['arrow-right']);
|
||||
export const ArrowUp = makeIcon('ArrowUp', G['arrow-up']);
|
||||
export const ArrowUpDown = makeIcon('ArrowUpDown', G['arrows-sort']);
|
||||
export const ArrowUpNarrowWide = makeIcon('ArrowUpNarrowWide', G['sort-ascending']);
|
||||
export const Ban = makeIcon('Ban', G['ban']);
|
||||
export const Bell = makeIcon('Bell', G['bell']);
|
||||
export const BellRing = makeIcon('BellRing', G['bell-ringing']);
|
||||
export const Bold = makeIcon('Bold', G['bold']);
|
||||
export const Book = makeIcon('Book', G['book-2']);
|
||||
export const BookOpen = makeIcon('BookOpen', G['book']);
|
||||
export const BookUser = makeIcon('BookUser', G['address-book']);
|
||||
export const Bug = makeIcon('Bug', G['bug']);
|
||||
export const Building = makeIcon('Building', G['building']);
|
||||
export const Cake = makeIcon('Cake', G['cake']);
|
||||
export const Calendar = makeIcon('Calendar', G['calendar']);
|
||||
export const CalendarDays = makeIcon('CalendarDays', G['calendar-month']);
|
||||
export const CalendarPlus = makeIcon('CalendarPlus', G['calendar-plus']);
|
||||
export const Camera = makeIcon('Camera', G['camera']);
|
||||
export const Check = makeIcon('Check', G['check']);
|
||||
export const CheckCheck = makeIcon('CheckCheck', G['checks']);
|
||||
export const CheckCircle = makeIcon('CheckCircle', G['circle-check']);
|
||||
export const CheckCircle2 = makeIcon('CheckCircle2', G['circle-check']);
|
||||
export const CheckSquare = makeIcon('CheckSquare', G['square-check']);
|
||||
export const ChevronDown = makeIcon('ChevronDown', G['chevron-down']);
|
||||
export const ChevronLeft = makeIcon('ChevronLeft', G['chevron-left']);
|
||||
export const ChevronRight = makeIcon('ChevronRight', G['chevron-right']);
|
||||
export const ChevronUp = makeIcon('ChevronUp', G['chevron-up']);
|
||||
export const CircleCheck = makeIcon('CircleCheck', G['circle-check']);
|
||||
export const ClipboardPaste = makeIcon('ClipboardPaste', G['clipboard-text']);
|
||||
export const Clock = makeIcon('Clock', G['clock']);
|
||||
export const CloudDownload = makeIcon('CloudDownload', G['cloud-download']);
|
||||
export const CloudOff = makeIcon('CloudOff', G['cloud-off']);
|
||||
export const Code = makeIcon('Code', G['code']);
|
||||
export const Columns3 = makeIcon('Columns3', G['columns-3']);
|
||||
export const Columns4 = makeIcon('Columns4', G['layout-columns']);
|
||||
export const Copy = makeIcon('Copy', G['copy']);
|
||||
export const Database = makeIcon('Database', G['database']);
|
||||
export const Download = makeIcon('Download', G['download']);
|
||||
export const Edit2 = makeIcon('Edit2', G['pencil']);
|
||||
export const Eraser = makeIcon('Eraser', G['eraser']);
|
||||
export const ExternalLink = makeIcon('ExternalLink', G['external-link']);
|
||||
export const Eye = makeIcon('Eye', G['eye']);
|
||||
export const EyeOff = makeIcon('EyeOff', G['eye-off']);
|
||||
export const File = makeIcon('File', G['file']);
|
||||
export const FileArchive = makeIcon('FileArchive', G['file-zip']);
|
||||
export const FileAudio = makeIcon('FileAudio', G['file-music']);
|
||||
export const FileCode = makeIcon('FileCode', G['file-code']);
|
||||
export const FileCode2 = makeIcon('FileCode2', G['file-code-2']);
|
||||
export const FileImage = makeIcon('FileImage', G['photo']);
|
||||
export const FileInput = makeIcon('FileInput', G['file-import']);
|
||||
export const FileSpreadsheet = makeIcon('FileSpreadsheet', G['file-spreadsheet']);
|
||||
export const FileText = makeIcon('FileText', G['file-text']);
|
||||
export const FileVideo = makeIcon('FileVideo', G['video']);
|
||||
export const Filter = makeIcon('Filter', G['filter']);
|
||||
export const Flag = makeIcon('Flag', G['flag']);
|
||||
export const FlaskConical = makeIcon('FlaskConical', G['flask']);
|
||||
export const Folder = makeIcon('Folder', G['folder']);
|
||||
export const FolderArchive = makeIcon('FolderArchive', G['archive']);
|
||||
export const FolderInput = makeIcon('FolderInput', G['folder-down']);
|
||||
export const FolderOpen = makeIcon('FolderOpen', G['folder-open']);
|
||||
export const FolderPlus = makeIcon('FolderPlus', G['folder-plus']);
|
||||
export const FolderSync = makeIcon('FolderSync', G['folders']);
|
||||
export const Forward = makeIcon('Forward', G['arrow-forward-up']);
|
||||
export const Globe = makeIcon('Globe', G['world']);
|
||||
export const Grid3x3 = makeIcon('Grid3x3', G['grid-dots']);
|
||||
export const GripVertical = makeIcon('GripVertical', G['grip-vertical']);
|
||||
export const HardDrive = makeIcon('HardDrive', G['server-2']);
|
||||
export const Heading1 = makeIcon('Heading1', G['h-1']);
|
||||
export const Heading2 = makeIcon('Heading2', G['h-2']);
|
||||
export const Heart = makeIcon('Heart', G['heart']);
|
||||
export const HelpCircle = makeIcon('HelpCircle', G['help-circle']);
|
||||
export const Highlighter = makeIcon('Highlighter', G['highlight']);
|
||||
export const Image = makeIcon('Image', G['photo']);
|
||||
export const Import = makeIcon('Import', G['file-import']);
|
||||
export const Inbox = makeIcon('Inbox', G['inbox']);
|
||||
export const Info = makeIcon('Info', G['info-circle']);
|
||||
export const Italic = makeIcon('Italic', G['italic']);
|
||||
export const Key = makeIcon('Key', G['key']);
|
||||
export const KeyRound = makeIcon('KeyRound', G['key']);
|
||||
export const Languages = makeIcon('Languages', G['language']);
|
||||
export const Layers = makeIcon('Layers', G['stack']);
|
||||
export const LayoutGrid = makeIcon('LayoutGrid', G['layout-grid']);
|
||||
export const LayoutTemplate = makeIcon('LayoutTemplate', G['template']);
|
||||
export const Link2 = makeIcon('Link2', G['link']);
|
||||
export const Link2Off = makeIcon('Link2Off', G['unlink']);
|
||||
export const List = makeIcon('List', G['list']);
|
||||
export const ListChecks = makeIcon('ListChecks', G['list-check']);
|
||||
export const ListOrdered = makeIcon('ListOrdered', G['list-numbers']);
|
||||
export const Lock = makeIcon('Lock', G['lock']);
|
||||
export const LogOut = makeIcon('LogOut', G['logout']);
|
||||
export const Mail = makeIcon('Mail', G['mail']);
|
||||
export const MailCheck = makeIcon('MailCheck', G['mail-check']);
|
||||
export const MailMinus = makeIcon('MailMinus', G['mail-minus']);
|
||||
export const MailOpen = makeIcon('MailOpen', G['mail-opened']);
|
||||
export const Mails = makeIcon('Mails', G['inbox']);
|
||||
export const MapPin = makeIcon('MapPin', G['map-pin']);
|
||||
export const Maximize2 = makeIcon('Maximize2', G['maximize']);
|
||||
export const Menu = makeIcon('Menu', G['menu-2']);
|
||||
export const MessageSquare = makeIcon('MessageSquare', G['message']);
|
||||
export const Minus = makeIcon('Minus', G['minus']);
|
||||
export const Monitor = makeIcon('Monitor', G['device-desktop']);
|
||||
export const Moon = makeIcon('Moon', G['moon']);
|
||||
export const MoreHorizontal = makeIcon('MoreHorizontal', G['dots']);
|
||||
export const MoreVertical = makeIcon('MoreVertical', G['dots-vertical']);
|
||||
export const MoveVertical = makeIcon('MoveVertical', G['arrows-vertical']);
|
||||
export const Palette = makeIcon('Palette', G['palette']);
|
||||
export const Palmtree = makeIcon('Palmtree', G['beach']);
|
||||
export const PanelLeftClose = makeIcon('PanelLeftClose', G['layout-sidebar-left-collapse']);
|
||||
export const PanelRight = makeIcon('PanelRight', G['layout-sidebar-right']);
|
||||
export const Paperclip = makeIcon('Paperclip', G['paperclip']);
|
||||
export const PenLine = makeIcon('PenLine', G['pencil']);
|
||||
export const PenSquare = makeIcon('PenSquare', G['edit']);
|
||||
export const Pencil = makeIcon('Pencil', G['pencil']);
|
||||
export const Phone = makeIcon('Phone', G['phone']);
|
||||
export const Pin = makeIcon('Pin', G['pinned']);
|
||||
export const PinOff = makeIcon('PinOff', G['pinned-off']);
|
||||
export const Plus = makeIcon('Plus', G['plus']);
|
||||
export const Puzzle = makeIcon('Puzzle', G['puzzle']);
|
||||
export const QrCode = makeIcon('QrCode', G['qrcode']);
|
||||
export const Quote = makeIcon('Quote', G['quote']);
|
||||
export const Redo2 = makeIcon('Redo2', G['arrow-forward']);
|
||||
export const RefreshCw = makeIcon('RefreshCw', G['refresh']);
|
||||
export const RemoveFormatting = makeIcon('RemoveFormatting', G['clear-formatting']);
|
||||
export const Repeat = makeIcon('Repeat', G['repeat']);
|
||||
export const Reply = makeIcon('Reply', G['arrow-back-up']);
|
||||
export const ReplyAll = makeIcon('ReplyAll', G['arrow-back-up-double']);
|
||||
export const RotateCcw = makeIcon('RotateCcw', G['rotate']);
|
||||
export const Rows2 = makeIcon('Rows2', G['layout-rows']);
|
||||
export const Rss = makeIcon('Rss', G['rss']);
|
||||
export const Save = makeIcon('Save', G['device-floppy']);
|
||||
export const ScanSearch = makeIcon('ScanSearch', G['zoom-scan']);
|
||||
export const Scale = makeIcon('Scale', G['scale']);
|
||||
export const Search = makeIcon('Search', G['search']);
|
||||
export const Send = makeIcon('Send', G['send']);
|
||||
export const Server = makeIcon('Server', G['server']);
|
||||
export const Settings = makeIcon('Settings', G['settings']);
|
||||
export const Share2 = makeIcon('Share2', G['share-2']);
|
||||
export const Shield = makeIcon('Shield', G['shield']);
|
||||
export const ShieldAlert = makeIcon('ShieldAlert', G['shield-exclamation']);
|
||||
export const ShieldCheck = makeIcon('ShieldCheck', G['shield-check']);
|
||||
export const ShieldQuestion = makeIcon('ShieldQuestion', G['shield-question']);
|
||||
export const Shuffle = makeIcon('Shuffle', G['arrows-shuffle']);
|
||||
export const Smartphone = makeIcon('Smartphone', G['device-mobile']);
|
||||
export const Square = makeIcon('Square', G['square']);
|
||||
export const SquareCheck = makeIcon('SquareCheck', G['square-check']);
|
||||
export const SquarePen = makeIcon('SquarePen', G['edit']);
|
||||
export const Star = makeIcon('Star', G['star']);
|
||||
export const StickyNote = makeIcon('StickyNote', G['note']);
|
||||
export const Strikethrough = makeIcon('Strikethrough', G['strikethrough']);
|
||||
export const Sun = makeIcon('Sun', G['sun']);
|
||||
export const Table = makeIcon('Table', G['table']);
|
||||
export const Tag = makeIcon('Tag', G['tag']);
|
||||
export const Tags = makeIcon('Tags', G['tags']);
|
||||
export const Terminal = makeIcon('Terminal', G['terminal']);
|
||||
export const Trash = makeIcon('Trash', G['trash']);
|
||||
export const Trash2 = makeIcon('Trash2', G['trash']);
|
||||
export const Type = makeIcon('Type', G['typography']);
|
||||
export const Underline = makeIcon('Underline', G['underline']);
|
||||
export const Undo2 = makeIcon('Undo2', G['arrow-back']);
|
||||
export const Upload = makeIcon('Upload', G['upload']);
|
||||
export const User = makeIcon('User', G['user']);
|
||||
export const UserCircle = makeIcon('UserCircle', G['user-circle']);
|
||||
export const UserMinus = makeIcon('UserMinus', G['user-minus']);
|
||||
export const UserPen = makeIcon('UserPen', G['user-edit']);
|
||||
export const UserPlus = makeIcon('UserPlus', G['user-plus']);
|
||||
export const UserRound = makeIcon('UserRound', G['user']);
|
||||
export const UserRoundPlus = makeIcon('UserRoundPlus', G['user-plus']);
|
||||
export const Users = makeIcon('Users', G['users']);
|
||||
export const Video = makeIcon('Video', G['video']);
|
||||
export const X = makeIcon('X', G['x']);
|
||||
export const XCircle = makeIcon('XCircle', G['circle-x']);
|
||||
@@ -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 'lucide-react-native';
|
||||
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 },
|
||||
});
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import DateTimePicker, { type DateTimePickerEvent } from '@react-native-communit
|
||||
import {
|
||||
Key, Smartphone, Lock, Eye, EyeOff, ShieldCheck, Monitor, Trash2,
|
||||
Plus, Shield, Terminal, Check, ExternalLink, Calendar, X,
|
||||
} from 'lucide-react-native';
|
||||
} from '../icons';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import Input from '../Input';
|
||||
import Button from '../Button';
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, Alert } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { ArrowDown, ArrowUp, Check, ChevronRight, Plus, Star, Trash2, Users } from 'lucide-react-native';
|
||||
import { ArrowDown, ArrowUp, Check, ChevronRight, Plus, Star, Trash2, Users } from '../icons';
|
||||
import { SettingsSection, SettingItem } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
|
||||
import { X, Plus } from 'lucide-react-native';
|
||||
import { X, Plus } from '../icons';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as FileSystem from 'expo-file-system/legacy';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import {
|
||||
Download, Upload, Plus, Pencil, Trash2, Check, X, BookUser, Star,
|
||||
} from 'lucide-react-native';
|
||||
} from '../icons';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import Dialog from '../Dialog';
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, Pressable, TextInput, FlatList, Modal, Alert,
|
||||
} from 'react-native';
|
||||
import { ChevronRight, X, Plus, Trash2, BookUser } from 'lucide-react-native';
|
||||
import { ChevronRight, X, Plus, Trash2, BookUser } from '../icons';
|
||||
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { RotateCcw } from 'lucide-react-native';
|
||||
import { RotateCcw } from '../icons';
|
||||
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
||||
import Input from '../Input';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
@@ -179,7 +179,7 @@ function makeStyles(c: ThemePalette) {
|
||||
backgroundColor: c.muted,
|
||||
gap: 2,
|
||||
},
|
||||
previewLabel: { fontSize: 11, fontWeight: '600', color: c.mutedForeground, textTransform: 'uppercase' },
|
||||
previewLabel: { fontSize: 12, fontWeight: '500', color: c.mutedForeground },
|
||||
previewValue: { ...typography.captionMedium, color: c.text },
|
||||
tokenWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 4, marginTop: spacing.md },
|
||||
tokenPill: {
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect } from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import {
|
||||
Folder, FileText, FileCode, FileAudio, File, Image as ImageIcon,
|
||||
} from 'lucide-react-native';
|
||||
} from '../icons';
|
||||
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
|
||||
@@ -3,7 +3,7 @@ import { View, Text, StyleSheet, Pressable, ActivityIndicator, Alert } from 'rea
|
||||
import {
|
||||
Plus, X, Code, AlertTriangle, Filter, RotateCcw, Lock,
|
||||
ChevronUp, ChevronDown, Palmtree, ChevronRight,
|
||||
} from 'lucide-react-native';
|
||||
} from '../icons';
|
||||
import { SettingsSection, ToggleSwitch } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
@@ -21,6 +21,7 @@ import { FilterRuleModal } from '../filters/FilterRuleModal';
|
||||
import { SieveEditorSheet } from '../filters/SieveEditorSheet';
|
||||
import type { FilterRule } from '../../lib/sieve/types';
|
||||
import { formatConditionValue, summarizeRule } from '../../lib/sieve/condition-value';
|
||||
import Badge from '../Badge';
|
||||
|
||||
type Translate = (key: string, fallback?: string) => string;
|
||||
|
||||
@@ -366,7 +367,7 @@ export function FilterSettings({ onOpenVacation }: FilterSettingsProps = {}) {
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<View style={styles.ruleNameRow}>
|
||||
<Text style={styles.ruleName} numberOfLines={1}>{rule.name}</Text>
|
||||
<View style={styles.originBadge}><Text style={styles.originBadgeText}>{label}</Text></View>
|
||||
<Badge>{label}</Badge>
|
||||
</View>
|
||||
{hasStructured ? (
|
||||
expandedView ? (
|
||||
@@ -571,8 +572,6 @@ function makeStyles(c: ThemePalette) {
|
||||
backgroundColor: c.muted,
|
||||
borderRadius: radius.xs,
|
||||
},
|
||||
originBadge: { paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.full, backgroundColor: c.muted },
|
||||
originBadgeText: { fontSize: 10, fontWeight: '500', color: c.mutedForeground },
|
||||
|
||||
reorderCol: { alignItems: 'center' },
|
||||
reorderBtn: { padding: 2 },
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import {
|
||||
Folder, Inbox, Send, FileText, Trash, ShieldAlert, Archive, Flag, Star, Mails,
|
||||
StickyNote, Clock, AlarmClock, Users, Plus, Pencil, Trash2, X,
|
||||
} from 'lucide-react-native';
|
||||
} from '../icons';
|
||||
import { SettingsSection, Select } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
@@ -27,6 +27,7 @@ import { jmapClient } from '../../api/jmap-client';
|
||||
import type { Mailbox } from '../../api/types';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
import { useSheetInsets } from '../../lib/use-layout';
|
||||
import Badge from '../Badge';
|
||||
|
||||
const ROLE_ICON: Record<string, any> = {
|
||||
inbox: Inbox, drafts: FileText, sent: Send, trash: Trash,
|
||||
@@ -275,11 +276,7 @@ export function FolderSettings() {
|
||||
<View style={styles.folderLeft}>
|
||||
<Icon size={16} color={mb.role ? c.primary : c.mutedForeground} />
|
||||
<Text style={styles.folderName} numberOfLines={1}>{localizeMailboxName(mb.role, mb.name, t)}</Text>
|
||||
{mb.role && (
|
||||
<View style={styles.rolePill}>
|
||||
<Text style={styles.rolePillText}>{mb.role}</Text>
|
||||
</View>
|
||||
)}
|
||||
{mb.role && <Badge>{mb.role}</Badge>}
|
||||
</View>
|
||||
<View style={styles.folderRight}>
|
||||
{mb.unreadEmails > 0 && (
|
||||
@@ -416,11 +413,6 @@ function makeStyles(c: ThemePalette) {
|
||||
folderRowPressed: { backgroundColor: c.muted },
|
||||
folderLeft: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flex: 1 },
|
||||
folderName: { ...typography.body, color: c.text, flexShrink: 1 },
|
||||
rolePill: {
|
||||
paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.full,
|
||||
backgroundColor: c.primaryBg,
|
||||
},
|
||||
rolePillText: { fontSize: 10, fontWeight: '500', color: c.primary },
|
||||
folderRight: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
unreadBadge: {
|
||||
paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.full,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { Plus, Star, Trash2, X } from 'lucide-react-native';
|
||||
import { Plus, Star, Trash2, X } from '../icons';
|
||||
import { SettingsSection } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import { typography, spacing, radius, type ThemePalette } from '../../theme/tokens';
|
||||
@@ -29,6 +29,7 @@ import { sanitizeDisplayName } from '../../lib/rfc5322-mailbox';
|
||||
import { sanitizeSignatureHtml } from '../../lib/signature-utils';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
import { useSheetInsets } from '../../lib/use-layout';
|
||||
import Badge from '../Badge';
|
||||
|
||||
type DraftIdentity = {
|
||||
id: string;
|
||||
@@ -280,9 +281,7 @@ export function IdentitySettings() {
|
||||
</Pressable>
|
||||
)}
|
||||
{!identity.mayDelete ? (
|
||||
<View style={styles.badge}>
|
||||
<Text style={styles.badgeText}>{t('settings.identities.primary', "primary")}</Text>
|
||||
</View>
|
||||
<Badge>{t('settings.identities.primary', "primary")}</Badge>
|
||||
) : (
|
||||
<Pressable
|
||||
onPress={() => confirmDelete(identity)}
|
||||
@@ -423,11 +422,6 @@ function makeStyles(c: ThemePalette) {
|
||||
identityName: { ...typography.bodyMedium, color: c.text },
|
||||
identityEmail: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
|
||||
identityDelete: { padding: 6 },
|
||||
badge: {
|
||||
paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.full,
|
||||
backgroundColor: c.primaryBg,
|
||||
},
|
||||
badgeText: { fontSize: 10, fontWeight: '500', color: c.primary },
|
||||
|
||||
modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' },
|
||||
modalSheet: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, TextInput, Alert, ActivityIndicator } from 'react-native';
|
||||
import { Plus, Pencil, Trash2, Check, X, RotateCcw, ScanSearch } from 'lucide-react-native';
|
||||
import { Plus, Pencil, Trash2, Check, X, RotateCcw, ScanSearch } from '../icons';
|
||||
import { SettingsSection } from './settings-section';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
@@ -317,6 +317,7 @@ function makeStyles(c: ThemePalette) {
|
||||
},
|
||||
bottomActions: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: spacing.sm,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { Check } from 'lucide-react-native';
|
||||
import { Check } from '../icons';
|
||||
import { SettingsSection, SettingItem, RadioGroup } from './settings-section';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { ArrowRight, ArrowLeft, MoveVertical, ArrowDownWideNarrow } from 'lucide-react-native';
|
||||
import { ArrowRight, ArrowLeft, MoveVertical, ArrowDownWideNarrow } from '../icons';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup, Select } from './settings-section';
|
||||
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { CheckCircle2, RefreshCw, XCircle } from 'lucide-react-native';
|
||||
import { CheckCircle2, RefreshCw, XCircle } from '../icons';
|
||||
import Button from '../Button';
|
||||
import Dialog from '../Dialog';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { Puzzle } from 'lucide-react-native';
|
||||
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,6 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
|
||||
import { AlertTriangle, FolderSync, X } from 'lucide-react-native';
|
||||
import { AlertTriangle, FolderSync, X } from '../icons';
|
||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
|
||||
@@ -2,13 +2,14 @@ import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
|
||||
import {
|
||||
Plus, Pencil, Trash2, Globe, ExternalLink, PanelRight, GripVertical,
|
||||
} from 'lucide-react-native';
|
||||
} from '../icons';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSettingsStore, type SidebarApp } from '../../stores/settings-store';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import Badge from '../Badge';
|
||||
|
||||
export function SidebarAppsSettings() {
|
||||
const c = useColors();
|
||||
@@ -79,17 +80,9 @@ export function SidebarAppsSettings() {
|
||||
<Text style={styles.appName} numberOfLines={1}>{app.name}</Text>
|
||||
<Text style={styles.appUrl} numberOfLines={1}>{app.url}</Text>
|
||||
</View>
|
||||
<View style={[
|
||||
styles.modeBadge,
|
||||
app.openMode === 'inline' ? styles.modeBadgeInline : styles.modeBadgeTab,
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.modeBadgeText,
|
||||
{ color: app.openMode === 'inline' ? c.primary : c.mutedForeground },
|
||||
]}>
|
||||
{app.openMode === 'inline' ? t('settings.sidebar_apps.open_mode.inline', "Inline") : t('settings.sidebar_apps.open_mode.tab', "Tab")}
|
||||
</Text>
|
||||
</View>
|
||||
<Badge variant={app.openMode === 'inline' ? 'primary' : 'default'}>
|
||||
{app.openMode === 'inline' ? t('settings.sidebar_apps.open_mode.inline', "Inline") : t('settings.sidebar_apps.open_mode.tab', "Tab")}
|
||||
</Badge>
|
||||
<Pressable style={styles.iconBtn} onPress={() => setEditingId(app.id)} accessibilityRole="button" accessibilityLabel={t('common.edit', "Edit")}>
|
||||
<Pencil size={14} color={c.mutedForeground} />
|
||||
</Pressable>
|
||||
@@ -237,14 +230,6 @@ function makeStyles(c: ThemePalette) {
|
||||
},
|
||||
appName: { ...typography.bodyMedium, color: c.text },
|
||||
appUrl: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
|
||||
modeBadge: {
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
modeBadgeInline: { backgroundColor: 'rgba(59, 130, 246, 0.1)' },
|
||||
modeBadgeTab: { backgroundColor: c.muted },
|
||||
modeBadgeText: { fontSize: 10, fontWeight: '500' },
|
||||
iconBtn: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { KeyRound } from 'lucide-react-native';
|
||||
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 },
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user