From d22fb8d5d0d2c809b023eb16b93f87b1142d1d5d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:39:57 +0200 Subject: [PATCH] feat: open the files inside ZIP archives and attached messages --- locales/rn/ar.json | 6 +- locales/rn/ca.json | 6 +- locales/rn/cs.json | 6 +- locales/rn/da.json | 6 +- locales/rn/de.json | 6 +- locales/rn/en.json | 6 +- locales/rn/es.json | 6 +- locales/rn/fa.json | 6 +- locales/rn/fr.json | 6 +- locales/rn/he.json | 6 +- locales/rn/hu.json | 6 +- locales/rn/it.json | 6 +- locales/rn/ja.json | 6 +- locales/rn/ko.json | 6 +- locales/rn/lv.json | 6 +- locales/rn/mn.json | 6 +- locales/rn/nb.json | 6 +- locales/rn/nl.json | 6 +- locales/rn/pl.json | 6 +- locales/rn/pt.json | 6 +- locales/rn/ro.json | 6 +- locales/rn/ru.json | 6 +- locales/rn/sk.json | 6 +- locales/rn/tr.json | 6 +- locales/rn/uk.json | 6 +- locales/rn/zh-TW.json | 6 +- locales/rn/zh.json | 6 +- .../email/AttachmentPreviewModal.tsx | 85 +++++++++- src/components/email/PreviewPartList.tsx | 152 ++++++++++++++++++ src/components/email/use-body-override.ts | 6 +- src/components/email/use-file-preview.ts | 14 ++ src/lib/__tests__/file-preview.test.ts | 12 +- src/lib/__tests__/zip-preview.test.ts | 72 +++++++++ src/lib/file-preview.ts | 11 +- src/lib/zip-preview.ts | 90 +++++++++++ 35 files changed, 562 insertions(+), 42 deletions(-) create mode 100644 src/components/email/PreviewPartList.tsx create mode 100644 src/lib/__tests__/zip-preview.test.ts create mode 100644 src/lib/zip-preview.ts diff --git a/locales/rn/ar.json b/locales/rn/ar.json index 31da86be..4874b1c5 100644 --- a/locales/rn/ar.json +++ b/locales/rn/ar.json @@ -410,7 +410,11 @@ "unsupported": "لا توجد معاينة لهذا النوع من الملفات.", "media_unsupported": "لا يمكن تشغيل هذا الملف هنا.", "pdf_protected": "ملف PDF هذا محمي بكلمة مرور. افتحه في تطبيق آخر لإدخال كلمة المرور.", - "pdf_unreadable": "لا يمكن عرض ملف PDF هذا هنا." + "pdf_unreadable": "لا يمكن عرض ملف PDF هذا هنا.", + "archive_files": "{count, plural, zero {لا ملفات} one {ملف واحد} two {ملفان} few {# ملفات} many {# ملفًا} other {# ملف}}", + "archive_first_files": "أول {count} ملف", + "archive_protected": "هذا الأرشيف محمي بكلمة مرور. افتحه في تطبيق آخر لإدخال كلمة المرور.", + "archive_unreadable": "لا يمكن فتح هذا الأرشيف هنا." }, "received_via": "إلى {name}", "remove_color": "إزالة الوسم", diff --git a/locales/rn/ca.json b/locales/rn/ca.json index 0572c33e..c35057c6 100644 --- a/locales/rn/ca.json +++ b/locales/rn/ca.json @@ -410,7 +410,11 @@ "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í." + "pdf_unreadable": "Aquest PDF no es pot mostrar aquí.", + "archive_files": "{count, plural, one {# fitxer} other {# fitxers}}", + "archive_first_files": "Els primers {count} fitxers", + "archive_protected": "Aquest arxiu està protegit amb contrasenya. Obre'l en una altra aplicació per introduir-la.", + "archive_unreadable": "Aquest arxiu no es pot obrir aquí." }, "received_via": "per a {name}", "remove_color": "Elimina l'etiqueta", diff --git a/locales/rn/cs.json b/locales/rn/cs.json index 4bb3f3d0..fb3f8006 100644 --- a/locales/rn/cs.json +++ b/locales/rn/cs.json @@ -410,7 +410,11 @@ "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." + "pdf_unreadable": "Toto PDF zde nelze zobrazit.", + "archive_files": "{count, plural, one {# soubor} few {# soubory} many {# souboru} other {# souborů}}", + "archive_first_files": "Prvních {count} souborů", + "archive_protected": "Tento archiv je chráněný heslem. Otevřete ho v jiné aplikaci a zadejte heslo.", + "archive_unreadable": "Tento archiv zde nelze otevřít." }, "received_via": "pro {name}", "remove_color": "Odebrat štítek", diff --git a/locales/rn/da.json b/locales/rn/da.json index 078ebb72..6d5c1a79 100644 --- a/locales/rn/da.json +++ b/locales/rn/da.json @@ -410,7 +410,11 @@ "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." + "pdf_unreadable": "Denne PDF kan ikke vises her.", + "archive_files": "{count, plural, one {# fil} other {# filer}}", + "archive_first_files": "De første {count} filer", + "archive_protected": "Dette arkiv er beskyttet med adgangskode. Åbn det i en anden app for at indtaste adgangskoden.", + "archive_unreadable": "Dette arkiv kan ikke åbnes her." }, "received_via": "til {name}", "remove_color": "Fjern tag", diff --git a/locales/rn/de.json b/locales/rn/de.json index 84ca7657..df75b691 100644 --- a/locales/rn/de.json +++ b/locales/rn/de.json @@ -416,7 +416,11 @@ "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." + "pdf_unreadable": "Diese PDF kann hier nicht angezeigt werden.", + "archive_files": "{count, plural, one {# Datei} other {# Dateien}}", + "archive_first_files": "Die ersten {count} Dateien", + "archive_protected": "Dieses Archiv ist passwortgeschützt. Öffne es in einer anderen App, um das Passwort einzugeben.", + "archive_unreadable": "Dieses Archiv kann hier nicht geöffnet werden." }, "received_via": "an {name}", "remove_color": "Label entfernen", diff --git a/locales/rn/en.json b/locales/rn/en.json index 45cb669a..56c33f23 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -427,7 +427,11 @@ "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." + "pdf_unreadable": "This PDF can't be shown here.", + "archive_files": "{count, plural, one {# file} other {# files}}", + "archive_first_files": "The first {count} files", + "archive_protected": "This archive is password-protected. Open it in another app to enter the password.", + "archive_unreadable": "This archive can't be opened here." }, "received_via": "to {name}", "remove_color": "Remove tag", diff --git a/locales/rn/es.json b/locales/rn/es.json index 14e0df37..ba43ca8f 100644 --- a/locales/rn/es.json +++ b/locales/rn/es.json @@ -410,7 +410,11 @@ "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í." + "pdf_unreadable": "Este PDF no se puede mostrar aquí.", + "archive_files": "{count, plural, one {# archivo} other {# archivos}}", + "archive_first_files": "Los primeros {count} archivos", + "archive_protected": "Este archivo comprimido está protegido con contraseña. Ábrelo en otra aplicación para introducirla.", + "archive_unreadable": "Este archivo comprimido no se puede abrir aquí." }, "received_via": "para {name}", "remove_color": "Eliminar etiqueta", diff --git a/locales/rn/fa.json b/locales/rn/fa.json index 393c9e25..e69110c6 100644 --- a/locales/rn/fa.json +++ b/locales/rn/fa.json @@ -410,7 +410,11 @@ "unsupported": "برای این نوع فایل پیش‌نمایشی وجود ندارد.", "media_unsupported": "این فایل اینجا پخش نمی‌شود.", "pdf_protected": "این PDF با رمز عبور محافظت شده است. برای وارد کردن رمز، آن را در برنامهٔ دیگری باز کنید.", - "pdf_unreadable": "این PDF اینجا نمایش داده نمی‌شود." + "pdf_unreadable": "این PDF اینجا نمایش داده نمی‌شود.", + "archive_files": "{count, plural, one {# فایل} other {# فایل}}", + "archive_first_files": "{count} فایل نخست", + "archive_protected": "این بایگانی با رمز عبور محافظت شده است. برای وارد کردن رمز، آن را در برنامهٔ دیگری باز کنید.", + "archive_unreadable": "این بایگانی اینجا باز نمی‌شود." }, "received_via": "به {name}", "remove_color": "حذف برچسب", diff --git a/locales/rn/fr.json b/locales/rn/fr.json index 8675882f..286dde78 100644 --- a/locales/rn/fr.json +++ b/locales/rn/fr.json @@ -410,7 +410,11 @@ "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." + "pdf_unreadable": "Ce PDF ne peut pas être affiché ici.", + "archive_files": "{count, plural, one {# fichier} other {# fichiers}}", + "archive_first_files": "Les {count} premiers fichiers", + "archive_protected": "Cette archive est protégée par un mot de passe. Ouvrez-la dans une autre application pour le saisir.", + "archive_unreadable": "Cette archive ne peut pas être ouverte ici." }, "received_via": "à {name}", "remove_color": "Retirer l'étiquette", diff --git a/locales/rn/he.json b/locales/rn/he.json index b3331776..94985885 100644 --- a/locales/rn/he.json +++ b/locales/rn/he.json @@ -410,7 +410,11 @@ "unsupported": "אין תצוגה מקדימה לסוג קובץ זה.", "media_unsupported": "לא ניתן להפעיל את הקובץ הזה כאן.", "pdf_protected": "קובץ ה-PDF הזה מוגן בסיסמה. פתחו אותו באפליקציה אחרת כדי להזין את הסיסמה.", - "pdf_unreadable": "לא ניתן להציג את קובץ ה-PDF הזה כאן." + "pdf_unreadable": "לא ניתן להציג את קובץ ה-PDF הזה כאן.", + "archive_files": "{count, plural, one {קובץ אחד} two {שני קבצים} other {# קבצים}}", + "archive_first_files": "{count} הקבצים הראשונים", + "archive_protected": "הארכיון הזה מוגן בסיסמה. פתחו אותו באפליקציה אחרת כדי להזין את הסיסמה.", + "archive_unreadable": "לא ניתן לפתוח את הארכיון הזה כאן." }, "received_via": "אל {name}", "remove_color": "הסר תג", diff --git a/locales/rn/hu.json b/locales/rn/hu.json index 1f9b1370..51401294 100644 --- a/locales/rn/hu.json +++ b/locales/rn/hu.json @@ -410,7 +410,11 @@ "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." + "pdf_unreadable": "Ez a PDF itt nem jeleníthető meg.", + "archive_files": "{count, plural, one {# fájl} other {# fájl}}", + "archive_first_files": "Az első {count} fájl", + "archive_protected": "Ez az archívum jelszóval védett. Nyisd meg egy másik alkalmazásban a jelszó megadásához.", + "archive_unreadable": "Ez az archívum itt nem nyitható meg." }, "received_via": "címzett: {name}", "remove_color": "Címke eltávolítása", diff --git a/locales/rn/it.json b/locales/rn/it.json index 512a386b..d960c64b 100644 --- a/locales/rn/it.json +++ b/locales/rn/it.json @@ -410,7 +410,11 @@ "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." + "pdf_unreadable": "Questo PDF non può essere visualizzato qui.", + "archive_files": "{count, plural, one {# file} other {# file}}", + "archive_first_files": "I primi {count} file", + "archive_protected": "Questo archivio è protetto da password. Aprilo in un'altra app per inserirla.", + "archive_unreadable": "Questo archivio non può essere aperto qui." }, "received_via": "a {name}", "remove_color": "Rimuovi etichetta", diff --git a/locales/rn/ja.json b/locales/rn/ja.json index 96a00d49..1a48476e 100644 --- a/locales/rn/ja.json +++ b/locales/rn/ja.json @@ -410,7 +410,11 @@ "unsupported": "このファイル形式はプレビューできません。", "media_unsupported": "このファイルはここでは再生できません。", "pdf_protected": "このPDFはパスワードで保護されています。パスワードを入力するには別のアプリで開いてください。", - "pdf_unreadable": "このPDFはここでは表示できません。" + "pdf_unreadable": "このPDFはここでは表示できません。", + "archive_files": "{count, plural, other {# 個のファイル}}", + "archive_first_files": "最初の {count} 個のファイル", + "archive_protected": "このアーカイブはパスワードで保護されています。パスワードを入力するには別のアプリで開いてください。", + "archive_unreadable": "このアーカイブはここでは開けません。" }, "received_via": "{name}宛", "remove_color": "ラベルを削除", diff --git a/locales/rn/ko.json b/locales/rn/ko.json index d9afa773..b0ffa78e 100644 --- a/locales/rn/ko.json +++ b/locales/rn/ko.json @@ -410,7 +410,11 @@ "unsupported": "이 파일 형식은 미리보기를 지원하지 않아요.", "media_unsupported": "이 파일은 여기서 재생할 수 없습니다.", "pdf_protected": "이 PDF는 암호로 보호되어 있습니다. 암호를 입력하려면 다른 앱에서 여세요.", - "pdf_unreadable": "이 PDF는 여기서 표시할 수 없습니다." + "pdf_unreadable": "이 PDF는 여기서 표시할 수 없습니다.", + "archive_files": "{count, plural, other {파일 #개}}", + "archive_first_files": "처음 {count}개 파일", + "archive_protected": "이 압축 파일은 암호로 보호되어 있습니다. 암호를 입력하려면 다른 앱에서 여세요.", + "archive_unreadable": "이 압축 파일은 여기서 열 수 없습니다." }, "received_via": "받는 사람: {name}", "remove_color": "태그 제거", diff --git a/locales/rn/lv.json b/locales/rn/lv.json index 3d413883..27189a1b 100644 --- a/locales/rn/lv.json +++ b/locales/rn/lv.json @@ -410,7 +410,11 @@ "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." + "pdf_unreadable": "Šo PDF šeit nevar parādīt.", + "archive_files": "{count, plural, zero {# failu} one {# fails} other {# faili}}", + "archive_first_files": "Pirmie {count} faili", + "archive_protected": "Šis arhīvs ir aizsargāts ar paroli. Atveriet to citā lietotnē, lai ievadītu paroli.", + "archive_unreadable": "Šo arhīvu šeit nevar atvērt." }, "received_via": "kam: {name}", "remove_color": "Noņemt tagu", diff --git a/locales/rn/mn.json b/locales/rn/mn.json index 6d63704b..c808bb5a 100644 --- a/locales/rn/mn.json +++ b/locales/rn/mn.json @@ -410,7 +410,11 @@ "unsupported": "Энэ төрлийн файлыг урьдчилан харах боломжгүй.", "media_unsupported": "Энэ файлыг энд тоглуулах боломжгүй.", "pdf_protected": "Энэ PDF нууц үгээр хамгаалагдсан. Нууц үг оруулахын тулд өөр апп-аар нээнэ үү.", - "pdf_unreadable": "Энэ PDF-ийг энд харуулах боломжгүй." + "pdf_unreadable": "Энэ PDF-ийг энд харуулах боломжгүй.", + "archive_files": "{count, plural, one {# файл} other {# файл}}", + "archive_first_files": "Эхний {count} файл", + "archive_protected": "Энэ архив нууц үгээр хамгаалагдсан. Нууц үг оруулахын тулд өөр апп-аар нээнэ үү.", + "archive_unreadable": "Энэ архивыг энд нээх боломжгүй." }, "received_via": "Хэнд: {name}", "remove_color": "Тагийг хасах", diff --git a/locales/rn/nb.json b/locales/rn/nb.json index a6612ee1..169a55b6 100644 --- a/locales/rn/nb.json +++ b/locales/rn/nb.json @@ -410,7 +410,11 @@ "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." + "pdf_unreadable": "Denne PDF-en kan ikke vises her.", + "archive_files": "{count, plural, one {# fil} other {# filer}}", + "archive_first_files": "De første {count} filene", + "archive_protected": "Dette arkivet er passordbeskyttet. Åpne det i en annen app for å skrive inn passordet.", + "archive_unreadable": "Dette arkivet kan ikke åpnes her." }, "received_via": "til {name}", "remove_color": "Fjern etiketten", diff --git a/locales/rn/nl.json b/locales/rn/nl.json index e5006356..6b468c43 100644 --- a/locales/rn/nl.json +++ b/locales/rn/nl.json @@ -410,7 +410,11 @@ "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." + "pdf_unreadable": "Deze pdf kan hier niet worden weergegeven.", + "archive_files": "{count, plural, one {# bestand} other {# bestanden}}", + "archive_first_files": "De eerste {count} bestanden", + "archive_protected": "Dit archief is beveiligd met een wachtwoord. Open het in een andere app om het wachtwoord in te voeren.", + "archive_unreadable": "Dit archief kan hier niet worden geopend." }, "received_via": "aan {name}", "remove_color": "Label verwijderen", diff --git a/locales/rn/pl.json b/locales/rn/pl.json index 249f3294..b57829c4 100644 --- a/locales/rn/pl.json +++ b/locales/rn/pl.json @@ -410,7 +410,11 @@ "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ć." + "pdf_unreadable": "Tego pliku PDF nie można tutaj wyświetlić.", + "archive_files": "{count, plural, one {# plik} few {# pliki} many {# plików} other {# pliku}}", + "archive_first_files": "Pierwsze {count} plików", + "archive_protected": "To archiwum jest chronione hasłem. Otwórz je w innej aplikacji, aby wpisać hasło.", + "archive_unreadable": "Tego archiwum nie można tutaj otworzyć." }, "received_via": "do {name}", "remove_color": "Usuń etykietę", diff --git a/locales/rn/pt.json b/locales/rn/pt.json index 17be6f02..19acf556 100644 --- a/locales/rn/pt.json +++ b/locales/rn/pt.json @@ -410,7 +410,11 @@ "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." + "pdf_unreadable": "Este PDF não pode ser mostrado aqui.", + "archive_files": "{count, plural, one {# ficheiro} other {# ficheiros}}", + "archive_first_files": "Os primeiros {count} ficheiros", + "archive_protected": "Este arquivo está protegido por palavra-passe. Abra-o noutra aplicação para a introduzir.", + "archive_unreadable": "Este arquivo não pode ser aberto aqui." }, "received_via": "para {name}", "remove_color": "Remover etiqueta", diff --git a/locales/rn/ro.json b/locales/rn/ro.json index a86f5a0d..618e98d8 100644 --- a/locales/rn/ro.json +++ b/locales/rn/ro.json @@ -410,7 +410,11 @@ "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." + "pdf_unreadable": "Acest PDF nu poate fi afișat aici.", + "archive_files": "{count, plural, one {# fișier} few {# fișiere} other {# de fișiere}}", + "archive_first_files": "Primele {count} fișiere", + "archive_protected": "Această arhivă este protejată cu parolă. Deschide-o în altă aplicație pentru a introduce parola.", + "archive_unreadable": "Această arhivă nu poate fi deschisă aici." }, "received_via": "către {name}", "remove_color": "Eliminați eticheta", diff --git a/locales/rn/ru.json b/locales/rn/ru.json index f12ec606..1bbd16d5 100644 --- a/locales/rn/ru.json +++ b/locales/rn/ru.json @@ -410,7 +410,11 @@ "unsupported": "Для этого типа файлов нет предпросмотра.", "media_unsupported": "Этот файл нельзя воспроизвести здесь.", "pdf_protected": "Этот PDF защищён паролем. Откройте его в другом приложении, чтобы ввести пароль.", - "pdf_unreadable": "Этот PDF нельзя показать здесь." + "pdf_unreadable": "Этот PDF нельзя показать здесь.", + "archive_files": "{count, plural, one {# файл} few {# файла} many {# файлов} other {# файла}}", + "archive_first_files": "Первые {count} файлов", + "archive_protected": "Этот архив защищён паролем. Откройте его в другом приложении, чтобы ввести пароль.", + "archive_unreadable": "Этот архив нельзя открыть здесь." }, "received_via": "на {name}", "remove_color": "Убрать тег", diff --git a/locales/rn/sk.json b/locales/rn/sk.json index 26494ae8..60df6ac0 100644 --- a/locales/rn/sk.json +++ b/locales/rn/sk.json @@ -410,7 +410,11 @@ "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ť." + "pdf_unreadable": "Toto PDF tu nie je možné zobraziť.", + "archive_files": "{count, plural, one {# súbor} few {# súbory} many {# súboru} other {# súborov}}", + "archive_first_files": "Prvých {count} súborov", + "archive_protected": "Tento archív je chránený heslom. Otvorte ho v inej aplikácii a zadajte heslo.", + "archive_unreadable": "Tento archív tu nie je možné otvoriť." }, "received_via": "pre {name}", "remove_color": "Odstrániť štítok", diff --git a/locales/rn/tr.json b/locales/rn/tr.json index be06fdab..deae15a7 100644 --- a/locales/rn/tr.json +++ b/locales/rn/tr.json @@ -410,7 +410,11 @@ "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." + "pdf_unreadable": "Bu PDF burada gösterilemiyor.", + "archive_files": "{count, plural, one {# dosya} other {# dosya}}", + "archive_first_files": "İlk {count} dosya", + "archive_protected": "Bu arşiv parola korumalı. Parolayı girmek için başka bir uygulamada açın.", + "archive_unreadable": "Bu arşiv burada açılamıyor." }, "received_via": "alıcı: {name}", "remove_color": "Etiketi kaldır", diff --git a/locales/rn/uk.json b/locales/rn/uk.json index 1be0e304..e3150d88 100644 --- a/locales/rn/uk.json +++ b/locales/rn/uk.json @@ -410,7 +410,11 @@ "unsupported": "Для цього типу файлів немає попереднього перегляду.", "media_unsupported": "Цей файл не можна відтворити тут.", "pdf_protected": "Цей PDF захищено паролем. Відкрийте його в іншому застосунку, щоб ввести пароль.", - "pdf_unreadable": "Цей PDF не можна показати тут." + "pdf_unreadable": "Цей PDF не можна показати тут.", + "archive_files": "{count, plural, one {# файл} few {# файли} many {# файлів} other {# файлу}}", + "archive_first_files": "Перші {count} файлів", + "archive_protected": "Цей архів захищено паролем. Відкрийте його в іншому застосунку, щоб ввести пароль.", + "archive_unreadable": "Цей архів не можна відкрити тут." }, "received_via": "на {name}", "remove_color": "Прибрати тег", diff --git a/locales/rn/zh-TW.json b/locales/rn/zh-TW.json index 14e41e90..dea23a13 100644 --- a/locales/rn/zh-TW.json +++ b/locales/rn/zh-TW.json @@ -410,7 +410,11 @@ "unsupported": "無法預覽此檔案類型。", "media_unsupported": "無法在此播放這個檔案。", "pdf_protected": "此 PDF 受密碼保護。請在其他應用程式中開啟以輸入密碼。", - "pdf_unreadable": "無法在此顯示這個 PDF。" + "pdf_unreadable": "無法在此顯示這個 PDF。", + "archive_files": "{count, plural, other {# 個檔案}}", + "archive_first_files": "前 {count} 個檔案", + "archive_protected": "此壓縮檔受密碼保護。請在其他應用程式中開啟以輸入密碼。", + "archive_unreadable": "無法在此開啟這個壓縮檔。" }, "received_via": "寄給 {name}", "remove_color": "移除標籤", diff --git a/locales/rn/zh.json b/locales/rn/zh.json index d0ab9384..71758718 100644 --- a/locales/rn/zh.json +++ b/locales/rn/zh.json @@ -410,7 +410,11 @@ "unsupported": "无法预览此类文件。", "media_unsupported": "无法在此播放该文件。", "pdf_protected": "此 PDF 受密码保护。请在其他应用中打开以输入密码。", - "pdf_unreadable": "无法在此显示该 PDF。" + "pdf_unreadable": "无法在此显示该 PDF。", + "archive_files": "{count, plural, other {# 个文件}}", + "archive_first_files": "前 {count} 个文件", + "archive_protected": "此压缩包受密码保护。请在其他应用中打开以输入密码。", + "archive_unreadable": "无法在此打开该压缩包。" }, "received_via": "发给 {name}", "remove_color": "删除标签", diff --git a/src/components/email/AttachmentPreviewModal.tsx b/src/components/email/AttachmentPreviewModal.tsx index da81ec10..8948d40c 100644 --- a/src/components/email/AttachmentPreviewModal.tsx +++ b/src/components/email/AttachmentPreviewModal.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { - View, Text, StyleSheet, Modal, Pressable, ScrollView, Image, ActivityIndicator, Platform, PixelRatio, + View, Text, StyleSheet, Modal, Pressable, ScrollView, Image, ActivityIndicator, Platform, PixelRatio, Alert, useWindowDimensions, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -10,10 +10,17 @@ import type { Email } from '../../api/types'; import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { useLocaleStore } from '../../stores/locale-store'; +import { useSettingsStore } from '../../stores/settings-store'; import EmailBodyView from '../EmailBodyView'; -import { needsNativeImageDecoder, needsWebImageViewer, type PreviewKind } from '../../lib/file-preview'; +import { + handoffMimeType, needsNativeImageDecoder, needsWebImageViewer, type PreviewKind, +} from '../../lib/file-preview'; import { pdfPageFileName, renderPdfPage } from '../../lib/pdf-pages'; +import type { ZipArchive } from '../../lib/zip-preview'; +import { formatFullDateTime } from '../../lib/email-date'; import type { EmlPreview } from './use-body-override'; +import { useFilePreview } from './use-file-preview'; +import { ArchiveSummary, PreviewPartList, type PreviewPart } from './PreviewPartList'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; export interface PreviewItem { @@ -30,6 +37,8 @@ export interface PreviewItem { html?: string; /** Parsed embedded message for .eml previews. */ eml?: EmlPreview; + /** A ZIP's directory, for archive previews. */ + archive?: ZipArchive; /** Replaces "No preview for this file type." on the fallback screen (e.g. the file is too large). */ notice?: string; } @@ -131,9 +140,11 @@ function PdfPagesView({ fileUri, viewerUri, background, onFailed }: { /** * 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. + * and audio (the WebView's player), text, Markdown and HTML documents, + * embedded messages and ZIP archives. The files inside an archive or a + * message open in a preview of their own on top. Files that don't decode, + * and types without a preview, offer another app instead. Used by every + * place that opens a file. */ export function AttachmentPreviewModal({ item, loading, title, onClose, onOpenExternal, onShare, onDownload, docked = false, @@ -141,7 +152,45 @@ export function AttachmentPreviewModal({ const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); + const locale = useLocaleStore((s) => s.locale); + const timeFormat = useSettingsStore((s) => s.timeFormat); const { width } = useWindowDimensions(); + // Files inside this one (archive entries, an .eml's attachments). + const nested = useFilePreview(); + const { open: openNested } = nested; + const openPart = React.useCallback(async (part: PreviewPart) => { + try { + const bytes = await part.read(); + await openNested({ + from: 'bytes', + bytes, + name: part.name, + type: part.type || handoffMimeType(part.name, ''), + size: bytes.byteLength, + }); + } catch (e) { + Alert.alert(t('email_viewer.attachment_failed', 'Could not open attachment'), e instanceof Error ? e.message : String(e)); + } + }, [openNested, t]); + const archive = item?.kind === 'archive' ? item.archive : undefined; + const archiveParts = React.useMemo(() => { + if (!archive) return []; + return archive.entries.map((entry) => ({ + key: entry.path, + name: entry.name, + folder: entry.folder, + size: entry.size, + read: () => archive.read(entry.path), + })); + }, [archive]); + const emlAttachments = item?.kind === 'eml' ? item.eml?.attachments : undefined; + const emlParts = React.useMemo(() => (emlAttachments ?? []).map((att, i) => ({ + key: `${i}-${att.name}`, + name: att.name, + type: att.type, + size: att.size, + read: async () => att.bytes, + })), [emlAttachments]); // Which viewers failed for the open file, keyed by its URI so the next one // starts clean: React Native's Image falls back to the WebView viewer, and // a WebView that can't decode the file falls back to another app. @@ -196,10 +245,11 @@ export function AttachmentPreviewModal({ ); - const renderDocument = (header?: React.ReactNode) => ( + const renderDocument = (header?: React.ReactNode, footer?: React.ReactNode) => ( {header} {docEmail && } + {footer} ); @@ -303,12 +353,30 @@ export function AttachmentPreviewModal({ {item.eml?.subject || t('email_viewer.no_subject', '(No Subject)')} {item.eml?.from ? {item.eml.from} : null} - {item.eml?.date ? {item.eml.date} : null} + {item.eml?.date ? {formatFullDateTime(item.eml.date, timeFormat, locale) || item.eml.date} : null} , + emlParts.length > 0 ? ( + } + /> + ) : null, + ); + case 'archive': + if (!item.archive) break; + return ( + } + /> ); default: - return renderFallback(item.notice ?? t('email_viewer.preview.unsupported', 'No preview for this file type.')); + break; } + return renderFallback(item.notice ?? t('email_viewer.preview.unsupported', 'No preview for this file type.')); }; const content = ( @@ -359,6 +427,7 @@ export function AttachmentPreviewModal({ {renderBody()} + {nested.modalProps.item || nested.modalProps.loading ? : null} ); if (docked) return item || loading ? content : null; diff --git a/src/components/email/PreviewPartList.tsx b/src/components/email/PreviewPartList.tsx new file mode 100644 index 00000000..72e2eea6 --- /dev/null +++ b/src/components/email/PreviewPartList.tsx @@ -0,0 +1,152 @@ +import React from 'react'; +import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from 'react-native'; +import { + File as FileIcon, FileArchive, FileAudio, FileImage, FileSpreadsheet, FileText, FileVideo, +} from '../icons'; +import { formatSize } from '../../lib/attachment-display'; +import { filePreviewKind, handoffMimeType } from '../../lib/file-preview'; +import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; +import { useColors } from '../../theme/colors'; +import { useLocaleStore } from '../../stores/locale-store'; + +/** A file inside a file: an archive entry or an attachment of an .eml. */ +export interface PreviewPart { + key: string; + name: string; + /** Folder inside an archive, '' at the top. */ + folder?: string; + type?: string; + size?: number; + read: () => Promise; +} + +function iconFor(part: PreviewPart) { + const type = part.type || handoffMimeType(part.name, ''); + if (/\.(xlsx?|ods|csv|tsv|numbers)$/i.test(part.name)) return FileSpreadsheet; + if (/\.(zip|7z|rar|gz|tgz|tar|bz2|xz)$/i.test(part.name)) return FileArchive; + // Platform-independent: the icon names the format, not what plays here. + switch (filePreviewKind({ name: part.name, type }, { platform: 'android' })) { + case 'image': return FileImage; + case 'video': return FileVideo; + case 'audio': return FileAudio; + case 'none': return /^(application\/(msword|vnd\.|rtf)|text\/)/.test(type) ? FileText : FileIcon; + default: return FileText; + } +} + +interface Props { + parts: PreviewPart[]; + /** Opens a part; the row shows a spinner until the promise settles. */ + onOpen: (part: PreviewPart) => Promise; + /** Above the rows (an .eml's body, an archive's summary). */ + header?: React.ReactElement; + /** Renders as a plain column inside a parent ScrollView instead of a list of its own. */ + inline?: boolean; +} + +/** + * The files inside an archive or an embedded message, each opening in the + * file preview like any attachment. + */ +export function PreviewPartList({ parts, onOpen, header, inline = false }: Props) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const [busy, setBusy] = React.useState(null); + + const open = (part: PreviewPart) => { + if (busy) return; + setBusy(part.key); + onOpen(part).finally(() => setBusy(null)); + }; + + const renderRow = (part: PreviewPart) => { + const Icon = iconFor(part); + return ( + [styles.row, pressed && styles.rowPressed]} + onPress={() => open(part)} + disabled={!!busy} + accessibilityRole="button" + accessibilityLabel={part.name} + > + + {busy === part.key ? : } + + + {part.name} + {part.folder ? {part.folder} : null} + + {part.size != null ? {formatSize(part.size) || '0 B'} : null} + + ); + }; + + if (inline) { + return ( + + {header} + {parts.map(renderRow)} + + ); + } + return ( + p.key} + renderItem={({ item }) => renderRow(item)} + ListHeaderComponent={header} + initialNumToRender={30} + /> + ); +} + +/** The summary line above an archive's entries. */ +export function ArchiveSummary({ count, truncated }: { count: number; truncated: boolean }) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + return ( + + {truncated + ? t('email_viewer.preview.archive_first_files', 'The first {count} files', { count }) + : t('email_viewer.preview.archive_files', '{count, plural, one {# file} other {# files}}', { count })} + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + list: { flex: 1 }, + inline: { paddingBottom: spacing.lg }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + minHeight: 52, + }, + rowPressed: { backgroundColor: c.surfaceHover }, + icon: { + width: 36, + height: 36, + borderRadius: radius.md, + backgroundColor: c.surface, + alignItems: 'center', + justifyContent: 'center', + }, + text: { flex: 1, minWidth: 0 }, + name: { ...typography.body, color: c.text }, + folder: { ...typography.caption, color: c.textMuted }, + size: { ...typography.caption, color: c.textMuted }, + summary: { + ...typography.caption, + color: c.textMuted, + paddingHorizontal: spacing.lg, + paddingTop: spacing.md, + paddingBottom: spacing.xs, + }, + }); +} diff --git a/src/components/email/use-body-override.ts b/src/components/email/use-body-override.ts index 47bde2ae..bd6387c1 100644 --- a/src/components/email/use-body-override.ts +++ b/src/components/email/use-body-override.ts @@ -60,16 +60,18 @@ export interface EmlPreview { date?: string; html: string | null; text: string | null; + /** The message's own attachments (not the images its body embeds). */ + attachments: ExtractedAttachment[]; } /** Parse a message once for the preview: its unwrapped body plus the header lines shown above it. */ export async function emlPreviewFromBytes(bytes: Uint8Array): Promise { const parsed = await parseMessage(bytes); - const { html, text } = unwrapParsed(parsed); + const { html, text, attachments } = unwrapParsed(parsed); const from = parsed.from && 'address' in parsed.from ? `${parsed.from.name ? `${parsed.from.name} ` : ''}<${parsed.from.address}>` : parsed.from?.name; - return { subject: parsed.subject, from, date: parsed.date, html, text }; + return { subject: parsed.subject, from, date: parsed.date, html, text, attachments }; } function unwrapParsed(parsed: ParsedEmail): UnwrappedMessage { diff --git a/src/components/email/use-file-preview.ts b/src/components/email/use-file-preview.ts index 6cc83a8c..0a9d9c2a 100644 --- a/src/components/email/use-file-preview.ts +++ b/src/components/email/use-file-preview.ts @@ -13,6 +13,7 @@ import type { PreviewKind } from '../../lib/file-preview'; import { markdownToHtml } from '../../lib/markdown-html'; import { imageViewerHtml, mediaViewerHtml, pdfViewerHtml, type ViewerColors } from '../../lib/preview-viewer-html'; import { closePdfPages, openPdfPages, PdfOpenError } from '../../lib/pdf-pages'; +import { openZip, ZipEncryptedError } from '../../lib/zip-preview'; import { useLocaleStore } from '../../stores/locale-store'; import { useColors, useResolvedTheme } from '../../theme/colors'; import type { PreviewItem } from './AttachmentPreviewModal'; @@ -79,6 +80,8 @@ interface Notices { tooLarge: string; pdfProtected: string; pdfUnreadable: string; + zipProtected: string; + zipUnreadable: string; } async function buildItem( @@ -100,6 +103,12 @@ async function buildItem( return { ...base, kind, html: await local.text() }; case 'eml': return { ...base, kind, eml: await emlPreviewFromBytes(await local.bytes()) }; + case 'archive': + try { + return { ...base, kind, archive: await openZip(await local.bytes()) }; + } catch (e) { + return { ...base, kind: 'none', notice: e instanceof ZipEncryptedError ? notices.zipProtected : notices.zipUnreadable }; + } case 'image': return { ...base, kind, fileUri: local.uri, viewerUri: writeViewer(local, imageViewerHtml(local.name, colors)) }; case 'video': @@ -208,6 +217,11 @@ export function useFilePreview(opts: { 'This PDF is password-protected. Open it in another app to enter the password.', ), pdfUnreadable: t('email_viewer.preview.pdf_unreadable', "This PDF can't be shown here."), + zipProtected: t( + 'email_viewer.preview.archive_protected', + 'This archive is password-protected. Open it in another app to enter the password.', + ), + zipUnreadable: t('email_viewer.preview.archive_unreadable', "This archive can't be opened here."), }, colors.current); if (current()) setItem(next); } catch (e) { diff --git a/src/lib/__tests__/file-preview.test.ts b/src/lib/__tests__/file-preview.test.ts index f4c6823a..4daa2e14 100644 --- a/src/lib/__tests__/file-preview.test.ts +++ b/src/lib/__tests__/file-preview.test.ts @@ -93,8 +93,16 @@ describe('filePreviewKind', () => { expect(filePreviewKind({ name: 'cover.mp3', type: 'image/jpeg' }, ANDROID)).toBe('image'); }); - it('leaves archives, office documents and binaries to other apps', () => { - expect(filePreviewKind({ name: 'archive.zip', type: 'application/zip' }, IOS)).toBe('none'); + it('lists the files in a ZIP, but not in the formats built on ZIP', () => { + expect(filePreviewKind({ name: 'archive.zip', type: 'application/zip' }, IOS)).toBe('archive'); + expect(filePreviewKind({ name: 'photos', type: 'application/x-zip-compressed' }, ANDROID)).toBe('archive'); + expect(filePreviewKind({ name: 'Archive.ZIP', type: 'application/octet-stream' }, ANDROID)).toBe('archive'); + expect(filePreviewKind({ name: 'app.apk', type: 'application/vnd.android.package-archive' }, ANDROID)).toBe('none'); + }); + + it('leaves other archives, office documents and binaries to other apps', () => { + expect(filePreviewKind({ name: 'backup.7z', type: 'application/x-7z-compressed' }, IOS)).toBe('none'); + expect(filePreviewKind({ name: 'logs.tar.gz', type: 'application/gzip' }, ANDROID)).toBe('none'); expect(filePreviewKind({ name: 'sheet.xlsx', type: 'application/octet-stream' }, IOS)).toBe('none'); expect(filePreviewKind({ name: 'contract.docx', type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }, ANDROID)).toBe('none'); expect(filePreviewKind({ name: 'data.bin', type: undefined }, IOS)).toBe('none'); diff --git a/src/lib/__tests__/zip-preview.test.ts b/src/lib/__tests__/zip-preview.test.ts new file mode 100644 index 00000000..c560c792 --- /dev/null +++ b/src/lib/__tests__/zip-preview.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; +import JSZip from 'jszip'; +import { openZip, isZipArchive, ZipEncryptedError, MAX_ZIP_ENTRIES } from '../zip-preview'; + +async function makeZip(files: Record): Promise { + const zip = new JSZip(); + for (const [path, text] of Object.entries(files)) zip.file(path, text); + return zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' }); +} + +// Set the "encrypted" bit in every local and central header, as a +// password-protected archive has it. +function markEncrypted(bytes: Uint8Array): Uint8Array { + const out = bytes.slice(); + const view = new DataView(out.buffer); + for (let i = 0; i + 4 <= out.length; i++) { + const sig = view.getUint32(i, true); + if (sig === 0x04034b50) view.setUint16(i + 6, view.getUint16(i + 6, true) | 1, true); + if (sig === 0x02014b50) view.setUint16(i + 8, view.getUint16(i + 8, true) | 1, true); + } + return out; +} + +describe('openZip', () => { + it('lists the files by folder and name, without folders or OS metadata', async () => { + const archive = await openZip(await makeZip({ + 'b.txt': 'b', + 'docs/z.md': '# z', + 'a.txt': 'a', + 'docs/inner/c.csv': '1,2', + '__MACOSX/docs/._z.md': 'junk', + '.DS_Store': 'junk', + 'docs/Thumbs.db': 'junk', + })); + expect(archive.entries.map((e) => e.path)).toEqual(['a.txt', 'b.txt', 'docs/z.md', 'docs/inner/c.csv']); + expect(archive.entries[2]).toMatchObject({ name: 'z.md', folder: 'docs', size: 3 }); + expect(archive.truncated).toBe(false); + }); + + it('reads an entry from the loaded archive', async () => { + const archive = await openZip(await makeZip({ 'docs/notes.txt': 'Grüße' })); + expect(new TextDecoder().decode(await archive.read('docs/notes.txt'))).toBe('Grüße'); + await expect(archive.read('missing.txt')).rejects.toThrow(/not in the archive/); + }); + + it('stops listing at the cap', async () => { + const files: Record = {}; + for (let i = 0; i < MAX_ZIP_ENTRIES + 5; i++) files[`f${i}.txt`] = ''; + const archive = await openZip(await makeZip(files)); + expect(archive.entries).toHaveLength(MAX_ZIP_ENTRIES); + expect(archive.truncated).toBe(true); + }); + + it('says when an archive is password-protected', async () => { + const locked = markEncrypted(await makeZip({ 'secret.txt': 'x' })); + await expect(openZip(locked)).rejects.toBeInstanceOf(ZipEncryptedError); + }); + + it('rejects data that is not a ZIP', async () => { + await expect(openZip(new TextEncoder().encode('not a zip'))).rejects.not.toBeInstanceOf(ZipEncryptedError); + }); +}); + +describe('isZipArchive', () => { + it('takes ZIPs by type or name, not the formats built on ZIP', () => { + expect(isZipArchive('a.zip', 'application/zip')).toBe(true); + expect(isZipArchive('bundle', 'application/x-zip-compressed')).toBe(true); + expect(isZipArchive('Archive.ZIP', 'application/octet-stream')).toBe(true); + expect(isZipArchive('contract.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')).toBe(false); + expect(isZipArchive('app.apk', 'application/vnd.android.package-archive')).toBe(false); + }); +}); diff --git a/src/lib/file-preview.ts b/src/lib/file-preview.ts index 8b707074..6e3fcb2e 100644 --- a/src/lib/file-preview.ts +++ b/src/lib/file-preview.ts @@ -5,7 +5,7 @@ // everything else goes to the OS "Open with" chooser. export type PreviewKind = - | 'image' | 'pdf' | 'text' | 'markdown' | 'html' | 'eml' | 'video' | 'audio' | 'none'; + | 'image' | 'pdf' | 'text' | 'markdown' | 'html' | 'eml' | 'video' | 'audio' | 'archive' | 'none'; export interface FilePreviewOptions { /** @@ -26,14 +26,16 @@ interface PreviewableFile { * Largest file each preview kind loads. Text is rendered in one selectable * ``, which gets slow to lay out past a few hundred KB; HTML and * Markdown go through the mail sanitiser in JS; parsing an .eml and decoding - * an image hold the whole file in memory. PDFs are paged by their viewer and - * media stream from the downloaded copy. Larger files open in another app. + * an image hold the whole file in memory, and so does listing a ZIP. PDFs + * are paged by their viewer and media stream from the downloaded copy. + * Larger files open in another app. */ export const FILE_PREVIEW_MAX_BYTES: Record, number> = { text: 512 * 1024, markdown: 1024 * 1024, html: 5 * 1024 * 1024, eml: 10 * 1024 * 1024, + archive: 50 * 1024 * 1024, image: 25 * 1024 * 1024, pdf: 100 * 1024 * 1024, audio: 500 * 1024 * 1024, @@ -94,6 +96,7 @@ const MEDIA: Record<'video' | 'audio', Record, op const media = /^(image|audio|video)\//.test(mime); if (mime === 'message/rfc822' || ext === 'eml') return 'eml'; + // ZIPs list their files; formats built on ZIP (docx, apk) are not ZIPs here. + if (ZIP_MIME.has(mime) || ((!mime || mime.startsWith('application/x-')) && ext === 'zip')) return 'archive'; if (HTML_MIME.has(mime) || HTML_EXT.has(ext)) return 'html'; if (MARKDOWN_MIME.has(mime) || MARKDOWN_EXT.has(ext)) return 'markdown'; diff --git a/src/lib/zip-preview.ts b/src/lib/zip-preview.ts new file mode 100644 index 00000000..848c0544 --- /dev/null +++ b/src/lib/zip-preview.ts @@ -0,0 +1,90 @@ +// ZIP archives in the file preview: the entries as a list, and one entry's +// bytes when it is opened. Nothing is extracted to disk under the archive's +// paths; an opened entry goes through the same preview as any attachment. + +import type JSZip from 'jszip'; + +export interface ZipEntry { + /** Full path inside the archive, as stored. */ + path: string; + /** Last path segment. */ + name: string; + /** Folder path, '' at the top. */ + folder: string; + /** Uncompressed size, when the archive records it. */ + size?: number; + modified?: Date; +} + +export interface ZipArchive { + entries: ZipEntry[]; + /** True when the listing stopped at {@link MAX_ZIP_ENTRIES}. */ + truncated: boolean; + /** One entry's bytes. */ + read(path: string): Promise; +} + +/** The archive is password-protected; JSZip can't decrypt it. */ +export class ZipEncryptedError extends Error {} + +// Metadata the archives of macOS and Windows carry and nobody wants to open. +const JUNK = /(^|\/)(__MACOSX\/|\.DS_Store$|Thumbs\.db$|desktop\.ini$)/i; + +/** Most entries listed; a larger archive lists the first ones. */ +export const MAX_ZIP_ENTRIES = 2000; + +// JSZip keeps the directory's sizes here without exposing them. +interface ZipObjectInternals { + _data?: { uncompressedSize?: number }; +} + +/** + * Read an archive's directory: its files (not folders), sorted by folder and + * then name, and a reader for their contents. + */ +export async function openZip(bytes: Uint8Array): Promise { + const { default: Zip } = await import('jszip'); + let zip: JSZip; + try { + zip = await Zip.loadAsync(bytes, { createFolders: false }); + } catch (e) { + if (/encrypt/i.test(e instanceof Error ? e.message : String(e))) throw new ZipEncryptedError('encrypted'); + throw e; + } + const entries: ZipEntry[] = []; + let truncated = false; + for (const file of Object.values(zip.files)) { + if (file.dir || JUNK.test(file.name)) continue; + if (entries.length >= MAX_ZIP_ENTRIES) { + truncated = true; + break; + } + const path = file.name; + const slash = path.lastIndexOf('/'); + const size = (file as unknown as ZipObjectInternals)._data?.uncompressedSize; + entries.push({ + path, + name: slash >= 0 ? path.slice(slash + 1) : path, + folder: slash >= 0 ? path.slice(0, slash) : '', + size: typeof size === 'number' && size >= 0 ? size : undefined, + modified: file.date instanceof Date && !Number.isNaN(file.date.getTime()) ? file.date : undefined, + }); + } + entries.sort((a, b) => a.folder.localeCompare(b.folder) || a.name.localeCompare(b.name)); + return { + entries, + truncated, + read: async (path) => { + const file = zip.file(path); + if (!file) throw new Error(`${path} is not in the archive`); + return file.async('uint8array'); + }, + }; +} + +/** Whether a file is a ZIP the preview lists (not one of the formats built on ZIP). */ +export function isZipArchive(name: string, type?: string | null): boolean { + const mime = (type || '').split(';')[0].trim().toLowerCase(); + if (mime === 'application/zip' || mime === 'application/x-zip-compressed' || mime === 'application/x-zip') return true; + return /\.zip$/i.test(name); +}