mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 08:07:11 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c9bbcc383 | ||
|
|
c7eda4735c | ||
|
|
5f5ffdaddb | ||
|
|
083ec958a0 | ||
|
|
2b486cb1ef |
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+953
-92
File diff suppressed because it is too large
Load Diff
+951
-90
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4507
-3646
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+1
-5
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
+4506
-3645
File diff suppressed because it is too large
Load Diff
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.82",
|
||||
"version": "0.1.83",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -37,6 +37,7 @@ const MAP = {
|
||||
ArrowUpNarrowWide: 'sort-ascending',
|
||||
Ban: 'ban',
|
||||
Bell: 'bell',
|
||||
BellOff: 'bell-off',
|
||||
BellRing: 'bell-ringing',
|
||||
Bold: 'bold',
|
||||
Book: 'book-2',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user