Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/channels.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add the required DCO sign-off

The reviewed commit has no Signed-off-by: trailer, so it violates the repository's per-commit DCO requirement and cannot pass the documented DCO merge gate. Recreate the commit with a sign-off matching its verified effective author identity.

AGENTS.md reference: AGENTS.md:L49-L54

Useful? React with 👍 / 👎.

"dockview-react": "8.2.0",
"emoji-mart": "5.6.0",
"flexlayout-react": "0.10.8",
Expand Down
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

141 changes: 141 additions & 0 deletions src/features/messages/AttachmentImage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<a
className={styles.attachmentImage}
style={
attachment.dimensions
? {
width: Math.min(
360,
attachment.dimensions.width,
(320 * attachment.dimensions.width) /
attachment.dimensions.height,
),
aspectRatio: `${attachment.dimensions.width} / ${attachment.dimensions.height}`,
}
: undefined
}
href={url}
target="_blank"
rel="noreferrer"
aria-label="Open image attachment"
onClick={(event) => {
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. */}
<ImagePixels
key={JSON.stringify([source, attachment.blurhash])}
source={source}
blurhash={attachment.blurhash}
/>
</a>
);
}

function ImagePixels({
source,
blurhash,
}: {
source: string;
blurhash: string | undefined;
}) {
const image = useRef<HTMLImageElement>(null);
const canvas = useRef<HTMLCanvasElement>(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 && (
<canvas
ref={canvas}
width={BLURHASH_SIZE}
height={BLURHASH_SIZE}
tabIndex={-1}
aria-hidden="true"
/>
)}
<img
ref={image}
src={source}
alt=""
loading="lazy"
style={{ visibility: ready ? "visible" : "hidden" }}
/>
</>
);
}
27 changes: 27 additions & 0 deletions src/features/messages/MessageRow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<MessageRow
row={{
...row,
attachments: [
{
url: "https://image.test/original.png",
video: false,
blurhash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj",
},
],
}}
profile={undefined}
media={media}
onOpenLink={() => false}
day={false}
retry={undefined}
/>,
);
expect(media).toHaveBeenCalledWith("https://image.test/original.png");
expect(html).toContain("Image attachment");
expect(html).not.toContain("<canvas");
expect(html).not.toContain("<img");
});
38 changes: 7 additions & 31 deletions src/features/messages/MessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { profileTarget } from "../profiles/target";
import { InlineText } from "../conversation/InlineText";
import type { ConversationExtensions } from "../conversation/contracts";
import type { ChannelMessage, Profile } from "../relay/contracts";
import { AttachmentImage } from "./AttachmentImage";
import { DeliveryNotice } from "./DeliveryNotice";
import { MessageMarkdown } from "./MessageMarkdown";
import { safeMessageUrl } from "../relay/message-content";
Expand Down Expand Up @@ -124,38 +125,13 @@ export const MessageRow = memo(function MessageRow({
{attachment.video ? "Video attachment" : "Image attachment"} ↗
</a>
) : (
<a
className={styles.attachmentImage}
style={
attachment.dimensions
? {
width: Math.min(
360,
attachment.dimensions.width,
(320 * attachment.dimensions.width) /
attachment.dimensions.height,
),
aspectRatio: `${attachment.dimensions.width} / ${attachment.dimensions.height}`,
}
: undefined
}
<AttachmentImage
key={url}
href={url}
target="_blank"
rel="noreferrer"
aria-label="Open image attachment"
onClick={(event) => {
if (
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey &&
onOpenLink(url)
)
event.preventDefault();
}}
>
<img src={source} alt="" loading="lazy" />
</a>
attachment={attachment}
url={url}
source={source}
onOpenLink={onOpenLink}
/>
);
})}
{row.reactions.length > 0 && (
Expand Down
3 changes: 3 additions & 0 deletions src/features/messages/Messages.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -188,12 +188,15 @@
border-radius: 10px;
background: var(--surface-hover);
}
.attachmentImage canvas,
.attachmentImage img {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.attachmentImage img {
object-fit: scale-down;
}
.composer {
Expand Down
54 changes: 54 additions & 0 deletions src/features/messages/blurhash.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { afterEach, expect, it, vi } from "vitest";
import { decode } from "blurhash";
import { BLURHASH_SIZE, paintBlurhash } from "./blurhash";
vi.mock("blurhash", async (original) => ({
...(await original<typeof import("blurhash")>()),
decode: vi.fn((await original<typeof import("blurhash")>()).decode),
}));
afterEach(() => vi.mocked(decode).mockClear());
const hash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj";
function canvas() {
const data = new Uint8ClampedArray(BLURHASH_SIZE ** 2 * 4);
const context = {
createImageData: vi.fn(() => ({ data })),
putImageData: vi.fn(),
};
const element = { getContext: vi.fn(() => context) };
return { element: element as unknown as HTMLCanvasElement, context, data };
}
it("uses the canonical decoder with a fixed tiny raster, with no source dimensions", () => {
const { element, context, data } = canvas();
paintBlurhash(element, hash);
expect(decode).toHaveBeenCalledExactlyOnceWith(hash, 32, 32);
expect(context.putImageData).toHaveBeenCalledOnce();
expect(data.some((value) => value > 0)).toBe(true);
});
it("rejects invalid/oversized input before any canvas or decoder work", () => {
const { element } = canvas();
for (const value of [
"invalid",
"0".repeat(10000),
"~000000000000000000000000000000000000000",
])
paintBlurhash(element, value);
expect(element.getContext).not.toHaveBeenCalled();
expect(decode).not.toHaveBeenCalled();
});
it("contains decoder, unavailable canvas and raster write failures", () => {
const { element, context } = canvas();
vi.mocked(decode).mockImplementationOnce(() => {
throw new Error("decode");
});
expect(() => paintBlurhash(element, hash)).not.toThrow();
expect(context.putImageData).not.toHaveBeenCalled();
context.putImageData.mockImplementationOnce(() => {
throw new Error("canvas");
});
expect(() => paintBlurhash(element, hash)).not.toThrow();
expect(() =>
paintBlurhash(
{ getContext: () => null } as unknown as HTMLCanvasElement,
hash,
),
).not.toThrow();
});
19 changes: 19 additions & 0 deletions src/features/messages/blurhash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { decode } from "blurhash";
import { validatedBlurhash } from "../relay/blurhash";

export const BLURHASH_SIZE = 32;

/** Local, bounded raster only; no URL, source-sized canvas or retained cache. */
export function paintBlurhash(canvas: HTMLCanvasElement, hash: string): void {
if (!validatedBlurhash(hash)) return;
try {
const context = canvas.getContext("2d");
if (!context) return;
const image = context.createImageData(BLURHASH_SIZE, BLURHASH_SIZE);
image.data.set(decode(hash, BLURHASH_SIZE, BLURHASH_SIZE));
context.putImageData(image, 0, 0);
} catch {
// Bad metadata, unavailable canvas or decoder failure keeps the frame's
// existing background. Original loading must never depend on the preview.
}
}
15 changes: 15 additions & 0 deletions src/features/relay/blurhash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Signed imeta is still untrusted. Validate syntax without doing pixel work in
// message folding: at most 9x9 components (166 base83 characters).
const BASE83 =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~";
export function validatedBlurhash(value: unknown): string | undefined {
if (typeof value !== "string" || value.length < 6 || value.length > 166)
return undefined;
for (const character of value) {
if (!BASE83.includes(character)) return undefined;
}
const size = BASE83.indexOf(value.charAt(0));
if (size > 80) return undefined;
const components = ((size % 9) + 1) * (Math.floor(size / 9) + 1);
return value.length === 4 + 2 * components ? value : undefined;
}
Loading
Loading