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 && (
+
+ )}
+
+ >
+ );
+}
diff --git a/src/features/messages/MessageRow.test.tsx b/src/features/messages/MessageRow.test.tsx
index 19808995..3894998d 100644
--- a/src/features/messages/MessageRow.test.tsx
+++ b/src/features/messages/MessageRow.test.tsx
@@ -260,3 +260,30 @@ it.each([
expect(html).toContain('loading="lazy"');
},
);
+
+it("does not bypass the session media resolver to paint an inaccessible attachment", () => {
+ const media = vi.fn(() => undefined);
+ const html = renderToStaticMarkup(
+ false}
+ day={false}
+ retry={undefined}
+ />,
+ );
+ expect(media).toHaveBeenCalledWith("https://image.test/original.png");
+ expect(html).toContain("Image attachment");
+ expect(html).not.toContain("