mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c9bbcc383 | ||
|
|
c7eda4735c | ||
|
|
5f5ffdaddb | ||
|
|
083ec958a0 | ||
|
|
2b486cb1ef | ||
|
|
86400b124a | ||
|
|
323797b4ee | ||
|
|
9166b0fb3c | ||
|
|
1f253e9488 | ||
|
|
d22fb8d5d0 | ||
|
|
e0f18713aa | ||
|
|
56d826c508 | ||
|
|
e5492b89c8 | ||
|
|
b50436b1ba | ||
|
|
11e74042e2 | ||
|
|
95580b84ea | ||
|
|
9a15334bef | ||
|
|
3aca2d80c0 | ||
|
|
7c534301ee | ||
|
|
14dc5ff2d4 | ||
|
|
14ba28c79c | ||
|
|
598adc154d | ||
|
|
3c0af52c4e | ||
|
|
8ce53816af | ||
|
|
767d4b9f9f |
@@ -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"/>
|
||||
|
||||
@@ -15,6 +15,7 @@ class BulwarkFcmPackage : ReactPackage {
|
||||
add(BulwarkClientCertModule(reactContext))
|
||||
add(BulwarkDeviceSyncModule(reactContext))
|
||||
add(BulwarkWidgetsModule(reactContext))
|
||||
add(BulwarkPdfModule(reactContext))
|
||||
// The bridge registers React Native's own module under this name.
|
||||
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
|
||||
add(BulwarkHeadlessJsTaskSupportModule(reactContext))
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.graphics.pdf.PdfRenderer
|
||||
import android.net.Uri
|
||||
import android.os.ParcelFileDescriptor
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Renders PDF pages to PNG files for the in-app file preview, with the
|
||||
* platform's own PdfRenderer: Android has no PDF view, and its WebView
|
||||
* doesn't show PDFs either. The preview draws the pages in a WebView of
|
||||
* its own so they zoom, asking for each page as it scrolls into view.
|
||||
*
|
||||
* PdfRenderer is not thread-safe and allows one open page at a time, so
|
||||
* every call runs on one background thread, and the open document is kept
|
||||
* between calls for the file the preview shows.
|
||||
*/
|
||||
class BulwarkPdfModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
private val worker = Executors.newSingleThreadExecutor { r -> Thread(r, "BulwarkPdf").apply { isDaemon = true } }
|
||||
private var openPath: String? = null
|
||||
private var descriptor: ParcelFileDescriptor? = null
|
||||
private var renderer: PdfRenderer? = null
|
||||
|
||||
override fun getName(): String = "BulwarkPdf"
|
||||
|
||||
override fun invalidate() {
|
||||
worker.execute { closeDocument() }
|
||||
worker.shutdown()
|
||||
super.invalidate()
|
||||
}
|
||||
|
||||
/** Page count and each page's size in points: `{ pages: [{ width, height }] }`. */
|
||||
@ReactMethod
|
||||
fun open(fileUri: String, promise: Promise) {
|
||||
worker.execute {
|
||||
try {
|
||||
val doc = document(fileUri)
|
||||
val pages = Arguments.createArray()
|
||||
for (i in 0 until doc.pageCount) {
|
||||
doc.openPage(i).use { page ->
|
||||
pages.pushMap(Arguments.createMap().apply {
|
||||
putInt("width", page.width)
|
||||
putInt("height", page.height)
|
||||
})
|
||||
}
|
||||
}
|
||||
promise.resolve(Arguments.createMap().apply { putArray("pages", pages) })
|
||||
} catch (e: SecurityException) {
|
||||
// Password-protected; PdfRenderer can't open those.
|
||||
closeDocument()
|
||||
promise.reject("E_PDF_PROTECTED", e.message, e)
|
||||
} catch (e: Throwable) {
|
||||
closeDocument()
|
||||
promise.reject("E_PDF_OPEN", e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Render one page `width` pixels wide into `outUri` (a PNG). Resolves with the file URI. */
|
||||
@ReactMethod
|
||||
fun renderPage(fileUri: String, index: Int, width: Int, outUri: String, promise: Promise) {
|
||||
worker.execute {
|
||||
try {
|
||||
val doc = document(fileUri)
|
||||
if (index < 0 || index >= doc.pageCount) throw IndexOutOfBoundsException("page $index")
|
||||
doc.openPage(index).use { page ->
|
||||
val w = width.coerceIn(1, MAX_PAGE_PX)
|
||||
val h = (w.toLong() * page.height / page.width.coerceAtLeast(1)).toInt().coerceIn(1, MAX_PAGE_PX * 2)
|
||||
val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
try {
|
||||
// Pages are transparent where nothing is drawn.
|
||||
bitmap.eraseColor(Color.WHITE)
|
||||
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
||||
val out = fileOf(outUri)
|
||||
// The preview's folder is gone once it closed; a page
|
||||
// still queued then must not bring it back.
|
||||
if (out.parentFile?.isDirectory != true) throw IllegalStateException("preview closed")
|
||||
FileOutputStream(out).use { stream -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) }
|
||||
} finally {
|
||||
bitmap.recycle()
|
||||
}
|
||||
}
|
||||
promise.resolve(outUri)
|
||||
} catch (e: Throwable) {
|
||||
promise.reject("E_PDF_RENDER", e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the document the preview showed. */
|
||||
@ReactMethod
|
||||
fun close(fileUri: String, promise: Promise) {
|
||||
worker.execute {
|
||||
if (openPath == fileOf(fileUri).absolutePath) closeDocument()
|
||||
promise.resolve(null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun document(fileUri: String): PdfRenderer {
|
||||
val path = fileOf(fileUri).absolutePath
|
||||
renderer?.let { if (openPath == path) return it }
|
||||
closeDocument()
|
||||
val fd = ParcelFileDescriptor.open(File(path), ParcelFileDescriptor.MODE_READ_ONLY)
|
||||
try {
|
||||
val doc = PdfRenderer(fd)
|
||||
descriptor = fd
|
||||
renderer = doc
|
||||
openPath = path
|
||||
return doc
|
||||
} catch (e: Throwable) {
|
||||
fd.close()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun closeDocument() {
|
||||
try { renderer?.close() } catch (_: Throwable) {}
|
||||
try { descriptor?.close() } catch (_: Throwable) {}
|
||||
renderer = null
|
||||
descriptor = null
|
||||
openPath = null
|
||||
}
|
||||
|
||||
private fun fileOf(uri: String): File {
|
||||
val path = if (uri.startsWith("file:")) Uri.parse(uri).path else uri
|
||||
return File(path ?: throw IllegalArgumentException("not a file: $uri"))
|
||||
}
|
||||
|
||||
companion object {
|
||||
// A 4096 px page bitmap is 64 MB; keep well below.
|
||||
private const val MAX_PAGE_PX = 2480
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -175,7 +175,7 @@ RN has a solid single-message reader (WebView body with CSP, shrink-to-fit + pin
|
||||
- What RN does: single tap follows the global `mailAttachmentAction`; `jszip` is already a dependency (`package.json:36`) but unused for mail.
|
||||
- Fix hint: long-press sheet with "Open / Save / Share"; a "Download all" entry building the zip with `jszip` into `Paths.cache` then sharing.
|
||||
|
||||
- [x] **In-app attachment preview (images, PDF, text, audio/video) is absent; iOS "Preview" is just the share sheet — fixed in d2ed27f** — `P3` — `partial`
|
||||
- [x] **In-app attachment preview (images, PDF, text, audio/video) is absent; iOS "Preview" is just the share sheet — fixed in d2ed27f; audio/video, HTML, Markdown, SVG, Android PDFs and ZIPs since 0.1.80-0.1.82** — `P3` — `partial`
|
||||
- What WEB does: `FilePreviewModal` for images/PDF (bytes handed to pdf.js, #871)/text/markdown/audio/video/eml, script-bearing types forced to download (`components/mail/mail-app.tsx:2887-2935`, `lib/file-preview.ts:29-92`).
|
||||
- What RN does: Android hands the file to an external viewer via `ACTION_VIEW`, iOS opens the share sheet (`src/lib/email-export.ts:64-79`, `115-141`); nothing is previewed inside the app.
|
||||
- Fix hint: for images/PDF/text open a modal `WebView` on the cached `file://` (or use `expo-file-system`'s `File.uri` with a QuickLook bridge on iOS); keep the external-viewer path as fallback.
|
||||
|
||||
@@ -110,7 +110,7 @@ Filters: RN carries a byte-for-byte port of WEB's Sieve parser/generator/tests a
|
||||
- What RN does: `getDocumentAsync({ multiple: false })` (`src/screens/FilesScreen.tsx:357-360`); uploading the same name twice creates two nodes with the same name.
|
||||
- Fix hint: `multiple: true` + sequential upload loop; reuse WEB's `getUniqueName` against `visibleFiles`.
|
||||
|
||||
- [x] **No in-app preview (image/text/markdown/PDF/audio/video/eml)** — `P2` — `missing` — done in 409cdb1 (images, text and source files, `.eml`, and PDFs on iOS open in the reader's preview; PDFs on Android, audio, video, HTML and Markdown still go to another app)
|
||||
- [x] **No in-app preview (image/text/markdown/PDF/audio/video/eml)** — `P2` — `missing` — done in 409cdb1 (images, text and source files, `.eml`, and PDFs on iOS open in the reader's preview); since 0.1.80/0.1.81 also audio, video, SVG/ICO, HTML, Markdown, PDFs on Android (native `PdfRenderer`) and ZIP listings, through the one preview the reader, list, Files and compose share (`src/components/email/use-file-preview.ts`)
|
||||
- What WEB does: `ImagePreviewModal` and `FilePreviewModal` (text, markdown, PDF incl. mobile pdf.js viewer, audio, video, EML) chosen via `getFilePreviewKind` (`lib/file-preview.ts:29-68`; `components/files/file-preview-modal.tsx`, `image-preview-modal.tsx`, `pdf-mobile-viewer.tsx`; `files-app.tsx:487-495`, `690-708`).
|
||||
- What RN does: tapping a file calls `shareAttachment` which downloads to cache and hands off to the OS share/viewer sheet (`src/screens/FilesScreen.tsx:248-258`; `src/lib/email-export.ts` "preview" variant). Works but there is no inline viewer and no "recent files" tracking.
|
||||
- Fix hint: reuse whatever attachment viewer the mail area has (image modal / WebView for text+PDF); N/A if the team decides the OS viewer is the mobile answer - then close this item.
|
||||
|
||||
+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
+9
-6
@@ -407,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "فتح في تطبيق",
|
||||
"pdf_external": "تُفتح ملفات PDF في عارض PDF على هذا الجهاز.",
|
||||
"unsupported": "لا توجد معاينة لهذا النوع من الملفات."
|
||||
"unsupported": "لا توجد معاينة لهذا النوع من الملفات.",
|
||||
"media_unsupported": "لا يمكن تشغيل هذا الملف هنا.",
|
||||
"pdf_protected": "ملف PDF هذا محمي بكلمة مرور. افتحه في تطبيق آخر لإدخال كلمة المرور.",
|
||||
"pdf_unreadable": "لا يمكن عرض ملف PDF هذا هنا.",
|
||||
"archive_files": "{count, plural, zero {لا ملفات} one {ملف واحد} two {ملفان} few {# ملفات} many {# ملفًا} other {# ملف}}",
|
||||
"archive_first_files": "أول {count} ملف",
|
||||
"archive_protected": "هذا الأرشيف محمي بكلمة مرور. افتحه في تطبيق آخر لإدخال كلمة المرور.",
|
||||
"archive_unreadable": "لا يمكن فتح هذا الأرشيف هنا."
|
||||
},
|
||||
"received_via": "إلى {name}",
|
||||
"remove_color": "إزالة الوسم",
|
||||
@@ -438,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": "فشل تحديث المشاركة",
|
||||
@@ -1095,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "الإضافات",
|
||||
"themes": "السمات",
|
||||
"updates": "التحديثات"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1112,9 +1117,7 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "لوحات الألوان الفاتحة والداكنة الافتراضية.",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "اختر سمة ألوان. تتبع السمات إعداد الوضع الفاتح/الداكن من المظهر؛ وتُدار حزم السمات المخصصة في بريد الويب.",
|
||||
"title": "السمات"
|
||||
"mobile_description": "اختر سمة ألوان. تتبع السمات إعداد الوضع الفاتح/الداكن من المظهر؛ وتُدار حزم السمات المخصصة في بريد الويب."
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
|
||||
+9
-6
@@ -407,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Obre en una aplicació",
|
||||
"pdf_external": "Els PDF s'obren amb el visualitzador de PDF d'aquest dispositiu.",
|
||||
"unsupported": "No hi ha previsualització per a aquest tipus de fitxer."
|
||||
"unsupported": "No hi ha previsualització per a aquest tipus de fitxer.",
|
||||
"media_unsupported": "Aquest fitxer no es pot reproduir aquí.",
|
||||
"pdf_protected": "Aquest PDF està protegit amb contrasenya. Obre'l en una altra aplicació per introduir-la.",
|
||||
"pdf_unreadable": "Aquest PDF no es pot mostrar aquí.",
|
||||
"archive_files": "{count, plural, one {# fitxer} other {# fitxers}}",
|
||||
"archive_first_files": "Els primers {count} fitxers",
|
||||
"archive_protected": "Aquest arxiu està protegit amb contrasenya. Obre'l en una altra aplicació per introduir-la.",
|
||||
"archive_unreadable": "Aquest arxiu no es pot obrir aquí."
|
||||
},
|
||||
"received_via": "per a {name}",
|
||||
"remove_color": "Elimina l'etiqueta",
|
||||
@@ -438,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ó",
|
||||
@@ -1095,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Connectors",
|
||||
"themes": "Temes",
|
||||
"updates": "Actualitzacions"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1112,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": {
|
||||
|
||||
+9
-6
@@ -407,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Otevřít v aplikaci",
|
||||
"pdf_external": "Soubory PDF se otevírají v prohlížeči PDF na tomto zařízení.",
|
||||
"unsupported": "Pro tento typ souboru není náhled k dispozici."
|
||||
"unsupported": "Pro tento typ souboru není náhled k dispozici.",
|
||||
"media_unsupported": "Tento soubor zde nelze přehrát.",
|
||||
"pdf_protected": "Toto PDF je chráněné heslem. Otevřete ho v jiné aplikaci a zadejte heslo.",
|
||||
"pdf_unreadable": "Toto PDF zde nelze zobrazit.",
|
||||
"archive_files": "{count, plural, one {# soubor} few {# soubory} many {# souboru} other {# souborů}}",
|
||||
"archive_first_files": "Prvních {count} souborů",
|
||||
"archive_protected": "Tento archiv je chráněný heslem. Otevřete ho v jiné aplikaci a zadejte heslo.",
|
||||
"archive_unreadable": "Tento archiv zde nelze otevřít."
|
||||
},
|
||||
"received_via": "pro {name}",
|
||||
"remove_color": "Odebrat štítek",
|
||||
@@ -438,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",
|
||||
@@ -1095,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Pluginy",
|
||||
"themes": "Motivy",
|
||||
"updates": "Aktualizace"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1112,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": {
|
||||
|
||||
+9
-6
@@ -407,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Åbn i app",
|
||||
"pdf_external": "PDF'er åbnes i din PDF-fremviser på denne enhed.",
|
||||
"unsupported": "Ingen forhåndsvisning for denne filtype."
|
||||
"unsupported": "Ingen forhåndsvisning for denne filtype.",
|
||||
"media_unsupported": "Denne fil kan ikke afspilles her.",
|
||||
"pdf_protected": "Denne PDF er beskyttet med adgangskode. Åbn den i en anden app for at indtaste adgangskoden.",
|
||||
"pdf_unreadable": "Denne PDF kan ikke vises her.",
|
||||
"archive_files": "{count, plural, one {# fil} other {# filer}}",
|
||||
"archive_first_files": "De første {count} filer",
|
||||
"archive_protected": "Dette arkiv er beskyttet med adgangskode. Åbn det i en anden app for at indtaste adgangskoden.",
|
||||
"archive_unreadable": "Dette arkiv kan ikke åbnes her."
|
||||
},
|
||||
"received_via": "til {name}",
|
||||
"remove_color": "Fjern tag",
|
||||
@@ -438,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",
|
||||
@@ -1095,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Plugins",
|
||||
"themes": "Temaer",
|
||||
"updates": "Opdateringer"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1112,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": {
|
||||
|
||||
+9
-6
@@ -413,7 +413,14 @@
|
||||
"preview": {
|
||||
"open_external": "In App öffnen",
|
||||
"pdf_external": "PDFs werden in Ihrem PDF-Viewer auf diesem Gerät geöffnet.",
|
||||
"unsupported": "Keine Vorschau für diesen Dateityp."
|
||||
"unsupported": "Keine Vorschau für diesen Dateityp.",
|
||||
"media_unsupported": "Diese Datei kann hier nicht abgespielt werden.",
|
||||
"pdf_protected": "Diese PDF ist passwortgeschützt. Öffne sie in einer anderen App, um das Passwort einzugeben.",
|
||||
"pdf_unreadable": "Diese PDF kann hier nicht angezeigt werden.",
|
||||
"archive_files": "{count, plural, one {# Datei} other {# Dateien}}",
|
||||
"archive_first_files": "Die ersten {count} Dateien",
|
||||
"archive_protected": "Dieses Archiv ist passwortgeschützt. Öffne es in einer anderen App, um das Passwort einzugeben.",
|
||||
"archive_unreadable": "Dieses Archiv kann hier nicht geöffnet werden."
|
||||
},
|
||||
"received_via": "an {name}",
|
||||
"remove_color": "Label entfernen",
|
||||
@@ -444,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",
|
||||
@@ -1113,7 +1119,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Plugins",
|
||||
"themes": "Designs",
|
||||
"updates": "Updates",
|
||||
"storage": "Speicher & Sicherung",
|
||||
"about": "Über",
|
||||
@@ -1133,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
-33
@@ -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,8 +421,15 @@
|
||||
"forwarded": "Forwarded",
|
||||
"load_failed": "Failed to load email",
|
||||
"preview": {
|
||||
"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.",
|
||||
"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}",
|
||||
@@ -455,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",
|
||||
@@ -470,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}"
|
||||
},
|
||||
@@ -583,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.",
|
||||
@@ -836,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."
|
||||
},
|
||||
@@ -979,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": {
|
||||
@@ -1177,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}\"?",
|
||||
@@ -1224,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.",
|
||||
@@ -1235,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\""
|
||||
}
|
||||
|
||||
+9
-6
@@ -407,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Abrir en una app",
|
||||
"pdf_external": "Los PDF se abren en el visor de PDF de este dispositivo.",
|
||||
"unsupported": "No hay vista previa para este tipo de archivo."
|
||||
"unsupported": "No hay vista previa para este tipo de archivo.",
|
||||
"media_unsupported": "Este archivo no se puede reproducir aquí.",
|
||||
"pdf_protected": "Este PDF está protegido con contraseña. Ábrelo en otra aplicación para introducirla.",
|
||||
"pdf_unreadable": "Este PDF no se puede mostrar aquí.",
|
||||
"archive_files": "{count, plural, one {# archivo} other {# archivos}}",
|
||||
"archive_first_files": "Los primeros {count} archivos",
|
||||
"archive_protected": "Este archivo comprimido está protegido con contraseña. Ábrelo en otra aplicación para introducirla.",
|
||||
"archive_unreadable": "Este archivo comprimido no se puede abrir aquí."
|
||||
},
|
||||
"received_via": "para {name}",
|
||||
"remove_color": "Eliminar etiqueta",
|
||||
@@ -438,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",
|
||||
@@ -1095,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Plugins",
|
||||
"themes": "Temas",
|
||||
"updates": "Actualizaciones"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1112,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": {
|
||||
|
||||
+9
-6
@@ -407,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "باز کردن در برنامه",
|
||||
"pdf_external": "فایلهای PDF در نمایشگر PDF این دستگاه باز میشوند.",
|
||||
"unsupported": "برای این نوع فایل پیشنمایشی وجود ندارد."
|
||||
"unsupported": "برای این نوع فایل پیشنمایشی وجود ندارد.",
|
||||
"media_unsupported": "این فایل اینجا پخش نمیشود.",
|
||||
"pdf_protected": "این PDF با رمز عبور محافظت شده است. برای وارد کردن رمز، آن را در برنامهٔ دیگری باز کنید.",
|
||||
"pdf_unreadable": "این PDF اینجا نمایش داده نمیشود.",
|
||||
"archive_files": "{count, plural, one {# فایل} other {# فایل}}",
|
||||
"archive_first_files": "{count} فایل نخست",
|
||||
"archive_protected": "این بایگانی با رمز عبور محافظت شده است. برای وارد کردن رمز، آن را در برنامهٔ دیگری باز کنید.",
|
||||
"archive_unreadable": "این بایگانی اینجا باز نمیشود."
|
||||
},
|
||||
"received_via": "به {name}",
|
||||
"remove_color": "حذف برچسب",
|
||||
@@ -438,7 +445,6 @@
|
||||
"open_error": "باز کردن فایل ممکن نشد",
|
||||
"open_with": "باز کردن با…",
|
||||
"preview_too_large": "این فایل برای پیشنمایش بیش از حد بزرگ است.",
|
||||
"selected_count": "{count, plural, one {# انتخاب شده} other {# انتخاب شده}}",
|
||||
"share_add_people": "افزودن افراد",
|
||||
"share_empty": "هنوز با کسی به اشتراک گذاشته نشده",
|
||||
"share_error": "بهروزرسانی اشتراکگذاری ناموفق بود",
|
||||
@@ -1095,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "افزونهها",
|
||||
"themes": "تمها",
|
||||
"updates": "بهروزرسانیها"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1112,9 +1117,7 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "پالتهای روشن و تاریک پیشفرض.",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "یک تم رنگی انتخاب کنید. تمها از تنظیم روشن/تاریک بخش ظاهر پیروی میکنند؛ بستههای تم سفارشی در ایمیل تحت وب مدیریت میشوند.",
|
||||
"title": "تمها"
|
||||
"mobile_description": "یک تم رنگی انتخاب کنید. تمها از تنظیم روشن/تاریک بخش ظاهر پیروی میکنند؛ بستههای تم سفارشی در ایمیل تحت وب مدیریت میشوند."
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
|
||||
+9
-6
@@ -407,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Ouvrir dans une app",
|
||||
"pdf_external": "Les PDF s'ouvrent dans votre lecteur PDF sur cet appareil.",
|
||||
"unsupported": "Aucun aperçu pour ce type de fichier."
|
||||
"unsupported": "Aucun aperçu pour ce type de fichier.",
|
||||
"media_unsupported": "Ce fichier ne peut pas être lu ici.",
|
||||
"pdf_protected": "Ce PDF est protégé par un mot de passe. Ouvrez-le dans une autre application pour le saisir.",
|
||||
"pdf_unreadable": "Ce PDF ne peut pas être affiché ici.",
|
||||
"archive_files": "{count, plural, one {# fichier} other {# fichiers}}",
|
||||
"archive_first_files": "Les {count} premiers fichiers",
|
||||
"archive_protected": "Cette archive est protégée par un mot de passe. Ouvrez-la dans une autre application pour le saisir.",
|
||||
"archive_unreadable": "Cette archive ne peut pas être ouverte ici."
|
||||
},
|
||||
"received_via": "à {name}",
|
||||
"remove_color": "Retirer l'étiquette",
|
||||
@@ -438,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",
|
||||
@@ -1095,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Plugins",
|
||||
"themes": "Thèmes",
|
||||
"updates": "Mises à jour"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1112,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": {
|
||||
|
||||
+9
-6
@@ -407,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "פתח באפליקציה",
|
||||
"pdf_external": "קובצי PDF נפתחים במציג ה-PDF במכשיר זה.",
|
||||
"unsupported": "אין תצוגה מקדימה לסוג קובץ זה."
|
||||
"unsupported": "אין תצוגה מקדימה לסוג קובץ זה.",
|
||||
"media_unsupported": "לא ניתן להפעיל את הקובץ הזה כאן.",
|
||||
"pdf_protected": "קובץ ה-PDF הזה מוגן בסיסמה. פתחו אותו באפליקציה אחרת כדי להזין את הסיסמה.",
|
||||
"pdf_unreadable": "לא ניתן להציג את קובץ ה-PDF הזה כאן.",
|
||||
"archive_files": "{count, plural, one {קובץ אחד} two {שני קבצים} other {# קבצים}}",
|
||||
"archive_first_files": "{count} הקבצים הראשונים",
|
||||
"archive_protected": "הארכיון הזה מוגן בסיסמה. פתחו אותו באפליקציה אחרת כדי להזין את הסיסמה.",
|
||||
"archive_unreadable": "לא ניתן לפתוח את הארכיון הזה כאן."
|
||||
},
|
||||
"received_via": "אל {name}",
|
||||
"remove_color": "הסר תג",
|
||||
@@ -438,7 +445,6 @@
|
||||
"open_error": "לא ניתן היה לפתוח את הקובץ",
|
||||
"open_with": "פתח באמצעות…",
|
||||
"preview_too_large": "הקובץ גדול מכדי להציג תצוגה מקדימה.",
|
||||
"selected_count": "{count, plural, one {נבחר פריט אחד} two {נבחרו שני פריטים} other {נבחרו # פריטים}}",
|
||||
"share_add_people": "הוסף אנשים",
|
||||
"share_empty": "עדיין לא משותף עם אף אחד",
|
||||
"share_error": "עדכון השיתוף נכשל",
|
||||
@@ -1095,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "תוספים",
|
||||
"themes": "ערכות נושא",
|
||||
"updates": "עדכונים"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1112,9 +1117,7 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "ערכות הצבעים הבהירה והכהה המוגדרות כברירת מחדל.",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "בחר ערכת צבעים. ערכות הנושא פועלות לפי הגדרת בהיר/כהה במראה; חבילות ערכות נושא מותאמות מנוהלות בדואר האינטרנט.",
|
||||
"title": "ערכות נושא"
|
||||
"mobile_description": "בחר ערכת צבעים. ערכות הנושא פועלות לפי הגדרת בהיר/כהה במראה; חבילות ערכות נושא מותאמות מנוהלות בדואר האינטרנט."
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
|
||||
+9
-6
@@ -407,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Megnyitás alkalmazásban",
|
||||
"pdf_external": "A PDF-fájlok az eszköz PDF-megjelenítőjében nyílnak meg.",
|
||||
"unsupported": "Ehhez a fájltípushoz nincs előnézet."
|
||||
"unsupported": "Ehhez a fájltípushoz nincs előnézet.",
|
||||
"media_unsupported": "Ez a fájl itt nem játszható le.",
|
||||
"pdf_protected": "Ez a PDF jelszóval védett. Nyisd meg egy másik alkalmazásban a jelszó megadásához.",
|
||||
"pdf_unreadable": "Ez a PDF itt nem jeleníthető meg.",
|
||||
"archive_files": "{count, plural, one {# fájl} other {# fájl}}",
|
||||
"archive_first_files": "Az első {count} fájl",
|
||||
"archive_protected": "Ez az archívum jelszóval védett. Nyisd meg egy másik alkalmazásban a jelszó megadásához.",
|
||||
"archive_unreadable": "Ez az archívum itt nem nyitható meg."
|
||||
},
|
||||
"received_via": "címzett: {name}",
|
||||
"remove_color": "Címke eltávolítása",
|
||||
@@ -438,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",
|
||||
@@ -1095,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Bővítmények",
|
||||
"themes": "Témák",
|
||||
"updates": "Frissítések"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1112,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": {
|
||||
|
||||
+9
-6
@@ -407,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Apri in un'app",
|
||||
"pdf_external": "I PDF si aprono nel lettore PDF di questo dispositivo.",
|
||||
"unsupported": "Nessuna anteprima per questo tipo di file."
|
||||
"unsupported": "Nessuna anteprima per questo tipo di file.",
|
||||
"media_unsupported": "Questo file non può essere riprodotto qui.",
|
||||
"pdf_protected": "Questo PDF è protetto da password. Aprilo in un'altra app per inserirla.",
|
||||
"pdf_unreadable": "Questo PDF non può essere visualizzato qui.",
|
||||
"archive_files": "{count, plural, one {# file} other {# file}}",
|
||||
"archive_first_files": "I primi {count} file",
|
||||
"archive_protected": "Questo archivio è protetto da password. Aprilo in un'altra app per inserirla.",
|
||||
"archive_unreadable": "Questo archivio non può essere aperto qui."
|
||||
},
|
||||
"received_via": "a {name}",
|
||||
"remove_color": "Rimuovi etichetta",
|
||||
@@ -438,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",
|
||||
@@ -1095,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Plugin",
|
||||
"themes": "Temi",
|
||||
"updates": "Aggiornamenti"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1112,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": {
|
||||
|
||||
+9
-6
@@ -407,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "アプリで開く",
|
||||
"pdf_external": "PDF はこのデバイスの PDF ビューアーで開きます。",
|
||||
"unsupported": "このファイル形式はプレビューできません。"
|
||||
"unsupported": "このファイル形式はプレビューできません。",
|
||||
"media_unsupported": "このファイルはここでは再生できません。",
|
||||
"pdf_protected": "このPDFはパスワードで保護されています。パスワードを入力するには別のアプリで開いてください。",
|
||||
"pdf_unreadable": "このPDFはここでは表示できません。",
|
||||
"archive_files": "{count, plural, other {# 個のファイル}}",
|
||||
"archive_first_files": "最初の {count} 個のファイル",
|
||||
"archive_protected": "このアーカイブはパスワードで保護されています。パスワードを入力するには別のアプリで開いてください。",
|
||||
"archive_unreadable": "このアーカイブはここでは開けません。"
|
||||
},
|
||||
"received_via": "{name}宛",
|
||||
"remove_color": "ラベルを削除",
|
||||
@@ -438,7 +445,6 @@
|
||||
"open_error": "ファイルを開けませんでした",
|
||||
"open_with": "別のアプリで開く…",
|
||||
"preview_too_large": "このファイルは大きすぎるため、プレビューできません。",
|
||||
"selected_count": "{count, plural, other {#件選択中}}",
|
||||
"share_add_people": "ユーザーを追加",
|
||||
"share_empty": "まだ誰にも共有されていません",
|
||||
"share_error": "共有の更新に失敗しました",
|
||||
@@ -1095,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "プラグイン",
|
||||
"themes": "テーマ",
|
||||
"updates": "アップデート"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1112,9 +1117,7 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "デフォルトのライト/ダークパレットです。",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "カラーテーマを選択します。テーマは「外観」のライト/ダーク設定に従います。カスタムテーマパッケージはウェブメールで管理します。",
|
||||
"title": "テーマ"
|
||||
"mobile_description": "カラーテーマを選択します。テーマは「外観」のライト/ダーク設定に従います。カスタムテーマパッケージはウェブメールで管理します。"
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
|
||||
+9
-6
@@ -407,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "앱에서 열기",
|
||||
"pdf_external": "PDF는 이 기기의 PDF 뷰어에서 열려요.",
|
||||
"unsupported": "이 파일 형식은 미리보기를 지원하지 않아요."
|
||||
"unsupported": "이 파일 형식은 미리보기를 지원하지 않아요.",
|
||||
"media_unsupported": "이 파일은 여기서 재생할 수 없습니다.",
|
||||
"pdf_protected": "이 PDF는 암호로 보호되어 있습니다. 암호를 입력하려면 다른 앱에서 여세요.",
|
||||
"pdf_unreadable": "이 PDF는 여기서 표시할 수 없습니다.",
|
||||
"archive_files": "{count, plural, other {파일 #개}}",
|
||||
"archive_first_files": "처음 {count}개 파일",
|
||||
"archive_protected": "이 압축 파일은 암호로 보호되어 있습니다. 암호를 입력하려면 다른 앱에서 여세요.",
|
||||
"archive_unreadable": "이 압축 파일은 여기서 열 수 없습니다."
|
||||
},
|
||||
"received_via": "받는 사람: {name}",
|
||||
"remove_color": "태그 제거",
|
||||
@@ -438,7 +445,6 @@
|
||||
"open_error": "파일을 열 수 없어요",
|
||||
"open_with": "다른 앱으로 열기…",
|
||||
"preview_too_large": "파일이 너무 커서 미리볼 수 없어요.",
|
||||
"selected_count": "{count, plural, other {#개 선택됨}}",
|
||||
"share_add_people": "사용자 추가",
|
||||
"share_empty": "아직 아무와도 공유하지 않았어요",
|
||||
"share_error": "공유 업데이트에 실패했어요",
|
||||
@@ -1095,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "플러그인",
|
||||
"themes": "테마",
|
||||
"updates": "업데이트"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1112,9 +1117,7 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "기본 라이트 및 다크 팔레트예요.",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "색상 테마를 선택하세요. 테마는 화면 설정의 라이트/다크 설정을 따르며, 사용자 지정 테마 패키지는 웹메일에서 관리해요.",
|
||||
"title": "테마"
|
||||
"mobile_description": "색상 테마를 선택하세요. 테마는 화면 설정의 라이트/다크 설정을 따르며, 사용자 지정 테마 패키지는 웹메일에서 관리해요."
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
|
||||
+9
-6
@@ -407,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Atvērt lietotnē",
|
||||
"pdf_external": "PDF faili tiek atvērti šīs ierīces PDF skatītājā.",
|
||||
"unsupported": "Šim faila tipam nav priekšskatījuma."
|
||||
"unsupported": "Šim faila tipam nav priekšskatījuma.",
|
||||
"media_unsupported": "Šo failu šeit nevar atskaņot.",
|
||||
"pdf_protected": "Šis PDF ir aizsargāts ar paroli. Atveriet to citā lietotnē, lai ievadītu paroli.",
|
||||
"pdf_unreadable": "Šo PDF šeit nevar parādīt.",
|
||||
"archive_files": "{count, plural, zero {# failu} one {# fails} other {# faili}}",
|
||||
"archive_first_files": "Pirmie {count} faili",
|
||||
"archive_protected": "Šis arhīvs ir aizsargāts ar paroli. Atveriet to citā lietotnē, lai ievadītu paroli.",
|
||||
"archive_unreadable": "Šo arhīvu šeit nevar atvērt."
|
||||
},
|
||||
"received_via": "kam: {name}",
|
||||
"remove_color": "Noņemt tagu",
|
||||
@@ -438,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",
|
||||
@@ -1095,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Spraudņi",
|
||||
"themes": "Tēmas",
|
||||
"updates": "Atjauninājumi"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1112,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": {
|
||||
|
||||
+9
-6
@@ -407,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Апп-д нээх",
|
||||
"pdf_external": "PDF файлууд энэ төхөөрөмжийн PDF үзэгчид нээгдэнэ.",
|
||||
"unsupported": "Энэ төрлийн файлыг урьдчилан харах боломжгүй."
|
||||
"unsupported": "Энэ төрлийн файлыг урьдчилан харах боломжгүй.",
|
||||
"media_unsupported": "Энэ файлыг энд тоглуулах боломжгүй.",
|
||||
"pdf_protected": "Энэ PDF нууц үгээр хамгаалагдсан. Нууц үг оруулахын тулд өөр апп-аар нээнэ үү.",
|
||||
"pdf_unreadable": "Энэ PDF-ийг энд харуулах боломжгүй.",
|
||||
"archive_files": "{count, plural, one {# файл} other {# файл}}",
|
||||
"archive_first_files": "Эхний {count} файл",
|
||||
"archive_protected": "Энэ архив нууц үгээр хамгаалагдсан. Нууц үг оруулахын тулд өөр апп-аар нээнэ үү.",
|
||||
"archive_unreadable": "Энэ архивыг энд нээх боломжгүй."
|
||||
},
|
||||
"received_via": "Хэнд: {name}",
|
||||
"remove_color": "Тагийг хасах",
|
||||
@@ -438,7 +445,6 @@
|
||||
"open_error": "Файлыг нээж чадсангүй",
|
||||
"open_with": "Өөр аппаар нээх…",
|
||||
"preview_too_large": "Энэ файл урьдчилан харахад хэт том байна.",
|
||||
"selected_count": "{count, plural, one {Сонгосон: #} other {Сонгосон: #}}",
|
||||
"share_add_people": "Хүн нэмэх",
|
||||
"share_empty": "Одоохондоо хэнтэй ч хуваалцаагүй",
|
||||
"share_error": "Хуваалцалтыг шинэчилж чадсангүй",
|
||||
@@ -1095,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Плагинууд",
|
||||
"themes": "Өнгөний загварууд",
|
||||
"updates": "Шинэчлэлт"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1112,9 +1117,7 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "Өгөгдмөл цайвар ба харанхуй палитр.",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "Өнгөний загвар сонгоно уу. Өнгөний загварууд Гадаад төрх хэсгийн цайвар/харанхуй тохиргоог дагана; өөрийн загварын багцуудыг вэбмэйлд удирдана.",
|
||||
"title": "Өнгөний загварууд"
|
||||
"mobile_description": "Өнгөний загвар сонгоно уу. Өнгөний загварууд Гадаад төрх хэсгийн цайвар/харанхуй тохиргоог дагана; өөрийн загварын багцуудыг вэбмэйлд удирдана."
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
|
||||
+9
-6
@@ -407,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Åpne i app",
|
||||
"pdf_external": "PDF-filer åpnes i PDF-leseren på denne enheten.",
|
||||
"unsupported": "Ingen forhåndsvisning for denne filtypen."
|
||||
"unsupported": "Ingen forhåndsvisning for denne filtypen.",
|
||||
"media_unsupported": "Denne filen kan ikke spilles av her.",
|
||||
"pdf_protected": "Denne PDF-en er passordbeskyttet. Åpne den i en annen app for å skrive inn passordet.",
|
||||
"pdf_unreadable": "Denne PDF-en kan ikke vises her.",
|
||||
"archive_files": "{count, plural, one {# fil} other {# filer}}",
|
||||
"archive_first_files": "De første {count} filene",
|
||||
"archive_protected": "Dette arkivet er passordbeskyttet. Åpne det i en annen app for å skrive inn passordet.",
|
||||
"archive_unreadable": "Dette arkivet kan ikke åpnes her."
|
||||
},
|
||||
"received_via": "til {name}",
|
||||
"remove_color": "Fjern etiketten",
|
||||
@@ -438,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",
|
||||
@@ -1095,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Programtillegg",
|
||||
"themes": "Temaer",
|
||||
"updates": "Oppdateringer"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1112,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": {
|
||||
|
||||
+9
-6
@@ -407,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Openen in app",
|
||||
"pdf_external": "PDF's worden geopend in de PDF-viewer op dit apparaat.",
|
||||
"unsupported": "Geen voorbeeld voor dit bestandstype."
|
||||
"unsupported": "Geen voorbeeld voor dit bestandstype.",
|
||||
"media_unsupported": "Dit bestand kan hier niet worden afgespeeld.",
|
||||
"pdf_protected": "Deze pdf is beveiligd met een wachtwoord. Open hem in een andere app om het wachtwoord in te voeren.",
|
||||
"pdf_unreadable": "Deze pdf kan hier niet worden weergegeven.",
|
||||
"archive_files": "{count, plural, one {# bestand} other {# bestanden}}",
|
||||
"archive_first_files": "De eerste {count} bestanden",
|
||||
"archive_protected": "Dit archief is beveiligd met een wachtwoord. Open het in een andere app om het wachtwoord in te voeren.",
|
||||
"archive_unreadable": "Dit archief kan hier niet worden geopend."
|
||||
},
|
||||
"received_via": "aan {name}",
|
||||
"remove_color": "Label verwijderen",
|
||||
@@ -438,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",
|
||||
@@ -1095,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Plug-ins",
|
||||
"themes": "Thema's",
|
||||
"updates": "Updates"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1112,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": {
|
||||
|
||||
+9
-6
@@ -407,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Otwórz w aplikacji",
|
||||
"pdf_external": "Pliki PDF otwierają się w przeglądarce PDF na tym urządzeniu.",
|
||||
"unsupported": "Brak podglądu dla tego typu pliku."
|
||||
"unsupported": "Brak podglądu dla tego typu pliku.",
|
||||
"media_unsupported": "Tego pliku nie można tutaj odtworzyć.",
|
||||
"pdf_protected": "Ten plik PDF jest chroniony hasłem. Otwórz go w innej aplikacji, aby wpisać hasło.",
|
||||
"pdf_unreadable": "Tego pliku PDF nie można tutaj wyświetlić.",
|
||||
"archive_files": "{count, plural, one {# plik} few {# pliki} many {# plików} other {# pliku}}",
|
||||
"archive_first_files": "Pierwsze {count} plików",
|
||||
"archive_protected": "To archiwum jest chronione hasłem. Otwórz je w innej aplikacji, aby wpisać hasło.",
|
||||
"archive_unreadable": "Tego archiwum nie można tutaj otworzyć."
|
||||
},
|
||||
"received_via": "do {name}",
|
||||
"remove_color": "Usuń etykietę",
|
||||
@@ -438,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",
|
||||
@@ -1095,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Wtyczki",
|
||||
"themes": "Motywy",
|
||||
"updates": "Aktualizacje"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1112,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": {
|
||||
|
||||
+9
-6
@@ -407,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Abrir em outro app",
|
||||
"pdf_external": "PDFs são abertos no leitor de PDF deste dispositivo.",
|
||||
"unsupported": "Sem pré-visualização para este tipo de arquivo."
|
||||
"unsupported": "Sem pré-visualização para este tipo de arquivo.",
|
||||
"media_unsupported": "Este ficheiro não pode ser reproduzido aqui.",
|
||||
"pdf_protected": "Este PDF está protegido por palavra-passe. Abra-o noutra aplicação para a introduzir.",
|
||||
"pdf_unreadable": "Este PDF não pode ser mostrado aqui.",
|
||||
"archive_files": "{count, plural, one {# ficheiro} other {# ficheiros}}",
|
||||
"archive_first_files": "Os primeiros {count} ficheiros",
|
||||
"archive_protected": "Este arquivo está protegido por palavra-passe. Abra-o noutra aplicação para a introduzir.",
|
||||
"archive_unreadable": "Este arquivo não pode ser aberto aqui."
|
||||
},
|
||||
"received_via": "para {name}",
|
||||
"remove_color": "Remover etiqueta",
|
||||
@@ -438,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",
|
||||
@@ -1095,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Plugins",
|
||||
"themes": "Temas",
|
||||
"updates": "Atualizações"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1112,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": {
|
||||
|
||||
+9
-6
@@ -407,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Deschideți într-o aplicație",
|
||||
"pdf_external": "Fișierele PDF se deschid în vizualizatorul PDF de pe acest dispozitiv.",
|
||||
"unsupported": "Nu există previzualizare pentru acest tip de fișier."
|
||||
"unsupported": "Nu există previzualizare pentru acest tip de fișier.",
|
||||
"media_unsupported": "Acest fișier nu poate fi redat aici.",
|
||||
"pdf_protected": "Acest PDF este protejat cu parolă. Deschide-l în altă aplicație pentru a introduce parola.",
|
||||
"pdf_unreadable": "Acest PDF nu poate fi afișat aici.",
|
||||
"archive_files": "{count, plural, one {# fișier} few {# fișiere} other {# de fișiere}}",
|
||||
"archive_first_files": "Primele {count} fișiere",
|
||||
"archive_protected": "Această arhivă este protejată cu parolă. Deschide-o în altă aplicație pentru a introduce parola.",
|
||||
"archive_unreadable": "Această arhivă nu poate fi deschisă aici."
|
||||
},
|
||||
"received_via": "către {name}",
|
||||
"remove_color": "Eliminați eticheta",
|
||||
@@ -438,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",
|
||||
@@ -1095,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Pluginuri",
|
||||
"themes": "Teme",
|
||||
"updates": "Actualizări"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1112,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": {
|
||||
|
||||
+9
-6
@@ -407,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Открыть в приложении",
|
||||
"pdf_external": "PDF-файлы открываются в программе просмотра PDF на этом устройстве.",
|
||||
"unsupported": "Для этого типа файлов нет предпросмотра."
|
||||
"unsupported": "Для этого типа файлов нет предпросмотра.",
|
||||
"media_unsupported": "Этот файл нельзя воспроизвести здесь.",
|
||||
"pdf_protected": "Этот PDF защищён паролем. Откройте его в другом приложении, чтобы ввести пароль.",
|
||||
"pdf_unreadable": "Этот PDF нельзя показать здесь.",
|
||||
"archive_files": "{count, plural, one {# файл} few {# файла} many {# файлов} other {# файла}}",
|
||||
"archive_first_files": "Первые {count} файлов",
|
||||
"archive_protected": "Этот архив защищён паролем. Откройте его в другом приложении, чтобы ввести пароль.",
|
||||
"archive_unreadable": "Этот архив нельзя открыть здесь."
|
||||
},
|
||||
"received_via": "на {name}",
|
||||
"remove_color": "Убрать тег",
|
||||
@@ -438,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": "Не удалось обновить общий доступ",
|
||||
@@ -1095,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Плагины",
|
||||
"themes": "Темы",
|
||||
"updates": "Обновления"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1112,9 +1117,7 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "Стандартные светлая и тёмная палитры.",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "Выберите цветовую тему. Темы учитывают светлый или тёмный режим из раздела «Внешний вид»; собственными пакетами тем можно управлять в веб-почте.",
|
||||
"title": "Темы"
|
||||
"mobile_description": "Выберите цветовую тему. Темы учитывают светлый или тёмный режим из раздела «Внешний вид»; собственными пакетами тем можно управлять в веб-почте."
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
|
||||
+9
-6
@@ -407,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Otvoriť v aplikácii",
|
||||
"pdf_external": "Súbory PDF sa otvárajú v prehliadači PDF na tomto zariadení.",
|
||||
"unsupported": "Pre tento typ súboru nie je k dispozícii náhľad."
|
||||
"unsupported": "Pre tento typ súboru nie je k dispozícii náhľad.",
|
||||
"media_unsupported": "Tento súbor tu nie je možné prehrať.",
|
||||
"pdf_protected": "Toto PDF je chránené heslom. Otvorte ho v inej aplikácii a zadajte heslo.",
|
||||
"pdf_unreadable": "Toto PDF tu nie je možné zobraziť.",
|
||||
"archive_files": "{count, plural, one {# súbor} few {# súbory} many {# súboru} other {# súborov}}",
|
||||
"archive_first_files": "Prvých {count} súborov",
|
||||
"archive_protected": "Tento archív je chránený heslom. Otvorte ho v inej aplikácii a zadajte heslo.",
|
||||
"archive_unreadable": "Tento archív tu nie je možné otvoriť."
|
||||
},
|
||||
"received_via": "pre {name}",
|
||||
"remove_color": "Odstrániť štítok",
|
||||
@@ -438,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",
|
||||
@@ -1095,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Pluginy",
|
||||
"themes": "Témy",
|
||||
"updates": "Aktualizácie"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1112,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": {
|
||||
|
||||
+9
-6
@@ -407,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Uygulamada aç",
|
||||
"pdf_external": "PDF dosyaları bu cihazdaki PDF görüntüleyicide açılır.",
|
||||
"unsupported": "Bu dosya türü için önizleme yok."
|
||||
"unsupported": "Bu dosya türü için önizleme yok.",
|
||||
"media_unsupported": "Bu dosya burada oynatılamıyor.",
|
||||
"pdf_protected": "Bu PDF parola korumalı. Parolayı girmek için başka bir uygulamada açın.",
|
||||
"pdf_unreadable": "Bu PDF burada gösterilemiyor.",
|
||||
"archive_files": "{count, plural, one {# dosya} other {# dosya}}",
|
||||
"archive_first_files": "İlk {count} dosya",
|
||||
"archive_protected": "Bu arşiv parola korumalı. Parolayı girmek için başka bir uygulamada açın.",
|
||||
"archive_unreadable": "Bu arşiv burada açılamıyor."
|
||||
},
|
||||
"received_via": "alıcı: {name}",
|
||||
"remove_color": "Etiketi kaldır",
|
||||
@@ -438,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",
|
||||
@@ -1095,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Eklentiler",
|
||||
"themes": "Temalar",
|
||||
"updates": "Güncellemeler"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1112,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": {
|
||||
|
||||
+9
-6
@@ -407,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "Відкрити в застосунку",
|
||||
"pdf_external": "PDF-файли відкриваються в переглядачі PDF на цьому пристрої.",
|
||||
"unsupported": "Для цього типу файлів немає попереднього перегляду."
|
||||
"unsupported": "Для цього типу файлів немає попереднього перегляду.",
|
||||
"media_unsupported": "Цей файл не можна відтворити тут.",
|
||||
"pdf_protected": "Цей PDF захищено паролем. Відкрийте його в іншому застосунку, щоб ввести пароль.",
|
||||
"pdf_unreadable": "Цей PDF не можна показати тут.",
|
||||
"archive_files": "{count, plural, one {# файл} few {# файли} many {# файлів} other {# файлу}}",
|
||||
"archive_first_files": "Перші {count} файлів",
|
||||
"archive_protected": "Цей архів захищено паролем. Відкрийте його в іншому застосунку, щоб ввести пароль.",
|
||||
"archive_unreadable": "Цей архів не можна відкрити тут."
|
||||
},
|
||||
"received_via": "на {name}",
|
||||
"remove_color": "Прибрати тег",
|
||||
@@ -438,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": "Не вдалося оновити спільний доступ",
|
||||
@@ -1095,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "Плагіни",
|
||||
"themes": "Теми",
|
||||
"updates": "Оновлення"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1112,9 +1117,7 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "Стандартні світла й темна палітри.",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "Виберіть кольорову тему. Теми враховують світлий або темний режим із розділу «Зовнішній вигляд»; власними пакетами тем можна керувати у веб-пошті.",
|
||||
"title": "Теми"
|
||||
"mobile_description": "Виберіть кольорову тему. Теми враховують світлий або темний режим із розділу «Зовнішній вигляд»; власними пакетами тем можна керувати у веб-пошті."
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
|
||||
@@ -407,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "在應用程式中開啟",
|
||||
"pdf_external": "PDF 會在此裝置的 PDF 檢視器中開啟。",
|
||||
"unsupported": "無法預覽此檔案類型。"
|
||||
"unsupported": "無法預覽此檔案類型。",
|
||||
"media_unsupported": "無法在此播放這個檔案。",
|
||||
"pdf_protected": "此 PDF 受密碼保護。請在其他應用程式中開啟以輸入密碼。",
|
||||
"pdf_unreadable": "無法在此顯示這個 PDF。",
|
||||
"archive_files": "{count, plural, other {# 個檔案}}",
|
||||
"archive_first_files": "前 {count} 個檔案",
|
||||
"archive_protected": "此壓縮檔受密碼保護。請在其他應用程式中開啟以輸入密碼。",
|
||||
"archive_unreadable": "無法在此開啟這個壓縮檔。"
|
||||
},
|
||||
"received_via": "寄給 {name}",
|
||||
"remove_color": "移除標籤",
|
||||
@@ -438,7 +445,6 @@
|
||||
"open_error": "無法開啟檔案",
|
||||
"open_with": "以其他應用程式開啟…",
|
||||
"preview_too_large": "此檔案太大,無法預覽。",
|
||||
"selected_count": "{count, plural, other {已選取 # 項}}",
|
||||
"share_add_people": "新增使用者",
|
||||
"share_empty": "尚未與任何人分享",
|
||||
"share_error": "無法更新分享設定",
|
||||
@@ -1095,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "外掛程式",
|
||||
"themes": "主題",
|
||||
"updates": "更新"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1112,9 +1117,7 @@
|
||||
},
|
||||
"themes": {
|
||||
"default_description": "預設的淺色與深色配色。",
|
||||
"default_name": "Bulwark",
|
||||
"mobile_description": "選擇色彩主題。主題會依照「外觀」中的淺色/深色設定;自訂主題套件請在網頁郵件中管理。",
|
||||
"title": "主題"
|
||||
"mobile_description": "選擇色彩主題。主題會依照「外觀」中的淺色/深色設定;自訂主題套件請在網頁郵件中管理。"
|
||||
},
|
||||
"vacation": {
|
||||
"warnings": {
|
||||
|
||||
+9
-6
@@ -407,7 +407,14 @@
|
||||
"preview": {
|
||||
"open_external": "在应用中打开",
|
||||
"pdf_external": "PDF 将在此设备的 PDF 查看器中打开。",
|
||||
"unsupported": "无法预览此类文件。"
|
||||
"unsupported": "无法预览此类文件。",
|
||||
"media_unsupported": "无法在此播放该文件。",
|
||||
"pdf_protected": "此 PDF 受密码保护。请在其他应用中打开以输入密码。",
|
||||
"pdf_unreadable": "无法在此显示该 PDF。",
|
||||
"archive_files": "{count, plural, other {# 个文件}}",
|
||||
"archive_first_files": "前 {count} 个文件",
|
||||
"archive_protected": "此压缩包受密码保护。请在其他应用中打开以输入密码。",
|
||||
"archive_unreadable": "无法在此打开该压缩包。"
|
||||
},
|
||||
"received_via": "发给 {name}",
|
||||
"remove_color": "删除标签",
|
||||
@@ -438,7 +445,6 @@
|
||||
"open_error": "无法打开文件",
|
||||
"open_with": "打开方式…",
|
||||
"preview_too_large": "此文件过大,无法预览。",
|
||||
"selected_count": "{count, plural, other {已选择 # 项}}",
|
||||
"share_add_people": "添加用户",
|
||||
"share_empty": "尚未与任何人共享",
|
||||
"share_error": "更新共享失败",
|
||||
@@ -1095,7 +1101,6 @@
|
||||
},
|
||||
"tabs": {
|
||||
"plugins": "插件",
|
||||
"themes": "主题",
|
||||
"updates": "更新"
|
||||
},
|
||||
"templates": {
|
||||
@@ -1112,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.78",
|
||||
"version": "0.1.83",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.78",
|
||||
"version": "0.1.83",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.78",
|
||||
"version": "0.1.83",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
diff --git a/node_modules/postal-mime/src/base64-decoder.js b/node_modules/postal-mime/src/base64-decoder.js
|
||||
index 93c2aea..ba6e3f1 100644
|
||||
--- a/node_modules/postal-mime/src/base64-decoder.js
|
||||
+++ b/node_modules/postal-mime/src/base64-decoder.js
|
||||
@@ -1,4 +1,4 @@
|
||||
-import { decodeBase64, blobToArrayBuffer } from './decode-strings.js';
|
||||
+import { decodeBase64, concatChunks } from './decode-strings.js';
|
||||
|
||||
export default class Base64Decoder {
|
||||
constructor(opts) {
|
||||
@@ -49,6 +49,6 @@ export default class Base64Decoder {
|
||||
finalize() {
|
||||
this.flushRemainder();
|
||||
|
||||
- return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
|
||||
+ return concatChunks(this.chunks);
|
||||
}
|
||||
}
|
||||
diff --git a/node_modules/postal-mime/src/decode-strings.js b/node_modules/postal-mime/src/decode-strings.js
|
||||
index a5e058e..f255e8d 100644
|
||||
--- a/node_modules/postal-mime/src/decode-strings.js
|
||||
+++ b/node_modules/postal-mime/src/decode-strings.js
|
||||
@@ -145,6 +145,23 @@ export function getDecoder(charset) {
|
||||
* @param {Blob} blob Blob to convert
|
||||
* @returns {ArrayBuffer} Converted value
|
||||
*/
|
||||
+// React Native's Blob cannot be built from ArrayBuffers or typed arrays, so
|
||||
+// the decoders join their chunks here instead of through a Blob.
|
||||
+export function concatChunks(chunks) {
|
||||
+ const parts = chunks.map(chunk => {
|
||||
+ if (typeof chunk === 'string') return textEncoder.encode(chunk);
|
||||
+ if (chunk instanceof ArrayBuffer) return new Uint8Array(chunk);
|
||||
+ return new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
||||
+ });
|
||||
+ const out = new Uint8Array(parts.reduce((n, part) => n + part.byteLength, 0));
|
||||
+ let pos = 0;
|
||||
+ for (const part of parts) {
|
||||
+ out.set(part, pos);
|
||||
+ pos += part.byteLength;
|
||||
+ }
|
||||
+ return out.buffer;
|
||||
+}
|
||||
+
|
||||
export async function blobToArrayBuffer(blob) {
|
||||
if ('arrayBuffer' in blob) {
|
||||
return await blob.arrayBuffer();
|
||||
diff --git a/node_modules/postal-mime/src/pass-through-decoder.js b/node_modules/postal-mime/src/pass-through-decoder.js
|
||||
index ad1c222..1e1d012 100644
|
||||
--- a/node_modules/postal-mime/src/pass-through-decoder.js
|
||||
+++ b/node_modules/postal-mime/src/pass-through-decoder.js
|
||||
@@ -1,4 +1,4 @@
|
||||
-import { blobToArrayBuffer } from './decode-strings.js';
|
||||
+import { concatChunks } from './decode-strings.js';
|
||||
|
||||
export default class PassThroughDecoder {
|
||||
constructor() {
|
||||
@@ -12,6 +12,6 @@ export default class PassThroughDecoder {
|
||||
|
||||
finalize() {
|
||||
// convert an array of arraybuffers into a blob and then back into a single arraybuffer
|
||||
- return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
|
||||
+ return concatChunks(this.chunks);
|
||||
}
|
||||
}
|
||||
diff --git a/node_modules/postal-mime/src/qp-decoder.js b/node_modules/postal-mime/src/qp-decoder.js
|
||||
index adee72c..3973673 100644
|
||||
--- a/node_modules/postal-mime/src/qp-decoder.js
|
||||
+++ b/node_modules/postal-mime/src/qp-decoder.js
|
||||
@@ -1,4 +1,4 @@
|
||||
-import { blobToArrayBuffer, hexNibble } from './decode-strings.js';
|
||||
+import { concatChunks, hexNibble } from './decode-strings.js';
|
||||
|
||||
const CHR_EQUALS = 0x3d;
|
||||
const CHR_LF = 0x0a;
|
||||
@@ -83,6 +83,6 @@ export default class QPDecoder {
|
||||
this.flushBuffer();
|
||||
|
||||
// convert an array of arraybuffers into a blob and then back into a single arraybuffer
|
||||
- return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
|
||||
+ return concatChunks(this.chunks);
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ const MAP = {
|
||||
ArrowUpNarrowWide: 'sort-ascending',
|
||||
Ban: 'ban',
|
||||
Bell: 'bell',
|
||||
BellOff: 'bell-off',
|
||||
BellRing: 'bell-ringing',
|
||||
Bold: 'bold',
|
||||
Book: 'book-2',
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'acc-1',
|
||||
request: vi.fn(),
|
||||
getMaxCallsInRequest: vi.fn(() => 16),
|
||||
getMaxObjectsInGet: vi.fn(() => 500),
|
||||
getMaxObjectsInSet: vi.fn(() => 500),
|
||||
},
|
||||
}));
|
||||
|
||||
import { jmapClient } from '../jmap-client';
|
||||
import { saveDraftVersion, type OutgoingAttachment, type OutgoingEmail } from '../email';
|
||||
import { initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs } from '../../lib/draft-part-blobs';
|
||||
|
||||
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||
|
||||
/**
|
||||
* A tiny server with Stalwart's blob semantics, as measured on 0.16.22 (2026-10-14):
|
||||
* an email's part blobIds are derived from that email and stop resolving the
|
||||
* moment it is destroyed ("blobId … does not exist on this server"); creating
|
||||
* an email copies the referenced bytes into new parts with new blobIds.
|
||||
*/
|
||||
function fakeStalwart() {
|
||||
let seq = 0;
|
||||
const emails = new Map<string, Array<{ blobId: string; name: string; size: number; cid?: string; type: string }>>();
|
||||
const live = (blobId: string) => [...emails.values()].some((parts) => parts.some((p) => p.blobId === blobId));
|
||||
const seed = (id: string, parts: OutgoingAttachment[]) => {
|
||||
emails.set(id, parts.map((p) => ({ blobId: p.blobId, name: p.name, size: p.size ?? 0, cid: p.cid, type: p.type })));
|
||||
};
|
||||
mockRequest.mockImplementation(async (calls: Array<[string, Record<string, any>, string]>) => {
|
||||
const out: Array<[string, unknown, string]> = [];
|
||||
let createdId: string | undefined;
|
||||
for (const [method, args, callId] of calls) {
|
||||
if (method === 'Email/set' && args.create) {
|
||||
const create = args.create.draft;
|
||||
const dead = (create.attachments ?? []).find((a: { blobId: string }) => !live(a.blobId));
|
||||
if (dead) {
|
||||
out.push(['Email/set', { notCreated: { draft: {
|
||||
type: 'blobNotFound', description: `blobId ${dead.blobId} does not exist on this server.`,
|
||||
} } }, callId]);
|
||||
continue;
|
||||
}
|
||||
createdId = `e${++seq}`;
|
||||
emails.set(createdId, (create.attachments ?? []).map((a: OutgoingAttachment, i: number) => ({
|
||||
blobId: `${createdId}-part${i}`, name: a.name, size: a.size ?? 0, cid: a.cid ? `<${a.cid}>` : undefined, type: a.type,
|
||||
})));
|
||||
out.push(['Email/set', { created: { draft: { id: createdId } } }, callId]);
|
||||
} else if (method === 'Email/set' && args.destroy) {
|
||||
for (const id of args.destroy) emails.delete(id);
|
||||
out.push(['Email/set', { destroyed: args.destroy }, callId]);
|
||||
} else if (method === 'Email/get') {
|
||||
if (!createdId || args.ids?.[0] !== '#draft') {
|
||||
out.push(['error', { type: 'invalidResultReference' }, callId]);
|
||||
continue;
|
||||
}
|
||||
out.push(['Email/get', { list: [{ id: createdId, attachments: emails.get(createdId) }] }, callId]);
|
||||
}
|
||||
}
|
||||
return { methodResponses: out };
|
||||
});
|
||||
return { seed, emails };
|
||||
}
|
||||
|
||||
const IMG_A: OutgoingAttachment = { blobId: 'd0-part-a', name: 'schema-a.png', size: 136574, type: 'image/png', disposition: 'inline', cid: 'a@undust.co' };
|
||||
const IMG_B: OutgoingAttachment = { blobId: 'd0-part-b', name: 'schema-b.png', size: 175155, type: 'image/png', disposition: 'inline', cid: 'b@undust.co' };
|
||||
|
||||
const draftWith = (attachments: OutgoingAttachment[]): OutgoingEmail => ({
|
||||
from: [{ email: 'me@undust.co' }],
|
||||
to: [{ email: 'test@example.invalid' }],
|
||||
subject: 'RE: draft with two inline images',
|
||||
htmlBody: '<p>x <img src="cid:a@undust.co"> <img src="cid:b@undust.co"></p>',
|
||||
textBody: 'x',
|
||||
attachments,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('saveDraftVersion', () => {
|
||||
it('reads the new version\'s parts back in the same request, by creation id', async () => {
|
||||
fakeStalwart().seed('d0', [IMG_A]);
|
||||
const saved = await saveDraftVersion(draftWith([IMG_A]), 'drafts', 'd0');
|
||||
const calls = mockRequest.mock.calls[0][0];
|
||||
expect(calls.map((c: [string]) => c[0])).toEqual(['Email/set', 'Email/get']);
|
||||
expect(calls[1][1].ids).toEqual(['#draft']);
|
||||
expect(saved.id).toBe('e1');
|
||||
expect(saved.attachments.map((a) => a.blobId)).toEqual(['e1-part0']);
|
||||
});
|
||||
|
||||
it('destroys the previous version only after the new one exists', async () => {
|
||||
fakeStalwart().seed('d0', [IMG_A]);
|
||||
await saveDraftVersion(draftWith([IMG_A]), 'drafts', 'd0');
|
||||
const destroyCall = mockRequest.mock.calls[1][0][0];
|
||||
expect(destroyCall[0]).toBe('Email/set');
|
||||
expect(destroyCall[1].destroy).toEqual(['d0']);
|
||||
});
|
||||
|
||||
it('still returns the id when the read-back fails', async () => {
|
||||
mockRequest.mockResolvedValueOnce({ methodResponses: [
|
||||
['Email/set', { created: { draft: { id: 'e9' } } }, '0'],
|
||||
['error', { type: 'serverFail' }, '1'],
|
||||
] });
|
||||
const saved = await saveDraftVersion(draftWith([]), 'drafts');
|
||||
expect(saved).toEqual({ id: 'e9', attachments: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('re-opened draft with inline images: save, save again, send', () => {
|
||||
// What ComposeScreen does: buildOutgoing maps each held blobId through the
|
||||
// state, saveDraftOnce rebases it on the parts read back.
|
||||
async function editSession(rebase: boolean) {
|
||||
const server = fakeStalwart();
|
||||
server.seed('d0', [IMG_A, IMG_B]); // the draft as deposited (IMAP APPEND or an earlier save)
|
||||
let state = initialDraftPartBlobState();
|
||||
let draftId = 'd0';
|
||||
const outgoing = () => draftWith([IMG_A, IMG_B].map((a) => ({ ...a, blobId: liveBlobId(state, a.blobId) })));
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const sent = outgoing();
|
||||
const saved = await saveDraftVersion(sent, 'drafts', draftId);
|
||||
draftId = saved.id;
|
||||
if (rebase) state = rebaseDraftPartBlobs(state, sent.attachments ?? [], saved.attachments);
|
||||
}
|
||||
return { server, draftId };
|
||||
}
|
||||
|
||||
it('reproduces the bug without the rebase: the second save references the destroyed draft\'s parts', async () => {
|
||||
await expect(editSession(false)).rejects.toThrow('blobId d0-part-a does not exist on this server.');
|
||||
});
|
||||
|
||||
it('keeps every save (and so the send, which builds the same attachments) on live blobs', async () => {
|
||||
const { server, draftId } = await editSession(true);
|
||||
expect([...server.emails.keys()]).toEqual([draftId]);
|
||||
expect(server.emails.get(draftId)!.map((p) => p.name)).toEqual(['schema-a.png', 'schema-b.png']);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
getFullEmails,
|
||||
getThreadsHeaders,
|
||||
createDraft,
|
||||
saveDraftVersion,
|
||||
destroyEmails,
|
||||
sendEmail,
|
||||
markAsSpam,
|
||||
undoSpam,
|
||||
@@ -33,6 +35,9 @@ import {
|
||||
EMAIL_FULL_PROPERTIES,
|
||||
} from '../email';
|
||||
import { getIdentities } from '../identity';
|
||||
import { uploadBytes } from '../blob';
|
||||
import { initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs } from '../../lib/draft-part-blobs';
|
||||
import type { OutgoingAttachment } from '../email';
|
||||
import { getCalendars, queryEvents, createEvent, deleteEvents } from '../calendar';
|
||||
import { getAddressBooks, createContact, deleteContacts } from '../contacts';
|
||||
|
||||
@@ -192,6 +197,44 @@ live('live Stalwart', () => {
|
||||
await destroyEmails([id]);
|
||||
}, 60_000);
|
||||
|
||||
it('keeps a re-opened draft with an inline image saveable across saves (#849 part blobs)', async () => {
|
||||
// A 1x1 PNG uploaded once; D0 stands for the draft as it sits on the server.
|
||||
const png = Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='), (c) => c.charCodeAt(0));
|
||||
const up = await uploadBytes(png, 'image/png');
|
||||
const base = {
|
||||
from: [{ email: USER }],
|
||||
to: [{ email: 'test@example.invalid' }],
|
||||
subject: '[TEST bulwark-blob — supprimable]',
|
||||
htmlBody: '<p>live <img src="cid:img1@live.test"></p>',
|
||||
textBody: 'live',
|
||||
};
|
||||
const img = (blobId: string): OutgoingAttachment => ({
|
||||
blobId, type: 'image/png', name: 'img1.png', size: png.byteLength, disposition: 'inline', cid: 'img1@live.test',
|
||||
});
|
||||
const d0 = await saveDraftVersion({ ...base, attachments: [img(up.blobId)] }, drafts);
|
||||
const created = [d0.id];
|
||||
try {
|
||||
// Re-open D0: the composer holds D0's part blobId.
|
||||
const seeded = d0.attachments[0].blobId;
|
||||
let state = initialDraftPartBlobState();
|
||||
let draftId = d0.id;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const sent = [img(liveBlobId(state, seeded))];
|
||||
const saved = await saveDraftVersion({ ...base, attachments: sent }, drafts, draftId);
|
||||
created.push(saved.id);
|
||||
draftId = saved.id;
|
||||
state = rebaseDraftPartBlobs(state, sent, saved.attachments);
|
||||
}
|
||||
// Without the rebase the seeded part blob is gone with D0.
|
||||
await expect(saveDraftVersion({ ...base, attachments: [img(seeded)] }, drafts))
|
||||
.rejects.toThrow(/does not exist|blobNotFound/);
|
||||
const [last] = await getFullEmails([draftId]);
|
||||
expect(last.attachments?.[0]?.cid).toContain('img1@live.test');
|
||||
} finally {
|
||||
await destroyEmails(created).catch(() => undefined);
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
it('sends through Drafts and files into Sent only after submission (#188)', async () => {
|
||||
const identities = await getIdentities();
|
||||
expect(identities.length).toBeGreaterThan(0);
|
||||
|
||||
+48
-5
@@ -1679,31 +1679,64 @@ export async function sendEmail(
|
||||
};
|
||||
}
|
||||
|
||||
/** A saved draft version and the attachment parts the server built for it. */
|
||||
export interface SavedDraftVersion {
|
||||
id: string;
|
||||
/**
|
||||
* Attachment and inline parts of the new version, with their blobIds. A
|
||||
* part's blobId only lives as long as this version: re-opened drafts must
|
||||
* send these (not the previous version's) on their next save or send.
|
||||
* Empty when the server did not answer the read-back.
|
||||
*/
|
||||
attachments: Attachment[];
|
||||
}
|
||||
|
||||
const DRAFT_PART_BODY_PROPERTIES = ['partId', 'blobId', 'size', 'name', 'type', 'cid', 'disposition'];
|
||||
|
||||
/**
|
||||
* Save a draft into the Drafts folder (`$draft` + `$seen`). When `previousDraftId`
|
||||
* is given it is destroyed only AFTER the replacement was created (#849): a
|
||||
* combined create+destroy would delete the last good copy when the create
|
||||
* fails (e.g. blobNotFound on a re-opened draft's attachments). Returns the
|
||||
* new draft id.
|
||||
* fails (e.g. blobNotFound on a re-opened draft's attachments). The new
|
||||
* version's attachment parts are read back in the same request, so the
|
||||
* composer can re-point its draft-part blobIds before the old version (and
|
||||
* its part blobs) is destroyed.
|
||||
*/
|
||||
export async function createDraft(
|
||||
export async function saveDraftVersion(
|
||||
email: OutgoingEmail,
|
||||
draftsMailboxId: string,
|
||||
previousDraftId?: string,
|
||||
accountIdOverride?: string,
|
||||
): Promise<string> {
|
||||
): Promise<SavedDraftVersion> {
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
const emailCreate = buildEmailCreate(email);
|
||||
emailCreate.mailboxIds = { [draftsMailboxId]: true };
|
||||
emailCreate.keywords = { $seen: true, $draft: true };
|
||||
const res = await jmapClient.request([
|
||||
['Email/set', { accountId, create: { draft: emailCreate } }, '0'],
|
||||
// `#draft` is the creation id (RFC 8620 §5.3). Stalwart 0.16 does not
|
||||
// resolve a result reference into an Email/set `created` map
|
||||
// (invalidResultReference), but it does resolve the creation id.
|
||||
['Email/get', {
|
||||
accountId,
|
||||
ids: ['#draft'],
|
||||
properties: ['id', 'attachments'],
|
||||
bodyProperties: DRAFT_PART_BODY_PROPERTIES,
|
||||
}, '1'],
|
||||
]);
|
||||
const body = requireMethodResult(res, '0', 'Email/set');
|
||||
const notCreated = body.notCreated?.draft as { description?: string; type?: string } | undefined;
|
||||
if (notCreated) throw new Error(notCreated.description || notCreated.type || 'Failed to save draft');
|
||||
const id = body.created?.draft?.id as string | undefined;
|
||||
if (!id) throw new Error('Draft save returned no id');
|
||||
let attachments: Attachment[] = [];
|
||||
try {
|
||||
const got = requireMethodResult(res, '1', 'Email/get');
|
||||
const saved = (got.list as Array<{ id: string; attachments?: Attachment[] }> | undefined)?.find((e) => e.id === id);
|
||||
attachments = (saved?.attachments ?? []).filter((a) => !!a.blobId);
|
||||
} catch (err) {
|
||||
console.warn('[email] could not read back the saved draft parts:', err);
|
||||
}
|
||||
if (previousDraftId && previousDraftId !== id) {
|
||||
try {
|
||||
await destroyEmails([previousDraftId], accountId);
|
||||
@@ -1711,7 +1744,17 @@ export async function createDraft(
|
||||
console.warn('[email] failed to destroy previous draft version:', err);
|
||||
}
|
||||
}
|
||||
return id;
|
||||
return { id, attachments };
|
||||
}
|
||||
|
||||
/** {@link saveDraftVersion} for callers that only need the new draft id. */
|
||||
export async function createDraft(
|
||||
email: OutgoingEmail,
|
||||
draftsMailboxId: string,
|
||||
previousDraftId?: string,
|
||||
accountIdOverride?: string,
|
||||
): Promise<string> {
|
||||
return (await saveDraftVersion(email, draftsMailboxId, previousDraftId, accountIdOverride)).id;
|
||||
}
|
||||
|
||||
export interface ScheduledEmail {
|
||||
|
||||
@@ -78,10 +78,16 @@ interface Props {
|
||||
onSelectionChange?: (state: RichTextSelectionState) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
/**
|
||||
* Android: the page undid a caret-reveal pan of `dy` px (dp) inside the
|
||||
* WebView. The host should scroll its ScrollView down by `dy` so the caret
|
||||
* stays visible (see `pinVisualViewport` in editor-html).
|
||||
*/
|
||||
onViewportShift?: (dy: number) => void;
|
||||
}
|
||||
|
||||
const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function RichTextEditor(
|
||||
{ initialHtml = '', placeholder = '', blockRemoteImages = false, onChange, onSelectionChange, onFocus, onBlur },
|
||||
{ initialHtml = '', placeholder = '', blockRemoteImages = false, onChange, onSelectionChange, onFocus, onBlur, onViewportShift },
|
||||
ref,
|
||||
) {
|
||||
const c = useColors();
|
||||
@@ -92,6 +98,8 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
||||
const onSelectionRef = React.useRef(onSelectionChange);
|
||||
const onFocusRef = React.useRef(onFocus);
|
||||
const onBlurRef = React.useRef(onBlur);
|
||||
const onViewportShiftRef = React.useRef(onViewportShift);
|
||||
onViewportShiftRef.current = onViewportShift;
|
||||
onChangeRef.current = onChange;
|
||||
onSelectionRef.current = onSelectionChange;
|
||||
onFocusRef.current = onFocus;
|
||||
@@ -100,7 +108,9 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
||||
// Build the source HTML once. Theming changes won't auto-rebuild the page,
|
||||
// which is fine: themes change rarely and the user can recompose if needed.
|
||||
const html = React.useMemo(
|
||||
() => buildEditorHtml({ initialHtml, placeholder, c, blockRemoteImages }),
|
||||
() => buildEditorHtml({
|
||||
initialHtml, placeholder, c, blockRemoteImages, pinVisualViewport: Platform.OS === 'android',
|
||||
}),
|
||||
// c reference changes per theme switch but we don't want to lose user
|
||||
// edits on a theme change. Bake in the initial colors only.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -224,6 +234,11 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
||||
case 'blur':
|
||||
onBlurRef.current?.();
|
||||
break;
|
||||
case 'viewportShift': {
|
||||
const dy = typeof data.payload === 'number' ? data.payload : Number(data.payload);
|
||||
if (Number.isFinite(dy) && dy !== 0) onViewportShiftRef.current?.(dy);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import {
|
||||
Check, X, Upload, Rss, Shuffle, Star, Plus, Pencil, Share2, Eraser, Trash2, RefreshCw,
|
||||
Check, X, Upload, Rss, Shuffle, Star, Plus, Pencil, Share2, Eraser, Trash2, RefreshCw, ArrowUp, ArrowDown,
|
||||
type AppIcon,
|
||||
} from '../icons';
|
||||
import type { Calendar } from '../../api/types';
|
||||
@@ -55,6 +55,9 @@ interface CalendarSidebarDrawerProps {
|
||||
onShare?: (calendar: Calendar) => void;
|
||||
onClear?: (calendar: Calendar) => void;
|
||||
onDelete?: (calendar: Calendar) => void;
|
||||
// Move one of the user's own calendars next to `targetId`, a neighbour in
|
||||
// the same section (before it when moving up, after it when moving down).
|
||||
onMove?: (calendar: Calendar, targetId: string) => void;
|
||||
// Client-side iCal subscriptions are managed from the subscriptions sheet.
|
||||
isSubscriptionCalendar?: (calendarId: string) => boolean;
|
||||
}
|
||||
@@ -79,6 +82,7 @@ export function CalendarSidebarDrawer({
|
||||
onShare,
|
||||
onClear,
|
||||
onDelete,
|
||||
onMove,
|
||||
isSubscriptionCalendar,
|
||||
}: CalendarSidebarDrawerProps) {
|
||||
const c = useColors();
|
||||
@@ -148,6 +152,7 @@ export function CalendarSidebarDrawer({
|
||||
onShare,
|
||||
onClear,
|
||||
onDelete,
|
||||
onMove,
|
||||
isSubscriptionCalendar,
|
||||
};
|
||||
|
||||
@@ -284,6 +289,7 @@ function Section({
|
||||
onShare,
|
||||
onClear,
|
||||
onDelete,
|
||||
onMove,
|
||||
isSubscriptionCalendar,
|
||||
}: {
|
||||
title: string;
|
||||
@@ -299,11 +305,14 @@ function Section({
|
||||
onShare?: (calendar: Calendar) => void;
|
||||
onClear?: (calendar: Calendar) => void;
|
||||
onDelete?: (calendar: Calendar) => void;
|
||||
onMove?: (calendar: Calendar, targetId: string) => void;
|
||||
isSubscriptionCalendar?: (calendarId: string) => boolean;
|
||||
}) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
// Neighbours a calendar can move past: the server calendars of this section.
|
||||
const orderable = calendars.filter((cal) => cal.id !== BIRTHDAY_CALENDAR_ID);
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
@@ -320,7 +329,11 @@ function Section({
|
||||
const canClear = !!onClear && isOwn && isWritableCalendar(cal);
|
||||
// Like webmail: the default calendar and shared calendars can't be deleted.
|
||||
const canDelete = !!onDelete && isOwn && !cal.isDefault && (!cal.myRights || cal.myRights.mayDelete !== false);
|
||||
const hasActions = canSetDefault || canRecolor || canRename || canShare || canClear || canDelete;
|
||||
const orderIndex = orderable.indexOf(cal);
|
||||
const previousId = isOwn && orderIndex > 0 ? orderable[orderIndex - 1].id : undefined;
|
||||
const nextId = isOwn && orderIndex >= 0 ? orderable[orderIndex + 1]?.id : undefined;
|
||||
const canMove = !!onMove && (!!previousId || !!nextId);
|
||||
const hasActions = canSetDefault || canRecolor || canRename || canShare || canClear || canDelete || canMove;
|
||||
const expanded = expandedId === cal.id && hasActions;
|
||||
return (
|
||||
<View key={cal.id}>
|
||||
@@ -355,6 +368,27 @@ function Section({
|
||||
|
||||
{expanded && (
|
||||
<View style={styles.actionsPanel}>
|
||||
{/* The panel stays open so a calendar can be moved several places. */}
|
||||
{canMove && previousId && (
|
||||
<Pressable
|
||||
onPress={() => onMove!(cal, previousId)}
|
||||
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<ArrowUp size={16} color={c.textSecondary} />
|
||||
<Text style={styles.panelRowText}>{t('settings.account.accounts.move_up', 'Move up')}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{canMove && nextId && (
|
||||
<Pressable
|
||||
onPress={() => onMove!(cal, nextId)}
|
||||
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<ArrowDown size={16} color={c.textSecondary} />
|
||||
<Text style={styles.panelRowText}>{t('settings.account.accounts.move_down', 'Move down')}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{canSetDefault && (
|
||||
<Pressable
|
||||
onPress={() => { onExpand(null); onSetDefault!(cal); }}
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, ActivityIndicator, Alert } from 'react-native';
|
||||
import { Paperclip, Download, Eye, ExternalLink, Save, Share2, FolderArchive } from '../icons';
|
||||
import type { File } from 'expo-file-system';
|
||||
import type { Attachment, Email } from '../../api/types';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import {
|
||||
shareAttachment, downloadAttachment, shareAttachmentViaSheet, fetchBlobBytes,
|
||||
shareBytes, shareLocalFile, cachePreviewFile, writePreviewFile, discardPreviewFile,
|
||||
shareAttachment, downloadAttachment, shareAttachmentViaSheet, fetchBlobBytes, shareBytes, attachmentSaveName,
|
||||
} from '../../lib/email-export';
|
||||
import {
|
||||
getAttachmentDisplayName, visibleAttachments, previewKindFor, formatSize,
|
||||
getAttachmentDisplayName, visibleAttachments, formatSize,
|
||||
} from '../../lib/attachment-display';
|
||||
import { emailExportFilename } from '../../lib/download-filename';
|
||||
import { ActionSheet, type ActionSheetItem } from './ActionSheet';
|
||||
import { AttachmentPreviewModal, type PreviewItem } from './AttachmentPreviewModal';
|
||||
import { emlPreviewFromBytes, type ExtractedAttachment } from './use-body-override';
|
||||
import { AttachmentPreviewModal } from './AttachmentPreviewModal';
|
||||
import type { ExtractedAttachment } from './use-body-override';
|
||||
import { previewKindOf, useFilePreview, type PreviewSource } from './use-file-preview';
|
||||
|
||||
interface Props {
|
||||
email: Email;
|
||||
@@ -65,24 +64,7 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
const [busyKey, setBusyKey] = React.useState<string | null>(null);
|
||||
const [sheetItem, setSheetItem] = React.useState<Item | null>(null);
|
||||
const [preview, setPreview] = React.useState<PreviewItem | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = React.useState(false);
|
||||
const previewSource = React.useRef<Item | null>(null);
|
||||
// The file the open preview shows, in a temp folder of its own: images are
|
||||
// cached by path, so two attachments both called "image001.png" must not
|
||||
// share one. Deleted when the preview closes, unless it went to another
|
||||
// app, which may still be reading it; the stale-export sweep gets that one.
|
||||
const previewFile = React.useRef<{ file: File; handedOff: boolean } | null>(null);
|
||||
// Bumped whenever a preview ends, so a download that finishes after its
|
||||
// preview was closed is dropped instead of reopening it.
|
||||
const previewSeq = React.useRef(0);
|
||||
const endPreview = React.useCallback(() => {
|
||||
previewSeq.current += 1;
|
||||
const open = previewFile.current;
|
||||
previewFile.current = null;
|
||||
if (open && !open.handedOff) discardPreviewFile(open.file);
|
||||
}, []);
|
||||
React.useEffect(() => endPreview, [endPreview]);
|
||||
const preview = useFilePreview();
|
||||
|
||||
const items = React.useMemo<Item[]>(() => {
|
||||
const blobs = visibleAttachments(email, { hideInlineImageAttachments, calendarBannerShown, tnefUnpacked })
|
||||
@@ -134,49 +116,29 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
|
||||
else await shareBytes(item.part.bytes, item.name, item.type, { forceSheet: true });
|
||||
}, t('email_viewer.attachment_failed', 'Could not share attachment'));
|
||||
|
||||
const showPreview = (item: Item) => {
|
||||
const kind = previewKindFor({ name: item.name, type: item.type });
|
||||
if (kind === 'none') { void open(item); return; }
|
||||
previewSource.current = item;
|
||||
endPreview();
|
||||
const seq = previewSeq.current;
|
||||
const stillOpen = () => seq === previewSeq.current;
|
||||
setPreviewLoading(true);
|
||||
setPreview(null);
|
||||
void (async () => {
|
||||
try {
|
||||
if (kind === 'text' || kind === 'eml') {
|
||||
const bytes = item.kind === 'blob'
|
||||
? await fetchBlobBytes(item.att.blobId, item.att.name, item.att.type, jmapAccountId)
|
||||
: item.part.bytes;
|
||||
const next: PreviewItem = kind === 'eml'
|
||||
? { kind, name: item.name, mimeType: item.type, eml: await emlPreviewFromBytes(bytes) }
|
||||
: { kind, name: item.name, mimeType: item.type, text: new TextDecoder('utf-8').decode(bytes) };
|
||||
if (stillOpen()) setPreview(next);
|
||||
return;
|
||||
}
|
||||
const file = item.kind === 'blob'
|
||||
? await cachePreviewFile(item.att.blobId, item.name, item.type, jmapAccountId)
|
||||
: writePreviewFile(item.part.bytes, item.name, item.type);
|
||||
if (!stillOpen()) {
|
||||
discardPreviewFile(file);
|
||||
return;
|
||||
}
|
||||
previewFile.current = { file, handedOff: false };
|
||||
setPreview({ kind, name: item.name, mimeType: item.type, fileUri: file.uri });
|
||||
} catch (e) {
|
||||
if (!stillOpen()) return;
|
||||
setPreview(null);
|
||||
Alert.alert(t('email_viewer.attachment_failed', 'Could not open attachment'), e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
if (stillOpen()) setPreviewLoading(false);
|
||||
}
|
||||
})();
|
||||
};
|
||||
const sourceOf = (item: Item): PreviewSource => (item.kind === 'blob'
|
||||
? {
|
||||
from: 'blob',
|
||||
blobId: item.att.blobId,
|
||||
accountId: jmapAccountId,
|
||||
email,
|
||||
name: item.name,
|
||||
type: item.type,
|
||||
size: item.size,
|
||||
saveName: attachmentSaveName(email, item.att.name, item.att.type),
|
||||
}
|
||||
: { from: 'bytes', bytes: item.part.bytes, name: item.name, type: item.type, size: item.size });
|
||||
|
||||
// The in-app preview where the type has one, else straight to another app.
|
||||
const showPreview = (item: Item) => run(
|
||||
item.key,
|
||||
() => preview.open(sourceOf(item)),
|
||||
t('email_viewer.attachment_failed', 'Could not open attachment'),
|
||||
);
|
||||
|
||||
const onTap = (item: Item) => {
|
||||
if (mailAttachmentAction === 'download') void save(item);
|
||||
else showPreview(item);
|
||||
else void showPreview(item);
|
||||
};
|
||||
|
||||
const downloadAll = () => run('__all__', async () => {
|
||||
@@ -204,11 +166,11 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
|
||||
|
||||
const visible = expanded ? items : items.slice(0, 3);
|
||||
const sheetActions: ActionSheetItem[] = sheetItem ? [
|
||||
...(previewKindFor({ name: sheetItem.name, type: sheetItem.type }) !== 'none' ? [{
|
||||
...(previewKindOf(sheetItem) !== 'none' ? [{
|
||||
key: 'preview',
|
||||
label: t('email_viewer.attachment_actions.preview', 'Preview'),
|
||||
icon: <Eye size={18} color={c.textSecondary} />,
|
||||
onPress: () => { const it = sheetItem; setSheetItem(null); showPreview(it); },
|
||||
onPress: () => { const it = sheetItem; setSheetItem(null); void showPreview(it); },
|
||||
}] : []),
|
||||
{
|
||||
key: 'open',
|
||||
@@ -278,36 +240,7 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
|
||||
onClose={() => setSheetItem(null)}
|
||||
/>
|
||||
|
||||
<AttachmentPreviewModal
|
||||
item={preview}
|
||||
loading={previewLoading}
|
||||
onClose={() => {
|
||||
endPreview();
|
||||
setPreview(null);
|
||||
setPreviewLoading(false);
|
||||
}}
|
||||
onOpenExternal={() => {
|
||||
const it = previewSource.current;
|
||||
if (!it) return;
|
||||
if (preview?.fileUri && it.kind === 'blob') {
|
||||
void run(it.key, () => shareAttachment(it.att.blobId, it.att.name, it.att.type, email, jmapAccountId), t('email_viewer.attachment_failed', 'Could not open attachment'));
|
||||
} else {
|
||||
void open(it);
|
||||
}
|
||||
}}
|
||||
onShare={() => {
|
||||
const it = previewSource.current;
|
||||
if (!it) return;
|
||||
const open = previewFile.current;
|
||||
if (open) {
|
||||
// The receiving app may keep reading it after the sheet closes.
|
||||
open.handedOff = true;
|
||||
void shareLocalFile(open.file, it.type, it.name, { forceSheet: true, keep: true }).catch(() => undefined);
|
||||
} else {
|
||||
void share(it);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<AttachmentPreviewModal {...preview.modalProps} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,44 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, Modal, Pressable, ScrollView, Image, ActivityIndicator, Platform, useWindowDimensions,
|
||||
View, Text, StyleSheet, Modal, Pressable, ScrollView, Image, ActivityIndicator, Platform, PixelRatio, Alert,
|
||||
useWindowDimensions,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { WebView } from 'react-native-webview';
|
||||
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
|
||||
import { X, ExternalLink, Share2, Download } from '../icons';
|
||||
import type { Email } from '../../api/types';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import EmailBodyView from '../EmailBodyView';
|
||||
import type { PreviewKind } from '../../lib/attachment-display';
|
||||
import {
|
||||
handoffMimeType, needsNativeImageDecoder, needsWebImageViewer, type PreviewKind,
|
||||
} from '../../lib/file-preview';
|
||||
import { pdfPageFileName, renderPdfPage } from '../../lib/pdf-pages';
|
||||
import type { ZipArchive } from '../../lib/zip-preview';
|
||||
import { formatFullDateTime } from '../../lib/email-date';
|
||||
import type { EmlPreview } from './use-body-override';
|
||||
import { useFilePreview } from './use-file-preview';
|
||||
import { ArchiveSummary, PreviewPartList, type PreviewPart } from './PreviewPartList';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
|
||||
export interface PreviewItem {
|
||||
kind: PreviewKind;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
/** Local file:// URI for images and PDFs. */
|
||||
/** Local file:// URI of the downloaded copy (images, PDFs, media). */
|
||||
fileUri?: string;
|
||||
/** The viewer page next to the file (images, video, audio, Android's PDFs); see preview-viewer-html. */
|
||||
viewerUri?: string;
|
||||
/** Decoded text for text-like parts. */
|
||||
text?: string;
|
||||
/** The document for HTML and Markdown files, shown through the mail sanitiser. */
|
||||
html?: string;
|
||||
/** Parsed embedded message for .eml previews. */
|
||||
eml?: EmlPreview;
|
||||
/** A ZIP's directory, for archive previews. */
|
||||
archive?: ZipArchive;
|
||||
/** Replaces "No preview for this file type." on the fallback screen (e.g. the file is too large). */
|
||||
notice?: string;
|
||||
}
|
||||
@@ -34,7 +49,7 @@ interface Props {
|
||||
/** Header title while the item is still loading. */
|
||||
title?: string;
|
||||
onClose: () => void;
|
||||
/** Hand the file to an external app (PDF on Android, unsupported types, the header's Open button). */
|
||||
/** Hand the file to another app (types without a preview, the header's Open button). */
|
||||
onOpenExternal: () => void;
|
||||
onShare: () => void;
|
||||
/** Save a copy; adds a Download button to the header. */
|
||||
@@ -43,11 +58,93 @@ interface Props {
|
||||
docked?: boolean;
|
||||
}
|
||||
|
||||
interface ViewerWebViewProps {
|
||||
uri: string;
|
||||
background: string;
|
||||
onFailed: () => void;
|
||||
/** Messages other than 'error'. */
|
||||
onMessage?: (data: string) => void;
|
||||
webRef?: React.Ref<WebView>;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-app preview for images, PDFs (iOS renders them in the WebView; Android
|
||||
* has no built-in PDF renderer, so it offers the external viewer), text and
|
||||
* embedded messages. Anything else goes to the external viewer / share sheet.
|
||||
* Used by the reader's attachment chips and the Files tab.
|
||||
* A local page in a WebView that may read only its own folder: the image,
|
||||
* media and PDF viewers. It posts 'error' when the file doesn't decode.
|
||||
*/
|
||||
function ViewerWebView({ uri, background, onFailed, onMessage, webRef }: ViewerWebViewProps) {
|
||||
const folder = uri.slice(0, uri.lastIndexOf('/') + 1);
|
||||
const handleMessage = (e: WebViewMessageEvent) => {
|
||||
if (e.nativeEvent.data === 'error') onFailed();
|
||||
else onMessage?.(e.nativeEvent.data);
|
||||
};
|
||||
return (
|
||||
<WebView
|
||||
ref={webRef}
|
||||
source={{ uri }}
|
||||
originWhitelist={['file://*']}
|
||||
allowFileAccess
|
||||
allowingReadAccessToURL={folder}
|
||||
javaScriptEnabled
|
||||
domStorageEnabled={false}
|
||||
cacheEnabled={false}
|
||||
incognito
|
||||
allowsInlineMediaPlayback
|
||||
mediaPlaybackRequiresUserAction={false}
|
||||
allowsFullscreenVideo
|
||||
allowsPictureInPictureMediaPlayback
|
||||
allowsAirPlayForMediaPlayback
|
||||
setSupportMultipleWindows={false}
|
||||
setBuiltInZoomControls
|
||||
setDisplayZoomControls={false}
|
||||
overScrollMode="never"
|
||||
bounces={false}
|
||||
onShouldStartLoadWithRequest={(req) => req.url === uri}
|
||||
onMessage={handleMessage}
|
||||
onError={onFailed}
|
||||
style={[viewerStyles.fill, { backgroundColor: background }]}
|
||||
containerStyle={{ backgroundColor: background }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Pages are rendered half again as wide as the screen's pixels, so text stays
|
||||
// sharp at the first zoom step; PdfRenderer's bitmap is capped natively.
|
||||
const PDF_PAGE_OVERSAMPLE = 1.5;
|
||||
|
||||
/**
|
||||
* Android's PDF view: the viewer page asks for each page as it comes near
|
||||
* the screen, and the page is rendered next to it by the platform's
|
||||
* PdfRenderer (pdf-pages.ts).
|
||||
*/
|
||||
function PdfPagesView({ fileUri, viewerUri, background, onFailed }: {
|
||||
fileUri: string;
|
||||
viewerUri: string;
|
||||
background: string;
|
||||
onFailed: () => void;
|
||||
}) {
|
||||
const ref = React.useRef<WebView>(null);
|
||||
const { width } = useWindowDimensions();
|
||||
const pagePx = PixelRatio.getPixelSizeForLayoutSize(Math.min(width, 1000)) * PDF_PAGE_OVERSAMPLE;
|
||||
const folder = viewerUri.slice(0, viewerUri.lastIndexOf('/') + 1);
|
||||
const onMessage = (data: string) => {
|
||||
const m = /^page:(\d+)$/.exec(data);
|
||||
if (!m) return;
|
||||
const index = Number(m[1]);
|
||||
const name = pdfPageFileName(index);
|
||||
renderPdfPage(fileUri, index, pagePx, folder + name)
|
||||
.then(() => ref.current?.injectJavaScript(`window.__pdfPage(${index}, ${JSON.stringify(name)});true;`))
|
||||
.catch(() => undefined);
|
||||
};
|
||||
return <ViewerWebView webRef={ref} uri={viewerUri} background={background} onFailed={onFailed} onMessage={onMessage} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-app preview for images, PDFs (iOS's WebView shows them, Android renders the pages), video
|
||||
* and audio (the WebView's player), text, Markdown and HTML documents,
|
||||
* embedded messages and ZIP archives. The files inside an archive or a
|
||||
* message open in a preview of their own on top. Files that don't decode,
|
||||
* and types without a preview, offer another app instead. Used by every
|
||||
* place that opens a file.
|
||||
*/
|
||||
export function AttachmentPreviewModal({
|
||||
item, loading, title, onClose, onOpenExternal, onShare, onDownload, docked = false,
|
||||
@@ -55,32 +152,88 @@ export function AttachmentPreviewModal({
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const locale = useLocaleStore((s) => s.locale);
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const { width } = useWindowDimensions();
|
||||
// An image the platform can't decode would stay blank; fall back to the
|
||||
// external viewer instead. Keyed by URI so the next item starts clean.
|
||||
const [failedImageUri, setFailedImageUri] = React.useState<string | null>(null);
|
||||
// Files inside this one (archive entries, an .eml's attachments).
|
||||
const nested = useFilePreview();
|
||||
const { open: openNested } = nested;
|
||||
const openPart = React.useCallback(async (part: PreviewPart) => {
|
||||
try {
|
||||
const bytes = await part.read();
|
||||
await openNested({
|
||||
from: 'bytes',
|
||||
bytes,
|
||||
name: part.name,
|
||||
type: part.type || handoffMimeType(part.name, ''),
|
||||
size: bytes.byteLength,
|
||||
});
|
||||
} catch (e) {
|
||||
Alert.alert(t('email_viewer.attachment_failed', 'Could not open attachment'), e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, [openNested, t]);
|
||||
const archive = item?.kind === 'archive' ? item.archive : undefined;
|
||||
const archiveParts = React.useMemo<PreviewPart[]>(() => {
|
||||
if (!archive) return [];
|
||||
return archive.entries.map((entry) => ({
|
||||
key: entry.path,
|
||||
name: entry.name,
|
||||
folder: entry.folder,
|
||||
size: entry.size,
|
||||
read: () => archive.read(entry.path),
|
||||
}));
|
||||
}, [archive]);
|
||||
const emlAttachments = item?.kind === 'eml' ? item.eml?.attachments : undefined;
|
||||
const emlParts = React.useMemo<PreviewPart[]>(() => (emlAttachments ?? []).map((att, i) => ({
|
||||
key: `${i}-${att.name}`,
|
||||
name: att.name,
|
||||
type: att.type,
|
||||
size: att.size,
|
||||
read: async () => att.bytes,
|
||||
})), [emlAttachments]);
|
||||
// Which viewers failed for the open file, keyed by its URI so the next one
|
||||
// starts clean: React Native's Image falls back to the WebView viewer, and
|
||||
// a WebView that can't decode the file falls back to another app.
|
||||
const [failed, setFailed] = React.useState<{ uri: string; native?: boolean; web?: boolean } | null>(null);
|
||||
const markFailed = React.useCallback((uri: string | undefined, which: 'native' | 'web') => {
|
||||
if (!uri) return;
|
||||
setFailed((prev) => ({ ...(prev?.uri === uri ? prev : {}), uri, [which]: true }));
|
||||
}, []);
|
||||
// The image fits the area it is shown in (a pane, a landscape window),
|
||||
// not a box derived from the window width that ran past the screen.
|
||||
const [imageArea, setImageArea] = React.useState<{ width: number; height: number } | null>(null);
|
||||
|
||||
const emlEmail = React.useMemo<Email | null>(() => {
|
||||
if (!item?.eml) return null;
|
||||
const docEmail = React.useMemo<Email | null>(() => {
|
||||
if (!item || (!item.eml && item.html == null)) return null;
|
||||
return {
|
||||
id: `eml-preview-${item.name}`,
|
||||
id: `file-preview-${item.kind}-${item.fileUri ?? item.name}`,
|
||||
threadId: '',
|
||||
mailboxIds: {},
|
||||
keywords: {},
|
||||
size: 0,
|
||||
receivedAt: item.eml.date ?? new Date().toISOString(),
|
||||
receivedAt: item.eml?.date ?? new Date().toISOString(),
|
||||
hasAttachment: false,
|
||||
subject: item.eml.subject,
|
||||
subject: item.eml?.subject ?? item.name,
|
||||
};
|
||||
}, [item]);
|
||||
|
||||
const imageFailed = item?.kind === 'image' && !!item.fileUri && failedImageUri === item.fileUri;
|
||||
const pdfInline = item?.kind === 'pdf' && Platform.OS === 'ios' && !!item.fileUri;
|
||||
const failure = item?.fileUri && failed?.uri === item.fileUri ? failed : null;
|
||||
// Android's WebView zooms; React Native's Image there doesn't. iOS zooms
|
||||
// the native image in its ScrollView. Each side's gaps go to the other.
|
||||
const imageInWeb = item?.kind === 'image' && !!item.viewerUri && !failure?.web && (
|
||||
failure?.native
|
||||
|| (Platform.OS === 'android' ? !needsNativeImageDecoder(item) : needsWebImageViewer(item))
|
||||
);
|
||||
const imageFailed = item?.kind === 'image' && (
|
||||
!!failure?.web && (!!failure.native || !item.viewerUri || needsWebImageViewer(item))
|
||||
|| (!!failure?.native && !item.viewerUri)
|
||||
);
|
||||
const mediaFailed = (item?.kind === 'video' || item?.kind === 'audio') && !!failure?.web;
|
||||
const pdfInline = item?.kind === 'pdf' && !!item.fileUri && (Platform.OS === 'ios' || !!item.viewerUri) && !failure?.web;
|
||||
// The fallback screen has its own Open button; the header adds one elsewhere.
|
||||
const showsFallback = !!item && (item.kind === 'none' || (item.kind === 'pdf' && !pdfInline) || imageFailed);
|
||||
const showsFallback = !!item && (
|
||||
item.kind === 'none' || (item.kind === 'pdf' && !pdfInline) || imageFailed || mediaFailed
|
||||
);
|
||||
|
||||
const renderFallback = (message: string) => (
|
||||
<View style={styles.centered}>
|
||||
@@ -92,6 +245,14 @@ export function AttachmentPreviewModal({
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderDocument = (header?: React.ReactNode, footer?: React.ReactNode) => (
|
||||
<ScrollView style={styles.flex}>
|
||||
{header}
|
||||
{docEmail && <EmailBodyView email={docEmail} bodyOverride={{ html: item?.html ?? item?.eml?.html, text: item?.eml?.text }} />}
|
||||
{footer}
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
const renderBody = () => {
|
||||
if (loading || !item) {
|
||||
return (
|
||||
@@ -105,6 +266,16 @@ export function AttachmentPreviewModal({
|
||||
if (imageFailed) {
|
||||
return renderFallback(t('email_viewer.preview.unsupported', 'No preview for this file type.'));
|
||||
}
|
||||
if (imageInWeb && item.viewerUri) {
|
||||
return (
|
||||
<ViewerWebView
|
||||
key={item.viewerUri}
|
||||
uri={item.viewerUri}
|
||||
background={c.background}
|
||||
onFailed={() => markFailed(item.fileUri, 'web')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.flex}
|
||||
@@ -124,11 +295,35 @@ export function AttachmentPreviewModal({
|
||||
: { width: width - spacing.lg * 2, height: width * 1.2 }}
|
||||
resizeMode="contain"
|
||||
accessibilityLabel={item.name}
|
||||
onError={() => setFailedImageUri(item.fileUri ?? null)}
|
||||
onError={() => markFailed(item.fileUri, 'native')}
|
||||
/>
|
||||
</ScrollView>
|
||||
);
|
||||
case 'video':
|
||||
case 'audio':
|
||||
if (mediaFailed || !item.viewerUri) {
|
||||
return renderFallback(t('email_viewer.preview.media_unsupported', "This file can't be played here."));
|
||||
}
|
||||
return (
|
||||
<ViewerWebView
|
||||
key={item.viewerUri}
|
||||
uri={item.viewerUri}
|
||||
background={item.kind === 'video' ? '#000' : c.background}
|
||||
onFailed={() => markFailed(item.fileUri, 'web')}
|
||||
/>
|
||||
);
|
||||
case 'pdf':
|
||||
if (pdfInline && item.fileUri && item.viewerUri) {
|
||||
return (
|
||||
<PdfPagesView
|
||||
key={item.viewerUri}
|
||||
fileUri={item.fileUri}
|
||||
viewerUri={item.viewerUri}
|
||||
background={c.surface}
|
||||
onFailed={() => markFailed(item.fileUri, 'web')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (pdfInline && item.fileUri) {
|
||||
return (
|
||||
<WebView
|
||||
@@ -143,32 +338,45 @@ export function AttachmentPreviewModal({
|
||||
/>
|
||||
);
|
||||
}
|
||||
return renderFallback(t('email_viewer.preview.pdf_external', 'PDFs open in your PDF viewer on this device.'));
|
||||
return renderFallback(t('email_viewer.preview.pdf_unreadable', "This PDF can't be shown here."));
|
||||
case 'text':
|
||||
return (
|
||||
<ScrollView style={styles.flex} contentContainerStyle={styles.textScroll}>
|
||||
<Text selectable style={styles.text}>{item.text ?? ''}</Text>
|
||||
</ScrollView>
|
||||
);
|
||||
case 'html':
|
||||
case 'markdown':
|
||||
return renderDocument();
|
||||
case 'eml':
|
||||
return renderDocument(
|
||||
<View style={styles.emlHeader}>
|
||||
<Text style={styles.emlSubject}>{item.eml?.subject || t('email_viewer.no_subject', '(No Subject)')}</Text>
|
||||
{item.eml?.from ? <Text style={styles.emlMeta}>{item.eml.from}</Text> : null}
|
||||
{item.eml?.date ? <Text style={styles.emlMeta}>{formatFullDateTime(item.eml.date, timeFormat, locale) || item.eml.date}</Text> : null}
|
||||
</View>,
|
||||
emlParts.length > 0 ? (
|
||||
<PreviewPartList
|
||||
inline
|
||||
parts={emlParts}
|
||||
onOpen={openPart}
|
||||
header={<ArchiveSummary count={emlParts.length} truncated={false} />}
|
||||
/>
|
||||
) : null,
|
||||
);
|
||||
case 'archive':
|
||||
if (!item.archive) break;
|
||||
return (
|
||||
<ScrollView style={styles.flex}>
|
||||
<View style={styles.emlHeader}>
|
||||
<Text style={styles.emlSubject}>{item.eml?.subject || t('email_viewer.no_subject', '(No Subject)')}</Text>
|
||||
{item.eml?.from ? <Text style={styles.emlMeta}>{item.eml.from}</Text> : null}
|
||||
{item.eml?.date ? <Text style={styles.emlMeta}>{item.eml.date}</Text> : null}
|
||||
</View>
|
||||
{emlEmail && (
|
||||
<EmailBodyView
|
||||
email={emlEmail}
|
||||
bodyOverride={{ html: item.eml?.html, text: item.eml?.text }}
|
||||
/>
|
||||
)}
|
||||
</ScrollView>
|
||||
<PreviewPartList
|
||||
parts={archiveParts}
|
||||
onOpen={openPart}
|
||||
header={<ArchiveSummary count={archiveParts.length} truncated={item.archive.truncated} />}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return renderFallback(item.notice ?? t('email_viewer.preview.unsupported', 'No preview for this file type.'));
|
||||
break;
|
||||
}
|
||||
return renderFallback(item.notice ?? t('email_viewer.preview.unsupported', 'No preview for this file type.'));
|
||||
};
|
||||
|
||||
const content = (
|
||||
@@ -219,6 +427,7 @@ export function AttachmentPreviewModal({
|
||||
</Pressable>
|
||||
</View>
|
||||
{renderBody()}
|
||||
{nested.modalProps.item || nested.modalProps.loading ? <AttachmentPreviewModal {...nested.modalProps} /> : null}
|
||||
</SafeAreaView>
|
||||
);
|
||||
if (docked) return item || loading ? content : null;
|
||||
@@ -229,6 +438,8 @@ export function AttachmentPreviewModal({
|
||||
);
|
||||
}
|
||||
|
||||
const viewerStyles = StyleSheet.create({ fill: { flex: 1 } });
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: c.background },
|
||||
|
||||
@@ -15,9 +15,10 @@ import {
|
||||
attachmentKind, realAttachments, requestListAttachments, shortAttachmentName,
|
||||
type AttachmentKind, type LoadListAttachments,
|
||||
} from '../../lib/list-attachments';
|
||||
import { getAttachmentDisplayName, previewKindFor } from '../../lib/attachment-display';
|
||||
import { cacheBlobFile, downloadAttachment, fetchBlobBytes, shareAttachment, shareLocalFile } from '../../lib/email-export';
|
||||
import { AttachmentPreviewModal, type PreviewItem } from './AttachmentPreviewModal';
|
||||
import { getAttachmentDisplayName } from '../../lib/attachment-display';
|
||||
import { attachmentSaveName, downloadAttachment } from '../../lib/email-export';
|
||||
import { AttachmentPreviewModal } from './AttachmentPreviewModal';
|
||||
import { useFilePreview } from './use-file-preview';
|
||||
|
||||
/** How many chips a row shows before collapsing the rest into a count. */
|
||||
const MAX_CHIPS = 2;
|
||||
@@ -124,26 +125,16 @@ export interface ListAttachmentOpenerHandle {
|
||||
open: (email: Email, attachment: Attachment, accountId?: string) => void;
|
||||
}
|
||||
|
||||
interface OpenTarget {
|
||||
email: Email;
|
||||
attachment: Attachment;
|
||||
accountId?: string;
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a list-row attachment the way the reader's chips do: the "attachment
|
||||
* click action" setting either saves it or previews it in-app (images, PDFs,
|
||||
* text), and anything that can't be previewed goes to the platform viewer.
|
||||
* Holds the preview modal, so the list itself never re-renders for it.
|
||||
* click action" setting either saves it or previews it in-app, and anything
|
||||
* that can't be previewed goes to another app. Holds the preview modal, so
|
||||
* the list itself never re-renders for it.
|
||||
*/
|
||||
export const ListAttachmentOpener = React.forwardRef<ListAttachmentOpenerHandle>(
|
||||
function ListAttachmentOpener(_props, ref) {
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const [preview, setPreview] = React.useState<PreviewItem | null>(null);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const target = React.useRef<OpenTarget | null>(null);
|
||||
const preview = useFilePreview();
|
||||
const busy = React.useRef(false);
|
||||
|
||||
const run = React.useCallback(async (fn: () => Promise<void>) => {
|
||||
@@ -161,59 +152,27 @@ export const ListAttachmentOpener = React.forwardRef<ListAttachmentOpenerHandle>
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const openExternal = React.useCallback((it: OpenTarget) => run(
|
||||
() => shareAttachment(it.attachment.blobId, it.attachment.name, it.attachment.type, it.email, it.accountId),
|
||||
), [run]);
|
||||
|
||||
const { open: openPreview } = preview;
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
open: (email, attachment, accountId) => {
|
||||
if (busy.current) return;
|
||||
const name = getAttachmentDisplayName(attachment.name, attachment.type);
|
||||
const type = attachment.type || 'application/octet-stream';
|
||||
const it: OpenTarget = { email, attachment, accountId, name, type };
|
||||
target.current = it;
|
||||
|
||||
if (useSettingsStore.getState().mailAttachmentAction === 'download') {
|
||||
void run(() => downloadAttachment(attachment.blobId, attachment.name, attachment.type, email, accountId));
|
||||
return;
|
||||
}
|
||||
const kind = previewKindFor({ name, type });
|
||||
if (kind !== 'image' && kind !== 'pdf' && kind !== 'text') {
|
||||
void openExternal(it);
|
||||
return;
|
||||
}
|
||||
setPreview(null);
|
||||
setLoading(true);
|
||||
void run(async () => {
|
||||
try {
|
||||
if (kind === 'text') {
|
||||
const bytes = await fetchBlobBytes(attachment.blobId, attachment.name, attachment.type, accountId);
|
||||
setPreview({ kind, name, mimeType: type, text: new TextDecoder('utf-8').decode(bytes) });
|
||||
} else {
|
||||
const file = await cacheBlobFile(attachment.blobId, name, type, accountId);
|
||||
setPreview({ kind, name, mimeType: type, fileUri: file.uri });
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
void run(() => openPreview({
|
||||
from: 'blob',
|
||||
blobId: attachment.blobId,
|
||||
accountId,
|
||||
email,
|
||||
name: getAttachmentDisplayName(attachment.name, attachment.type),
|
||||
type: attachment.type || 'application/octet-stream',
|
||||
size: attachment.size,
|
||||
saveName: attachmentSaveName(email, attachment.name, attachment.type),
|
||||
}));
|
||||
},
|
||||
}), [run, openExternal]);
|
||||
}), [run, openPreview]);
|
||||
|
||||
return (
|
||||
<AttachmentPreviewModal
|
||||
item={preview}
|
||||
loading={loading}
|
||||
onClose={() => { setPreview(null); setLoading(false); }}
|
||||
onOpenExternal={() => { if (target.current) void openExternal(target.current); }}
|
||||
onShare={() => {
|
||||
const it = target.current;
|
||||
if (!it || !preview?.fileUri) return;
|
||||
const { File } = require('expo-file-system') as typeof import('expo-file-system');
|
||||
void shareLocalFile(new File(preview.fileUri), it.type, it.name, { forceSheet: true }).catch(() => undefined);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return <AttachmentPreviewModal {...preview.modalProps} />;
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import React from 'react';
|
||||
import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import {
|
||||
File as FileIcon, FileArchive, FileAudio, FileImage, FileSpreadsheet, FileText, FileVideo,
|
||||
} from '../icons';
|
||||
import { formatSize } from '../../lib/attachment-display';
|
||||
import { filePreviewKind, handoffMimeType } from '../../lib/file-preview';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
|
||||
/** A file inside a file: an archive entry or an attachment of an .eml. */
|
||||
export interface PreviewPart {
|
||||
key: string;
|
||||
name: string;
|
||||
/** Folder inside an archive, '' at the top. */
|
||||
folder?: string;
|
||||
type?: string;
|
||||
size?: number;
|
||||
read: () => Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
function iconFor(part: PreviewPart) {
|
||||
const type = part.type || handoffMimeType(part.name, '');
|
||||
if (/\.(xlsx?|ods|csv|tsv|numbers)$/i.test(part.name)) return FileSpreadsheet;
|
||||
if (/\.(zip|7z|rar|gz|tgz|tar|bz2|xz)$/i.test(part.name)) return FileArchive;
|
||||
// Platform-independent: the icon names the format, not what plays here.
|
||||
switch (filePreviewKind({ name: part.name, type }, { platform: 'android' })) {
|
||||
case 'image': return FileImage;
|
||||
case 'video': return FileVideo;
|
||||
case 'audio': return FileAudio;
|
||||
case 'none': return /^(application\/(msword|vnd\.|rtf)|text\/)/.test(type) ? FileText : FileIcon;
|
||||
default: return FileText;
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
parts: PreviewPart[];
|
||||
/** Opens a part; the row shows a spinner until the promise settles. */
|
||||
onOpen: (part: PreviewPart) => Promise<void>;
|
||||
/** Above the rows (an .eml's body, an archive's summary). */
|
||||
header?: React.ReactElement;
|
||||
/** Renders as a plain column inside a parent ScrollView instead of a list of its own. */
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The files inside an archive or an embedded message, each opening in the
|
||||
* file preview like any attachment.
|
||||
*/
|
||||
export function PreviewPartList({ parts, onOpen, header, inline = false }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const [busy, setBusy] = React.useState<string | null>(null);
|
||||
|
||||
const open = (part: PreviewPart) => {
|
||||
if (busy) return;
|
||||
setBusy(part.key);
|
||||
onOpen(part).finally(() => setBusy(null));
|
||||
};
|
||||
|
||||
const renderRow = (part: PreviewPart) => {
|
||||
const Icon = iconFor(part);
|
||||
return (
|
||||
<Pressable
|
||||
key={part.key}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => open(part)}
|
||||
disabled={!!busy}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={part.name}
|
||||
>
|
||||
<View style={styles.icon}>
|
||||
{busy === part.key ? <ActivityIndicator size="small" color={c.textMuted} /> : <Icon size={20} color={c.textMuted} />}
|
||||
</View>
|
||||
<View style={styles.text}>
|
||||
<Text style={styles.name} numberOfLines={1}>{part.name}</Text>
|
||||
{part.folder ? <Text style={styles.folder} numberOfLines={1}>{part.folder}</Text> : null}
|
||||
</View>
|
||||
{part.size != null ? <Text style={styles.size}>{formatSize(part.size) || '0 B'}</Text> : null}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<View style={styles.inline}>
|
||||
{header}
|
||||
{parts.map(renderRow)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FlatList
|
||||
style={styles.list}
|
||||
data={parts}
|
||||
keyExtractor={(p) => p.key}
|
||||
renderItem={({ item }) => renderRow(item)}
|
||||
ListHeaderComponent={header}
|
||||
initialNumToRender={30}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The summary line above an archive's entries. */
|
||||
export function ArchiveSummary({ count, truncated }: { count: number; truncated: boolean }) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
return (
|
||||
<Text style={styles.summary}>
|
||||
{truncated
|
||||
? t('email_viewer.preview.archive_first_files', 'The first {count} files', { count })
|
||||
: t('email_viewer.preview.archive_files', '{count, plural, one {# file} other {# files}}', { count })}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
list: { flex: 1 },
|
||||
inline: { paddingBottom: spacing.lg },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
minHeight: 52,
|
||||
},
|
||||
rowPressed: { backgroundColor: c.surfaceHover },
|
||||
icon: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: c.surface,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
text: { flex: 1, minWidth: 0 },
|
||||
name: { ...typography.body, color: c.text },
|
||||
folder: { ...typography.caption, color: c.textMuted },
|
||||
size: { ...typography.caption, color: c.textMuted },
|
||||
summary: {
|
||||
...typography.caption,
|
||||
color: c.textMuted,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.xs,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -60,16 +60,18 @@ export interface EmlPreview {
|
||||
date?: string;
|
||||
html: string | null;
|
||||
text: string | null;
|
||||
/** The message's own attachments (not the images its body embeds). */
|
||||
attachments: ExtractedAttachment[];
|
||||
}
|
||||
|
||||
/** Parse a message once for the preview: its unwrapped body plus the header lines shown above it. */
|
||||
export async function emlPreviewFromBytes(bytes: Uint8Array): Promise<EmlPreview> {
|
||||
const parsed = await parseMessage(bytes);
|
||||
const { html, text } = unwrapParsed(parsed);
|
||||
const { html, text, attachments } = unwrapParsed(parsed);
|
||||
const from = parsed.from && 'address' in parsed.from
|
||||
? `${parsed.from.name ? `${parsed.from.name} ` : ''}<${parsed.from.address}>`
|
||||
: parsed.from?.name;
|
||||
return { subject: parsed.subject, from, date: parsed.date, html, text };
|
||||
return { subject: parsed.subject, from, date: parsed.date, html, text, attachments };
|
||||
}
|
||||
|
||||
function unwrapParsed(parsed: ParsedEmail): UnwrappedMessage {
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import React from 'react';
|
||||
import { Alert, Platform } from 'react-native';
|
||||
import { File } from 'expo-file-system';
|
||||
import type { Email } from '../../api/types';
|
||||
import {
|
||||
cachePreviewFile, copyPreviewFile, discardPreviewFile, saveLocalFileCopy, shareAttachment,
|
||||
shareBytes, shareLocalFile, writePreviewFile,
|
||||
} from '../../lib/email-export';
|
||||
import {
|
||||
exceedsPreviewLimit, inAppPreviewKind, type FilePreviewOptions,
|
||||
} from '../../lib/file-preview';
|
||||
import type { PreviewKind } from '../../lib/file-preview';
|
||||
import { markdownToHtml } from '../../lib/markdown-html';
|
||||
import { imageViewerHtml, mediaViewerHtml, pdfViewerHtml, type ViewerColors } from '../../lib/preview-viewer-html';
|
||||
import { closePdfPages, openPdfPages, PdfOpenError } from '../../lib/pdf-pages';
|
||||
import { openZip, ZipEncryptedError } from '../../lib/zip-preview';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { useColors, useResolvedTheme } from '../../theme/colors';
|
||||
import type { PreviewItem } from './AttachmentPreviewModal';
|
||||
import { emlPreviewFromBytes } from './use-body-override';
|
||||
|
||||
export const PREVIEW_OPTIONS: FilePreviewOptions = { platform: Platform.OS === 'ios' ? 'ios' : 'android' };
|
||||
|
||||
interface PreviewSourceBase {
|
||||
/** Display name, shown in the header and given to the cached copy. */
|
||||
name: string;
|
||||
type: string;
|
||||
size?: number | null;
|
||||
/** Name for "Download" (the attachment filename template); defaults to `name`. */
|
||||
saveName?: string;
|
||||
}
|
||||
|
||||
/** Where a previewed file comes from. */
|
||||
export type PreviewSource = PreviewSourceBase & (
|
||||
| { from: 'blob'; blobId: string; accountId?: string; email?: Email | null }
|
||||
| { from: 'bytes'; bytes: Uint8Array }
|
||||
| { from: 'local'; uri: string }
|
||||
);
|
||||
|
||||
/** The preview a tap on `source` opens, or 'none' when it goes to another app. */
|
||||
export function previewKindOf(source: Pick<PreviewSource, 'name' | 'type' | 'size'>): PreviewKind {
|
||||
return inAppPreviewKind(source, PREVIEW_OPTIONS);
|
||||
}
|
||||
|
||||
/** Copy the source into a preview folder of its own (see cachePreviewFile). */
|
||||
async function materialize(source: PreviewSource): Promise<File> {
|
||||
switch (source.from) {
|
||||
case 'blob':
|
||||
return cachePreviewFile(source.blobId, source.name, source.type, source.accountId);
|
||||
case 'bytes':
|
||||
return writePreviewFile(source.bytes, source.name, source.type);
|
||||
case 'local':
|
||||
return copyPreviewFile(source.uri, source.name, source.type);
|
||||
}
|
||||
}
|
||||
|
||||
/** Hand a file the app doesn't show to the app that owns its type ("Open with"). */
|
||||
function handOff(source: PreviewSource): Promise<void> {
|
||||
switch (source.from) {
|
||||
case 'blob':
|
||||
return shareAttachment(source.blobId, source.name, source.type, source.email, source.accountId);
|
||||
case 'bytes':
|
||||
return shareBytes(source.bytes, source.name, source.type);
|
||||
case 'local':
|
||||
// A copy, so the cleanup after the hand-off never removes the original.
|
||||
return shareLocalFile(copyPreviewFile(source.uri, source.name, source.type), source.type, source.name);
|
||||
}
|
||||
}
|
||||
|
||||
// The viewer page sits next to the file it shows (see preview-viewer-html).
|
||||
function writeViewer(local: File, html: string): string {
|
||||
const page = new File(local.parentDirectory, '__preview.html');
|
||||
if (page.exists) page.delete();
|
||||
page.create();
|
||||
page.write(html);
|
||||
return page.uri;
|
||||
}
|
||||
|
||||
interface Notices {
|
||||
tooLarge: string;
|
||||
pdfProtected: string;
|
||||
pdfUnreadable: string;
|
||||
zipProtected: string;
|
||||
zipUnreadable: string;
|
||||
}
|
||||
|
||||
async function buildItem(
|
||||
kind: Exclude<PreviewKind, 'none'>,
|
||||
source: PreviewSource,
|
||||
local: File,
|
||||
notices: Notices,
|
||||
colors: ViewerColors,
|
||||
): Promise<PreviewItem> {
|
||||
const base = { name: source.name, mimeType: source.type };
|
||||
// The source may not know its size; the copy does.
|
||||
if (exceedsPreviewLimit(kind, local.size)) return { ...base, kind: 'none', notice: notices.tooLarge };
|
||||
switch (kind) {
|
||||
case 'text':
|
||||
return { ...base, kind, text: await local.text() };
|
||||
case 'markdown':
|
||||
return { ...base, kind, html: markdownToHtml(await local.text()) };
|
||||
case 'html':
|
||||
return { ...base, kind, html: await local.text() };
|
||||
case 'eml':
|
||||
return { ...base, kind, eml: await emlPreviewFromBytes(await local.bytes()) };
|
||||
case 'archive':
|
||||
try {
|
||||
return { ...base, kind, archive: await openZip(await local.bytes()) };
|
||||
} catch (e) {
|
||||
return { ...base, kind: 'none', notice: e instanceof ZipEncryptedError ? notices.zipProtected : notices.zipUnreadable };
|
||||
}
|
||||
case 'image':
|
||||
return { ...base, kind, fileUri: local.uri, viewerUri: writeViewer(local, imageViewerHtml(local.name, colors)) };
|
||||
case 'video':
|
||||
case 'audio':
|
||||
return { ...base, kind, fileUri: local.uri, viewerUri: writeViewer(local, mediaViewerHtml(kind, local.name, colors)) };
|
||||
case 'pdf': {
|
||||
// iOS's WebView shows the PDF itself; Android renders its pages.
|
||||
if (Platform.OS !== 'android') return { ...base, kind, fileUri: local.uri };
|
||||
try {
|
||||
const pages = await openPdfPages(local.uri);
|
||||
return { ...base, kind, fileUri: local.uri, viewerUri: writeViewer(local, pdfViewerHtml(local.name, pages, colors)) };
|
||||
} catch (e) {
|
||||
const protectedPdf = e instanceof PdfOpenError && e.reason === 'protected';
|
||||
return { ...base, kind: 'none', notice: protectedPdf ? notices.pdfProtected : notices.pdfUnreadable };
|
||||
}
|
||||
}
|
||||
default:
|
||||
return { ...base, kind, fileUri: local.uri };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One in-app preview: picks how a file is shown, downloads it once into a
|
||||
* temp folder of its own, and serves Open with, Share and Download from that
|
||||
* copy. Files the app can't show go straight to another app. The copy is
|
||||
* deleted when the preview closes or the next one opens, unless another app
|
||||
* got it, which may still be reading it; the stale-export sweep removes
|
||||
* those. Used by every place that opens a file: the reader's chips, the
|
||||
* list's chips, the Files tab and compose.
|
||||
*
|
||||
* Render `<AttachmentPreviewModal {...preview.modalProps} />` next to it.
|
||||
*/
|
||||
export function useFilePreview(opts: {
|
||||
/** Called when the preview closes: the close button, or a download that failed. */
|
||||
onClose?: () => void;
|
||||
} = {}) {
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const c = useColors();
|
||||
const scheme = useResolvedTheme();
|
||||
// Read when a preview is built; the viewer page keeps the theme it opened with.
|
||||
const viewerColors: ViewerColors = { scheme, background: c.background, text: c.text, textMuted: c.textMuted, surface: c.surface };
|
||||
const colors = React.useRef(viewerColors);
|
||||
colors.current = viewerColors;
|
||||
const onCloseRef = React.useRef(opts.onClose);
|
||||
onCloseRef.current = opts.onClose;
|
||||
const [item, setItem] = React.useState<PreviewItem | null>(null);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [title, setTitle] = React.useState('');
|
||||
const source = React.useRef<PreviewSource | null>(null);
|
||||
const session = React.useRef<{ file: File; handedOff: boolean } | null>(null);
|
||||
// Bumped whenever a preview ends, so a download that finishes after its
|
||||
// preview was closed is dropped instead of reopening it.
|
||||
const seq = React.useRef(0);
|
||||
const busy = React.useRef(false);
|
||||
|
||||
const end = React.useCallback(() => {
|
||||
seq.current += 1;
|
||||
const open = session.current;
|
||||
session.current = null;
|
||||
if (!open) return;
|
||||
closePdfPages(open.file.uri);
|
||||
if (!open.handedOff) discardPreviewFile(open.file);
|
||||
}, []);
|
||||
React.useEffect(() => end, [end]);
|
||||
|
||||
/** Close without telling the owner (it closed the preview itself). */
|
||||
const reset = React.useCallback(() => {
|
||||
end();
|
||||
source.current = null;
|
||||
setItem(null);
|
||||
setLoading(false);
|
||||
}, [end]);
|
||||
|
||||
const close = React.useCallback(() => {
|
||||
reset();
|
||||
onCloseRef.current?.();
|
||||
}, [reset]);
|
||||
|
||||
/**
|
||||
* Show `src` in the preview, or hand it to another app when the preview
|
||||
* can't show it. The promise settles once that hand-off is done (and
|
||||
* rejects when it fails); a preview resolves at once.
|
||||
*/
|
||||
const open = React.useCallback((src: PreviewSource): Promise<void> => {
|
||||
const kind = previewKindOf(src);
|
||||
if (kind === 'none') return handOff(src);
|
||||
end();
|
||||
source.current = src;
|
||||
const mine = seq.current;
|
||||
const current = () => mine === seq.current;
|
||||
setTitle(src.name);
|
||||
setItem(null);
|
||||
setLoading(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const local = await materialize(src);
|
||||
if (!current()) {
|
||||
discardPreviewFile(local);
|
||||
return;
|
||||
}
|
||||
session.current = { file: local, handedOff: false };
|
||||
const next = await buildItem(kind, src, local, {
|
||||
tooLarge: t('files.preview_too_large', 'This file is too large to preview.'),
|
||||
pdfProtected: t(
|
||||
'email_viewer.preview.pdf_protected',
|
||||
'This PDF is password-protected. Open it in another app to enter the password.',
|
||||
),
|
||||
pdfUnreadable: t('email_viewer.preview.pdf_unreadable', "This PDF can't be shown here."),
|
||||
zipProtected: t(
|
||||
'email_viewer.preview.archive_protected',
|
||||
'This archive is password-protected. Open it in another app to enter the password.',
|
||||
),
|
||||
zipUnreadable: t('email_viewer.preview.archive_unreadable', "This archive can't be opened here."),
|
||||
}, colors.current);
|
||||
if (current()) setItem(next);
|
||||
// Closed while the PDF was opening: end() ran before the document was.
|
||||
else closePdfPages(local.uri);
|
||||
} catch (e) {
|
||||
if (!current()) return;
|
||||
close();
|
||||
Alert.alert(t('files.preview_error', 'Failed to load preview'), e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
if (current()) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return Promise.resolve();
|
||||
}, [end, close, t]);
|
||||
|
||||
// The preview's own buttons, all on the downloaded copy.
|
||||
const act = React.useCallback((run: (local: File, src: PreviewSource) => Promise<void>, handsOff: boolean, failTitle: string) => {
|
||||
const open = session.current;
|
||||
const src = source.current;
|
||||
if (!open || !src || busy.current) return;
|
||||
busy.current = true;
|
||||
// Another app may keep reading the file after the sheet or viewer returns.
|
||||
if (handsOff) open.handedOff = true;
|
||||
run(open.file, src)
|
||||
.catch((e) => Alert.alert(failTitle, e instanceof Error ? e.message : String(e)))
|
||||
.finally(() => { busy.current = false; });
|
||||
}, []);
|
||||
|
||||
const modalProps = React.useMemo(() => ({
|
||||
item,
|
||||
loading,
|
||||
title,
|
||||
onClose: close,
|
||||
onOpenExternal: () => act(
|
||||
(local, src) => shareLocalFile(local, src.type, src.name, { keep: true }),
|
||||
true,
|
||||
t('files.open_error', 'Could not open the file'),
|
||||
),
|
||||
onShare: () => act(
|
||||
(local, src) => shareLocalFile(local, src.type, src.name, { forceSheet: true, keep: true }),
|
||||
true,
|
||||
t('files.share_file_error', 'Could not share the file'),
|
||||
),
|
||||
onDownload: () => act(
|
||||
(local, src) => saveLocalFileCopy(local, src.type, src.saveName ?? src.name),
|
||||
false,
|
||||
t('files.download_error', 'Failed to download'),
|
||||
),
|
||||
}), [item, loading, title, close, act, t]);
|
||||
|
||||
return { open, close, reset, modalProps };
|
||||
}
|
||||
@@ -1,24 +1,13 @@
|
||||
import React from 'react';
|
||||
import { Alert, Platform } from 'react-native';
|
||||
import type { File } from 'expo-file-system';
|
||||
import type { FileNode } from '../../api/types';
|
||||
import {
|
||||
cachePreviewFile, discardPreviewFile, saveLocalFileCopy, shareLocalFile,
|
||||
} from '../../lib/email-export';
|
||||
import {
|
||||
exceedsPreviewLimit, inAppPreviewKind, type FilePreviewOptions,
|
||||
} from '../../lib/file-preview';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { AttachmentPreviewModal, type PreviewItem } from '../email/AttachmentPreviewModal';
|
||||
import { emlPreviewFromBytes } from '../email/use-body-override';
|
||||
|
||||
const PREVIEW_OPTIONS: FilePreviewOptions = { inlinePdf: Platform.OS === 'ios' };
|
||||
import { AttachmentPreviewModal } from '../email/AttachmentPreviewModal';
|
||||
import { previewKindOf, useFilePreview } from '../email/use-file-preview';
|
||||
|
||||
export type PreviewableFileNode = Pick<FileNode, 'name' | 'type' | 'size' | 'blobId' | 'accountId'>;
|
||||
|
||||
/** Whether a tap on this file opens the in-app preview (else the OS chooser, as before). */
|
||||
export function canPreviewInApp(node: PreviewableFileNode): boolean {
|
||||
return !!node.blobId && inAppPreviewKind(node, PREVIEW_OPTIONS) !== 'none';
|
||||
return !!node.blobId && previewKindOf({ name: node.name, type: node.type || '', size: node.size }) !== 'none';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -30,22 +19,13 @@ interface Props {
|
||||
}
|
||||
|
||||
/**
|
||||
* The Files tab's in-app preview: the reader's attachment preview fed from a
|
||||
* FileNode. The file is downloaded once, with the same authenticated
|
||||
* download as "Download" (token refresh, client certificate), into its own
|
||||
* temp folder; Open with, Share and Download then all use that copy instead
|
||||
* of fetching it again. The copy is deleted when the preview closes, unless
|
||||
* another app got it, in which case the stale-export sweep removes it.
|
||||
* The Files tab's in-app preview: the shared file preview fed from a
|
||||
* FileNode, downloaded with the same authenticated download as "Download"
|
||||
* (token refresh, client certificate).
|
||||
*/
|
||||
export function FilePreviewModal({ file, onClose, docked = false }: Props) {
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const [item, setItem] = React.useState<PreviewItem | null>(null);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
// The downloaded copy of the open preview.
|
||||
const session = React.useRef<{ file: File; handedOff: boolean } | null>(null);
|
||||
const busy = React.useRef(false);
|
||||
const onCloseRef = React.useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
const preview = useFilePreview({ onClose });
|
||||
const { open, reset } = preview;
|
||||
|
||||
const blobId = file?.blobId ?? null;
|
||||
const accountId = file?.accountId;
|
||||
@@ -55,93 +35,9 @@ export function FilePreviewModal({ file, onClose, docked = false }: Props) {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!blobId) return undefined;
|
||||
let cancelled = false;
|
||||
setItem(null);
|
||||
setLoading(true);
|
||||
void open({ from: 'blob', blobId, accountId, name, type, size });
|
||||
return reset;
|
||||
}, [blobId, accountId, name, type, size, open, reset]);
|
||||
|
||||
const build = async (local: File): Promise<PreviewItem> => {
|
||||
const kind = inAppPreviewKind({ name, type, size }, PREVIEW_OPTIONS);
|
||||
// The node may not report a size; the download does.
|
||||
if (kind === 'none' || exceedsPreviewLimit(kind, local.size)) {
|
||||
return {
|
||||
kind: 'none',
|
||||
name,
|
||||
mimeType: type,
|
||||
notice: kind === 'none' ? undefined : t('files.preview_too_large', 'This file is too large to preview.'),
|
||||
};
|
||||
}
|
||||
switch (kind) {
|
||||
case 'text':
|
||||
return { kind, name, mimeType: type, text: await local.text() };
|
||||
case 'eml':
|
||||
return { kind, name, mimeType: type, eml: await emlPreviewFromBytes(await local.bytes()) };
|
||||
default:
|
||||
return { kind, name, mimeType: type, fileUri: local.uri };
|
||||
}
|
||||
};
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const local = await cachePreviewFile(blobId, name, type, accountId);
|
||||
if (cancelled) {
|
||||
discardPreviewFile(local);
|
||||
return;
|
||||
}
|
||||
session.current = { file: local, handedOff: false };
|
||||
const next = await build(local);
|
||||
if (!cancelled) setItem(next);
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
Alert.alert(t('files.preview_error', 'Failed to load preview'), e instanceof Error ? e.message : String(e));
|
||||
onCloseRef.current();
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
const open = session.current;
|
||||
session.current = null;
|
||||
if (open && !open.handedOff) discardPreviewFile(open.file);
|
||||
setItem(null);
|
||||
setLoading(false);
|
||||
};
|
||||
}, [blobId, accountId, name, type, size, t]);
|
||||
|
||||
const act = (run: (local: File) => Promise<void>, handsOff: boolean, failTitle: string) => {
|
||||
const open = session.current;
|
||||
if (!open || busy.current) return;
|
||||
busy.current = true;
|
||||
// Another app may keep reading the file after the sheet or viewer returns.
|
||||
if (handsOff) open.handedOff = true;
|
||||
run(open.file)
|
||||
.catch((e) => Alert.alert(failTitle, e instanceof Error ? e.message : String(e)))
|
||||
.finally(() => { busy.current = false; });
|
||||
};
|
||||
|
||||
return (
|
||||
<AttachmentPreviewModal
|
||||
item={item}
|
||||
loading={loading}
|
||||
title={name}
|
||||
docked={docked}
|
||||
onClose={onClose}
|
||||
onOpenExternal={() => act(
|
||||
(local) => shareLocalFile(local, type, name, { keep: true }),
|
||||
true,
|
||||
t('files.open_error', 'Could not open the file'),
|
||||
)}
|
||||
onShare={() => act(
|
||||
(local) => shareLocalFile(local, type, name, { forceSheet: true, keep: true }),
|
||||
true,
|
||||
t('files.share_file_error', 'Could not share the file'),
|
||||
)}
|
||||
onDownload={() => act(
|
||||
(local) => saveLocalFileCopy(local, type),
|
||||
false,
|
||||
t('files.download_error', 'Failed to download'),
|
||||
)}
|
||||
/>
|
||||
);
|
||||
return <AttachmentPreviewModal {...preview.modalProps} docked={docked} />;
|
||||
}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
getAttachmentDisplayName, visibleAttachments, previewKindFor, isReportPart, isRfc822Attachment,
|
||||
getAttachmentDisplayName, visibleAttachments, isReportPart, isRfc822Attachment,
|
||||
isEmbeddedInBody,
|
||||
} from '../attachment-display';
|
||||
import { buildForwardAsAttachmentPayload } from '../forward-as-attachment';
|
||||
@@ -93,16 +93,8 @@ describe('isEmbeddedInBody', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('previewKindFor / classification', () => {
|
||||
it('classifies previewable types and refuses script-bearing ones', () => {
|
||||
expect(previewKindFor({ type: 'image/jpeg', name: 'a.jpg' })).toBe('image');
|
||||
expect(previewKindFor({ type: 'application/pdf', name: 'a.pdf' })).toBe('pdf');
|
||||
expect(previewKindFor({ type: 'text/plain', name: 'a.txt' })).toBe('text');
|
||||
expect(previewKindFor({ type: 'application/octet-stream', name: 'notes.md' })).toBe('text');
|
||||
expect(previewKindFor({ type: 'message/rfc822', name: 'fwd.eml' })).toBe('eml');
|
||||
expect(previewKindFor({ type: 'text/html', name: 'a.html' })).toBe('none');
|
||||
expect(previewKindFor({ type: 'image/svg+xml', name: 'a.svg' })).toBe('none');
|
||||
expect(previewKindFor({ type: 'application/zip', name: 'a.zip' })).toBe('none');
|
||||
describe('report and embedded-message parts', () => {
|
||||
it('recognises DSN reports and .eml parts', () => {
|
||||
expect(isReportPart('message/delivery-status')).toBe(true);
|
||||
expect(isRfc822Attachment({ type: 'application/octet-stream', name: 'x.eml' })).toBe(true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { compareCalendars, moveCalendar, sortCalendars, sortOrderUpdates } from '../calendar-sort';
|
||||
import type { Calendar } from '../../api/types';
|
||||
|
||||
function cal(overrides: Partial<Calendar>): Calendar {
|
||||
return { id: 'c', name: 'Cal', sortOrder: 0, ...overrides };
|
||||
}
|
||||
|
||||
const names = (list: Calendar[]) => list.map((c) => c.name);
|
||||
const ids = (list: Calendar[]) => list.map((c) => c.id);
|
||||
|
||||
describe('sortCalendars', () => {
|
||||
it('orders by sortOrder, whatever order the server listed them in', () => {
|
||||
const list = [
|
||||
cal({ id: 'b', name: 'Default', sortOrder: 20 }),
|
||||
cal({ id: 'n', name: 'Holidays', sortOrder: 30 }),
|
||||
cal({ id: 'e', name: 'Work', sortOrder: 1 }),
|
||||
cal({ id: 'd', name: 'Personal', sortOrder: 10 }),
|
||||
];
|
||||
expect(names(sortCalendars(list))).toEqual(['Work', 'Personal', 'Default', 'Holidays']);
|
||||
});
|
||||
|
||||
it('sorts equal sortOrder by name, ignoring case and accents, numbers by value', () => {
|
||||
const list = [
|
||||
cal({ id: '1', name: 'zeta' }),
|
||||
cal({ id: '2', name: 'Team 10' }),
|
||||
cal({ id: '3', name: 'Éclair' }),
|
||||
cal({ id: '4', name: 'alpha' }),
|
||||
cal({ id: '5', name: 'Team 2' }),
|
||||
cal({ id: '6', name: 'Beta' }),
|
||||
];
|
||||
expect(names(sortCalendars(list))).toEqual(['alpha', 'Beta', 'Éclair', 'Team 2', 'Team 10', 'zeta']);
|
||||
});
|
||||
|
||||
it('treats a missing sortOrder as 0', () => {
|
||||
const list = [cal({ id: 'a', name: 'B', sortOrder: 1 }), cal({ id: 'b', name: 'A', sortOrder: undefined })];
|
||||
expect(names(sortCalendars(list))).toEqual(['A', 'B']);
|
||||
});
|
||||
|
||||
it('keeps each account together, in the order the accounts came', () => {
|
||||
const list = [
|
||||
cal({ id: 'own-2', name: 'Own 2', sortOrder: 2 }),
|
||||
cal({ id: 'own-1', name: 'Own 1', sortOrder: 1 }),
|
||||
cal({ id: 'x:b', name: 'Shared B', sortOrder: 0, isShared: true, accountId: 'x' }),
|
||||
cal({ id: 'y:a', name: 'Other A', sortOrder: 0, isShared: true, accountId: 'y' }),
|
||||
cal({ id: 'x:a', name: 'Shared A', sortOrder: 5, isShared: true, accountId: 'x' }),
|
||||
];
|
||||
expect(ids(sortCalendars(list))).toEqual(['own-1', 'own-2', 'x:b', 'x:a', 'y:a']);
|
||||
});
|
||||
|
||||
it('does not change the list it is given', () => {
|
||||
const list = [cal({ id: 'b', sortOrder: 2 }), cal({ id: 'a', sortOrder: 1 })];
|
||||
sortCalendars(list);
|
||||
expect(ids(list)).toEqual(['b', 'a']);
|
||||
});
|
||||
|
||||
it('compareCalendars puts the lower sortOrder first', () => {
|
||||
expect(compareCalendars(cal({ sortOrder: 1, name: 'Z' }), cal({ sortOrder: 2, name: 'A' }))).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortOrderUpdates', () => {
|
||||
it('numbers the calendars 1, 2, 3 in the given order and skips unchanged ones', () => {
|
||||
const list = [cal({ id: 'a', sortOrder: 1 }), cal({ id: 'b', sortOrder: 2 }), cal({ id: 'c', sortOrder: 3 })];
|
||||
expect(sortOrderUpdates(list, ['a', 'c', 'b'])).toEqual([
|
||||
{ id: 'c', sortOrder: 2 },
|
||||
{ id: 'b', sortOrder: 3 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveCalendar', () => {
|
||||
const list = [
|
||||
cal({ id: 'work', name: 'Work', sortOrder: 1 }),
|
||||
cal({ id: 'home', name: 'Home', sortOrder: 10 }),
|
||||
cal({ id: 'tasks', name: 'Tasks', sortOrder: 15 }),
|
||||
cal({ id: 'holidays', name: 'Holidays', sortOrder: 30 }),
|
||||
cal({ id: 'x:team', name: 'Team', sortOrder: 0, isShared: true, accountId: 'x' }),
|
||||
];
|
||||
|
||||
it('moving up puts the calendar just before the target', () => {
|
||||
expect(moveCalendar(list, 'holidays', 'home')).toEqual(['work', 'holidays', 'home', 'tasks']);
|
||||
});
|
||||
|
||||
it('moving down puts the calendar just after the target', () => {
|
||||
expect(moveCalendar(list, 'work', 'home')).toEqual(['home', 'work', 'tasks', 'holidays']);
|
||||
});
|
||||
|
||||
it('refuses a move across accounts, onto itself or to an unknown id', () => {
|
||||
expect(moveCalendar(list, 'work', 'x:team')).toBeNull();
|
||||
expect(moveCalendar(list, 'work', 'work')).toBeNull();
|
||||
expect(moveCalendar(list, 'work', 'gone')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
initialDraftPartBlobState, liveBlobId, matchDraftParts, rebaseDraftPartBlobs, withSnapshotBody,
|
||||
} from '../draft-part-blobs';
|
||||
import type { OutgoingAttachment } from '../../api/email';
|
||||
|
||||
const inline = (blobId: string, cid: string, name = 'img.png', size = 10): OutgoingAttachment => ({
|
||||
blobId, cid, name, size, type: 'image/png', disposition: 'inline',
|
||||
});
|
||||
const file = (blobId: string, name: string, size: number): OutgoingAttachment => ({
|
||||
blobId, name, size, type: 'application/pdf', disposition: 'attachment',
|
||||
});
|
||||
|
||||
describe('matchDraftParts', () => {
|
||||
it('pairs inline parts by Content-ID, brackets and case aside', () => {
|
||||
const pairs = matchDraftParts(
|
||||
[inline('old-a', 'a@x'), inline('old-b', 'b@x')],
|
||||
[
|
||||
{ blobId: 'new-b', cid: '<B@x>', name: 'img.png', size: 10 },
|
||||
{ blobId: 'new-a', cid: '<a@x>', name: 'img.png', size: 10 },
|
||||
],
|
||||
);
|
||||
expect(pairs).toEqual(new Map([['old-a', 'new-a'], ['old-b', 'new-b']]));
|
||||
});
|
||||
|
||||
it('pairs file attachments by name and size, claiming each new part once', () => {
|
||||
const pairs = matchDraftParts(
|
||||
[file('old-1', 'q.pdf', 5), file('old-2', 'q.pdf', 5), file('old-3', 'r.pdf', 7)],
|
||||
[
|
||||
{ blobId: 'new-r', name: 'r.pdf', size: 7 },
|
||||
{ blobId: 'new-q1', name: 'q.pdf', size: 5 },
|
||||
{ blobId: 'new-q2', name: 'q.pdf', size: 5 },
|
||||
],
|
||||
);
|
||||
expect(pairs.get('old-1')).toBe('new-q1');
|
||||
expect(pairs.get('old-2')).toBe('new-q2');
|
||||
expect(pairs.get('old-3')).toBe('new-r');
|
||||
});
|
||||
|
||||
it('leaves a part it cannot find unpaired', () => {
|
||||
expect(matchDraftParts([file('old', 'gone.pdf', 1)], [{ blobId: 'n', name: 'other.pdf', size: 1 }]).size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rebaseDraftPartBlobs (webmail #849, second half)', () => {
|
||||
it('follows the draft across successive saves so no save sends a destroyed part', () => {
|
||||
// Re-opened draft D0 with one inline image and one PDF.
|
||||
let state = initialDraftPartBlobState();
|
||||
const sentAt = () => [inline(liveBlobId(state, 'd0-img'), 'img@x'), file(liveBlobId(state, 'd0-pdf'), 'q.pdf', 5)];
|
||||
|
||||
// Save 1 sends D0's parts (D0 is still alive), creates D1, destroys D0.
|
||||
expect(sentAt().map((a) => a.blobId)).toEqual(['d0-img', 'd0-pdf']);
|
||||
state = rebaseDraftPartBlobs(state, sentAt(), [
|
||||
{ blobId: 'd1-img', cid: '<img@x>', name: 'img.png', size: 10 },
|
||||
{ blobId: 'd1-pdf', name: 'q.pdf', size: 5 },
|
||||
]);
|
||||
// Save 2 / send must reference D1's parts, not D0's dead ones.
|
||||
expect(sentAt().map((a) => a.blobId)).toEqual(['d1-img', 'd1-pdf']);
|
||||
state = rebaseDraftPartBlobs(state, sentAt(), [
|
||||
{ blobId: 'd2-img', cid: '<img@x>', name: 'img.png', size: 10 },
|
||||
{ blobId: 'd2-pdf', name: 'q.pdf', size: 5 },
|
||||
]);
|
||||
expect(sentAt().map((a) => a.blobId)).toEqual(['d2-img', 'd2-pdf']);
|
||||
});
|
||||
|
||||
it('re-points uploads too: an upload expires once nothing references it', () => {
|
||||
let state = initialDraftPartBlobState();
|
||||
const sent = () => [inline(liveBlobId(state, 'd0-img'), 'img@x'), file(liveBlobId(state, 'upload-1'), 'new.pdf', 3)];
|
||||
state = rebaseDraftPartBlobs(state, sent(), [
|
||||
{ blobId: 'd1-img', cid: '<img@x>', name: 'img.png', size: 10 },
|
||||
{ blobId: 'd1-new', name: 'new.pdf', size: 3 },
|
||||
]);
|
||||
expect(sent().map((a) => a.blobId)).toEqual(['d1-img', 'd1-new']);
|
||||
});
|
||||
|
||||
it('leaves the state alone when nothing was sent', () => {
|
||||
const state = initialDraftPartBlobState();
|
||||
expect(rebaseDraftPartBlobs(state, [], [{ blobId: 'p', name: 'a.pdf', size: 1 }])).toBe(state);
|
||||
});
|
||||
|
||||
it('keeps the old id when the read-back came back empty (next save fails loudly, nothing is lost)', () => {
|
||||
const state = initialDraftPartBlobState();
|
||||
const next = rebaseDraftPartBlobs(state, [inline('d0-img', 'img@x')], []);
|
||||
expect(liveBlobId(next, 'd0-img')).toBe('d0-img');
|
||||
});
|
||||
});
|
||||
|
||||
describe('withSnapshotBody', () => {
|
||||
it('replaces only the body of a composer snapshot', () => {
|
||||
const snap = JSON.stringify({ to: [['A', 'a@x', null]], cc: [], bcc: [], subject: 'S', body: '<p>raw</p>', att: ['b1'] });
|
||||
expect(JSON.parse(withSnapshotBody(snap, '<p>normalised</p>'))).toEqual({
|
||||
to: [['A', 'a@x', null]], cc: [], bcc: [], subject: 'S', body: '<p>normalised</p>', att: ['b1'],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the key order, so an unchanged composer still compares equal', () => {
|
||||
const make = (body: string) => JSON.stringify({ to: [], cc: [], bcc: [], subject: 'S', body, att: [] });
|
||||
expect(withSnapshotBody(make('<p>raw</p>'), '<p>x</p>')).toBe(make('<p>x</p>'));
|
||||
});
|
||||
});
|
||||
@@ -196,3 +196,87 @@ describe('editor page messages', () => {
|
||||
expect((page.selections()[1].payload as { bold: boolean }).bold).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Android pans the editor page's visual viewport to reveal the caret and the
|
||||
// pan outlived the moment: back at the top of the composer the body's first
|
||||
// lines stayed hidden under To/Cc/Subject. The page undoes each pan and hands
|
||||
// the offset to RN instead.
|
||||
describe('editor visual viewport pinning', () => {
|
||||
function boot(pinVisualViewport: boolean | undefined, withVisualViewport = true) {
|
||||
const posted: Array<{ type: string; payload: unknown }> = [];
|
||||
const scrolledIntoView: unknown[] = [];
|
||||
const vvListeners: Record<string, Array<() => void>> = {};
|
||||
const visualViewport = {
|
||||
offsetTop: 0,
|
||||
addEventListener: (name: string, fn: () => void) => { (vvListeners[name] ??= []).push(fn); },
|
||||
};
|
||||
const editor = {
|
||||
innerHTML: '',
|
||||
get textContent() { return this.innerHTML.replace(/<[^>]*>/g, ''); },
|
||||
scrollHeight: 100,
|
||||
querySelector: () => null,
|
||||
setAttribute: () => undefined,
|
||||
focus: () => undefined,
|
||||
addEventListener: () => undefined,
|
||||
// Resetting the pan is what scrolling the page top into view does.
|
||||
scrollIntoView: (opts: unknown) => { scrolledIntoView.push(opts); visualViewport.offsetTop = 0; },
|
||||
};
|
||||
const document = {
|
||||
activeElement: editor as unknown,
|
||||
getElementById: () => editor,
|
||||
addEventListener: () => undefined,
|
||||
queryCommandState: () => false,
|
||||
};
|
||||
const window = {
|
||||
ReactNativeWebView: { postMessage: (msg: string) => posted.push(JSON.parse(msg)) },
|
||||
addEventListener: () => undefined,
|
||||
getSelection: () => null,
|
||||
visualViewport: withVisualViewport ? visualViewport : undefined,
|
||||
};
|
||||
const script = extractScript(buildEditorHtml({
|
||||
initialHtml: '<p><br></p>', placeholder: '', c: LIGHT_COLORS, pinVisualViewport,
|
||||
}));
|
||||
new Function('window', 'document', script)(window, document);
|
||||
const pan = (offsetTop: number) => {
|
||||
visualViewport.offsetTop = offsetTop;
|
||||
(vvListeners.scroll ?? []).forEach((fn) => fn());
|
||||
};
|
||||
const shifts = () => posted.filter((m) => m.type === 'viewportShift').map((m) => m.payload);
|
||||
return { pan, shifts, scrolledIntoView, visualViewport, vvListeners };
|
||||
}
|
||||
|
||||
it('undoes a caret-reveal pan and reports its offset to RN', () => {
|
||||
const page = boot(true);
|
||||
page.pan(18.5);
|
||||
expect(page.visualViewport.offsetTop).toBe(0);
|
||||
expect(page.scrolledIntoView).toEqual([{ block: 'start', inline: 'nearest' }]);
|
||||
expect(page.shifts()).toEqual([18.5]);
|
||||
});
|
||||
|
||||
it('ignores the scroll event its own reset fires', () => {
|
||||
const page = boot(true);
|
||||
page.pan(40);
|
||||
page.pan(0);
|
||||
page.pan(0.2);
|
||||
expect(page.shifts()).toEqual([40]);
|
||||
expect(page.scrolledIntoView).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports each later pan separately', () => {
|
||||
const page = boot(true);
|
||||
page.pan(12);
|
||||
page.pan(30);
|
||||
expect(page.shifts()).toEqual([12, 30]);
|
||||
});
|
||||
|
||||
it('leaves the viewport alone unless asked (iOS, settings editors)', () => {
|
||||
const off = boot(false);
|
||||
expect(off.vvListeners.scroll).toBeUndefined();
|
||||
const unset = boot(undefined);
|
||||
expect(unset.vvListeners.scroll).toBeUndefined();
|
||||
});
|
||||
|
||||
it('survives a WebView without visualViewport', () => {
|
||||
expect(() => boot(true, false)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,52 +1,80 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
filePreviewKind, inAppPreviewKind, exceedsPreviewLimit, FILE_PREVIEW_MAX_BYTES,
|
||||
needsNativeImageDecoder, needsWebImageViewer, handoffMimeType,
|
||||
} from '../file-preview';
|
||||
|
||||
const IOS = { inlinePdf: true };
|
||||
const ANDROID = { inlinePdf: false };
|
||||
const IOS = { platform: 'ios' } as const;
|
||||
const ANDROID = { platform: 'android' } as const;
|
||||
|
||||
describe('filePreviewKind', () => {
|
||||
it('previews the images React Native decodes, by type or by extension', () => {
|
||||
it('previews common images by type or by extension', () => {
|
||||
expect(filePreviewKind({ name: 'photo.jpg', type: 'image/jpeg' }, ANDROID)).toBe('image');
|
||||
expect(filePreviewKind({ name: 'scan.PNG', type: 'application/octet-stream' }, ANDROID)).toBe('image');
|
||||
expect(filePreviewKind({ name: 'IMG_0001', type: 'image/heic' }, ANDROID)).toBe('image');
|
||||
expect(filePreviewKind({ name: 'anim.webp', type: '' }, ANDROID)).toBe('image');
|
||||
});
|
||||
|
||||
it('hands images it cannot decode to another app', () => {
|
||||
it('draws SVG and ICO images instead of handing them off', () => {
|
||||
expect(filePreviewKind({ name: 'logo.svg', type: 'image/svg+xml' }, IOS)).toBe('image');
|
||||
expect(filePreviewKind({ name: 'logo.svg', type: 'application/octet-stream' }, ANDROID)).toBe('image');
|
||||
expect(filePreviewKind({ name: 'favicon.ico', type: 'image/x-icon' }, ANDROID)).toBe('image');
|
||||
expect(filePreviewKind({ name: 'logo.svgz', type: 'image/svg+xml' }, IOS)).toBe('none');
|
||||
});
|
||||
|
||||
it('shows TIFF only where the platform decodes it', () => {
|
||||
expect(filePreviewKind({ name: 'fax.tiff', type: 'image/tiff' }, IOS)).toBe('image');
|
||||
expect(filePreviewKind({ name: 'fax.tif', type: '' }, IOS)).toBe('image');
|
||||
expect(filePreviewKind({ name: 'fax.tiff', type: 'image/tiff' }, ANDROID)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'raw.cr2', type: 'image/x-canon-cr2' }, ANDROID)).toBe('none');
|
||||
});
|
||||
|
||||
it('keeps script-bearing documents out of the preview', () => {
|
||||
expect(filePreviewKind({ name: 'logo.svg', type: 'image/svg+xml' }, IOS)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'page.html', type: 'text/html' }, IOS)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'page.htm', type: 'application/octet-stream' }, IOS)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'doc', type: 'application/xhtml+xml' }, IOS)).toBe('none');
|
||||
it('renders HTML and Markdown documents', () => {
|
||||
expect(filePreviewKind({ name: 'page.html', type: 'text/html' }, IOS)).toBe('html');
|
||||
expect(filePreviewKind({ name: 'page.htm', type: 'application/octet-stream' }, ANDROID)).toBe('html');
|
||||
expect(filePreviewKind({ name: 'doc', type: 'application/xhtml+xml' }, IOS)).toBe('html');
|
||||
expect(filePreviewKind({ name: 'README.md', type: 'application/octet-stream' }, ANDROID)).toBe('markdown');
|
||||
expect(filePreviewKind({ name: 'notes', type: 'text/markdown' }, ANDROID)).toBe('markdown');
|
||||
});
|
||||
|
||||
it('shows PDFs in-app only where the platform renders them', () => {
|
||||
it('shows PDFs in-app on both platforms', () => {
|
||||
expect(filePreviewKind({ name: 'report.pdf', type: 'application/pdf' }, IOS)).toBe('pdf');
|
||||
expect(filePreviewKind({ name: 'report.pdf', type: 'application/octet-stream' }, IOS)).toBe('pdf');
|
||||
expect(filePreviewKind({ name: 'report.pdf', type: 'application/pdf' }, ANDROID)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'report.pdf', type: 'application/pdf' }, ANDROID)).toBe('pdf');
|
||||
expect(filePreviewKind({ name: 'REPORT.PDF', type: '' }, ANDROID)).toBe('pdf');
|
||||
});
|
||||
|
||||
it('shows plain text, markdown and data files as text', () => {
|
||||
it('plays the video formats each platform plays', () => {
|
||||
expect(filePreviewKind({ name: 'clip.mp4', type: 'video/mp4' }, IOS)).toBe('video');
|
||||
expect(filePreviewKind({ name: 'clip.mov', type: 'video/quicktime' }, IOS)).toBe('video');
|
||||
expect(filePreviewKind({ name: 'clip.mp4', type: 'video/mp4' }, ANDROID)).toBe('video');
|
||||
expect(filePreviewKind({ name: 'clip.webm', type: 'video/webm' }, ANDROID)).toBe('video');
|
||||
expect(filePreviewKind({ name: 'clip.mkv', type: 'application/octet-stream' }, ANDROID)).toBe('video');
|
||||
expect(filePreviewKind({ name: 'clip.webm', type: 'video/webm' }, IOS)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'old.avi', type: 'video/x-msvideo' }, ANDROID)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'old.wmv', type: 'video/x-ms-wmv' }, IOS)).toBe('none');
|
||||
});
|
||||
|
||||
it('plays the audio formats each platform plays', () => {
|
||||
expect(filePreviewKind({ name: 'song.mp3', type: 'audio/mpeg' }, IOS)).toBe('audio');
|
||||
expect(filePreviewKind({ name: 'voice.m4a', type: '' }, IOS)).toBe('audio');
|
||||
expect(filePreviewKind({ name: 'memo.ogg', type: 'audio/ogg' }, ANDROID)).toBe('audio');
|
||||
expect(filePreviewKind({ name: 'memo.ogg', type: 'application/octet-stream' }, ANDROID)).toBe('audio');
|
||||
expect(filePreviewKind({ name: 'memo.opus', type: 'audio/ogg' }, IOS)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'call.amr', type: 'audio/amr' }, ANDROID)).toBe('none');
|
||||
});
|
||||
|
||||
it('shows plain text, data and source files as text', () => {
|
||||
expect(filePreviewKind({ name: 'notes.txt', type: 'text/plain' }, ANDROID)).toBe('text');
|
||||
expect(filePreviewKind({ name: 'README.md', type: 'application/octet-stream' }, ANDROID)).toBe('text');
|
||||
expect(filePreviewKind({ name: 'data.csv', type: 'text/csv' }, ANDROID)).toBe('text');
|
||||
expect(filePreviewKind({ name: 'config', type: 'application/json' }, ANDROID)).toBe('text');
|
||||
expect(filePreviewKind({ name: 'feed', type: 'application/atom+xml' }, ANDROID)).toBe('text');
|
||||
});
|
||||
|
||||
it('shows source code as text, including the script types the reader leaves out', () => {
|
||||
expect(filePreviewKind({ name: 'app.ts', type: 'application/octet-stream' }, ANDROID)).toBe('text');
|
||||
expect(filePreviewKind({ name: 'main.py', type: '' }, ANDROID)).toBe('text');
|
||||
expect(filePreviewKind({ name: 'build.sh', type: 'application/x-sh' }, ANDROID)).toBe('text');
|
||||
expect(filePreviewKind({ name: 'index.js', type: 'text/javascript' }, ANDROID)).toBe('text');
|
||||
expect(filePreviewKind({ name: 'bundle', type: 'application/javascript' }, ANDROID)).toBe('text');
|
||||
expect(filePreviewKind({ name: 'Dockerfile', type: '' }, ANDROID)).toBe('text');
|
||||
expect(filePreviewKind({ name: 'invite.ics', type: 'text/calendar' }, IOS)).toBe('text');
|
||||
});
|
||||
|
||||
it('shows saved messages like the reader does', () => {
|
||||
@@ -54,10 +82,29 @@ describe('filePreviewKind', () => {
|
||||
expect(filePreviewKind({ name: 'fwd', type: 'message/rfc822' }, ANDROID)).toBe('eml');
|
||||
});
|
||||
|
||||
it('leaves binaries and office documents to other apps', () => {
|
||||
expect(filePreviewKind({ name: 'archive.zip', type: 'application/zip' }, IOS)).toBe('none');
|
||||
it('goes by the name when the declared type is a catch-all', () => {
|
||||
expect(filePreviewKind({ name: 'invoice.pdf', type: 'application/x-as400attachment' }, IOS)).toBe('pdf');
|
||||
expect(filePreviewKind({ name: 'photo.jpg', type: 'application/x-as400attachment' }, ANDROID)).toBe('image');
|
||||
expect(filePreviewKind({ name: 'download', type: 'image/png' }, ANDROID)).toBe('image');
|
||||
});
|
||||
|
||||
it('lets a media type decide over the name', () => {
|
||||
expect(filePreviewKind({ name: 'clip.avi', type: 'video/mp4' }, ANDROID)).toBe('video');
|
||||
expect(filePreviewKind({ name: 'cover.mp3', type: 'image/jpeg' }, ANDROID)).toBe('image');
|
||||
});
|
||||
|
||||
it('lists the files in a ZIP, but not in the formats built on ZIP', () => {
|
||||
expect(filePreviewKind({ name: 'archive.zip', type: 'application/zip' }, IOS)).toBe('archive');
|
||||
expect(filePreviewKind({ name: 'photos', type: 'application/x-zip-compressed' }, ANDROID)).toBe('archive');
|
||||
expect(filePreviewKind({ name: 'Archive.ZIP', type: 'application/octet-stream' }, ANDROID)).toBe('archive');
|
||||
expect(filePreviewKind({ name: 'app.apk', type: 'application/vnd.android.package-archive' }, ANDROID)).toBe('none');
|
||||
});
|
||||
|
||||
it('leaves other archives, office documents and binaries to other apps', () => {
|
||||
expect(filePreviewKind({ name: 'backup.7z', type: 'application/x-7z-compressed' }, IOS)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'logs.tar.gz', type: 'application/gzip' }, ANDROID)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'sheet.xlsx', type: 'application/octet-stream' }, IOS)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'movie.mp4', type: 'video/mp4' }, IOS)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'contract.docx', type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }, ANDROID)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'data.bin', type: undefined }, IOS)).toBe('none');
|
||||
});
|
||||
|
||||
@@ -67,11 +114,26 @@ describe('filePreviewKind', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('image viewer choice', () => {
|
||||
it('sends formats Chromium cannot decode to the native decoder', () => {
|
||||
expect(needsNativeImageDecoder({ name: 'IMG.HEIC', type: '' })).toBe(true);
|
||||
expect(needsNativeImageDecoder({ name: 'x', type: 'image/heif' })).toBe(true);
|
||||
expect(needsNativeImageDecoder({ name: 'photo.jpg', type: 'image/jpeg' })).toBe(false);
|
||||
});
|
||||
|
||||
it('sends formats React Native cannot draw to the WebView', () => {
|
||||
expect(needsWebImageViewer({ name: 'logo.svg', type: '' })).toBe(true);
|
||||
expect(needsWebImageViewer({ name: 'x', type: 'image/x-icon' })).toBe(true);
|
||||
expect(needsWebImageViewer({ name: 'photo.jpg', type: 'image/jpeg' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('preview size limits', () => {
|
||||
it('caps text far below images and PDFs', () => {
|
||||
it('caps text far below images, PDFs and media', () => {
|
||||
expect(FILE_PREVIEW_MAX_BYTES.text).toBe(512 * 1024);
|
||||
expect(FILE_PREVIEW_MAX_BYTES.text).toBeLessThan(FILE_PREVIEW_MAX_BYTES.image);
|
||||
expect(FILE_PREVIEW_MAX_BYTES.image).toBeLessThan(FILE_PREVIEW_MAX_BYTES.pdf);
|
||||
expect(FILE_PREVIEW_MAX_BYTES.pdf).toBeLessThan(FILE_PREVIEW_MAX_BYTES.video);
|
||||
});
|
||||
|
||||
it('flags only known sizes over the cap', () => {
|
||||
@@ -89,5 +151,27 @@ describe('preview size limits', () => {
|
||||
expect(inAppPreviewKind({ ...big, size: undefined }, ANDROID)).toBe('text');
|
||||
expect(inAppPreviewKind({ name: 'pano.jpg', type: 'image/jpeg', size: 40 * 1024 * 1024 }, ANDROID)).toBe('none');
|
||||
expect(inAppPreviewKind({ name: 'pano.jpg', type: 'image/jpeg', size: 4 * 1024 * 1024 }, ANDROID)).toBe('image');
|
||||
expect(inAppPreviewKind({ name: 'film.mp4', type: 'video/mp4', size: 300 * 1024 * 1024 }, ANDROID)).toBe('video');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handoffMimeType', () => {
|
||||
it('keeps a real declared type', () => {
|
||||
expect(handoffMimeType('a.pdf', 'application/pdf')).toBe('application/pdf');
|
||||
expect(handoffMimeType('scan', 'image/png')).toBe('image/png');
|
||||
expect(handoffMimeType('a.zip', 'application/x-zip-compressed')).toBe('application/zip');
|
||||
});
|
||||
|
||||
it('names the format when the declared type does not, so Android finds a viewer', () => {
|
||||
expect(handoffMimeType('contract.docx', 'application/octet-stream'))
|
||||
.toBe('application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
||||
expect(handoffMimeType('invoice.PDF', '')).toBe('application/pdf');
|
||||
expect(handoffMimeType('invoice.pdf', 'application/x-as400attachment')).toBe('application/pdf');
|
||||
expect(handoffMimeType('photo.jpg', 'application/x-unknown')).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('falls back to the declared type or octet-stream for unknown names', () => {
|
||||
expect(handoffMimeType('data.bin', '')).toBe('application/octet-stream');
|
||||
expect(handoffMimeType('data.xyz', 'application/x-foo')).toBe('application/x-foo');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { markdownToHtml, renderInline } from '../markdown-html';
|
||||
|
||||
const body = (md: string) => {
|
||||
const html = markdownToHtml(md);
|
||||
return html.slice(html.indexOf('<body>') + 6, html.indexOf('</body>'));
|
||||
};
|
||||
|
||||
describe('markdownToHtml', () => {
|
||||
it('renders headings, paragraphs and rules', () => {
|
||||
expect(body('# Title\n\nSome text\nwrapped.\n\n---\n\n### Small ###'))
|
||||
.toBe('<h1>Title</h1>\n<p>Some text\nwrapped.</p>\n<hr>\n<h3>Small</h3>');
|
||||
expect(body('Big\n===\n\nSmaller\n---')).toBe('<h1>Big</h1>\n<h2>Smaller</h2>');
|
||||
});
|
||||
|
||||
it('renders nested and ordered lists and task items', () => {
|
||||
expect(body('- one\n- two\n - nested\n- three')).toBe(
|
||||
'<ul><li>one</li><li>two\n<ul><li>nested</li></ul></li><li>three</li></ul>',
|
||||
);
|
||||
expect(body('3. c\n4. d')).toBe('<ol start="3"><li>c</li><li>d</li></ol>');
|
||||
expect(body('- [x] done\n- [ ] open')).toBe('<ul><li class="task">\u2611 done</li><li class="task">\u2610 open</li></ul>');
|
||||
});
|
||||
|
||||
it('keeps fenced and indented code verbatim and escaped', () => {
|
||||
expect(body('```js\nconst a = "<b>";\n**not bold**\n```')).toBe(
|
||||
'<pre><code class="language-js">const a = "<b>";\n**not bold**</code></pre>',
|
||||
);
|
||||
expect(body(' x < 1')).toBe('<pre><code>x < 1</code></pre>');
|
||||
});
|
||||
|
||||
it('renders quotes and GFM tables with alignment', () => {
|
||||
expect(body('> quoted\n> **text**')).toBe('<blockquote><p>quoted\n<strong>text</strong></p></blockquote>');
|
||||
expect(body('| a | b |\n|:--|--:|\n| 1 | 2 |')).toBe(
|
||||
'<table><thead><tr><th style="text-align:left">a</th><th style="text-align:right">b</th></tr></thead>'
|
||||
+ '<tbody><tr><td style="text-align:left">1</td><td style="text-align:right">2</td></tr></tbody></table>',
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes raw HTML instead of passing it through', () => {
|
||||
expect(body('<script>alert(1)</script>')).toBe('<p><script>alert(1)</script></p>');
|
||||
expect(body('<img src=x onerror=alert(1)>')).not.toContain('<img');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderInline', () => {
|
||||
it('renders emphasis, code and strikethrough', () => {
|
||||
expect(renderInline('**b** *i* _j_ `c*d*` ~~s~~')).toBe(
|
||||
'<strong>b</strong> <em>i</em> <em>j</em> <code>c*d*</code> <del>s</del>',
|
||||
);
|
||||
expect(renderInline('snake_case_name stays')).toBe('snake_case_name stays');
|
||||
expect(renderInline('\\*literal\\*')).toBe('*literal*');
|
||||
});
|
||||
|
||||
it('links only to web and mail URLs', () => {
|
||||
expect(renderInline('[site](https://example.org "T")')).toBe('<a href="https://example.org" title="T">site</a>');
|
||||
expect(renderInline('[x](javascript:alert(1))')).toBe('x)');
|
||||
expect(renderInline('[doc](./docs/a.md)')).toBe('doc');
|
||||
expect(renderInline('see https://example.org/a.')).toBe('see <a href="https://example.org/a">https://example.org/a</a>.');
|
||||
expect(renderInline('<mailto:a@b.c>')).toBe('<a href="mailto:a@b.c">mailto:a@b.c</a>');
|
||||
});
|
||||
|
||||
it('keeps image URLs for the sanitiser and drops unsafe ones', () => {
|
||||
expect(renderInline('')).toBe('<img src="https://example.org/l.png" alt="logo">');
|
||||
expect(renderInline('')).toBe('x');
|
||||
});
|
||||
|
||||
it('escapes attribute breakouts in link text and titles', () => {
|
||||
expect(renderInline('[a"b](https://e.org "x\\" onmouseover=\\"y")')).not.toMatch(/onmouseover="/);
|
||||
});
|
||||
});
|
||||
@@ -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 } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NativeModules } from 'react-native';
|
||||
import { openPdfPages, PdfOpenError, renderPdfPage, canRenderPdfPages } from '../pdf-pages';
|
||||
|
||||
const modules = NativeModules as Record<string, unknown>;
|
||||
|
||||
function nativeError(code: string, message: string): Error {
|
||||
return Object.assign(new Error(message), { code });
|
||||
}
|
||||
|
||||
describe('pdf-pages', () => {
|
||||
afterEach(() => { delete modules.BulwarkPdf; });
|
||||
|
||||
it('reports the page sizes the renderer returns', async () => {
|
||||
modules.BulwarkPdf = { open: vi.fn(async () => ({ pages: [{ width: 595, height: 842 }] })) };
|
||||
await expect(openPdfPages('file:///x.pdf')).resolves.toEqual([{ width: 595, height: 842 }]);
|
||||
});
|
||||
|
||||
it('tells a password-protected PDF from an unreadable one', async () => {
|
||||
modules.BulwarkPdf = { open: vi.fn(async () => { throw nativeError('E_PDF_PROTECTED', 'password'); }) };
|
||||
await expect(openPdfPages('file:///x.pdf')).rejects.toMatchObject({ reason: 'protected' });
|
||||
|
||||
modules.BulwarkPdf = { open: vi.fn(async () => { throw nativeError('E_PDF_OPEN', 'not a PDF'); }) };
|
||||
await expect(openPdfPages('file:///x.pdf')).rejects.toMatchObject({ reason: 'unreadable' });
|
||||
|
||||
modules.BulwarkPdf = { open: vi.fn(async () => ({ pages: [] })) };
|
||||
await expect(openPdfPages('file:///x.pdf')).rejects.toBeInstanceOf(PdfOpenError);
|
||||
});
|
||||
|
||||
it('fails cleanly in a build without the renderer', async () => {
|
||||
expect(canRenderPdfPages()).toBe(false);
|
||||
await expect(openPdfPages('file:///x.pdf')).rejects.toMatchObject({ reason: 'unreadable' });
|
||||
await expect(renderPdfPage('file:///x.pdf', 0, 100, 'file:///p.png')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('renders at a whole pixel width', async () => {
|
||||
const renderPage = vi.fn(async () => 'file:///p.png');
|
||||
modules.BulwarkPdf = { renderPage };
|
||||
await renderPdfPage('file:///x.pdf', 2, 1620.6, 'file:///p.png');
|
||||
expect(renderPage).toHaveBeenCalledWith('file:///x.pdf', 2, 1621, 'file:///p.png');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import PostalMime from 'postal-mime';
|
||||
|
||||
// React Native's Blob only takes strings and other Blobs; building one from an
|
||||
// ArrayBuffer or typed array throws. postal-mime used to join every decoded
|
||||
// part through such a Blob, so an .eml with an attachment could not be
|
||||
// previewed on the device (patches/postal-mime+3.0.0.patch).
|
||||
class ReactNativeBlob {
|
||||
constructor(parts: unknown[] = []) {
|
||||
if (parts.some((p) => p instanceof ArrayBuffer || ArrayBuffer.isView(p))) {
|
||||
throw new Error("Creating blobs from 'ArrayBuffer' and 'ArrayBufferView' are not supported");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MESSAGE = [
|
||||
'From: Carol <carol@example.org>',
|
||||
'Subject: With parts',
|
||||
'MIME-Version: 1.0',
|
||||
'Content-Type: multipart/mixed; boundary="b1"',
|
||||
'',
|
||||
'--b1',
|
||||
'Content-Type: text/plain; charset=utf-8',
|
||||
'Content-Transfer-Encoding: quoted-printable',
|
||||
'',
|
||||
'Gr=C3=BC=C3=9Fe',
|
||||
'--b1',
|
||||
'Content-Type: application/octet-stream; name="a.bin"',
|
||||
'Content-Transfer-Encoding: base64',
|
||||
'Content-Disposition: attachment; filename="a.bin"',
|
||||
'',
|
||||
'AAECAwQF',
|
||||
'--b1',
|
||||
'Content-Type: text/plain; name="inner.txt"',
|
||||
'Content-Disposition: attachment; filename="inner.txt"',
|
||||
'',
|
||||
'Inner text.',
|
||||
'--b1--',
|
||||
'',
|
||||
].join('\r\n');
|
||||
|
||||
describe('postal-mime under React Native', () => {
|
||||
const realBlob = globalThis.Blob;
|
||||
beforeEach(() => { (globalThis as { Blob: unknown }).Blob = ReactNativeBlob; });
|
||||
afterEach(() => { globalThis.Blob = realBlob; });
|
||||
|
||||
it('decodes base64, quoted-printable and plain parts without a Blob', async () => {
|
||||
const parsed = await PostalMime.parse(new TextEncoder().encode(MESSAGE), { attachmentEncoding: 'arraybuffer' });
|
||||
expect(parsed.text?.trim()).toBe('Grüße');
|
||||
const [bin, txt] = parsed.attachments;
|
||||
expect(Array.from(new Uint8Array(bin.content as ArrayBuffer))).toEqual([0, 1, 2, 3, 4, 5]);
|
||||
expect(new TextDecoder().decode(txt.content as ArrayBuffer).trim()).toBe('Inner text.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { imageViewerHtml, mediaViewerHtml, relativeFileUrl, type ViewerColors } from '../preview-viewer-html';
|
||||
|
||||
const COLORS: ViewerColors = { scheme: 'dark', background: '#111', text: '#eee', textMuted: '#999', surface: '#222' };
|
||||
const HOSTILE = `x"><script>alert(1)</script>'.png`;
|
||||
|
||||
describe('relativeFileUrl', () => {
|
||||
it('encodes everything a URL or an attribute could read as syntax', () => {
|
||||
expect(relativeFileUrl('Scan 1 (final).PNG')).toBe('Scan%201%20%28final%29.PNG');
|
||||
expect(relativeFileUrl('a#b?c%d.jpg')).toBe('a%23b%3Fc%25d.jpg');
|
||||
expect(relativeFileUrl(HOSTILE)).not.toMatch(/["'<>]/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('viewer pages', () => {
|
||||
it('load the file by a relative URL next to the page', () => {
|
||||
expect(imageViewerHtml('photo 1.jpg', COLORS)).toContain('src="photo%201.jpg"');
|
||||
expect(mediaViewerHtml('video', 'clip.mp4', COLORS)).toContain('<video id="m" src="clip.mp4"');
|
||||
expect(mediaViewerHtml('audio', 'song.mp3', COLORS)).toContain('<audio id="m" src="song.mp3"');
|
||||
});
|
||||
|
||||
it('never put a file name into markup unescaped', () => {
|
||||
for (const html of [
|
||||
imageViewerHtml(HOSTILE, COLORS),
|
||||
mediaViewerHtml('video', HOSTILE, COLORS),
|
||||
mediaViewerHtml('audio', HOSTILE, COLORS),
|
||||
]) {
|
||||
expect(html).not.toContain('<script>alert(1)');
|
||||
expect(html.match(/<script>/g)).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('report a file that does not decode', () => {
|
||||
expect(imageViewerHtml('a.svg', COLORS)).toContain("addEventListener('error'");
|
||||
expect(mediaViewerHtml('audio', 'a.ogg', COLORS)).toContain("report('error')");
|
||||
});
|
||||
|
||||
it('draw the WebView controls in the app theme, and video on black', () => {
|
||||
expect(mediaViewerHtml('audio', 'a.mp3', COLORS)).toContain('color-scheme:dark');
|
||||
expect(mediaViewerHtml('audio', 'a.mp3', { ...COLORS, scheme: 'light' })).toContain('color-scheme:light');
|
||||
expect(mediaViewerHtml('video', 'a.mp4', { ...COLORS, scheme: 'light' })).toContain('color-scheme:dark');
|
||||
});
|
||||
});
|
||||
@@ -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,72 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import JSZip from 'jszip';
|
||||
import { openZip, isZipArchive, ZipEncryptedError, MAX_ZIP_ENTRIES } from '../zip-preview';
|
||||
|
||||
async function makeZip(files: Record<string, string>): Promise<Uint8Array> {
|
||||
const zip = new JSZip();
|
||||
for (const [path, text] of Object.entries(files)) zip.file(path, text);
|
||||
return zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' });
|
||||
}
|
||||
|
||||
// Set the "encrypted" bit in every local and central header, as a
|
||||
// password-protected archive has it.
|
||||
function markEncrypted(bytes: Uint8Array): Uint8Array {
|
||||
const out = bytes.slice();
|
||||
const view = new DataView(out.buffer);
|
||||
for (let i = 0; i + 4 <= out.length; i++) {
|
||||
const sig = view.getUint32(i, true);
|
||||
if (sig === 0x04034b50) view.setUint16(i + 6, view.getUint16(i + 6, true) | 1, true);
|
||||
if (sig === 0x02014b50) view.setUint16(i + 8, view.getUint16(i + 8, true) | 1, true);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('openZip', () => {
|
||||
it('lists the files by folder and name, without folders or OS metadata', async () => {
|
||||
const archive = await openZip(await makeZip({
|
||||
'b.txt': 'b',
|
||||
'docs/z.md': '# z',
|
||||
'a.txt': 'a',
|
||||
'docs/inner/c.csv': '1,2',
|
||||
'__MACOSX/docs/._z.md': 'junk',
|
||||
'.DS_Store': 'junk',
|
||||
'docs/Thumbs.db': 'junk',
|
||||
}));
|
||||
expect(archive.entries.map((e) => e.path)).toEqual(['a.txt', 'b.txt', 'docs/z.md', 'docs/inner/c.csv']);
|
||||
expect(archive.entries[2]).toMatchObject({ name: 'z.md', folder: 'docs', size: 3 });
|
||||
expect(archive.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('reads an entry from the loaded archive', async () => {
|
||||
const archive = await openZip(await makeZip({ 'docs/notes.txt': 'Grüße' }));
|
||||
expect(new TextDecoder().decode(await archive.read('docs/notes.txt'))).toBe('Grüße');
|
||||
await expect(archive.read('missing.txt')).rejects.toThrow(/not in the archive/);
|
||||
});
|
||||
|
||||
it('stops listing at the cap', async () => {
|
||||
const files: Record<string, string> = {};
|
||||
for (let i = 0; i < MAX_ZIP_ENTRIES + 5; i++) files[`f${i}.txt`] = '';
|
||||
const archive = await openZip(await makeZip(files));
|
||||
expect(archive.entries).toHaveLength(MAX_ZIP_ENTRIES);
|
||||
expect(archive.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it('says when an archive is password-protected', async () => {
|
||||
const locked = markEncrypted(await makeZip({ 'secret.txt': 'x' }));
|
||||
await expect(openZip(locked)).rejects.toBeInstanceOf(ZipEncryptedError);
|
||||
});
|
||||
|
||||
it('rejects data that is not a ZIP', async () => {
|
||||
await expect(openZip(new TextEncoder().encode('not a zip'))).rejects.not.toBeInstanceOf(ZipEncryptedError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isZipArchive', () => {
|
||||
it('takes ZIPs by type or name, not the formats built on ZIP', () => {
|
||||
expect(isZipArchive('a.zip', 'application/zip')).toBe(true);
|
||||
expect(isZipArchive('bundle', 'application/x-zip-compressed')).toBe(true);
|
||||
expect(isZipArchive('Archive.ZIP', 'application/octet-stream')).toBe(true);
|
||||
expect(isZipArchive('contract.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')).toBe(false);
|
||||
expect(isZipArchive('app.apk', 'application/vnd.android.package-archive')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -140,24 +140,6 @@ export function calendarBannerShownFor(email: Pick<Email, 'attachments'>, parsin
|
||||
return parsingEnabled && !!findCalendarAttachment(email);
|
||||
}
|
||||
|
||||
export type PreviewKind = 'image' | 'pdf' | 'text' | 'eml' | 'none';
|
||||
|
||||
const TEXT_EXT = /\.(txt|md|markdown|csv|log|json|xml|ics|vcf|yml|yaml|ini|conf|diff|patch)$/i;
|
||||
// Script-bearing types are never previewed inline (html/svg/js) - webmail lib/file-preview.ts.
|
||||
const SCRIPT_TYPES = /^(text\/html|image\/svg\+xml|application\/(x-)?javascript|text\/javascript|application\/xhtml\+xml)$/i;
|
||||
|
||||
/** How the in-app preview should render a part, if at all. */
|
||||
export function previewKindFor(att: Pick<Attachment, 'name' | 'type'>): PreviewKind {
|
||||
const base = (att.type || '').split(';')[0].trim().toLowerCase();
|
||||
const name = att.name || '';
|
||||
if (SCRIPT_TYPES.test(base) || /\.(html?|svg|js)$/i.test(name)) return 'none';
|
||||
if (isRfc822Attachment(att)) return 'eml';
|
||||
if (base.startsWith('image/') || /\.(png|jpe?g|gif|webp|bmp|heic|avif)$/i.test(name)) return 'image';
|
||||
if (base === 'application/pdf' || /\.pdf$/i.test(name)) return 'pdf';
|
||||
if (base.startsWith('text/') || base === 'application/json' || base === 'application/xml' || TEXT_EXT.test(name)) return 'text';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
export function formatSize(bytes?: number): string {
|
||||
if (!bytes) return '';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Calendar } from '../api/types';
|
||||
|
||||
// Names compare the way the user reads them: accents and case are ignored, and
|
||||
// "Team 2" comes before "Team 10".
|
||||
const nameCollator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true });
|
||||
|
||||
/**
|
||||
* JMAP Calendars: a calendar with a lower `sortOrder` is shown first, and
|
||||
* calendars with equal `sortOrder` are sorted by name. Mirrors the webmail.
|
||||
*/
|
||||
export function compareCalendars(a: Calendar, b: Calendar): number {
|
||||
return (a.sortOrder ?? 0) - (b.sortOrder ?? 0) || nameCollator.compare(a.name ?? '', b.name ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendars of one account stay together: the user's own first, then each
|
||||
* sharing account, as fetched.
|
||||
*/
|
||||
export function calendarAccountKey(calendar: Calendar): string {
|
||||
return calendar.isShared ? calendar.accountId ?? '' : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendars in display order: grouped by account in the order the
|
||||
* accounts first appear, and by `sortOrder`, then name, within each account.
|
||||
*/
|
||||
export function sortCalendars(calendars: Calendar[]): Calendar[] {
|
||||
const accountRank = new Map<string, number>();
|
||||
for (const calendar of calendars) {
|
||||
const key = calendarAccountKey(calendar);
|
||||
if (!accountRank.has(key)) accountRank.set(key, accountRank.size);
|
||||
}
|
||||
return [...calendars].sort((a, b) =>
|
||||
accountRank.get(calendarAccountKey(a))! - accountRank.get(calendarAccountKey(b))! || compareCalendars(a, b),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `sortOrder` values that put `orderedIds` in that order: 1, 2, 3... Only the
|
||||
* calendars whose value changes are returned.
|
||||
*/
|
||||
export function sortOrderUpdates(calendars: Calendar[], orderedIds: string[]): { id: string; sortOrder: number }[] {
|
||||
const byId = new Map(calendars.map((calendar) => [calendar.id, calendar]));
|
||||
const updates: { id: string; sortOrder: number }[] = [];
|
||||
orderedIds.forEach((id, index) => {
|
||||
const calendar = byId.get(id);
|
||||
if (calendar && calendar.sortOrder !== index + 1) updates.push({ id, sortOrder: index + 1 });
|
||||
});
|
||||
return updates;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ids of the calendar's account in display order once it is moved next to
|
||||
* `targetId`: before the target when it moves up, after it when it moves down.
|
||||
* Null when the move is not possible (unknown ids, or another account).
|
||||
*/
|
||||
export function moveCalendar(calendars: Calendar[], calendarId: string, targetId: string): string[] | null {
|
||||
const sorted = sortCalendars(calendars);
|
||||
const moving = sorted.find((calendar) => calendar.id === calendarId);
|
||||
const target = sorted.find((calendar) => calendar.id === targetId);
|
||||
if (!moving || !target || moving === target) return null;
|
||||
const key = calendarAccountKey(moving);
|
||||
if (calendarAccountKey(target) !== key) return null;
|
||||
const group = sorted.filter((calendar) => calendarAccountKey(calendar) === key);
|
||||
const movingDown = group.indexOf(moving) < group.indexOf(target);
|
||||
const rest = group.filter((calendar) => calendar !== moving);
|
||||
rest.splice(rest.indexOf(target) + (movingDown ? 1 : 0), 0, moving);
|
||||
return rest.map((calendar) => calendar.id);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Keep a re-opened draft's attachments pointing at blobs that still exist.
|
||||
//
|
||||
// A draft's attachments and inline images are MIME parts of that draft, and a
|
||||
// part's blobId lives only as long as the message that holds it: once a save
|
||||
// creates the next version and destroys the previous one, the server forgets
|
||||
// the old part blobs (Stalwart answers `blobNotFound`, "blobId … does not
|
||||
// exist on this server"). The composer was seeded with those part blobIds, so
|
||||
// without a re-point the second save of a re-opened draft - and any send
|
||||
// after its first save - failed (webmail #849, second half).
|
||||
//
|
||||
// Upload blobs are only a reprieve: the server keeps an unreferenced upload
|
||||
// for a limited time, so every attachment is re-pointed at the parts of the
|
||||
// version each save creates.
|
||||
|
||||
import type { Attachment } from '../api/types';
|
||||
import type { OutgoingAttachment } from '../api/email';
|
||||
import { stripMessageIdBrackets } from './email-threading';
|
||||
|
||||
const normCid = (cid?: string | null) => (cid ? stripMessageIdBrackets(cid).trim().toLowerCase() : '');
|
||||
|
||||
/**
|
||||
* Pair each sent attachment with the part of the newly created version that
|
||||
* carries the same content: inline parts by Content-ID, the rest by name and
|
||||
* size. Each new part is claimed at most once. Returns sent blobId → new blobId.
|
||||
*/
|
||||
export function matchDraftParts(
|
||||
sent: readonly OutgoingAttachment[],
|
||||
fresh: readonly Pick<Attachment, 'blobId' | 'name' | 'size' | 'cid'>[],
|
||||
): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
const claimed = new Set<number>();
|
||||
const claim = (s: OutgoingAttachment, test: (p: (typeof fresh)[number]) => boolean) => {
|
||||
if (out.has(s.blobId)) return;
|
||||
const i = fresh.findIndex((p, idx) => !claimed.has(idx) && !!p.blobId && test(p));
|
||||
if (i === -1) return;
|
||||
claimed.add(i);
|
||||
out.set(s.blobId, fresh[i].blobId);
|
||||
};
|
||||
for (const s of sent) {
|
||||
const cid = normCid(s.cid);
|
||||
if (cid) claim(s, (p) => normCid(p.cid) === cid);
|
||||
}
|
||||
for (const s of sent) {
|
||||
claim(s, (p) => (p.name || '') === (s.name || '') && (s.size == null || p.size === s.size));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface DraftPartBlobState {
|
||||
/** blobId an attachment entry holds → blobId to send for it now. */
|
||||
live: Map<string, string>;
|
||||
}
|
||||
|
||||
export function initialDraftPartBlobState(): DraftPartBlobState {
|
||||
return { live: new Map() };
|
||||
}
|
||||
|
||||
/** The blobId to put on the wire for an entry that holds `blobId`. */
|
||||
export function liveBlobId(state: DraftPartBlobState, blobId: string): string {
|
||||
return state.live.get(blobId) ?? blobId;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a save: point every attachment that was sent at the matching part of
|
||||
* the version just created. Those parts live exactly as long as the saved
|
||||
* draft, so the next save or the send always references live blobs - whether
|
||||
* the entry came from the previous version (dies with it) or from an upload
|
||||
* (an unreferenced upload expires on the server). Blobs that could not be
|
||||
* matched keep their id: the next save then fails loudly (and, with
|
||||
* create-before-destroy, never loses the draft) instead of silently dropping
|
||||
* an attachment.
|
||||
*/
|
||||
export function rebaseDraftPartBlobs(
|
||||
state: DraftPartBlobState,
|
||||
sent: readonly OutgoingAttachment[],
|
||||
fresh: readonly Pick<Attachment, 'blobId' | 'name' | 'size' | 'cid'>[],
|
||||
): DraftPartBlobState {
|
||||
const pairs = matchDraftParts(sent, fresh);
|
||||
if (pairs.size === 0) return state;
|
||||
const live = new Map(state.live);
|
||||
const current = new Set(state.live.values());
|
||||
for (const [held, sentId] of state.live) {
|
||||
const next = pairs.get(sentId);
|
||||
if (next) live.set(held, next);
|
||||
}
|
||||
for (const [sentId, next] of pairs) {
|
||||
// An entry sent under its own blobId for the first time.
|
||||
if (!current.has(sentId)) live.set(sentId, next);
|
||||
}
|
||||
return { live };
|
||||
}
|
||||
|
||||
/**
|
||||
* The composer's dirty snapshot with its body replaced. Opening a draft whose
|
||||
* inline images get hydrated rewrites the editor body (normalised DOM HTML,
|
||||
* data URLs for `cid:` images); that rewrite is not an edit, so the baseline
|
||||
* has to follow it - comparing against the raw server HTML instead made every
|
||||
* re-opened draft with inline images look edited, and the autosave replaced
|
||||
* it (and destroyed the opened version) two seconds after opening.
|
||||
*/
|
||||
export function withSnapshotBody(snapshot: string, body: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(snapshot) as Record<string, unknown>;
|
||||
return JSON.stringify({ ...parsed, body });
|
||||
} catch {
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user