feat: play video and audio, and show SVG, HTML and Markdown files in the preview

This commit is contained in:
Linus Rath
2026-10-16 19:11:24 +02:00
parent 9a15334bef
commit 95580b84ea
37 changed files with 912 additions and 175 deletions
+2 -1
View File
@@ -407,7 +407,8 @@
"preview": {
"open_external": "فتح في تطبيق",
"pdf_external": "تُفتح ملفات PDF في عارض PDF على هذا الجهاز.",
"unsupported": "لا توجد معاينة لهذا النوع من الملفات."
"unsupported": "لا توجد معاينة لهذا النوع من الملفات.",
"media_unsupported": "لا يمكن تشغيل هذا الملف هنا."
},
"received_via": "إلى {name}",
"remove_color": "إزالة الوسم",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -407,7 +407,8 @@
"preview": {
"open_external": "باز کردن در برنامه",
"pdf_external": "فایل‌های PDF در نمایشگر PDF این دستگاه باز می‌شوند.",
"unsupported": "برای این نوع فایل پیش‌نمایشی وجود ندارد."
"unsupported": "برای این نوع فایل پیش‌نمایشی وجود ندارد.",
"media_unsupported": "این فایل اینجا پخش نمی‌شود."
},
"received_via": "به {name}",
"remove_color": "حذف برچسب",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -407,7 +407,8 @@
"preview": {
"open_external": "פתח באפליקציה",
"pdf_external": "קובצי PDF נפתחים במציג ה-PDF במכשיר זה.",
"unsupported": "אין תצוגה מקדימה לסוג קובץ זה."
"unsupported": "אין תצוגה מקדימה לסוג קובץ זה.",
"media_unsupported": "לא ניתן להפעיל את הקובץ הזה כאן."
},
"received_via": "אל {name}",
"remove_color": "הסר תג",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -407,7 +407,8 @@
"preview": {
"open_external": "アプリで開く",
"pdf_external": "PDF はこのデバイスの PDF ビューアーで開きます。",
"unsupported": "このファイル形式はプレビューできません。"
"unsupported": "このファイル形式はプレビューできません。",
"media_unsupported": "このファイルはここでは再生できません。"
},
"received_via": "{name}宛",
"remove_color": "ラベルを削除",
+2 -1
View File
@@ -407,7 +407,8 @@
"preview": {
"open_external": "앱에서 열기",
"pdf_external": "PDF는 이 기기의 PDF 뷰어에서 열려요.",
"unsupported": "이 파일 형식은 미리보기를 지원하지 않아요."
"unsupported": "이 파일 형식은 미리보기를 지원하지 않아요.",
"media_unsupported": "이 파일은 여기서 재생할 수 없습니다."
},
"received_via": "받는 사람: {name}",
"remove_color": "태그 제거",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -407,7 +407,8 @@
"preview": {
"open_external": "Апп-д нээх",
"pdf_external": "PDF файлууд энэ төхөөрөмжийн PDF үзэгчид нээгдэнэ.",
"unsupported": "Энэ төрлийн файлыг урьдчилан харах боломжгүй."
"unsupported": "Энэ төрлийн файлыг урьдчилан харах боломжгүй.",
"media_unsupported": "Энэ файлыг энд тоглуулах боломжгүй."
},
"received_via": "Хэнд: {name}",
"remove_color": "Тагийг хасах",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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ę",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -407,7 +407,8 @@
"preview": {
"open_external": "Открыть в приложении",
"pdf_external": "PDF-файлы открываются в программе просмотра PDF на этом устройстве.",
"unsupported": "Для этого типа файлов нет предпросмотра."
"unsupported": "Для этого типа файлов нет предпросмотра.",
"media_unsupported": "Этот файл нельзя воспроизвести здесь."
},
"received_via": "на {name}",
"remove_color": "Убрать тег",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -407,7 +407,8 @@
"preview": {
"open_external": "Відкрити в застосунку",
"pdf_external": "PDF-файли відкриваються в переглядачі PDF на цьому пристрої.",
"unsupported": "Для цього типу файлів немає попереднього перегляду."
"unsupported": "Для цього типу файлів немає попереднього перегляду.",
"media_unsupported": "Цей файл не можна відтворити тут."
},
"received_via": "на {name}",
"remove_color": "Прибрати тег",
+2 -1
View File
@@ -407,7 +407,8 @@
"preview": {
"open_external": "在應用程式中開啟",
"pdf_external": "PDF 會在此裝置的 PDF 檢視器中開啟。",
"unsupported": "無法預覽此檔案類型。"
"unsupported": "無法預覽此檔案類型。",
"media_unsupported": "無法在此播放這個檔案。"
},
"received_via": "寄給 {name}",
"remove_color": "移除標籤",
+2 -1
View File
@@ -407,7 +407,8 @@
"preview": {
"open_external": "在应用中打开",
"pdf_external": "PDF 将在此设备的 PDF 查看器中打开。",
"unsupported": "无法预览此类文件。"
"unsupported": "无法预览此类文件。",
"media_unsupported": "无法在此播放该文件。"
},
"received_via": "发给 {name}",
"remove_color": "删除标签",
+116 -32
View File
@@ -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 (
<WebView
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={(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<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 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) => (
<View style={styles.centered}>
@@ -92,6 +149,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 +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 (
<ViewerWebView
key={item.viewerUri}
uri={item.viewerUri}
background={c.background}
onFailed={() => markFailed(item.fileUri, 'web')}
/>
);
}
return (
<ScrollView
style={styles.flex}
@@ -124,10 +198,23 @@ 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) {
return (
@@ -150,21 +237,16 @@ export function AttachmentPreviewModal({
<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 +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 },
+34 -4
View File
@@ -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<void> {
}
}
// The viewer page sits next to the file it shows (see preview-viewer-html).
function writeViewer(local: File, html: string): string {
const page = new File(local.parentDirectory, '__preview.html');
if (page.exists) page.delete();
page.create();
page.write(html);
return page.uri;
}
async function buildItem(
kind: Exclude<PreviewKind, 'none'>,
source: PreviewSource,
local: File,
tooLarge: string,
colors: ViewerColors,
): Promise<PreviewItem> {
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<PreviewItem | null>(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;
+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);
});
+72 -18
View File
@@ -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');
});
});
+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="/);
});
});
@@ -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`;
+136 -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, and only iOS renders PDFs in-app.
*/
inlinePdf: boolean;
platform: 'ios' | 'android';
}
interface PreviewableFile {
@@ -24,74 +24,133 @@ 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';
}
if (mime === 'application/pdf' || (!media && ext === 'pdf')) {
return opts.platform === 'ios' ? 'pdf' : 'none';
}
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 +161,21 @@ 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);
}
+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>`;
}
+104
View File
@@ -0,0 +1,104 @@
// 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}`,
);
}