Skip to content

Files: what each file really is, from its bytes, and viewers in the app for text, code, Markdown, JSON, CSV, PDF and archives - #18

Merged
zyansheep merged 1 commit into
mainfrom
feat/file-viewers
Nov 8, 2026
Merged

zyansheep merged 1 commit into
mainfrom
feat/file-viewers

Conversation

@zyansheep

Copy link
Copy Markdown
Contributor

A file in a message now says what it really is, and opens in a viewer in the app. docs/files.md has the whole story.

Hub: what each kept file is, from its bytes

  • packages/hub/src/fileinfo.ts sniffs magic numbers and content, not the network's type. It records:
    • true mime and kind;
    • size;
    • PDF pages (linearized /N, page-tree /Count, also inside compressed object streams);
    • text encoding, line count and language (name, #!, content);
    • Markdown, JSON and CSV;
    • ZIP, tar and gzip entry counts and the first 50 names and sizes (Office, ODF, EPUB, JAR and APK told apart);
    • image dimensions;
    • duration for MP4, M4A, QuickTime, WebM, Matroska, WAV, FLAC, Ogg and MP3.
  • HTML, SVG and XML are recognised by content and are only ever text.
  • Reads are bounded:
    • 64 KiB for the type, 2 MiB of text;
    • a ZIP's tail and directory;
    • a PDF's first 4 MiB and last 1 MiB;
    • tar headers one at a time;
    • at most 8 MiB out of a gzip (a gzip bomb stops there).
  • Nothing is extracted. No new native dependencies.
  • Results go in a new media_info table (additive), keyed like the media cache.
  • packages/hub/src/mediainfo.ts walks attachments in the background, one file at a time. It keeps a cursor in meta and stays behind the archive's cursor.
  • The archive hands it each file it keeps (first fetch, retry, re-signed Discord link).
  • At most about 4 s of work every 30 s. A demo hub fetches its local demo files itself.
  • GET /api/attachments/:id/info looks at a file now, for the viewer.
  • AttachmentView.info?: FileInfo is additive and optional. PROTOCOL_VERSION stays 1. New packages/protocol/src/files.ts.
  • /media keeps nosniff, the sandbox CSP and the inline allowlist. One change: a file the network called application/octet-stream (or similar) is now served as the type its bytes say. A PNG then shows inline; an HTML page is still a sandboxed download.

Web: viewers, each in a lazy chunk

  • Viewers live in packages/web/src/ui/files/. They are loaded by the shell added to Viewer.tsx:
    • Text and code: monospace, line numbers, wrap, a small built-in highlighter (highlight.ts), 3,000 lines at a time.
    • HTML, SVG, XML: shown as source.
    • Markdown: marked parses only, and its tokens become elements. No raw HTML passthrough, http/https/mailto links only (through the existing link path and the desk helper), remote pictures load only on a click, without a referrer.
    • JSON: a collapsible tree.
    • CSV/TSV: a table.
    • Archives: the hub's entry list.
    • PDF: pdf.js from npm, page by page.
  • Text fetches stop at 2 MB, with a note saying so.
  • The header always has Download. It has Open File when the desk "open files" switch is on or in the Android app, and Open With… when the helper is paired. Both reuse desk.ts; the open code moved from menus.tsx to ui/fileopen.tsx, shared with the menus.
  • Open With… lists apps for the sniffed type, with text editors as a fallback for text.
  • The file menu gains View File.
  • The file card shows the sniffed type, pages, lines or entries, and size. A click opens the viewer.
  • A type with no viewer keeps today's behaviour: the desk or Android app when on, else a download.
  • A file whose bytes are an image, video or audio now renders as one in the timeline.
  • Why pdf.js: I tested the alternative (verify-viewers.ts --probe-pdf). Chromium's built-in viewer does render a /media PDF in a frame despite the sandbox CSP. But the Android app's WebView has no PDF viewer, and Chromium's runs a PDF's own script. pdf.js runs no PDF script, works the same everywhere, and its 446 KB chunk and 1.3 MB worker load only when a PDF is opened.
  • pdfjs-dist lists @napi-rs/canvas (a native Node binary) as optional. The root package.json overrides it with an empty stub, so bun install downloads no native code.
  • Startup bundle: +12 KB JS and +6 KB CSS before gzip, about 7 KB gzipped.
    • The viewers import nothing from the app's modules but Preact.
    • rolldown still splits two small shared chunks off (hooks, JSX runtime), so the page has 2 more modulepreload links.

Demo world

demo.ts adds these files, several with wrong network types as real networks send them:

  • build-482.txt;
  • insert_get.rs, as application/rls-services+xml like Discord sends it;
  • routing-notes.md, as octet-stream, with a table, a code block, raw <script> and a remote image;
  • bench-483.json, as octet-stream;
  • stretch.csv, with quoted commas;
  • dither-sim-0.4.zip;
  • topology.svg, containing a <script>;
  • rent-split.xlsx, a type with no viewer.

Verification

  • bun run typecheck and bun run test pass (all packages).
  • New packages/hub/test/fileinfo.test.ts (31 tests):
    • wrong network types;
    • truncated PNG, JPEG, GIF, MP4, WebM, WAV, Ogg, PDF, ZIP, tar and tar.gz;
    • hostile names: direction overrides, paths, NULs, 5,000-character names, and archive entry names;
    • bounded reads: a 12 MB text, a 96 MB gzip bomb, a 12 MB PDF;
    • the walker's cursor and resume, the archive hand-off;
    • the info endpoint and /media serving the sniffed type with the same headers.
  • New packages/web/test/files.test.ts (12 tests): CSV parsing, highlighting, card text, the guess before analysis.
  • scripts/verify-viewers.ts (new): 73 checks, all pass, on a fresh demo hub.
    • Every viewer opened from its card at 1280×800 and at 390 px, with screenshots.
    • No page errors, nothing wider than the phone screen.
    • An open card updates by itself once the walker reaches it.
    • No viewer chunk loads before a file is opened.
    • No script, frame or object in the Markdown/SVG viewers, nothing ran, the remote picture was not fetched.
    • The xlsx card still downloads.
  • scripts/verify-desk.ts (updated): 23 checks pass.
    • A PDF click now opens the viewer; its header has Open File / Open With… / Download, and Open File reaches the opener.
    • A click on the xlsx (no viewer) still goes straight to the opener.
  • scripts/verify-history.ts: 22 checks pass.
  • scripts/verify-perf.ts:
    • A full run against perf-baseline.json failed on times across the board, including hub.bootstrap.ms (+135%), code this PR doesn't touch. The machine's load average was 12–14.
    • Instead I alternated short runs (--runs 1 --pages 5) of origin/main and this branch, three each. At 1×, medians: cold list 750 vs 758 ms, open 175 vs 182 ms, older page 105 vs 101 ms, long reopen 96 vs 106 ms (one 172 ms outlier), relaunch 420 vs 389 ms. At 4× (earlier round): cold 2326 vs 2367 ms, open 703 vs 674 ms. Everything is within noise and far inside the harness's ×1.25 + 50 ms rule.
    • That A/B found and fixed one real issue. The walker's first pass over a demo world full of data: media didn't yield between batches; it now filters inline media in SQL and yields.
    • DOM node counts are +2 vs origin/main: exactly the two extra modulepreload links. origin/main itself is already 44 over the stored baseline at 1× cold, so I didn't edit the baseline.

Not verified

  • Not run against the live hub's data or files.
  • Not tried in the Android app's WebView, on a real phone, or in Firefox or Safari.
  • PDFs: no text layer (no select or search). Password PDFs show a message.
  • Archives: 7z, RAR, xz, bz2 and zstd are recognised but not listed.
  • The first walk over the live hub's backlog is untimed; it is paced at most 4 s of every 30 s.
  • dither-accounts' packages/chat-protocol needs bun scripts/accounts-sync.ts after merge (new files.ts, AttachmentView.info).

🤖 Generated with Claude Code

…es, lines, entries, size, length), and text, code, Markdown, JSON, CSV, PDF and archives open in viewers of their own in the app

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@zyansheep
zyansheep enabled auto-merge (squash) November 8, 2026 00:13
@zyansheep
zyansheep merged commit d669928 into main Nov 8, 2026
1 check passed
@zyansheep
zyansheep deleted the feat/file-viewers branch November 8, 2026 00:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant