Files
cinny/src/app/utils/copyImage.test.ts
T
Lotus CIandClaude Opus 5.5 f5d3c43b6c
CI / Build & Quality Checks (push) Successful in 4m39s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 12s
CI / Playwright smoke (e2e) (push) Successful in 11m58s
CI / Trigger Desktop Build (push) Successful in 8s
fix(viewer): thread media steps through the thread; add Copy image (#164)
Thread images opened the room media lightbox, which looks the event up in
the room's detached media timeline. Thread replies never reach that
timeline, so a thread image always showed "1 / 1" with no prev/next, after
paging the room's media up to six times. The thread panel now builds the
viewer's items from its own root + loaded replies, and "Go to message"
scrolls the thread panel instead of the room.

The viewer gains a "Copy image" button: fetches the displayed media (blob
URL for E2EE, authenticated URL otherwise), re-encodes to PNG when needed,
and writes it via ClipboardItem with a promise so Safari keeps the click's
user activation. Hidden where ClipboardItem is missing. No "open in new
tab": an E2EE blob URL is revoked when the viewer closes and authenticated
media 401s in a bare tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-26 19:02:49 -04:00

73 lines
2.3 KiB
TypeScript

import { afterEach, test } from 'node:test';
import assert from 'node:assert/strict';
import { canCopyImage, copyImageFromUrl, toPngBlob } from './copyImage';
const g = globalThis as Record<string, unknown>;
const saved = {
ClipboardItem: Object.getOwnPropertyDescriptor(g, 'ClipboardItem'),
navigator: Object.getOwnPropertyDescriptor(g, 'navigator'),
fetch: Object.getOwnPropertyDescriptor(g, 'fetch'),
};
const stub = (key: keyof typeof saved, value: unknown) =>
Object.defineProperty(g, key, { value, configurable: true, writable: true });
afterEach(() => {
(Object.keys(saved) as (keyof typeof saved)[]).forEach((key) => {
const desc = saved[key];
if (desc) Object.defineProperty(g, key, desc);
else delete g[key];
});
});
class FakeClipboardItem {
constructor(public data: Record<string, Promise<Blob>>) {}
}
test('canCopyImage is false without ClipboardItem', () => {
stub('ClipboardItem', undefined);
assert.equal(canCopyImage(), false);
});
test('canCopyImage is true with ClipboardItem and clipboard.write', () => {
stub('ClipboardItem', FakeClipboardItem);
stub('navigator', { clipboard: { write: async () => undefined } });
assert.equal(canCopyImage(), true);
});
test('toPngBlob passes a PNG through untouched', async () => {
const png = new Blob(['x'], { type: 'image/png' });
assert.equal(await toPngBlob(png), png);
});
test('copyImageFromUrl writes a single image/png item', async () => {
const png = new Blob(['x'], { type: 'image/png' });
stub('fetch', async () => ({ ok: true, blob: async () => png }));
stub('ClipboardItem', FakeClipboardItem);
const written: FakeClipboardItem[][] = [];
stub('navigator', {
clipboard: {
write: async (items: FakeClipboardItem[]) => {
written.push(items);
},
},
});
await copyImageFromUrl('blob:x');
assert.equal(written.length, 1);
assert.deepEqual(Object.keys(written[0][0].data), ['image/png']);
assert.equal(await written[0][0].data['image/png'], png);
});
test('copyImageFromUrl rejects when the fetch fails', async () => {
stub('fetch', async () => ({ ok: false, status: 404 }));
stub('ClipboardItem', FakeClipboardItem);
stub('navigator', {
clipboard: {
write: async (items: FakeClipboardItem[]) => {
await items[0].data['image/png'];
},
},
});
await assert.rejects(copyImageFromUrl('blob:x'), /404/);
});