Compare commits

..
15 Commits
Author SHA1 Message Date
Linus Rath e0f18713aa chore: update version number to 0.1.81 2026-10-16 19:27:04 +02:00
Linus Rath 56d826c508 test: cover how the PDF preview reports protected and unreadable files 2026-10-16 19:24:19 +02:00
Linus Rath e5492b89c8 feat: show PDFs in the app on Android 2026-10-16 19:23:17 +02:00
Linus Rath b50436b1ba chore: update version number to 0.1.80 2026-10-16 19:12:43 +02:00
Linus Rath 11e74042e2 fix: hand files with a generic type to other apps by the type their name implies 2026-10-16 19:11:50 +02:00
Linus Rath 95580b84ea feat: play video and audio, and show SVG, HTML and Markdown files in the preview 2026-10-16 19:11:24 +02:00
Linus Rath 9a15334bef chore: update version number to 0.1.79 2026-10-16 18:49:43 +02:00
Linus Rath 3aca2d80c0 fix: preview .eml attachments that carry attachments of their own 2026-10-16 18:48:26 +02:00
Linus Rath 7c534301ee refactor: open files from mail, the list, Files and compose through one preview 2026-10-16 18:48:26 +02:00
Linus Rath 14dc5ff2d4 Merge pull request #76 from lucletoffe/feat/calendar-sort-order
feat(calendar): order calendars by sortOrder and move them from the drawer
2026-10-15 23:37:37 +02:00
Linus Rath 14ba28c79c Merge pull request #74 from lucletoffe/fix/compose-visual-viewport
fix(composer): stop the body's first lines hiding under To/Cc/Subject on Android
2026-10-15 23:23:40 +02:00
Linus Rath 598adc154d Merge pull request #72 from lucletoffe/fix/draft-part-blobids
fix(drafts): keep a re-opened draft's attachments on live blobs across saves and sends
2026-10-15 23:23:26 +02:00
lucletoffeandClaude Opus 5.5 3c0af52c4e feat(calendar): order calendars by sortOrder and move them from the drawer
Calendars were listed in the order the server returns them (creation
order on Stalwart) and sortOrder was never read. Sort them like the
webmail now does and as JMAP Calendars asks: lower sortOrder first,
equal sortOrder by name (case- and accent-insensitive, numbers by
value), each account's calendars kept together. The store keeps the list
sorted, so the drawer, the event editor and the other pickers agree.

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 22:07:33 +02:00
lucletoffeandClaude Opus 5.5 8ce53816af fix(composer): stop the body's first lines hiding under To/Cc/Subject on Android
Android's WebView shrinks the editor page's visual viewport to the part of
the view that is on screen and pans it to reveal the caret while typing.
The editor is an auto-height WebView inside the composer's ScrollView, so
nothing ever resets that pan: scrolled back to the top, the body is still
drawn shifted up by the pan offset and its first lines sit hidden under the
header fields until a scroll to the very bottom clamps it away.

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 08:47:21 +02:00
65 changed files with 2822 additions and 493 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.78
0.1.81
@@ -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,141 @@
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)
out.parentFile?.mkdirs()
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
}
}
+4 -1
View File
@@ -407,7 +407,10 @@
"preview": {
"open_external": "فتح في تطبيق",
"pdf_external": "تُفتح ملفات PDF في عارض PDF على هذا الجهاز.",
"unsupported": "لا توجد معاينة لهذا النوع من الملفات."
"unsupported": "لا توجد معاينة لهذا النوع من الملفات.",
"media_unsupported": "لا يمكن تشغيل هذا الملف هنا.",
"pdf_protected": "ملف PDF هذا محمي بكلمة مرور. افتحه في تطبيق آخر لإدخال كلمة المرور.",
"pdf_unreadable": "لا يمكن عرض ملف PDF هذا هنا."
},
"received_via": "إلى {name}",
"remove_color": "إزالة الوسم",
+4 -1
View File
@@ -407,7 +407,10 @@
"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í."
},
"received_via": "per a {name}",
"remove_color": "Elimina l'etiqueta",
+4 -1
View File
@@ -407,7 +407,10 @@
"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."
},
"received_via": "pro {name}",
"remove_color": "Odebrat štítek",
+4 -1
View File
@@ -407,7 +407,10 @@
"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."
},
"received_via": "til {name}",
"remove_color": "Fjern tag",
+4 -1
View File
@@ -413,7 +413,10 @@
"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."
},
"received_via": "an {name}",
"remove_color": "Label entfernen",
+4 -1
View File
@@ -424,7 +424,10 @@
"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."
},
"received_via": "to {name}",
"remove_color": "Remove tag",
+4 -1
View File
@@ -407,7 +407,10 @@
"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í."
},
"received_via": "para {name}",
"remove_color": "Eliminar etiqueta",
+4 -1
View File
@@ -407,7 +407,10 @@
"preview": {
"open_external": "باز کردن در برنامه",
"pdf_external": "فایل‌های PDF در نمایشگر PDF این دستگاه باز می‌شوند.",
"unsupported": "برای این نوع فایل پیش‌نمایشی وجود ندارد."
"unsupported": "برای این نوع فایل پیش‌نمایشی وجود ندارد.",
"media_unsupported": "این فایل اینجا پخش نمی‌شود.",
"pdf_protected": "این PDF با رمز عبور محافظت شده است. برای وارد کردن رمز، آن را در برنامهٔ دیگری باز کنید.",
"pdf_unreadable": "این PDF اینجا نمایش داده نمی‌شود."
},
"received_via": "به {name}",
"remove_color": "حذف برچسب",
+4 -1
View File
@@ -407,7 +407,10 @@
"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."
},
"received_via": "à {name}",
"remove_color": "Retirer l'étiquette",
+4 -1
View File
@@ -407,7 +407,10 @@
"preview": {
"open_external": "פתח באפליקציה",
"pdf_external": "קובצי PDF נפתחים במציג ה-PDF במכשיר זה.",
"unsupported": "אין תצוגה מקדימה לסוג קובץ זה."
"unsupported": "אין תצוגה מקדימה לסוג קובץ זה.",
"media_unsupported": "לא ניתן להפעיל את הקובץ הזה כאן.",
"pdf_protected": "קובץ ה-PDF הזה מוגן בסיסמה. פתחו אותו באפליקציה אחרת כדי להזין את הסיסמה.",
"pdf_unreadable": "לא ניתן להציג את קובץ ה-PDF הזה כאן."
},
"received_via": "אל {name}",
"remove_color": "הסר תג",
+4 -1
View File
@@ -407,7 +407,10 @@
"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."
},
"received_via": "címzett: {name}",
"remove_color": "Címke eltávolítása",
+4 -1
View File
@@ -407,7 +407,10 @@
"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."
},
"received_via": "a {name}",
"remove_color": "Rimuovi etichetta",
+4 -1
View File
@@ -407,7 +407,10 @@
"preview": {
"open_external": "アプリで開く",
"pdf_external": "PDF はこのデバイスの PDF ビューアーで開きます。",
"unsupported": "このファイル形式はプレビューできません。"
"unsupported": "このファイル形式はプレビューできません。",
"media_unsupported": "このファイルはここでは再生できません。",
"pdf_protected": "このPDFはパスワードで保護されています。パスワードを入力するには別のアプリで開いてください。",
"pdf_unreadable": "このPDFはここでは表示できません。"
},
"received_via": "{name}宛",
"remove_color": "ラベルを削除",
+4 -1
View File
@@ -407,7 +407,10 @@
"preview": {
"open_external": "앱에서 열기",
"pdf_external": "PDF는 이 기기의 PDF 뷰어에서 열려요.",
"unsupported": "이 파일 형식은 미리보기를 지원하지 않아요."
"unsupported": "이 파일 형식은 미리보기를 지원하지 않아요.",
"media_unsupported": "이 파일은 여기서 재생할 수 없습니다.",
"pdf_protected": "이 PDF는 암호로 보호되어 있습니다. 암호를 입력하려면 다른 앱에서 여세요.",
"pdf_unreadable": "이 PDF는 여기서 표시할 수 없습니다."
},
"received_via": "받는 사람: {name}",
"remove_color": "태그 제거",
+4 -1
View File
@@ -407,7 +407,10 @@
"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."
},
"received_via": "kam: {name}",
"remove_color": "Noņemt tagu",
+4 -1
View File
@@ -407,7 +407,10 @@
"preview": {
"open_external": "Апп-д нээх",
"pdf_external": "PDF файлууд энэ төхөөрөмжийн PDF үзэгчид нээгдэнэ.",
"unsupported": "Энэ төрлийн файлыг урьдчилан харах боломжгүй."
"unsupported": "Энэ төрлийн файлыг урьдчилан харах боломжгүй.",
"media_unsupported": "Энэ файлыг энд тоглуулах боломжгүй.",
"pdf_protected": "Энэ PDF нууц үгээр хамгаалагдсан. Нууц үг оруулахын тулд өөр апп-аар нээнэ үү.",
"pdf_unreadable": "Энэ PDF-ийг энд харуулах боломжгүй."
},
"received_via": "Хэнд: {name}",
"remove_color": "Тагийг хасах",
+4 -1
View File
@@ -407,7 +407,10 @@
"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."
},
"received_via": "til {name}",
"remove_color": "Fjern etiketten",
+4 -1
View File
@@ -407,7 +407,10 @@
"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."
},
"received_via": "aan {name}",
"remove_color": "Label verwijderen",
+4 -1
View File
@@ -407,7 +407,10 @@
"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ć."
},
"received_via": "do {name}",
"remove_color": "Usuń etykietę",
+4 -1
View File
@@ -407,7 +407,10 @@
"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."
},
"received_via": "para {name}",
"remove_color": "Remover etiqueta",
+4 -1
View File
@@ -407,7 +407,10 @@
"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."
},
"received_via": "către {name}",
"remove_color": "Eliminați eticheta",
+4 -1
View File
@@ -407,7 +407,10 @@
"preview": {
"open_external": "Открыть в приложении",
"pdf_external": "PDF-файлы открываются в программе просмотра PDF на этом устройстве.",
"unsupported": "Для этого типа файлов нет предпросмотра."
"unsupported": "Для этого типа файлов нет предпросмотра.",
"media_unsupported": "Этот файл нельзя воспроизвести здесь.",
"pdf_protected": "Этот PDF защищён паролем. Откройте его в другом приложении, чтобы ввести пароль.",
"pdf_unreadable": "Этот PDF нельзя показать здесь."
},
"received_via": "на {name}",
"remove_color": "Убрать тег",
+4 -1
View File
@@ -407,7 +407,10 @@
"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ť."
},
"received_via": "pre {name}",
"remove_color": "Odstrániť štítok",
+4 -1
View File
@@ -407,7 +407,10 @@
"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."
},
"received_via": "alıcı: {name}",
"remove_color": "Etiketi kaldır",
+4 -1
View File
@@ -407,7 +407,10 @@
"preview": {
"open_external": "Відкрити в застосунку",
"pdf_external": "PDF-файли відкриваються в переглядачі PDF на цьому пристрої.",
"unsupported": "Для цього типу файлів немає попереднього перегляду."
"unsupported": "Для цього типу файлів немає попереднього перегляду.",
"media_unsupported": "Цей файл не можна відтворити тут.",
"pdf_protected": "Цей PDF захищено паролем. Відкрийте його в іншому застосунку, щоб ввести пароль.",
"pdf_unreadable": "Цей PDF не можна показати тут."
},
"received_via": "на {name}",
"remove_color": "Прибрати тег",
+4 -1
View File
@@ -407,7 +407,10 @@
"preview": {
"open_external": "在應用程式中開啟",
"pdf_external": "PDF 會在此裝置的 PDF 檢視器中開啟。",
"unsupported": "無法預覽此檔案類型。"
"unsupported": "無法預覽此檔案類型。",
"media_unsupported": "無法在此播放這個檔案。",
"pdf_protected": "此 PDF 受密碼保護。請在其他應用程式中開啟以輸入密碼。",
"pdf_unreadable": "無法在此顯示這個 PDF。"
},
"received_via": "寄給 {name}",
"remove_color": "移除標籤",
+4 -1
View File
@@ -407,7 +407,10 @@
"preview": {
"open_external": "在应用中打开",
"pdf_external": "PDF 将在此设备的 PDF 查看器中打开。",
"unsupported": "无法预览此类文件。"
"unsupported": "无法预览此类文件。",
"media_unsupported": "无法在此播放该文件。",
"pdf_protected": "此 PDF 受密码保护。请在其他应用中打开以输入密码。",
"pdf_unreadable": "无法在此显示该 PDF。"
},
"received_via": "发给 {name}",
"remove_color": "删除标签",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.78",
"version": "0.1.81",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.78",
"version": "0.1.81",
"hasInstallScript": true,
"license": "AGPL-3.0-only",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.78",
"version": "0.1.81",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
+82
View File
@@ -0,0 +1,82 @@
diff --git a/node_modules/postal-mime/src/base64-decoder.js b/node_modules/postal-mime/src/base64-decoder.js
index 93c2aea..ba6e3f1 100644
--- a/node_modules/postal-mime/src/base64-decoder.js
+++ b/node_modules/postal-mime/src/base64-decoder.js
@@ -1,4 +1,4 @@
-import { decodeBase64, blobToArrayBuffer } from './decode-strings.js';
+import { decodeBase64, concatChunks } from './decode-strings.js';
export default class Base64Decoder {
constructor(opts) {
@@ -49,6 +49,6 @@ export default class Base64Decoder {
finalize() {
this.flushRemainder();
- return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
+ return concatChunks(this.chunks);
}
}
diff --git a/node_modules/postal-mime/src/decode-strings.js b/node_modules/postal-mime/src/decode-strings.js
index a5e058e..f255e8d 100644
--- a/node_modules/postal-mime/src/decode-strings.js
+++ b/node_modules/postal-mime/src/decode-strings.js
@@ -145,6 +145,23 @@ export function getDecoder(charset) {
* @param {Blob} blob Blob to convert
* @returns {ArrayBuffer} Converted value
*/
+// React Native's Blob cannot be built from ArrayBuffers or typed arrays, so
+// the decoders join their chunks here instead of through a Blob.
+export function concatChunks(chunks) {
+ const parts = chunks.map(chunk => {
+ if (typeof chunk === 'string') return textEncoder.encode(chunk);
+ if (chunk instanceof ArrayBuffer) return new Uint8Array(chunk);
+ return new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
+ });
+ const out = new Uint8Array(parts.reduce((n, part) => n + part.byteLength, 0));
+ let pos = 0;
+ for (const part of parts) {
+ out.set(part, pos);
+ pos += part.byteLength;
+ }
+ return out.buffer;
+}
+
export async function blobToArrayBuffer(blob) {
if ('arrayBuffer' in blob) {
return await blob.arrayBuffer();
diff --git a/node_modules/postal-mime/src/pass-through-decoder.js b/node_modules/postal-mime/src/pass-through-decoder.js
index ad1c222..1e1d012 100644
--- a/node_modules/postal-mime/src/pass-through-decoder.js
+++ b/node_modules/postal-mime/src/pass-through-decoder.js
@@ -1,4 +1,4 @@
-import { blobToArrayBuffer } from './decode-strings.js';
+import { concatChunks } from './decode-strings.js';
export default class PassThroughDecoder {
constructor() {
@@ -12,6 +12,6 @@ export default class PassThroughDecoder {
finalize() {
// convert an array of arraybuffers into a blob and then back into a single arraybuffer
- return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
+ return concatChunks(this.chunks);
}
}
diff --git a/node_modules/postal-mime/src/qp-decoder.js b/node_modules/postal-mime/src/qp-decoder.js
index adee72c..3973673 100644
--- a/node_modules/postal-mime/src/qp-decoder.js
+++ b/node_modules/postal-mime/src/qp-decoder.js
@@ -1,4 +1,4 @@
-import { blobToArrayBuffer, hexNibble } from './decode-strings.js';
+import { concatChunks, hexNibble } from './decode-strings.js';
const CHR_EQUALS = 0x3d;
const CHR_LF = 0x0a;
@@ -83,6 +83,6 @@ export default class QPDecoder {
this.flushBuffer();
// convert an array of arraybuffers into a blob and then back into a single arraybuffer
- return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
+ return concatChunks(this.chunks);
}
}
+138
View File
@@ -0,0 +1,138 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
request: vi.fn(),
getMaxCallsInRequest: vi.fn(() => 16),
getMaxObjectsInGet: vi.fn(() => 500),
getMaxObjectsInSet: vi.fn(() => 500),
},
}));
import { jmapClient } from '../jmap-client';
import { saveDraftVersion, type OutgoingAttachment, type OutgoingEmail } from '../email';
import { initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs } from '../../lib/draft-part-blobs';
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
/**
* A tiny server with Stalwart's blob semantics, as measured on 0.16.22 (2026-10-14):
* an email's part blobIds are derived from that email and stop resolving the
* moment it is destroyed ("blobId … does not exist on this server"); creating
* an email copies the referenced bytes into new parts with new blobIds.
*/
function fakeStalwart() {
let seq = 0;
const emails = new Map<string, Array<{ blobId: string; name: string; size: number; cid?: string; type: string }>>();
const live = (blobId: string) => [...emails.values()].some((parts) => parts.some((p) => p.blobId === blobId));
const seed = (id: string, parts: OutgoingAttachment[]) => {
emails.set(id, parts.map((p) => ({ blobId: p.blobId, name: p.name, size: p.size ?? 0, cid: p.cid, type: p.type })));
};
mockRequest.mockImplementation(async (calls: Array<[string, Record<string, any>, string]>) => {
const out: Array<[string, unknown, string]> = [];
let createdId: string | undefined;
for (const [method, args, callId] of calls) {
if (method === 'Email/set' && args.create) {
const create = args.create.draft;
const dead = (create.attachments ?? []).find((a: { blobId: string }) => !live(a.blobId));
if (dead) {
out.push(['Email/set', { notCreated: { draft: {
type: 'blobNotFound', description: `blobId ${dead.blobId} does not exist on this server.`,
} } }, callId]);
continue;
}
createdId = `e${++seq}`;
emails.set(createdId, (create.attachments ?? []).map((a: OutgoingAttachment, i: number) => ({
blobId: `${createdId}-part${i}`, name: a.name, size: a.size ?? 0, cid: a.cid ? `<${a.cid}>` : undefined, type: a.type,
})));
out.push(['Email/set', { created: { draft: { id: createdId } } }, callId]);
} else if (method === 'Email/set' && args.destroy) {
for (const id of args.destroy) emails.delete(id);
out.push(['Email/set', { destroyed: args.destroy }, callId]);
} else if (method === 'Email/get') {
if (!createdId || args.ids?.[0] !== '#draft') {
out.push(['error', { type: 'invalidResultReference' }, callId]);
continue;
}
out.push(['Email/get', { list: [{ id: createdId, attachments: emails.get(createdId) }] }, callId]);
}
}
return { methodResponses: out };
});
return { seed, emails };
}
const IMG_A: OutgoingAttachment = { blobId: 'd0-part-a', name: 'schema-a.png', size: 136574, type: 'image/png', disposition: 'inline', cid: 'a@undust.co' };
const IMG_B: OutgoingAttachment = { blobId: 'd0-part-b', name: 'schema-b.png', size: 175155, type: 'image/png', disposition: 'inline', cid: 'b@undust.co' };
const draftWith = (attachments: OutgoingAttachment[]): OutgoingEmail => ({
from: [{ email: 'me@undust.co' }],
to: [{ email: 'test@example.invalid' }],
subject: 'RE: draft with two inline images',
htmlBody: '<p>x <img src="cid:a@undust.co"> <img src="cid:b@undust.co"></p>',
textBody: 'x',
attachments,
});
beforeEach(() => {
vi.clearAllMocks();
});
describe('saveDraftVersion', () => {
it('reads the new version\'s parts back in the same request, by creation id', async () => {
fakeStalwart().seed('d0', [IMG_A]);
const saved = await saveDraftVersion(draftWith([IMG_A]), 'drafts', 'd0');
const calls = mockRequest.mock.calls[0][0];
expect(calls.map((c: [string]) => c[0])).toEqual(['Email/set', 'Email/get']);
expect(calls[1][1].ids).toEqual(['#draft']);
expect(saved.id).toBe('e1');
expect(saved.attachments.map((a) => a.blobId)).toEqual(['e1-part0']);
});
it('destroys the previous version only after the new one exists', async () => {
fakeStalwart().seed('d0', [IMG_A]);
await saveDraftVersion(draftWith([IMG_A]), 'drafts', 'd0');
const destroyCall = mockRequest.mock.calls[1][0][0];
expect(destroyCall[0]).toBe('Email/set');
expect(destroyCall[1].destroy).toEqual(['d0']);
});
it('still returns the id when the read-back fails', async () => {
mockRequest.mockResolvedValueOnce({ methodResponses: [
['Email/set', { created: { draft: { id: 'e9' } } }, '0'],
['error', { type: 'serverFail' }, '1'],
] });
const saved = await saveDraftVersion(draftWith([]), 'drafts');
expect(saved).toEqual({ id: 'e9', attachments: [] });
});
});
describe('re-opened draft with inline images: save, save again, send', () => {
// What ComposeScreen does: buildOutgoing maps each held blobId through the
// state, saveDraftOnce rebases it on the parts read back.
async function editSession(rebase: boolean) {
const server = fakeStalwart();
server.seed('d0', [IMG_A, IMG_B]); // the draft as deposited (IMAP APPEND or an earlier save)
let state = initialDraftPartBlobState();
let draftId = 'd0';
const outgoing = () => draftWith([IMG_A, IMG_B].map((a) => ({ ...a, blobId: liveBlobId(state, a.blobId) })));
for (let i = 0; i < 2; i++) {
const sent = outgoing();
const saved = await saveDraftVersion(sent, 'drafts', draftId);
draftId = saved.id;
if (rebase) state = rebaseDraftPartBlobs(state, sent.attachments ?? [], saved.attachments);
}
return { server, draftId };
}
it('reproduces the bug without the rebase: the second save references the destroyed draft\'s parts', async () => {
await expect(editSession(false)).rejects.toThrow('blobId d0-part-a does not exist on this server.');
});
it('keeps every save (and so the send, which builds the same attachments) on live blobs', async () => {
const { server, draftId } = await editSession(true);
expect([...server.emails.keys()]).toEqual([draftId]);
expect(server.emails.get(draftId)!.map((p) => p.name)).toEqual(['schema-a.png', 'schema-b.png']);
});
});
+43
View File
@@ -22,6 +22,8 @@ import {
getFullEmails,
getThreadsHeaders,
createDraft,
saveDraftVersion,
destroyEmails,
sendEmail,
markAsSpam,
undoSpam,
@@ -33,6 +35,9 @@ import {
EMAIL_FULL_PROPERTIES,
} from '../email';
import { getIdentities } from '../identity';
import { uploadBytes } from '../blob';
import { initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs } from '../../lib/draft-part-blobs';
import type { OutgoingAttachment } from '../email';
import { getCalendars, queryEvents, createEvent, deleteEvents } from '../calendar';
import { getAddressBooks, createContact, deleteContacts } from '../contacts';
@@ -192,6 +197,44 @@ live('live Stalwart', () => {
await destroyEmails([id]);
}, 60_000);
it('keeps a re-opened draft with an inline image saveable across saves (#849 part blobs)', async () => {
// A 1x1 PNG uploaded once; D0 stands for the draft as it sits on the server.
const png = Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='), (c) => c.charCodeAt(0));
const up = await uploadBytes(png, 'image/png');
const base = {
from: [{ email: USER }],
to: [{ email: 'test@example.invalid' }],
subject: '[TEST bulwark-blob — supprimable]',
htmlBody: '<p>live <img src="cid:img1@live.test"></p>',
textBody: 'live',
};
const img = (blobId: string): OutgoingAttachment => ({
blobId, type: 'image/png', name: 'img1.png', size: png.byteLength, disposition: 'inline', cid: 'img1@live.test',
});
const d0 = await saveDraftVersion({ ...base, attachments: [img(up.blobId)] }, drafts);
const created = [d0.id];
try {
// Re-open D0: the composer holds D0's part blobId.
const seeded = d0.attachments[0].blobId;
let state = initialDraftPartBlobState();
let draftId = d0.id;
for (let i = 0; i < 2; i++) {
const sent = [img(liveBlobId(state, seeded))];
const saved = await saveDraftVersion({ ...base, attachments: sent }, drafts, draftId);
created.push(saved.id);
draftId = saved.id;
state = rebaseDraftPartBlobs(state, sent, saved.attachments);
}
// Without the rebase the seeded part blob is gone with D0.
await expect(saveDraftVersion({ ...base, attachments: [img(seeded)] }, drafts))
.rejects.toThrow(/does not exist|blobNotFound/);
const [last] = await getFullEmails([draftId]);
expect(last.attachments?.[0]?.cid).toContain('img1@live.test');
} finally {
await destroyEmails(created).catch(() => undefined);
}
}, 90_000);
it('sends through Drafts and files into Sent only after submission (#188)', async () => {
const identities = await getIdentities();
expect(identities.length).toBeGreaterThan(0);
+48 -5
View File
@@ -1679,31 +1679,64 @@ export async function sendEmail(
};
}
/** A saved draft version and the attachment parts the server built for it. */
export interface SavedDraftVersion {
id: string;
/**
* Attachment and inline parts of the new version, with their blobIds. A
* part's blobId only lives as long as this version: re-opened drafts must
* send these (not the previous version's) on their next save or send.
* Empty when the server did not answer the read-back.
*/
attachments: Attachment[];
}
const DRAFT_PART_BODY_PROPERTIES = ['partId', 'blobId', 'size', 'name', 'type', 'cid', 'disposition'];
/**
* Save a draft into the Drafts folder (`$draft` + `$seen`). When `previousDraftId`
* is given it is destroyed only AFTER the replacement was created (#849): a
* combined create+destroy would delete the last good copy when the create
* fails (e.g. blobNotFound on a re-opened draft's attachments). Returns the
* new draft id.
* fails (e.g. blobNotFound on a re-opened draft's attachments). The new
* version's attachment parts are read back in the same request, so the
* composer can re-point its draft-part blobIds before the old version (and
* its part blobs) is destroyed.
*/
export async function createDraft(
export async function saveDraftVersion(
email: OutgoingEmail,
draftsMailboxId: string,
previousDraftId?: string,
accountIdOverride?: string,
): Promise<string> {
): Promise<SavedDraftVersion> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const emailCreate = buildEmailCreate(email);
emailCreate.mailboxIds = { [draftsMailboxId]: true };
emailCreate.keywords = { $seen: true, $draft: true };
const res = await jmapClient.request([
['Email/set', { accountId, create: { draft: emailCreate } }, '0'],
// `#draft` is the creation id (RFC 8620 §5.3). Stalwart 0.16 does not
// resolve a result reference into an Email/set `created` map
// (invalidResultReference), but it does resolve the creation id.
['Email/get', {
accountId,
ids: ['#draft'],
properties: ['id', 'attachments'],
bodyProperties: DRAFT_PART_BODY_PROPERTIES,
}, '1'],
]);
const body = requireMethodResult(res, '0', 'Email/set');
const notCreated = body.notCreated?.draft as { description?: string; type?: string } | undefined;
if (notCreated) throw new Error(notCreated.description || notCreated.type || 'Failed to save draft');
const id = body.created?.draft?.id as string | undefined;
if (!id) throw new Error('Draft save returned no id');
let attachments: Attachment[] = [];
try {
const got = requireMethodResult(res, '1', 'Email/get');
const saved = (got.list as Array<{ id: string; attachments?: Attachment[] }> | undefined)?.find((e) => e.id === id);
attachments = (saved?.attachments ?? []).filter((a) => !!a.blobId);
} catch (err) {
console.warn('[email] could not read back the saved draft parts:', err);
}
if (previousDraftId && previousDraftId !== id) {
try {
await destroyEmails([previousDraftId], accountId);
@@ -1711,7 +1744,17 @@ export async function createDraft(
console.warn('[email] failed to destroy previous draft version:', err);
}
}
return id;
return { id, attachments };
}
/** {@link saveDraftVersion} for callers that only need the new draft id. */
export async function createDraft(
email: OutgoingEmail,
draftsMailboxId: string,
previousDraftId?: string,
accountIdOverride?: string,
): Promise<string> {
return (await saveDraftVersion(email, draftsMailboxId, previousDraftId, accountIdOverride)).id;
}
export interface ScheduledEmail {
+17 -2
View File
@@ -78,10 +78,16 @@ interface Props {
onSelectionChange?: (state: RichTextSelectionState) => void;
onFocus?: () => void;
onBlur?: () => void;
/**
* Android: the page undid a caret-reveal pan of `dy` px (dp) inside the
* WebView. The host should scroll its ScrollView down by `dy` so the caret
* stays visible (see `pinVisualViewport` in editor-html).
*/
onViewportShift?: (dy: number) => void;
}
const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function RichTextEditor(
{ initialHtml = '', placeholder = '', blockRemoteImages = false, onChange, onSelectionChange, onFocus, onBlur },
{ initialHtml = '', placeholder = '', blockRemoteImages = false, onChange, onSelectionChange, onFocus, onBlur, onViewportShift },
ref,
) {
const c = useColors();
@@ -92,6 +98,8 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
const onSelectionRef = React.useRef(onSelectionChange);
const onFocusRef = React.useRef(onFocus);
const onBlurRef = React.useRef(onBlur);
const onViewportShiftRef = React.useRef(onViewportShift);
onViewportShiftRef.current = onViewportShift;
onChangeRef.current = onChange;
onSelectionRef.current = onSelectionChange;
onFocusRef.current = onFocus;
@@ -100,7 +108,9 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
// Build the source HTML once. Theming changes won't auto-rebuild the page,
// which is fine: themes change rarely and the user can recompose if needed.
const html = React.useMemo(
() => buildEditorHtml({ initialHtml, placeholder, c, blockRemoteImages }),
() => buildEditorHtml({
initialHtml, placeholder, c, blockRemoteImages, pinVisualViewport: Platform.OS === 'android',
}),
// c reference changes per theme switch but we don't want to lose user
// edits on a theme change. Bake in the initial colors only.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -224,6 +234,11 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
case 'blur':
onBlurRef.current?.();
break;
case 'viewportShift': {
const dy = typeof data.payload === 'number' ? data.payload : Number(data.payload);
if (Number.isFinite(dy) && dy !== 0) onViewportShiftRef.current?.(dy);
break;
}
}
};
@@ -12,7 +12,7 @@ import {
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import {
Check, X, Upload, Rss, Shuffle, Star, Plus, Pencil, Share2, Eraser, Trash2, RefreshCw,
Check, X, Upload, Rss, Shuffle, Star, Plus, Pencil, Share2, Eraser, Trash2, RefreshCw, ArrowUp, ArrowDown,
type AppIcon,
} from '../icons';
import type { Calendar } from '../../api/types';
@@ -55,6 +55,9 @@ interface CalendarSidebarDrawerProps {
onShare?: (calendar: Calendar) => void;
onClear?: (calendar: Calendar) => void;
onDelete?: (calendar: Calendar) => void;
// Move one of the user's own calendars next to `targetId`, a neighbour in
// the same section (before it when moving up, after it when moving down).
onMove?: (calendar: Calendar, targetId: string) => void;
// Client-side iCal subscriptions are managed from the subscriptions sheet.
isSubscriptionCalendar?: (calendarId: string) => boolean;
}
@@ -79,6 +82,7 @@ export function CalendarSidebarDrawer({
onShare,
onClear,
onDelete,
onMove,
isSubscriptionCalendar,
}: CalendarSidebarDrawerProps) {
const c = useColors();
@@ -148,6 +152,7 @@ export function CalendarSidebarDrawer({
onShare,
onClear,
onDelete,
onMove,
isSubscriptionCalendar,
};
@@ -284,6 +289,7 @@ function Section({
onShare,
onClear,
onDelete,
onMove,
isSubscriptionCalendar,
}: {
title: string;
@@ -299,11 +305,14 @@ function Section({
onShare?: (calendar: Calendar) => void;
onClear?: (calendar: Calendar) => void;
onDelete?: (calendar: Calendar) => void;
onMove?: (calendar: Calendar, targetId: string) => void;
isSubscriptionCalendar?: (calendarId: string) => boolean;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
// Neighbours a calendar can move past: the server calendars of this section.
const orderable = calendars.filter((cal) => cal.id !== BIRTHDAY_CALENDAR_ID);
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{title}</Text>
@@ -320,7 +329,11 @@ function Section({
const canClear = !!onClear && isOwn && isWritableCalendar(cal);
// Like webmail: the default calendar and shared calendars can't be deleted.
const canDelete = !!onDelete && isOwn && !cal.isDefault && (!cal.myRights || cal.myRights.mayDelete !== false);
const hasActions = canSetDefault || canRecolor || canRename || canShare || canClear || canDelete;
const orderIndex = orderable.indexOf(cal);
const previousId = isOwn && orderIndex > 0 ? orderable[orderIndex - 1].id : undefined;
const nextId = isOwn && orderIndex >= 0 ? orderable[orderIndex + 1]?.id : undefined;
const canMove = !!onMove && (!!previousId || !!nextId);
const hasActions = canSetDefault || canRecolor || canRename || canShare || canClear || canDelete || canMove;
const expanded = expandedId === cal.id && hasActions;
return (
<View key={cal.id}>
@@ -355,6 +368,27 @@ function Section({
{expanded && (
<View style={styles.actionsPanel}>
{/* The panel stays open so a calendar can be moved several places. */}
{canMove && previousId && (
<Pressable
onPress={() => onMove!(cal, previousId)}
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
accessibilityRole="button"
>
<ArrowUp size={16} color={c.textSecondary} />
<Text style={styles.panelRowText}>{t('settings.account.accounts.move_up', 'Move up')}</Text>
</Pressable>
)}
{canMove && nextId && (
<Pressable
onPress={() => onMove!(cal, nextId)}
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
accessibilityRole="button"
>
<ArrowDown size={16} color={c.textSecondary} />
<Text style={styles.panelRowText}>{t('settings.account.accounts.move_down', 'Move down')}</Text>
</Pressable>
)}
{canSetDefault && (
<Pressable
onPress={() => { onExpand(null); onSetDefault!(cal); }}
+29 -96
View File
@@ -1,23 +1,22 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, ActivityIndicator, Alert } from 'react-native';
import { Paperclip, Download, Eye, ExternalLink, Save, Share2, FolderArchive } from '../icons';
import type { File } from 'expo-file-system';
import type { Attachment, Email } from '../../api/types';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore } from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store';
import {
shareAttachment, downloadAttachment, shareAttachmentViaSheet, fetchBlobBytes,
shareBytes, shareLocalFile, cachePreviewFile, writePreviewFile, discardPreviewFile,
shareAttachment, downloadAttachment, shareAttachmentViaSheet, fetchBlobBytes, shareBytes, attachmentSaveName,
} from '../../lib/email-export';
import {
getAttachmentDisplayName, visibleAttachments, previewKindFor, formatSize,
getAttachmentDisplayName, visibleAttachments, formatSize,
} from '../../lib/attachment-display';
import { emailExportFilename } from '../../lib/download-filename';
import { ActionSheet, type ActionSheetItem } from './ActionSheet';
import { AttachmentPreviewModal, type PreviewItem } from './AttachmentPreviewModal';
import { emlPreviewFromBytes, type ExtractedAttachment } from './use-body-override';
import { AttachmentPreviewModal } from './AttachmentPreviewModal';
import type { ExtractedAttachment } from './use-body-override';
import { previewKindOf, useFilePreview, type PreviewSource } from './use-file-preview';
interface Props {
email: Email;
@@ -65,24 +64,7 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
const [expanded, setExpanded] = React.useState(false);
const [busyKey, setBusyKey] = React.useState<string | null>(null);
const [sheetItem, setSheetItem] = React.useState<Item | null>(null);
const [preview, setPreview] = React.useState<PreviewItem | null>(null);
const [previewLoading, setPreviewLoading] = React.useState(false);
const previewSource = React.useRef<Item | null>(null);
// The file the open preview shows, in a temp folder of its own: images are
// cached by path, so two attachments both called "image001.png" must not
// share one. Deleted when the preview closes, unless it went to another
// app, which may still be reading it; the stale-export sweep gets that one.
const previewFile = React.useRef<{ file: File; handedOff: boolean } | null>(null);
// Bumped whenever a preview ends, so a download that finishes after its
// preview was closed is dropped instead of reopening it.
const previewSeq = React.useRef(0);
const endPreview = React.useCallback(() => {
previewSeq.current += 1;
const open = previewFile.current;
previewFile.current = null;
if (open && !open.handedOff) discardPreviewFile(open.file);
}, []);
React.useEffect(() => endPreview, [endPreview]);
const preview = useFilePreview();
const items = React.useMemo<Item[]>(() => {
const blobs = visibleAttachments(email, { hideInlineImageAttachments, calendarBannerShown, tnefUnpacked })
@@ -134,49 +116,29 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
else await shareBytes(item.part.bytes, item.name, item.type, { forceSheet: true });
}, t('email_viewer.attachment_failed', 'Could not share attachment'));
const showPreview = (item: Item) => {
const kind = previewKindFor({ name: item.name, type: item.type });
if (kind === 'none') { void open(item); return; }
previewSource.current = item;
endPreview();
const seq = previewSeq.current;
const stillOpen = () => seq === previewSeq.current;
setPreviewLoading(true);
setPreview(null);
void (async () => {
try {
if (kind === 'text' || kind === 'eml') {
const bytes = item.kind === 'blob'
? await fetchBlobBytes(item.att.blobId, item.att.name, item.att.type, jmapAccountId)
: item.part.bytes;
const next: PreviewItem = kind === 'eml'
? { kind, name: item.name, mimeType: item.type, eml: await emlPreviewFromBytes(bytes) }
: { kind, name: item.name, mimeType: item.type, text: new TextDecoder('utf-8').decode(bytes) };
if (stillOpen()) setPreview(next);
return;
}
const file = item.kind === 'blob'
? await cachePreviewFile(item.att.blobId, item.name, item.type, jmapAccountId)
: writePreviewFile(item.part.bytes, item.name, item.type);
if (!stillOpen()) {
discardPreviewFile(file);
return;
}
previewFile.current = { file, handedOff: false };
setPreview({ kind, name: item.name, mimeType: item.type, fileUri: file.uri });
} catch (e) {
if (!stillOpen()) return;
setPreview(null);
Alert.alert(t('email_viewer.attachment_failed', 'Could not open attachment'), e instanceof Error ? e.message : String(e));
} finally {
if (stillOpen()) setPreviewLoading(false);
}
})();
};
const sourceOf = (item: Item): PreviewSource => (item.kind === 'blob'
? {
from: 'blob',
blobId: item.att.blobId,
accountId: jmapAccountId,
email,
name: item.name,
type: item.type,
size: item.size,
saveName: attachmentSaveName(email, item.att.name, item.att.type),
}
: { from: 'bytes', bytes: item.part.bytes, name: item.name, type: item.type, size: item.size });
// The in-app preview where the type has one, else straight to another app.
const showPreview = (item: Item) => run(
item.key,
() => preview.open(sourceOf(item)),
t('email_viewer.attachment_failed', 'Could not open attachment'),
);
const onTap = (item: Item) => {
if (mailAttachmentAction === 'download') void save(item);
else showPreview(item);
else void showPreview(item);
};
const downloadAll = () => run('__all__', async () => {
@@ -204,11 +166,11 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
const visible = expanded ? items : items.slice(0, 3);
const sheetActions: ActionSheetItem[] = sheetItem ? [
...(previewKindFor({ name: sheetItem.name, type: sheetItem.type }) !== 'none' ? [{
...(previewKindOf(sheetItem) !== 'none' ? [{
key: 'preview',
label: t('email_viewer.attachment_actions.preview', 'Preview'),
icon: <Eye size={18} color={c.textSecondary} />,
onPress: () => { const it = sheetItem; setSheetItem(null); showPreview(it); },
onPress: () => { const it = sheetItem; setSheetItem(null); void showPreview(it); },
}] : []),
{
key: 'open',
@@ -278,36 +240,7 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
onClose={() => setSheetItem(null)}
/>
<AttachmentPreviewModal
item={preview}
loading={previewLoading}
onClose={() => {
endPreview();
setPreview(null);
setPreviewLoading(false);
}}
onOpenExternal={() => {
const it = previewSource.current;
if (!it) return;
if (preview?.fileUri && it.kind === 'blob') {
void run(it.key, () => shareAttachment(it.att.blobId, it.att.name, it.att.type, email, jmapAccountId), t('email_viewer.attachment_failed', 'Could not open attachment'));
} else {
void open(it);
}
}}
onShare={() => {
const it = previewSource.current;
if (!it) return;
const open = previewFile.current;
if (open) {
// The receiving app may keep reading it after the sheet closes.
open.handedOff = true;
void shareLocalFile(open.file, it.type, it.name, { forceSheet: true, keep: true }).catch(() => undefined);
} else {
void share(it);
}
}}
/>
<AttachmentPreviewModal {...preview.modalProps} />
</View>
);
}
+178 -36
View File
@@ -1,16 +1,18 @@
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,
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 EmailBodyView from '../EmailBodyView';
import type { PreviewKind } from '../../lib/attachment-display';
import { needsNativeImageDecoder, needsWebImageViewer, type PreviewKind } from '../../lib/file-preview';
import { pdfPageFileName, renderPdfPage } from '../../lib/pdf-pages';
import type { EmlPreview } from './use-body-override';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
@@ -18,10 +20,14 @@ 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;
/** Replaces "No preview for this file type." on the fallback screen (e.g. the file is too large). */
@@ -34,7 +40,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 +49,91 @@ 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 and
* embedded messages. Files that don't decode, and types without a preview,
* offer another app instead. Used by every place that opens a file.
*/
export function AttachmentPreviewModal({
item, loading, title, onClose, onOpenExternal, onShare, onDownload, docked = false,
@@ -56,31 +142,49 @@ export function AttachmentPreviewModal({
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
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);
// 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 +196,13 @@ export function AttachmentPreviewModal({
</View>
);
const renderDocument = (header?: React.ReactNode) => (
<ScrollView style={styles.flex}>
{header}
{docEmail && <EmailBodyView email={docEmail} bodyOverride={{ html: item?.html ?? item?.eml?.html, text: item?.eml?.text }} />}
</ScrollView>
);
const renderBody = () => {
if (loading || !item) {
return (
@@ -105,6 +216,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 +245,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,28 +288,23 @@ 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 (
<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>
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}>{item.eml.date}</Text> : null}
</View>,
);
default:
return renderFallback(item.notice ?? t('email_viewer.preview.unsupported', 'No preview for this file type.'));
@@ -229,6 +369,8 @@ export function AttachmentPreviewModal({
);
}
const viewerStyles = StyleSheet.create({ fill: { flex: 1 } });
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
+21 -62
View File
@@ -15,9 +15,10 @@ import {
attachmentKind, realAttachments, requestListAttachments, shortAttachmentName,
type AttachmentKind, type LoadListAttachments,
} from '../../lib/list-attachments';
import { getAttachmentDisplayName, previewKindFor } from '../../lib/attachment-display';
import { cacheBlobFile, downloadAttachment, fetchBlobBytes, shareAttachment, shareLocalFile } from '../../lib/email-export';
import { AttachmentPreviewModal, type PreviewItem } from './AttachmentPreviewModal';
import { getAttachmentDisplayName } from '../../lib/attachment-display';
import { attachmentSaveName, downloadAttachment } from '../../lib/email-export';
import { AttachmentPreviewModal } from './AttachmentPreviewModal';
import { useFilePreview } from './use-file-preview';
/** How many chips a row shows before collapsing the rest into a count. */
const MAX_CHIPS = 2;
@@ -124,26 +125,16 @@ export interface ListAttachmentOpenerHandle {
open: (email: Email, attachment: Attachment, accountId?: string) => void;
}
interface OpenTarget {
email: Email;
attachment: Attachment;
accountId?: string;
name: string;
type: string;
}
/**
* Opens a list-row attachment the way the reader's chips do: the "attachment
* click action" setting either saves it or previews it in-app (images, PDFs,
* text), and anything that can't be previewed goes to the platform viewer.
* Holds the preview modal, so the list itself never re-renders for it.
* click action" setting either saves it or previews it in-app, and anything
* that can't be previewed goes to another app. Holds the preview modal, so
* the list itself never re-renders for it.
*/
export const ListAttachmentOpener = React.forwardRef<ListAttachmentOpenerHandle>(
function ListAttachmentOpener(_props, ref) {
const t = useLocaleStore((s) => s.t);
const [preview, setPreview] = React.useState<PreviewItem | null>(null);
const [loading, setLoading] = React.useState(false);
const target = React.useRef<OpenTarget | null>(null);
const preview = useFilePreview();
const busy = React.useRef(false);
const run = React.useCallback(async (fn: () => Promise<void>) => {
@@ -161,59 +152,27 @@ export const ListAttachmentOpener = React.forwardRef<ListAttachmentOpenerHandle>
}
}, [t]);
const openExternal = React.useCallback((it: OpenTarget) => run(
() => shareAttachment(it.attachment.blobId, it.attachment.name, it.attachment.type, it.email, it.accountId),
), [run]);
const { open: openPreview } = preview;
React.useImperativeHandle(ref, () => ({
open: (email, attachment, accountId) => {
if (busy.current) return;
const name = getAttachmentDisplayName(attachment.name, attachment.type);
const type = attachment.type || 'application/octet-stream';
const it: OpenTarget = { email, attachment, accountId, name, type };
target.current = it;
if (useSettingsStore.getState().mailAttachmentAction === 'download') {
void run(() => downloadAttachment(attachment.blobId, attachment.name, attachment.type, email, accountId));
return;
}
const kind = previewKindFor({ name, type });
if (kind !== 'image' && kind !== 'pdf' && kind !== 'text') {
void openExternal(it);
return;
}
setPreview(null);
setLoading(true);
void run(async () => {
try {
if (kind === 'text') {
const bytes = await fetchBlobBytes(attachment.blobId, attachment.name, attachment.type, accountId);
setPreview({ kind, name, mimeType: type, text: new TextDecoder('utf-8').decode(bytes) });
} else {
const file = await cacheBlobFile(attachment.blobId, name, type, accountId);
setPreview({ kind, name, mimeType: type, fileUri: file.uri });
}
} finally {
setLoading(false);
}
});
void run(() => openPreview({
from: 'blob',
blobId: attachment.blobId,
accountId,
email,
name: getAttachmentDisplayName(attachment.name, attachment.type),
type: attachment.type || 'application/octet-stream',
size: attachment.size,
saveName: attachmentSaveName(email, attachment.name, attachment.type),
}));
},
}), [run, openExternal]);
}), [run, openPreview]);
return (
<AttachmentPreviewModal
item={preview}
loading={loading}
onClose={() => { setPreview(null); setLoading(false); }}
onOpenExternal={() => { if (target.current) void openExternal(target.current); }}
onShare={() => {
const it = target.current;
if (!it || !preview?.fileUri) return;
const { File } = require('expo-file-system') as typeof import('expo-file-system');
void shareLocalFile(new File(preview.fileUri), it.type, it.name, { forceSheet: true }).catch(() => undefined);
}}
/>
);
return <AttachmentPreviewModal {...preview.modalProps} />;
},
);
+260
View File
@@ -0,0 +1,260 @@
import React from 'react';
import { Alert, Platform } from 'react-native';
import { File } from 'expo-file-system';
import type { Email } from '../../api/types';
import {
cachePreviewFile, copyPreviewFile, discardPreviewFile, saveLocalFileCopy, shareAttachment,
shareBytes, shareLocalFile, writePreviewFile,
} from '../../lib/email-export';
import {
exceedsPreviewLimit, inAppPreviewKind, type FilePreviewOptions,
} from '../../lib/file-preview';
import type { PreviewKind } from '../../lib/file-preview';
import { markdownToHtml } from '../../lib/markdown-html';
import { imageViewerHtml, mediaViewerHtml, pdfViewerHtml, type ViewerColors } from '../../lib/preview-viewer-html';
import { closePdfPages, openPdfPages, PdfOpenError } from '../../lib/pdf-pages';
import { useLocaleStore } from '../../stores/locale-store';
import { useColors, useResolvedTheme } from '../../theme/colors';
import type { PreviewItem } from './AttachmentPreviewModal';
import { emlPreviewFromBytes } from './use-body-override';
export const PREVIEW_OPTIONS: FilePreviewOptions = { platform: Platform.OS === 'ios' ? 'ios' : 'android' };
interface PreviewSourceBase {
/** Display name, shown in the header and given to the cached copy. */
name: string;
type: string;
size?: number | null;
/** Name for "Download" (the attachment filename template); defaults to `name`. */
saveName?: string;
}
/** Where a previewed file comes from. */
export type PreviewSource = PreviewSourceBase & (
| { from: 'blob'; blobId: string; accountId?: string; email?: Email | null }
| { from: 'bytes'; bytes: Uint8Array }
| { from: 'local'; uri: string }
);
/** The preview a tap on `source` opens, or 'none' when it goes to another app. */
export function previewKindOf(source: Pick<PreviewSource, 'name' | 'type' | 'size'>): PreviewKind {
return inAppPreviewKind(source, PREVIEW_OPTIONS);
}
/** Copy the source into a preview folder of its own (see cachePreviewFile). */
async function materialize(source: PreviewSource): Promise<File> {
switch (source.from) {
case 'blob':
return cachePreviewFile(source.blobId, source.name, source.type, source.accountId);
case 'bytes':
return writePreviewFile(source.bytes, source.name, source.type);
case 'local':
return copyPreviewFile(source.uri, source.name, source.type);
}
}
/** Hand a file the app doesn't show to the app that owns its type ("Open with"). */
function handOff(source: PreviewSource): Promise<void> {
switch (source.from) {
case 'blob':
return shareAttachment(source.blobId, source.name, source.type, source.email, source.accountId);
case 'bytes':
return shareBytes(source.bytes, source.name, source.type);
case 'local':
// A copy, so the cleanup after the hand-off never removes the original.
return shareLocalFile(copyPreviewFile(source.uri, source.name, source.type), source.type, source.name);
}
}
// The viewer page sits next to the file it shows (see preview-viewer-html).
function writeViewer(local: File, html: string): string {
const page = new File(local.parentDirectory, '__preview.html');
if (page.exists) page.delete();
page.create();
page.write(html);
return page.uri;
}
interface Notices {
tooLarge: string;
pdfProtected: string;
pdfUnreadable: string;
}
async function buildItem(
kind: Exclude<PreviewKind, 'none'>,
source: PreviewSource,
local: File,
notices: Notices,
colors: ViewerColors,
): Promise<PreviewItem> {
const base = { name: source.name, mimeType: source.type };
// The source may not know its size; the copy does.
if (exceedsPreviewLimit(kind, local.size)) return { ...base, kind: 'none', notice: notices.tooLarge };
switch (kind) {
case 'text':
return { ...base, kind, text: await local.text() };
case 'markdown':
return { ...base, kind, html: markdownToHtml(await local.text()) };
case 'html':
return { ...base, kind, html: await local.text() };
case 'eml':
return { ...base, kind, eml: await emlPreviewFromBytes(await local.bytes()) };
case 'image':
return { ...base, kind, fileUri: local.uri, viewerUri: writeViewer(local, imageViewerHtml(local.name, colors)) };
case 'video':
case 'audio':
return { ...base, kind, fileUri: local.uri, viewerUri: writeViewer(local, mediaViewerHtml(kind, local.name, colors)) };
case 'pdf': {
// iOS's WebView shows the PDF itself; Android renders its pages.
if (Platform.OS !== 'android') return { ...base, kind, fileUri: local.uri };
try {
const pages = await openPdfPages(local.uri);
return { ...base, kind, fileUri: local.uri, viewerUri: writeViewer(local, pdfViewerHtml(local.name, pages, colors)) };
} catch (e) {
const protectedPdf = e instanceof PdfOpenError && e.reason === 'protected';
return { ...base, kind: 'none', notice: protectedPdf ? notices.pdfProtected : notices.pdfUnreadable };
}
}
default:
return { ...base, kind, fileUri: local.uri };
}
}
/**
* One in-app preview: picks how a file is shown, downloads it once into a
* temp folder of its own, and serves Open with, Share and Download from that
* copy. Files the app can't show go straight to another app. The copy is
* deleted when the preview closes or the next one opens, unless another app
* got it, which may still be reading it; the stale-export sweep removes
* those. Used by every place that opens a file: the reader's chips, the
* list's chips, the Files tab and compose.
*
* Render `<AttachmentPreviewModal {...preview.modalProps} />` next to it.
*/
export function useFilePreview(opts: {
/** Called when the preview closes: the close button, or a download that failed. */
onClose?: () => void;
} = {}) {
const t = useLocaleStore((s) => s.t);
const c = useColors();
const scheme = useResolvedTheme();
// Read when a preview is built; the viewer page keeps the theme it opened with.
const viewerColors: ViewerColors = { scheme, background: c.background, text: c.text, textMuted: c.textMuted, surface: c.surface };
const colors = React.useRef(viewerColors);
colors.current = viewerColors;
const onCloseRef = React.useRef(opts.onClose);
onCloseRef.current = opts.onClose;
const [item, setItem] = React.useState<PreviewItem | null>(null);
const [loading, setLoading] = React.useState(false);
const [title, setTitle] = React.useState('');
const source = React.useRef<PreviewSource | null>(null);
const session = React.useRef<{ file: File; handedOff: boolean } | null>(null);
// Bumped whenever a preview ends, so a download that finishes after its
// preview was closed is dropped instead of reopening it.
const seq = React.useRef(0);
const busy = React.useRef(false);
const end = React.useCallback(() => {
seq.current += 1;
const open = session.current;
session.current = null;
if (!open) return;
closePdfPages(open.file.uri);
if (!open.handedOff) discardPreviewFile(open.file);
}, []);
React.useEffect(() => end, [end]);
/** Close without telling the owner (it closed the preview itself). */
const reset = React.useCallback(() => {
end();
source.current = null;
setItem(null);
setLoading(false);
}, [end]);
const close = React.useCallback(() => {
reset();
onCloseRef.current?.();
}, [reset]);
/**
* Show `src` in the preview, or hand it to another app when the preview
* can't show it. The promise settles once that hand-off is done (and
* rejects when it fails); a preview resolves at once.
*/
const open = React.useCallback((src: PreviewSource): Promise<void> => {
const kind = previewKindOf(src);
if (kind === 'none') return handOff(src);
end();
source.current = src;
const mine = seq.current;
const current = () => mine === seq.current;
setTitle(src.name);
setItem(null);
setLoading(true);
void (async () => {
try {
const local = await materialize(src);
if (!current()) {
discardPreviewFile(local);
return;
}
session.current = { file: local, handedOff: false };
const next = await buildItem(kind, src, local, {
tooLarge: t('files.preview_too_large', 'This file is too large to preview.'),
pdfProtected: t(
'email_viewer.preview.pdf_protected',
'This PDF is password-protected. Open it in another app to enter the password.',
),
pdfUnreadable: t('email_viewer.preview.pdf_unreadable', "This PDF can't be shown here."),
}, colors.current);
if (current()) setItem(next);
} catch (e) {
if (!current()) return;
close();
Alert.alert(t('files.preview_error', 'Failed to load preview'), e instanceof Error ? e.message : String(e));
} finally {
if (current()) setLoading(false);
}
})();
return Promise.resolve();
}, [end, close, t]);
// The preview's own buttons, all on the downloaded copy.
const act = React.useCallback((run: (local: File, src: PreviewSource) => Promise<void>, handsOff: boolean, failTitle: string) => {
const open = session.current;
const src = source.current;
if (!open || !src || busy.current) return;
busy.current = true;
// Another app may keep reading the file after the sheet or viewer returns.
if (handsOff) open.handedOff = true;
run(open.file, src)
.catch((e) => Alert.alert(failTitle, e instanceof Error ? e.message : String(e)))
.finally(() => { busy.current = false; });
}, []);
const modalProps = React.useMemo(() => ({
item,
loading,
title,
onClose: close,
onOpenExternal: () => act(
(local, src) => shareLocalFile(local, src.type, src.name, { keep: true }),
true,
t('files.open_error', 'Could not open the file'),
),
onShare: () => act(
(local, src) => shareLocalFile(local, src.type, src.name, { forceSheet: true, keep: true }),
true,
t('files.share_file_error', 'Could not share the file'),
),
onDownload: () => act(
(local, src) => saveLocalFileCopy(local, src.type, src.saveName ?? src.name),
false,
t('files.download_error', 'Failed to download'),
),
}), [item, loading, title, close, act, t]);
return { open, close, reset, modalProps };
}
+12 -116
View File
@@ -1,24 +1,13 @@
import React from 'react';
import { Alert, Platform } from 'react-native';
import type { File } from 'expo-file-system';
import type { FileNode } from '../../api/types';
import {
cachePreviewFile, discardPreviewFile, saveLocalFileCopy, shareLocalFile,
} from '../../lib/email-export';
import {
exceedsPreviewLimit, inAppPreviewKind, type FilePreviewOptions,
} from '../../lib/file-preview';
import { useLocaleStore } from '../../stores/locale-store';
import { AttachmentPreviewModal, type PreviewItem } from '../email/AttachmentPreviewModal';
import { emlPreviewFromBytes } from '../email/use-body-override';
const PREVIEW_OPTIONS: FilePreviewOptions = { inlinePdf: Platform.OS === 'ios' };
import { AttachmentPreviewModal } from '../email/AttachmentPreviewModal';
import { previewKindOf, useFilePreview } from '../email/use-file-preview';
export type PreviewableFileNode = Pick<FileNode, 'name' | 'type' | 'size' | 'blobId' | 'accountId'>;
/** Whether a tap on this file opens the in-app preview (else the OS chooser, as before). */
export function canPreviewInApp(node: PreviewableFileNode): boolean {
return !!node.blobId && inAppPreviewKind(node, PREVIEW_OPTIONS) !== 'none';
return !!node.blobId && previewKindOf({ name: node.name, type: node.type || '', size: node.size }) !== 'none';
}
interface Props {
@@ -30,22 +19,13 @@ interface Props {
}
/**
* The Files tab's in-app preview: the reader's attachment preview fed from a
* FileNode. The file is downloaded once, with the same authenticated
* download as "Download" (token refresh, client certificate), into its own
* temp folder; Open with, Share and Download then all use that copy instead
* of fetching it again. The copy is deleted when the preview closes, unless
* another app got it, in which case the stale-export sweep removes it.
* The Files tab's in-app preview: the shared file preview fed from a
* FileNode, downloaded with the same authenticated download as "Download"
* (token refresh, client certificate).
*/
export function FilePreviewModal({ file, onClose, docked = false }: Props) {
const t = useLocaleStore((s) => s.t);
const [item, setItem] = React.useState<PreviewItem | null>(null);
const [loading, setLoading] = React.useState(false);
// The downloaded copy of the open preview.
const session = React.useRef<{ file: File; handedOff: boolean } | null>(null);
const busy = React.useRef(false);
const onCloseRef = React.useRef(onClose);
onCloseRef.current = onClose;
const preview = useFilePreview({ onClose });
const { open, reset } = preview;
const blobId = file?.blobId ?? null;
const accountId = file?.accountId;
@@ -55,93 +35,9 @@ export function FilePreviewModal({ file, onClose, docked = false }: Props) {
React.useEffect(() => {
if (!blobId) return undefined;
let cancelled = false;
setItem(null);
setLoading(true);
void open({ from: 'blob', blobId, accountId, name, type, size });
return reset;
}, [blobId, accountId, name, type, size, open, reset]);
const build = async (local: File): Promise<PreviewItem> => {
const kind = inAppPreviewKind({ name, type, size }, PREVIEW_OPTIONS);
// The node may not report a size; the download does.
if (kind === 'none' || exceedsPreviewLimit(kind, local.size)) {
return {
kind: 'none',
name,
mimeType: type,
notice: kind === 'none' ? undefined : t('files.preview_too_large', 'This file is too large to preview.'),
};
}
switch (kind) {
case 'text':
return { kind, name, mimeType: type, text: await local.text() };
case 'eml':
return { kind, name, mimeType: type, eml: await emlPreviewFromBytes(await local.bytes()) };
default:
return { kind, name, mimeType: type, fileUri: local.uri };
}
};
void (async () => {
try {
const local = await cachePreviewFile(blobId, name, type, accountId);
if (cancelled) {
discardPreviewFile(local);
return;
}
session.current = { file: local, handedOff: false };
const next = await build(local);
if (!cancelled) setItem(next);
} catch (e) {
if (cancelled) return;
Alert.alert(t('files.preview_error', 'Failed to load preview'), e instanceof Error ? e.message : String(e));
onCloseRef.current();
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
const open = session.current;
session.current = null;
if (open && !open.handedOff) discardPreviewFile(open.file);
setItem(null);
setLoading(false);
};
}, [blobId, accountId, name, type, size, t]);
const act = (run: (local: File) => Promise<void>, handsOff: boolean, failTitle: string) => {
const open = session.current;
if (!open || busy.current) return;
busy.current = true;
// Another app may keep reading the file after the sheet or viewer returns.
if (handsOff) open.handedOff = true;
run(open.file)
.catch((e) => Alert.alert(failTitle, e instanceof Error ? e.message : String(e)))
.finally(() => { busy.current = false; });
};
return (
<AttachmentPreviewModal
item={item}
loading={loading}
title={name}
docked={docked}
onClose={onClose}
onOpenExternal={() => act(
(local) => shareLocalFile(local, type, name, { keep: true }),
true,
t('files.open_error', 'Could not open the file'),
)}
onShare={() => act(
(local) => shareLocalFile(local, type, name, { forceSheet: true, keep: true }),
true,
t('files.share_file_error', 'Could not share the file'),
)}
onDownload={() => act(
(local) => saveLocalFileCopy(local, type),
false,
t('files.download_error', 'Failed to download'),
)}
/>
);
return <AttachmentPreviewModal {...preview.modalProps} docked={docked} />;
}
+3 -11
View File
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import {
getAttachmentDisplayName, visibleAttachments, previewKindFor, isReportPart, isRfc822Attachment,
getAttachmentDisplayName, visibleAttachments, isReportPart, isRfc822Attachment,
isEmbeddedInBody,
} from '../attachment-display';
import { buildForwardAsAttachmentPayload } from '../forward-as-attachment';
@@ -93,16 +93,8 @@ describe('isEmbeddedInBody', () => {
});
});
describe('previewKindFor / classification', () => {
it('classifies previewable types and refuses script-bearing ones', () => {
expect(previewKindFor({ type: 'image/jpeg', name: 'a.jpg' })).toBe('image');
expect(previewKindFor({ type: 'application/pdf', name: 'a.pdf' })).toBe('pdf');
expect(previewKindFor({ type: 'text/plain', name: 'a.txt' })).toBe('text');
expect(previewKindFor({ type: 'application/octet-stream', name: 'notes.md' })).toBe('text');
expect(previewKindFor({ type: 'message/rfc822', name: 'fwd.eml' })).toBe('eml');
expect(previewKindFor({ type: 'text/html', name: 'a.html' })).toBe('none');
expect(previewKindFor({ type: 'image/svg+xml', name: 'a.svg' })).toBe('none');
expect(previewKindFor({ type: 'application/zip', name: 'a.zip' })).toBe('none');
describe('report and embedded-message parts', () => {
it('recognises DSN reports and .eml parts', () => {
expect(isReportPart('message/delivery-status')).toBe(true);
expect(isRfc822Attachment({ type: 'application/octet-stream', name: 'x.eml' })).toBe(true);
});
+94
View File
@@ -0,0 +1,94 @@
import { describe, it, expect } from 'vitest';
import { compareCalendars, moveCalendar, sortCalendars, sortOrderUpdates } from '../calendar-sort';
import type { Calendar } from '../../api/types';
function cal(overrides: Partial<Calendar>): Calendar {
return { id: 'c', name: 'Cal', sortOrder: 0, ...overrides };
}
const names = (list: Calendar[]) => list.map((c) => c.name);
const ids = (list: Calendar[]) => list.map((c) => c.id);
describe('sortCalendars', () => {
it('orders by sortOrder, whatever order the server listed them in', () => {
const list = [
cal({ id: 'b', name: 'Default', sortOrder: 20 }),
cal({ id: 'n', name: 'Holidays', sortOrder: 30 }),
cal({ id: 'e', name: 'Work', sortOrder: 1 }),
cal({ id: 'd', name: 'Personal', sortOrder: 10 }),
];
expect(names(sortCalendars(list))).toEqual(['Work', 'Personal', 'Default', 'Holidays']);
});
it('sorts equal sortOrder by name, ignoring case and accents, numbers by value', () => {
const list = [
cal({ id: '1', name: 'zeta' }),
cal({ id: '2', name: 'Team 10' }),
cal({ id: '3', name: 'Éclair' }),
cal({ id: '4', name: 'alpha' }),
cal({ id: '5', name: 'Team 2' }),
cal({ id: '6', name: 'Beta' }),
];
expect(names(sortCalendars(list))).toEqual(['alpha', 'Beta', 'Éclair', 'Team 2', 'Team 10', 'zeta']);
});
it('treats a missing sortOrder as 0', () => {
const list = [cal({ id: 'a', name: 'B', sortOrder: 1 }), cal({ id: 'b', name: 'A', sortOrder: undefined })];
expect(names(sortCalendars(list))).toEqual(['A', 'B']);
});
it('keeps each account together, in the order the accounts came', () => {
const list = [
cal({ id: 'own-2', name: 'Own 2', sortOrder: 2 }),
cal({ id: 'own-1', name: 'Own 1', sortOrder: 1 }),
cal({ id: 'x:b', name: 'Shared B', sortOrder: 0, isShared: true, accountId: 'x' }),
cal({ id: 'y:a', name: 'Other A', sortOrder: 0, isShared: true, accountId: 'y' }),
cal({ id: 'x:a', name: 'Shared A', sortOrder: 5, isShared: true, accountId: 'x' }),
];
expect(ids(sortCalendars(list))).toEqual(['own-1', 'own-2', 'x:b', 'x:a', 'y:a']);
});
it('does not change the list it is given', () => {
const list = [cal({ id: 'b', sortOrder: 2 }), cal({ id: 'a', sortOrder: 1 })];
sortCalendars(list);
expect(ids(list)).toEqual(['b', 'a']);
});
it('compareCalendars puts the lower sortOrder first', () => {
expect(compareCalendars(cal({ sortOrder: 1, name: 'Z' }), cal({ sortOrder: 2, name: 'A' }))).toBeLessThan(0);
});
});
describe('sortOrderUpdates', () => {
it('numbers the calendars 1, 2, 3 in the given order and skips unchanged ones', () => {
const list = [cal({ id: 'a', sortOrder: 1 }), cal({ id: 'b', sortOrder: 2 }), cal({ id: 'c', sortOrder: 3 })];
expect(sortOrderUpdates(list, ['a', 'c', 'b'])).toEqual([
{ id: 'c', sortOrder: 2 },
{ id: 'b', sortOrder: 3 },
]);
});
});
describe('moveCalendar', () => {
const list = [
cal({ id: 'work', name: 'Work', sortOrder: 1 }),
cal({ id: 'home', name: 'Home', sortOrder: 10 }),
cal({ id: 'tasks', name: 'Tasks', sortOrder: 15 }),
cal({ id: 'holidays', name: 'Holidays', sortOrder: 30 }),
cal({ id: 'x:team', name: 'Team', sortOrder: 0, isShared: true, accountId: 'x' }),
];
it('moving up puts the calendar just before the target', () => {
expect(moveCalendar(list, 'holidays', 'home')).toEqual(['work', 'holidays', 'home', 'tasks']);
});
it('moving down puts the calendar just after the target', () => {
expect(moveCalendar(list, 'work', 'home')).toEqual(['home', 'work', 'tasks', 'holidays']);
});
it('refuses a move across accounts, onto itself or to an unknown id', () => {
expect(moveCalendar(list, 'work', 'x:team')).toBeNull();
expect(moveCalendar(list, 'work', 'work')).toBeNull();
expect(moveCalendar(list, 'work', 'gone')).toBeNull();
});
});
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest';
import {
initialDraftPartBlobState, liveBlobId, matchDraftParts, rebaseDraftPartBlobs, withSnapshotBody,
} from '../draft-part-blobs';
import type { OutgoingAttachment } from '../../api/email';
const inline = (blobId: string, cid: string, name = 'img.png', size = 10): OutgoingAttachment => ({
blobId, cid, name, size, type: 'image/png', disposition: 'inline',
});
const file = (blobId: string, name: string, size: number): OutgoingAttachment => ({
blobId, name, size, type: 'application/pdf', disposition: 'attachment',
});
describe('matchDraftParts', () => {
it('pairs inline parts by Content-ID, brackets and case aside', () => {
const pairs = matchDraftParts(
[inline('old-a', 'a@x'), inline('old-b', 'b@x')],
[
{ blobId: 'new-b', cid: '<B@x>', name: 'img.png', size: 10 },
{ blobId: 'new-a', cid: '<a@x>', name: 'img.png', size: 10 },
],
);
expect(pairs).toEqual(new Map([['old-a', 'new-a'], ['old-b', 'new-b']]));
});
it('pairs file attachments by name and size, claiming each new part once', () => {
const pairs = matchDraftParts(
[file('old-1', 'q.pdf', 5), file('old-2', 'q.pdf', 5), file('old-3', 'r.pdf', 7)],
[
{ blobId: 'new-r', name: 'r.pdf', size: 7 },
{ blobId: 'new-q1', name: 'q.pdf', size: 5 },
{ blobId: 'new-q2', name: 'q.pdf', size: 5 },
],
);
expect(pairs.get('old-1')).toBe('new-q1');
expect(pairs.get('old-2')).toBe('new-q2');
expect(pairs.get('old-3')).toBe('new-r');
});
it('leaves a part it cannot find unpaired', () => {
expect(matchDraftParts([file('old', 'gone.pdf', 1)], [{ blobId: 'n', name: 'other.pdf', size: 1 }]).size).toBe(0);
});
});
describe('rebaseDraftPartBlobs (webmail #849, second half)', () => {
it('follows the draft across successive saves so no save sends a destroyed part', () => {
// Re-opened draft D0 with one inline image and one PDF.
let state = initialDraftPartBlobState();
const sentAt = () => [inline(liveBlobId(state, 'd0-img'), 'img@x'), file(liveBlobId(state, 'd0-pdf'), 'q.pdf', 5)];
// Save 1 sends D0's parts (D0 is still alive), creates D1, destroys D0.
expect(sentAt().map((a) => a.blobId)).toEqual(['d0-img', 'd0-pdf']);
state = rebaseDraftPartBlobs(state, sentAt(), [
{ blobId: 'd1-img', cid: '<img@x>', name: 'img.png', size: 10 },
{ blobId: 'd1-pdf', name: 'q.pdf', size: 5 },
]);
// Save 2 / send must reference D1's parts, not D0's dead ones.
expect(sentAt().map((a) => a.blobId)).toEqual(['d1-img', 'd1-pdf']);
state = rebaseDraftPartBlobs(state, sentAt(), [
{ blobId: 'd2-img', cid: '<img@x>', name: 'img.png', size: 10 },
{ blobId: 'd2-pdf', name: 'q.pdf', size: 5 },
]);
expect(sentAt().map((a) => a.blobId)).toEqual(['d2-img', 'd2-pdf']);
});
it('re-points uploads too: an upload expires once nothing references it', () => {
let state = initialDraftPartBlobState();
const sent = () => [inline(liveBlobId(state, 'd0-img'), 'img@x'), file(liveBlobId(state, 'upload-1'), 'new.pdf', 3)];
state = rebaseDraftPartBlobs(state, sent(), [
{ blobId: 'd1-img', cid: '<img@x>', name: 'img.png', size: 10 },
{ blobId: 'd1-new', name: 'new.pdf', size: 3 },
]);
expect(sent().map((a) => a.blobId)).toEqual(['d1-img', 'd1-new']);
});
it('leaves the state alone when nothing was sent', () => {
const state = initialDraftPartBlobState();
expect(rebaseDraftPartBlobs(state, [], [{ blobId: 'p', name: 'a.pdf', size: 1 }])).toBe(state);
});
it('keeps the old id when the read-back came back empty (next save fails loudly, nothing is lost)', () => {
const state = initialDraftPartBlobState();
const next = rebaseDraftPartBlobs(state, [inline('d0-img', 'img@x')], []);
expect(liveBlobId(next, 'd0-img')).toBe('d0-img');
});
});
describe('withSnapshotBody', () => {
it('replaces only the body of a composer snapshot', () => {
const snap = JSON.stringify({ to: [['A', 'a@x', null]], cc: [], bcc: [], subject: 'S', body: '<p>raw</p>', att: ['b1'] });
expect(JSON.parse(withSnapshotBody(snap, '<p>normalised</p>'))).toEqual({
to: [['A', 'a@x', null]], cc: [], bcc: [], subject: 'S', body: '<p>normalised</p>', att: ['b1'],
});
});
it('keeps the key order, so an unchanged composer still compares equal', () => {
const make = (body: string) => JSON.stringify({ to: [], cc: [], bcc: [], subject: 'S', body, att: [] });
expect(withSnapshotBody(make('<p>raw</p>'), '<p>x</p>')).toBe(make('<p>x</p>'));
});
});
+84
View File
@@ -196,3 +196,87 @@ describe('editor page messages', () => {
expect((page.selections()[1].payload as { bold: boolean }).bold).toBe(true);
});
});
// Android pans the editor page's visual viewport to reveal the caret and the
// pan outlived the moment: back at the top of the composer the body's first
// lines stayed hidden under To/Cc/Subject. The page undoes each pan and hands
// the offset to RN instead.
describe('editor visual viewport pinning', () => {
function boot(pinVisualViewport: boolean | undefined, withVisualViewport = true) {
const posted: Array<{ type: string; payload: unknown }> = [];
const scrolledIntoView: unknown[] = [];
const vvListeners: Record<string, Array<() => void>> = {};
const visualViewport = {
offsetTop: 0,
addEventListener: (name: string, fn: () => void) => { (vvListeners[name] ??= []).push(fn); },
};
const editor = {
innerHTML: '',
get textContent() { return this.innerHTML.replace(/<[^>]*>/g, ''); },
scrollHeight: 100,
querySelector: () => null,
setAttribute: () => undefined,
focus: () => undefined,
addEventListener: () => undefined,
// Resetting the pan is what scrolling the page top into view does.
scrollIntoView: (opts: unknown) => { scrolledIntoView.push(opts); visualViewport.offsetTop = 0; },
};
const document = {
activeElement: editor as unknown,
getElementById: () => editor,
addEventListener: () => undefined,
queryCommandState: () => false,
};
const window = {
ReactNativeWebView: { postMessage: (msg: string) => posted.push(JSON.parse(msg)) },
addEventListener: () => undefined,
getSelection: () => null,
visualViewport: withVisualViewport ? visualViewport : undefined,
};
const script = extractScript(buildEditorHtml({
initialHtml: '<p><br></p>', placeholder: '', c: LIGHT_COLORS, pinVisualViewport,
}));
new Function('window', 'document', script)(window, document);
const pan = (offsetTop: number) => {
visualViewport.offsetTop = offsetTop;
(vvListeners.scroll ?? []).forEach((fn) => fn());
};
const shifts = () => posted.filter((m) => m.type === 'viewportShift').map((m) => m.payload);
return { pan, shifts, scrolledIntoView, visualViewport, vvListeners };
}
it('undoes a caret-reveal pan and reports its offset to RN', () => {
const page = boot(true);
page.pan(18.5);
expect(page.visualViewport.offsetTop).toBe(0);
expect(page.scrolledIntoView).toEqual([{ block: 'start', inline: 'nearest' }]);
expect(page.shifts()).toEqual([18.5]);
});
it('ignores the scroll event its own reset fires', () => {
const page = boot(true);
page.pan(40);
page.pan(0);
page.pan(0.2);
expect(page.shifts()).toEqual([40]);
expect(page.scrolledIntoView).toHaveLength(1);
});
it('reports each later pan separately', () => {
const page = boot(true);
page.pan(12);
page.pan(30);
expect(page.shifts()).toEqual([12, 30]);
});
it('leaves the viewport alone unless asked (iOS, settings editors)', () => {
const off = boot(false);
expect(off.vvListeners.scroll).toBeUndefined();
const unset = boot(undefined);
expect(unset.vvListeners.scroll).toBeUndefined();
});
it('survives a WebView without visualViewport', () => {
expect(() => boot(true, false)).not.toThrow();
});
});
+96 -20
View File
@@ -1,52 +1,80 @@
import { describe, it, expect } from 'vitest';
import {
filePreviewKind, inAppPreviewKind, exceedsPreviewLimit, FILE_PREVIEW_MAX_BYTES,
needsNativeImageDecoder, needsWebImageViewer, handoffMimeType,
} from '../file-preview';
const IOS = { inlinePdf: true };
const ANDROID = { inlinePdf: false };
const IOS = { platform: 'ios' } as const;
const ANDROID = { platform: 'android' } as const;
describe('filePreviewKind', () => {
it('previews the images React Native decodes, by type or by extension', () => {
it('previews common images by type or by extension', () => {
expect(filePreviewKind({ name: 'photo.jpg', type: 'image/jpeg' }, ANDROID)).toBe('image');
expect(filePreviewKind({ name: 'scan.PNG', type: 'application/octet-stream' }, ANDROID)).toBe('image');
expect(filePreviewKind({ name: 'IMG_0001', type: 'image/heic' }, ANDROID)).toBe('image');
expect(filePreviewKind({ name: 'anim.webp', type: '' }, ANDROID)).toBe('image');
});
it('hands images it cannot decode to another app', () => {
it('draws SVG and ICO images instead of handing them off', () => {
expect(filePreviewKind({ name: 'logo.svg', type: 'image/svg+xml' }, IOS)).toBe('image');
expect(filePreviewKind({ name: 'logo.svg', type: 'application/octet-stream' }, ANDROID)).toBe('image');
expect(filePreviewKind({ name: 'favicon.ico', type: 'image/x-icon' }, ANDROID)).toBe('image');
expect(filePreviewKind({ name: 'logo.svgz', type: 'image/svg+xml' }, IOS)).toBe('none');
});
it('shows TIFF only where the platform decodes it', () => {
expect(filePreviewKind({ name: 'fax.tiff', type: 'image/tiff' }, IOS)).toBe('image');
expect(filePreviewKind({ name: 'fax.tif', type: '' }, IOS)).toBe('image');
expect(filePreviewKind({ name: 'fax.tiff', type: 'image/tiff' }, ANDROID)).toBe('none');
expect(filePreviewKind({ name: 'raw.cr2', type: 'image/x-canon-cr2' }, ANDROID)).toBe('none');
});
it('keeps script-bearing documents out of the preview', () => {
expect(filePreviewKind({ name: 'logo.svg', type: 'image/svg+xml' }, IOS)).toBe('none');
expect(filePreviewKind({ name: 'page.html', type: 'text/html' }, IOS)).toBe('none');
expect(filePreviewKind({ name: 'page.htm', type: 'application/octet-stream' }, IOS)).toBe('none');
expect(filePreviewKind({ name: 'doc', type: 'application/xhtml+xml' }, IOS)).toBe('none');
it('renders HTML and Markdown documents', () => {
expect(filePreviewKind({ name: 'page.html', type: 'text/html' }, IOS)).toBe('html');
expect(filePreviewKind({ name: 'page.htm', type: 'application/octet-stream' }, ANDROID)).toBe('html');
expect(filePreviewKind({ name: 'doc', type: 'application/xhtml+xml' }, IOS)).toBe('html');
expect(filePreviewKind({ name: 'README.md', type: 'application/octet-stream' }, ANDROID)).toBe('markdown');
expect(filePreviewKind({ name: 'notes', type: 'text/markdown' }, ANDROID)).toBe('markdown');
});
it('shows PDFs in-app only where the platform renders them', () => {
it('shows PDFs in-app on both platforms', () => {
expect(filePreviewKind({ name: 'report.pdf', type: 'application/pdf' }, IOS)).toBe('pdf');
expect(filePreviewKind({ name: 'report.pdf', type: 'application/octet-stream' }, IOS)).toBe('pdf');
expect(filePreviewKind({ name: 'report.pdf', type: 'application/pdf' }, ANDROID)).toBe('none');
expect(filePreviewKind({ name: 'report.pdf', type: 'application/pdf' }, ANDROID)).toBe('pdf');
expect(filePreviewKind({ name: 'REPORT.PDF', type: '' }, ANDROID)).toBe('pdf');
});
it('shows plain text, markdown and data files as text', () => {
it('plays the video formats each platform plays', () => {
expect(filePreviewKind({ name: 'clip.mp4', type: 'video/mp4' }, IOS)).toBe('video');
expect(filePreviewKind({ name: 'clip.mov', type: 'video/quicktime' }, IOS)).toBe('video');
expect(filePreviewKind({ name: 'clip.mp4', type: 'video/mp4' }, ANDROID)).toBe('video');
expect(filePreviewKind({ name: 'clip.webm', type: 'video/webm' }, ANDROID)).toBe('video');
expect(filePreviewKind({ name: 'clip.mkv', type: 'application/octet-stream' }, ANDROID)).toBe('video');
expect(filePreviewKind({ name: 'clip.webm', type: 'video/webm' }, IOS)).toBe('none');
expect(filePreviewKind({ name: 'old.avi', type: 'video/x-msvideo' }, ANDROID)).toBe('none');
expect(filePreviewKind({ name: 'old.wmv', type: 'video/x-ms-wmv' }, IOS)).toBe('none');
});
it('plays the audio formats each platform plays', () => {
expect(filePreviewKind({ name: 'song.mp3', type: 'audio/mpeg' }, IOS)).toBe('audio');
expect(filePreviewKind({ name: 'voice.m4a', type: '' }, IOS)).toBe('audio');
expect(filePreviewKind({ name: 'memo.ogg', type: 'audio/ogg' }, ANDROID)).toBe('audio');
expect(filePreviewKind({ name: 'memo.ogg', type: 'application/octet-stream' }, ANDROID)).toBe('audio');
expect(filePreviewKind({ name: 'memo.opus', type: 'audio/ogg' }, IOS)).toBe('none');
expect(filePreviewKind({ name: 'call.amr', type: 'audio/amr' }, ANDROID)).toBe('none');
});
it('shows plain text, data and source files as text', () => {
expect(filePreviewKind({ name: 'notes.txt', type: 'text/plain' }, ANDROID)).toBe('text');
expect(filePreviewKind({ name: 'README.md', type: 'application/octet-stream' }, ANDROID)).toBe('text');
expect(filePreviewKind({ name: 'data.csv', type: 'text/csv' }, ANDROID)).toBe('text');
expect(filePreviewKind({ name: 'config', type: 'application/json' }, ANDROID)).toBe('text');
expect(filePreviewKind({ name: 'feed', type: 'application/atom+xml' }, ANDROID)).toBe('text');
});
it('shows source code as text, including the script types the reader leaves out', () => {
expect(filePreviewKind({ name: 'app.ts', type: 'application/octet-stream' }, ANDROID)).toBe('text');
expect(filePreviewKind({ name: 'main.py', type: '' }, ANDROID)).toBe('text');
expect(filePreviewKind({ name: 'build.sh', type: 'application/x-sh' }, ANDROID)).toBe('text');
expect(filePreviewKind({ name: 'index.js', type: 'text/javascript' }, ANDROID)).toBe('text');
expect(filePreviewKind({ name: 'bundle', type: 'application/javascript' }, ANDROID)).toBe('text');
expect(filePreviewKind({ name: 'Dockerfile', type: '' }, ANDROID)).toBe('text');
expect(filePreviewKind({ name: 'invite.ics', type: 'text/calendar' }, IOS)).toBe('text');
});
it('shows saved messages like the reader does', () => {
@@ -54,10 +82,21 @@ describe('filePreviewKind', () => {
expect(filePreviewKind({ name: 'fwd', type: 'message/rfc822' }, ANDROID)).toBe('eml');
});
it('leaves binaries and office documents to other apps', () => {
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('leaves archives, office documents and binaries to other apps', () => {
expect(filePreviewKind({ name: 'archive.zip', type: 'application/zip' }, IOS)).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 +106,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 +143,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');
});
});
+70
View File
@@ -0,0 +1,70 @@
import { describe, it, expect } from 'vitest';
import { markdownToHtml, renderInline } from '../markdown-html';
const body = (md: string) => {
const html = markdownToHtml(md);
return html.slice(html.indexOf('<body>') + 6, html.indexOf('</body>'));
};
describe('markdownToHtml', () => {
it('renders headings, paragraphs and rules', () => {
expect(body('# Title\n\nSome text\nwrapped.\n\n---\n\n### Small ###'))
.toBe('<h1>Title</h1>\n<p>Some text\nwrapped.</p>\n<hr>\n<h3>Small</h3>');
expect(body('Big\n===\n\nSmaller\n---')).toBe('<h1>Big</h1>\n<h2>Smaller</h2>');
});
it('renders nested and ordered lists and task items', () => {
expect(body('- one\n- two\n - nested\n- three')).toBe(
'<ul><li>one</li><li>two\n<ul><li>nested</li></ul></li><li>three</li></ul>',
);
expect(body('3. c\n4. d')).toBe('<ol start="3"><li>c</li><li>d</li></ol>');
expect(body('- [x] done\n- [ ] open')).toBe('<ul><li class="task">\u2611 done</li><li class="task">\u2610 open</li></ul>');
});
it('keeps fenced and indented code verbatim and escaped', () => {
expect(body('```js\nconst a = "<b>";\n**not bold**\n```')).toBe(
'<pre><code class="language-js">const a = &quot;&lt;b&gt;&quot;;\n**not bold**</code></pre>',
);
expect(body(' x < 1')).toBe('<pre><code>x &lt; 1</code></pre>');
});
it('renders quotes and GFM tables with alignment', () => {
expect(body('> quoted\n> **text**')).toBe('<blockquote><p>quoted\n<strong>text</strong></p></blockquote>');
expect(body('| a | b |\n|:--|--:|\n| 1 | 2 |')).toBe(
'<table><thead><tr><th style="text-align:left">a</th><th style="text-align:right">b</th></tr></thead>'
+ '<tbody><tr><td style="text-align:left">1</td><td style="text-align:right">2</td></tr></tbody></table>',
);
});
it('escapes raw HTML instead of passing it through', () => {
expect(body('<script>alert(1)</script>')).toBe('<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>');
expect(body('<img src=x onerror=alert(1)>')).not.toContain('<img');
});
});
describe('renderInline', () => {
it('renders emphasis, code and strikethrough', () => {
expect(renderInline('**b** *i* _j_ `c*d*` ~~s~~')).toBe(
'<strong>b</strong> <em>i</em> <em>j</em> <code>c*d*</code> <del>s</del>',
);
expect(renderInline('snake_case_name stays')).toBe('snake_case_name stays');
expect(renderInline('\\*literal\\*')).toBe('*literal*');
});
it('links only to web and mail URLs', () => {
expect(renderInline('[site](https://example.org "T")')).toBe('<a href="https://example.org" title="T">site</a>');
expect(renderInline('[x](javascript:alert(1))')).toBe('x)');
expect(renderInline('[doc](./docs/a.md)')).toBe('doc');
expect(renderInline('see https://example.org/a.')).toBe('see <a href="https://example.org/a">https://example.org/a</a>.');
expect(renderInline('<mailto:a@b.c>')).toBe('<a href="mailto:a@b.c">mailto:a@b.c</a>');
});
it('keeps image URLs for the sanitiser and drops unsafe ones', () => {
expect(renderInline('![logo](https://example.org/l.png)')).toBe('<img src="https://example.org/l.png" alt="logo">');
expect(renderInline('![x](data:image/png;base64,AAA)')).toBe('x');
});
it('escapes attribute breakouts in link text and titles', () => {
expect(renderInline('[a"b](https://e.org "x\\" onmouseover=\\"y")')).not.toMatch(/onmouseover="/);
});
});
+42
View File
@@ -0,0 +1,42 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { NativeModules } from 'react-native';
import { openPdfPages, PdfOpenError, renderPdfPage, canRenderPdfPages } from '../pdf-pages';
const modules = NativeModules as Record<string, unknown>;
function nativeError(code: string, message: string): Error {
return Object.assign(new Error(message), { code });
}
describe('pdf-pages', () => {
afterEach(() => { delete modules.BulwarkPdf; });
it('reports the page sizes the renderer returns', async () => {
modules.BulwarkPdf = { open: vi.fn(async () => ({ pages: [{ width: 595, height: 842 }] })) };
await expect(openPdfPages('file:///x.pdf')).resolves.toEqual([{ width: 595, height: 842 }]);
});
it('tells a password-protected PDF from an unreadable one', async () => {
modules.BulwarkPdf = { open: vi.fn(async () => { throw nativeError('E_PDF_PROTECTED', 'password'); }) };
await expect(openPdfPages('file:///x.pdf')).rejects.toMatchObject({ reason: 'protected' });
modules.BulwarkPdf = { open: vi.fn(async () => { throw nativeError('E_PDF_OPEN', 'not a PDF'); }) };
await expect(openPdfPages('file:///x.pdf')).rejects.toMatchObject({ reason: 'unreadable' });
modules.BulwarkPdf = { open: vi.fn(async () => ({ pages: [] })) };
await expect(openPdfPages('file:///x.pdf')).rejects.toBeInstanceOf(PdfOpenError);
});
it('fails cleanly in a build without the renderer', async () => {
expect(canRenderPdfPages()).toBe(false);
await expect(openPdfPages('file:///x.pdf')).rejects.toMatchObject({ reason: 'unreadable' });
await expect(renderPdfPage('file:///x.pdf', 0, 100, 'file:///p.png')).rejects.toThrow();
});
it('renders at a whole pixel width', async () => {
const renderPage = vi.fn(async () => 'file:///p.png');
modules.BulwarkPdf = { renderPage };
await renderPdfPage('file:///x.pdf', 2, 1620.6, 'file:///p.png');
expect(renderPage).toHaveBeenCalledWith('file:///x.pdf', 2, 1621, 'file:///p.png');
});
});
+54
View File
@@ -0,0 +1,54 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import PostalMime from 'postal-mime';
// React Native's Blob only takes strings and other Blobs; building one from an
// ArrayBuffer or typed array throws. postal-mime used to join every decoded
// part through such a Blob, so an .eml with an attachment could not be
// previewed on the device (patches/postal-mime+3.0.0.patch).
class ReactNativeBlob {
constructor(parts: unknown[] = []) {
if (parts.some((p) => p instanceof ArrayBuffer || ArrayBuffer.isView(p))) {
throw new Error("Creating blobs from 'ArrayBuffer' and 'ArrayBufferView' are not supported");
}
}
}
const MESSAGE = [
'From: Carol <carol@example.org>',
'Subject: With parts',
'MIME-Version: 1.0',
'Content-Type: multipart/mixed; boundary="b1"',
'',
'--b1',
'Content-Type: text/plain; charset=utf-8',
'Content-Transfer-Encoding: quoted-printable',
'',
'Gr=C3=BC=C3=9Fe',
'--b1',
'Content-Type: application/octet-stream; name="a.bin"',
'Content-Transfer-Encoding: base64',
'Content-Disposition: attachment; filename="a.bin"',
'',
'AAECAwQF',
'--b1',
'Content-Type: text/plain; name="inner.txt"',
'Content-Disposition: attachment; filename="inner.txt"',
'',
'Inner text.',
'--b1--',
'',
].join('\r\n');
describe('postal-mime under React Native', () => {
const realBlob = globalThis.Blob;
beforeEach(() => { (globalThis as { Blob: unknown }).Blob = ReactNativeBlob; });
afterEach(() => { globalThis.Blob = realBlob; });
it('decodes base64, quoted-printable and plain parts without a Blob', async () => {
const parsed = await PostalMime.parse(new TextEncoder().encode(MESSAGE), { attachmentEncoding: 'arraybuffer' });
expect(parsed.text?.trim()).toBe('Grüße');
const [bin, txt] = parsed.attachments;
expect(Array.from(new Uint8Array(bin.content as ArrayBuffer))).toEqual([0, 1, 2, 3, 4, 5]);
expect(new TextDecoder().decode(txt.content as ArrayBuffer).trim()).toBe('Inner text.');
});
});
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import { imageViewerHtml, mediaViewerHtml, relativeFileUrl, type ViewerColors } from '../preview-viewer-html';
const COLORS: ViewerColors = { scheme: 'dark', background: '#111', text: '#eee', textMuted: '#999', surface: '#222' };
const HOSTILE = `x"><script>alert(1)</script>'.png`;
describe('relativeFileUrl', () => {
it('encodes everything a URL or an attribute could read as syntax', () => {
expect(relativeFileUrl('Scan 1 (final).PNG')).toBe('Scan%201%20%28final%29.PNG');
expect(relativeFileUrl('a#b?c%d.jpg')).toBe('a%23b%3Fc%25d.jpg');
expect(relativeFileUrl(HOSTILE)).not.toMatch(/["'<>]/);
});
});
describe('viewer pages', () => {
it('load the file by a relative URL next to the page', () => {
expect(imageViewerHtml('photo 1.jpg', COLORS)).toContain('src="photo%201.jpg"');
expect(mediaViewerHtml('video', 'clip.mp4', COLORS)).toContain('<video id="m" src="clip.mp4"');
expect(mediaViewerHtml('audio', 'song.mp3', COLORS)).toContain('<audio id="m" src="song.mp3"');
});
it('never put a file name into markup unescaped', () => {
for (const html of [
imageViewerHtml(HOSTILE, COLORS),
mediaViewerHtml('video', HOSTILE, COLORS),
mediaViewerHtml('audio', HOSTILE, COLORS),
]) {
expect(html).not.toContain('<script>alert(1)');
expect(html.match(/<script>/g)).toHaveLength(1);
}
});
it('report a file that does not decode', () => {
expect(imageViewerHtml('a.svg', COLORS)).toContain("addEventListener('error'");
expect(mediaViewerHtml('audio', 'a.ogg', COLORS)).toContain("report('error')");
});
it('draw the WebView controls in the app theme, and video on black', () => {
expect(mediaViewerHtml('audio', 'a.mp3', COLORS)).toContain('color-scheme:dark');
expect(mediaViewerHtml('audio', 'a.mp3', { ...COLORS, scheme: 'light' })).toContain('color-scheme:light');
expect(mediaViewerHtml('video', 'a.mp4', { ...COLORS, scheme: 'light' })).toContain('color-scheme:dark');
});
});
-18
View File
@@ -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`;
+69
View File
@@ -0,0 +1,69 @@
import type { Calendar } from '../api/types';
// Names compare the way the user reads them: accents and case are ignored, and
// "Team 2" comes before "Team 10".
const nameCollator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true });
/**
* JMAP Calendars: a calendar with a lower `sortOrder` is shown first, and
* calendars with equal `sortOrder` are sorted by name. Mirrors the webmail.
*/
export function compareCalendars(a: Calendar, b: Calendar): number {
return (a.sortOrder ?? 0) - (b.sortOrder ?? 0) || nameCollator.compare(a.name ?? '', b.name ?? '');
}
/**
* Calendars of one account stay together: the user's own first, then each
* sharing account, as fetched.
*/
export function calendarAccountKey(calendar: Calendar): string {
return calendar.isShared ? calendar.accountId ?? '' : '';
}
/**
* The calendars in display order: grouped by account in the order the
* accounts first appear, and by `sortOrder`, then name, within each account.
*/
export function sortCalendars(calendars: Calendar[]): Calendar[] {
const accountRank = new Map<string, number>();
for (const calendar of calendars) {
const key = calendarAccountKey(calendar);
if (!accountRank.has(key)) accountRank.set(key, accountRank.size);
}
return [...calendars].sort((a, b) =>
accountRank.get(calendarAccountKey(a))! - accountRank.get(calendarAccountKey(b))! || compareCalendars(a, b),
);
}
/**
* `sortOrder` values that put `orderedIds` in that order: 1, 2, 3... Only the
* calendars whose value changes are returned.
*/
export function sortOrderUpdates(calendars: Calendar[], orderedIds: string[]): { id: string; sortOrder: number }[] {
const byId = new Map(calendars.map((calendar) => [calendar.id, calendar]));
const updates: { id: string; sortOrder: number }[] = [];
orderedIds.forEach((id, index) => {
const calendar = byId.get(id);
if (calendar && calendar.sortOrder !== index + 1) updates.push({ id, sortOrder: index + 1 });
});
return updates;
}
/**
* The ids of the calendar's account in display order once it is moved next to
* `targetId`: before the target when it moves up, after it when it moves down.
* Null when the move is not possible (unknown ids, or another account).
*/
export function moveCalendar(calendars: Calendar[], calendarId: string, targetId: string): string[] | null {
const sorted = sortCalendars(calendars);
const moving = sorted.find((calendar) => calendar.id === calendarId);
const target = sorted.find((calendar) => calendar.id === targetId);
if (!moving || !target || moving === target) return null;
const key = calendarAccountKey(moving);
if (calendarAccountKey(target) !== key) return null;
const group = sorted.filter((calendar) => calendarAccountKey(calendar) === key);
const movingDown = group.indexOf(moving) < group.indexOf(target);
const rest = group.filter((calendar) => calendar !== moving);
rest.splice(rest.indexOf(target) + (movingDown ? 1 : 0), 0, moving);
return rest.map((calendar) => calendar.id);
}
+108
View File
@@ -0,0 +1,108 @@
// Keep a re-opened draft's attachments pointing at blobs that still exist.
//
// A draft's attachments and inline images are MIME parts of that draft, and a
// part's blobId lives only as long as the message that holds it: once a save
// creates the next version and destroys the previous one, the server forgets
// the old part blobs (Stalwart answers `blobNotFound`, "blobId … does not
// exist on this server"). The composer was seeded with those part blobIds, so
// without a re-point the second save of a re-opened draft - and any send
// after its first save - failed (webmail #849, second half).
//
// Upload blobs are only a reprieve: the server keeps an unreferenced upload
// for a limited time, so every attachment is re-pointed at the parts of the
// version each save creates.
import type { Attachment } from '../api/types';
import type { OutgoingAttachment } from '../api/email';
import { stripMessageIdBrackets } from './email-threading';
const normCid = (cid?: string | null) => (cid ? stripMessageIdBrackets(cid).trim().toLowerCase() : '');
/**
* Pair each sent attachment with the part of the newly created version that
* carries the same content: inline parts by Content-ID, the rest by name and
* size. Each new part is claimed at most once. Returns sent blobId → new blobId.
*/
export function matchDraftParts(
sent: readonly OutgoingAttachment[],
fresh: readonly Pick<Attachment, 'blobId' | 'name' | 'size' | 'cid'>[],
): Map<string, string> {
const out = new Map<string, string>();
const claimed = new Set<number>();
const claim = (s: OutgoingAttachment, test: (p: (typeof fresh)[number]) => boolean) => {
if (out.has(s.blobId)) return;
const i = fresh.findIndex((p, idx) => !claimed.has(idx) && !!p.blobId && test(p));
if (i === -1) return;
claimed.add(i);
out.set(s.blobId, fresh[i].blobId);
};
for (const s of sent) {
const cid = normCid(s.cid);
if (cid) claim(s, (p) => normCid(p.cid) === cid);
}
for (const s of sent) {
claim(s, (p) => (p.name || '') === (s.name || '') && (s.size == null || p.size === s.size));
}
return out;
}
export interface DraftPartBlobState {
/** blobId an attachment entry holds → blobId to send for it now. */
live: Map<string, string>;
}
export function initialDraftPartBlobState(): DraftPartBlobState {
return { live: new Map() };
}
/** The blobId to put on the wire for an entry that holds `blobId`. */
export function liveBlobId(state: DraftPartBlobState, blobId: string): string {
return state.live.get(blobId) ?? blobId;
}
/**
* After a save: point every attachment that was sent at the matching part of
* the version just created. Those parts live exactly as long as the saved
* draft, so the next save or the send always references live blobs - whether
* the entry came from the previous version (dies with it) or from an upload
* (an unreferenced upload expires on the server). Blobs that could not be
* matched keep their id: the next save then fails loudly (and, with
* create-before-destroy, never loses the draft) instead of silently dropping
* an attachment.
*/
export function rebaseDraftPartBlobs(
state: DraftPartBlobState,
sent: readonly OutgoingAttachment[],
fresh: readonly Pick<Attachment, 'blobId' | 'name' | 'size' | 'cid'>[],
): DraftPartBlobState {
const pairs = matchDraftParts(sent, fresh);
if (pairs.size === 0) return state;
const live = new Map(state.live);
const current = new Set(state.live.values());
for (const [held, sentId] of state.live) {
const next = pairs.get(sentId);
if (next) live.set(held, next);
}
for (const [sentId, next] of pairs) {
// An entry sent under its own blobId for the first time.
if (!current.has(sentId)) live.set(sentId, next);
}
return { live };
}
/**
* The composer's dirty snapshot with its body replaced. Opening a draft whose
* inline images get hydrated rewrites the editor body (normalised DOM HTML,
* data URLs for `cid:` images); that rewrite is not an edit, so the baseline
* has to follow it - comparing against the raw server HTML instead made every
* re-opened draft with inline images look edited, and the autosave replaced
* it (and destroyed the opened version) two seconds after opening.
*/
export function withSnapshotBody(snapshot: string, body: string): string {
try {
const parsed = JSON.parse(snapshot) as Record<string, unknown>;
return JSON.stringify({ ...parsed, body });
} catch {
return snapshot;
}
}
+28 -1
View File
@@ -70,8 +70,14 @@ export function buildEditorHtml(opts: {
c: ThemePalette;
/** Block remote images and fonts (see `buildEditorCsp`). */
blockRemoteImages?: boolean;
/**
* Keep the page's visual viewport at the top and report every pan to RN as
* a `viewportShift` (CSS px = dp) instead (see the page script). Android
* only: its WebView pans the visual viewport to reveal the caret.
*/
pinVisualViewport?: boolean;
}): string {
const { initialHtml, placeholder, c, blockRemoteImages = false } = opts;
const { initialHtml, placeholder, c, blockRemoteImages = false, pinVisualViewport = false } = opts;
// Inject initial content as a JSON-encoded string so any HTML/quotes inside
// are safely embedded (no template literal collision with the script body).
const initialJson = JSON.stringify(initialHtml);
@@ -362,6 +368,27 @@ export function buildEditorHtml(opts: {
reportHeight();
reportChange();
// ── Visual viewport pinning (Android) ──────────────────────────────
// The WebView is as tall as its content and sits in RN's ScrollView, so
// the page itself never scrolls. But Android's WebView shrinks the visual
// viewport to the part of the view that is on screen (above the keyboard,
// below the header fields) and pans it to reveal the caret while typing.
// The pan outlives the moment: once the composer is scrolled back to the
// top the body is still drawn shifted up by that offset, so its first
// lines hide under To/Cc/Subject until a scroll to the very bottom clamps
// it away. Undo every pan at once (window.scrollTo only moves the layout
// viewport; scrolling the top of the page into view resets both) and hand
// the offset to RN, which scrolls the composer by as much so the caret
// stays where the pan had put it.
if (${pinVisualViewport ? 'true' : 'false'} && window.visualViewport) {
window.visualViewport.addEventListener('scroll', function () {
var shift = window.visualViewport.offsetTop;
if (Math.abs(shift) < 0.5) return;
try { editor.scrollIntoView({ block: 'start', inline: 'nearest' }); } catch (e) {}
post('viewportShift', shift);
});
}
// Re-measure after async layout (image loads, font swap).
window.addEventListener('load', function () {
setTimeout(reportHeight, 50);
+24 -4
View File
@@ -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;
@@ -47,6 +48,11 @@ function attachmentFileOptions(): EmailFilenameOptions {
};
}
/** The name "Save" gives an attachment of `email`: the user's attachment filename template. */
export function attachmentSaveName(email: Email, name: string | undefined, type: string | undefined): string {
return attachmentDownloadFilename(email, { name, type }, attachmentFileOptions());
}
function safeAttachmentName(name: string | undefined, type: string | undefined): string {
const fallbackExt = type?.split('/')[1]?.replace(/[^a-z0-9]/gi, '').slice(0, 8) || 'bin';
const cleaned = (name ?? '').replace(/[\\/:*?"<>|]/g, '_').trim();
@@ -151,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;
@@ -254,7 +260,7 @@ export async function shareAttachment(
}
try {
await Sharing.shareAsync(downloaded.uri, {
mimeType,
mimeType: handoffMimeType(filename, mimeType),
dialogTitle: filename,
});
} finally {
@@ -291,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,
});
}
@@ -347,6 +353,20 @@ export async function cachePreviewFile(
}
}
/**
* {@link cachePreviewFile} for a file already on the device (a compose
* attachment): copied, so the preview owns its copy and the original is never
* deleted with it.
*/
export function copyPreviewFile(uri: string, name: string | undefined, type: string | undefined): File {
void sweepStaleExportFiles();
const dir = newPreviewDir();
ensureDir(dir);
const file = new File(dir, safeAttachmentName(name, type));
new File(uri).copy(file);
return file;
}
/**
* {@link cachePreviewFile} for bytes already in memory (parts unpacked on the
* device, e.g. from winmail.dat).
@@ -403,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);
}
+213 -65
View File
@@ -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' | '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,132 @@ 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. 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,
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 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';
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 +160,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;
}
+280
View File
@@ -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> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
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>`;
}
+67
View File
@@ -0,0 +1,67 @@
// Android's PDF pages for the file preview, rendered by BulwarkPdfModule
// (the platform's PdfRenderer) into PNGs next to the PDF. iOS needs none of
// this: its WebView shows PDFs itself.
import { NativeModules, Platform } from 'react-native';
export interface PdfPageSize {
/** In points. */
width: number;
height: number;
}
interface NativePdf {
open(fileUri: string): Promise<{ pages: PdfPageSize[] }>;
renderPage(fileUri: string, index: number, width: number, outUri: string): Promise<string>;
close(fileUri: string): Promise<void>;
}
function native(): NativePdf | null {
if (Platform.OS !== 'android') return null;
return (NativeModules.BulwarkPdf as NativePdf | undefined) ?? null;
}
/** Whether this build renders PDFs in-app. */
export function canRenderPdfPages(): boolean {
return native() !== null;
}
export type PdfOpenFailure = 'protected' | 'unreadable';
export class PdfOpenError extends Error {
constructor(readonly reason: PdfOpenFailure, message: string) {
super(message);
}
}
/** The pages of a PDF, or a {@link PdfOpenError} saying why it can't be shown. */
export async function openPdfPages(fileUri: string): Promise<PdfPageSize[]> {
const pdf = native();
if (!pdf) throw new PdfOpenError('unreadable', 'PDF rendering is not available');
try {
const { pages } = await pdf.open(fileUri);
if (!pages.length) throw new PdfOpenError('unreadable', 'The PDF has no pages');
return pages;
} catch (e) {
if (e instanceof PdfOpenError) throw e;
const code = (e as { code?: string } | null)?.code;
throw new PdfOpenError(code === 'E_PDF_PROTECTED' ? 'protected' : 'unreadable', e instanceof Error ? e.message : String(e));
}
}
/** Render page `index` (0-based) `width` pixels wide into `outUri`. */
export function renderPdfPage(fileUri: string, index: number, width: number, outUri: string): Promise<string> {
const pdf = native();
if (!pdf) return Promise.reject(new Error('PDF rendering is not available'));
return pdf.renderPage(fileUri, index, Math.round(width), outUri);
}
/** Let go of the open document. */
export function closePdfPages(fileUri: string): void {
void native()?.close(fileUri).catch(() => undefined);
}
/** Name of the image a page is rendered into, next to the viewer page. */
export function pdfPageFileName(index: number): string {
return `__page-${index + 1}.png`;
}
+142
View File
@@ -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> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
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>`,
);
}
+11
View File
@@ -96,6 +96,7 @@ import {
truncateRecurrenceRules,
} from '../lib/recurrence-overrides';
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from '../lib/birthday-calendar';
import { moveCalendar } from '../lib/calendar-sort';
import { useContactsStore } from '../stores/contacts-store';
import { useLocaleStore } from '../stores/locale-store';
import { useUserCalendarAddresses } from '../lib/calendar-user-addresses';
@@ -262,6 +263,7 @@ export default function CalendarScreen() {
const setDefaultCalendar = useCalendarStore((s) => s.setDefaultCalendar);
const createCalendar = useCalendarStore((s) => s.createCalendar);
const updateCalendar = useCalendarStore((s) => s.updateCalendar);
const reorderCalendars = useCalendarStore((s) => s.reorderCalendars);
const removeCalendar = useCalendarStore((s) => s.removeCalendar);
const clearCalendarEvents = useCalendarStore((s) => s.clearCalendarEvents);
const shareCalendar = useCalendarStore((s) => s.shareCalendar);
@@ -954,6 +956,14 @@ export default function CalendarScreen() {
[setSharedCalendarColor, updateCalendar, reportError],
);
const handleMoveCalendar = React.useCallback(
(cal: Calendar, targetId: string) => {
const orderedIds = moveCalendar(storeCalendars, cal.id, targetId);
if (orderedIds) reorderCalendars(orderedIds).catch(reportError);
},
[storeCalendars, reorderCalendars, reportError],
);
const handleClearCalendar = React.useCallback(
(cal: Calendar) => {
Alert.alert(
@@ -1277,6 +1287,7 @@ export default function CalendarScreen() {
onShare={(cal) => { setSidebarVisible(false); setShareTarget(cal); }}
onClear={handleClearCalendar}
onDelete={handleDeleteCalendar}
onMove={handleMoveCalendar}
isSubscriptionCalendar={isSubscriptionCalendar}
/>
+61 -21
View File
@@ -17,7 +17,6 @@ import DateTimePicker, { type DateTimePickerEvent } from '@react-native-communit
import * as ImagePicker from 'expo-image-picker';
import * as DocumentPicker from 'expo-document-picker';
import * as FileSystem from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing';
import * as Clipboard from 'expo-clipboard';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
@@ -43,7 +42,7 @@ import { toast } from '../stores/toast-store';
import { type EmailTemplate } from '../stores/templates-store';
import { getIdentities } from '../api/identity';
import {
sendEmail, createDraft, destroyEmails, patchKeywordsForEmails, type OutgoingAttachment, type OutgoingEmail,
sendEmail, saveDraftVersion, destroyEmails, patchKeywordsForEmails, type OutgoingAttachment, type OutgoingEmail,
} from '../api/email';
import { jmapClient, RequestTimeoutError, ScheduleTooLateError } from '../api/jmap-client';
import { uploadBlob, uploadBytes } from '../api/blob';
@@ -64,6 +63,9 @@ import {
findComposeIdentityId, findDraftIdentityId, resolveComposeAccountEmail, resolveReplyIdentity,
} from '../lib/reply-identity';
import { shouldBlockEditorRemoteImages } from '../lib/editor-html';
import {
initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs, withSnapshotBody,
} from '../lib/draft-part-blobs';
import {
signatureIdentityFor, buildEmbeddedSignatureHtml, containsEmbeddedSignature, spliceSignature,
insertSignatureAboveQuote, getPlainTextSignature, appendPlainTextSignature,
@@ -80,6 +82,8 @@ import type { EmailAddress, Identity } from '../api/types';
import type { RootStackParamList } from '../navigation/types';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import ToggleSwitch from '../components/ToggleSwitch';
import { AttachmentPreviewModal } from '../components/email/AttachmentPreviewModal';
import { useFilePreview } from '../components/email/use-file-preview';
type Props = NativeStackScreenProps<RootStackParamList, 'Compose'>;
@@ -395,6 +399,7 @@ function OptionsSheet({
}
export default function ComposeScreen({ route, navigation }: Props) {
const filePreview = useFilePreview();
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
@@ -673,10 +678,29 @@ export default function ComposeScreen({ route, navigation }: Props) {
const subjectRef = React.useRef<TextInput>(null);
const plainBodyRef = React.useRef<TextInput>(null);
const [subjectFocused, setSubjectFocused] = React.useState(false);
// The composer's ScrollView and its live offset. On Android the editor page
// hands back the caret-reveal pans it undoes (see `pinVisualViewport` in
// editor-html); scrolling the composer by the same amount keeps the caret
// where the pan had put it, without leaving the body drawn shifted up.
const bodyScrollRef = React.useRef<ScrollView>(null);
const bodyScrollY = React.useRef(0);
const onEditorViewportShift = React.useCallback((dy: number) => {
const y = Math.max(0, bodyScrollY.current + dy);
bodyScrollY.current = y;
bodyScrollRef.current?.scrollTo({ y, animated: false });
}, []);
const isPickingSuggestion = React.useRef(false);
// Track inline-image placeholders that haven't yet been rewritten to cid:
// until send time. Maps cid → blobId/type/name/size.
const inlineRegistryRef = React.useRef<Map<string, AttachmentEntry>>(new Map());
// A re-opened draft's attachments and inline images are parts of that
// draft: their blobIds die with each version a save replaces (#849), and an
// upload expires once nothing references it. This maps the blobId each
// entry holds to the matching part of the latest saved version, which
// `buildOutgoing` sends. Kept out of the attachment state so the dirty
// snapshot does not change on every save.
const draftPartBlobsRef = React.useRef(initialDraftPartBlobState());
const getAutocomplete = useContactsStore((s) => s.getAutocomplete);
const getGroupRecipients = useContactsStore((s) => s.getGroupRecipients);
@@ -836,6 +860,10 @@ export default function ComposeScreen({ route, navigation }: Props) {
}
}
if (cancelled || map.size === 0) return;
// No change message since the editor loaded = the user has not typed
// yet, so the editor still holds the seeded body (normalised by the
// WebView DOM, which is why it can differ from the raw server HTML).
const untouched = !plainTextMode && latestRef.current.bodyHtml === initialBodyHtml;
let live = latestRef.current.bodyHtml;
try {
live = (await editorRef.current?.getHtml()) ?? live;
@@ -846,8 +874,16 @@ export default function ComposeScreen({ route, navigation }: Props) {
editorRef.current?.setHtml(next);
}
// The hydrated images are part of the untouched baseline, not an edit.
baselineRef.current = replaceInlineImagePlaceholders(baselineRef.current, map);
if (lastSavedRef.current) lastSavedRef.current = replaceInlineImagePlaceholders(lastSavedRef.current, map);
if (untouched) {
// Rebase on the body the composer now holds: comparing it with the
// raw server HTML made every re-opened draft with inline images look
// edited, and the autosave replaced (and destroyed) it unasked.
baselineRef.current = withSnapshotBody(baselineRef.current, next);
if (lastSavedRef.current) lastSavedRef.current = withSnapshotBody(lastSavedRef.current, next);
} else {
baselineRef.current = replaceInlineImagePlaceholders(baselineRef.current, map);
if (lastSavedRef.current) lastSavedRef.current = replaceInlineImagePlaceholders(lastSavedRef.current, map);
}
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -1229,7 +1265,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
.map((cid) => inlineRegistryRef.current.get(cid))
.filter((e): e is AttachmentEntry => !!e && !!e.blobId && !e.error)
.map<OutgoingAttachment>((e) => ({
blobId: e.blobId!,
blobId: liveBlobId(draftPartBlobsRef.current, e.blobId!),
type: e.type,
name: e.name,
size: e.size,
@@ -1240,7 +1276,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
const fileAttachments = attachments
.filter((a) => !a.inline && a.blobId && !a.error)
.map<OutgoingAttachment>((a) => ({
blobId: a.blobId!,
blobId: liveBlobId(draftPartBlobsRef.current, a.blobId!),
type: a.type,
name: a.name,
size: a.size,
@@ -1300,8 +1336,14 @@ export default function ComposeScreen({ route, navigation }: Props) {
setDraftStatus('saving');
try {
const outgoing = buildOutgoing(identity, html, { forDraft: true });
const id = await createDraft(outgoing, draftsMailbox.id, draftIdRef.current ?? undefined);
const saved = await saveDraftVersion(outgoing, draftsMailbox.id, draftIdRef.current ?? undefined);
const id = saved.id;
draftIdRef.current = id;
// The previous version and its part blobs are gone: point every
// attachment at the parts of the version just created.
draftPartBlobsRef.current = rebaseDraftPartBlobs(
draftPartBlobsRef.current, outgoing.attachments ?? [], saved.attachments,
);
lastSavedRef.current = snapshot;
lastSaveAtRef.current = Date.now();
setDraftStatus('saved');
@@ -1705,22 +1747,14 @@ export default function ComposeScreen({ route, navigation }: Props) {
await insertInlineImages(result.assets);
};
// Open a chip: local files straight from their URI, server blobs after a
// download into the cache.
// Open a chip in the file preview: local files from their URI, server
// blobs (forwarded, from a draft) after a download.
const previewAttachment = async (entry: AttachmentEntry) => {
try {
let uri = entry.uri;
if (!uri && entry.blobId) {
const buf = await jmapClient.fetchBlobArrayBuffer(entry.blobId, entry.name, entry.type);
const dir = FileSystem.cacheDirectory ?? '';
uri = `${dir}${entry.localId}-${entry.name.replace(/[^\w.-]+/g, '_')}`;
await FileSystem.writeAsStringAsync(uri, bytesToBase64(new Uint8Array(buf)), {
encoding: FileSystem.EncodingType.Base64,
});
}
if (!uri) return;
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(uri, { mimeType: entry.type, dialogTitle: entry.name });
if (entry.uri) {
await filePreview.open({ from: 'local', uri: entry.uri, name: entry.name, type: entry.type, size: entry.size });
} else if (entry.blobId) {
await filePreview.open({ from: 'blob', blobId: entry.blobId, name: entry.name, type: entry.type, size: entry.size });
}
} catch (err) {
Alert.alert(t('email_composer.attach', 'Attach'), err instanceof Error ? err.message : String(err));
@@ -2363,9 +2397,12 @@ export default function ComposeScreen({ route, navigation }: Props) {
<View style={[styles.flex, { paddingBottom: bottomPad }]}>
<ScrollView
ref={bodyScrollRef}
style={styles.flex}
keyboardShouldPersistTaps="always"
contentContainerStyle={styles.scrollContent}
onScroll={(e) => { bodyScrollY.current = e.nativeEvent.contentOffset.y; }}
scrollEventThrottle={16}
>
<View style={styles.fieldRow}>
<Text style={[styles.fieldLabel, fieldLabelWidth]} numberOfLines={1}>{t('email_composer.from', 'From')}</Text>
@@ -2456,6 +2493,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
placeholder={t('email_composer.body_placeholder', 'Write your message...')}
onChange={setBodyHtml}
onSelectionChange={setSelState}
onViewportShift={onEditorViewportShift}
/>
)}
</ScrollView>
@@ -2915,6 +2953,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
selectedIdentityId={selectedIdentityId}
onPick={(identity) => { setSelectedIdentityId(identity.id); setFromOverride(null); }}
/>
<AttachmentPreviewModal {...filePreview.modalProps} />
</SafeAreaView>
);
if (!isSplit) return composer;
@@ -0,0 +1,84 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../api/calendar', () => ({
getCalendars: vi.fn(),
updateCalendar: vi.fn(),
createCalendar: vi.fn(),
isCalendarAccessDenied: vi.fn(() => false),
noteCalendarAccessError: vi.fn(() => false),
resetCalendarAccessDenied: vi.fn(),
supportsSyntheticCalendarIds: vi.fn(async () => false),
resetSyntheticIdSupport: vi.fn(),
}));
vi.mock('../../api/jmap-client', () => ({
jmapClient: { accountId: 'acc-1', isConnected: true },
}));
vi.mock('../../device-sync/app/request', () => ({ requestDeviceSync: vi.fn() }));
import * as calendarApi from '../../api/calendar';
import { useCalendarStore } from '../calendar-store';
import type { Calendar } from '../../api/types';
const mockGetCalendars = calendarApi.getCalendars as ReturnType<typeof vi.fn>;
const mockUpdateCalendar = calendarApi.updateCalendar as ReturnType<typeof vi.fn>;
const cal = (overrides: Partial<Calendar>): Calendar => ({ id: 'c', name: 'Cal', sortOrder: 0, ...overrides });
const names = () => useCalendarStore.getState().calendars.map((c) => c.name);
beforeEach(() => {
vi.clearAllMocks();
useCalendarStore.setState({ calendars: [], error: null });
});
describe('calendar order', () => {
it('fetchCalendars lists the calendars by sortOrder, not in server order', async () => {
mockGetCalendars.mockResolvedValue([
cal({ id: 'b', name: 'Default', sortOrder: 20 }),
cal({ id: 'n', name: 'Holidays', sortOrder: 30 }),
cal({ id: 'e', name: 'Work', sortOrder: 1 }),
]);
await useCalendarStore.getState().fetchCalendars();
expect(names()).toEqual(['Work', 'Default', 'Holidays']);
});
it('reorderCalendars writes only the sortOrders that change, to the owning account', async () => {
useCalendarStore.setState({
calendars: [
cal({ id: 'a', name: 'A', sortOrder: 1 }),
cal({ id: 'b', name: 'B', sortOrder: 2 }),
cal({ id: 'x:c', originalId: 'c', name: 'C', sortOrder: 0, isShared: true, accountId: 'x' }),
],
});
mockUpdateCalendar.mockResolvedValue(undefined);
const done = useCalendarStore.getState().reorderCalendars(['b', 'a']);
expect(names()).toEqual(['B', 'A', 'C']);
await done;
expect(mockUpdateCalendar.mock.calls).toEqual([
['b', { sortOrder: 1 }, undefined],
['a', { sortOrder: 2 }, undefined],
]);
});
it('after a failed write, shows the order the server holds and rethrows', async () => {
useCalendarStore.setState({
calendars: [
cal({ id: 'a', name: 'A', sortOrder: 1 }),
cal({ id: 'b', name: 'B', sortOrder: 2 }),
cal({ id: 'c', name: 'C', sortOrder: 3 }),
],
});
mockUpdateCalendar.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('forbidden'));
await expect(useCalendarStore.getState().reorderCalendars(['c', 'a', 'b'])).rejects.toThrow('forbidden');
expect(useCalendarStore.getState().calendars.map((c) => [c.id, c.sortOrder])).toEqual([
['a', 1], ['c', 1], ['b', 2],
]);
});
});
+31 -3
View File
@@ -48,6 +48,7 @@ import {
import { findTasksOnlyCalendarIds, isTaskLikeObject } from '../lib/calendar-component-detection';
import { mergeRangeEvents, mergedSpanLimit, planRangeLoad, sameRange } from '../lib/calendar-range-cache';
import { requestDeviceSync } from '../device-sync/app/request';
import { sortCalendars, sortOrderUpdates } from '../lib/calendar-sort';
import { CALENDAR_AUTHORITY } from '../device-sync/types';
// extendRange merges up to twice the requested range (at least half a year,
@@ -266,6 +267,8 @@ export interface CalendarState {
importEvents: (events: Partial<CalendarEvent>[], calendarId: string) => Promise<ImportResult>;
createCalendar: (name: string, color?: string, description?: string) => Promise<Calendar>;
updateCalendar: (id: string, updates: CalendarUpdates) => Promise<void>;
// Store `orderedIds` in that order by renumbering their sortOrder.
reorderCalendars: (orderedIds: string[]) => Promise<void>;
removeCalendar: (id: string) => Promise<void>;
clearCalendarEvents: (id: string) => Promise<number>;
shareCalendar: (id: string, principalId: string, rights: CalendarRights | null) => Promise<void>;
@@ -572,7 +575,7 @@ export const useCalendarStore = create<CalendarState>()(
if (calendarsInFlight) return calendarsInFlight;
calendarsInFlight = (async () => {
try {
const calendars = (await fetchCalendars()) ?? [];
const calendars = sortCalendars((await fetchCalendars()) ?? []);
set({ calendars });
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Failed to load calendars' });
@@ -1013,7 +1016,7 @@ export const useCalendarStore = create<CalendarState>()(
createCalendar: async (name, color, description) => {
const created = await apiCreateCalendar(name, color, description);
set({ calendars: [...get().calendars, created] });
set({ calendars: sortCalendars([...get().calendars, created]) });
requestDeviceSync(CALENDAR_AUTHORITY);
return created;
},
@@ -1022,11 +1025,36 @@ export const useCalendarStore = create<CalendarState>()(
const cal = get().calendars.find((c) => c.id === id);
await apiUpdateCalendar(cal?.originalId || id, updates, cal?.accountId);
set({
calendars: get().calendars.map((c) => (c.id === id ? { ...c, ...updates } as Calendar : c)),
calendars: sortCalendars(get().calendars.map((c) => (c.id === id ? { ...c, ...updates } as Calendar : c))),
});
requestDeviceSync(CALENDAR_AUTHORITY);
},
reorderCalendars: async (orderedIds) => {
const updates = sortOrderUpdates(get().calendars, orderedIds);
if (updates.length === 0) return;
const before = get().calendars;
const withSortOrders = (sortOrders: Map<string, number>) => sortCalendars(before.map((c) =>
sortOrders.has(c.id) ? { ...c, sortOrder: sortOrders.get(c.id)! } : c));
// Optimistic, so the list moves at once.
set({ calendars: withSortOrders(new Map(updates.map((u) => [u.id, u.sortOrder]))) });
const saved = new Map<string, number>();
try {
for (const { id, sortOrder } of updates) {
const cal = before.find((c) => c.id === id);
await apiUpdateCalendar(cal?.originalId || id, { sortOrder }, cal?.accountId);
saved.set(id, sortOrder);
}
} catch (err) {
// Show the order the server now holds: the moves saved before the
// failure stay, the rest are undone.
set({ calendars: withSortOrders(saved) });
throw err;
} finally {
if (saved.size > 0) requestDeviceSync(CALENDAR_AUTHORITY);
}
},
removeCalendar: async (id) => {
const cal = get().calendars.find((c) => c.id === id);
await apiDeleteCalendar(cal?.originalId || id, cal?.accountId);