Compare commits

..
5 Commits
76 changed files with 119741 additions and 91501 deletions
+38
View File
@@ -39,6 +39,9 @@ import {
type NotificationTapPayload,
} from './src/lib/push-notifications';
import { addUnifiedPushEndpointListener } from './src/lib/unified-push';
// Pause between a mute change and the push-filter update it sends (#78).
const MUTES_RESYNC_DELAY_MS = 1_000;
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
import ComposeScreen from './src/screens/ComposeScreen';
import EmailThreadScreen from './src/screens/EmailThreadScreen';
@@ -684,6 +687,41 @@ export default function App() {
};
}, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]);
// Muting a folder or account changes the server-side push filter (#78),
// which the setup above would only send at the next launch. Send it now,
// after a short pause so a few taps in a row make one update. Compared by
// content, since hydration hands back an equal but new object; the value
// seen first after hydration is the baseline, not a change.
const notificationMutes = useSettingsStore((s) => s.notificationMutes);
const settingsHydrated = useSettingsStore((s) => s.hydrated);
const mutesKey = JSON.stringify(notificationMutes);
const lastMutesKey = React.useRef<string | null>(null);
React.useEffect(() => {
if (!settingsHydrated) return;
if (lastMutesKey.current === null || lastMutesKey.current === mutesKey) {
lastMutesKey.current = mutesKey;
return;
}
lastMutesKey.current = mutesKey;
if (!isAuthenticated || !client || !emailNotificationsEnabled) return;
let cancelled = false;
const timer = setTimeout(async () => {
const relayBaseUrl = await getStoredRelayBaseUrl();
if (!relayBaseUrl || cancelled) return;
try {
await resyncPushNotifications({ relayBaseUrl, accountLabel: client.username ?? undefined });
} catch (error) {
console.warn('[push] mute update failed:', error instanceof Error ? error.message : error);
}
}, MUTES_RESYNC_DELAY_MS);
return () => {
cancelled = true;
clearTimeout(timer);
};
// Only a change of the mutes should send an update; the rest is read live.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mutesKey, settingsHydrated]);
// Live updates (SSE with polling fallback), re-armed on every account
// switch and every re-established session — the singleton `client` object
// never changes identity, so it cannot be the dependency on its own.
+1 -1
View File
@@ -1 +1 @@
0.1.82
0.1.83
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Debug builds load the bundle from Metro over http, which the main config
forbids (a network security config replaces the manifest's
usesCleartextTraffic). Same trust anchors as the release config.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>
+1 -1
View File
@@ -21,7 +21,7 @@
<data android:scheme="https"/>
</intent>
</queries>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules">
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules" android:networkSecurityConfig="@xml/network_security_config">
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Trust anchors for every connection the app makes (fetch, the client
certificate module, the sync adapters). Android 7+ trusts only the system
CAs unless the app opts in; a self-hosted server behind a private CA
installed under Settings > Security > Encryption & credentials would fail
with "Network request failed" otherwise (#3). Cleartext stays off, as it is
for an app targeting API 28+ without this file; the debug source set
overrides this for Metro.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+953 -92
View File
File diff suppressed because it is too large Load Diff
+951 -90
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4507 -3646
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+1 -5
View File
@@ -445,7 +445,6 @@
"open_error": "تعذّر فتح الملف",
"open_with": "فتح باستخدام…",
"preview_too_large": "هذا الملف كبير جدًا بحيث لا يمكن معاينته.",
"selected_count": "{count, plural, zero {تم تحديد #} one {تم تحديد عنصر واحد} two {تم تحديد عنصرين} few {تم تحديد # عناصر} many {تم تحديد # عنصرًا} other {تم تحديد # عنصر}}",
"share_add_people": "إضافة أشخاص",
"share_empty": "لم تتم المشاركة مع أي شخص بعد",
"share_error": "فشل تحديث المشاركة",
@@ -1102,7 +1101,6 @@
},
"tabs": {
"plugins": "الإضافات",
"themes": "السمات",
"updates": "التحديثات"
},
"templates": {
@@ -1119,9 +1117,7 @@
},
"themes": {
"default_description": "لوحات الألوان الفاتحة والداكنة الافتراضية.",
"default_name": "Bulwark",
"mobile_description": "اختر سمة ألوان. تتبع السمات إعداد الوضع الفاتح/الداكن من المظهر؛ وتُدار حزم السمات المخصصة في بريد الويب.",
"title": "السمات"
"mobile_description": "اختر سمة ألوان. تتبع السمات إعداد الوضع الفاتح/الداكن من المظهر؛ وتُدار حزم السمات المخصصة في بريد الويب."
},
"vacation": {
"warnings": {
+1 -5
View File
@@ -445,7 +445,6 @@
"open_error": "No s'ha pogut obrir el fitxer",
"open_with": "Obre amb…",
"preview_too_large": "Aquest fitxer és massa gran per previsualitzar-lo.",
"selected_count": "{count, plural, one {# seleccionat} other {# seleccionats}}",
"share_add_people": "Afegeix persones",
"share_empty": "Encara no s'ha compartit amb ningú",
"share_error": "No s'ha pogut actualitzar la compartició",
@@ -1102,7 +1101,6 @@
},
"tabs": {
"plugins": "Connectors",
"themes": "Temes",
"updates": "Actualitzacions"
},
"templates": {
@@ -1119,9 +1117,7 @@
},
"themes": {
"default_description": "Les paletes clara i fosca per defecte.",
"default_name": "Bulwark",
"mobile_description": "Trieu un tema de color. Els temes segueixen el paràmetre clar/fosc d'Aparença; els paquets de temes personalitzats es gestionen al webmail.",
"title": "Temes"
"mobile_description": "Trieu un tema de color. Els temes segueixen el paràmetre clar/fosc d'Aparença; els paquets de temes personalitzats es gestionen al webmail."
},
"vacation": {
"warnings": {
+1 -5
View File
@@ -445,7 +445,6 @@
"open_error": "Soubor se nepodařilo otevřít",
"open_with": "Otevřít pomocí…",
"preview_too_large": "Tento soubor je příliš velký pro náhled.",
"selected_count": "{count, plural, one {# vybraná} few {# vybrané} many {# vybrané} other {# vybraných}}",
"share_add_people": "Přidat osoby",
"share_empty": "Zatím nikomu nesdíleno",
"share_error": "Aktualizace sdílení selhala",
@@ -1102,7 +1101,6 @@
},
"tabs": {
"plugins": "Pluginy",
"themes": "Motivy",
"updates": "Aktualizace"
},
"templates": {
@@ -1119,9 +1117,7 @@
},
"themes": {
"default_description": "Výchozí světlé a tmavé palety.",
"default_name": "Bulwark",
"mobile_description": "Vyberte barevný motiv. Motivy se řídí nastavením světlý/tmavý ve Vzhledu; vlastní balíčky motivů se spravují ve webmailu.",
"title": "Motivy"
"mobile_description": "Vyberte barevný motiv. Motivy se řídí nastavením světlý/tmavý ve Vzhledu; vlastní balíčky motivů se spravují ve webmailu."
},
"vacation": {
"warnings": {
+1 -5
View File
@@ -445,7 +445,6 @@
"open_error": "Kunne ikke åbne filen",
"open_with": "Åbn med…",
"preview_too_large": "Denne fil er for stor til forhåndsvisning.",
"selected_count": "{count, plural, one {# valgt} other {# valgte}}",
"share_add_people": "Tilføj personer",
"share_empty": "Ikke delt med nogen endnu",
"share_error": "Kunne ikke opdatere deling",
@@ -1102,7 +1101,6 @@
},
"tabs": {
"plugins": "Plugins",
"themes": "Temaer",
"updates": "Opdateringer"
},
"templates": {
@@ -1119,9 +1117,7 @@
},
"themes": {
"default_description": "Standardpaletterne til lys og mørk.",
"default_name": "Bulwark",
"mobile_description": "Vælg et farvetema. Temaer følger indstillingen for lys/mørk under Udseende; brugerdefinerede temapakker administreres i webmailen.",
"title": "Temaer"
"mobile_description": "Vælg et farvetema. Temaer følger indstillingen for lys/mørk under Udseende; brugerdefinerede temapakker administreres i webmailen."
},
"vacation": {
"warnings": {
+1 -5
View File
@@ -451,7 +451,6 @@
"open_error": "Die Datei konnte nicht geöffnet werden",
"open_with": "Öffnen mit…",
"preview_too_large": "Diese Datei ist zu groß für eine Vorschau.",
"selected_count": "{count, plural, one {# ausgewählt} other {# ausgewählt}}",
"share_add_people": "Personen hinzufügen",
"share_empty": "Noch mit niemandem geteilt",
"share_error": "Freigabe konnte nicht aktualisiert werden",
@@ -1120,7 +1119,6 @@
},
"tabs": {
"plugins": "Plugins",
"themes": "Designs",
"updates": "Updates",
"storage": "Speicher & Sicherung",
"about": "Über",
@@ -1140,9 +1138,7 @@
},
"themes": {
"default_description": "Die Standardpaletten für Hell und Dunkel.",
"default_name": "Bulwark",
"mobile_description": "Wählen Sie ein Farbdesign. Designs folgen der Hell/Dunkel-Einstellung unter Darstellung; eigene Designpakete werden im Webmail verwaltet.",
"title": "Designs"
"mobile_description": "Wählen Sie ein Farbdesign. Designs folgen der Hell/Dunkel-Einstellung unter Darstellung; eigene Designpakete werden im Webmail verwaltet."
},
"vacation": {
"warnings": {
+61 -40
View File
@@ -284,7 +284,6 @@
"attach_camera": "Camera",
"attachment_reminder_body": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
"attachment_reminder_title": "Forgot an attachment?",
"attachment_too_large": "This file is larger than the server allows ({size} > {max}).",
"attachments_too_large_total": "The attachments would exceed the {max} this server allows per message.",
"editor_unavailable": "Could not read the message content. Copy your text, then close and reopen the composer.",
"format_plain_text": "Switch to plain text",
@@ -297,6 +296,7 @@
"no_sent_folder": "No Sent folder found",
"permission_camera": "Camera permission is required to take a photo.",
"permission_photos": "Photo library permission is required to attach images.",
"read_receipt_off": "Request a read receipt",
"recipient_add_contact": "Add to contacts",
"recipient_copy": "Copy address",
"recipient_move_to": "Move to {field}",
@@ -322,15 +322,14 @@
"send_timeout_body": "The message may already have gone out. Check your Sent folder before sending it again.",
"send_timeout_title": "No answer from the server",
"toolbar": {
"background_color": "Background color",
"insert_image": "Insert image",
"remove_background_color": "Remove background color",
"remove_link": "Remove link"
},
"undo_send_label": "Sending in {seconds}s…",
"upload_failed_short": "Upload failed",
"uploading": "Uploading...",
"uploading_pct": "Uploading {pct}%",
"use_template": "Template",
"validation": {
"subject_required": "Add a subject"
}
@@ -422,16 +421,16 @@
"forwarded": "Forwarded",
"load_failed": "Failed to load email",
"preview": {
"open_external": "Open in app",
"pdf_external": "PDFs open in your PDF viewer on this device.",
"unsupported": "No preview for this file type.",
"media_unsupported": "This file can't be played here.",
"pdf_protected": "This PDF is password-protected. Open it in another app to enter the password.",
"pdf_unreadable": "This PDF can't be shown here.",
"archive_files": "{count, plural, one {# file} other {# files}}",
"archive_first_files": "The first {count} files",
"archive_protected": "This archive is password-protected. Open it in another app to enter the password.",
"archive_unreadable": "This archive can't be opened here."
"archive_unreadable": "This archive can't be opened here.",
"media_unsupported": "This file can't be played here.",
"open_external": "Open in app",
"pdf_external": "PDFs open in your PDF viewer on this device.",
"pdf_protected": "This PDF is password-protected. Open it in another app to enter the password.",
"pdf_unreadable": "This PDF can't be shown here.",
"unsupported": "No preview for this file type."
},
"received_via": "to {name}",
"remove_color": "Remove tag",
@@ -462,7 +461,23 @@
"open_error": "Could not open the file",
"open_with": "Open with…",
"preview_too_large": "This file is too large to preview.",
"selected_count": "{count, plural, one {# selected} other {# selected}}",
"settings_behavior": "Behavior",
"settings_colored_icons": "Colored Icons",
"settings_colored_icons_desc": "Use colorful icons instead of monochrome",
"settings_default_sort": "Default Sort",
"settings_default_sort_desc": "Choose the default sorting for files",
"settings_default_view": "Default View",
"settings_default_view_desc": "Choose between grid and list layout",
"settings_display": "Display",
"settings_icons": "Icons",
"settings_show_hidden": "Show Hidden Files",
"settings_show_hidden_desc": "Display files and folders that start with a dot",
"settings_show_icons": "Show File Icons",
"settings_show_icons_desc": "Display icons next to files and folders",
"settings_show_thumbnails": "Show Thumbnails",
"settings_show_thumbnails_desc": "Display image previews instead of icons for image files",
"settings_sort_direction": "Sort Direction",
"settings_sort_direction_desc": "Choose ascending or descending order",
"share_add_people": "Add people",
"share_empty": "Not shared with anyone yet",
"share_error": "Failed to update sharing",
@@ -477,6 +492,7 @@
"share_unavailable": "Sharing is not available on this device",
"shared_with": "Shared with",
"sharing_access": "Sharing & access",
"upload": "Upload",
"upload_unavailable": "The file picker is not installed in this build. Rebuild the app to enable uploads.",
"uploading_progress": "Uploading {current}/{total} · {name}"
},
@@ -590,6 +606,10 @@
"qr_title": "Sign-in code",
"scan_desc": "The fastest way in if you're signed in on the web",
"scan_title": "Scan a sign-in code",
"server_cert_active": "Signing in with the client certificate \"{alias}\". Tap to choose another.",
"server_cert_clear": "Sign in without the certificate",
"server_cert_failed": "Could not pick a certificate",
"server_cert_hint": "Does your server ask for a client certificate? Pick one installed on this phone.",
"server_hint": "Same address you type in the browser. We'll add https:// for you.",
"server_label": "Server address",
"server_not_found": "We couldn't find a server for {domain}. If you self-host, the address is the one you use for webmail.",
@@ -843,12 +863,6 @@
"plain_text_mode": {
"description_mobile": "Compose and read in plain text only."
},
"reply_identity_match": {
"description": "Which received addresses count as yours. Exact address only picks one of your configured identities. Same domain also treats any other address on one of your identity domains as a catch-all alias and rewrites the From header to it. Choose exact address if those addresses are distribution lists rather than aliases.",
"domain": "Any address on my domains",
"exact": "Exact address only",
"label": "Received Address Matching"
},
"send_delay": {
"description_mobile": "Hold outgoing mail for a few seconds so you can cancel it. Requires server support."
},
@@ -986,6 +1000,8 @@
"email": {
"app_icon_badge": "Unread count on app icon",
"app_icon_badge_desc": "Show the inbox unread count as a badge on the app icon.",
"mute_account": "Mute this account",
"mute_account_desc": "No alerts for new mail in this account. Long-press a folder in the sidebar to mute or unmute it on its own.",
"sound_channel_hint": "Sound and vibration for mail alerts are controlled by the Android notification channel: long-press a notification or open the system app settings."
},
"push": {
@@ -1184,38 +1200,37 @@
"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": {
"composing": "Signature, send delay, drafts",
"contacts": "Address books, sorting",
"content_senders": "Remote images, trusted senders",
"downloads": "File names for saved mail",
"events_off": "events off",
"events_on": "events on",
"files": "View, sorting, thumbnails",
"filters": "Rules for incoming mail",
"folders": "Create, rename, roles",
"identities": "Addresses you send from",
"keywords": "Tag names and colours",
"layout": "Quick actions, swipes, list order",
"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"
"templates": "Reusable messages",
"updates": "Install new versions",
"vacation": "Automatic reply while away",
"version": "Version {version}",
"week_starts": "week starts {day}"
},
"tabs": {
"plugins": "Plugins",
"themes": "Themes",
"updates": "Updates",
"storage": "Storage & backup",
"about": "About",
"troubleshooting": "Troubleshooting"
"plugins": "Plugins",
"storage": "Storage & backup",
"troubleshooting": "Troubleshooting",
"updates": "Updates"
},
"templates": {
"delete_confirm": "Permanently delete \"{name}\"?",
@@ -1231,9 +1246,7 @@
},
"themes": {
"default_description": "The default light and dark palettes.",
"default_name": "Bulwark",
"mobile_description": "Pick a colour theme. Themes follow the light/dark setting from Appearance; custom theme packages are managed in the webmail.",
"title": "Themes"
"mobile_description": "Pick a colour theme. Themes follow the light/dark setting from Appearance; custom theme packages are managed in the webmail."
},
"troubleshooting": {
"cache_desc": "When a list looks stale or wrong.",
@@ -1242,6 +1255,14 @@
"title": "Troubleshooting"
},
"vacation": {
"date_range": {
"description": "Optionally limit the auto-reply to a specific period",
"title": "Date Range"
},
"message": {
"description": "The message that will be sent as a reply",
"title": "Auto-Reply Message"
},
"warnings": {
"date_format": "Dates must be \"YYYY-MM-DD\" or \"YYYY-MM-DD HH:MM\""
}
+1 -5
View File
@@ -445,7 +445,6 @@
"open_error": "No se pudo abrir el archivo",
"open_with": "Abrir con…",
"preview_too_large": "Este archivo es demasiado grande para la vista previa.",
"selected_count": "{count, plural, one {# seleccionado} other {# seleccionados}}",
"share_add_people": "Añadir personas",
"share_empty": "Aún no se ha compartido con nadie",
"share_error": "No se pudo actualizar el uso compartido",
@@ -1102,7 +1101,6 @@
},
"tabs": {
"plugins": "Plugins",
"themes": "Temas",
"updates": "Actualizaciones"
},
"templates": {
@@ -1119,9 +1117,7 @@
},
"themes": {
"default_description": "Las paletas clara y oscura predeterminadas.",
"default_name": "Bulwark",
"mobile_description": "Elige un tema de color. Los temas siguen el ajuste claro/oscuro de Apariencia; los paquetes de temas personalizados se gestionan en el webmail.",
"title": "Temas"
"mobile_description": "Elige un tema de color. Los temas siguen el ajuste claro/oscuro de Apariencia; los paquetes de temas personalizados se gestionan en el webmail."
},
"vacation": {
"warnings": {
+1 -5
View File
@@ -445,7 +445,6 @@
"open_error": "باز کردن فایل ممکن نشد",
"open_with": "باز کردن با…",
"preview_too_large": "این فایل برای پیش‌نمایش بیش از حد بزرگ است.",
"selected_count": "{count, plural, one {# انتخاب شده} other {# انتخاب شده}}",
"share_add_people": "افزودن افراد",
"share_empty": "هنوز با کسی به اشتراک گذاشته نشده",
"share_error": "به‌روزرسانی اشتراک‌گذاری ناموفق بود",
@@ -1102,7 +1101,6 @@
},
"tabs": {
"plugins": "افزونه‌ها",
"themes": "تم‌ها",
"updates": "به‌روزرسانی‌ها"
},
"templates": {
@@ -1119,9 +1117,7 @@
},
"themes": {
"default_description": "پالت‌های روشن و تاریک پیش‌فرض.",
"default_name": "Bulwark",
"mobile_description": "یک تم رنگی انتخاب کنید. تم‌ها از تنظیم روشن/تاریک بخش ظاهر پیروی می‌کنند؛ بسته‌های تم سفارشی در ایمیل تحت وب مدیریت می‌شوند.",
"title": "تم‌ها"
"mobile_description": "یک تم رنگی انتخاب کنید. تم‌ها از تنظیم روشن/تاریک بخش ظاهر پیروی می‌کنند؛ بسته‌های تم سفارشی در ایمیل تحت وب مدیریت می‌شوند."
},
"vacation": {
"warnings": {
+1 -5
View File
@@ -445,7 +445,6 @@
"open_error": "Impossible d'ouvrir le fichier",
"open_with": "Ouvrir avec…",
"preview_too_large": "Ce fichier est trop volumineux pour être prévisualisé.",
"selected_count": "{count, plural, one {# sélectionné} other {# sélectionnés}}",
"share_add_people": "Ajouter des personnes",
"share_empty": "Pas encore partagé",
"share_error": "Échec de la mise à jour du partage",
@@ -1102,7 +1101,6 @@
},
"tabs": {
"plugins": "Plugins",
"themes": "Thèmes",
"updates": "Mises à jour"
},
"templates": {
@@ -1119,9 +1117,7 @@
},
"themes": {
"default_description": "Les palettes claire et sombre par défaut.",
"default_name": "Bulwark",
"mobile_description": "Choisissez un thème de couleurs. Les thèmes suivent le réglage clair/sombre de l'Apparence. Les packs de thèmes personnalisés se gèrent dans le webmail.",
"title": "Thèmes"
"mobile_description": "Choisissez un thème de couleurs. Les thèmes suivent le réglage clair/sombre de l'Apparence. Les packs de thèmes personnalisés se gèrent dans le webmail."
},
"vacation": {
"warnings": {
+1 -5
View File
@@ -445,7 +445,6 @@
"open_error": "לא ניתן היה לפתוח את הקובץ",
"open_with": "פתח באמצעות…",
"preview_too_large": "הקובץ גדול מכדי להציג תצוגה מקדימה.",
"selected_count": "{count, plural, one {נבחר פריט אחד} two {נבחרו שני פריטים} other {נבחרו # פריטים}}",
"share_add_people": "הוסף אנשים",
"share_empty": "עדיין לא משותף עם אף אחד",
"share_error": "עדכון השיתוף נכשל",
@@ -1102,7 +1101,6 @@
},
"tabs": {
"plugins": "תוספים",
"themes": "ערכות נושא",
"updates": "עדכונים"
},
"templates": {
@@ -1119,9 +1117,7 @@
},
"themes": {
"default_description": "ערכות הצבעים הבהירה והכהה המוגדרות כברירת מחדל.",
"default_name": "Bulwark",
"mobile_description": "בחר ערכת צבעים. ערכות הנושא פועלות לפי הגדרת בהיר/כהה במראה; חבילות ערכות נושא מותאמות מנוהלות בדואר האינטרנט.",
"title": "ערכות נושא"
"mobile_description": "בחר ערכת צבעים. ערכות הנושא פועלות לפי הגדרת בהיר/כהה במראה; חבילות ערכות נושא מותאמות מנוהלות בדואר האינטרנט."
},
"vacation": {
"warnings": {
+1 -5
View File
@@ -445,7 +445,6 @@
"open_error": "Nem sikerült megnyitni a fájlt",
"open_with": "Megnyitás ezzel…",
"preview_too_large": "Ez a fájl túl nagy az előnézethez.",
"selected_count": "{count, plural, one {# kijelölve} other {# kijelölve}}",
"share_add_people": "Személyek hozzáadása",
"share_empty": "Még nincs megosztva senkivel",
"share_error": "Nem sikerült frissíteni a megosztást",
@@ -1102,7 +1101,6 @@
},
"tabs": {
"plugins": "Bővítmények",
"themes": "Témák",
"updates": "Frissítések"
},
"templates": {
@@ -1119,9 +1117,7 @@
},
"themes": {
"default_description": "Az alapértelmezett világos és sötét paletták.",
"default_name": "Bulwark",
"mobile_description": "Válassz színtémát. A témák a Megjelenés világos/sötét beállítását követik; az egyéni témacsomagokat a webmailben kezelheted.",
"title": "Témák"
"mobile_description": "Válassz színtémát. A témák a Megjelenés világos/sötét beállítását követik; az egyéni témacsomagokat a webmailben kezelheted."
},
"vacation": {
"warnings": {
+1 -5
View File
@@ -445,7 +445,6 @@
"open_error": "Impossibile aprire il file",
"open_with": "Apri con…",
"preview_too_large": "Questo file è troppo grande per l'anteprima.",
"selected_count": "{count, plural, one {# selezionato} other {# selezionati}}",
"share_add_people": "Aggiungi persone",
"share_empty": "Non ancora condiviso con nessuno",
"share_error": "Impossibile aggiornare la condivisione",
@@ -1102,7 +1101,6 @@
},
"tabs": {
"plugins": "Plugin",
"themes": "Temi",
"updates": "Aggiornamenti"
},
"templates": {
@@ -1119,9 +1117,7 @@
},
"themes": {
"default_description": "Le palette chiara e scura predefinite.",
"default_name": "Bulwark",
"mobile_description": "Scegli un tema di colori. I temi seguono l'impostazione chiaro/scuro di Aspetto; i pacchetti di temi personalizzati si gestiscono nella webmail.",
"title": "Temi"
"mobile_description": "Scegli un tema di colori. I temi seguono l'impostazione chiaro/scuro di Aspetto; i pacchetti di temi personalizzati si gestiscono nella webmail."
},
"vacation": {
"warnings": {
+1 -5
View File
@@ -445,7 +445,6 @@
"open_error": "ファイルを開けませんでした",
"open_with": "別のアプリで開く…",
"preview_too_large": "このファイルは大きすぎるため、プレビューできません。",
"selected_count": "{count, plural, other {#件選択中}}",
"share_add_people": "ユーザーを追加",
"share_empty": "まだ誰にも共有されていません",
"share_error": "共有の更新に失敗しました",
@@ -1102,7 +1101,6 @@
},
"tabs": {
"plugins": "プラグイン",
"themes": "テーマ",
"updates": "アップデート"
},
"templates": {
@@ -1119,9 +1117,7 @@
},
"themes": {
"default_description": "デフォルトのライト/ダークパレットです。",
"default_name": "Bulwark",
"mobile_description": "カラーテーマを選択します。テーマは「外観」のライト/ダーク設定に従います。カスタムテーマパッケージはウェブメールで管理します。",
"title": "テーマ"
"mobile_description": "カラーテーマを選択します。テーマは「外観」のライト/ダーク設定に従います。カスタムテーマパッケージはウェブメールで管理します。"
},
"vacation": {
"warnings": {
+1 -5
View File
@@ -445,7 +445,6 @@
"open_error": "파일을 열 수 없어요",
"open_with": "다른 앱으로 열기…",
"preview_too_large": "파일이 너무 커서 미리볼 수 없어요.",
"selected_count": "{count, plural, other {#개 선택됨}}",
"share_add_people": "사용자 추가",
"share_empty": "아직 아무와도 공유하지 않았어요",
"share_error": "공유 업데이트에 실패했어요",
@@ -1102,7 +1101,6 @@
},
"tabs": {
"plugins": "플러그인",
"themes": "테마",
"updates": "업데이트"
},
"templates": {
@@ -1119,9 +1117,7 @@
},
"themes": {
"default_description": "기본 라이트 및 다크 팔레트예요.",
"default_name": "Bulwark",
"mobile_description": "색상 테마를 선택하세요. 테마는 화면 설정의 라이트/다크 설정을 따르며, 사용자 지정 테마 패키지는 웹메일에서 관리해요.",
"title": "테마"
"mobile_description": "색상 테마를 선택하세요. 테마는 화면 설정의 라이트/다크 설정을 따르며, 사용자 지정 테마 패키지는 웹메일에서 관리해요."
},
"vacation": {
"warnings": {
+1 -5
View File
@@ -445,7 +445,6 @@
"open_error": "Neizdevās atvērt failu",
"open_with": "Atvērt ar…",
"preview_too_large": "Šis fails ir pārāk liels priekšskatīšanai.",
"selected_count": "{count, plural, zero {Atlasīti #} one {Atlasīts #} other {Atlasīti #}}",
"share_add_people": "Pievienot personas",
"share_empty": "Vēl nav kopīgots ne ar vienu",
"share_error": "Neizdevās atjaunināt kopīgošanu",
@@ -1102,7 +1101,6 @@
},
"tabs": {
"plugins": "Spraudņi",
"themes": "Tēmas",
"updates": "Atjauninājumi"
},
"templates": {
@@ -1119,9 +1117,7 @@
},
"themes": {
"default_description": "Noklusējuma gaišās un tumšās paletes.",
"default_name": "Bulwark",
"mobile_description": "Izvēlieties krāsu tēmu. Tēmas seko gaišā/tumšā režīma iestatījumam sadaļā Izskats; pielāgotas tēmu pakotnes tiek pārvaldītas tīmekļa pastā.",
"title": "Tēmas"
"mobile_description": "Izvēlieties krāsu tēmu. Tēmas seko gaišā/tumšā režīma iestatījumam sadaļā Izskats; pielāgotas tēmu pakotnes tiek pārvaldītas tīmekļa pastā."
},
"vacation": {
"warnings": {
+1 -5
View File
@@ -445,7 +445,6 @@
"open_error": "Файлыг нээж чадсангүй",
"open_with": "Өөр аппаар нээх…",
"preview_too_large": "Энэ файл урьдчилан харахад хэт том байна.",
"selected_count": "{count, plural, one {Сонгосон: #} other {Сонгосон: #}}",
"share_add_people": "Хүн нэмэх",
"share_empty": "Одоохондоо хэнтэй ч хуваалцаагүй",
"share_error": "Хуваалцалтыг шинэчилж чадсангүй",
@@ -1102,7 +1101,6 @@
},
"tabs": {
"plugins": "Плагинууд",
"themes": "Өнгөний загварууд",
"updates": "Шинэчлэлт"
},
"templates": {
@@ -1119,9 +1117,7 @@
},
"themes": {
"default_description": "Өгөгдмөл цайвар ба харанхуй палитр.",
"default_name": "Bulwark",
"mobile_description": "Өнгөний загвар сонгоно уу. Өнгөний загварууд Гадаад төрх хэсгийн цайвар/харанхуй тохиргоог дагана; өөрийн загварын багцуудыг вэбмэйлд удирдана.",
"title": "Өнгөний загварууд"
"mobile_description": "Өнгөний загвар сонгоно уу. Өнгөний загварууд Гадаад төрх хэсгийн цайвар/харанхуй тохиргоог дагана; өөрийн загварын багцуудыг вэбмэйлд удирдана."
},
"vacation": {
"warnings": {
+1 -5
View File
@@ -445,7 +445,6 @@
"open_error": "Kunne ikke åpne filen",
"open_with": "Åpne med…",
"preview_too_large": "Denne filen er for stor til å forhåndsvises.",
"selected_count": "{count, plural, one {# valgt} other {# valgte}}",
"share_add_people": "Legg til personer",
"share_empty": "Ikke delt med noen ennå",
"share_error": "Kunne ikke oppdatere deling",
@@ -1102,7 +1101,6 @@
},
"tabs": {
"plugins": "Programtillegg",
"themes": "Temaer",
"updates": "Oppdateringer"
},
"templates": {
@@ -1119,9 +1117,7 @@
},
"themes": {
"default_description": "Standardpalettene for lyst og mørkt.",
"default_name": "Bulwark",
"mobile_description": "Velg et fargetema. Temaene følger innstillingen for lyst/mørkt under Utseende; egendefinerte temapakker administreres i nettposten.",
"title": "Temaer"
"mobile_description": "Velg et fargetema. Temaene følger innstillingen for lyst/mørkt under Utseende; egendefinerte temapakker administreres i nettposten."
},
"vacation": {
"warnings": {
+1 -5
View File
@@ -445,7 +445,6 @@
"open_error": "Kon het bestand niet openen",
"open_with": "Openen met…",
"preview_too_large": "Dit bestand is te groot voor een voorbeeld.",
"selected_count": "{count, plural, one {# geselecteerd} other {# geselecteerd}}",
"share_add_people": "Personen toevoegen",
"share_empty": "Nog met niemand gedeeld",
"share_error": "Delen kon niet worden bijgewerkt",
@@ -1102,7 +1101,6 @@
},
"tabs": {
"plugins": "Plug-ins",
"themes": "Thema's",
"updates": "Updates"
},
"templates": {
@@ -1119,9 +1117,7 @@
},
"themes": {
"default_description": "De standaard lichte en donkere paletten.",
"default_name": "Bulwark",
"mobile_description": "Kies een kleurthema. Thema's volgen de instelling licht/donker onder Uiterlijk; aangepaste themapakketten worden beheerd in de webmail.",
"title": "Thema's"
"mobile_description": "Kies een kleurthema. Thema's volgen de instelling licht/donker onder Uiterlijk; aangepaste themapakketten worden beheerd in de webmail."
},
"vacation": {
"warnings": {
+1 -5
View File
@@ -445,7 +445,6 @@
"open_error": "Nie udało się otworzyć pliku",
"open_with": "Otwórz za pomocą…",
"preview_too_large": "Ten plik jest zbyt duży, aby wyświetlić podgląd.",
"selected_count": "{count, plural, one {# zaznaczony} few {# zaznaczone} many {# zaznaczonych} other {# zaznaczonego}}",
"share_add_people": "Dodaj osoby",
"share_empty": "Jeszcze nikomu nie udostępniono",
"share_error": "Nie udało się zaktualizować udostępniania",
@@ -1102,7 +1101,6 @@
},
"tabs": {
"plugins": "Wtyczki",
"themes": "Motywy",
"updates": "Aktualizacje"
},
"templates": {
@@ -1119,9 +1117,7 @@
},
"themes": {
"default_description": "Domyślne jasne i ciemne palety kolorów.",
"default_name": "Bulwark",
"mobile_description": "Wybierz motyw kolorystyczny. Motywy stosują ustawienie jasny/ciemny z sekcji Wygląd; własnymi pakietami motywów zarządza się w webmailu.",
"title": "Motywy"
"mobile_description": "Wybierz motyw kolorystyczny. Motywy stosują ustawienie jasny/ciemny z sekcji Wygląd; własnymi pakietami motywów zarządza się w webmailu."
},
"vacation": {
"warnings": {
+1 -5
View File
@@ -445,7 +445,6 @@
"open_error": "Não foi possível abrir o arquivo",
"open_with": "Abrir com…",
"preview_too_large": "Este arquivo é grande demais para pré-visualizar.",
"selected_count": "{count, plural, one {# selecionado} other {# selecionados}}",
"share_add_people": "Adicionar pessoas",
"share_empty": "Ainda não compartilhado com ninguém",
"share_error": "Falha ao atualizar o compartilhamento",
@@ -1102,7 +1101,6 @@
},
"tabs": {
"plugins": "Plugins",
"themes": "Temas",
"updates": "Atualizações"
},
"templates": {
@@ -1119,9 +1117,7 @@
},
"themes": {
"default_description": "As paletas clara e escura padrão.",
"default_name": "Bulwark",
"mobile_description": "Escolha um tema de cores. Os temas seguem a configuração clara/escura de Aparência; pacotes de temas personalizados são gerenciados no webmail.",
"title": "Temas"
"mobile_description": "Escolha um tema de cores. Os temas seguem a configuração clara/escura de Aparência; pacotes de temas personalizados são gerenciados no webmail."
},
"vacation": {
"warnings": {
+1 -5
View File
@@ -445,7 +445,6 @@
"open_error": "Fișierul nu a putut fi deschis",
"open_with": "Deschideți cu…",
"preview_too_large": "Acest fișier este prea mare pentru previzualizare.",
"selected_count": "{count, plural, one {# element selectat} few {# elemente selectate} other {# de elemente selectate}}",
"share_add_people": "Adăugați persoane",
"share_empty": "Nu a fost partajat încă cu nimeni",
"share_error": "Nu s-a putut actualiza partajarea",
@@ -1102,7 +1101,6 @@
},
"tabs": {
"plugins": "Pluginuri",
"themes": "Teme",
"updates": "Actualizări"
},
"templates": {
@@ -1119,9 +1117,7 @@
},
"themes": {
"default_description": "Paletele implicite, luminoasă și întunecată.",
"default_name": "Bulwark",
"mobile_description": "Alegeți o temă de culori. Temele urmează setarea luminos/întunecat din Aspect; pachetele de teme personalizate se gestionează în webmail.",
"title": "Teme"
"mobile_description": "Alegeți o temă de culori. Temele urmează setarea luminos/întunecat din Aspect; pachetele de teme personalizate se gestionează în webmail."
},
"vacation": {
"warnings": {
+1 -5
View File
@@ -445,7 +445,6 @@
"open_error": "Не удалось открыть файл",
"open_with": "Открыть с помощью…",
"preview_too_large": "Файл слишком большой для предпросмотра.",
"selected_count": "{count, plural, one {Выбрано: #} few {Выбрано: #} many {Выбрано: #} other {Выбрано: #}}",
"share_add_people": "Добавить людей",
"share_empty": "Пока никому не предоставлен доступ",
"share_error": "Не удалось обновить общий доступ",
@@ -1102,7 +1101,6 @@
},
"tabs": {
"plugins": "Плагины",
"themes": "Темы",
"updates": "Обновления"
},
"templates": {
@@ -1119,9 +1117,7 @@
},
"themes": {
"default_description": "Стандартные светлая и тёмная палитры.",
"default_name": "Bulwark",
"mobile_description": "Выберите цветовую тему. Темы учитывают светлый или тёмный режим из раздела «Внешний вид»; собственными пакетами тем можно управлять в веб-почте.",
"title": "Темы"
"mobile_description": "Выберите цветовую тему. Темы учитывают светлый или тёмный режим из раздела «Внешний вид»; собственными пакетами тем можно управлять в веб-почте."
},
"vacation": {
"warnings": {
+1 -5
View File
@@ -445,7 +445,6 @@
"open_error": "Súbor sa nepodarilo otvoriť",
"open_with": "Otvoriť pomocou…",
"preview_too_large": "Tento súbor je príliš veľký na náhľad.",
"selected_count": "{count, plural, one {# vybraná} few {# vybrané} many {# vybranej} other {# vybraných}}",
"share_add_people": "Pridať osoby",
"share_empty": "Zatiaľ nikomu nezdieľané",
"share_error": "Aktualizácia zdieľania zlyhala",
@@ -1102,7 +1101,6 @@
},
"tabs": {
"plugins": "Pluginy",
"themes": "Témy",
"updates": "Aktualizácie"
},
"templates": {
@@ -1119,9 +1117,7 @@
},
"themes": {
"default_description": "Predvolené svetlé a tmavé palety.",
"default_name": "Bulwark",
"mobile_description": "Vyberte farebnú tému. Témy sa riadia nastavením svetlý/tmavý vo Vzhľade; vlastné balíky tém sa spravujú vo webmaile.",
"title": "Témy"
"mobile_description": "Vyberte farebnú tému. Témy sa riadia nastavením svetlý/tmavý vo Vzhľade; vlastné balíky tém sa spravujú vo webmaile."
},
"vacation": {
"warnings": {
+1 -5
View File
@@ -445,7 +445,6 @@
"open_error": "Dosya açılamadı",
"open_with": "Birlikte aç…",
"preview_too_large": "Bu dosya, önizleme için çok büyük.",
"selected_count": "{count, plural, one {# seçildi} other {# seçildi}}",
"share_add_people": "Kişi ekle",
"share_empty": "Henüz kimseyle paylaşılmadı",
"share_error": "Paylaşım güncellenemedi",
@@ -1102,7 +1101,6 @@
},
"tabs": {
"plugins": "Eklentiler",
"themes": "Temalar",
"updates": "Güncellemeler"
},
"templates": {
@@ -1119,9 +1117,7 @@
},
"themes": {
"default_description": "Varsayılan açık ve koyu paletler.",
"default_name": "Bulwark",
"mobile_description": "Bir renk teması seçin. Temalar Görünüm'deki açık/koyu ayarını izler; özel tema paketleri webmail'de yönetilir.",
"title": "Temalar"
"mobile_description": "Bir renk teması seçin. Temalar Görünüm'deki açık/koyu ayarını izler; özel tema paketleri webmail'de yönetilir."
},
"vacation": {
"warnings": {
+1 -5
View File
@@ -445,7 +445,6 @@
"open_error": "Не вдалося відкрити файл",
"open_with": "Відкрити за допомогою…",
"preview_too_large": "Файл завеликий для попереднього перегляду.",
"selected_count": "{count, plural, one {Вибрано: #} few {Вибрано: #} many {Вибрано: #} other {Вибрано: #}}",
"share_add_people": "Додати людей",
"share_empty": "Поки що ні з ким не поділено",
"share_error": "Не вдалося оновити спільний доступ",
@@ -1102,7 +1101,6 @@
},
"tabs": {
"plugins": "Плагіни",
"themes": "Теми",
"updates": "Оновлення"
},
"templates": {
@@ -1119,9 +1117,7 @@
},
"themes": {
"default_description": "Стандартні світла й темна палітри.",
"default_name": "Bulwark",
"mobile_description": "Виберіть кольорову тему. Теми враховують світлий або темний режим із розділу «Зовнішній вигляд»; власними пакетами тем можна керувати у веб-пошті.",
"title": "Теми"
"mobile_description": "Виберіть кольорову тему. Теми враховують світлий або темний режим із розділу «Зовнішній вигляд»; власними пакетами тем можна керувати у веб-пошті."
},
"vacation": {
"warnings": {
+1 -5
View File
@@ -445,7 +445,6 @@
"open_error": "無法開啟檔案",
"open_with": "以其他應用程式開啟…",
"preview_too_large": "此檔案太大,無法預覽。",
"selected_count": "{count, plural, other {已選取 # 項}}",
"share_add_people": "新增使用者",
"share_empty": "尚未與任何人分享",
"share_error": "無法更新分享設定",
@@ -1102,7 +1101,6 @@
},
"tabs": {
"plugins": "外掛程式",
"themes": "主題",
"updates": "更新"
},
"templates": {
@@ -1119,9 +1117,7 @@
},
"themes": {
"default_description": "預設的淺色與深色配色。",
"default_name": "Bulwark",
"mobile_description": "選擇色彩主題。主題會依照「外觀」中的淺色/深色設定;自訂主題套件請在網頁郵件中管理。",
"title": "主題"
"mobile_description": "選擇色彩主題。主題會依照「外觀」中的淺色/深色設定;自訂主題套件請在網頁郵件中管理。"
},
"vacation": {
"warnings": {
+1 -5
View File
@@ -445,7 +445,6 @@
"open_error": "无法打开文件",
"open_with": "打开方式…",
"preview_too_large": "此文件过大,无法预览。",
"selected_count": "{count, plural, other {已选择 # 项}}",
"share_add_people": "添加用户",
"share_empty": "尚未与任何人共享",
"share_error": "更新共享失败",
@@ -1102,7 +1101,6 @@
},
"tabs": {
"plugins": "插件",
"themes": "主题",
"updates": "更新"
},
"templates": {
@@ -1119,9 +1117,7 @@
},
"themes": {
"default_description": "默认的浅色和深色配色。",
"default_name": "Bulwark",
"mobile_description": "选择颜色主题。主题会跟随“外观”中的浅色/深色设置;自定义主题包需在网页邮箱中管理。",
"title": "主题"
"mobile_description": "选择颜色主题。主题会跟随“外观”中的浅色/深色设置;自定义主题包需在网页邮箱中管理。"
},
"vacation": {
"warnings": {
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.82",
"version": "0.1.83",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.82",
"version": "0.1.83",
"hasInstallScript": true,
"license": "AGPL-3.0-only",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.82",
"version": "0.1.83",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
+1
View File
@@ -37,6 +37,7 @@ const MAP = {
ArrowUpNarrowWide: 'sort-ascending',
Ban: 'ban',
Bell: 'bell',
BellOff: 'bell-off',
BellRing: 'bell-ringing',
Bold: 'bold',
Book: 'book-2',
+76 -3
View File
@@ -9,7 +9,7 @@ import {
Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Star,
Folder, FolderOpen, ChevronDown, ChevronRight, X, Settings, LogOut, Check, Plus,
Clock, Layers, Users, Tag, Mails, MailOpen, StickyNote, AlarmClock, Flag,
CheckCheck, Eraser, FolderPlus, Pencil, AlertTriangle, UserMinus,
CheckCheck, Eraser, FolderPlus, Pencil, AlertTriangle, UserMinus, Bell, BellOff, RotateCcw,
type AppIcon,
} from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
@@ -40,6 +40,11 @@ import type { Mailbox } from '../api/types';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { confirmSignOut } from '../lib/sign-out-confirm';
import { setPendingSettingsTab } from '../navigation/pending-settings-tab';
import {
isAccountMuted, isMailboxMuted, mailboxOverridesForAccount, resetMailboxMutes, setAccountMuted, setMailboxMuted,
type NotificationMutes,
} from '../lib/notification-mutes';
import { serverSupportsEmailPush } from '../lib/push-notifications';
const CHEVRON_SLOT = 20;
const INDENT_STEP = 12;
@@ -157,6 +162,8 @@ interface SidebarRowProps {
showTotal: boolean;
hasChildren: boolean;
isExpanded: boolean;
/** New mail here does not notify (#78). */
muted?: boolean;
onPress: () => void;
onLongPress?: () => void;
onToggleExpand: () => void;
@@ -165,7 +172,7 @@ interface SidebarRowProps {
function SidebarRow({
icon, label, depth, isSelected, isAccountHeader, unread, total, showTotal,
hasChildren, isExpanded, onPress, onLongPress, onToggleExpand, onPressUnread,
hasChildren, isExpanded, muted, onPress, onLongPress, onToggleExpand, onPressUnread,
}: SidebarRowProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -215,6 +222,11 @@ function SidebarRow({
>
{label}
</Text>
{muted && (
<View style={styles.rowMuted} accessibilityLabel={t('sidebar.notifications_muted', 'Notifications muted')}>
<BellOff size={12} color={c.textMuted} />
</View>
)}
<RowCounts unread={unread} total={total} showTotal={showTotal} onPressUnread={onPressUnread} />
</Pressable>
);
@@ -334,6 +346,8 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
const showFolderTotalCount = useSettingsStore((s) => s.showFolderTotalCount);
const includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified);
const unifiedCrossAccount = useSettingsStore((s) => s.unifiedCrossAccount);
const notificationMutes = useSettingsStore((s) => s.notificationMutes);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const keywordDefs = useKeywordsStore((s) => s.keywords);
const keywordsHydrated = useKeywordsStore((s) => s.hydrated);
const hydrateKeywords = useKeywordsStore((s) => s.hydrate);
@@ -459,6 +473,41 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
id: mb.originalId ?? mb.id,
accountId: mb.isShared ? mb.accountId : undefined,
});
// Notification mutes (#78). Muting only takes effect through the server's
// emailPush filter, so the actions show only where that exists. The
// settings are keyed by the server's ids: a folder of the own account may
// carry no accountId, so the client's stands in.
let primaryJmapAccountId: string | null = null;
try { primaryJmapAccountId = jmapClient.accountId; } catch { primaryJmapAccountId = null; }
const canMute = primaryJmapAccountId !== null && serverSupportsEmailPush();
const muteAccountOf = (accountId?: string | null): string => accountId || primaryJmapAccountId || '';
const saveMutes = (next: NotificationMutes) => updateSetting('notificationMutes', next);
const muteActionsForAccount = (accountId: string): SheetAction[] => {
const muted = isAccountMuted(notificationMutes, accountId);
const overrides = mailboxOverridesForAccount(notificationMutes, accountId);
const actions: SheetAction[] = [{
key: 'mute',
label: muted
? t('mailbox_context_menu.unmute_account_notifications', 'Unmute notifications for this account')
: t('mailbox_context_menu.mute_account_notifications', 'Mute notifications for this account'),
icon: muted ? Bell : BellOff,
onPress: () => saveMutes(setAccountMuted(notificationMutes, accountId, !muted)),
}];
if (overrides.muted.length + overrides.unmuted.length > 0) {
actions.push({
key: 'reset-mutes',
label: t('mailbox_context_menu.reset_folder_notifications', 'Reset folder notification settings'),
icon: RotateCcw,
onPress: () => saveMutes(resetMailboxMutes(notificationMutes, accountId)),
});
}
return actions;
};
const openOwnAccountSheet = () => {
if (!canMute) return;
setSheet({ title: username ?? t('sidebar.folders', 'Folders'), actions: muteActionsForAccount(muteAccountOf()) });
};
const runFolderAction = async (label: string, work: () => Promise<unknown>, affectsCurrent: boolean) => {
setBusy(true);
try {
@@ -499,6 +548,18 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
if (subtree.length > 1) {
actions.push({ key: 'tree', label: t('mailbox_context_menu.mark_folder_tree_read', 'Mark folder & subfolders as read'), icon: CheckCheck, onPress: () => markRead(subtree) });
}
if (canMute) {
const accountId = muteAccountOf(mb.accountId);
const muted = isMailboxMuted(notificationMutes, accountId, ref.id);
actions.push({
key: 'mute',
label: muted
? t('mailbox_context_menu.unmute_notifications', 'Unmute notifications')
: t('mailbox_context_menu.mute_notifications', 'Mute notifications'),
icon: muted ? Bell : BellOff,
onPress: () => saveMutes(setMailboxMuted(notificationMutes, accountId, ref.id, !muted)),
});
}
if (!mb.isShared) {
actions.push({
key: 'all',
@@ -622,6 +683,7 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
onPress: () => markRead(mailboxes.filter((m) => m.isShared && m.accountId === accountId).map((m) => m.id)),
},
];
if (canMute) actions.push(...muteActionsForAccount(accountId));
if (canCreate) {
actions.push({
key: 'new',
@@ -1036,7 +1098,14 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
)}
{/* Folders section */}
<Pressable style={styles.sectionHeader} onPress={() => toggleSection('foldersExpanded', setFoldersExpanded)}>
<Pressable
style={styles.sectionHeader}
onPress={() => toggleSection('foldersExpanded', setFoldersExpanded)}
// The own account's notification mutes (#78), like the webmail's
// menu on this header.
onLongPress={canMute ? openOwnAccountSheet : undefined}
delayLongPress={350}
>
{foldersExpanded ? (
<ChevronDown size={14} color={c.textMuted} />
) : (
@@ -1090,6 +1159,9 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
unread={node.unreadEmails}
total={node.totalEmails}
showTotal={showFolderTotalCount && !node.isAccountNode}
muted={canMute && (node.isAccountNode
? isAccountMuted(notificationMutes, muteAccountOf(node.accountId))
: isMailboxMuted(notificationMutes, muteAccountOf(node.accountId), node.originalId ?? node.id))}
hasChildren={hasChildren}
isExpanded={isExpanded}
onPress={
@@ -1458,6 +1530,7 @@ function makeStyles(c: ThemePalette) {
...typography.bodySemibold,
color: c.text,
},
rowMuted: { marginLeft: spacing.xs },
rowLabelAccount: {
fontSize: 13,
lineHeight: 18,
+3 -5
View File
@@ -9,7 +9,7 @@ import { useColors } from '../../theme/colors';
import SenderAvatar from '../SenderAvatar';
import { useSettingsStore } from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store';
import { useKeywordsStore, keywordToken } from '../../stores/keywords-store';
import { useKeywordsStore } from '../../stores/keywords-store';
import { emailDisplayDate, formatHeaderDate, formatHeaderTime, formatFullDateTime } from '../../lib/email-date';
import {
deriveHeaderInfo, deliveryDeltaMs, formatDelta, isAuthenticationSpoofed, findReceivingIdentity,
@@ -17,6 +17,7 @@ import {
} from '../../lib/email-headers';
import { formatSize } from '../../lib/attachment-display';
import { isSmimeEmail } from '../../lib/smime';
import { resolveEmailTags } from '../../lib/thread-utils';
interface Props {
email: Email;
@@ -121,10 +122,7 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
return null;
}, [spoofed, identities, from?.email, email]);
const tags = React.useMemo(
() => keywordDefs.filter((kw) => !!email.keywords?.[keywordToken(kw.id)]),
[keywordDefs, email.keywords],
);
const tags = React.useMemo(() => resolveEmailTags(email.keywords, keywordDefs), [keywordDefs, email.keywords]);
const important = !!email.keywords?.$important;
const answered = !!email.keywords?.$answered;
const forwarded = !!email.keywords?.$forwarded;
+2
View File
@@ -29,6 +29,7 @@ const G = {
'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-off': ["M9.346 5.353c.21 -.129 .428 -.246 .654 -.353a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3m-1 3h-13a4 4 0 0 0 2 -3v-3a6.996 6.996 0 0 1 1.273 -3.707","M9 17v1a3 3 0 0 0 6 0v-1","M3 3l18 18"],
'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"],
@@ -190,6 +191,7 @@ 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 BellOff = makeIcon('BellOff', G['bell-off']);
export const BellRing = makeIcon('BellRing', G['bell-ringing']);
export const Bold = makeIcon('Bold', G['bold']);
export const Book = makeIcon('Book', G['book-2']);
@@ -27,6 +27,7 @@ import {
PushSetupError,
readPushAccountIds,
revokePushDevice,
serverSupportsEmailPush,
setStoredRelayBaseUrl,
setupPushNotifications,
getStoredPushTransport,
@@ -42,6 +43,8 @@ import {
saveUnifiedPushDistributor,
} from '../../lib/unified-push';
import { canBadgeAppIcon } from '../../lib/app-badge';
import { isAccountMuted, setAccountMuted } from '../../lib/notification-mutes';
import { jmapClient } from '../../api/jmap-client';
type PushStatus =
| { kind: 'idle' }
@@ -78,6 +81,14 @@ export function NotificationSettings() {
const hydrate = useSettingsStore((s) => s.hydrate);
const update = useSettingsStore((s) => s.updateSetting);
const emailEnabled = useSettingsStore((s) => s.emailNotificationsEnabled);
const notificationMutes = useSettingsStore((s) => s.notificationMutes);
// Muting works through the server's emailPush filter (#78): offered only
// where it exists, for the account this screen shows. Single folders are
// muted from the sidebar.
let ownJmapAccountId: string | null = null;
try { ownJmapAccountId = jmapClient.accountId; } catch { ownJmapAccountId = null; }
const canMuteAccount = ownJmapAccountId !== null && serverSupportsEmailPush();
const accountMuted = ownJmapAccountId !== null && isAccountMuted(notificationMutes, ownJmapAccountId);
const calEnabled = useSettingsStore((s) => s.calendarNotificationsEnabled);
const invitationParsing = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
const appIconBadge = useSettingsStore((s) => s.appIconUnreadBadge);
@@ -467,6 +478,22 @@ export function NotificationSettings() {
>
<ToggleSwitch checked={emailEnabled} onChange={(v) => update('emailNotificationsEnabled', v)} />
</SettingItem>
{canMuteAccount && (
<SettingItem
label={t('settings.notifications.email.mute_account', 'Mute this account')}
description={t(
'settings.notifications.email.mute_account_desc',
'No alerts for new mail in this account. Long-press a folder in the sidebar to mute or unmute it on its own.',
)}
>
<ToggleSwitch
checked={accountMuted}
onChange={(v) => {
if (ownJmapAccountId) update('notificationMutes', setAccountMuted(notificationMutes, ownJmapAccountId, v));
}}
/>
</SettingItem>
)}
{canBadgeAppIcon() && (
<SettingItem
label={t('settings.notifications.email.app_icon_badge', 'Unread count on app icon')}
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import {
EMPTY_NOTIFICATION_MUTES,
isMailboxMuted,
mailboxOverridesForAccount,
normalizeNotificationMutes,
resetMailboxMutes,
setAccountMuted,
setMailboxMuted,
} from '../notification-mutes';
describe('notification mutes (#78)', () => {
it('lets a folder follow its account until it has its own setting', () => {
const accountMuted = setAccountMuted(EMPTY_NOTIFICATION_MUTES, 'a', true);
expect(isMailboxMuted(accountMuted, 'a', 'reports')).toBe(true);
expect(isMailboxMuted(accountMuted, 'b', 'reports')).toBe(false);
const unmutedFolder = setMailboxMuted(accountMuted, 'a', 'inbox', false);
expect(isMailboxMuted(unmutedFolder, 'a', 'inbox')).toBe(false);
expect(isMailboxMuted(unmutedFolder, 'a', 'reports')).toBe(true);
// The folder's own setting wins over the account in both directions.
const accountBack = setAccountMuted(unmutedFolder, 'a', false);
const mutedFolder = setMailboxMuted(accountBack, 'a', 'renovate', true);
expect(isMailboxMuted(mutedFolder, 'a', 'renovate')).toBe(true);
expect(isMailboxMuted(mutedFolder, 'a', 'reports')).toBe(false);
});
it('drops the folder setting when it is set back to what the account says', () => {
const muted = setMailboxMuted(EMPTY_NOTIFICATION_MUTES, 'a', 'renovate', true);
const unmuted = setMailboxMuted(muted, 'a', 'renovate', false);
expect(unmuted.mailboxes).toEqual({});
// So muting the account later reaches this folder too.
expect(isMailboxMuted(setAccountMuted(unmuted, 'a', true), 'a', 'renovate')).toBe(true);
});
it('resets the folder settings of one account only', () => {
let mutes = setMailboxMuted(EMPTY_NOTIFICATION_MUTES, 'a', 'x', true);
mutes = setMailboxMuted(mutes, 'ab', 'y', true);
mutes = resetMailboxMutes(mutes, 'a');
expect(mailboxOverridesForAccount(mutes, 'a')).toEqual({ muted: [], unmuted: [] });
// An account id that merely starts with "a" keeps its settings.
expect(mailboxOverridesForAccount(mutes, 'ab')).toEqual({ muted: ['y'], unmuted: [] });
});
it('repairs malformed stored values', () => {
expect(normalizeNotificationMutes(null)).toEqual(EMPTY_NOTIFICATION_MUTES);
expect(normalizeNotificationMutes(['a'])).toEqual(EMPTY_NOTIFICATION_MUTES);
expect(normalizeNotificationMutes({ accounts: ['a', 3, ''], mailboxes: { 'a/x': true, 'a/y': 'yes' } }))
.toEqual({ accounts: ['a'], mailboxes: { 'a/x': true } });
});
});
@@ -19,6 +19,7 @@ vi.mock('../../api/email', () => ({
}));
import { getMailboxes, getSharedMailboxes } from '../../api/email';
import { EMPTY_NOTIFICATION_MUTES, type NotificationMutes } from '../notification-mutes';
import { buildEmailPushConfig } from '../push-notifications';
import { provideLoadedMailboxes } from '../mailbox-source';
import { generateAccountId } from '../account-utils';
@@ -65,3 +66,72 @@ describe('push filter folders', () => {
});
});
});
// Muted accounts and folders (#78) are left out by the server-side filter.
describe('push filter with muted folders and accounts (#78)', () => {
const folders = [
{ id: 'inbox', role: 'inbox', accountId: 'jmap-primary' },
{ id: 'junk', role: 'junk', accountId: 'jmap-primary' },
{ id: 'renovate', accountId: 'jmap-primary' },
{ id: 'team:in', originalId: 'in', role: 'inbox', accountId: 'team', isShared: true },
{ id: 'team:j', originalId: 'j', role: 'junk', accountId: 'team', isShared: true },
];
const mutes = (accounts: string[], mailboxes: Record<string, boolean> = {}): NotificationMutes => ({ accounts, mailboxes });
const junkOnly = (ids: string[]) => ({ operator: 'AND', conditions: [{ notKeyword: '$junk' }, { inMailboxOtherThan: ids }] });
beforeEach(() => {
provideLoadedMailboxes(async () => folders as never);
});
it('changes nothing without mutes', async () => {
const config = await buildEmailPushConfig(EMPTY_NOTIFICATION_MUTES);
expect(config['jmap-primary'].filter).toEqual(junkOnly(['junk']));
expect(config.team.filter).toEqual(junkOnly(['j']));
});
it('adds a muted folder to the excluded mailboxes next to Junk', async () => {
const config = await buildEmailPushConfig(mutes([], { 'jmap-primary/renovate': true, 'jmap-primary/gone': true }));
expect(config['jmap-primary'].filter).toEqual(junkOnly(['junk', 'renovate']));
// Another account's folders are untouched.
expect(config.team.filter).toEqual(junkOnly(['j']));
});
it('silences a muted account entirely', async () => {
const config = await buildEmailPushConfig(mutes(['team']));
expect(config.team.filter).toEqual({
operator: 'AND',
conditions: [{ notKeyword: '$junk' }, { hasKeyword: '$junk' }],
});
expect(config['jmap-primary'].filter).toEqual(junkOnly(['junk']));
});
it('lets a muted account notify for the folders unmuted again', async () => {
const one = await buildEmailPushConfig(mutes(['jmap-primary'], { 'jmap-primary/renovate': false }));
expect(one['jmap-primary'].filter).toEqual({
operator: 'AND',
conditions: [{ notKeyword: '$junk' }, { inMailbox: 'renovate' }],
});
const two = await buildEmailPushConfig(mutes(['jmap-primary'], {
'jmap-primary/renovate': false,
'jmap-primary/inbox': false,
// Gone from the server: must not end up in the filter.
'jmap-primary/deleted': false,
// Unmuting Junk does not bring spam back.
'jmap-primary/junk': false,
}));
expect(two['jmap-primary'].filter).toEqual({
operator: 'AND',
conditions: [
{ notKeyword: '$junk' },
{ operator: 'OR', conditions: [{ inMailbox: 'inbox' }, { inMailbox: 'renovate' }] },
],
});
});
it('fails instead of dropping the mutes when the folders cannot be read', async () => {
provideLoadedMailboxes(async () => null);
vi.mocked(getMailboxes).mockRejectedValueOnce(new Error('JMAP down'));
await expect(buildEmailPushConfig(mutes(['jmap-primary']))).rejects.toThrow(/JMAP down/);
});
});
+10
View File
@@ -4,6 +4,7 @@ import {
expandThreadSelection,
getEmailTagIds,
getThreadTagIds,
resolveEmailTags,
tagIdFromKeyword,
threadKeyOf,
rowKeyOf,
@@ -30,6 +31,15 @@ describe('tag ids', () => {
])).toEqual(['work', 'todo']);
});
it('keeps a tag with no local definition, grey and under its raw id (#79)', () => {
const defs = [{ id: 'work', label: 'Work', color: 'blue' as const }];
expect(resolveEmailTags({ '$label:work': true, '$label:pgp-encrypted': true, $seen: true }, defs)).toEqual([
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'pgp-encrypted', label: 'pgp-encrypted', color: 'gray' },
]);
expect(resolveEmailTags(undefined, defs)).toEqual([]);
});
it('extracts the tag id from a keyword', () => {
expect(tagIdFromKeyword('$label:work')).toBe('work');
expect(tagIdFromKeyword('$color:work')).toBe('work');
+104
View File
@@ -0,0 +1,104 @@
/**
* Which accounts and folders stay silent when new mail arrives (#78,
* webmail #1169). Port of the webmail's lib/notification-mutes.ts - keep in sync.
*
* An account is either muted or not. A folder can follow its account or carry
* its own setting, and its own setting always wins: mute one folder in a
* normal account, or mute a whole account and unmute the folders that still
* matter. Ids are the server's JMAP account and mailbox ids, which is what the
* push filter needs; shared-account mailboxes must pass their originalId.
*/
export interface NotificationMutes {
/** JMAP account ids muted as a whole. */
accounts: string[];
/** `accountId/mailboxId` -> true (muted) or false (unmuted). Missing = follow the account. */
mailboxes: Record<string, boolean>;
}
export const EMPTY_NOTIFICATION_MUTES: NotificationMutes = { accounts: [], mailboxes: {} };
function mailboxKey(accountId: string, mailboxId: string): string {
return `${accountId}/${mailboxId}`;
}
/** Repairs a value from storage or another build into a usable mutes object. */
export function normalizeNotificationMutes(value: unknown): NotificationMutes {
if (!value || typeof value !== 'object' || Array.isArray(value)) return EMPTY_NOTIFICATION_MUTES;
const raw = value as { accounts?: unknown; mailboxes?: unknown };
const accounts = Array.isArray(raw.accounts)
? raw.accounts.filter((id): id is string => typeof id === 'string' && id !== '')
: [];
const mailboxes: Record<string, boolean> = {};
if (raw.mailboxes && typeof raw.mailboxes === 'object' && !Array.isArray(raw.mailboxes)) {
for (const [key, muted] of Object.entries(raw.mailboxes as Record<string, unknown>)) {
if (typeof muted === 'boolean') mailboxes[key] = muted;
}
}
return { accounts, mailboxes };
}
export function isAccountMuted(mutes: NotificationMutes, accountId: string): boolean {
return mutes.accounts.includes(accountId);
}
/** The folder's own setting, or undefined when it follows its account. */
export function mailboxMuteOverride(
mutes: NotificationMutes,
accountId: string,
mailboxId: string,
): boolean | undefined {
return mutes.mailboxes[mailboxKey(accountId, mailboxId)];
}
export function isMailboxMuted(mutes: NotificationMutes, accountId: string, mailboxId: string): boolean {
return mailboxMuteOverride(mutes, accountId, mailboxId) ?? isAccountMuted(mutes, accountId);
}
/** Mailbox ids of one account with their own setting, split by that setting. */
export function mailboxOverridesForAccount(
mutes: NotificationMutes,
accountId: string,
): { muted: string[]; unmuted: string[] } {
const prefix = mailboxKey(accountId, '');
const muted: string[] = [];
const unmuted: string[] = [];
for (const [key, value] of Object.entries(mutes.mailboxes)) {
if (!key.startsWith(prefix)) continue;
(value ? muted : unmuted).push(key.slice(prefix.length));
}
return { muted, unmuted };
}
export function setAccountMuted(mutes: NotificationMutes, accountId: string, muted: boolean): NotificationMutes {
const accounts = mutes.accounts.filter((id) => id !== accountId);
if (muted) accounts.push(accountId);
return { ...mutes, accounts };
}
/**
* Mutes or unmutes one folder. Setting it back to what the account says drops
* the folder's own setting, so a folder muted and unmuted again follows the
* account later instead of staying pinned to "unmuted".
*/
export function setMailboxMuted(
mutes: NotificationMutes,
accountId: string,
mailboxId: string,
muted: boolean,
): NotificationMutes {
const mailboxes = { ...mutes.mailboxes };
const key = mailboxKey(accountId, mailboxId);
if (muted === isAccountMuted(mutes, accountId)) delete mailboxes[key];
else mailboxes[key] = muted;
return { ...mutes, mailboxes };
}
/** Drops every folder setting of one account so all its folders follow the account again. */
export function resetMailboxMutes(mutes: NotificationMutes, accountId: string): NotificationMutes {
const prefix = mailboxKey(accountId, '');
const mailboxes = Object.fromEntries(
Object.entries(mutes.mailboxes).filter(([key]) => !key.startsWith(prefix)),
);
return { ...mutes, mailboxes };
}
+50 -9
View File
@@ -14,6 +14,13 @@ import { JMAPMethodError, requireMethodResult } from '../api/jmap-result';
import { CAPABILITIES } from '../api/types';
import type { EmailPushConfig, JMAPAccountInfo, JMAPSession, Mailbox } from '../api/types';
import { generateAccountId } from './account-utils';
import {
EMPTY_NOTIFICATION_MUTES,
isAccountMuted,
mailboxOverridesForAccount,
type NotificationMutes,
} from './notification-mutes';
import { useSettingsStore } from '../stores/settings-store';
import { t } from '../stores/locale-store';
import {
authorityOfType,
@@ -296,32 +303,62 @@ export function serverSupportsEmailPush(): boolean {
* a Junk-role mailbox (Sieve `fileinto` doesn't set the keyword). The two are
* ANDed so a stale mailbox id - the user deleted and recreated Junk - degrades
* to keyword-only filtering rather than letting everything through.
*
* Muted accounts and folders (#78) are cut out of the same filter: a muted
* folder joins Junk in the "not only in" list; a muted account only lets
* through mail in the folders the user unmuted again, or nothing at all.
*/
export async function buildEmailPushConfig(): Promise<Record<string, EmailPushConfig>> {
export async function buildEmailPushConfig(
mutes: NotificationMutes = EMPTY_NOTIFICATION_MUTES,
): Promise<Record<string, EmailPushConfig>> {
const primary = jmapClient.accountId;
const junkByAccount = new Map<string, string[]>([[primary, []]]);
const mailboxIdsByAccount = new Map<string, Set<string>>();
// The mail store loads the same folders at the same moment (sign-in,
// start): reuse its list rather than a second Mailbox/get of our own.
const username = jmapClient.username;
const serverUrl = jmapClient.serverUrl;
const loaded = username && serverUrl ? await loadedMailboxes(generateAccountId(username, serverUrl)) : null;
const all = loaded ?? [
...(await getMailboxes().catch(() => [] as Mailbox[])),
...(await getSharedMailboxes().catch(() => [] as Mailbox[])),
];
// With mutes a swallowed fetch failure would look like "no such folder"
// and unmute everything, so let the real error surface; otherwise the
// filter can still degrade to keyword-only on a transient miss.
const hasMutes = mutes.accounts.length > 0 || Object.keys(mutes.mailboxes).length > 0;
const all = loaded ?? (hasMutes
? [...(await getMailboxes()), ...(await getSharedMailboxes())]
: [
...(await getMailboxes().catch(() => [] as Mailbox[])),
...(await getSharedMailboxes().catch(() => [] as Mailbox[])),
]);
for (const m of all) {
const accountId = m.accountId || primary;
const junk = junkByAccount.get(accountId) ?? [];
// Shared-account mailboxes carry a client-side "<account>:<id>" id;
// the server only knows the original.
if (m.role === 'junk') junk.push(m.originalId ?? m.id);
const serverId = m.originalId ?? m.id;
const ids = mailboxIdsByAccount.get(accountId) ?? new Set<string>();
ids.add(serverId);
mailboxIdsByAccount.set(accountId, ids);
const junk = junkByAccount.get(accountId) ?? [];
if (m.role === 'junk') junk.push(serverId);
junkByAccount.set(accountId, junk);
}
const config: Record<string, EmailPushConfig> = {};
for (const [accountId, junkIds] of junkByAccount) {
// Settings for folders that no longer exist are left out.
const existing = mailboxIdsByAccount.get(accountId) ?? new Set<string>();
const overrides = mailboxOverridesForAccount(mutes, accountId);
const conditions: Record<string, unknown>[] = [{ notKeyword: '$junk' }];
if (junkIds.length > 0) conditions.push({ inMailboxOtherThan: [...junkIds].sort() });
if (isAccountMuted(mutes, accountId)) {
const allowed = overrides.unmuted
.filter((id) => existing.has(id) && !junkIds.includes(id))
.sort();
if (allowed.length === 0) conditions.push(NEVER_MATCH);
else if (allowed.length === 1) conditions.push({ inMailbox: allowed[0] });
else conditions.push({ operator: 'OR', conditions: allowed.map((id) => ({ inMailbox: id })) });
} else {
const excluded = new Set([...junkIds, ...overrides.muted.filter((id) => existing.has(id))]);
if (excluded.size > 0) conditions.push({ inMailboxOtherThan: [...excluded].sort() });
}
config[accountId] = {
// Always the operator form: that's how the server echoes it back, so a
// stored config compares equal to a freshly built one.
@@ -333,6 +370,10 @@ export async function buildEmailPushConfig(): Promise<Record<string, EmailPushCo
return config;
}
// Contradicts the `notKeyword: '$junk'` every filter starts with, so the AND
// never matches: the account stays in the map but never pushes.
const NEVER_MATCH = { hasKeyword: '$junk' } as const;
function normalizeEmailPush(value: unknown): string {
const sortKeys = (v: unknown): unknown => {
if (Array.isArray(v)) return v.map(sortKeys);
@@ -980,7 +1021,7 @@ async function setupPushNotificationsInner(
if (params.forceRecreate) await AsyncStorage.removeItem(emailPushRefusedKey(accountId));
const refusedBefore = await readRefusedEmailPushAccounts(accountId);
const emailPush = serverSupportsEmailPush()
? withoutAccounts(await buildEmailPushConfig(), refusedBefore)
? withoutAccounts(await buildEmailPushConfig(useSettingsStore.getState().notificationMutes), refusedBefore)
: null;
const subKey = subscriptionIdKey(accountId);
const storedServerId = await AsyncStorage.getItem(subKey);
+16
View File
@@ -1,4 +1,5 @@
import type { Email } from '../api/types';
import type { KeywordDef } from '../stores/keywords-store';
// Port of the webmail's `lib/thread-utils.ts` (tag id extraction, thread
// grouping helpers) trimmed to what the native list needs.
@@ -23,6 +24,21 @@ export function getEmailTagIds(keywords: Record<string, boolean> | undefined): s
return [...tags];
}
/**
* The tags to draw for a message: every tag id on it, with its local
* definition where there is one. A tag no local definition explains (set by
* another client or by the server) is kept, grey and labelled with its raw
* id, so the header shows the same tags as the list row (#79).
*/
export function resolveEmailTags(
keywords: Record<string, boolean> | undefined,
defs: readonly KeywordDef[],
): KeywordDef[] {
return getEmailTagIds(keywords).map(
(id) => defs.find((d) => d.id === id) ?? { id, label: id, color: 'gray' },
);
}
/**
* Every tag anywhere in a thread, deduplicated. A collapsed thread row stands
* in for all its messages, so it has to account for all their tags.
+55
View File
@@ -20,6 +20,12 @@ import {
normalizeServerUrl,
} from '../lib/server-discovery';
import { describeLoginError, type LoginErrorCopy } from '../lib/login-errors';
import {
clearClientCertAlias,
getClientCertAlias,
isClientCertSupported,
pickClientCertAlias,
} from '../lib/client-cert';
import LoginShell from './login/LoginShell';
import ChooseStep from './login/ChooseStep';
import EmailStep from './login/EmailStep';
@@ -109,6 +115,19 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
// Addresses typed before on this device (last 5) plus the registry's, so a
// returning user taps instead of retyping. Never contains passwords.
const [recentEmails, setRecentEmails] = React.useState<string[]>([]);
// TLS client certificate (#3). Every request from discovery on presents
// the picked certificate (lib/client-cert secureFetch), so a server behind
// a proxy that demands one has to get it before the first sign-in - the
// settings screen that also offers it is only reachable afterwards. The
// choice is device-wide, as in the settings.
const certSupported = isClientCertSupported();
const [certAlias, setCertAlias] = React.useState<string | null>(null);
const [certBusy, setCertBusy] = React.useState(false);
React.useEffect(() => {
if (!certSupported) return;
void getClientCertAlias().then(setCertAlias).catch(() => undefined);
}, [certSupported]);
React.useEffect(() => {
void AsyncStorage.getItem(RECENT_EMAILS_KEY).then((raw) => {
try {
@@ -135,6 +154,36 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
// Servers we already hold credentials for. Discovery trusts these without a
// probe, which makes "second account on the same host" instant.
const knownServerUrls = React.useMemo(() => accounts.map((a) => a.serverUrl), [accounts]);
const pickCert = React.useCallback(async () => {
if (certBusy) return;
setCertBusy(true);
setNotice(null);
try {
// The host narrows the system picker to certificates issued for it.
const normalized = normalizeServerUrl(serverInput);
const host = normalized ? new URL(normalized).hostname : null;
const alias = await pickClientCertAlias(host);
if (alias !== null) setCertAlias(alias);
} catch (err) {
setNotice({
title: t('login.mobile.server_cert_failed', 'Could not pick a certificate'),
detail: err instanceof Error ? err.message : String(err),
});
} finally {
setCertBusy(false);
}
}, [certBusy, serverInput, t]);
const clearCert = React.useCallback(async () => {
if (certBusy) return;
setCertBusy(true);
try {
await clearClientCertAlias();
setCertAlias(null);
} finally {
setCertBusy(false);
}
}, [certBusy]);
const knownServerUrl = React.useMemo(() => {
const active = accounts.find((a) => a.id === activeAccountId);
return active?.serverUrl ?? accounts[0]?.serverUrl ?? null;
@@ -562,6 +611,12 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
onPaste={() => setPasteVisible(true)}
failedDomain={failedDomain}
notice={notice}
clientCert={certSupported ? {
alias: certAlias,
busy: certBusy,
onPick: () => { void pickCert(); },
onClear: () => { void clearCert(); },
} : undefined}
/>
) : null}
+44 -1
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { ClipboardPaste, Globe, QrCode } from '../../components/icons';
import { ClipboardPaste, Globe, QrCode, ShieldCheck, X } from '../../components/icons';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { Button, Input } from '../../components';
@@ -16,6 +16,16 @@ interface ServerStepProps {
/** Set when we got here because discovery came back empty for this domain. */
failedDomain?: string | null;
notice?: { title: string; detail?: string } | null;
/**
* TLS client certificate for a reverse proxy that asks for one (#3).
* Undefined where the platform has no certificate store to pick from.
*/
clientCert?: {
alias: string | null;
busy: boolean;
onPick: () => void;
onClear: () => void;
};
}
/**
@@ -31,6 +41,7 @@ export default function ServerStep({
onPaste,
failedDomain,
notice,
clientCert,
}: ServerStepProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -82,6 +93,38 @@ export default function ServerStep({
{t('login.mobile.server_paste_hint', 'Have a sign-in link instead? Paste it here.')}
</Text>
</Pressable>
{clientCert ? (
<Pressable
onPress={clientCert.onPick}
disabled={clientCert.busy}
hitSlop={8}
style={styles.scanRow}
accessibilityRole="button"
testID="login-client-cert"
>
<ShieldCheck size={15} color={clientCert.alias ? c.success : c.textMuted} />
<Text style={styles.scanText}>
{clientCert.alias
? t('login.mobile.server_cert_active', 'Signing in with the client certificate "{alias}". Tap to choose another.', { alias: clientCert.alias })
: t('login.mobile.server_cert_hint', 'Does your server ask for a client certificate? Pick one installed on this phone.')}
</Text>
</Pressable>
) : null}
{clientCert?.alias ? (
<Pressable
onPress={clientCert.onClear}
disabled={clientCert.busy}
hitSlop={8}
style={styles.scanRow}
accessibilityRole="button"
testID="login-client-cert-clear"
>
<X size={15} color={c.textMuted} />
<Text style={styles.scanText}>
{t('login.mobile.server_cert_clear', 'Sign in without the certificate')}
</Text>
</Pressable>
) : null}
</View>
</View>
);
+7
View File
@@ -4,6 +4,7 @@ import type { Identity } from '../api/types';
import { getIdentities as fetchIdentities } from '../api/identity';
import { jmapClient } from '../api/jmap-client';
import type { SortLevel, MessageListOrderScope } from '../lib/message-list-order';
import { EMPTY_NOTIFICATION_MUTES, normalizeNotificationMutes, type NotificationMutes } from '../lib/notification-mutes';
export type ExternalContentPolicy = 'allow' | 'block' | 'ask';
export type ThemeMode = 'light' | 'dark' | 'system';
@@ -260,6 +261,9 @@ interface PersistedSettings {
// Notifications. Sound/vibration live in the Android notification channel
// (the OS owns them after channel creation), so there are no sound keys.
emailNotificationsEnabled: boolean;
// Accounts and folders whose new mail does not notify (#78), keyed by the
// server's JMAP ids; see lib/notification-mutes.
notificationMutes: NotificationMutes;
calendarNotificationsEnabled: boolean;
calendarInvitationParsingEnabled: boolean;
// Inbox unread count on the app icon (iOS, see lib/app-badge). Same
@@ -413,6 +417,7 @@ const DEFAULT_PERSISTED: PersistedSettings = {
filesShowHiddenFiles: false,
emailNotificationsEnabled: true,
notificationMutes: EMPTY_NOTIFICATION_MUTES,
calendarNotificationsEnabled: true,
calendarInvitationParsingEnabled: true,
appIconUnreadBadge: true,
@@ -605,6 +610,8 @@ export function mergeWithDefaults(parsed: Partial<PersistedSettings>): Persisted
if (validator && !validator(v)) continue;
if (k === 'bottomQuickActions') {
out[k] = normalizeBottomQuickActions(v);
} else if (k === 'notificationMutes') {
out[k] = normalizeNotificationMutes(v);
} else if (Array.isArray(def)) {
if (Array.isArray(v)) out[k] = v;
} else if (typeof def === 'object') {