Compare commits

...
8 Commits
46 changed files with 1022 additions and 72 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.80
0.1.82
@@ -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
}
}
+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.
+7 -1
View File
@@ -408,7 +408,13 @@
"open_external": "فتح في تطبيق",
"pdf_external": "تُفتح ملفات PDF في عارض PDF على هذا الجهاز.",
"unsupported": "لا توجد معاينة لهذا النوع من الملفات.",
"media_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": "إزالة الوسم",
+7 -1
View File
@@ -408,7 +408,13 @@
"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.",
"media_unsupported": "Aquest fitxer no es pot reproduir aquí."
"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",
+7 -1
View File
@@ -408,7 +408,13 @@
"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.",
"media_unsupported": "Tento soubor zde nelze přehrát."
"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",
+7 -1
View File
@@ -408,7 +408,13 @@
"open_external": "Åbn i app",
"pdf_external": "PDF'er åbnes i din PDF-fremviser på denne enhed.",
"unsupported": "Ingen forhåndsvisning for denne filtype.",
"media_unsupported": "Denne fil kan ikke afspilles her."
"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",
+7 -1
View File
@@ -414,7 +414,13 @@
"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.",
"media_unsupported": "Diese Datei kann hier nicht abgespielt werden."
"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",
+7 -1
View File
@@ -425,7 +425,13 @@
"open_external": "Open in app",
"pdf_external": "PDFs open in your PDF viewer on this device.",
"unsupported": "No preview for this file type.",
"media_unsupported": "This file can't be played here."
"media_unsupported": "This file can't be played here.",
"pdf_protected": "This PDF is password-protected. Open it in another app to enter the password.",
"pdf_unreadable": "This PDF can't be shown here.",
"archive_files": "{count, plural, one {# file} other {# files}}",
"archive_first_files": "The first {count} files",
"archive_protected": "This archive is password-protected. Open it in another app to enter the password.",
"archive_unreadable": "This archive can't be opened here."
},
"received_via": "to {name}",
"remove_color": "Remove tag",
+7 -1
View File
@@ -408,7 +408,13 @@
"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.",
"media_unsupported": "Este archivo no se puede reproducir aquí."
"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",
+7 -1
View File
@@ -408,7 +408,13 @@
"open_external": "باز کردن در برنامه",
"pdf_external": "فایل‌های PDF در نمایشگر PDF این دستگاه باز می‌شوند.",
"unsupported": "برای این نوع فایل پیش‌نمایشی وجود ندارد.",
"media_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": "حذف برچسب",
+7 -1
View File
@@ -408,7 +408,13 @@
"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.",
"media_unsupported": "Ce fichier ne peut pas être lu ici."
"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",
+7 -1
View File
@@ -408,7 +408,13 @@
"open_external": "פתח באפליקציה",
"pdf_external": "קובצי PDF נפתחים במציג ה-PDF במכשיר זה.",
"unsupported": "אין תצוגה מקדימה לסוג קובץ זה.",
"media_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": "הסר תג",
+7 -1
View File
@@ -408,7 +408,13 @@
"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.",
"media_unsupported": "Ez a fájl itt nem játszható le."
"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",
+7 -1
View File
@@ -408,7 +408,13 @@
"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.",
"media_unsupported": "Questo file non può essere riprodotto qui."
"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",
+7 -1
View File
@@ -408,7 +408,13 @@
"open_external": "アプリで開く",
"pdf_external": "PDF はこのデバイスの PDF ビューアーで開きます。",
"unsupported": "このファイル形式はプレビューできません。",
"media_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": "ラベルを削除",
+7 -1
View File
@@ -408,7 +408,13 @@
"open_external": "앱에서 열기",
"pdf_external": "PDF는 이 기기의 PDF 뷰어에서 열려요.",
"unsupported": "이 파일 형식은 미리보기를 지원하지 않아요.",
"media_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": "태그 제거",
+7 -1
View File
@@ -408,7 +408,13 @@
"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.",
"media_unsupported": "Šo failu šeit nevar atskaņot."
"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",
+7 -1
View File
@@ -408,7 +408,13 @@
"open_external": "Апп-д нээх",
"pdf_external": "PDF файлууд энэ төхөөрөмжийн PDF үзэгчид нээгдэнэ.",
"unsupported": "Энэ төрлийн файлыг урьдчилан харах боломжгүй.",
"media_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": "Тагийг хасах",
+7 -1
View File
@@ -408,7 +408,13 @@
"open_external": "Åpne i app",
"pdf_external": "PDF-filer åpnes i PDF-leseren på denne enheten.",
"unsupported": "Ingen forhåndsvisning for denne filtypen.",
"media_unsupported": "Denne filen kan ikke spilles av her."
"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",
+7 -1
View File
@@ -408,7 +408,13 @@
"open_external": "Openen in app",
"pdf_external": "PDF's worden geopend in de PDF-viewer op dit apparaat.",
"unsupported": "Geen voorbeeld voor dit bestandstype.",
"media_unsupported": "Dit bestand kan hier niet worden afgespeeld."
"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",
+7 -1
View File
@@ -408,7 +408,13 @@
"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.",
"media_unsupported": "Tego pliku nie można tutaj odtworzyć."
"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ę",
+7 -1
View File
@@ -408,7 +408,13 @@
"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.",
"media_unsupported": "Este ficheiro não pode ser reproduzido aqui."
"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",
+7 -1
View File
@@ -408,7 +408,13 @@
"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.",
"media_unsupported": "Acest fișier nu poate fi redat aici."
"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",
+7 -1
View File
@@ -408,7 +408,13 @@
"open_external": "Открыть в приложении",
"pdf_external": "PDF-файлы открываются в программе просмотра PDF на этом устройстве.",
"unsupported": "Для этого типа файлов нет предпросмотра.",
"media_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": "Убрать тег",
+7 -1
View File
@@ -408,7 +408,13 @@
"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.",
"media_unsupported": "Tento súbor tu nie je možné prehrať."
"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",
+7 -1
View File
@@ -408,7 +408,13 @@
"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.",
"media_unsupported": "Bu dosya burada oynatılamıyor."
"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",
+7 -1
View File
@@ -408,7 +408,13 @@
"open_external": "Відкрити в застосунку",
"pdf_external": "PDF-файли відкриваються в переглядачі PDF на цьому пристрої.",
"unsupported": "Для цього типу файлів немає попереднього перегляду.",
"media_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": "Прибрати тег",
+7 -1
View File
@@ -408,7 +408,13 @@
"open_external": "在應用程式中開啟",
"pdf_external": "PDF 會在此裝置的 PDF 檢視器中開啟。",
"unsupported": "無法預覽此檔案類型。",
"media_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": "移除標籤",
+7 -1
View File
@@ -408,7 +408,13 @@
"open_external": "在应用中打开",
"pdf_external": "PDF 将在此设备的 PDF 查看器中打开。",
"unsupported": "无法预览此类文件。",
"media_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": "删除标签",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.80",
"version": "0.1.82",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.80",
"version": "0.1.82",
"hasInstallScript": true,
"license": "AGPL-3.0-only",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.80",
"version": "0.1.82",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
+144 -17
View File
@@ -1,17 +1,26 @@
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 { needsNativeImageDecoder, needsWebImageViewer, type PreviewKind } from '../../lib/file-preview';
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 {
@@ -20,7 +29,7 @@ export interface PreviewItem {
mimeType: string;
/** Local file:// URI of the downloaded copy (images, PDFs, media). */
fileUri?: string;
/** The viewer page next to the file (images, video, audio); see preview-viewer-html. */
/** 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;
@@ -28,6 +37,8 @@ export interface PreviewItem {
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;
}
@@ -47,14 +58,28 @@ interface Props {
docked?: boolean;
}
interface ViewerWebViewProps {
uri: string;
background: string;
onFailed: () => void;
/** Messages other than 'error'. */
onMessage?: (data: string) => void;
webRef?: React.Ref<WebView>;
}
/**
* A local page in a WebView that may read only its own folder: the image
* and media viewers. It posts 'error' when the file doesn't decode.
* 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 }: { uri: string; background: string; onFailed: () => void }) {
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
@@ -74,7 +99,7 @@ function ViewerWebView({ uri, background, onFailed }: { uri: string; background:
overScrollMode="never"
bounces={false}
onShouldStartLoadWithRequest={(req) => req.url === uri}
onMessage={(e) => { if (e.nativeEvent.data === 'error') onFailed(); }}
onMessage={handleMessage}
onError={onFailed}
style={[viewerStyles.fill, { backgroundColor: background }]}
containerStyle={{ backgroundColor: background }}
@@ -82,11 +107,44 @@ function ViewerWebView({ uri, background, onFailed }: { uri: string; 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;
/**
* In-app preview for images, PDFs (iOS renders them in the WebView), video
* and audio (the WebView's player), text, Markdown and HTML documents and
* embedded messages. Files that don't decode, and types without a preview,
* offer another app instead. Used by every place that opens a file.
* 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,
@@ -94,7 +152,45 @@ 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();
// 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.
@@ -133,7 +229,7 @@ export function AttachmentPreviewModal({
|| (!!failure?.native && !item.viewerUri)
);
const mediaFailed = (item?.kind === 'video' || item?.kind === 'audio') && !!failure?.web;
const pdfInline = item?.kind === 'pdf' && Platform.OS === 'ios' && !!item.fileUri;
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 || mediaFailed
@@ -149,10 +245,11 @@ export function AttachmentPreviewModal({
</View>
);
const renderDocument = (header?: React.ReactNode) => (
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>
);
@@ -216,6 +313,17 @@ export function AttachmentPreviewModal({
/>
);
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
@@ -230,7 +338,7 @@ 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}>
@@ -245,12 +353,30 @@ export function AttachmentPreviewModal({
<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}
{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 (
<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 = (
@@ -301,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;
+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 {
+48 -7
View File
@@ -11,7 +11,9 @@ import {
} from '../../lib/file-preview';
import type { PreviewKind } from '../../lib/file-preview';
import { markdownToHtml } from '../../lib/markdown-html';
import { imageViewerHtml, mediaViewerHtml, type ViewerColors } from '../../lib/preview-viewer-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';
@@ -74,16 +76,24 @@ function writeViewer(local: File, html: string): string {
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,
tooLarge: string,
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: tooLarge };
if (exceedsPreviewLimit(kind, local.size)) return { ...base, kind: 'none', notice: notices.tooLarge };
switch (kind) {
case 'text':
return { ...base, kind, text: await local.text() };
@@ -93,11 +103,28 @@ async function buildItem(
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 };
}
@@ -141,7 +168,9 @@ export function useFilePreview(opts: {
seq.current += 1;
const open = session.current;
session.current = null;
if (open && !open.handedOff) discardPreviewFile(open.file);
if (!open) return;
closePdfPages(open.file.uri);
if (!open.handedOff) discardPreviewFile(open.file);
}, []);
React.useEffect(() => end, [end]);
@@ -181,10 +210,22 @@ export function useFilePreview(opts: {
return;
}
session.current = { file: local, handedOff: false };
const next = await buildItem(
kind, src, local, t('files.preview_too_large', 'This file is too large to preview.'), colors.current,
);
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();
+13 -4
View File
@@ -37,10 +37,11 @@ describe('filePreviewKind', () => {
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('plays the video formats each platform plays', () => {
@@ -92,8 +93,16 @@ describe('filePreviewKind', () => {
expect(filePreviewKind({ name: 'cover.mp3', type: 'image/jpeg' }, ANDROID)).toBe('image');
});
it('leaves archives, office documents and binaries to other apps', () => {
expect(filePreviewKind({ name: 'archive.zip', type: 'application/zip' }, IOS)).toBe('none');
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: 'contract.docx', type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }, ANDROID)).toBe('none');
expect(filePreviewKind({ name: 'data.bin', type: undefined }, IOS)).toBe('none');
+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');
});
});
+72
View File
@@ -0,0 +1,72 @@
import { describe, it, expect } from 'vitest';
import JSZip from 'jszip';
import { openZip, isZipArchive, ZipEncryptedError, MAX_ZIP_ENTRIES } from '../zip-preview';
async function makeZip(files: Record<string, string>): Promise<Uint8Array> {
const zip = new JSZip();
for (const [path, text] of Object.entries(files)) zip.file(path, text);
return zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' });
}
// Set the "encrypted" bit in every local and central header, as a
// password-protected archive has it.
function markEncrypted(bytes: Uint8Array): Uint8Array {
const out = bytes.slice();
const view = new DataView(out.buffer);
for (let i = 0; i + 4 <= out.length; i++) {
const sig = view.getUint32(i, true);
if (sig === 0x04034b50) view.setUint16(i + 6, view.getUint16(i + 6, true) | 1, true);
if (sig === 0x02014b50) view.setUint16(i + 8, view.getUint16(i + 8, true) | 1, true);
}
return out;
}
describe('openZip', () => {
it('lists the files by folder and name, without folders or OS metadata', async () => {
const archive = await openZip(await makeZip({
'b.txt': 'b',
'docs/z.md': '# z',
'a.txt': 'a',
'docs/inner/c.csv': '1,2',
'__MACOSX/docs/._z.md': 'junk',
'.DS_Store': 'junk',
'docs/Thumbs.db': 'junk',
}));
expect(archive.entries.map((e) => e.path)).toEqual(['a.txt', 'b.txt', 'docs/z.md', 'docs/inner/c.csv']);
expect(archive.entries[2]).toMatchObject({ name: 'z.md', folder: 'docs', size: 3 });
expect(archive.truncated).toBe(false);
});
it('reads an entry from the loaded archive', async () => {
const archive = await openZip(await makeZip({ 'docs/notes.txt': 'Grüße' }));
expect(new TextDecoder().decode(await archive.read('docs/notes.txt'))).toBe('Grüße');
await expect(archive.read('missing.txt')).rejects.toThrow(/not in the archive/);
});
it('stops listing at the cap', async () => {
const files: Record<string, string> = {};
for (let i = 0; i < MAX_ZIP_ENTRIES + 5; i++) files[`f${i}.txt`] = '';
const archive = await openZip(await makeZip(files));
expect(archive.entries).toHaveLength(MAX_ZIP_ENTRIES);
expect(archive.truncated).toBe(true);
});
it('says when an archive is password-protected', async () => {
const locked = markEncrypted(await makeZip({ 'secret.txt': 'x' }));
await expect(openZip(locked)).rejects.toBeInstanceOf(ZipEncryptedError);
});
it('rejects data that is not a ZIP', async () => {
await expect(openZip(new TextEncoder().encode('not a zip'))).rejects.not.toBeInstanceOf(ZipEncryptedError);
});
});
describe('isZipArchive', () => {
it('takes ZIPs by type or name, not the formats built on ZIP', () => {
expect(isZipArchive('a.zip', 'application/zip')).toBe(true);
expect(isZipArchive('bundle', 'application/x-zip-compressed')).toBe(true);
expect(isZipArchive('Archive.ZIP', 'application/octet-stream')).toBe(true);
expect(isZipArchive('contract.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')).toBe(false);
expect(isZipArchive('app.apk', 'application/vnd.android.package-archive')).toBe(false);
});
});
+11 -7
View File
@@ -5,13 +5,13 @@
// everything else goes to the OS "Open with" chooser.
export type PreviewKind =
| 'image' | 'pdf' | 'text' | 'markdown' | 'html' | 'eml' | 'video' | 'audio' | 'none';
| 'image' | 'pdf' | 'text' | 'markdown' | 'html' | 'eml' | 'video' | 'audio' | 'archive' | 'none';
export interface FilePreviewOptions {
/**
* The platform's viewers decide what plays: video and audio play in the
* system WebView (Chromium on Android, WebKit on iOS), whose formats
* differ, and only iOS renders PDFs in-app.
* differ.
*/
platform: 'ios' | 'android';
}
@@ -26,14 +26,16 @@ interface PreviewableFile {
* Largest file each preview kind loads. Text is rendered in one selectable
* `<Text>`, which gets slow to lay out past a few hundred KB; HTML and
* Markdown go through the mail sanitiser in JS; parsing an .eml and decoding
* an image hold the whole file in memory. PDFs are paged by their viewer and
* media stream from the downloaded copy. Larger files open in another app.
* an image hold the whole file in memory, and so does listing a ZIP. PDFs
* are paged by their viewer and media stream from the downloaded copy.
* Larger files open in another app.
*/
export const FILE_PREVIEW_MAX_BYTES: Record<Exclude<PreviewKind, 'none'>, number> = {
text: 512 * 1024,
markdown: 1024 * 1024,
html: 5 * 1024 * 1024,
eml: 10 * 1024 * 1024,
archive: 50 * 1024 * 1024,
image: 25 * 1024 * 1024,
pdf: 100 * 1024 * 1024,
audio: 500 * 1024 * 1024,
@@ -94,6 +96,7 @@ const MEDIA: Record<'video' | 'audio', Record<FilePreviewOptions['platform'], {
},
};
const ZIP_MIME = new Set(['application/zip', 'application/x-zip-compressed', 'application/x-zip']);
const HTML_MIME = new Set(['text/html', 'application/xhtml+xml']);
const HTML_EXT = new Set(['html', 'htm', 'xhtml', 'xht']);
const MARKDOWN_MIME = new Set(['text/markdown', 'text/x-markdown']);
@@ -128,6 +131,8 @@ export function filePreviewKind(file: Pick<PreviewableFile, 'name' | 'type'>, op
const media = /^(image|audio|video)\//.test(mime);
if (mime === 'message/rfc822' || ext === 'eml') return 'eml';
// ZIPs list their files; formats built on ZIP (docx, apk) are not ZIPs here.
if (ZIP_MIME.has(mime) || ((!mime || mime.startsWith('application/x-')) && ext === 'zip')) return 'archive';
if (HTML_MIME.has(mime) || HTML_EXT.has(ext)) return 'html';
if (MARKDOWN_MIME.has(mime) || MARKDOWN_EXT.has(ext)) return 'markdown';
@@ -139,9 +144,8 @@ export function filePreviewKind(file: Pick<PreviewableFile, 'name' | 'type'>, op
|| (ios && (IOS_IMAGE_MIME.has(mime) || IOS_IMAGE_EXT.has(ext)));
return shown ? 'image' : 'none';
}
if (mime === 'application/pdf' || (!media && ext === 'pdf')) {
return opts.platform === 'ios' ? 'pdf' : 'none';
}
// iOS's WebView shows PDFs; Android renders their pages (pdf-pages.ts).
if (mime === 'application/pdf' || (!media && ext === 'pdf')) return 'pdf';
for (const kind of ['video', 'audio'] as const) {
const formats = MEDIA[kind][opts.platform];
if (mime.startsWith(`${kind}/`)) return formats.mime.has(mime) || formats.ext.has(ext) ? kind : 'none';
+67
View File
@@ -0,0 +1,67 @@
// Android's PDF pages for the file preview, rendered by BulwarkPdfModule
// (the platform's PdfRenderer) into PNGs next to the PDF. iOS needs none of
// this: its WebView shows PDFs itself.
import { NativeModules, Platform } from 'react-native';
export interface PdfPageSize {
/** In points. */
width: number;
height: number;
}
interface NativePdf {
open(fileUri: string): Promise<{ pages: PdfPageSize[] }>;
renderPage(fileUri: string, index: number, width: number, outUri: string): Promise<string>;
close(fileUri: string): Promise<void>;
}
function native(): NativePdf | null {
if (Platform.OS !== 'android') return null;
return (NativeModules.BulwarkPdf as NativePdf | undefined) ?? null;
}
/** Whether this build renders PDFs in-app. */
export function canRenderPdfPages(): boolean {
return native() !== null;
}
export type PdfOpenFailure = 'protected' | 'unreadable';
export class PdfOpenError extends Error {
constructor(readonly reason: PdfOpenFailure, message: string) {
super(message);
}
}
/** The pages of a PDF, or a {@link PdfOpenError} saying why it can't be shown. */
export async function openPdfPages(fileUri: string): Promise<PdfPageSize[]> {
const pdf = native();
if (!pdf) throw new PdfOpenError('unreadable', 'PDF rendering is not available');
try {
const { pages } = await pdf.open(fileUri);
if (!pages.length) throw new PdfOpenError('unreadable', 'The PDF has no pages');
return pages;
} catch (e) {
if (e instanceof PdfOpenError) throw e;
const code = (e as { code?: string } | null)?.code;
throw new PdfOpenError(code === 'E_PDF_PROTECTED' ? 'protected' : 'unreadable', e instanceof Error ? e.message : String(e));
}
}
/** Render page `index` (0-based) `width` pixels wide into `outUri`. */
export function renderPdfPage(fileUri: string, index: number, width: number, outUri: string): Promise<string> {
const pdf = native();
if (!pdf) return Promise.reject(new Error('PDF rendering is not available'));
return pdf.renderPage(fileUri, index, Math.round(width), outUri);
}
/** Let go of the open document. */
export function closePdfPages(fileUri: string): void {
void native()?.close(fileUri).catch(() => undefined);
}
/** Name of the image a page is rendered into, next to the viewer page. */
export function pdfPageFileName(index: number): string {
return `__page-${index + 1}.png`;
}
+38
View File
@@ -102,3 +102,41 @@ export function mediaViewerHtml(kind: 'video' | 'audio', fileName: string, color
+ `<audio id="m" src="${src}" controls autoplay preload="auto"></audio></div>${script}`,
);
}
/**
* Android's PDF viewer: one placeholder per page at the page's proportions,
* filled with an image the app renders when the page comes near the screen
* (it posts `page:<index>` and the app answers by calling
* `window.__pdfPage(index, src)`). The WebView zooms the whole document, and a
* badge shows the page at the middle of the screen.
*/
export function pdfViewerHtml(
fileName: string,
pages: ReadonlyArray<{ width: number; height: number }>,
colors: ViewerColors,
): string {
const sizes = JSON.stringify(pages.map((p) => [Math.max(1, Math.round(p.width)), Math.max(1, Math.round(p.height))]));
return page(
fileName,
{ ...colors, background: colors.surface },
'width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=6,user-scalable=yes',
'body{height:auto;min-height:100%}'
+ '#doc{padding:8px 8px 56px;display:flex;flex-direction:column;align-items:center;gap:8px}'
+ '.page{width:100%;max-width:1000px;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.25);position:relative}'
+ '.page img{position:absolute;inset:0;width:100%;height:100%;opacity:0;transition:opacity .15s}'
+ '.page img.on{opacity:1}'
+ `#badge{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);padding:4px 12px;border-radius:999px;`
+ `background:rgba(0,0,0,.65);color:#fff;font-size:13px;font-variant-numeric:tabular-nums;transition:opacity .3s}`
+ '#badge.hide{opacity:0}',
'<div id="doc"></div><div id="badge"></div>'
+ `<script>${REPORT}(function(){var sizes=${sizes},doc=document.getElementById('doc'),badge=document.getElementById('badge'),asked={},els=[];`
+ `sizes.forEach(function(s,i){var d=document.createElement('div');d.className='page';d.style.aspectRatio=s[0]+'/'+s[1];d.dataset.i=i;`
+ `var img=document.createElement('img');img.alt='';img.onload=function(){img.className='on'};d.appendChild(img);doc.appendChild(d);els.push(d)});`
+ `var io=new IntersectionObserver(function(es){es.forEach(function(e){var i=+e.target.dataset.i;if(e.isIntersecting&&!asked[i]){asked[i]=1;report('page:'+i)}})},{rootMargin:'100% 0px'});`
+ `els.forEach(function(el){io.observe(el)});`
+ `window.__pdfPage=function(i,src){var img=els[i]&&els[i].firstChild;if(img)img.src=src};`
+ `var t;function where(){var mid=innerHeight/2,n=1;for(var i=0;i<els.length;i++){var r=els[i].getBoundingClientRect();if(r.top<=mid){n=i+1}else break}`
+ `badge.textContent=n+' / '+els.length;badge.className='';clearTimeout(t);t=setTimeout(function(){badge.className='hide'},1200)}`
+ `addEventListener('scroll',where,{passive:true});where();report('loaded')})();</script>`,
);
}
+90
View File
@@ -0,0 +1,90 @@
// ZIP archives in the file preview: the entries as a list, and one entry's
// bytes when it is opened. Nothing is extracted to disk under the archive's
// paths; an opened entry goes through the same preview as any attachment.
import type JSZip from 'jszip';
export interface ZipEntry {
/** Full path inside the archive, as stored. */
path: string;
/** Last path segment. */
name: string;
/** Folder path, '' at the top. */
folder: string;
/** Uncompressed size, when the archive records it. */
size?: number;
modified?: Date;
}
export interface ZipArchive {
entries: ZipEntry[];
/** True when the listing stopped at {@link MAX_ZIP_ENTRIES}. */
truncated: boolean;
/** One entry's bytes. */
read(path: string): Promise<Uint8Array>;
}
/** The archive is password-protected; JSZip can't decrypt it. */
export class ZipEncryptedError extends Error {}
// Metadata the archives of macOS and Windows carry and nobody wants to open.
const JUNK = /(^|\/)(__MACOSX\/|\.DS_Store$|Thumbs\.db$|desktop\.ini$)/i;
/** Most entries listed; a larger archive lists the first ones. */
export const MAX_ZIP_ENTRIES = 2000;
// JSZip keeps the directory's sizes here without exposing them.
interface ZipObjectInternals {
_data?: { uncompressedSize?: number };
}
/**
* Read an archive's directory: its files (not folders), sorted by folder and
* then name, and a reader for their contents.
*/
export async function openZip(bytes: Uint8Array): Promise<ZipArchive> {
const { default: Zip } = await import('jszip');
let zip: JSZip;
try {
zip = await Zip.loadAsync(bytes, { createFolders: false });
} catch (e) {
if (/encrypt/i.test(e instanceof Error ? e.message : String(e))) throw new ZipEncryptedError('encrypted');
throw e;
}
const entries: ZipEntry[] = [];
let truncated = false;
for (const file of Object.values(zip.files)) {
if (file.dir || JUNK.test(file.name)) continue;
if (entries.length >= MAX_ZIP_ENTRIES) {
truncated = true;
break;
}
const path = file.name;
const slash = path.lastIndexOf('/');
const size = (file as unknown as ZipObjectInternals)._data?.uncompressedSize;
entries.push({
path,
name: slash >= 0 ? path.slice(slash + 1) : path,
folder: slash >= 0 ? path.slice(0, slash) : '',
size: typeof size === 'number' && size >= 0 ? size : undefined,
modified: file.date instanceof Date && !Number.isNaN(file.date.getTime()) ? file.date : undefined,
});
}
entries.sort((a, b) => a.folder.localeCompare(b.folder) || a.name.localeCompare(b.name));
return {
entries,
truncated,
read: async (path) => {
const file = zip.file(path);
if (!file) throw new Error(`${path} is not in the archive`);
return file.async('uint8array');
},
};
}
/** Whether a file is a ZIP the preview lists (not one of the formats built on ZIP). */
export function isZipArchive(name: string, type?: string | null): boolean {
const mime = (type || '').split(';')[0].trim().toLowerCase();
if (mime === 'application/zip' || mime === 'application/x-zip-compressed' || mime === 'application/x-zip') return true;
return /\.zip$/i.test(name);
}
+2 -2
View File
@@ -360,8 +360,8 @@ export default function FilesScreen() {
}
}, [t]);
// Tap on a file: the in-app preview for the types it shows (images, text
// and code, .eml, PDFs on iOS), the other app for the rest.
// Tap on a file: the in-app preview for the types it shows (lib/file-preview),
// the other app for the rest.
const openFile = useCallback((row: FileRow) => {
if (canPreviewInApp(row)) setPreviewTarget(row);
else void openFileWith(row);