Files
cinny/src/app/utils/copyImage.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

44 lines
1.5 KiB
TypeScript

/**
* [Gitea #164] Copy an image to the system clipboard.
*
* The async Clipboard API only guarantees `image/png`, so anything else (JPEG,
* WebP, a GIF's first frame) is re-encoded through a canvas first. The blob is
* handed to `ClipboardItem` as a promise so the write starts inside the click's
* user activation — Safari rejects a write that begins after an await.
*/
export function canCopyImage(): boolean {
return (
typeof ClipboardItem !== 'undefined' &&
typeof navigator !== 'undefined' &&
typeof navigator.clipboard?.write === 'function'
);
}
export async function toPngBlob(blob: Blob): Promise<Blob> {
if (blob.type === 'image/png') return blob;
const bitmap = await createImageBitmap(blob);
try {
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('no 2d context');
ctx.drawImage(bitmap, 0, 0);
return await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('png encode failed'))), 'image/png');
});
} finally {
bitmap.close();
}
}
export async function copyImageFromUrl(url: string): Promise<void> {
const png = fetch(url)
.then((res) => {
if (!res.ok) throw new Error(`fetch failed: ${res.status}`);
return res.blob();
})
.then(toPngBlob);
await navigator.clipboard.write([new ClipboardItem({ 'image/png': png })]);
}