Compare commits

...
32 Commits
Author SHA1 Message Date
Linus Rath 1c9bbcc383 chore: update version number to 0.1.83 2026-10-19 13:16:57 +02:00
Linus Rath c7eda4735c feat: trust user-installed CAs and pick the TLS client certificate before the first sign-in #3 2026-10-19 13:16:47 +02:00
Linus Rath 5f5ffdaddb feat: mute notifications for single folders or whole accounts #78 2026-10-19 13:11:12 +02:00
Linus Rath 083ec958a0 chore: sync the locale catalogs from the webmail 2026-10-19 13:03:52 +02:00
Linus Rath 2b486cb1ef fix: show tags with no local definition in the message header, as the list does #79 2026-10-19 13:02:29 +02:00
Linus Rath 86400b124a chore: update version number to 0.1.82 2026-10-16 19:46:04 +02:00
Linus Rath 323797b4ee fix: release a PDF that finishes opening after its preview was closed 2026-10-16 19:42:04 +02:00
Linus Rath 9166b0fb3c docs: note which files the preview shows 2026-10-16 19:40:59 +02:00
Linus Rath 1f253e9488 fix: stop a PDF page rendered after its preview closed from leaving files behind 2026-10-16 19:40:59 +02:00
Linus Rath d22fb8d5d0 feat: open the files inside ZIP archives and attached messages 2026-10-16 19:39:57 +02:00
Linus Rath e0f18713aa chore: update version number to 0.1.81 2026-10-16 19:27:04 +02:00
Linus Rath 56d826c508 test: cover how the PDF preview reports protected and unreadable files 2026-10-16 19:24:19 +02:00
Linus Rath e5492b89c8 feat: show PDFs in the app on Android 2026-10-16 19:23:17 +02:00
Linus Rath b50436b1ba chore: update version number to 0.1.80 2026-10-16 19:12:43 +02:00
Linus Rath 11e74042e2 fix: hand files with a generic type to other apps by the type their name implies 2026-10-16 19:11:50 +02:00
Linus Rath 95580b84ea feat: play video and audio, and show SVG, HTML and Markdown files in the preview 2026-10-16 19:11:24 +02:00
Linus Rath 9a15334bef chore: update version number to 0.1.79 2026-10-16 18:49:43 +02:00
Linus Rath 3aca2d80c0 fix: preview .eml attachments that carry attachments of their own 2026-10-16 18:48:26 +02:00
Linus Rath 7c534301ee refactor: open files from mail, the list, Files and compose through one preview 2026-10-16 18:48:26 +02:00
Linus Rath 14dc5ff2d4 Merge pull request #76 from lucletoffe/feat/calendar-sort-order
feat(calendar): order calendars by sortOrder and move them from the drawer
2026-10-15 23:37:37 +02:00
Linus Rath 14ba28c79c Merge pull request #74 from lucletoffe/fix/compose-visual-viewport
fix(composer): stop the body's first lines hiding under To/Cc/Subject on Android
2026-10-15 23:23:40 +02:00
Linus Rath 598adc154d Merge pull request #72 from lucletoffe/fix/draft-part-blobids
fix(drafts): keep a re-opened draft's attachments on live blobs across saves and sends
2026-10-15 23:23:26 +02:00
lucletoffeandClaude Opus 5.5 3c0af52c4e feat(calendar): order calendars by sortOrder and move them from the drawer
Calendars were listed in the order the server returns them (creation
order on Stalwart) and sortOrder was never read. Sort them like the
webmail now does and as JMAP Calendars asks: lower sortOrder first,
equal sortOrder by name (case- and accent-insensitive, numbers by
value), each account's calendars kept together. The store keeps the list
sorted, so the drawer, the event editor and the other pickers agree.

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 22:07:33 +02:00
Linus Rath 7108431dbb chore: update version number to 0.1.78 2026-10-15 16:58:08 +02:00
Linus Rath 744b17908d fix: open a settings page at its top instead of the list's scroll position 2026-10-15 15:36:59 +02:00
Linus Rath c7edae9ed3 fix: move a setting's control under its label when a long label leaves no room 2026-10-15 15:29:13 +02:00
Linus Rath c07b39a070 fix: keep device sync free of expo by registering device locales from i18n #75 2026-10-15 15:19:18 +02:00
Linus Rath 7e5e296271 fix: use the app switch for the sender override in compose 2026-10-15 15:16:32 +02:00
Linus Rath ad6dff79ba fix: draw contact form fields like the webmail and quiet the letter headers in the contact list 2026-10-15 15:16:32 +02:00
Linus Rath a4ca72fb3d fix: draw event and task fields like the webmail and use the app switch in the event form 2026-10-15 15:16:32 +02:00
lucletoffeandClaude Opus 5.5 8ce53816af fix(composer): stop the body's first lines hiding under To/Cc/Subject on Android
Android's WebView shrinks the editor page's visual viewport to the part of
the view that is on screen and pans it to reveal the caret while typing.
The editor is an auto-height WebView inside the composer's ScrollView, so
nothing ever resets that pan: scrolled back to the top, the body is still
drawn shifted up by the pan offset and its first lines sit hidden under the
header fields until a scroll to the very bottom clamps it away.

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 08:47:21 +02:00
128 changed files with 123182 additions and 92038 deletions
+38
View File
@@ -39,6 +39,9 @@ import {
type NotificationTapPayload,
} from './src/lib/push-notifications';
import { addUnifiedPushEndpointListener } from './src/lib/unified-push';
// Pause between a mute change and the push-filter update it sends (#78).
const MUTES_RESYNC_DELAY_MS = 1_000;
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
import ComposeScreen from './src/screens/ComposeScreen';
import EmailThreadScreen from './src/screens/EmailThreadScreen';
@@ -684,6 +687,41 @@ export default function App() {
};
}, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]);
// Muting a folder or account changes the server-side push filter (#78),
// which the setup above would only send at the next launch. Send it now,
// after a short pause so a few taps in a row make one update. Compared by
// content, since hydration hands back an equal but new object; the value
// seen first after hydration is the baseline, not a change.
const notificationMutes = useSettingsStore((s) => s.notificationMutes);
const settingsHydrated = useSettingsStore((s) => s.hydrated);
const mutesKey = JSON.stringify(notificationMutes);
const lastMutesKey = React.useRef<string | null>(null);
React.useEffect(() => {
if (!settingsHydrated) return;
if (lastMutesKey.current === null || lastMutesKey.current === mutesKey) {
lastMutesKey.current = mutesKey;
return;
}
lastMutesKey.current = mutesKey;
if (!isAuthenticated || !client || !emailNotificationsEnabled) return;
let cancelled = false;
const timer = setTimeout(async () => {
const relayBaseUrl = await getStoredRelayBaseUrl();
if (!relayBaseUrl || cancelled) return;
try {
await resyncPushNotifications({ relayBaseUrl, accountLabel: client.username ?? undefined });
} catch (error) {
console.warn('[push] mute update failed:', error instanceof Error ? error.message : error);
}
}, MUTES_RESYNC_DELAY_MS);
return () => {
cancelled = true;
clearTimeout(timer);
};
// Only a change of the mutes should send an update; the rest is read live.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mutesKey, settingsHydrated]);
// Live updates (SSE with polling fallback), re-armed on every account
// switch and every re-established session — the singleton `client` object
// never changes identity, so it cannot be the dependency on its own.
+1 -1
View File
@@ -1 +1 @@
0.1.77
0.1.83
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Debug builds load the bundle from Metro over http, which the main config
forbids (a network security config replaces the manifest's
usesCleartextTraffic). Same trust anchors as the release config.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>
+1 -1
View File
@@ -21,7 +21,7 @@
<data android:scheme="https"/>
</intent>
</queries>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules">
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules" android:networkSecurityConfig="@xml/network_security_config">
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
@@ -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>
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+953 -92
View File
File diff suppressed because it is too large Load Diff
+951 -90
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4507 -3646
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+9 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -284,7 +284,6 @@
"attach_camera": "Camera",
"attachment_reminder_body": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
"attachment_reminder_title": "Forgot an attachment?",
"attachment_too_large": "This file is larger than the server allows ({size} > {max}).",
"attachments_too_large_total": "The attachments would exceed the {max} this server allows per message.",
"editor_unavailable": "Could not read the message content. Copy your text, then close and reopen the composer.",
"format_plain_text": "Switch to plain text",
@@ -297,6 +296,7 @@
"no_sent_folder": "No Sent folder found",
"permission_camera": "Camera permission is required to take a photo.",
"permission_photos": "Photo library permission is required to attach images.",
"read_receipt_off": "Request a read receipt",
"recipient_add_contact": "Add to contacts",
"recipient_copy": "Copy address",
"recipient_move_to": "Move to {field}",
@@ -322,15 +322,14 @@
"send_timeout_body": "The message may already have gone out. Check your Sent folder before sending it again.",
"send_timeout_title": "No answer from the server",
"toolbar": {
"background_color": "Background color",
"insert_image": "Insert image",
"remove_background_color": "Remove background color",
"remove_link": "Remove link"
},
"undo_send_label": "Sending in {seconds}s…",
"upload_failed_short": "Upload failed",
"uploading": "Uploading...",
"uploading_pct": "Uploading {pct}%",
"use_template": "Template",
"validation": {
"subject_required": "Add a subject"
}
@@ -422,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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+4506 -3645
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.77",
"version": "0.1.83",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.77",
"version": "0.1.83",
"hasInstallScript": true,
"license": "AGPL-3.0-only",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.77",
"version": "0.1.83",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
+82
View File
@@ -0,0 +1,82 @@
diff --git a/node_modules/postal-mime/src/base64-decoder.js b/node_modules/postal-mime/src/base64-decoder.js
index 93c2aea..ba6e3f1 100644
--- a/node_modules/postal-mime/src/base64-decoder.js
+++ b/node_modules/postal-mime/src/base64-decoder.js
@@ -1,4 +1,4 @@
-import { decodeBase64, blobToArrayBuffer } from './decode-strings.js';
+import { decodeBase64, concatChunks } from './decode-strings.js';
export default class Base64Decoder {
constructor(opts) {
@@ -49,6 +49,6 @@ export default class Base64Decoder {
finalize() {
this.flushRemainder();
- return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
+ return concatChunks(this.chunks);
}
}
diff --git a/node_modules/postal-mime/src/decode-strings.js b/node_modules/postal-mime/src/decode-strings.js
index a5e058e..f255e8d 100644
--- a/node_modules/postal-mime/src/decode-strings.js
+++ b/node_modules/postal-mime/src/decode-strings.js
@@ -145,6 +145,23 @@ export function getDecoder(charset) {
* @param {Blob} blob Blob to convert
* @returns {ArrayBuffer} Converted value
*/
+// React Native's Blob cannot be built from ArrayBuffers or typed arrays, so
+// the decoders join their chunks here instead of through a Blob.
+export function concatChunks(chunks) {
+ const parts = chunks.map(chunk => {
+ if (typeof chunk === 'string') return textEncoder.encode(chunk);
+ if (chunk instanceof ArrayBuffer) return new Uint8Array(chunk);
+ return new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
+ });
+ const out = new Uint8Array(parts.reduce((n, part) => n + part.byteLength, 0));
+ let pos = 0;
+ for (const part of parts) {
+ out.set(part, pos);
+ pos += part.byteLength;
+ }
+ return out.buffer;
+}
+
export async function blobToArrayBuffer(blob) {
if ('arrayBuffer' in blob) {
return await blob.arrayBuffer();
diff --git a/node_modules/postal-mime/src/pass-through-decoder.js b/node_modules/postal-mime/src/pass-through-decoder.js
index ad1c222..1e1d012 100644
--- a/node_modules/postal-mime/src/pass-through-decoder.js
+++ b/node_modules/postal-mime/src/pass-through-decoder.js
@@ -1,4 +1,4 @@
-import { blobToArrayBuffer } from './decode-strings.js';
+import { concatChunks } from './decode-strings.js';
export default class PassThroughDecoder {
constructor() {
@@ -12,6 +12,6 @@ export default class PassThroughDecoder {
finalize() {
// convert an array of arraybuffers into a blob and then back into a single arraybuffer
- return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
+ return concatChunks(this.chunks);
}
}
diff --git a/node_modules/postal-mime/src/qp-decoder.js b/node_modules/postal-mime/src/qp-decoder.js
index adee72c..3973673 100644
--- a/node_modules/postal-mime/src/qp-decoder.js
+++ b/node_modules/postal-mime/src/qp-decoder.js
@@ -1,4 +1,4 @@
-import { blobToArrayBuffer, hexNibble } from './decode-strings.js';
+import { concatChunks, hexNibble } from './decode-strings.js';
const CHR_EQUALS = 0x3d;
const CHR_LF = 0x0a;
@@ -83,6 +83,6 @@ export default class QPDecoder {
this.flushBuffer();
// convert an array of arraybuffers into a blob and then back into a single arraybuffer
- return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
+ return concatChunks(this.chunks);
}
}
+1
View File
@@ -37,6 +37,7 @@ const MAP = {
ArrowUpNarrowWide: 'sort-ascending',
Ban: 'ban',
Bell: 'bell',
BellOff: 'bell-off',
BellRing: 'bell-ringing',
Bold: 'bold',
Book: 'book-2',
+138
View File
@@ -0,0 +1,138 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
request: vi.fn(),
getMaxCallsInRequest: vi.fn(() => 16),
getMaxObjectsInGet: vi.fn(() => 500),
getMaxObjectsInSet: vi.fn(() => 500),
},
}));
import { jmapClient } from '../jmap-client';
import { saveDraftVersion, type OutgoingAttachment, type OutgoingEmail } from '../email';
import { initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs } from '../../lib/draft-part-blobs';
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
/**
* A tiny server with Stalwart's blob semantics, as measured on 0.16.22 (2026-10-14):
* an email's part blobIds are derived from that email and stop resolving the
* moment it is destroyed ("blobId … does not exist on this server"); creating
* an email copies the referenced bytes into new parts with new blobIds.
*/
function fakeStalwart() {
let seq = 0;
const emails = new Map<string, Array<{ blobId: string; name: string; size: number; cid?: string; type: string }>>();
const live = (blobId: string) => [...emails.values()].some((parts) => parts.some((p) => p.blobId === blobId));
const seed = (id: string, parts: OutgoingAttachment[]) => {
emails.set(id, parts.map((p) => ({ blobId: p.blobId, name: p.name, size: p.size ?? 0, cid: p.cid, type: p.type })));
};
mockRequest.mockImplementation(async (calls: Array<[string, Record<string, any>, string]>) => {
const out: Array<[string, unknown, string]> = [];
let createdId: string | undefined;
for (const [method, args, callId] of calls) {
if (method === 'Email/set' && args.create) {
const create = args.create.draft;
const dead = (create.attachments ?? []).find((a: { blobId: string }) => !live(a.blobId));
if (dead) {
out.push(['Email/set', { notCreated: { draft: {
type: 'blobNotFound', description: `blobId ${dead.blobId} does not exist on this server.`,
} } }, callId]);
continue;
}
createdId = `e${++seq}`;
emails.set(createdId, (create.attachments ?? []).map((a: OutgoingAttachment, i: number) => ({
blobId: `${createdId}-part${i}`, name: a.name, size: a.size ?? 0, cid: a.cid ? `<${a.cid}>` : undefined, type: a.type,
})));
out.push(['Email/set', { created: { draft: { id: createdId } } }, callId]);
} else if (method === 'Email/set' && args.destroy) {
for (const id of args.destroy) emails.delete(id);
out.push(['Email/set', { destroyed: args.destroy }, callId]);
} else if (method === 'Email/get') {
if (!createdId || args.ids?.[0] !== '#draft') {
out.push(['error', { type: 'invalidResultReference' }, callId]);
continue;
}
out.push(['Email/get', { list: [{ id: createdId, attachments: emails.get(createdId) }] }, callId]);
}
}
return { methodResponses: out };
});
return { seed, emails };
}
const IMG_A: OutgoingAttachment = { blobId: 'd0-part-a', name: 'schema-a.png', size: 136574, type: 'image/png', disposition: 'inline', cid: 'a@undust.co' };
const IMG_B: OutgoingAttachment = { blobId: 'd0-part-b', name: 'schema-b.png', size: 175155, type: 'image/png', disposition: 'inline', cid: 'b@undust.co' };
const draftWith = (attachments: OutgoingAttachment[]): OutgoingEmail => ({
from: [{ email: 'me@undust.co' }],
to: [{ email: 'test@example.invalid' }],
subject: 'RE: draft with two inline images',
htmlBody: '<p>x <img src="cid:a@undust.co"> <img src="cid:b@undust.co"></p>',
textBody: 'x',
attachments,
});
beforeEach(() => {
vi.clearAllMocks();
});
describe('saveDraftVersion', () => {
it('reads the new version\'s parts back in the same request, by creation id', async () => {
fakeStalwart().seed('d0', [IMG_A]);
const saved = await saveDraftVersion(draftWith([IMG_A]), 'drafts', 'd0');
const calls = mockRequest.mock.calls[0][0];
expect(calls.map((c: [string]) => c[0])).toEqual(['Email/set', 'Email/get']);
expect(calls[1][1].ids).toEqual(['#draft']);
expect(saved.id).toBe('e1');
expect(saved.attachments.map((a) => a.blobId)).toEqual(['e1-part0']);
});
it('destroys the previous version only after the new one exists', async () => {
fakeStalwart().seed('d0', [IMG_A]);
await saveDraftVersion(draftWith([IMG_A]), 'drafts', 'd0');
const destroyCall = mockRequest.mock.calls[1][0][0];
expect(destroyCall[0]).toBe('Email/set');
expect(destroyCall[1].destroy).toEqual(['d0']);
});
it('still returns the id when the read-back fails', async () => {
mockRequest.mockResolvedValueOnce({ methodResponses: [
['Email/set', { created: { draft: { id: 'e9' } } }, '0'],
['error', { type: 'serverFail' }, '1'],
] });
const saved = await saveDraftVersion(draftWith([]), 'drafts');
expect(saved).toEqual({ id: 'e9', attachments: [] });
});
});
describe('re-opened draft with inline images: save, save again, send', () => {
// What ComposeScreen does: buildOutgoing maps each held blobId through the
// state, saveDraftOnce rebases it on the parts read back.
async function editSession(rebase: boolean) {
const server = fakeStalwart();
server.seed('d0', [IMG_A, IMG_B]); // the draft as deposited (IMAP APPEND or an earlier save)
let state = initialDraftPartBlobState();
let draftId = 'd0';
const outgoing = () => draftWith([IMG_A, IMG_B].map((a) => ({ ...a, blobId: liveBlobId(state, a.blobId) })));
for (let i = 0; i < 2; i++) {
const sent = outgoing();
const saved = await saveDraftVersion(sent, 'drafts', draftId);
draftId = saved.id;
if (rebase) state = rebaseDraftPartBlobs(state, sent.attachments ?? [], saved.attachments);
}
return { server, draftId };
}
it('reproduces the bug without the rebase: the second save references the destroyed draft\'s parts', async () => {
await expect(editSession(false)).rejects.toThrow('blobId d0-part-a does not exist on this server.');
});
it('keeps every save (and so the send, which builds the same attachments) on live blobs', async () => {
const { server, draftId } = await editSession(true);
expect([...server.emails.keys()]).toEqual([draftId]);
expect(server.emails.get(draftId)!.map((p) => p.name)).toEqual(['schema-a.png', 'schema-b.png']);
});
});
+43
View File
@@ -22,6 +22,8 @@ import {
getFullEmails,
getThreadsHeaders,
createDraft,
saveDraftVersion,
destroyEmails,
sendEmail,
markAsSpam,
undoSpam,
@@ -33,6 +35,9 @@ import {
EMAIL_FULL_PROPERTIES,
} from '../email';
import { getIdentities } from '../identity';
import { uploadBytes } from '../blob';
import { initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs } from '../../lib/draft-part-blobs';
import type { OutgoingAttachment } from '../email';
import { getCalendars, queryEvents, createEvent, deleteEvents } from '../calendar';
import { getAddressBooks, createContact, deleteContacts } from '../contacts';
@@ -192,6 +197,44 @@ live('live Stalwart', () => {
await destroyEmails([id]);
}, 60_000);
it('keeps a re-opened draft with an inline image saveable across saves (#849 part blobs)', async () => {
// A 1x1 PNG uploaded once; D0 stands for the draft as it sits on the server.
const png = Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='), (c) => c.charCodeAt(0));
const up = await uploadBytes(png, 'image/png');
const base = {
from: [{ email: USER }],
to: [{ email: 'test@example.invalid' }],
subject: '[TEST bulwark-blob — supprimable]',
htmlBody: '<p>live <img src="cid:img1@live.test"></p>',
textBody: 'live',
};
const img = (blobId: string): OutgoingAttachment => ({
blobId, type: 'image/png', name: 'img1.png', size: png.byteLength, disposition: 'inline', cid: 'img1@live.test',
});
const d0 = await saveDraftVersion({ ...base, attachments: [img(up.blobId)] }, drafts);
const created = [d0.id];
try {
// Re-open D0: the composer holds D0's part blobId.
const seeded = d0.attachments[0].blobId;
let state = initialDraftPartBlobState();
let draftId = d0.id;
for (let i = 0; i < 2; i++) {
const sent = [img(liveBlobId(state, seeded))];
const saved = await saveDraftVersion({ ...base, attachments: sent }, drafts, draftId);
created.push(saved.id);
draftId = saved.id;
state = rebaseDraftPartBlobs(state, sent, saved.attachments);
}
// Without the rebase the seeded part blob is gone with D0.
await expect(saveDraftVersion({ ...base, attachments: [img(seeded)] }, drafts))
.rejects.toThrow(/does not exist|blobNotFound/);
const [last] = await getFullEmails([draftId]);
expect(last.attachments?.[0]?.cid).toContain('img1@live.test');
} finally {
await destroyEmails(created).catch(() => undefined);
}
}, 90_000);
it('sends through Drafts and files into Sent only after submission (#188)', async () => {
const identities = await getIdentities();
expect(identities.length).toBeGreaterThan(0);
+48 -5
View File
@@ -1679,31 +1679,64 @@ export async function sendEmail(
};
}
/** A saved draft version and the attachment parts the server built for it. */
export interface SavedDraftVersion {
id: string;
/**
* Attachment and inline parts of the new version, with their blobIds. A
* part's blobId only lives as long as this version: re-opened drafts must
* send these (not the previous version's) on their next save or send.
* Empty when the server did not answer the read-back.
*/
attachments: Attachment[];
}
const DRAFT_PART_BODY_PROPERTIES = ['partId', 'blobId', 'size', 'name', 'type', 'cid', 'disposition'];
/**
* Save a draft into the Drafts folder (`$draft` + `$seen`). When `previousDraftId`
* is given it is destroyed only AFTER the replacement was created (#849): a
* combined create+destroy would delete the last good copy when the create
* fails (e.g. blobNotFound on a re-opened draft's attachments). Returns the
* new draft id.
* fails (e.g. blobNotFound on a re-opened draft's attachments). The new
* version's attachment parts are read back in the same request, so the
* composer can re-point its draft-part blobIds before the old version (and
* its part blobs) is destroyed.
*/
export async function createDraft(
export async function saveDraftVersion(
email: OutgoingEmail,
draftsMailboxId: string,
previousDraftId?: string,
accountIdOverride?: string,
): Promise<string> {
): Promise<SavedDraftVersion> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const emailCreate = buildEmailCreate(email);
emailCreate.mailboxIds = { [draftsMailboxId]: true };
emailCreate.keywords = { $seen: true, $draft: true };
const res = await jmapClient.request([
['Email/set', { accountId, create: { draft: emailCreate } }, '0'],
// `#draft` is the creation id (RFC 8620 §5.3). Stalwart 0.16 does not
// resolve a result reference into an Email/set `created` map
// (invalidResultReference), but it does resolve the creation id.
['Email/get', {
accountId,
ids: ['#draft'],
properties: ['id', 'attachments'],
bodyProperties: DRAFT_PART_BODY_PROPERTIES,
}, '1'],
]);
const body = requireMethodResult(res, '0', 'Email/set');
const notCreated = body.notCreated?.draft as { description?: string; type?: string } | undefined;
if (notCreated) throw new Error(notCreated.description || notCreated.type || 'Failed to save draft');
const id = body.created?.draft?.id as string | undefined;
if (!id) throw new Error('Draft save returned no id');
let attachments: Attachment[] = [];
try {
const got = requireMethodResult(res, '1', 'Email/get');
const saved = (got.list as Array<{ id: string; attachments?: Attachment[] }> | undefined)?.find((e) => e.id === id);
attachments = (saved?.attachments ?? []).filter((a) => !!a.blobId);
} catch (err) {
console.warn('[email] could not read back the saved draft parts:', err);
}
if (previousDraftId && previousDraftId !== id) {
try {
await destroyEmails([previousDraftId], accountId);
@@ -1711,7 +1744,17 @@ export async function createDraft(
console.warn('[email] failed to destroy previous draft version:', err);
}
}
return id;
return { id, attachments };
}
/** {@link saveDraftVersion} for callers that only need the new draft id. */
export async function createDraft(
email: OutgoingEmail,
draftsMailboxId: string,
previousDraftId?: string,
accountIdOverride?: string,
): Promise<string> {
return (await saveDraftVersion(email, draftsMailboxId, previousDraftId, accountIdOverride)).id;
}
export interface ScheduledEmail {
+17 -2
View File
@@ -78,10 +78,16 @@ interface Props {
onSelectionChange?: (state: RichTextSelectionState) => void;
onFocus?: () => void;
onBlur?: () => void;
/**
* Android: the page undid a caret-reveal pan of `dy` px (dp) inside the
* WebView. The host should scroll its ScrollView down by `dy` so the caret
* stays visible (see `pinVisualViewport` in editor-html).
*/
onViewportShift?: (dy: number) => void;
}
const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function RichTextEditor(
{ initialHtml = '', placeholder = '', blockRemoteImages = false, onChange, onSelectionChange, onFocus, onBlur },
{ initialHtml = '', placeholder = '', blockRemoteImages = false, onChange, onSelectionChange, onFocus, onBlur, onViewportShift },
ref,
) {
const c = useColors();
@@ -92,6 +98,8 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
const onSelectionRef = React.useRef(onSelectionChange);
const onFocusRef = React.useRef(onFocus);
const onBlurRef = React.useRef(onBlur);
const onViewportShiftRef = React.useRef(onViewportShift);
onViewportShiftRef.current = onViewportShift;
onChangeRef.current = onChange;
onSelectionRef.current = onSelectionChange;
onFocusRef.current = onFocus;
@@ -100,7 +108,9 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
// Build the source HTML once. Theming changes won't auto-rebuild the page,
// which is fine: themes change rarely and the user can recompose if needed.
const html = React.useMemo(
() => buildEditorHtml({ initialHtml, placeholder, c, blockRemoteImages }),
() => buildEditorHtml({
initialHtml, placeholder, c, blockRemoteImages, pinVisualViewport: Platform.OS === 'android',
}),
// c reference changes per theme switch but we don't want to lose user
// edits on a theme change. Bake in the initial colors only.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -224,6 +234,11 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
case 'blur':
onBlurRef.current?.();
break;
case 'viewportShift': {
const dy = typeof data.payload === 'number' ? data.payload : Number(data.payload);
if (Number.isFinite(dy) && dy !== 0) onViewportShiftRef.current?.(dy);
break;
}
}
};
+76 -3
View File
@@ -9,7 +9,7 @@ import {
Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Star,
Folder, FolderOpen, ChevronDown, ChevronRight, X, Settings, LogOut, Check, Plus,
Clock, Layers, Users, Tag, Mails, MailOpen, StickyNote, AlarmClock, Flag,
CheckCheck, Eraser, FolderPlus, Pencil, AlertTriangle, UserMinus,
CheckCheck, Eraser, FolderPlus, Pencil, AlertTriangle, UserMinus, Bell, BellOff, RotateCcw,
type AppIcon,
} from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
@@ -40,6 +40,11 @@ import type { Mailbox } from '../api/types';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { confirmSignOut } from '../lib/sign-out-confirm';
import { setPendingSettingsTab } from '../navigation/pending-settings-tab';
import {
isAccountMuted, isMailboxMuted, mailboxOverridesForAccount, resetMailboxMutes, setAccountMuted, setMailboxMuted,
type NotificationMutes,
} from '../lib/notification-mutes';
import { serverSupportsEmailPush } from '../lib/push-notifications';
const CHEVRON_SLOT = 20;
const INDENT_STEP = 12;
@@ -157,6 +162,8 @@ interface SidebarRowProps {
showTotal: boolean;
hasChildren: boolean;
isExpanded: boolean;
/** New mail here does not notify (#78). */
muted?: boolean;
onPress: () => void;
onLongPress?: () => void;
onToggleExpand: () => void;
@@ -165,7 +172,7 @@ interface SidebarRowProps {
function SidebarRow({
icon, label, depth, isSelected, isAccountHeader, unread, total, showTotal,
hasChildren, isExpanded, onPress, onLongPress, onToggleExpand, onPressUnread,
hasChildren, isExpanded, muted, onPress, onLongPress, onToggleExpand, onPressUnread,
}: SidebarRowProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -215,6 +222,11 @@ function SidebarRow({
>
{label}
</Text>
{muted && (
<View style={styles.rowMuted} accessibilityLabel={t('sidebar.notifications_muted', 'Notifications muted')}>
<BellOff size={12} color={c.textMuted} />
</View>
)}
<RowCounts unread={unread} total={total} showTotal={showTotal} onPressUnread={onPressUnread} />
</Pressable>
);
@@ -334,6 +346,8 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
const showFolderTotalCount = useSettingsStore((s) => s.showFolderTotalCount);
const includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified);
const unifiedCrossAccount = useSettingsStore((s) => s.unifiedCrossAccount);
const notificationMutes = useSettingsStore((s) => s.notificationMutes);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const keywordDefs = useKeywordsStore((s) => s.keywords);
const keywordsHydrated = useKeywordsStore((s) => s.hydrated);
const hydrateKeywords = useKeywordsStore((s) => s.hydrate);
@@ -459,6 +473,41 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
id: mb.originalId ?? mb.id,
accountId: mb.isShared ? mb.accountId : undefined,
});
// Notification mutes (#78). Muting only takes effect through the server's
// emailPush filter, so the actions show only where that exists. The
// settings are keyed by the server's ids: a folder of the own account may
// carry no accountId, so the client's stands in.
let primaryJmapAccountId: string | null = null;
try { primaryJmapAccountId = jmapClient.accountId; } catch { primaryJmapAccountId = null; }
const canMute = primaryJmapAccountId !== null && serverSupportsEmailPush();
const muteAccountOf = (accountId?: string | null): string => accountId || primaryJmapAccountId || '';
const saveMutes = (next: NotificationMutes) => updateSetting('notificationMutes', next);
const muteActionsForAccount = (accountId: string): SheetAction[] => {
const muted = isAccountMuted(notificationMutes, accountId);
const overrides = mailboxOverridesForAccount(notificationMutes, accountId);
const actions: SheetAction[] = [{
key: 'mute',
label: muted
? t('mailbox_context_menu.unmute_account_notifications', 'Unmute notifications for this account')
: t('mailbox_context_menu.mute_account_notifications', 'Mute notifications for this account'),
icon: muted ? Bell : BellOff,
onPress: () => saveMutes(setAccountMuted(notificationMutes, accountId, !muted)),
}];
if (overrides.muted.length + overrides.unmuted.length > 0) {
actions.push({
key: 'reset-mutes',
label: t('mailbox_context_menu.reset_folder_notifications', 'Reset folder notification settings'),
icon: RotateCcw,
onPress: () => saveMutes(resetMailboxMutes(notificationMutes, accountId)),
});
}
return actions;
};
const openOwnAccountSheet = () => {
if (!canMute) return;
setSheet({ title: username ?? t('sidebar.folders', 'Folders'), actions: muteActionsForAccount(muteAccountOf()) });
};
const runFolderAction = async (label: string, work: () => Promise<unknown>, affectsCurrent: boolean) => {
setBusy(true);
try {
@@ -499,6 +548,18 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
if (subtree.length > 1) {
actions.push({ key: 'tree', label: t('mailbox_context_menu.mark_folder_tree_read', 'Mark folder & subfolders as read'), icon: CheckCheck, onPress: () => markRead(subtree) });
}
if (canMute) {
const accountId = muteAccountOf(mb.accountId);
const muted = isMailboxMuted(notificationMutes, accountId, ref.id);
actions.push({
key: 'mute',
label: muted
? t('mailbox_context_menu.unmute_notifications', 'Unmute notifications')
: t('mailbox_context_menu.mute_notifications', 'Mute notifications'),
icon: muted ? Bell : BellOff,
onPress: () => saveMutes(setMailboxMuted(notificationMutes, accountId, ref.id, !muted)),
});
}
if (!mb.isShared) {
actions.push({
key: 'all',
@@ -622,6 +683,7 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
onPress: () => markRead(mailboxes.filter((m) => m.isShared && m.accountId === accountId).map((m) => m.id)),
},
];
if (canMute) actions.push(...muteActionsForAccount(accountId));
if (canCreate) {
actions.push({
key: 'new',
@@ -1036,7 +1098,14 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
)}
{/* Folders section */}
<Pressable style={styles.sectionHeader} onPress={() => toggleSection('foldersExpanded', setFoldersExpanded)}>
<Pressable
style={styles.sectionHeader}
onPress={() => toggleSection('foldersExpanded', setFoldersExpanded)}
// The own account's notification mutes (#78), like the webmail's
// menu on this header.
onLongPress={canMute ? openOwnAccountSheet : undefined}
delayLongPress={350}
>
{foldersExpanded ? (
<ChevronDown size={14} color={c.textMuted} />
) : (
@@ -1090,6 +1159,9 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
unread={node.unreadEmails}
total={node.totalEmails}
showTotal={showFolderTotalCount && !node.isAccountNode}
muted={canMute && (node.isAccountNode
? isAccountMuted(notificationMutes, muteAccountOf(node.accountId))
: isMailboxMuted(notificationMutes, muteAccountOf(node.accountId), node.originalId ?? node.id))}
hasChildren={hasChildren}
isExpanded={isExpanded}
onPress={
@@ -1458,6 +1530,7 @@ function makeStyles(c: ThemePalette) {
...typography.bodySemibold,
color: c.text,
},
rowMuted: { marginLeft: spacing.xs },
rowLabelAccount: {
fontSize: 13,
lineHeight: 18,
@@ -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); }}
+15 -19
View File
@@ -7,7 +7,6 @@ import {
TextInput,
Pressable,
StyleSheet,
Switch,
Platform,
Alert as NativeAlert,
} from 'react-native';
@@ -70,6 +69,7 @@ import { ParticipantInput } from './ParticipantInput';
import { useContactNameResolver } from '../../lib/contact-name-resolver';
import { RecurrenceEditor } from './RecurrenceEditor';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import ToggleSwitch from '../ToggleSwitch';
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
@@ -459,7 +459,7 @@ export function EventModal({
value={title}
onChangeText={setTitle}
placeholder={t('calendar.form.title', 'Title')}
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
style={styles.titleInput}
/>
@@ -515,12 +515,10 @@ export function EventModal({
<Section icon={<Clock size={16} color={c.textMuted} />} label={t('calendar.form.time', 'Time')}>
<View style={styles.allDayRow}>
<Text style={styles.fieldText}>{t('calendar.form.all_day_event', 'All-day event')}</Text>
<Switch
<ToggleSwitch
value={allDay}
onValueChange={setAllDay}
accessibilityLabel={t('calendar.form.all_day_event', 'All-day event')}
thumbColor={allDay ? c.primary : c.textMuted}
trackColor={{ false: c.surface, true: c.primaryBg }}
/>
</View>
@@ -673,7 +671,7 @@ export function EventModal({
keyboardType="number-pad"
style={styles.customAmountInput}
placeholder="30"
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
/>
<View style={styles.customUnits}>
{(['minutes', 'hours', 'days', 'weeks'] as ReminderUnit[]).map((unit) => (
@@ -708,7 +706,7 @@ export function EventModal({
value={location}
onChangeText={setLocation}
placeholder={t('calendar.form.location_placeholder', 'Place or address')}
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
style={styles.fieldButton}
/>
</Section>
@@ -718,7 +716,7 @@ export function EventModal({
value={videoUrl}
onChangeText={setVideoUrl}
placeholder="https://meet.example.com/…"
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
@@ -734,7 +732,7 @@ export function EventModal({
value={description}
onChangeText={setDescription}
placeholder={t('calendar.form.description_placeholder', 'Notes, agenda…')}
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
multiline
style={styles.descriptionInput}
/>
@@ -756,12 +754,10 @@ export function EventModal({
<Text style={[styles.fieldText, { flex: 1 }]}>
{t('calendar.participants.send_invitations', 'Send invitations to participants')}
</Text>
<Switch
<ToggleSwitch
value={sendInvitations}
onValueChange={setSendInvitations}
accessibilityLabel={t('calendar.participants.send_invitations', 'Send invitations to participants')}
thumbColor={sendInvitations ? c.primary : c.textMuted}
trackColor={{ false: c.surface, true: c.primaryBg }}
/>
</View>
)}
@@ -913,7 +909,7 @@ function makeStyles(c: ThemePalette) {
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
backgroundColor: c.background,
justifyContent: 'center',
},
fieldText: { ...typography.body, color: c.text },
@@ -930,7 +926,7 @@ function makeStyles(c: ThemePalette) {
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
backgroundColor: c.background,
},
reminderRemove: { padding: 2 },
customReminderRow: {
@@ -939,7 +935,7 @@ function makeStyles(c: ThemePalette) {
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: c.surface,
backgroundColor: c.background,
},
customAmountInput: {
...typography.body,
@@ -975,7 +971,7 @@ function makeStyles(c: ThemePalette) {
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: c.surface,
backgroundColor: c.popover,
overflow: 'hidden',
},
popoverRow: {
@@ -997,7 +993,7 @@ function makeStyles(c: ThemePalette) {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.sm,
backgroundColor: c.surface,
backgroundColor: c.background,
borderWidth: 1,
borderColor: c.border,
},
@@ -1009,7 +1005,7 @@ function makeStyles(c: ThemePalette) {
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
backgroundColor: c.background,
},
dateLabel: { ...typography.body, color: c.textSecondary, flex: 1 },
datePickers: { flexDirection: 'row', gap: spacing.sm },
@@ -1030,7 +1026,7 @@ function makeStyles(c: ThemePalette) {
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.surface,
backgroundColor: c.background,
color: c.text,
...typography.body,
textAlignVertical: 'top',
+2 -2
View File
@@ -530,7 +530,7 @@ function makeStyles(c: ThemePalette) {
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: c.surface,
backgroundColor: c.background,
},
addBtn: {
width: 38,
@@ -552,7 +552,7 @@ function makeStyles(c: ThemePalette) {
borderRadius: radius.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: c.surface,
backgroundColor: c.background,
textAlignVertical: 'top',
},
editorRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flexWrap: 'wrap' },
+1 -1
View File
@@ -136,6 +136,6 @@ function makeStyles(c: ThemePalette) {
info: { flex: 1, minWidth: 0 },
name: { ...typography.bodyMedium, color: c.text },
subtitle: { ...typography.caption, color: c.textMuted, marginTop: 1 },
tertiary: { ...typography.small, color: c.textSecondary, marginTop: 1 },
tertiary: { ...typography.caption, color: c.mutedForeground, marginTop: 1 },
});
}
+29 -96
View File
@@ -1,23 +1,22 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, ActivityIndicator, Alert } from 'react-native';
import { Paperclip, Download, Eye, ExternalLink, Save, Share2, FolderArchive } from '../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>
);
}
+247 -36
View File
@@ -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 },
+21 -62
View File
@@ -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} />;
},
);
+3 -5
View File
@@ -9,7 +9,7 @@ import { useColors } from '../../theme/colors';
import SenderAvatar from '../SenderAvatar';
import { useSettingsStore } from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store';
import { useKeywordsStore, keywordToken } from '../../stores/keywords-store';
import { useKeywordsStore } from '../../stores/keywords-store';
import { emailDisplayDate, formatHeaderDate, formatHeaderTime, formatFullDateTime } from '../../lib/email-date';
import {
deriveHeaderInfo, deliveryDeltaMs, formatDelta, isAuthenticationSpoofed, findReceivingIdentity,
@@ -17,6 +17,7 @@ import {
} from '../../lib/email-headers';
import { formatSize } from '../../lib/attachment-display';
import { isSmimeEmail } from '../../lib/smime';
import { resolveEmailTags } from '../../lib/thread-utils';
interface Props {
email: Email;
@@ -121,10 +122,7 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
return null;
}, [spoofed, identities, from?.email, email]);
const tags = React.useMemo(
() => keywordDefs.filter((kw) => !!email.keywords?.[keywordToken(kw.id)]),
[keywordDefs, email.keywords],
);
const tags = React.useMemo(() => resolveEmailTags(email.keywords, keywordDefs), [keywordDefs, email.keywords]);
const important = !!email.keywords?.$important;
const answered = !!email.keywords?.$answered;
const forwarded = !!email.keywords?.$forwarded;
+152
View File
@@ -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,
},
});
}
+4 -2
View File
@@ -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 {
+276
View File
@@ -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 };
}
+12 -116
View File
@@ -1,24 +1,13 @@
import React from 'react';
import { Alert, Platform } from 'react-native';
import type { File } from 'expo-file-system';
import type { FileNode } from '../../api/types';
import {
cachePreviewFile, discardPreviewFile, saveLocalFileCopy, shareLocalFile,
} from '../../lib/email-export';
import {
exceedsPreviewLimit, inAppPreviewKind, type FilePreviewOptions,
} from '../../lib/file-preview';
import { useLocaleStore } from '../../stores/locale-store';
import { AttachmentPreviewModal, type PreviewItem } from '../email/AttachmentPreviewModal';
import { emlPreviewFromBytes } from '../email/use-body-override';
const PREVIEW_OPTIONS: FilePreviewOptions = { inlinePdf: Platform.OS === 'ios' };
import { AttachmentPreviewModal } from '../email/AttachmentPreviewModal';
import { previewKindOf, useFilePreview } from '../email/use-file-preview';
export type PreviewableFileNode = Pick<FileNode, 'name' | 'type' | 'size' | 'blobId' | 'accountId'>;
/** Whether a tap on this file opens the in-app preview (else the OS chooser, as before). */
export function canPreviewInApp(node: PreviewableFileNode): boolean {
return !!node.blobId && inAppPreviewKind(node, PREVIEW_OPTIONS) !== 'none';
return !!node.blobId && previewKindOf({ name: node.name, type: node.type || '', size: node.size }) !== 'none';
}
interface Props {
@@ -30,22 +19,13 @@ interface Props {
}
/**
* The Files tab's in-app preview: the reader's attachment preview fed from a
* FileNode. The file is downloaded once, with the same authenticated
* download as "Download" (token refresh, client certificate), into its own
* temp folder; Open with, Share and Download then all use that copy instead
* of fetching it again. The copy is deleted when the preview closes, unless
* another app got it, in which case the stale-export sweep removes it.
* The Files tab's in-app preview: the shared file preview fed from a
* FileNode, downloaded with the same authenticated download as "Download"
* (token refresh, client certificate).
*/
export function FilePreviewModal({ file, onClose, docked = false }: Props) {
const t = useLocaleStore((s) => s.t);
const [item, setItem] = React.useState<PreviewItem | null>(null);
const [loading, setLoading] = React.useState(false);
// The downloaded copy of the open preview.
const session = React.useRef<{ file: File; handedOff: boolean } | null>(null);
const busy = React.useRef(false);
const onCloseRef = React.useRef(onClose);
onCloseRef.current = onClose;
const preview = useFilePreview({ onClose });
const { open, reset } = preview;
const blobId = file?.blobId ?? null;
const accountId = file?.accountId;
@@ -55,93 +35,9 @@ export function FilePreviewModal({ file, onClose, docked = false }: Props) {
React.useEffect(() => {
if (!blobId) return undefined;
let cancelled = false;
setItem(null);
setLoading(true);
void open({ from: 'blob', blobId, accountId, name, type, size });
return reset;
}, [blobId, accountId, name, type, size, open, reset]);
const build = async (local: File): Promise<PreviewItem> => {
const kind = inAppPreviewKind({ name, type, size }, PREVIEW_OPTIONS);
// The node may not report a size; the download does.
if (kind === 'none' || exceedsPreviewLimit(kind, local.size)) {
return {
kind: 'none',
name,
mimeType: type,
notice: kind === 'none' ? undefined : t('files.preview_too_large', 'This file is too large to preview.'),
};
}
switch (kind) {
case 'text':
return { kind, name, mimeType: type, text: await local.text() };
case 'eml':
return { kind, name, mimeType: type, eml: await emlPreviewFromBytes(await local.bytes()) };
default:
return { kind, name, mimeType: type, fileUri: local.uri };
}
};
void (async () => {
try {
const local = await cachePreviewFile(blobId, name, type, accountId);
if (cancelled) {
discardPreviewFile(local);
return;
}
session.current = { file: local, handedOff: false };
const next = await build(local);
if (!cancelled) setItem(next);
} catch (e) {
if (cancelled) return;
Alert.alert(t('files.preview_error', 'Failed to load preview'), e instanceof Error ? e.message : String(e));
onCloseRef.current();
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
const open = session.current;
session.current = null;
if (open && !open.handedOff) discardPreviewFile(open.file);
setItem(null);
setLoading(false);
};
}, [blobId, accountId, name, type, size, t]);
const act = (run: (local: File) => Promise<void>, handsOff: boolean, failTitle: string) => {
const open = session.current;
if (!open || busy.current) return;
busy.current = true;
// Another app may keep reading the file after the sheet or viewer returns.
if (handsOff) open.handedOff = true;
run(open.file)
.catch((e) => Alert.alert(failTitle, e instanceof Error ? e.message : String(e)))
.finally(() => { busy.current = false; });
};
return (
<AttachmentPreviewModal
item={item}
loading={loading}
title={name}
docked={docked}
onClose={onClose}
onOpenExternal={() => act(
(local) => shareLocalFile(local, type, name, { keep: true }),
true,
t('files.open_error', 'Could not open the file'),
)}
onShare={() => act(
(local) => shareLocalFile(local, type, name, { forceSheet: true, keep: true }),
true,
t('files.share_file_error', 'Could not share the file'),
)}
onDownload={() => act(
(local) => saveLocalFileCopy(local, type),
false,
t('files.download_error', 'Failed to download'),
)}
/>
);
return <AttachmentPreviewModal {...preview.modalProps} docked={docked} />;
}
+2
View File
@@ -29,6 +29,7 @@ const G = {
'ban': ["M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0","M5.7 5.7l12.6 12.6"],
'beach': ["M17.553 16.75a7.5 7.5 0 0 0 -10.606 0","M18 3.804a6 6 0 0 0 -8.196 2.196l10.392 6a6 6 0 0 0 -2.196 -8.196","M16.732 10c1.658 -2.87 2.225 -5.644 1.268 -6.196c-.957 -.552 -3.075 1.326 -4.732 4.196","M15 9l-3 5.196","M3 19.25a2.4 2.4 0 0 1 1 -.25a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 1 .25"],
'bell': ["M10 5a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3h-16a4 4 0 0 0 2 -3v-3a7 7 0 0 1 4 -6","M9 17v1a3 3 0 0 0 6 0v-1"],
'bell-off': ["M9.346 5.353c.21 -.129 .428 -.246 .654 -.353a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3m-1 3h-13a4 4 0 0 0 2 -3v-3a6.996 6.996 0 0 1 1.273 -3.707","M9 17v1a3 3 0 0 0 6 0v-1","M3 3l18 18"],
'bell-ringing': ["M10 5a2 2 0 0 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3h-16a4 4 0 0 0 2 -3v-3a7 7 0 0 1 4 -6","M9 17v1a3 3 0 0 0 6 0v-1","M21 6.727a11.05 11.05 0 0 0 -2.794 -3.727","M3 6.727a11.05 11.05 0 0 1 2.792 -3.727"],
'bold': ["M7 5h6a3.5 3.5 0 0 1 0 7h-6l0 -7","M13 12h1a3.5 3.5 0 0 1 0 7h-7v-7"],
'book': ["M3 19a9 9 0 0 1 9 0a9 9 0 0 1 9 0","M3 6a9 9 0 0 1 9 0a9 9 0 0 1 9 0","M3 6l0 13","M12 6l0 13","M21 6l0 13"],
@@ -190,6 +191,7 @@ export const ArrowUpDown = makeIcon('ArrowUpDown', G['arrows-sort']);
export const ArrowUpNarrowWide = makeIcon('ArrowUpNarrowWide', G['sort-ascending']);
export const Ban = makeIcon('Ban', G['ban']);
export const Bell = makeIcon('Bell', G['bell']);
export const BellOff = makeIcon('BellOff', G['bell-off']);
export const BellRing = makeIcon('BellRing', G['bell-ringing']);
export const Bold = makeIcon('Bold', G['bold']);
export const Book = makeIcon('Book', G['book-2']);
@@ -27,6 +27,7 @@ import {
PushSetupError,
readPushAccountIds,
revokePushDevice,
serverSupportsEmailPush,
setStoredRelayBaseUrl,
setupPushNotifications,
getStoredPushTransport,
@@ -42,6 +43,8 @@ import {
saveUnifiedPushDistributor,
} from '../../lib/unified-push';
import { canBadgeAppIcon } from '../../lib/app-badge';
import { isAccountMuted, setAccountMuted } from '../../lib/notification-mutes';
import { jmapClient } from '../../api/jmap-client';
type PushStatus =
| { kind: 'idle' }
@@ -78,6 +81,14 @@ export function NotificationSettings() {
const hydrate = useSettingsStore((s) => s.hydrate);
const update = useSettingsStore((s) => s.updateSetting);
const emailEnabled = useSettingsStore((s) => s.emailNotificationsEnabled);
const notificationMutes = useSettingsStore((s) => s.notificationMutes);
// Muting works through the server's emailPush filter (#78): offered only
// where it exists, for the account this screen shows. Single folders are
// muted from the sidebar.
let ownJmapAccountId: string | null = null;
try { ownJmapAccountId = jmapClient.accountId; } catch { ownJmapAccountId = null; }
const canMuteAccount = ownJmapAccountId !== null && serverSupportsEmailPush();
const accountMuted = ownJmapAccountId !== null && isAccountMuted(notificationMutes, ownJmapAccountId);
const calEnabled = useSettingsStore((s) => s.calendarNotificationsEnabled);
const invitationParsing = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
const appIconBadge = useSettingsStore((s) => s.appIconUnreadBadge);
@@ -467,6 +478,22 @@ export function NotificationSettings() {
>
<ToggleSwitch checked={emailEnabled} onChange={(v) => update('emailNotificationsEnabled', v)} />
</SettingItem>
{canMuteAccount && (
<SettingItem
label={t('settings.notifications.email.mute_account', 'Mute this account')}
description={t(
'settings.notifications.email.mute_account_desc',
'No alerts for new mail in this account. Long-press a folder in the sidebar to mute or unmute it on its own.',
)}
>
<ToggleSwitch
checked={accountMuted}
onChange={(v) => {
if (ownJmapAccountId) update('notificationMutes', setAccountMuted(notificationMutes, ownJmapAccountId, v));
}}
/>
</SettingItem>
)}
{canBadgeAppIcon() && (
<SettingItem
label={t('settings.notifications.email.app_icon_badge', 'Unread count on app icon')}
+35 -4
View File
@@ -7,6 +7,7 @@ import {
Modal,
ScrollView,
ViewStyle,
type LayoutChangeEvent,
} from 'react-native';
import { FlaskConical, Lock, ChevronDown, ChevronRight, Check, ExternalLink, type AppIcon } from '../icons';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
@@ -68,6 +69,9 @@ export function SettingsSection({
);
}
/** Narrowest label column before a row's control moves under the label. */
const STACK_BELOW = 140;
// The label of the SettingItem a control sits in. A switch has no text of
// its own, so without it a screen reader only announces "switch, off".
const SettingLabelContext = React.createContext<string | undefined>(undefined);
@@ -95,10 +99,27 @@ export function SettingItem({
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const highlight = useSearchHighlight(label);
const rowStyle = [styles.settingItem, !noBorder && styles.settingItemBorder, locked && styles.locked, highlight.highlighted && styles.searchHighlight];
// The control sits on the right. When that leaves the label column narrower
// than STACK_BELOW (a long translation, two buttons), the control moves
// under the label, as the webmail stacks it on a phone. A new row width
// (rotation, split view) measures again.
const [stacked, setStacked] = useState(false);
const rowWidth = React.useRef(0);
const onRowLayout = (e: LayoutChangeEvent) => {
highlight.onLayout(e);
const width = e.nativeEvent.layout.width;
if (Math.abs(width - rowWidth.current) > 1) {
rowWidth.current = width;
setStacked(false);
}
};
const onContentLayout = (e: LayoutChangeEvent) => {
if (!stacked && children && e.nativeEvent.layout.width < STACK_BELOW) setStacked(true);
};
const rowStyle = [styles.settingItem, stacked && styles.settingItemStacked, !noBorder && styles.settingItemBorder, locked && styles.locked, highlight.highlighted && styles.searchHighlight];
const body = (
<>
<View style={styles.settingContent}>
<View style={[styles.settingContent, stacked && styles.settingContentStacked]} onLayout={onContentLayout}>
<View style={styles.settingLabelRow}>
<Text style={styles.settingLabel}>{label}</Text>
{locked && <Lock size={12} color={c.mutedForeground} />}
@@ -119,7 +140,7 @@ export function SettingItem({
return (
<Pressable
ref={highlight.ref}
onLayout={highlight.onLayout}
onLayout={onRowLayout}
onPress={onPress}
accessibilityRole={link === 'external' ? 'link' : 'button'}
style={({ pressed }) => [...rowStyle, pressed && styles.settingItemPressed]}
@@ -129,7 +150,7 @@ export function SettingItem({
);
}
return (
<View ref={highlight.ref} onLayout={highlight.onLayout} style={rowStyle}>
<View ref={highlight.ref} onLayout={onRowLayout} style={rowStyle}>
{body}
</View>
);
@@ -366,6 +387,16 @@ function makeStyles(c: ThemePalette) {
settingContent: {
flex: 1,
},
// The control under the label instead of beside it (see SettingItem).
settingItemStacked: {
flexDirection: 'column',
alignItems: 'flex-start',
gap: spacing.sm,
},
settingContentStacked: {
flex: 0,
alignSelf: 'stretch',
},
settingLabelRow: {
flexDirection: 'row',
alignItems: 'center',
+15
View File
@@ -7,6 +7,7 @@
import { I18nManager } from 'react-native';
import { getLocales } from 'expo-localization';
import { formatMessage, type MessageParams } from './format';
import { setDeviceLocales } from '../lib/intl-locale';
// English is the fallback for every lookup, so it is loaded eagerly. The other
// catalogs are loaded on first use; see CATALOG_LOADERS below.
@@ -212,6 +213,20 @@ export function detectDeviceLocale(): LocaleCode {
return 'en';
}
/**
* Hand the device locales to the date formatters (lib/intl-locale), so an
* en-AU phone formats dates as en-AU while reading the English catalog.
*/
export function registerDeviceLocales(): void {
try {
setDeviceLocales(getLocales().map((l) => ({ appLocale: resolveLocaleTag(l), languageTag: l.languageTag })));
} catch {
setDeviceLocales([]);
}
}
registerDeviceLocales();
function lookup(dict: Dictionary, key: string): string | undefined {
let current: unknown = dict;
for (const part of key.split('.')) {
+3 -11
View File
@@ -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);
});
+94
View File
@@ -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();
});
});
+100
View File
@@ -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>'));
});
});
+84
View File
@@ -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();
});
});
+105 -21
View File
@@ -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');
});
});
+3 -2
View File
@@ -7,13 +7,14 @@ vi.mock('expo-localization', () => ({
getCalendars: () => [{ timeZone: 'UTC', firstWeekday: 1 }],
}));
import { intlLocale, resetIntlLocaleCache } from '../intl-locale';
import { intlLocale } from '../intl-locale';
import { registerDeviceLocales } from '../../i18n';
import { formatCalendarDate, formatListDate } from '../date-format';
import { formatHeaderDate } from '../email-date';
function setDevice(...tags: string[]) {
device.locales = tags.map((t) => ({ languageTag: t, languageCode: t.split('-')[0] }));
resetIntlLocaleCache();
registerDeviceLocales();
}
describe('intlLocale', () => {
+70
View File
@@ -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 = &quot;&lt;b&gt;&quot;;\n**not bold**</code></pre>',
);
expect(body(' x < 1')).toBe('<pre><code>x &lt; 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>&lt;script&gt;alert(1)&lt;/script&gt;</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('![logo](https://example.org/l.png)')).toBe('<img src="https://example.org/l.png" alt="logo">');
expect(renderInline('![x](data:image/png;base64,AAA)')).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 } });
});
});
+42
View File
@@ -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');
});
});
+54
View File
@@ -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');
});
});

Some files were not shown because too many files have changed in this diff Show More