From 95580b84ea8bc379cee501272fe43e5991948085 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:11:24 +0200 Subject: [PATCH] feat: play video and audio, and show SVG, HTML and Markdown files in the preview --- locales/rn/ar.json | 3 +- locales/rn/ca.json | 3 +- locales/rn/cs.json | 3 +- locales/rn/da.json | 3 +- locales/rn/de.json | 3 +- locales/rn/en.json | 3 +- locales/rn/es.json | 3 +- locales/rn/fa.json | 3 +- locales/rn/fr.json | 3 +- locales/rn/he.json | 3 +- locales/rn/hu.json | 3 +- locales/rn/it.json | 3 +- locales/rn/ja.json | 3 +- locales/rn/ko.json | 3 +- locales/rn/lv.json | 3 +- locales/rn/mn.json | 3 +- locales/rn/nb.json | 3 +- locales/rn/nl.json | 3 +- locales/rn/pl.json | 3 +- locales/rn/pt.json | 3 +- locales/rn/ro.json | 3 +- locales/rn/ru.json | 3 +- locales/rn/sk.json | 3 +- locales/rn/tr.json | 3 +- locales/rn/uk.json | 3 +- locales/rn/zh-TW.json | 3 +- locales/rn/zh.json | 3 +- .../email/AttachmentPreviewModal.tsx | 148 +++++++-- src/components/email/use-file-preview.ts | 38 ++- src/lib/__tests__/attachment-display.test.ts | 14 +- src/lib/__tests__/file-preview.test.ts | 90 ++++-- src/lib/__tests__/markdown-html.test.ts | 70 +++++ src/lib/__tests__/preview-viewer-html.test.ts | 43 +++ src/lib/attachment-display.ts | 18 -- src/lib/file-preview.ts | 201 +++++++++---- src/lib/markdown-html.ts | 280 ++++++++++++++++++ src/lib/preview-viewer-html.ts | 104 +++++++ 37 files changed, 912 insertions(+), 175 deletions(-) create mode 100644 src/lib/__tests__/markdown-html.test.ts create mode 100644 src/lib/__tests__/preview-viewer-html.test.ts create mode 100644 src/lib/markdown-html.ts create mode 100644 src/lib/preview-viewer-html.ts diff --git a/locales/rn/ar.json b/locales/rn/ar.json index 2f8c9ecf..549d87d4 100644 --- a/locales/rn/ar.json +++ b/locales/rn/ar.json @@ -407,7 +407,8 @@ "preview": { "open_external": "فتح في تطبيق", "pdf_external": "تُفتح ملفات PDF في عارض PDF على هذا الجهاز.", - "unsupported": "لا توجد معاينة لهذا النوع من الملفات." + "unsupported": "لا توجد معاينة لهذا النوع من الملفات.", + "media_unsupported": "لا يمكن تشغيل هذا الملف هنا." }, "received_via": "إلى {name}", "remove_color": "إزالة الوسم", diff --git a/locales/rn/ca.json b/locales/rn/ca.json index ef93de9b..3168ddc8 100644 --- a/locales/rn/ca.json +++ b/locales/rn/ca.json @@ -407,7 +407,8 @@ "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í." }, "received_via": "per a {name}", "remove_color": "Elimina l'etiqueta", diff --git a/locales/rn/cs.json b/locales/rn/cs.json index 7a91b5fe..06ae3816 100644 --- a/locales/rn/cs.json +++ b/locales/rn/cs.json @@ -407,7 +407,8 @@ "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." }, "received_via": "pro {name}", "remove_color": "Odebrat štítek", diff --git a/locales/rn/da.json b/locales/rn/da.json index 756977c8..2e4d1b37 100644 --- a/locales/rn/da.json +++ b/locales/rn/da.json @@ -407,7 +407,8 @@ "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." }, "received_via": "til {name}", "remove_color": "Fjern tag", diff --git a/locales/rn/de.json b/locales/rn/de.json index 3d6f3546..813cf886 100644 --- a/locales/rn/de.json +++ b/locales/rn/de.json @@ -413,7 +413,8 @@ "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." }, "received_via": "an {name}", "remove_color": "Label entfernen", diff --git a/locales/rn/en.json b/locales/rn/en.json index dcb23d39..a02d31a2 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -424,7 +424,8 @@ "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." }, "received_via": "to {name}", "remove_color": "Remove tag", diff --git a/locales/rn/es.json b/locales/rn/es.json index 72e45092..86836702 100644 --- a/locales/rn/es.json +++ b/locales/rn/es.json @@ -407,7 +407,8 @@ "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í." }, "received_via": "para {name}", "remove_color": "Eliminar etiqueta", diff --git a/locales/rn/fa.json b/locales/rn/fa.json index cde97c86..ded4c31c 100644 --- a/locales/rn/fa.json +++ b/locales/rn/fa.json @@ -407,7 +407,8 @@ "preview": { "open_external": "باز کردن در برنامه", "pdf_external": "فایل‌های PDF در نمایشگر PDF این دستگاه باز می‌شوند.", - "unsupported": "برای این نوع فایل پیش‌نمایشی وجود ندارد." + "unsupported": "برای این نوع فایل پیش‌نمایشی وجود ندارد.", + "media_unsupported": "این فایل اینجا پخش نمی‌شود." }, "received_via": "به {name}", "remove_color": "حذف برچسب", diff --git a/locales/rn/fr.json b/locales/rn/fr.json index 9b7cdb19..ce9ed8e3 100644 --- a/locales/rn/fr.json +++ b/locales/rn/fr.json @@ -407,7 +407,8 @@ "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." }, "received_via": "à {name}", "remove_color": "Retirer l'étiquette", diff --git a/locales/rn/he.json b/locales/rn/he.json index bc93ac4c..0e37ee86 100644 --- a/locales/rn/he.json +++ b/locales/rn/he.json @@ -407,7 +407,8 @@ "preview": { "open_external": "פתח באפליקציה", "pdf_external": "קובצי PDF נפתחים במציג ה-PDF במכשיר זה.", - "unsupported": "אין תצוגה מקדימה לסוג קובץ זה." + "unsupported": "אין תצוגה מקדימה לסוג קובץ זה.", + "media_unsupported": "לא ניתן להפעיל את הקובץ הזה כאן." }, "received_via": "אל {name}", "remove_color": "הסר תג", diff --git a/locales/rn/hu.json b/locales/rn/hu.json index 8865527e..0f7f75c6 100644 --- a/locales/rn/hu.json +++ b/locales/rn/hu.json @@ -407,7 +407,8 @@ "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." }, "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 769707e7..6479ef03 100644 --- a/locales/rn/it.json +++ b/locales/rn/it.json @@ -407,7 +407,8 @@ "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." }, "received_via": "a {name}", "remove_color": "Rimuovi etichetta", diff --git a/locales/rn/ja.json b/locales/rn/ja.json index 1902b9f9..8828cba6 100644 --- a/locales/rn/ja.json +++ b/locales/rn/ja.json @@ -407,7 +407,8 @@ "preview": { "open_external": "アプリで開く", "pdf_external": "PDF はこのデバイスの PDF ビューアーで開きます。", - "unsupported": "このファイル形式はプレビューできません。" + "unsupported": "このファイル形式はプレビューできません。", + "media_unsupported": "このファイルはここでは再生できません。" }, "received_via": "{name}宛", "remove_color": "ラベルを削除", diff --git a/locales/rn/ko.json b/locales/rn/ko.json index 50718578..cff3ef27 100644 --- a/locales/rn/ko.json +++ b/locales/rn/ko.json @@ -407,7 +407,8 @@ "preview": { "open_external": "앱에서 열기", "pdf_external": "PDF는 이 기기의 PDF 뷰어에서 열려요.", - "unsupported": "이 파일 형식은 미리보기를 지원하지 않아요." + "unsupported": "이 파일 형식은 미리보기를 지원하지 않아요.", + "media_unsupported": "이 파일은 여기서 재생할 수 없습니다." }, "received_via": "받는 사람: {name}", "remove_color": "태그 제거", diff --git a/locales/rn/lv.json b/locales/rn/lv.json index a6f047f7..09342da2 100644 --- a/locales/rn/lv.json +++ b/locales/rn/lv.json @@ -407,7 +407,8 @@ "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." }, "received_via": "kam: {name}", "remove_color": "Noņemt tagu", diff --git a/locales/rn/mn.json b/locales/rn/mn.json index a49c5f4b..6a3f0bfd 100644 --- a/locales/rn/mn.json +++ b/locales/rn/mn.json @@ -407,7 +407,8 @@ "preview": { "open_external": "Апп-д нээх", "pdf_external": "PDF файлууд энэ төхөөрөмжийн PDF үзэгчид нээгдэнэ.", - "unsupported": "Энэ төрлийн файлыг урьдчилан харах боломжгүй." + "unsupported": "Энэ төрлийн файлыг урьдчилан харах боломжгүй.", + "media_unsupported": "Энэ файлыг энд тоглуулах боломжгүй." }, "received_via": "Хэнд: {name}", "remove_color": "Тагийг хасах", diff --git a/locales/rn/nb.json b/locales/rn/nb.json index 6bdcf0f2..0f5cf1dd 100644 --- a/locales/rn/nb.json +++ b/locales/rn/nb.json @@ -407,7 +407,8 @@ "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." }, "received_via": "til {name}", "remove_color": "Fjern etiketten", diff --git a/locales/rn/nl.json b/locales/rn/nl.json index ca7b3e6b..c7f279a1 100644 --- a/locales/rn/nl.json +++ b/locales/rn/nl.json @@ -407,7 +407,8 @@ "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." }, "received_via": "aan {name}", "remove_color": "Label verwijderen", diff --git a/locales/rn/pl.json b/locales/rn/pl.json index 0c9498c2..43613007 100644 --- a/locales/rn/pl.json +++ b/locales/rn/pl.json @@ -407,7 +407,8 @@ "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ć." }, "received_via": "do {name}", "remove_color": "Usuń etykietę", diff --git a/locales/rn/pt.json b/locales/rn/pt.json index b54889ca..06e4ff49 100644 --- a/locales/rn/pt.json +++ b/locales/rn/pt.json @@ -407,7 +407,8 @@ "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." }, "received_via": "para {name}", "remove_color": "Remover etiqueta", diff --git a/locales/rn/ro.json b/locales/rn/ro.json index 2a5dd945..782dc7ed 100644 --- a/locales/rn/ro.json +++ b/locales/rn/ro.json @@ -407,7 +407,8 @@ "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." }, "received_via": "către {name}", "remove_color": "Eliminați eticheta", diff --git a/locales/rn/ru.json b/locales/rn/ru.json index 3c5bd301..483fe7f2 100644 --- a/locales/rn/ru.json +++ b/locales/rn/ru.json @@ -407,7 +407,8 @@ "preview": { "open_external": "Открыть в приложении", "pdf_external": "PDF-файлы открываются в программе просмотра PDF на этом устройстве.", - "unsupported": "Для этого типа файлов нет предпросмотра." + "unsupported": "Для этого типа файлов нет предпросмотра.", + "media_unsupported": "Этот файл нельзя воспроизвести здесь." }, "received_via": "на {name}", "remove_color": "Убрать тег", diff --git a/locales/rn/sk.json b/locales/rn/sk.json index 2dda2114..a561cf0d 100644 --- a/locales/rn/sk.json +++ b/locales/rn/sk.json @@ -407,7 +407,8 @@ "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ť." }, "received_via": "pre {name}", "remove_color": "Odstrániť štítok", diff --git a/locales/rn/tr.json b/locales/rn/tr.json index 5f88e169..af1e539e 100644 --- a/locales/rn/tr.json +++ b/locales/rn/tr.json @@ -407,7 +407,8 @@ "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." }, "received_via": "alıcı: {name}", "remove_color": "Etiketi kaldır", diff --git a/locales/rn/uk.json b/locales/rn/uk.json index 147d95d0..666e4504 100644 --- a/locales/rn/uk.json +++ b/locales/rn/uk.json @@ -407,7 +407,8 @@ "preview": { "open_external": "Відкрити в застосунку", "pdf_external": "PDF-файли відкриваються в переглядачі PDF на цьому пристрої.", - "unsupported": "Для цього типу файлів немає попереднього перегляду." + "unsupported": "Для цього типу файлів немає попереднього перегляду.", + "media_unsupported": "Цей файл не можна відтворити тут." }, "received_via": "на {name}", "remove_color": "Прибрати тег", diff --git a/locales/rn/zh-TW.json b/locales/rn/zh-TW.json index 8c086673..c577ec2a 100644 --- a/locales/rn/zh-TW.json +++ b/locales/rn/zh-TW.json @@ -407,7 +407,8 @@ "preview": { "open_external": "在應用程式中開啟", "pdf_external": "PDF 會在此裝置的 PDF 檢視器中開啟。", - "unsupported": "無法預覽此檔案類型。" + "unsupported": "無法預覽此檔案類型。", + "media_unsupported": "無法在此播放這個檔案。" }, "received_via": "寄給 {name}", "remove_color": "移除標籤", diff --git a/locales/rn/zh.json b/locales/rn/zh.json index 4f40afaf..0a27a7fc 100644 --- a/locales/rn/zh.json +++ b/locales/rn/zh.json @@ -407,7 +407,8 @@ "preview": { "open_external": "在应用中打开", "pdf_external": "PDF 将在此设备的 PDF 查看器中打开。", - "unsupported": "无法预览此类文件。" + "unsupported": "无法预览此类文件。", + "media_unsupported": "无法在此播放该文件。" }, "received_via": "发给 {name}", "remove_color": "删除标签", diff --git a/src/components/email/AttachmentPreviewModal.tsx b/src/components/email/AttachmentPreviewModal.tsx index 2e3aa9a7..0b94b90e 100644 --- a/src/components/email/AttachmentPreviewModal.tsx +++ b/src/components/email/AttachmentPreviewModal.tsx @@ -10,7 +10,7 @@ import { spacing, radius, typography, componentSizes, type ThemePalette } from ' 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 type { EmlPreview } from './use-body-override'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; @@ -18,10 +18,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); 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 +38,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. */ @@ -44,10 +48,45 @@ interface Props { } /** - * 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 + * and media viewers. It posts 'error' when the file doesn't decode. + */ +function ViewerWebView({ uri, background, onFailed }: { uri: string; background: string; onFailed: () => void }) { + const folder = uri.slice(0, uri.lastIndexOf('/') + 1); + return ( + req.url === uri} + onMessage={(e) => { if (e.nativeEvent.data === 'error') onFailed(); }} + onError={onFailed} + style={[viewerStyles.fill, { backgroundColor: background }]} + containerStyle={{ backgroundColor: background }} + /> + ); +} + +/** + * In-app preview for images, PDFs (iOS renders them in the WebView), video + * and audio (the WebView's player), text, Markdown and HTML documents and + * embedded messages. Files that don't decode, and types without a preview, + * offer another app instead. Used by every place that opens a file. */ export function AttachmentPreviewModal({ item, loading, title, onClose, onOpenExternal, onShare, onDownload, docked = false, @@ -56,31 +95,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(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(() => { - if (!item?.eml) return null; + const docEmail = React.useMemo(() => { + 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 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' && Platform.OS === 'ios' && !!item.fileUri; // 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) => ( @@ -92,6 +149,13 @@ export function AttachmentPreviewModal({ ); + const renderDocument = (header?: React.ReactNode) => ( + + {header} + {docEmail && } + + ); + const renderBody = () => { if (loading || !item) { return ( @@ -105,6 +169,16 @@ export function AttachmentPreviewModal({ if (imageFailed) { return renderFallback(t('email_viewer.preview.unsupported', 'No preview for this file type.')); } + if (imageInWeb && item.viewerUri) { + return ( + markFailed(item.fileUri, 'web')} + /> + ); + } return ( setFailedImageUri(item.fileUri ?? null)} + onError={() => markFailed(item.fileUri, 'native')} /> ); + case 'video': + case 'audio': + if (mediaFailed || !item.viewerUri) { + return renderFallback(t('email_viewer.preview.media_unsupported', "This file can't be played here.")); + } + return ( + markFailed(item.fileUri, 'web')} + /> + ); case 'pdf': if (pdfInline && item.fileUri) { return ( @@ -150,21 +237,16 @@ export function AttachmentPreviewModal({ {item.text ?? ''} ); + case 'html': + case 'markdown': + return renderDocument(); case 'eml': - return ( - - - {item.eml?.subject || t('email_viewer.no_subject', '(No Subject)')} - {item.eml?.from ? {item.eml.from} : null} - {item.eml?.date ? {item.eml.date} : null} - - {emlEmail && ( - - )} - + return renderDocument( + + {item.eml?.subject || t('email_viewer.no_subject', '(No Subject)')} + {item.eml?.from ? {item.eml.from} : null} + {item.eml?.date ? {item.eml.date} : null} + , ); default: return renderFallback(item.notice ?? t('email_viewer.preview.unsupported', 'No preview for this file type.')); @@ -229,6 +311,8 @@ export function AttachmentPreviewModal({ ); } +const viewerStyles = StyleSheet.create({ fill: { flex: 1 } }); + function makeStyles(c: ThemePalette) { return StyleSheet.create({ container: { flex: 1, backgroundColor: c.background }, diff --git a/src/components/email/use-file-preview.ts b/src/components/email/use-file-preview.ts index 2b2967dc..d18888d4 100644 --- a/src/components/email/use-file-preview.ts +++ b/src/components/email/use-file-preview.ts @@ -1,6 +1,6 @@ import React from 'react'; import { Alert, Platform } from 'react-native'; -import type { File } from 'expo-file-system'; +import { File } from 'expo-file-system'; import type { Email } from '../../api/types'; import { cachePreviewFile, copyPreviewFile, discardPreviewFile, saveLocalFileCopy, shareAttachment, @@ -9,12 +9,15 @@ import { import { exceedsPreviewLimit, inAppPreviewKind, type FilePreviewOptions, } from '../../lib/file-preview'; -import type { PreviewKind } from '../../lib/attachment-display'; +import type { PreviewKind } from '../../lib/file-preview'; +import { markdownToHtml } from '../../lib/markdown-html'; +import { imageViewerHtml, mediaViewerHtml, type ViewerColors } from '../../lib/preview-viewer-html'; import { useLocaleStore } from '../../stores/locale-store'; +import { useColors, useResolvedTheme } from '../../theme/colors'; import type { PreviewItem } from './AttachmentPreviewModal'; import { emlPreviewFromBytes } from './use-body-override'; -export const PREVIEW_OPTIONS: FilePreviewOptions = { inlinePdf: Platform.OS === 'ios' }; +export const PREVIEW_OPTIONS: FilePreviewOptions = { platform: Platform.OS === 'ios' ? 'ios' : 'android' }; interface PreviewSourceBase { /** Display name, shown in the header and given to the cached copy. */ @@ -62,11 +65,21 @@ function handOff(source: PreviewSource): Promise { } } +// 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; +} + async function buildItem( kind: Exclude, source: PreviewSource, local: File, tooLarge: string, + colors: ViewerColors, ): Promise { const base = { name: source.name, mimeType: source.type }; // The source may not know its size; the copy does. @@ -74,8 +87,17 @@ async function buildItem( 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)) }; default: return { ...base, kind, fileUri: local.uri }; } @@ -97,6 +119,12 @@ export function useFilePreview(opts: { onClose?: () => void; } = {}) { const t = useLocaleStore((s) => s.t); + const c = useColors(); + const scheme = useResolvedTheme(); + // Read when a preview is built; the viewer page keeps the theme it opened with. + const viewerColors: ViewerColors = { scheme, background: c.background, text: c.text, textMuted: c.textMuted, surface: c.surface }; + const colors = React.useRef(viewerColors); + colors.current = viewerColors; const onCloseRef = React.useRef(opts.onClose); onCloseRef.current = opts.onClose; const [item, setItem] = React.useState(null); @@ -153,7 +181,9 @@ export function useFilePreview(opts: { return; } session.current = { file: local, handedOff: false }; - const next = await buildItem(kind, src, local, t('files.preview_too_large', 'This file is too large to preview.')); + const next = await buildItem( + kind, src, local, t('files.preview_too_large', 'This file is too large to preview.'), colors.current, + ); if (current()) setItem(next); } catch (e) { if (!current()) return; diff --git a/src/lib/__tests__/attachment-display.test.ts b/src/lib/__tests__/attachment-display.test.ts index 6653d0fb..b7763bb2 100644 --- a/src/lib/__tests__/attachment-display.test.ts +++ b/src/lib/__tests__/attachment-display.test.ts @@ -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); }); diff --git a/src/lib/__tests__/file-preview.test.ts b/src/lib/__tests__/file-preview.test.ts index a500af61..bdfe70c7 100644 --- a/src/lib/__tests__/file-preview.test.ts +++ b/src/lib/__tests__/file-preview.test.ts @@ -1,29 +1,40 @@ import { describe, it, expect } from 'vitest'; import { filePreviewKind, inAppPreviewKind, exceedsPreviewLimit, FILE_PREVIEW_MAX_BYTES, + needsNativeImageDecoder, needsWebImageViewer, } 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', () => { @@ -32,21 +43,37 @@ describe('filePreviewKind', () => { expect(filePreviewKind({ name: 'report.pdf', type: 'application/pdf' }, ANDROID)).toBe('none'); }); - 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 +81,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 +105,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 +142,6 @@ 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'); }); }); diff --git a/src/lib/__tests__/markdown-html.test.ts b/src/lib/__tests__/markdown-html.test.ts new file mode 100644 index 00000000..f85881f9 --- /dev/null +++ b/src/lib/__tests__/markdown-html.test.ts @@ -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('') + 6, html.indexOf('')); +}; + +describe('markdownToHtml', () => { + it('renders headings, paragraphs and rules', () => { + expect(body('# Title\n\nSome text\nwrapped.\n\n---\n\n### Small ###')) + .toBe('

Title

\n

Some text\nwrapped.

\n
\n

Small

'); + expect(body('Big\n===\n\nSmaller\n---')).toBe('

Big

\n

Smaller

'); + }); + + it('renders nested and ordered lists and task items', () => { + expect(body('- one\n- two\n - nested\n- three')).toBe( + '
  • one
  • two\n
    • nested
  • three
', + ); + expect(body('3. c\n4. d')).toBe('
  1. c
  2. d
'); + expect(body('- [x] done\n- [ ] open')).toBe('
  • \u2611 done
  • \u2610 open
'); + }); + + it('keeps fenced and indented code verbatim and escaped', () => { + expect(body('```js\nconst a = "";\n**not bold**\n```')).toBe( + '
const a = "<b>";\n**not bold**
', + ); + expect(body(' x < 1')).toBe('
x < 1
'); + }); + + it('renders quotes and GFM tables with alignment', () => { + expect(body('> quoted\n> **text**')).toBe('

quoted\ntext

'); + expect(body('| a | b |\n|:--|--:|\n| 1 | 2 |')).toBe( + '' + + '
ab
12
', + ); + }); + + it('escapes raw HTML instead of passing it through', () => { + expect(body('')).toBe('

<script>alert(1)</script>

'); + expect(body('')).not.toContain(' { + it('renders emphasis, code and strikethrough', () => { + expect(renderInline('**b** *i* _j_ `c*d*` ~~s~~')).toBe( + 'b i j c*d* s', + ); + 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('site'); + 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 https://example.org/a.'); + expect(renderInline('')).toBe('mailto:a@b.c'); + }); + + it('keeps image URLs for the sanitiser and drops unsafe ones', () => { + expect(renderInline('![logo](https://example.org/l.png)')).toBe('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="/); + }); +}); diff --git a/src/lib/__tests__/preview-viewer-html.test.ts b/src/lib/__tests__/preview-viewer-html.test.ts new file mode 100644 index 00000000..e43ce0da --- /dev/null +++ b/src/lib/__tests__/preview-viewer-html.test.ts @@ -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">'.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('