feat: preview images, text and PDFs in the Files tab

Tapping a file in the Files tab downloaded it and went straight to the
Android "Open with" chooser or the iOS share sheet. It now opens the
reader's attachment preview for the types that preview can show, and
hands only the rest to another app, as before.

- `filePreviewKind` (`src/lib/file-preview.ts`) builds on the reader's
  `previewKindFor` and adds source code and config files as text; HTML,
  SVG, images React Native cannot decode and PDFs on Android (no in-app
  renderer) still go to another app
- files over a cap per type (text 512 KB, .eml 10 MB, images 25 MB,
  PDFs 50 MB) open in another app instead; when a node has no size,
  the downloaded file is checked
- the preview downloads the file once, with the same authenticated
  download as Download, into a temp folder of its own; Open with, Share
  and Download reuse that copy, so they work without another fetch
- the folder is deleted when the preview closes, or left to the
  stale-export sweep, which now also clears preview folders, once
  another app got the file
- the per-file menu offers Preview and "Open with…" instead of the
  combined "Preview / Share"
- the shared preview gains a header Open button, an optional Download
  button, labels on its icon buttons and a fallback when an image does
  not decode; the .eml parsing moves into `emlPreviewFromBytes`, which
  the reader uses too
This commit is contained in:
Linus Rath
2026-09-24 19:28:37 +02:00
parent 1d755fc066
commit 409cdb139e
10 changed files with 771 additions and 65 deletions
+177 -3
View File
@@ -14,6 +14,16 @@ vi.mock('react-native', () => ({
},
}));
// What the mocked file system was asked to do: deleted and copied paths, and
// directory listings (by directory uri) for the stale-file sweep.
const { fsState } = vi.hoisted(() => ({
fsState: {
deleted: [] as string[],
copies: [] as Array<[string, string]>,
listings: new Map<string, Array<{ uri: string }>>(),
},
}));
// A `File` that records both URL forms, so tests can assert which one each
// handoff path was given: expo-sharing needs `file://`, an Intent needs
// `content://`.
@@ -23,24 +33,34 @@ vi.mock('expo-file-system', () => {
uri: string;
contentUri: string;
exists = true;
modificationTime: number | null = null;
private parent: { uri: string };
constructor(dir: { uri: string }, name: string) {
this.parent = dir;
this.name = name;
this.uri = `${dir.uri}${name}`;
this.contentUri = `content://org.bulwarkmail.mobile.FileSystemFileProvider/${name}`;
}
get parentDirectory() { return this.parent; }
create() {}
delete() {}
delete() { fsState.deleted.push(this.uri); }
write() {}
copy(dest: { uri: string }) { fsState.copies.push([this.uri, dest.uri]); }
static downloadFileAsync = vi.fn(async () => undefined);
}
class Directory {
uri: string;
name: string;
exists = true;
constructor(parent: { uri: string }, name: string) {
this.name = name;
this.uri = `${parent.uri}${name}/`;
}
create() {}
list() { return []; }
delete() { fsState.deleted.push(this.uri); }
list() {
return (fsState.listings.get(this.uri) ?? []).filter((e) => !fsState.deleted.includes(e.uri));
}
}
return {
File,
@@ -81,7 +101,12 @@ vi.mock('../../api/blob', () => ({
import * as IntentLauncher from 'expo-intent-launcher';
import * as Sharing from 'expo-sharing';
import { shareAttachment, downloadAttachment } from '../email-export';
import { File, Directory } from 'expo-file-system';
import { getDownloadUrl } from '../../api/blob';
import {
shareAttachment, downloadAttachment, cachePreviewFile, discardPreviewFile, saveLocalFileCopy,
shareLocalFile,
} from '../email-export';
const VIEW = 'android.intent.action.VIEW';
const FLAG_GRANT_READ_URI_PERMISSION = 0x00000001;
@@ -183,3 +208,152 @@ describe('downloadAttachment (save a copy)', () => {
});
});
});
describe('cachePreviewFile (in-app preview)', () => {
beforeEach(() => {
fsState.deleted.length = 0;
vi.mocked(File.downloadFileAsync).mockClear();
vi.mocked(getDownloadUrl).mockClear();
});
it('downloads into a folder of its own, keeping the real name and the owning account', async () => {
const file = await cachePreviewFile('blob-p', 'report.pdf', 'application/pdf', 'acct-2');
expect(getDownloadUrl).toHaveBeenCalledWith('blob-p', 'report.pdf', 'application/pdf', 'acct-2');
expect(file.uri).toMatch(/^file:\/\/\/cache\/bulwark-exports\/preview-[^/]+\/report\.pdf$/);
expect(File.downloadFileAsync).toHaveBeenCalledWith(
'https://mail.example.com/blob-p/report.pdf',
file,
{ headers: { Authorization: 'Bearer token' }, idempotent: true },
);
});
it('never reuses a path, so two files with one name do not share an image cache entry', async () => {
const a = await cachePreviewFile('blob-a', 'photo.jpg', 'image/jpeg');
const b = await cachePreviewFile('blob-b', 'photo.jpg', 'image/jpeg');
expect(a.name).toBe('photo.jpg');
expect(b.name).toBe('photo.jpg');
expect(a.uri).not.toBe(b.uri);
});
it('makes the name safe for the file system', async () => {
const file = await cachePreviewFile('blob-n', 'a:b?.txt', 'text/plain');
expect(file.name).toBe('a_b_.txt');
});
it('removes its folder and rethrows when the download fails', async () => {
vi.mocked(File.downloadFileAsync).mockRejectedValueOnce(new Error('Download failed: 404'));
await expect(cachePreviewFile('blob-x', 'gone.txt', 'text/plain')).rejects.toThrow('404');
expect(fsState.deleted).toHaveLength(1);
expect(fsState.deleted[0]).toMatch(/\/bulwark-exports\/preview-[^/]+\/$/);
});
});
describe('discardPreviewFile', () => {
beforeEach(() => {
fsState.deleted.length = 0;
});
it('deletes the preview file together with its folder', async () => {
const file = await cachePreviewFile('blob-d', 'notes.txt', 'text/plain');
discardPreviewFile(file);
expect(fsState.deleted).toEqual([file.uri.replace(/notes\.txt$/, '')]);
});
it('only deletes the file itself outside a preview folder', () => {
const exports = new Directory({ uri: 'file:///cache/' } as never, 'bulwark-exports');
const file = new File(exports as never, 'shared.zip');
discardPreviewFile(file);
expect(fsState.deleted).toEqual(['file:///cache/bulwark-exports/shared.zip']);
});
});
describe('saveLocalFileCopy (Download from a preview)', () => {
beforeEach(() => {
platform.OS = 'android';
fsState.copies.length = 0;
vi.mocked(File.downloadFileAsync).mockClear();
vi.mocked(IntentLauncher.startActivityAsync).mockClear();
vi.mocked(Sharing.shareAsync).mockClear();
});
it('copies the file into documents and offers the share sheet without downloading again', async () => {
const preview = await cachePreviewFile('blob-s', 'report.pdf', 'application/pdf');
vi.mocked(File.downloadFileAsync).mockClear();
await saveLocalFileCopy(preview, 'application/pdf');
expect(fsState.copies).toEqual([[preview.uri, 'file:///documents/report.pdf']]);
expect(File.downloadFileAsync).not.toHaveBeenCalled();
expect(IntentLauncher.startActivityAsync).not.toHaveBeenCalled();
expect(Sharing.shareAsync).toHaveBeenCalledWith('file:///documents/report.pdf', {
mimeType: 'application/pdf',
dialogTitle: 'report.pdf',
});
});
});
describe('shareLocalFile', () => {
beforeEach(() => {
platform.OS = 'ios';
fsState.deleted.length = 0;
vi.mocked(Sharing.shareAsync).mockClear();
});
it('removes the file after sharing by default', async () => {
const file = new File({ uri: 'file:///cache/bulwark-exports/' } as never, 'bundle.zip');
await shareLocalFile(file, 'application/zip');
expect(Sharing.shareAsync).toHaveBeenCalledTimes(1);
expect(fsState.deleted).toEqual([file.uri]);
});
it('keeps the file an open preview still shows', async () => {
const file = new File({ uri: 'file:///cache/bulwark-exports/preview-1/' } as never, 'photo.jpg');
await shareLocalFile(file, 'image/jpeg', 'photo.jpg', { forceSheet: true, keep: true });
expect(Sharing.shareAsync).toHaveBeenCalledTimes(1);
expect(fsState.deleted).toEqual([]);
});
});
// Last: it reloads the module to get a process that has not swept yet.
describe('sweepStaleExportFiles', () => {
it('clears stale files, and preview folders once they are empty', async () => {
vi.resetModules();
const fsMod = await import('expo-file-system');
const { sweepStaleExportFiles } = await import('../email-export');
fsState.deleted.length = 0;
fsState.listings.clear();
const now = Date.now();
const day = 24 * 60 * 60 * 1000;
const root = new fsMod.Directory({ uri: 'file:///cache/' } as never, 'bulwark-exports');
const aged = (dir: { uri: string }, name: string, age: number) => {
const f = new fsMod.File(dir as never, name);
(f as unknown as { modificationTime: number }).modificationTime = now - age;
return f;
};
const oldExport = aged(root, 'old.pdf', 2 * day);
const newExport = aged(root, 'new.pdf', 60_000);
const handedOff = new fsMod.Directory(root as never, 'preview-a');
const open = new fsMod.Directory(root as never, 'preview-b');
const other = new fsMod.Directory(root as never, 'keep-me');
fsState.listings.set(root.uri, [oldExport, newExport, handedOff, open, other]);
fsState.listings.set(handedOff.uri, [aged(handedOff, 'photo.jpg', 2 * day)]);
fsState.listings.set(open.uri, [aged(open, 'notes.txt', 60_000)]);
fsState.listings.set(other.uri, [aged(other, 'x.bin', 2 * day)]);
await sweepStaleExportFiles();
expect(fsState.deleted).toEqual([
oldExport.uri,
`${handedOff.uri}photo.jpg`,
handedOff.uri,
]);
});
});
+93
View File
@@ -0,0 +1,93 @@
import { describe, it, expect } from 'vitest';
import {
filePreviewKind, inAppPreviewKind, exceedsPreviewLimit, FILE_PREVIEW_MAX_BYTES,
} from '../file-preview';
const IOS = { inlinePdf: true };
const ANDROID = { inlinePdf: false };
describe('filePreviewKind', () => {
it('previews the images React Native decodes, 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', () => {
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('shows PDFs in-app only where the platform renders them', () => {
expect(filePreviewKind({ name: 'report.pdf', type: 'application/pdf' }, IOS)).toBe('pdf');
expect(filePreviewKind({ name: 'report.pdf', type: 'application/octet-stream' }, IOS)).toBe('pdf');
expect(filePreviewKind({ name: 'report.pdf', type: 'application/pdf' }, ANDROID)).toBe('none');
});
it('shows plain text, markdown and data 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');
});
it('shows saved messages like the reader does', () => {
expect(filePreviewKind({ name: 'bounce.eml', type: 'application/octet-stream' }, ANDROID)).toBe('eml');
expect(filePreviewKind({ name: 'fwd', type: 'message/rfc822' }, ANDROID)).toBe('eml');
});
it('leaves binaries and office documents to other apps', () => {
expect(filePreviewKind({ name: 'archive.zip', type: 'application/zip' }, IOS)).toBe('none');
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: 'data.bin', type: undefined }, IOS)).toBe('none');
});
it('ignores MIME parameters and case', () => {
expect(filePreviewKind({ name: 'x', type: 'Text/Plain; charset=utf-8' }, ANDROID)).toBe('text');
expect(filePreviewKind({ name: 'x', type: 'IMAGE/PNG' }, ANDROID)).toBe('image');
});
});
describe('preview size limits', () => {
it('caps text far below images and PDFs', () => {
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);
});
it('flags only known sizes over the cap', () => {
expect(exceedsPreviewLimit('text', FILE_PREVIEW_MAX_BYTES.text)).toBe(false);
expect(exceedsPreviewLimit('text', FILE_PREVIEW_MAX_BYTES.text + 1)).toBe(true);
expect(exceedsPreviewLimit('text', undefined)).toBe(false);
expect(exceedsPreviewLimit('text', null)).toBe(false);
expect(exceedsPreviewLimit('none', 10 ** 12)).toBe(false);
});
it('opens files over the cap in another app instead', () => {
const big = { name: 'server.log', type: 'text/plain', size: 5 * 1024 * 1024 };
expect(inAppPreviewKind(big, ANDROID)).toBe('none');
expect(inAppPreviewKind({ ...big, size: 20 * 1024 }, ANDROID)).toBe('text');
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');
});
});
+90 -11
View File
@@ -73,12 +73,24 @@ function deleteQuietly(file: File): void {
} catch { /* already gone or still in use - the sweep gets it later */ }
}
// Folders the in-app preview downloads into (see cachePreviewFile).
const PREVIEW_DIR_PREFIX = 'preview-';
function deleteStaleFiles(dir: Directory, now: number, maxAgeMs: number): void {
for (const entry of dir.list()) {
if (!(entry instanceof File)) continue;
const modified = (entry as { modificationTime?: number | null }).modificationTime;
const age = typeof modified === 'number' ? now - modified : Number.POSITIVE_INFINITY;
if (age > maxAgeMs) deleteQuietly(entry);
}
}
/**
* Remove temp files older than a day. Files shared into other apps could not
* always be deleted right after the share sheet closed (the receiving app may
* still be reading them), so anything that slipped through is collected here.
* Runs once per process, lazily before the first export, and is also safe to
* call at launch.
* still be reading them), so anything that slipped through is collected here,
* including preview folders whose file went to another app. Runs once per
* process, lazily before the first export, and is also safe to call at launch.
*/
let sweptThisProcess = false;
export async function sweepStaleExportFiles(maxAgeMs = STALE_EXPORT_MS): Promise<void> {
@@ -88,11 +100,13 @@ export async function sweepStaleExportFiles(maxAgeMs = STALE_EXPORT_MS): Promise
const dir = exportsDir();
if (!dir.exists) return;
const now = Date.now();
deleteStaleFiles(dir, now, maxAgeMs);
for (const entry of dir.list()) {
if (!(entry instanceof File)) continue;
const modified = (entry as { modificationTime?: number | null }).modificationTime;
const age = typeof modified === 'number' ? now - modified : Number.POSITIVE_INFINITY;
if (age > maxAgeMs) deleteQuietly(entry);
if (!(entry instanceof Directory) || !entry.name.startsWith(PREVIEW_DIR_PREFIX)) continue;
try {
deleteStaleFiles(entry, now, maxAgeMs);
if (entry.list().length === 0) entry.delete();
} catch { /* in use - a later launch gets it */ }
}
} catch { /* housekeeping only */ }
}
@@ -261,15 +275,78 @@ export async function downloadAttachment(
const dest = new File(Paths.document, filename);
const url = getDownloadUrl(blobId, filename, mimeType, accountId);
const downloaded = await downloadInto(url, dest, Paths.document);
await offerSavedFile(downloaded, filename, mimeType);
}
async function offerSavedFile(file: File, filename: string, mimeType: string): Promise<void> {
if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device');
}
await Sharing.shareAsync(downloaded.uri, {
await Sharing.shareAsync(file.uri, {
mimeType,
dialogTitle: filename,
});
}
/**
* {@link downloadAttachment} for a file already on the device (an open
* preview): copy it into the document directory and offer it through the
* share sheet, without downloading it again.
*/
export async function saveLocalFileCopy(file: File, mimeType: string, filename = file.name): Promise<void> {
const name = safeAttachmentName(filename, mimeType);
const dest = new File(Paths.document, name);
if (dest.exists) dest.delete();
file.copy(dest);
await offerSavedFile(dest, name, mimeType);
}
// ─── In-app preview files ───────────────────────────────────────────────
let previewSeq = 0;
/**
* Download a blob for the in-app preview. Every preview gets its own folder
* in the exports cache, so the file keeps its real name for Share and Open
* with while its path stays unique: React Native's Image caches decoded
* images by URI, and two files both called "photo.jpg" must not show the same
* picture. Remove it with {@link discardPreviewFile} when the preview closes;
* once it went to another app, leave it to the stale-file sweep instead.
*/
export async function cachePreviewFile(
blobId: string,
name: string | undefined,
type: string | undefined,
// Owning account for blobs shared by another principal (Files app).
accountId?: string,
): Promise<File> {
void sweepStaleExportFiles();
previewSeq += 1;
const dir = new Directory(exportsDir(), `${PREVIEW_DIR_PREFIX}${Date.now().toString(36)}-${previewSeq.toString(36)}`);
const filename = safeAttachmentName(name, type);
const url = getDownloadUrl(blobId, filename, type || 'application/octet-stream', accountId);
try {
return await downloadInto(url, new File(dir, filename), dir);
} catch (err) {
try {
if (dir.exists) dir.delete();
} catch { /* the sweep gets it */ }
throw err;
}
}
/** Delete a file from {@link cachePreviewFile} together with its folder. */
export function discardPreviewFile(file: File): void {
try {
const dir = file.parentDirectory;
if (dir.name.startsWith(PREVIEW_DIR_PREFIX)) {
if (dir.exists) dir.delete();
return;
}
} catch { /* fall back to the file alone */ }
deleteQuietly(file);
}
/** Write bytes into the exports cache (caller shares / previews / cleans up). */
export function writeTempFile(bytes: Uint8Array, filename: string, mimeType?: string): File {
void sweepStaleExportFiles();
@@ -285,13 +362,15 @@ export function writeTempFile(bytes: Uint8Array, filename: string, mimeType?: st
/**
* Share an already-materialised local file (zip bundles, extracted parts).
* `forceSheet` skips the Android viewer handoff and always shows the share
* sheet - the explicit "Share" action, as opposed to "Open".
* sheet - the explicit "Share" action, as opposed to "Open". `keep` leaves the
* file in place afterwards, for a caller that still shows it (an open
* preview) and removes it itself.
*/
export async function shareLocalFile(
file: File,
mimeType: string,
dialogTitle?: string,
opts: { forceSheet?: boolean } = {},
opts: { forceSheet?: boolean; keep?: boolean } = {},
): Promise<void> {
if (!opts.forceSheet && Platform.OS === 'android' && (await openWithViewer(file, mimeType))) return;
if (!(await Sharing.isAvailableAsync())) {
@@ -300,7 +379,7 @@ export async function shareLocalFile(
try {
await Sharing.shareAsync(file.uri, { mimeType, dialogTitle: dialogTitle ?? file.name });
} finally {
scheduleTempCleanup(file);
if (!opts.keep) scheduleTempCleanup(file);
}
}
+110
View File
@@ -0,0 +1,110 @@
// 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.
import { previewKindFor, type PreviewKind } from './attachment-display';
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.
*/
inlinePdf: boolean;
}
interface PreviewableFile {
name: string;
type?: string | null;
size?: number | null;
}
/**
* 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.
*/
export const FILE_PREVIEW_MAX_BYTES: Record<Exclude<PreviewKind, 'none'>, number> = {
text: 512 * 1024,
eml: 10 * 1024 * 1024,
image: 25 * 1024 * 1024,
pdf: 50 * 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();
}
/** 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 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;
}
}
/** True when a known size is over the preview cap for `kind`. Unknown sizes pass; the caller checks the download. */
export function exceedsPreviewLimit(kind: PreviewKind, size: number | null | undefined): boolean {
if (kind === 'none' || size == null) return false;
return size > FILE_PREVIEW_MAX_BYTES[kind];
}
/**
* 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).
*/
export function inAppPreviewKind(file: PreviewableFile, opts: FilePreviewOptions): PreviewKind {
const kind = filePreviewKind(file, opts);
return exceedsPreviewLimit(kind, file.size) ? 'none' : kind;
}