diff --git a/docs/channels.md b/docs/channels.md index f87cd3cc..2158e5cd 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -284,5 +284,14 @@ and 320px tall without upscaling. Missing/invalid dimensions use a stable 360:32 that shrinks with the available width; the image is contained without cropping or upscaling. Unknown-size images may therefore have empty space in the frame. Loading, failure, or retry does not resize it or force an above-bottom reader to the newest row. +Valid message-carried `imeta blurhash` is decoded locally into a 32×32 canvas in +that same frame when it intersects the viewport. No thumbnail is fetched. The +preview is removed entirely (including behind transparency) only after the lazy +original decodes; failure retains the preview. Missing/invalid hashes or canvas +failures keep the existing background. Syntax validation bounds hashes to 166 +base83 characters / 9×9 components; folding does no pixel work. Preview work is +per-mounted-image and uncached, visibility-gated even in nonvirtualized threads. +Without IntersectionObserver, only the ordinary placeholder/original is used. +This favors bounded visible work over instant offscreen previews on scrolling. `tests/browser/image-scroll.spec.mjs` covers delayed/failed loads, actual remounts, bottom following, reading anchors and narrow layout in Chromium and WebKit. diff --git a/package.json b/package.json index ec19fb25..2532f3c1 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@tauri-apps/api": "^2.11.1", "@xterm/addon-fit": "0.10.0", "@xterm/xterm": "5.5.0", + "blurhash": "2.0.5", "dockview-react": "8.2.0", "emoji-mart": "5.6.0", "flexlayout-react": "0.10.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bad39e68..55a44e61 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@xterm/xterm': specifier: 5.5.0 version: 5.5.0 + blurhash: + specifier: 2.0.5 + version: 2.0.5 dockview-react: specifier: 8.2.0 version: 8.2.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -333,6 +336,7 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.2.7': resolution: {integrity: sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==} @@ -822,6 +826,9 @@ packages: bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + blurhash@2.0.5: + resolution: {integrity: sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -2092,6 +2099,8 @@ snapshots: bail@2.0.2: {} + blurhash@2.0.5: {} + ccount@2.0.1: {} chai@6.2.2: {} diff --git a/src/features/messages/AttachmentImage.tsx b/src/features/messages/AttachmentImage.tsx new file mode 100644 index 00000000..118ae1e1 --- /dev/null +++ b/src/features/messages/AttachmentImage.tsx @@ -0,0 +1,141 @@ +import { useEffect, useRef, useState } from "react"; +import type { Attachment } from "../relay/contracts"; +import { validatedBlurhash } from "../relay/blurhash"; +import { BLURHASH_SIZE, paintBlurhash } from "./blurhash"; +import styles from "./Messages.module.css"; + +export function AttachmentImage({ + attachment, + url, + source, + onOpenLink, +}: { + attachment: Attachment; + url: string; + source: string; + onOpenLink(url: string): boolean; +}) { + return ( + { + if ( + !event.metaKey && + !event.ctrlKey && + !event.shiftKey && + onOpenLink(url) + ) + event.preventDefault(); + }} + > + {/* Retargeting retires both the DOM pixels and all pending callbacks before + the new source can paint. Session switches also remount the workspace. */} + + + ); +} + +function ImagePixels({ + source, + blurhash, +}: { + source: string; + blurhash: string | undefined; +}) { + const image = useRef(null); + const canvas = useRef(null); + const [ready, setReady] = useState(false); + const hash = validatedBlurhash(blurhash); + useEffect(() => { + const original = image.current; + if (!original) return; + let active = true; + let decoded = false; + let painted = false; + let decoding = false; + let observer: IntersectionObserver | undefined; + const loaded = async () => { + if (!active || decoding || decoded || !original.naturalWidth) return; + decoding = true; + try { + await original.decode(); + if (!active) return; + decoded = true; + observer?.disconnect(); + setReady(true); + } catch { + // Keep the blur preview on original failure, including decode rejection. + } finally { + decoding = false; + } + }; + original.addEventListener("load", loaded); + if (original.complete) void loaded(); + const preview = canvas.current; + if (hash && preview && typeof IntersectionObserver !== "undefined") { + // Original requests keep native loading="lazy". Only preview CPU work is + // visibility-gated, including nonvirtualized thread rows. No new scheduler. + observer = new IntersectionObserver((entries) => { + if ( + !active || + decoded || + painted || + !entries.some((entry) => entry.isIntersecting) + ) + return; + observer?.disconnect(); + painted = true; + paintBlurhash(preview, hash); + }); + observer.observe(preview); + } + // Without IntersectionObserver, retain the old placeholder rather than + // eagerly decode every mounted thread attachment. + return () => { + active = false; + original.removeEventListener("load", loaded); + observer?.disconnect(); + }; + }, [hash]); + return ( + <> + {hash && !ready && ( +