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
3 changes: 2 additions & 1 deletion docs/channels.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,8 @@ The port retains the prepared-store implementation and its behavior tests:
Selecting an already-queued catch-up promotes that existing read without adding
a request or resetting its deadline.
- 1,024 profile entries / 2 MiB signed-record budget, narrow row profile selectors,
and a bounded avatar preparation cache. Signature verification yields in batches.
and request-warmed avatars (fetched and decoded, nothing retained; disabled
under the Save-Data preference). Signature verification yields in batches.
- Account/relay-scoped IndexedDB: 64 records / 8 MiB global disk budget, 24-hour
expiry. Cached events are reverified only after fresh roster authorization.
- A 60-second head freshness lease; warm revisits reuse heads without new reads.
Expand Down
84 changes: 62 additions & 22 deletions src/features/relay/media.test.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,84 @@
import { assert, afterEach, expect, it, vi } from "vitest";
import { createMediaPreparation } from "./media";
import { createMediaPreparation, saveData } from "./media";

afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
it("bounds speculation, charges decoded pixels, and releases active image timers on dispose", async () => {
vi.useFakeTimers();
const created: FakeImage[] = [];
class FakeImage {
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
naturalWidth = 100;
naturalHeight = 100;
src = "";
referrerPolicy = "";
decode = vi.fn(async () => {});
constructor() {
created.push(this);
}

class FakeImage {
static created: FakeImage[] = [];
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
naturalWidth = 100;
naturalHeight = 100;
src = "";
referrerPolicy = "";
decode = vi.fn(async () => {});
constructor() {
FakeImage.created.push(this);
}
}

function stubImages() {
FakeImage.created = [];
vi.stubGlobal("Image", FakeImage);
const media = createMediaPreparation({ maxBytes: 100_000, maxEntries: 2 });
return FakeImage.created;
}

const flushDecode = async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
};

it("warms requests without retaining decoded bytes, and releases active image timers on dispose", async () => {
vi.useFakeTimers();
const created = stubImages();
const media = createMediaPreparation();
media.prepare(["a", "b", "c", "d"]);
expect(media.stats()).toMatchObject({ active: 2, queued: 2 });
const first = created[0];
assert.exists(first);
assert.exists(first.onload);
first.onload();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect(media.stats().bytes).toBe(40_000);
await flushDecode();
expect(first.decode).toHaveBeenCalled();
// The freed slot immediately promotes the next queued speculation.
expect(media.stats()).toMatchObject({ active: 2, queued: 1, decoded: 1 });
const second = created[1];
assert.exists(second);
second.naturalWidth = 10000;
second.naturalWidth = 21000;
assert.exists(second.onload);
second.onload();
// Oversized originals are not explicitly decoded just to prepare an avatar.
expect(second.decode).not.toHaveBeenCalled();
media.dispose();
expect(media.stats()).toEqual({ entries: 0, bytes: 0, active: 0, queued: 0 });
expect(media.stats()).toEqual({ active: 0, queued: 0, decoded: 1 });
expect(vi.getTimerCount()).toBe(0);
});

it("skips urls already in flight", () => {
stubImages();
const media = createMediaPreparation();
media.prepare(["a"]);
media.prepare(["a", "a"]);
expect(FakeImage.created).toHaveLength(1);
});

it("Save-Data disables warming but nothing else", () => {
stubImages();
vi.stubGlobal("navigator", { connection: { saveData: true } });
expect(saveData()).toBe(true);
const media = createMediaPreparation();
media.prepare(["a", "b"]);
expect(media.stats()).toEqual({ active: 0, queued: 0, decoded: 0 });
expect(FakeImage.created).toHaveLength(0);

vi.stubGlobal("navigator", { connection: { saveData: false } });
media.prepare(["a"]);
expect(FakeImage.created).toHaveLength(1);

vi.stubGlobal("navigator", {});
expect(saveData()).toBe(false);
});
45 changes: 27 additions & 18 deletions src/features/relay/media.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
import { ByteLru } from "./budget";
/** Small decoded-avatar hot set. Never warms originals/attachments that this renderer doesn't display.
* Natural dimensions account for decoded pixels, not compressed transfer bytes. */
export function createMediaPreparation({
maxBytes = 16 * 1024 * 1024,
maxEntries = 64,
} = {}) {
const images = new ByteLru<HTMLImageElement>(maxEntries, maxBytes);
/** Avatar request warming, the react-native-web Image model: fetch and
* decode() into detached images, then retain nothing. The browser's own HTTP
* and decoded-image caches serve the real <img> mounts. Never warms
* originals/attachments that this renderer doesn't display. */
export function createMediaPreparation() {
const pending = new Set<string>();
let queue: string[] = [];
let disposed = false;
const active = new Set<HTMLImageElement>();
const cancellations = new Map<HTMLImageElement, () => void>();
let decoded = 0;
function pump() {
if (disposed || typeof Image === "undefined") return;
while (active.size < 2 && queue.length) {
Expand All @@ -33,51 +31,49 @@ export function createMediaPreparation({
image.src = "";
finish();
}, 10000);
cancellations.set(image, finish);
image.onerror = finish;
cancellations.set(image, () => finish());
image.onerror = () => finish();
image.onload = () => {
// Do not explicitly decode enormous originals just to prepare an avatar.
const bytes = image.naturalWidth * image.naturalHeight * 4;
if (bytes > maxBytes / 2) {
if (image.naturalWidth * image.naturalHeight * 4 > 8 * 1024 * 1024) {
finish();
return;
}
void image
.decode()
.then(
() => {
if (!disposed) images.set(url, image, bytes);
decoded++;
},
() => {},
)
.finally(finish);
.finally(() => finish());
};
image.referrerPolicy = "no-referrer";
image.src = url;
}
}
return {
prepare(urls: readonly string[]) {
if (disposed || typeof Image === "undefined") return;
if (disposed || saveData() || typeof Image === "undefined") return;
// New intent replaces queued speculation; active requests remain capped at two.
for (const url of queue) pending.delete(url);
queue = [];
for (const url of [...new Set(urls)].slice(0, 24)) {
if (images.get(url) || pending.has(url)) continue;
if (pending.has(url)) continue;
pending.add(url);
queue.push(url);
}
pump();
},
stats: () => ({
...images.stats(),
active: active.size,
queued: queue.length,
decoded,
}),
dispose() {
disposed = true;
queue = [];
images.clear();
for (const image of active) {
image.src = "";
cancellations.get(image)?.();
Expand All @@ -86,3 +82,16 @@ export function createMediaPreparation({
},
};
}

/** The Save-Data preference (Network Information API) opts out of speculative
* media traffic. Demand fetches still happen; only warming is disabled. */
export function saveData(): boolean {
try {
const connection = (
navigator as Navigator & { connection?: { saveData?: boolean } }
).connection;
return connection?.saveData === true;
} catch {
return false;
}
}
3 changes: 2 additions & 1 deletion src/features/relay/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type { ProfileDirectory } from "./profile-directory";
import { parseWindow, windowFilter, type WindowCursor } from "./window";
import { ByteLru, byteSize } from "./budget";
import type { HeadPersistence, SavedHead } from "./persistence";
import { createMediaPreparation } from "./media";
import { createMediaPreparation, saveData } from "./media";

type Listener = () => void;
type WindowState = {
Expand Down Expand Up @@ -921,6 +921,7 @@ export function createChannelStore(
const head = heads.get(channelId);
if (head) prepareMedia(channelId);
if (
saveData() ||
preparing ||
(head && !head.cached && now() - head.savedAt < FRESH_FOR)
)
Expand Down
Loading