Compare commits

...
25 Commits
Author SHA1 Message Date
Linus Rath 9a15334bef chore: update version number to 0.1.79 2026-10-16 18:49:43 +02:00
Linus Rath 3aca2d80c0 fix: preview .eml attachments that carry attachments of their own 2026-10-16 18:48:26 +02:00
Linus Rath 7c534301ee refactor: open files from mail, the list, Files and compose through one preview 2026-10-16 18:48:26 +02:00
Linus Rath 14dc5ff2d4 Merge pull request #76 from lucletoffe/feat/calendar-sort-order
feat(calendar): order calendars by sortOrder and move them from the drawer
2026-10-15 23:37:37 +02:00
Linus Rath 14ba28c79c Merge pull request #74 from lucletoffe/fix/compose-visual-viewport
fix(composer): stop the body's first lines hiding under To/Cc/Subject on Android
2026-10-15 23:23:40 +02:00
Linus Rath 598adc154d Merge pull request #72 from lucletoffe/fix/draft-part-blobids
fix(drafts): keep a re-opened draft's attachments on live blobs across saves and sends
2026-10-15 23:23:26 +02:00
lucletoffeandClaude Opus 5.5 3c0af52c4e feat(calendar): order calendars by sortOrder and move them from the drawer
Calendars were listed in the order the server returns them (creation
order on Stalwart) and sortOrder was never read. Sort them like the
webmail now does and as JMAP Calendars asks: lower sortOrder first,
equal sortOrder by name (case- and accent-insensitive, numbers by
value), each account's calendars kept together. The store keeps the list
sorted, so the drawer, the event editor and the other pickers agree.

The drawer's long-press panel gains Move up / Move down for the user's
own calendars. A move renumbers that account's calendars 1, 2, 3... and
writes sortOrder only where it changes; the panel stays open so a
calendar can be moved several places.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 22:07:33 +02:00
Linus Rath 7108431dbb chore: update version number to 0.1.78 2026-10-15 16:58:08 +02:00
Linus Rath 744b17908d fix: open a settings page at its top instead of the list's scroll position 2026-10-15 15:36:59 +02:00
Linus Rath c7edae9ed3 fix: move a setting's control under its label when a long label leaves no room 2026-10-15 15:29:13 +02:00
Linus Rath c07b39a070 fix: keep device sync free of expo by registering device locales from i18n #75 2026-10-15 15:19:18 +02:00
Linus Rath 7e5e296271 fix: use the app switch for the sender override in compose 2026-10-15 15:16:32 +02:00
Linus Rath ad6dff79ba fix: draw contact form fields like the webmail and quiet the letter headers in the contact list 2026-10-15 15:16:32 +02:00
Linus Rath a4ca72fb3d fix: draw event and task fields like the webmail and use the app switch in the event form 2026-10-15 15:16:32 +02:00
Linus Rath 107eb807e3 chore: update version number to 0.1.77 2026-10-15 15:15:29 +02:00
Linus Rath e731357b89 fix: format dates in the device region for the app language #75 2026-10-15 15:15:19 +02:00
Linus Rath 9cf1d3e849 fix: draw the text field in dialogs like the other inputs 2026-10-15 15:04:01 +02:00
Linus Rath d3dcc4e830 fix: show folder roles, filter origins and app modes as outline badges 2026-10-15 15:04:01 +02:00
Linus Rath e0d2b190d3 fix: give the Files header the same title size as Mail and Contacts 2026-10-15 15:04:00 +02:00
Linus Rath b048eaca03 fix: tint unread mail and draw the mail search, subject, quick reply and thread buttons like the webmail 2026-10-15 15:04:00 +02:00
Linus Rath 6f515e15f7 feat: rework settings into the picked list, About and Storage & backup pages
The settings list now has the account card on top, title-case group
labels and 56 dp rows that show each pane's current value. About and
"About & Data" were one placeholder page with a letter logo, a fixed
name and a store link that pointed at GitHub releases.

- About: the real logo, the app name from app.config.js, the update
  state, a source link that a build can set with SOURCE_CODE_URL, and
  rows for the server, update checks (Android), open-source licences,
  privacy and a prefilled GitHub issue. iPhone updates link TestFlight.
- Storage & backup: usage card, offline mail, settings export/import
  and reset, and a Troubleshooting page for refresh and debug logging.
- Sender favicons moved to Appearance, like the webmail.
- The S/MIME and Plugins stub pages are gone; both are webmail plugins.

The licence list is generated by `scripts/generate-licences.mjs`.
2026-10-15 14:49:37 +02:00
Linus Rath 0a176ab51c feat: draw buttons, switches, selects, inputs and badges like the webmail 2026-10-15 14:49:37 +02:00
Linus Rath 75299244cf refactor: draw the webmail's Tabler icons instead of Lucide 2026-10-15 14:13:02 +02:00
lucletoffeandClaude Opus 5.5 8ce53816af fix(composer): stop the body's first lines hiding under To/Cc/Subject on Android
Android's WebView shrinks the editor page's visual viewport to the part of
the view that is on screen and pans it to reveal the caret while typing.
The editor is an auto-height WebView inside the composer's ScrollView, so
nothing ever resets that pan: scrolled back to the top, the body is still
drawn shifted up by the pan offset and its first lines sit hidden under the
header fields until a scroll to the very bottom clamps it away.

The page now undoes every pan as it happens (scrolling the page top into
view, since window.scrollTo only moves the layout viewport) and reports the
offset as a `viewportShift` message; the composer scrolls its ScrollView by
the same amount so the caret stays visible. Android only; iOS and the other
RichTextEditor hosts keep their behaviour (hosts without the callback still
get the reset).

Reproduced and verified on a Pixel 4 (Android 13, WebView 153): before,
visualViewport.offsetTop stayed at ~18 px at the top of the composer and
cut the first line; after, it stays 0 and the caret still follows typing
above the keyboard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 08:49:57 +02:00
lucletoffeandClaude Opus 5.5 767d4b9f9f fix: keep a re-opened draft's attachments on live blobs across saves and sends
A draft's attachments and inline images are MIME parts of that draft, and
their blobIds die with the version that holds them. Each save creates the
next version and then destroys the previous one (#849), but the composer kept
sending the blobIds it was seeded with. The first save worked, every later
save failed with "blobId ... does not exist on this server", and so did the
send. This is the second half of webmail #849, which the webmail fixed in
5d2b937a but which was never ported here.

- saveDraftVersion() reads the new version's attachment parts back in the
  same request (Email/get on the creation id "#draft"; Stalwart 0.16 does not
  resolve a result reference into Email/set "created"). createDraft() keeps
  its signature on top of it.
- The composer maps each held blobId to the matching part of the latest
  version (inline by Content-ID, the rest by name and size) and sends that.
  Uploads are re-pointed too, since an unreferenced upload expires. The map
  lives in a ref, so the dirty snapshot does not change on every save.
- Opening a draft with inline images no longer looks like an edit. The
  hydration swapped the editor body for its normalised DOM HTML but compared
  it with the raw server HTML, so the autosave replaced the draft about two
  seconds after it opened. That destroyed the opened version, and a plain
  "open, then Send" failed with the same blobNotFound.

Tests: unit tests for the matching and rebasing, a fake-server test that
reproduces the failure without the rebase, and an opt-in live Stalwart test
(verified against Stalwart 0.16.22).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 08:47:21 +02:00
160 changed files with 4450 additions and 1629 deletions
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -1 +1 @@
0.1.76
0.1.79
+3
View File
@@ -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',
},
},
};
-1
View File
@@ -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
View File
@@ -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
View File
@@ -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\""
-15
View File
@@ -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;
+3 -14
View File
@@ -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
View File
@@ -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",
+82
View File
@@ -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);
}
}
-91
View File
@@ -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;
+241
View File
@@ -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();
+46
View File
@@ -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`);
+138
View File
@@ -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']);
});
});
+43
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 },
});
}
+6 -4
View File
@@ -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: {
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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
View File
@@ -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,
+3 -3
View File
@@ -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 -1
View File
@@ -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';
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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';
+17 -2
View File
@@ -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;
}
}
};
+8 -9
View File
@@ -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 -4
View File
@@ -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;
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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';
+11 -7
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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';
+1 -1
View File
@@ -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';
+16 -20
View File
@@ -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',
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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 -1
View File
@@ -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';
+3 -3
View File
@@ -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';
+1 -1
View File
@@ -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';
+2 -2
View File
@@ -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 -1
View File
@@ -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';
+1 -1
View File
@@ -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 -1
View File
@@ -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';
+1 -1
View File
@@ -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';
+30 -97
View File
@@ -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';
+22 -63
View File
@@ -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} />;
},
);
+1 -1
View File
@@ -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';
+6 -6
View File
@@ -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 -1
View File
@@ -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';
+12 -11
View File
@@ -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 },
});
}
+1 -1
View File
@@ -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';
+205
View File
@@ -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 };
}
+12 -116
View File
@@ -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} />;
}
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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);
});
});
+41
View File
@@ -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);
}
+357
View File
@@ -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 },
});
}
+202
View File
@@ -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';
+1 -1
View File
@@ -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';
+11 -1
View File
@@ -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';
+1 -1
View File
@@ -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: {
+1 -1
View File
@@ -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 -4
View File
@@ -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 },
+3 -11
View File
@@ -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,
+3 -9
View File
@@ -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: {
+2 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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,
-55
View File
@@ -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