mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86400b124a | ||
|
|
323797b4ee | ||
|
|
9166b0fb3c | ||
|
|
1f253e9488 | ||
|
|
d22fb8d5d0 | ||
|
|
e0f18713aa | ||
|
|
56d826c508 | ||
|
|
e5492b89c8 | ||
|
|
b50436b1ba | ||
|
|
11e74042e2 | ||
|
|
95580b84ea |
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -175,7 +175,7 @@ RN has a solid single-message reader (WebView body with CSP, shrink-to-fit + pin
|
||||
- What RN does: single tap follows the global `mailAttachmentAction`; `jszip` is already a dependency (`package.json:36`) but unused for mail.
|
||||
- Fix hint: long-press sheet with "Open / Save / Share"; a "Download all" entry building the zip with `jszip` into `Paths.cache` then sharing.
|
||||
|
||||
- [x] **In-app attachment preview (images, PDF, text, audio/video) is absent; iOS "Preview" is just the share sheet — fixed in d2ed27f** — `P3` — `partial`
|
||||
- [x] **In-app attachment preview (images, PDF, text, audio/video) is absent; iOS "Preview" is just the share sheet — fixed in d2ed27f; audio/video, HTML, Markdown, SVG, Android PDFs and ZIPs since 0.1.80-0.1.82** — `P3` — `partial`
|
||||
- What WEB does: `FilePreviewModal` for images/PDF (bytes handed to pdf.js, #871)/text/markdown/audio/video/eml, script-bearing types forced to download (`components/mail/mail-app.tsx:2887-2935`, `lib/file-preview.ts:29-92`).
|
||||
- What RN does: Android hands the file to an external viewer via `ACTION_VIEW`, iOS opens the share sheet (`src/lib/email-export.ts:64-79`, `115-141`); nothing is previewed inside the app.
|
||||
- Fix hint: for images/PDF/text open a modal `WebView` on the cached `file://` (or use `expo-file-system`'s `File.uri` with a QuickLook bridge on iOS); keep the external-viewer path as fallback.
|
||||
|
||||
@@ -110,7 +110,7 @@ Filters: RN carries a byte-for-byte port of WEB's Sieve parser/generator/tests a
|
||||
- What RN does: `getDocumentAsync({ multiple: false })` (`src/screens/FilesScreen.tsx:357-360`); uploading the same name twice creates two nodes with the same name.
|
||||
- Fix hint: `multiple: true` + sequential upload loop; reuse WEB's `getUniqueName` against `visibleFiles`.
|
||||
|
||||
- [x] **No in-app preview (image/text/markdown/PDF/audio/video/eml)** — `P2` — `missing` — done in 409cdb1 (images, text and source files, `.eml`, and PDFs on iOS open in the reader's preview; PDFs on Android, audio, video, HTML and Markdown still go to another app)
|
||||
- [x] **No in-app preview (image/text/markdown/PDF/audio/video/eml)** — `P2` — `missing` — done in 409cdb1 (images, text and source files, `.eml`, and PDFs on iOS open in the reader's preview); since 0.1.80/0.1.81 also audio, video, SVG/ICO, HTML, Markdown, PDFs on Android (native `PdfRenderer`) and ZIP listings, through the one preview the reader, list, Files and compose share (`src/components/email/use-file-preview.ts`)
|
||||
- What WEB does: `ImagePreviewModal` and `FilePreviewModal` (text, markdown, PDF incl. mobile pdf.js viewer, audio, video, EML) chosen via `getFilePreviewKind` (`lib/file-preview.ts:29-68`; `components/files/file-preview-modal.tsx`, `image-preview-modal.tsx`, `pdf-mobile-viewer.tsx`; `files-app.tsx:487-495`, `690-708`).
|
||||
- What RN does: tapping a file calls `shareAttachment` which downloads to cache and hands off to the OS share/viewer sheet (`src/screens/FilesScreen.tsx:248-258`; `src/lib/email-export.ts` "preview" variant). Works but there is no inline viewer and no "recent files" tracking.
|
||||
- Fix hint: reuse whatever attachment viewer the mail area has (image modal / WebView for text+PDF); N/A if the team decides the OS viewer is the mobile answer - then close this item.
|
||||
|
||||
+8
-1
@@ -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": "إزالة الوسم",
|
||||
|
||||
+8
-1
@@ -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",
|
||||
|
||||
+8
-1
@@ -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",
|
||||
|
||||
+8
-1
@@ -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",
|
||||
|
||||
+8
-1
@@ -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",
|
||||
|
||||
+8
-1
@@ -424,7 +424,14 @@
|
||||
"preview": {
|
||||
"open_external": "Open in app",
|
||||
"pdf_external": "PDFs open in your PDF viewer on this device.",
|
||||
"unsupported": "No preview for this file type."
|
||||
"unsupported": "No preview for this file type.",
|
||||
"media_unsupported": "This file can't be played here.",
|
||||
"pdf_protected": "This PDF is password-protected. Open it in another app to enter the password.",
|
||||
"pdf_unreadable": "This PDF can't be shown here.",
|
||||
"archive_files": "{count, plural, one {# file} other {# files}}",
|
||||
"archive_first_files": "The first {count} files",
|
||||
"archive_protected": "This archive is password-protected. Open it in another app to enter the password.",
|
||||
"archive_unreadable": "This archive can't be opened here."
|
||||
},
|
||||
"received_via": "to {name}",
|
||||
"remove_color": "Remove tag",
|
||||
|
||||
+8
-1
@@ -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",
|
||||
|
||||
+8
-1
@@ -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": "حذف برچسب",
|
||||
|
||||
+8
-1
@@ -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",
|
||||
|
||||
+8
-1
@@ -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": "הסר תג",
|
||||
|
||||
+8
-1
@@ -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",
|
||||
|
||||
+8
-1
@@ -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",
|
||||
|
||||
+8
-1
@@ -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": "ラベルを削除",
|
||||
|
||||
+8
-1
@@ -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": "태그 제거",
|
||||
|
||||
+8
-1
@@ -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",
|
||||
|
||||
+8
-1
@@ -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": "Тагийг хасах",
|
||||
|
||||
+8
-1
@@ -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",
|
||||
|
||||
+8
-1
@@ -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",
|
||||
|
||||
+8
-1
@@ -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ę",
|
||||
|
||||
+8
-1
@@ -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",
|
||||
|
||||
+8
-1
@@ -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",
|
||||
|
||||
+8
-1
@@ -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": "Убрать тег",
|
||||
|
||||
+8
-1
@@ -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",
|
||||
|
||||
+8
-1
@@ -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",
|
||||
|
||||
+8
-1
@@ -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": "Прибрати тег",
|
||||
|
||||
@@ -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": "移除標籤",
|
||||
|
||||
+8
-1
@@ -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": "删除标签",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.79",
|
||||
"version": "0.1.82",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.79",
|
||||
"version": "0.1.82",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.79",
|
||||
"version": "0.1.82",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import React from 'react';
|
||||
import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import {
|
||||
File as FileIcon, FileArchive, FileAudio, FileImage, FileSpreadsheet, FileText, FileVideo,
|
||||
} from '../icons';
|
||||
import { formatSize } from '../../lib/attachment-display';
|
||||
import { filePreviewKind, handoffMimeType } from '../../lib/file-preview';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
|
||||
/** A file inside a file: an archive entry or an attachment of an .eml. */
|
||||
export interface PreviewPart {
|
||||
key: string;
|
||||
name: string;
|
||||
/** Folder inside an archive, '' at the top. */
|
||||
folder?: string;
|
||||
type?: string;
|
||||
size?: number;
|
||||
read: () => Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
function iconFor(part: PreviewPart) {
|
||||
const type = part.type || handoffMimeType(part.name, '');
|
||||
if (/\.(xlsx?|ods|csv|tsv|numbers)$/i.test(part.name)) return FileSpreadsheet;
|
||||
if (/\.(zip|7z|rar|gz|tgz|tar|bz2|xz)$/i.test(part.name)) return FileArchive;
|
||||
// Platform-independent: the icon names the format, not what plays here.
|
||||
switch (filePreviewKind({ name: part.name, type }, { platform: 'android' })) {
|
||||
case 'image': return FileImage;
|
||||
case 'video': return FileVideo;
|
||||
case 'audio': return FileAudio;
|
||||
case 'none': return /^(application\/(msword|vnd\.|rtf)|text\/)/.test(type) ? FileText : FileIcon;
|
||||
default: return FileText;
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
parts: PreviewPart[];
|
||||
/** Opens a part; the row shows a spinner until the promise settles. */
|
||||
onOpen: (part: PreviewPart) => Promise<void>;
|
||||
/** Above the rows (an .eml's body, an archive's summary). */
|
||||
header?: React.ReactElement;
|
||||
/** Renders as a plain column inside a parent ScrollView instead of a list of its own. */
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The files inside an archive or an embedded message, each opening in the
|
||||
* file preview like any attachment.
|
||||
*/
|
||||
export function PreviewPartList({ parts, onOpen, header, inline = false }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const [busy, setBusy] = React.useState<string | null>(null);
|
||||
|
||||
const open = (part: PreviewPart) => {
|
||||
if (busy) return;
|
||||
setBusy(part.key);
|
||||
onOpen(part).finally(() => setBusy(null));
|
||||
};
|
||||
|
||||
const renderRow = (part: PreviewPart) => {
|
||||
const Icon = iconFor(part);
|
||||
return (
|
||||
<Pressable
|
||||
key={part.key}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => open(part)}
|
||||
disabled={!!busy}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={part.name}
|
||||
>
|
||||
<View style={styles.icon}>
|
||||
{busy === part.key ? <ActivityIndicator size="small" color={c.textMuted} /> : <Icon size={20} color={c.textMuted} />}
|
||||
</View>
|
||||
<View style={styles.text}>
|
||||
<Text style={styles.name} numberOfLines={1}>{part.name}</Text>
|
||||
{part.folder ? <Text style={styles.folder} numberOfLines={1}>{part.folder}</Text> : null}
|
||||
</View>
|
||||
{part.size != null ? <Text style={styles.size}>{formatSize(part.size) || '0 B'}</Text> : null}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<View style={styles.inline}>
|
||||
{header}
|
||||
{parts.map(renderRow)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FlatList
|
||||
style={styles.list}
|
||||
data={parts}
|
||||
keyExtractor={(p) => p.key}
|
||||
renderItem={({ item }) => renderRow(item)}
|
||||
ListHeaderComponent={header}
|
||||
initialNumToRender={30}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The summary line above an archive's entries. */
|
||||
export function ArchiveSummary({ count, truncated }: { count: number; truncated: boolean }) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
return (
|
||||
<Text style={styles.summary}>
|
||||
{truncated
|
||||
? t('email_viewer.preview.archive_first_files', 'The first {count} files', { count })
|
||||
: t('email_viewer.preview.archive_files', '{count, plural, one {# file} other {# files}}', { count })}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
list: { flex: 1 },
|
||||
inline: { paddingBottom: spacing.lg },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
minHeight: 52,
|
||||
},
|
||||
rowPressed: { backgroundColor: c.surfaceHover },
|
||||
icon: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: c.surface,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
text: { flex: 1, minWidth: 0 },
|
||||
name: { ...typography.body, color: c.text },
|
||||
folder: { ...typography.caption, color: c.textMuted },
|
||||
size: { ...typography.caption, color: c.textMuted },
|
||||
summary: {
|
||||
...typography.caption,
|
||||
color: c.textMuted,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.xs,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -60,16 +60,18 @@ export interface EmlPreview {
|
||||
date?: string;
|
||||
html: string | null;
|
||||
text: string | null;
|
||||
/** The message's own attachments (not the images its body embeds). */
|
||||
attachments: ExtractedAttachment[];
|
||||
}
|
||||
|
||||
/** Parse a message once for the preview: its unwrapped body plus the header lines shown above it. */
|
||||
export async function emlPreviewFromBytes(bytes: Uint8Array): Promise<EmlPreview> {
|
||||
const parsed = await parseMessage(bytes);
|
||||
const { html, text } = unwrapParsed(parsed);
|
||||
const { html, text, attachments } = unwrapParsed(parsed);
|
||||
const from = parsed.from && 'address' in parsed.from
|
||||
? `${parsed.from.name ? `${parsed.from.name} ` : ''}<${parsed.from.address}>`
|
||||
: parsed.from?.name;
|
||||
return { subject: parsed.subject, from, date: parsed.date, html, text };
|
||||
return { subject: parsed.subject, from, date: parsed.date, html, text, attachments };
|
||||
}
|
||||
|
||||
function unwrapParsed(parsed: ParsedEmail): UnwrappedMessage {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Alert, Platform } from 'react-native';
|
||||
import type { File } from 'expo-file-system';
|
||||
import { File } from 'expo-file-system';
|
||||
import type { Email } from '../../api/types';
|
||||
import {
|
||||
cachePreviewFile, copyPreviewFile, discardPreviewFile, saveLocalFileCopy, shareAttachment,
|
||||
@@ -9,12 +9,17 @@ import {
|
||||
import {
|
||||
exceedsPreviewLimit, inAppPreviewKind, type FilePreviewOptions,
|
||||
} from '../../lib/file-preview';
|
||||
import type { PreviewKind } from '../../lib/attachment-display';
|
||||
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 = { inlinePdf: Platform.OS === 'ios' };
|
||||
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. */
|
||||
@@ -62,20 +67,64 @@ function handOff(source: PreviewSource): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
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() };
|
||||
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 };
|
||||
}
|
||||
@@ -97,6 +146,12 @@ export function useFilePreview(opts: {
|
||||
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);
|
||||
@@ -113,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]);
|
||||
|
||||
@@ -153,8 +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.'));
|
||||
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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -1,52 +1,80 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
filePreviewKind, inAppPreviewKind, exceedsPreviewLimit, FILE_PREVIEW_MAX_BYTES,
|
||||
needsNativeImageDecoder, needsWebImageViewer, handoffMimeType,
|
||||
} from '../file-preview';
|
||||
|
||||
const IOS = { inlinePdf: true };
|
||||
const ANDROID = { inlinePdf: false };
|
||||
const IOS = { platform: 'ios' } as const;
|
||||
const ANDROID = { platform: 'android' } as const;
|
||||
|
||||
describe('filePreviewKind', () => {
|
||||
it('previews the images React Native decodes, by type or by extension', () => {
|
||||
it('previews common images by type or by extension', () => {
|
||||
expect(filePreviewKind({ name: 'photo.jpg', type: 'image/jpeg' }, ANDROID)).toBe('image');
|
||||
expect(filePreviewKind({ name: 'scan.PNG', type: 'application/octet-stream' }, ANDROID)).toBe('image');
|
||||
expect(filePreviewKind({ name: 'IMG_0001', type: 'image/heic' }, ANDROID)).toBe('image');
|
||||
expect(filePreviewKind({ name: 'anim.webp', type: '' }, ANDROID)).toBe('image');
|
||||
});
|
||||
|
||||
it('hands images it cannot decode to another app', () => {
|
||||
it('draws SVG and ICO images instead of handing them off', () => {
|
||||
expect(filePreviewKind({ name: 'logo.svg', type: 'image/svg+xml' }, IOS)).toBe('image');
|
||||
expect(filePreviewKind({ name: 'logo.svg', type: 'application/octet-stream' }, ANDROID)).toBe('image');
|
||||
expect(filePreviewKind({ name: 'favicon.ico', type: 'image/x-icon' }, ANDROID)).toBe('image');
|
||||
expect(filePreviewKind({ name: 'logo.svgz', type: 'image/svg+xml' }, IOS)).toBe('none');
|
||||
});
|
||||
|
||||
it('shows TIFF only where the platform decodes it', () => {
|
||||
expect(filePreviewKind({ name: 'fax.tiff', type: 'image/tiff' }, IOS)).toBe('image');
|
||||
expect(filePreviewKind({ name: 'fax.tif', type: '' }, IOS)).toBe('image');
|
||||
expect(filePreviewKind({ name: 'fax.tiff', type: 'image/tiff' }, ANDROID)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'raw.cr2', type: 'image/x-canon-cr2' }, ANDROID)).toBe('none');
|
||||
});
|
||||
|
||||
it('keeps script-bearing documents out of the preview', () => {
|
||||
expect(filePreviewKind({ name: 'logo.svg', type: 'image/svg+xml' }, IOS)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'page.html', type: 'text/html' }, IOS)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'page.htm', type: 'application/octet-stream' }, IOS)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'doc', type: 'application/xhtml+xml' }, IOS)).toBe('none');
|
||||
it('renders HTML and Markdown documents', () => {
|
||||
expect(filePreviewKind({ name: 'page.html', type: 'text/html' }, IOS)).toBe('html');
|
||||
expect(filePreviewKind({ name: 'page.htm', type: 'application/octet-stream' }, ANDROID)).toBe('html');
|
||||
expect(filePreviewKind({ name: 'doc', type: 'application/xhtml+xml' }, IOS)).toBe('html');
|
||||
expect(filePreviewKind({ name: 'README.md', type: 'application/octet-stream' }, ANDROID)).toBe('markdown');
|
||||
expect(filePreviewKind({ name: 'notes', type: 'text/markdown' }, ANDROID)).toBe('markdown');
|
||||
});
|
||||
|
||||
it('shows PDFs in-app only where the platform renders them', () => {
|
||||
it('shows PDFs in-app on both platforms', () => {
|
||||
expect(filePreviewKind({ name: 'report.pdf', type: 'application/pdf' }, IOS)).toBe('pdf');
|
||||
expect(filePreviewKind({ name: 'report.pdf', type: 'application/octet-stream' }, IOS)).toBe('pdf');
|
||||
expect(filePreviewKind({ name: 'report.pdf', type: 'application/pdf' }, ANDROID)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'report.pdf', type: 'application/pdf' }, ANDROID)).toBe('pdf');
|
||||
expect(filePreviewKind({ name: 'REPORT.PDF', type: '' }, ANDROID)).toBe('pdf');
|
||||
});
|
||||
|
||||
it('shows plain text, markdown and data files as text', () => {
|
||||
it('plays the video formats each platform plays', () => {
|
||||
expect(filePreviewKind({ name: 'clip.mp4', type: 'video/mp4' }, IOS)).toBe('video');
|
||||
expect(filePreviewKind({ name: 'clip.mov', type: 'video/quicktime' }, IOS)).toBe('video');
|
||||
expect(filePreviewKind({ name: 'clip.mp4', type: 'video/mp4' }, ANDROID)).toBe('video');
|
||||
expect(filePreviewKind({ name: 'clip.webm', type: 'video/webm' }, ANDROID)).toBe('video');
|
||||
expect(filePreviewKind({ name: 'clip.mkv', type: 'application/octet-stream' }, ANDROID)).toBe('video');
|
||||
expect(filePreviewKind({ name: 'clip.webm', type: 'video/webm' }, IOS)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'old.avi', type: 'video/x-msvideo' }, ANDROID)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'old.wmv', type: 'video/x-ms-wmv' }, IOS)).toBe('none');
|
||||
});
|
||||
|
||||
it('plays the audio formats each platform plays', () => {
|
||||
expect(filePreviewKind({ name: 'song.mp3', type: 'audio/mpeg' }, IOS)).toBe('audio');
|
||||
expect(filePreviewKind({ name: 'voice.m4a', type: '' }, IOS)).toBe('audio');
|
||||
expect(filePreviewKind({ name: 'memo.ogg', type: 'audio/ogg' }, ANDROID)).toBe('audio');
|
||||
expect(filePreviewKind({ name: 'memo.ogg', type: 'application/octet-stream' }, ANDROID)).toBe('audio');
|
||||
expect(filePreviewKind({ name: 'memo.opus', type: 'audio/ogg' }, IOS)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'call.amr', type: 'audio/amr' }, ANDROID)).toBe('none');
|
||||
});
|
||||
|
||||
it('shows plain text, data and source files as text', () => {
|
||||
expect(filePreviewKind({ name: 'notes.txt', type: 'text/plain' }, ANDROID)).toBe('text');
|
||||
expect(filePreviewKind({ name: 'README.md', type: 'application/octet-stream' }, ANDROID)).toBe('text');
|
||||
expect(filePreviewKind({ name: 'data.csv', type: 'text/csv' }, ANDROID)).toBe('text');
|
||||
expect(filePreviewKind({ name: 'config', type: 'application/json' }, ANDROID)).toBe('text');
|
||||
expect(filePreviewKind({ name: 'feed', type: 'application/atom+xml' }, ANDROID)).toBe('text');
|
||||
});
|
||||
|
||||
it('shows source code as text, including the script types the reader leaves out', () => {
|
||||
expect(filePreviewKind({ name: 'app.ts', type: 'application/octet-stream' }, ANDROID)).toBe('text');
|
||||
expect(filePreviewKind({ name: 'main.py', type: '' }, ANDROID)).toBe('text');
|
||||
expect(filePreviewKind({ name: 'build.sh', type: 'application/x-sh' }, ANDROID)).toBe('text');
|
||||
expect(filePreviewKind({ name: 'index.js', type: 'text/javascript' }, ANDROID)).toBe('text');
|
||||
expect(filePreviewKind({ name: 'bundle', type: 'application/javascript' }, ANDROID)).toBe('text');
|
||||
expect(filePreviewKind({ name: 'Dockerfile', type: '' }, ANDROID)).toBe('text');
|
||||
expect(filePreviewKind({ name: 'invite.ics', type: 'text/calendar' }, IOS)).toBe('text');
|
||||
});
|
||||
|
||||
it('shows saved messages like the reader does', () => {
|
||||
@@ -54,10 +82,29 @@ describe('filePreviewKind', () => {
|
||||
expect(filePreviewKind({ name: 'fwd', type: 'message/rfc822' }, ANDROID)).toBe('eml');
|
||||
});
|
||||
|
||||
it('leaves binaries and office documents to other apps', () => {
|
||||
expect(filePreviewKind({ name: 'archive.zip', type: 'application/zip' }, IOS)).toBe('none');
|
||||
it('goes by the name when the declared type is a catch-all', () => {
|
||||
expect(filePreviewKind({ name: 'invoice.pdf', type: 'application/x-as400attachment' }, IOS)).toBe('pdf');
|
||||
expect(filePreviewKind({ name: 'photo.jpg', type: 'application/x-as400attachment' }, ANDROID)).toBe('image');
|
||||
expect(filePreviewKind({ name: 'download', type: 'image/png' }, ANDROID)).toBe('image');
|
||||
});
|
||||
|
||||
it('lets a media type decide over the name', () => {
|
||||
expect(filePreviewKind({ name: 'clip.avi', type: 'video/mp4' }, ANDROID)).toBe('video');
|
||||
expect(filePreviewKind({ name: 'cover.mp3', type: 'image/jpeg' }, ANDROID)).toBe('image');
|
||||
});
|
||||
|
||||
it('lists the files in a ZIP, but not in the formats built on ZIP', () => {
|
||||
expect(filePreviewKind({ name: 'archive.zip', type: 'application/zip' }, IOS)).toBe('archive');
|
||||
expect(filePreviewKind({ name: 'photos', type: 'application/x-zip-compressed' }, ANDROID)).toBe('archive');
|
||||
expect(filePreviewKind({ name: 'Archive.ZIP', type: 'application/octet-stream' }, ANDROID)).toBe('archive');
|
||||
expect(filePreviewKind({ name: 'app.apk', type: 'application/vnd.android.package-archive' }, ANDROID)).toBe('none');
|
||||
});
|
||||
|
||||
it('leaves other archives, office documents and binaries to other apps', () => {
|
||||
expect(filePreviewKind({ name: 'backup.7z', type: 'application/x-7z-compressed' }, IOS)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'logs.tar.gz', type: 'application/gzip' }, ANDROID)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'sheet.xlsx', type: 'application/octet-stream' }, IOS)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'movie.mp4', type: 'video/mp4' }, IOS)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'contract.docx', type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }, ANDROID)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'data.bin', type: undefined }, IOS)).toBe('none');
|
||||
});
|
||||
|
||||
@@ -67,11 +114,26 @@ describe('filePreviewKind', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('image viewer choice', () => {
|
||||
it('sends formats Chromium cannot decode to the native decoder', () => {
|
||||
expect(needsNativeImageDecoder({ name: 'IMG.HEIC', type: '' })).toBe(true);
|
||||
expect(needsNativeImageDecoder({ name: 'x', type: 'image/heif' })).toBe(true);
|
||||
expect(needsNativeImageDecoder({ name: 'photo.jpg', type: 'image/jpeg' })).toBe(false);
|
||||
});
|
||||
|
||||
it('sends formats React Native cannot draw to the WebView', () => {
|
||||
expect(needsWebImageViewer({ name: 'logo.svg', type: '' })).toBe(true);
|
||||
expect(needsWebImageViewer({ name: 'x', type: 'image/x-icon' })).toBe(true);
|
||||
expect(needsWebImageViewer({ name: 'photo.jpg', type: 'image/jpeg' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('preview size limits', () => {
|
||||
it('caps text far below images and PDFs', () => {
|
||||
it('caps text far below images, PDFs and media', () => {
|
||||
expect(FILE_PREVIEW_MAX_BYTES.text).toBe(512 * 1024);
|
||||
expect(FILE_PREVIEW_MAX_BYTES.text).toBeLessThan(FILE_PREVIEW_MAX_BYTES.image);
|
||||
expect(FILE_PREVIEW_MAX_BYTES.image).toBeLessThan(FILE_PREVIEW_MAX_BYTES.pdf);
|
||||
expect(FILE_PREVIEW_MAX_BYTES.pdf).toBeLessThan(FILE_PREVIEW_MAX_BYTES.video);
|
||||
});
|
||||
|
||||
it('flags only known sizes over the cap', () => {
|
||||
@@ -89,5 +151,27 @@ describe('preview size limits', () => {
|
||||
expect(inAppPreviewKind({ ...big, size: undefined }, ANDROID)).toBe('text');
|
||||
expect(inAppPreviewKind({ name: 'pano.jpg', type: 'image/jpeg', size: 40 * 1024 * 1024 }, ANDROID)).toBe('none');
|
||||
expect(inAppPreviewKind({ name: 'pano.jpg', type: 'image/jpeg', size: 4 * 1024 * 1024 }, ANDROID)).toBe('image');
|
||||
expect(inAppPreviewKind({ name: 'film.mp4', type: 'video/mp4', size: 300 * 1024 * 1024 }, ANDROID)).toBe('video');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handoffMimeType', () => {
|
||||
it('keeps a real declared type', () => {
|
||||
expect(handoffMimeType('a.pdf', 'application/pdf')).toBe('application/pdf');
|
||||
expect(handoffMimeType('scan', 'image/png')).toBe('image/png');
|
||||
expect(handoffMimeType('a.zip', 'application/x-zip-compressed')).toBe('application/zip');
|
||||
});
|
||||
|
||||
it('names the format when the declared type does not, so Android finds a viewer', () => {
|
||||
expect(handoffMimeType('contract.docx', 'application/octet-stream'))
|
||||
.toBe('application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
||||
expect(handoffMimeType('invoice.PDF', '')).toBe('application/pdf');
|
||||
expect(handoffMimeType('invoice.pdf', 'application/x-as400attachment')).toBe('application/pdf');
|
||||
expect(handoffMimeType('photo.jpg', 'application/x-unknown')).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('falls back to the declared type or octet-stream for unknown names', () => {
|
||||
expect(handoffMimeType('data.bin', '')).toBe('application/octet-stream');
|
||||
expect(handoffMimeType('data.xyz', 'application/x-foo')).toBe('application/x-foo');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { markdownToHtml, renderInline } from '../markdown-html';
|
||||
|
||||
const body = (md: string) => {
|
||||
const html = markdownToHtml(md);
|
||||
return html.slice(html.indexOf('<body>') + 6, html.indexOf('</body>'));
|
||||
};
|
||||
|
||||
describe('markdownToHtml', () => {
|
||||
it('renders headings, paragraphs and rules', () => {
|
||||
expect(body('# Title\n\nSome text\nwrapped.\n\n---\n\n### Small ###'))
|
||||
.toBe('<h1>Title</h1>\n<p>Some text\nwrapped.</p>\n<hr>\n<h3>Small</h3>');
|
||||
expect(body('Big\n===\n\nSmaller\n---')).toBe('<h1>Big</h1>\n<h2>Smaller</h2>');
|
||||
});
|
||||
|
||||
it('renders nested and ordered lists and task items', () => {
|
||||
expect(body('- one\n- two\n - nested\n- three')).toBe(
|
||||
'<ul><li>one</li><li>two\n<ul><li>nested</li></ul></li><li>three</li></ul>',
|
||||
);
|
||||
expect(body('3. c\n4. d')).toBe('<ol start="3"><li>c</li><li>d</li></ol>');
|
||||
expect(body('- [x] done\n- [ ] open')).toBe('<ul><li class="task">\u2611 done</li><li class="task">\u2610 open</li></ul>');
|
||||
});
|
||||
|
||||
it('keeps fenced and indented code verbatim and escaped', () => {
|
||||
expect(body('```js\nconst a = "<b>";\n**not bold**\n```')).toBe(
|
||||
'<pre><code class="language-js">const a = "<b>";\n**not bold**</code></pre>',
|
||||
);
|
||||
expect(body(' x < 1')).toBe('<pre><code>x < 1</code></pre>');
|
||||
});
|
||||
|
||||
it('renders quotes and GFM tables with alignment', () => {
|
||||
expect(body('> quoted\n> **text**')).toBe('<blockquote><p>quoted\n<strong>text</strong></p></blockquote>');
|
||||
expect(body('| a | b |\n|:--|--:|\n| 1 | 2 |')).toBe(
|
||||
'<table><thead><tr><th style="text-align:left">a</th><th style="text-align:right">b</th></tr></thead>'
|
||||
+ '<tbody><tr><td style="text-align:left">1</td><td style="text-align:right">2</td></tr></tbody></table>',
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes raw HTML instead of passing it through', () => {
|
||||
expect(body('<script>alert(1)</script>')).toBe('<p><script>alert(1)</script></p>');
|
||||
expect(body('<img src=x onerror=alert(1)>')).not.toContain('<img');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderInline', () => {
|
||||
it('renders emphasis, code and strikethrough', () => {
|
||||
expect(renderInline('**b** *i* _j_ `c*d*` ~~s~~')).toBe(
|
||||
'<strong>b</strong> <em>i</em> <em>j</em> <code>c*d*</code> <del>s</del>',
|
||||
);
|
||||
expect(renderInline('snake_case_name stays')).toBe('snake_case_name stays');
|
||||
expect(renderInline('\\*literal\\*')).toBe('*literal*');
|
||||
});
|
||||
|
||||
it('links only to web and mail URLs', () => {
|
||||
expect(renderInline('[site](https://example.org "T")')).toBe('<a href="https://example.org" title="T">site</a>');
|
||||
expect(renderInline('[x](javascript:alert(1))')).toBe('x)');
|
||||
expect(renderInline('[doc](./docs/a.md)')).toBe('doc');
|
||||
expect(renderInline('see https://example.org/a.')).toBe('see <a href="https://example.org/a">https://example.org/a</a>.');
|
||||
expect(renderInline('<mailto:a@b.c>')).toBe('<a href="mailto:a@b.c">mailto:a@b.c</a>');
|
||||
});
|
||||
|
||||
it('keeps image URLs for the sanitiser and drops unsafe ones', () => {
|
||||
expect(renderInline('')).toBe('<img src="https://example.org/l.png" alt="logo">');
|
||||
expect(renderInline('')).toBe('x');
|
||||
});
|
||||
|
||||
it('escapes attribute breakouts in link text and titles', () => {
|
||||
expect(renderInline('[a"b](https://e.org "x\\" onmouseover=\\"y")')).not.toMatch(/onmouseover="/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NativeModules } from 'react-native';
|
||||
import { openPdfPages, PdfOpenError, renderPdfPage, canRenderPdfPages } from '../pdf-pages';
|
||||
|
||||
const modules = NativeModules as Record<string, unknown>;
|
||||
|
||||
function nativeError(code: string, message: string): Error {
|
||||
return Object.assign(new Error(message), { code });
|
||||
}
|
||||
|
||||
describe('pdf-pages', () => {
|
||||
afterEach(() => { delete modules.BulwarkPdf; });
|
||||
|
||||
it('reports the page sizes the renderer returns', async () => {
|
||||
modules.BulwarkPdf = { open: vi.fn(async () => ({ pages: [{ width: 595, height: 842 }] })) };
|
||||
await expect(openPdfPages('file:///x.pdf')).resolves.toEqual([{ width: 595, height: 842 }]);
|
||||
});
|
||||
|
||||
it('tells a password-protected PDF from an unreadable one', async () => {
|
||||
modules.BulwarkPdf = { open: vi.fn(async () => { throw nativeError('E_PDF_PROTECTED', 'password'); }) };
|
||||
await expect(openPdfPages('file:///x.pdf')).rejects.toMatchObject({ reason: 'protected' });
|
||||
|
||||
modules.BulwarkPdf = { open: vi.fn(async () => { throw nativeError('E_PDF_OPEN', 'not a PDF'); }) };
|
||||
await expect(openPdfPages('file:///x.pdf')).rejects.toMatchObject({ reason: 'unreadable' });
|
||||
|
||||
modules.BulwarkPdf = { open: vi.fn(async () => ({ pages: [] })) };
|
||||
await expect(openPdfPages('file:///x.pdf')).rejects.toBeInstanceOf(PdfOpenError);
|
||||
});
|
||||
|
||||
it('fails cleanly in a build without the renderer', async () => {
|
||||
expect(canRenderPdfPages()).toBe(false);
|
||||
await expect(openPdfPages('file:///x.pdf')).rejects.toMatchObject({ reason: 'unreadable' });
|
||||
await expect(renderPdfPage('file:///x.pdf', 0, 100, 'file:///p.png')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('renders at a whole pixel width', async () => {
|
||||
const renderPage = vi.fn(async () => 'file:///p.png');
|
||||
modules.BulwarkPdf = { renderPage };
|
||||
await renderPdfPage('file:///x.pdf', 2, 1620.6, 'file:///p.png');
|
||||
expect(renderPage).toHaveBeenCalledWith('file:///x.pdf', 2, 1621, 'file:///p.png');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import JSZip from 'jszip';
|
||||
import { openZip, isZipArchive, ZipEncryptedError, MAX_ZIP_ENTRIES } from '../zip-preview';
|
||||
|
||||
async function makeZip(files: Record<string, string>): Promise<Uint8Array> {
|
||||
const zip = new JSZip();
|
||||
for (const [path, text] of Object.entries(files)) zip.file(path, text);
|
||||
return zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' });
|
||||
}
|
||||
|
||||
// Set the "encrypted" bit in every local and central header, as a
|
||||
// password-protected archive has it.
|
||||
function markEncrypted(bytes: Uint8Array): Uint8Array {
|
||||
const out = bytes.slice();
|
||||
const view = new DataView(out.buffer);
|
||||
for (let i = 0; i + 4 <= out.length; i++) {
|
||||
const sig = view.getUint32(i, true);
|
||||
if (sig === 0x04034b50) view.setUint16(i + 6, view.getUint16(i + 6, true) | 1, true);
|
||||
if (sig === 0x02014b50) view.setUint16(i + 8, view.getUint16(i + 8, true) | 1, true);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('openZip', () => {
|
||||
it('lists the files by folder and name, without folders or OS metadata', async () => {
|
||||
const archive = await openZip(await makeZip({
|
||||
'b.txt': 'b',
|
||||
'docs/z.md': '# z',
|
||||
'a.txt': 'a',
|
||||
'docs/inner/c.csv': '1,2',
|
||||
'__MACOSX/docs/._z.md': 'junk',
|
||||
'.DS_Store': 'junk',
|
||||
'docs/Thumbs.db': 'junk',
|
||||
}));
|
||||
expect(archive.entries.map((e) => e.path)).toEqual(['a.txt', 'b.txt', 'docs/z.md', 'docs/inner/c.csv']);
|
||||
expect(archive.entries[2]).toMatchObject({ name: 'z.md', folder: 'docs', size: 3 });
|
||||
expect(archive.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('reads an entry from the loaded archive', async () => {
|
||||
const archive = await openZip(await makeZip({ 'docs/notes.txt': 'Grüße' }));
|
||||
expect(new TextDecoder().decode(await archive.read('docs/notes.txt'))).toBe('Grüße');
|
||||
await expect(archive.read('missing.txt')).rejects.toThrow(/not in the archive/);
|
||||
});
|
||||
|
||||
it('stops listing at the cap', async () => {
|
||||
const files: Record<string, string> = {};
|
||||
for (let i = 0; i < MAX_ZIP_ENTRIES + 5; i++) files[`f${i}.txt`] = '';
|
||||
const archive = await openZip(await makeZip(files));
|
||||
expect(archive.entries).toHaveLength(MAX_ZIP_ENTRIES);
|
||||
expect(archive.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it('says when an archive is password-protected', async () => {
|
||||
const locked = markEncrypted(await makeZip({ 'secret.txt': 'x' }));
|
||||
await expect(openZip(locked)).rejects.toBeInstanceOf(ZipEncryptedError);
|
||||
});
|
||||
|
||||
it('rejects data that is not a ZIP', async () => {
|
||||
await expect(openZip(new TextEncoder().encode('not a zip'))).rejects.not.toBeInstanceOf(ZipEncryptedError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isZipArchive', () => {
|
||||
it('takes ZIPs by type or name, not the formats built on ZIP', () => {
|
||||
expect(isZipArchive('a.zip', 'application/zip')).toBe(true);
|
||||
expect(isZipArchive('bundle', 'application/x-zip-compressed')).toBe(true);
|
||||
expect(isZipArchive('Archive.ZIP', 'application/octet-stream')).toBe(true);
|
||||
expect(isZipArchive('contract.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')).toBe(false);
|
||||
expect(isZipArchive('app.apk', 'application/vnd.android.package-archive')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -140,24 +140,6 @@ export function calendarBannerShownFor(email: Pick<Email, 'attachments'>, parsin
|
||||
return parsingEnabled && !!findCalendarAttachment(email);
|
||||
}
|
||||
|
||||
export type PreviewKind = 'image' | 'pdf' | 'text' | 'eml' | 'none';
|
||||
|
||||
const TEXT_EXT = /\.(txt|md|markdown|csv|log|json|xml|ics|vcf|yml|yaml|ini|conf|diff|patch)$/i;
|
||||
// Script-bearing types are never previewed inline (html/svg/js) - webmail lib/file-preview.ts.
|
||||
const SCRIPT_TYPES = /^(text\/html|image\/svg\+xml|application\/(x-)?javascript|text\/javascript|application\/xhtml\+xml)$/i;
|
||||
|
||||
/** How the in-app preview should render a part, if at all. */
|
||||
export function previewKindFor(att: Pick<Attachment, 'name' | 'type'>): PreviewKind {
|
||||
const base = (att.type || '').split(';')[0].trim().toLowerCase();
|
||||
const name = att.name || '';
|
||||
if (SCRIPT_TYPES.test(base) || /\.(html?|svg|js)$/i.test(name)) return 'none';
|
||||
if (isRfc822Attachment(att)) return 'eml';
|
||||
if (base.startsWith('image/') || /\.(png|jpe?g|gif|webp|bmp|heic|avif)$/i.test(name)) return 'image';
|
||||
if (base === 'application/pdf' || /\.pdf$/i.test(name)) return 'pdf';
|
||||
if (base.startsWith('text/') || base === 'application/json' || base === 'application/xml' || TEXT_EXT.test(name)) return 'text';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
export function formatSize(bytes?: number): string {
|
||||
if (!bytes) return '';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from './download-filename';
|
||||
import { getClientCertAlias, secureFetch } from './client-cert';
|
||||
import { sniffImageMime } from './email-html';
|
||||
import { handoffMimeType } from './file-preview';
|
||||
|
||||
const RFC822 = 'message/rfc822';
|
||||
const FLAG_GRANT_READ_URI_PERMISSION = 0x00000001;
|
||||
@@ -156,7 +157,7 @@ async function openWithViewer(file: File, mimeType: string): Promise<boolean> {
|
||||
// intent. Deliberately no FLAG_ACTIVITY_NEW_TASK: expo-intent-launcher
|
||||
// uses startActivityForResult, and a new task cancels that result.
|
||||
data: file.contentUri,
|
||||
type: mimeType,
|
||||
type: handoffMimeType(file.name, mimeType),
|
||||
flags: FLAG_GRANT_READ_URI_PERMISSION,
|
||||
});
|
||||
return true;
|
||||
@@ -259,7 +260,7 @@ export async function shareAttachment(
|
||||
}
|
||||
try {
|
||||
await Sharing.shareAsync(downloaded.uri, {
|
||||
mimeType,
|
||||
mimeType: handoffMimeType(filename, mimeType),
|
||||
dialogTitle: filename,
|
||||
});
|
||||
} finally {
|
||||
@@ -296,7 +297,7 @@ async function offerSavedFile(file: File, filename: string, mimeType: string): P
|
||||
throw new Error(t('files.share_unavailable', 'Sharing is not available on this device'));
|
||||
}
|
||||
await Sharing.shareAsync(file.uri, {
|
||||
mimeType,
|
||||
mimeType: handoffMimeType(filename, mimeType),
|
||||
dialogTitle: filename,
|
||||
});
|
||||
}
|
||||
@@ -422,7 +423,7 @@ export async function shareLocalFile(
|
||||
throw new Error(t('files.share_unavailable', 'Sharing is not available on this device'));
|
||||
}
|
||||
try {
|
||||
await Sharing.shareAsync(file.uri, { mimeType, dialogTitle: dialogTitle ?? file.name });
|
||||
await Sharing.shareAsync(file.uri, { mimeType: handoffMimeType(file.name, mimeType), dialogTitle: dialogTitle ?? file.name });
|
||||
} finally {
|
||||
if (!opts.keep) scheduleTempCleanup(file);
|
||||
}
|
||||
|
||||
+218
-65
@@ -1,19 +1,19 @@
|
||||
// Which Files-tab nodes open in the in-app preview, and how large they may be.
|
||||
// Builds on the reader's attachment classification (`previewKindFor`) so both
|
||||
// previews agree, and adds what a drive holds more often than mail: source
|
||||
// code and config files, shown as text. Port of the webmail's
|
||||
// `lib/file-preview.ts`, narrowed to what the native preview can render;
|
||||
// everything else keeps going to the OS "Open with" chooser.
|
||||
// Which files open in the in-app preview, how, and how large they may be.
|
||||
// One classification for every place that opens a file: the reader's and the
|
||||
// list's attachment chips, the Files tab and compose. Port of the webmail's
|
||||
// `lib/file-preview.ts`, narrowed to what each platform's viewers render;
|
||||
// everything else goes to the OS "Open with" chooser.
|
||||
|
||||
import { previewKindFor, type PreviewKind } from './attachment-display';
|
||||
export type PreviewKind =
|
||||
| 'image' | 'pdf' | 'text' | 'markdown' | 'html' | 'eml' | 'video' | 'audio' | 'archive' | 'none';
|
||||
|
||||
export interface FilePreviewOptions {
|
||||
/**
|
||||
* Whether this platform renders PDFs in-app. iOS does, in the WebView;
|
||||
* Android has no built-in renderer, so a PDF there goes straight to the
|
||||
* PDF viewer instead of a preview screen that can only offer that viewer.
|
||||
* The platform's viewers decide what plays: video and audio play in the
|
||||
* system WebView (Chromium on Android, WebKit on iOS), whose formats
|
||||
* differ.
|
||||
*/
|
||||
inlinePdf: boolean;
|
||||
platform: 'ios' | 'android';
|
||||
}
|
||||
|
||||
interface PreviewableFile {
|
||||
@@ -24,74 +24,137 @@ 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; parsing an
|
||||
* .eml and decoding an image hold the whole file in memory; PDFs are paged by
|
||||
* the WebView. Larger files open in another app instead.
|
||||
* `<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, 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: 50 * 1024 * 1024,
|
||||
pdf: 100 * 1024 * 1024,
|
||||
audio: 500 * 1024 * 1024,
|
||||
video: 1024 * 1024 * 1024,
|
||||
};
|
||||
|
||||
// Script-bearing documents open in the browser, as in the reader. The webmail
|
||||
// renders HTML in a sandboxed iframe and SVG through <img>; neither has a safe
|
||||
// native equivalent here.
|
||||
const NEVER_INLINE_MIME = new Set(['text/html', 'application/xhtml+xml', 'image/svg+xml']);
|
||||
const NEVER_INLINE_EXT = /\.(html?|xht(ml)?|svgz?)$/i;
|
||||
|
||||
// Formats React Native's Image decodes. Other image types (TIFF, RAW, PSD,
|
||||
// ICO) would render blank.
|
||||
const RENDERABLE_IMAGE_MIME = new Set([
|
||||
'image/png', 'image/jpeg', 'image/jpg', 'image/pjpeg', 'image/gif', 'image/webp',
|
||||
'image/bmp', 'image/x-ms-bmp', 'image/heic', 'image/heif', 'image/avif',
|
||||
]);
|
||||
const RENDERABLE_IMAGE_EXT = /\.(png|jpe?g|jfif|gif|webp|bmp|heic|heif|avif)$/i;
|
||||
|
||||
// Source and config files the reader's list doesn't cover (it only sees mail
|
||||
// attachments). The webmail's TEXT_EXTENSIONS plus the languages the Files
|
||||
// tab already draws a code icon for.
|
||||
const CODE_EXT = new RegExp(
|
||||
'\\.('
|
||||
+ 'text|tsv|toml|cfg|env|properties|sql|graphql|gql|css|scss|sass|less'
|
||||
+ '|js|mjs|cjs|jsx|ts|mts|cts|tsx|vue|svelte'
|
||||
+ '|py|rb|go|rs|java|kt|kts|swift|c|h|cc|cpp|hpp|cs|php|pl|lua|r|dart|scala|gradle'
|
||||
+ '|sh|bash|zsh|fish|ps1|bat|cmd'
|
||||
+ '|tex|rst|adoc|org|srt|vtt'
|
||||
+ ')$',
|
||||
'i',
|
||||
);
|
||||
const CODE_FILE_NAMES = new Set(['dockerfile', 'makefile', 'readme', 'license', 'changelog', '.gitignore', '.editorconfig']);
|
||||
const TEXT_MIME = new Set([
|
||||
'application/json', 'application/ld+json', 'application/xml', 'application/javascript',
|
||||
'application/x-javascript', 'text/javascript', 'application/typescript', 'application/x-sh',
|
||||
'application/x-yaml', 'application/yaml', 'application/toml', 'application/sql', 'application/graphql',
|
||||
]);
|
||||
|
||||
function baseMime(type?: string | null): string {
|
||||
return (type || '').split(';')[0].trim().toLowerCase();
|
||||
}
|
||||
|
||||
function extension(name: string): string {
|
||||
const dot = name.lastIndexOf('.');
|
||||
return dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
|
||||
}
|
||||
|
||||
// Generic types say nothing about the file; the name decides.
|
||||
const GENERIC_MIME = new Set(['', 'application/octet-stream', 'binary/octet-stream', 'application/unknown']);
|
||||
|
||||
// Images React Native's Image decodes, plus SVG and ICO, which the preview
|
||||
// draws in a WebView. iOS decodes TIFF as well; Android's decoders don't.
|
||||
const IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'jfif', 'pjpeg', 'gif', 'webp', 'bmp', 'heic', 'heif', 'avif', 'svg', 'ico']);
|
||||
const IMAGE_MIME = new Set([
|
||||
'image/png', 'image/jpeg', 'image/jpg', 'image/pjpeg', 'image/gif', 'image/webp', 'image/bmp',
|
||||
'image/x-ms-bmp', 'image/heic', 'image/heif', 'image/avif', 'image/svg+xml', 'image/x-icon',
|
||||
'image/vnd.microsoft.icon', 'image/ico',
|
||||
]);
|
||||
const IOS_IMAGE_EXT = new Set(['tif', 'tiff']);
|
||||
const IOS_IMAGE_MIME = new Set(['image/tiff', 'image/tif']);
|
||||
|
||||
// What the system WebView plays. Chromium handles the open formats and
|
||||
// Matroska; WebKit handles Apple's, and neither plays AVI, WMV or FLV.
|
||||
const MEDIA: Record<'video' | 'audio', Record<FilePreviewOptions['platform'], { ext: Set<string>; mime: Set<string> }>> = {
|
||||
video: {
|
||||
android: {
|
||||
ext: new Set(['mp4', 'm4v', 'mov', 'webm', 'ogv', 'mkv', '3gp', '3g2']),
|
||||
mime: new Set(['video/mp4', 'video/x-m4v', 'video/quicktime', 'video/webm', 'video/ogg', 'video/x-matroska', 'video/3gpp', 'video/3gpp2']),
|
||||
},
|
||||
ios: {
|
||||
ext: new Set(['mp4', 'm4v', 'mov', '3gp', '3g2']),
|
||||
mime: new Set(['video/mp4', 'video/x-m4v', 'video/quicktime', 'video/3gpp', 'video/3gpp2']),
|
||||
},
|
||||
},
|
||||
audio: {
|
||||
android: {
|
||||
ext: new Set(['mp3', 'm4a', 'aac', 'wav', 'ogg', 'oga', 'opus', 'flac', 'weba']),
|
||||
mime: new Set([
|
||||
'audio/mpeg', 'audio/mp3', 'audio/mp4', 'audio/x-m4a', 'audio/aac', 'audio/wav', 'audio/x-wav', 'audio/wave',
|
||||
'audio/ogg', 'audio/opus', 'audio/flac', 'audio/x-flac', 'audio/webm',
|
||||
]),
|
||||
},
|
||||
ios: {
|
||||
ext: new Set(['mp3', 'm4a', 'aac', 'wav', 'flac', 'aif', 'aiff', 'caf']),
|
||||
mime: new Set([
|
||||
'audio/mpeg', 'audio/mp3', 'audio/mp4', 'audio/x-m4a', 'audio/aac', 'audio/wav', 'audio/x-wav', 'audio/wave',
|
||||
'audio/flac', 'audio/x-flac', 'audio/aiff', 'audio/x-aiff', 'audio/x-caf',
|
||||
]),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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']);
|
||||
const MARKDOWN_EXT = new Set(['md', 'markdown', 'mdown', 'mkd', 'mkdn']);
|
||||
|
||||
// Plain text and source files, shown as text. Script types are inert there.
|
||||
const TEXT_EXT = new Set([
|
||||
'txt', 'text', 'log', 'csv', 'tsv', 'json', 'xml', 'ics', 'vcf', 'yml', 'yaml', 'ini', 'conf', 'cfg', 'env',
|
||||
'properties', 'toml', 'diff', 'patch', 'sql', 'graphql', 'gql', 'css', 'scss', 'sass', 'less',
|
||||
'js', 'mjs', 'cjs', 'jsx', 'ts', 'mts', 'cts', 'tsx', 'vue', 'svelte',
|
||||
'py', 'rb', 'go', 'rs', 'java', 'kt', 'kts', 'swift', 'c', 'h', 'cc', 'cpp', 'hpp', 'cs', 'php', 'pl', 'lua', 'r',
|
||||
'dart', 'scala', 'gradle', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd',
|
||||
'tex', 'rst', 'adoc', 'org', 'srt', 'vtt',
|
||||
]);
|
||||
const TEXT_FILE_NAMES = new Set(['dockerfile', 'makefile', 'readme', 'license', 'changelog', '.gitignore', '.editorconfig']);
|
||||
const TEXT_MIME = new Set([
|
||||
'application/json', 'application/ld+json', 'application/xml', 'application/javascript',
|
||||
'application/x-javascript', 'application/typescript', 'application/x-sh', 'application/x-yaml',
|
||||
'application/yaml', 'application/toml', 'application/sql', 'application/graphql',
|
||||
'application/ics', 'application/icalendar',
|
||||
]);
|
||||
|
||||
/** How the in-app preview renders a file, or 'none' for the OS chooser. */
|
||||
export function filePreviewKind(file: Pick<PreviewableFile, 'name' | 'type'>, opts: FilePreviewOptions): PreviewKind {
|
||||
const mime = baseMime(file.type);
|
||||
const declared = baseMime(file.type);
|
||||
const mime = GENERIC_MIME.has(declared) ? '' : declared;
|
||||
const name = file.name || '';
|
||||
if (NEVER_INLINE_MIME.has(mime) || NEVER_INLINE_EXT.test(name)) return 'none';
|
||||
const kind = previewKindFor({ name, type: mime });
|
||||
switch (kind) {
|
||||
case 'image':
|
||||
return RENDERABLE_IMAGE_MIME.has(mime) || RENDERABLE_IMAGE_EXT.test(name) ? 'image' : 'none';
|
||||
case 'pdf':
|
||||
return opts.inlinePdf ? 'pdf' : 'none';
|
||||
case 'none':
|
||||
// The reader keeps .js and friends out; shown as source text they are inert.
|
||||
if (CODE_EXT.test(name) || CODE_FILE_NAMES.has(name.toLowerCase())) return 'text';
|
||||
if (TEXT_MIME.has(mime) || /\+(json|xml)$/.test(mime)) return 'text';
|
||||
return 'none';
|
||||
default:
|
||||
return kind;
|
||||
const ext = extension(name);
|
||||
// An image, audio or video type decides the viewer. Any other type leaves
|
||||
// it to the name: senders put catch-alls such as the
|
||||
// application/x-as400attachment IBM i systems use on every part.
|
||||
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';
|
||||
|
||||
if (mime.startsWith('image/') || (!media && (IMAGE_EXT.has(ext) || IOS_IMAGE_EXT.has(ext)))) {
|
||||
// Compressed SVG needs a Content-Encoding a local file doesn't have.
|
||||
if (ext === 'svgz') return 'none';
|
||||
const ios = opts.platform === 'ios';
|
||||
const shown = IMAGE_MIME.has(mime) || IMAGE_EXT.has(ext)
|
||||
|| (ios && (IOS_IMAGE_MIME.has(mime) || IOS_IMAGE_EXT.has(ext)));
|
||||
return shown ? 'image' : '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';
|
||||
if (!media && formats.ext.has(ext)) return kind;
|
||||
}
|
||||
|
||||
if (mime.startsWith('text/') || TEXT_MIME.has(mime) || /\+(json|xml)$/.test(mime)) return 'text';
|
||||
if (TEXT_EXT.has(ext) || TEXT_FILE_NAMES.has(name.toLowerCase())) return 'text';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/** True when a known size is over the preview cap for `kind`. Unknown sizes pass; the caller checks the download. */
|
||||
@@ -102,9 +165,99 @@ export function exceedsPreviewLimit(kind: PreviewKind, size: number | null | und
|
||||
|
||||
/**
|
||||
* The preview a tap on this file opens: its kind when the app can show it and
|
||||
* the file is within the cap, else 'none' (hand it to another app, as before).
|
||||
* the file is within the cap, else 'none' (hand it to another app).
|
||||
*/
|
||||
export function inAppPreviewKind(file: PreviewableFile, opts: FilePreviewOptions): PreviewKind {
|
||||
const kind = filePreviewKind(file, opts);
|
||||
return exceedsPreviewLimit(kind, file.size) ? 'none' : kind;
|
||||
}
|
||||
|
||||
/** Images Android's WebView can't decode, which the preview draws with React Native's Image instead. */
|
||||
export function needsNativeImageDecoder(file: Pick<PreviewableFile, 'name' | 'type'>): boolean {
|
||||
const mime = baseMime(file.type);
|
||||
return /^image\/(heic|heif|tiff?)$/.test(mime) || /\.(heic|heif|tiff?)$/i.test(file.name);
|
||||
}
|
||||
|
||||
/** Images React Native's Image can't draw, which go to the WebView viewer on iOS too. */
|
||||
export function needsWebImageViewer(file: Pick<PreviewableFile, 'name' | 'type'>): boolean {
|
||||
const mime = baseMime(file.type);
|
||||
return /^image\/(svg\+xml|x-icon|vnd\.microsoft\.icon|ico)$/.test(mime) || /\.(svg|ico)$/i.test(file.name);
|
||||
}
|
||||
|
||||
// Types for handing a file to another app when the sender declared none, or
|
||||
// a catch-all: Android picks the apps for "Open with" by type, and finds
|
||||
// none for application/octet-stream.
|
||||
const EXT_TO_MIME: Record<string, string> = {
|
||||
pdf: 'application/pdf',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
odt: 'application/vnd.oasis.opendocument.text',
|
||||
ods: 'application/vnd.oasis.opendocument.spreadsheet',
|
||||
odp: 'application/vnd.oasis.opendocument.presentation',
|
||||
rtf: 'application/rtf',
|
||||
epub: 'application/epub+zip',
|
||||
zip: 'application/zip',
|
||||
'7z': 'application/x-7z-compressed',
|
||||
rar: 'application/vnd.rar',
|
||||
gz: 'application/gzip',
|
||||
tar: 'application/x-tar',
|
||||
apk: 'application/vnd.android.package-archive',
|
||||
txt: 'text/plain',
|
||||
csv: 'text/csv',
|
||||
html: 'text/html',
|
||||
htm: 'text/html',
|
||||
md: 'text/markdown',
|
||||
json: 'application/json',
|
||||
xml: 'application/xml',
|
||||
ics: 'text/calendar',
|
||||
vcf: 'text/vcard',
|
||||
eml: 'message/rfc822',
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
heic: 'image/heic',
|
||||
heif: 'image/heif',
|
||||
avif: 'image/avif',
|
||||
bmp: 'image/bmp',
|
||||
tif: 'image/tiff',
|
||||
tiff: 'image/tiff',
|
||||
svg: 'image/svg+xml',
|
||||
ico: 'image/x-icon',
|
||||
mp3: 'audio/mpeg',
|
||||
m4a: 'audio/mp4',
|
||||
aac: 'audio/aac',
|
||||
wav: 'audio/wav',
|
||||
ogg: 'audio/ogg',
|
||||
opus: 'audio/ogg',
|
||||
flac: 'audio/flac',
|
||||
amr: 'audio/amr',
|
||||
mp4: 'video/mp4',
|
||||
m4v: 'video/mp4',
|
||||
mov: 'video/quicktime',
|
||||
webm: 'video/webm',
|
||||
mkv: 'video/x-matroska',
|
||||
avi: 'video/x-msvideo',
|
||||
wmv: 'video/x-ms-wmv',
|
||||
'3gp': 'video/3gpp',
|
||||
};
|
||||
|
||||
/**
|
||||
* The type to hand a file to another app with: the declared one, unless it
|
||||
* is generic or a catch-all that names no format, in which case the one the
|
||||
* file name implies.
|
||||
*/
|
||||
export function handoffMimeType(name: string | undefined, type: string | null | undefined): string {
|
||||
const declared = baseMime(type);
|
||||
const fromName = EXT_TO_MIME[extension(name || '')];
|
||||
if (!fromName) return type || 'application/octet-stream';
|
||||
if (GENERIC_MIME.has(declared)) return fromName;
|
||||
// An "x-" application type the name contradicts, such as x-as400attachment.
|
||||
if (/^application\/x-/.test(declared) && declared !== fromName) return fromName;
|
||||
return type || fromName;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
// Markdown to HTML for the file preview. Covers what README files and notes
|
||||
// use: ATX and setext headings, paragraphs, emphasis, strikethrough, inline
|
||||
// and fenced code, block quotes, nested lists and task lists, GFM tables,
|
||||
// rules, links, images and autolinks. Every piece of source text is escaped
|
||||
// and only http(s)/mailto URLs survive, so the output carries no markup the
|
||||
// author wrote; it is still shown through the mail-body sanitiser, which
|
||||
// blocks remote images like it does for mail.
|
||||
|
||||
const ESCAPES: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/[&<>"']/g, (ch) => ESCAPES[ch]);
|
||||
}
|
||||
|
||||
function safeUrl(raw: string): string | null {
|
||||
const url = raw.trim().replace(/^<|>$/g, '');
|
||||
if (/^(https?:|mailto:)/i.test(url)) return url;
|
||||
// Relative links point into a repository the preview doesn't have.
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Inline markup of one block's text. */
|
||||
export function renderInline(src: string): string {
|
||||
// Code spans first: nothing inside them is markup.
|
||||
const codes: string[] = [];
|
||||
let s = src.replace(/(`+)([\s\S]*?[^`])\1(?!`)/g, (_m, _ticks, code: string) => {
|
||||
codes.push(`<code>${escapeHtml(code.trim())}</code>`);
|
||||
return `\u0000${codes.length - 1}\u0000`;
|
||||
});
|
||||
// Backslash escapes.
|
||||
const escaped: string[] = [];
|
||||
s = s.replace(/\\([\\`*_{}[\]()#+\-.!|~>])/g, (_m, ch: string) => {
|
||||
escaped.push(escapeHtml(ch));
|
||||
return `\u0001${escaped.length - 1}\u0001`;
|
||||
});
|
||||
// Links and images, before escaping so the brackets are still there.
|
||||
const links: string[] = [];
|
||||
const stash = (html: string) => {
|
||||
links.push(html);
|
||||
return `\u0002${links.length - 1}\u0002`;
|
||||
};
|
||||
s = s.replace(/!\[([^\]]*)\]\(\s*(<[^>]*>|[^\s)]+)(?:\s+"([^"]*)")?\s*\)/g, (_m, alt: string, url: string, title?: string) => {
|
||||
const href = safeUrl(url);
|
||||
if (!href) return stash(escapeHtml(alt));
|
||||
const t = title ? ` title="${escapeHtml(title)}"` : '';
|
||||
return stash(`<img src="${escapeHtml(href)}" alt="${escapeHtml(alt)}"${t}>`);
|
||||
});
|
||||
s = s.replace(/\[([^\]]+)\]\(\s*(<[^>]*>|[^\s)]+)(?:\s+"([^"]*)")?\s*\)/g, (_m, text: string, url: string, title?: string) => {
|
||||
const href = safeUrl(url);
|
||||
const inner = renderEmphasis(escapeHtml(text));
|
||||
if (!href) return stash(inner);
|
||||
const t = title ? ` title="${escapeHtml(title)}"` : '';
|
||||
return stash(`<a href="${escapeHtml(href)}"${t}>${inner}</a>`);
|
||||
});
|
||||
s = s.replace(/<((?:https?:\/\/|mailto:)[^\s>]+)>/gi, (_m, url: string) => stash(`<a href="${escapeHtml(url)}">${escapeHtml(url)}</a>`));
|
||||
s = s.replace(/(^|[\s(])(https?:\/\/[^\s<>()]+[^\s<>().,;:!?'"])/g, (_m, lead: string, url: string) => (
|
||||
lead + stash(`<a href="${escapeHtml(url)}">${escapeHtml(url)}</a>`)
|
||||
));
|
||||
|
||||
s = renderEmphasis(escapeHtml(s));
|
||||
// Hard line breaks: two trailing spaces or a backslash.
|
||||
s = s.replace(/ {2,}\n|\\\n/g, '<br>\n');
|
||||
|
||||
return s
|
||||
.replace(/\u0002(\d+)\u0002/g, (_m, i: string) => links[Number(i)])
|
||||
.replace(/\u0001(\d+)\u0001/g, (_m, i: string) => escaped[Number(i)])
|
||||
.replace(/\u0000(\d+)\u0000/g, (_m, i: string) => codes[Number(i)]);
|
||||
}
|
||||
|
||||
function renderEmphasis(s: string): string {
|
||||
return s
|
||||
.replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, '<strong>$2</strong>')
|
||||
.replace(/(^|[^\w*])\*(?=\S)([\s\S]*?\S)\*(?!\*)/g, '$1<em>$2</em>')
|
||||
.replace(/(^|[^\w])_(?=\S)([\s\S]*?\S)_(?!\w)/g, '$1<em>$2</em>')
|
||||
.replace(/~~(?=\S)([\s\S]*?\S)~~/g, '<del>$1</del>');
|
||||
}
|
||||
|
||||
const FENCE = /^ {0,3}(`{3,}|~{3,})\s*([^`\s]*)/;
|
||||
const HEADING = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$/;
|
||||
const RULE = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/;
|
||||
const LIST_ITEM = /^( *)([-*+]|\d{1,9}[.)])[ \t]+(.*)$/;
|
||||
const QUOTE = /^ {0,3}> ?(.*)$/;
|
||||
const TABLE_DELIM = /^ *\|? *:?-+:? *(\| *:?-+:? *)*\|? *$/;
|
||||
|
||||
function splitRow(line: string): string[] {
|
||||
let row = line.trim();
|
||||
if (row.startsWith('|')) row = row.slice(1);
|
||||
if (row.endsWith('|') && !row.endsWith('\\|')) row = row.slice(0, -1);
|
||||
return row.split(/(?<!\\)\|/).map((cell) => cell.trim().replace(/\\\|/g, '|'));
|
||||
}
|
||||
|
||||
function isBlockStart(line: string): boolean {
|
||||
return FENCE.test(line) || HEADING.test(line) || RULE.test(line) || QUOTE.test(line) || LIST_ITEM.test(line);
|
||||
}
|
||||
|
||||
function renderList(lines: string[], start: number): { html: string; next: number } {
|
||||
const first = LIST_ITEM.exec(lines[start])!;
|
||||
const indent = first[1].length;
|
||||
const ordered = /\d/.test(first[2]);
|
||||
const startNum = ordered ? parseInt(first[2], 10) : 1;
|
||||
const items: string[] = [];
|
||||
let i = start;
|
||||
while (i < lines.length) {
|
||||
const m = LIST_ITEM.exec(lines[i]);
|
||||
if (!m || m[1].length !== indent || /\d/.test(m[2]) !== ordered) break;
|
||||
// The item's own lines: continuation text and deeper-indented blocks.
|
||||
const body: string[] = [m[3]];
|
||||
i += 1;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (line.trim() === '') {
|
||||
const nextLine = lines[i + 1];
|
||||
if (nextLine !== undefined && /^\s+/.test(nextLine) && nextLine.search(/\S/) > indent) {
|
||||
body.push('');
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
const sub = LIST_ITEM.exec(line);
|
||||
if (sub && sub[1].length <= indent) break;
|
||||
if (!sub && line.search(/\S/) <= indent && isBlockStart(line)) break;
|
||||
body.push(line.slice(Math.min(line.search(/\S/), indent + 2)));
|
||||
i += 1;
|
||||
}
|
||||
let content = body.join('\n');
|
||||
let task = '';
|
||||
const box = /^\[([ xX])\][ \t]+/.exec(content);
|
||||
if (box) {
|
||||
// A glyph, not an <input>: the mail sanitiser drops form controls.
|
||||
task = box[1] === ' ' ? '☐ ' : '☑ ';
|
||||
content = content.slice(box[0].length);
|
||||
}
|
||||
const inner = renderBlocks(content.split('\n'), true);
|
||||
items.push(`<li${task ? ' class="task"' : ''}>${task}${inner}</li>`);
|
||||
if (lines[i] !== undefined && lines[i].trim() === '') {
|
||||
// A blank line between items keeps the list going.
|
||||
const after = LIST_ITEM.exec(lines[i + 1] ?? '');
|
||||
if (after && after[1].length === indent) i += 1;
|
||||
}
|
||||
}
|
||||
const tag = ordered ? 'ol' : 'ul';
|
||||
const startAttr = ordered && startNum !== 1 ? ` start="${startNum}"` : '';
|
||||
return { html: `<${tag}${startAttr}>${items.join('')}</${tag}>`, next: i };
|
||||
}
|
||||
|
||||
function renderBlocks(lines: string[], tight = false): string {
|
||||
const out: string[] = [];
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (line.trim() === '') { i += 1; continue; }
|
||||
|
||||
const fence = FENCE.exec(line);
|
||||
if (fence) {
|
||||
const marker = fence[1];
|
||||
const code: string[] = [];
|
||||
i += 1;
|
||||
while (i < lines.length && !lines[i].trimStart().startsWith(marker)) {
|
||||
code.push(lines[i]);
|
||||
i += 1;
|
||||
}
|
||||
i += 1;
|
||||
const lang = fence[2] ? ` class="language-${escapeHtml(fence[2])}"` : '';
|
||||
out.push(`<pre><code${lang}>${escapeHtml(code.join('\n'))}</code></pre>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = HEADING.exec(line);
|
||||
if (heading) {
|
||||
const level = heading[1].length;
|
||||
out.push(`<h${level}>${renderInline(heading[2] ?? '')}</h${level}>`);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (RULE.test(line)) {
|
||||
out.push('<hr>');
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (QUOTE.test(line)) {
|
||||
const quoted: string[] = [];
|
||||
while (i < lines.length && lines[i].trim() !== '') {
|
||||
const q = QUOTE.exec(lines[i]);
|
||||
quoted.push(q ? q[1] : lines[i]);
|
||||
i += 1;
|
||||
}
|
||||
out.push(`<blockquote>${renderBlocks(quoted)}</blockquote>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (LIST_ITEM.test(line)) {
|
||||
const list = renderList(lines, i);
|
||||
out.push(list.html);
|
||||
i = list.next;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^ {4,}\S/.test(line)) {
|
||||
const code: string[] = [];
|
||||
while (i < lines.length && (/^ {4}/.test(lines[i]) || lines[i].trim() === '')) {
|
||||
code.push(lines[i].slice(4));
|
||||
i += 1;
|
||||
}
|
||||
while (code.length && code[code.length - 1].trim() === '') code.pop();
|
||||
out.push(`<pre><code>${escapeHtml(code.join('\n'))}</code></pre>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.includes('|') && i + 1 < lines.length && TABLE_DELIM.test(lines[i + 1]) && lines[i + 1].includes('-')) {
|
||||
const head = splitRow(line);
|
||||
const align = splitRow(lines[i + 1]).map((cell) => {
|
||||
const left = cell.startsWith(':');
|
||||
const right = cell.endsWith(':');
|
||||
if (left && right) return 'center';
|
||||
if (right) return 'right';
|
||||
return left ? 'left' : '';
|
||||
});
|
||||
const cell = (tag: string, text: string, col: number) => {
|
||||
const a = align[col] ? ` style="text-align:${align[col]}"` : '';
|
||||
return `<${tag}${a}>${renderInline(text)}</${tag}>`;
|
||||
};
|
||||
const rows: string[] = [];
|
||||
i += 2;
|
||||
while (i < lines.length && lines[i].trim() !== '' && lines[i].includes('|')) {
|
||||
const cells = splitRow(lines[i]);
|
||||
rows.push(`<tr>${head.map((_h, col) => cell('td', cells[col] ?? '', col)).join('')}</tr>`);
|
||||
i += 1;
|
||||
}
|
||||
out.push(`<table><thead><tr>${head.map((h, col) => cell('th', h, col)).join('')}</tr></thead>`
|
||||
+ `<tbody>${rows.join('')}</tbody></table>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Paragraph: until a blank line or another block; a setext underline
|
||||
// turns it into a heading.
|
||||
const para: string[] = [];
|
||||
while (i < lines.length && lines[i].trim() !== '' && (para.length === 0 || !isBlockStart(lines[i]))) {
|
||||
if (para.length > 0 && /^ {0,3}(=+|-+)[ \t]*$/.test(lines[i])) break;
|
||||
para.push(lines[i]);
|
||||
i += 1;
|
||||
}
|
||||
const setext = i < lines.length ? /^ {0,3}(=+|-+)[ \t]*$/.exec(lines[i]) : null;
|
||||
if (setext && para.length > 0) {
|
||||
const level = setext[1].startsWith('=') ? 1 : 2;
|
||||
out.push(`<h${level}>${renderInline(para.join('\n').trim())}</h${level}>`);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const text = renderInline(para.map((l) => l.replace(/^ {1,3}/, '')).join('\n'));
|
||||
// List items keep their text bare, as in a tight list.
|
||||
out.push(tight ? text : `<p>${text}</p>`);
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
const STYLE = `
|
||||
body{font-family:-apple-system,system-ui,Roboto,sans-serif;font-size:15px;line-height:1.55;word-wrap:break-word}
|
||||
h1,h2{border-bottom:1px solid rgba(128,128,128,.3);padding-bottom:.25em}
|
||||
h1{font-size:1.6em}h2{font-size:1.35em}h3{font-size:1.15em}
|
||||
h1,h2,h3,h4,h5,h6{margin:1.1em 0 .5em;line-height:1.25}
|
||||
p,ul,ol,blockquote,pre,table{margin:0 0 .9em}
|
||||
ul,ol{padding-left:1.6em}li>p{margin:0 0 .4em}li.task{list-style:none;margin-left:-1.3em}
|
||||
code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:.9em;background:rgba(128,128,128,.15);padding:.1em .3em;border-radius:4px}
|
||||
pre{background:rgba(128,128,128,.12);padding:.8em;border-radius:6px;overflow-x:auto}
|
||||
pre code{background:none;padding:0;font-size:.85em}
|
||||
blockquote{margin-left:0;padding-left:1em;border-left:4px solid rgba(128,128,128,.4);color:rgba(128,128,128,1)}
|
||||
table{border-collapse:collapse;display:block;overflow-x:auto}
|
||||
th,td{border:1px solid rgba(128,128,128,.35);padding:.35em .7em}th{background:rgba(128,128,128,.1)}
|
||||
hr{border:0;border-top:1px solid rgba(128,128,128,.35);margin:1.4em 0}
|
||||
img{max-width:100%}
|
||||
`;
|
||||
|
||||
/** A Markdown file as an HTML document for the preview. */
|
||||
export function markdownToHtml(markdown: string): string {
|
||||
const lines = markdown.replace(/\r\n?/g, '\n').replace(/\t/g, ' ').split('\n');
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><style>${STYLE}</style></head><body>${renderBlocks(lines)}</body></html>`;
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// The pages the file preview shows images and media in. Each is written next
|
||||
// to the downloaded file and loaded from there, so the WebView reads the
|
||||
// file by a relative URL and nothing else on the device. They post 'error'
|
||||
// when the WebView can't decode the file, so the preview can offer another
|
||||
// app instead of a blank page or a dead player.
|
||||
|
||||
export interface ViewerColors {
|
||||
/** The app's theme, so the WebView draws its own controls to match. */
|
||||
scheme: 'light' | 'dark';
|
||||
background: string;
|
||||
text: string;
|
||||
textMuted: string;
|
||||
surface: string;
|
||||
}
|
||||
|
||||
const ESCAPES: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/[&<>"']/g, (ch) => ESCAPES[ch]);
|
||||
}
|
||||
|
||||
/** A file name as a relative URL: every reserved character encoded. */
|
||||
export function relativeFileUrl(fileName: string): string {
|
||||
return encodeURIComponent(fileName).replace(/[!'()*]/g, (ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase()}`);
|
||||
}
|
||||
|
||||
const REPORT = `function report(m){try{window.ReactNativeWebView.postMessage(m)}catch(e){}}`;
|
||||
|
||||
function page(title: string, colors: ViewerColors, viewport: string, style: string, body: string): string {
|
||||
return '<!doctype html><html><head><meta charset="utf-8">'
|
||||
+ `<meta name="viewport" content="${viewport}">`
|
||||
+ `<title>${escapeHtml(title)}</title>`
|
||||
+ `<style>:root{color-scheme:${colors.scheme}}html,body{margin:0;height:100%;background:${colors.background};color:${colors.text};`
|
||||
+ `font-family:-apple-system,system-ui,Roboto,sans-serif;-webkit-user-select:none;user-select:none}${style}</style>`
|
||||
+ `</head><body>${body}</body></html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* An image fitted to the screen, with the WebView's own pinch zoom. Used on
|
||||
* Android, where React Native's Image can't zoom, and for SVG and ICO, which
|
||||
* it can't draw. SVG is shown through <img>, so its scripts never run and it
|
||||
* loads nothing from the network.
|
||||
*/
|
||||
export function imageViewerHtml(fileName: string, colors: ViewerColors): string {
|
||||
// Raster icons scaled up stay crisp instead of blurring.
|
||||
const fillClass = /\.(ico|bmp|gif|png)$/i.test(fileName) ? 'fill pixel' : 'fill';
|
||||
return page(
|
||||
fileName,
|
||||
colors,
|
||||
'width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=10,user-scalable=yes',
|
||||
'body{display:flex;align-items:center;justify-content:center;box-sizing:border-box;padding:16px}'
|
||||
+ 'img{display:block;max-width:100%;max-height:100%;width:auto;height:auto;object-fit:contain}'
|
||||
// A vector or a tiny icon has no useful natural size; fill the screen.
|
||||
+ 'img.fill{width:100%;height:100%}'
|
||||
+ 'img.pixel{image-rendering:pixelated}',
|
||||
`<img id="i" alt="${escapeHtml(fileName)}" src="${relativeFileUrl(fileName)}">`
|
||||
+ `<script>${REPORT}var i=document.getElementById('i');`
|
||||
+ `i.addEventListener('error',function(){report('error')});`
|
||||
+ `i.addEventListener('load',function(){var s=Math.min(innerWidth,innerHeight)/3;`
|
||||
+ `if(!i.naturalWidth||i.naturalWidth<s&&i.naturalHeight<s){i.className='${fillClass}'}`
|
||||
+ `report('loaded')});</script>`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The platform's own player for a video or a sound. A video fills the
|
||||
* screen on black, with fullscreen and picture in picture where the WebView
|
||||
* offers them; a sound gets a card with its name. Playback starts at once.
|
||||
*/
|
||||
export function mediaViewerHtml(kind: 'video' | 'audio', fileName: string, colors: ViewerColors): string {
|
||||
const src = relativeFileUrl(fileName);
|
||||
const script = `<script>${REPORT}var m=document.getElementById('m');`
|
||||
+ `function failed(){if(!m.currentSrc||m.error||m.networkState===3)report('error')}`
|
||||
+ `m.addEventListener('error',failed,true);`
|
||||
// A file whose container plays but has no track the WebView decodes
|
||||
// reports a 0x0 video; offer another app for that too.
|
||||
+ `m.addEventListener('loadedmetadata',function(){if(m.tagName==='VIDEO'&&!m.videoWidth&&!m.duration)report('error');else report('loaded')});`
|
||||
+ `</script>`;
|
||||
if (kind === 'video') {
|
||||
return page(
|
||||
fileName,
|
||||
{ ...colors, scheme: 'dark', background: '#000' },
|
||||
'width=device-width,initial-scale=1',
|
||||
'body{display:flex;align-items:center;justify-content:center}video{width:100%;height:100%;object-fit:contain;background:#000}',
|
||||
`<video id="m" src="${src}" controls autoplay playsinline preload="auto"></video>${script}`,
|
||||
);
|
||||
}
|
||||
const note = '<svg width="72" height="72" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" '
|
||||
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
|
||||
+ '<circle cx="6" cy="17" r="3"/><circle cx="16" cy="15" r="3"/><path d="M9 17V5l10-2v12"/><path d="M9 9l10-2"/></svg>';
|
||||
return page(
|
||||
fileName,
|
||||
colors,
|
||||
'width=device-width,initial-scale=1',
|
||||
'body{display:flex;align-items:center;justify-content:center;box-sizing:border-box;padding:24px}'
|
||||
+ `.card{width:100%;max-width:480px;display:flex;flex-direction:column;align-items:center;gap:20px}`
|
||||
+ `.art{width:160px;height:160px;border-radius:24px;background:${colors.surface};color:${colors.textMuted};`
|
||||
+ 'display:flex;align-items:center;justify-content:center}'
|
||||
+ '.name{font-size:17px;font-weight:600;text-align:center;word-break:break-word}'
|
||||
+ 'audio{width:100%}',
|
||||
`<div class="card"><div class="art">${note}</div><div class="name">${escapeHtml(fileName)}</div>`
|
||||
+ `<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>`,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user