diff --git a/src/app/features/room/MediaGallery.tsx b/src/app/features/room/MediaGallery.tsx index c11eea64e..044bf90b2 100644 --- a/src/app/features/room/MediaGallery.tsx +++ b/src/app/features/room/MediaGallery.tsx @@ -41,6 +41,7 @@ import { stopPropagation } from '../../utils/keyboard'; import * as css from './MediaGallery.css'; import { useTimestampFormatter } from '../../hooks/useTimestampFormatter'; import { formatRelativeAge } from '../../utils/formatTimestamp'; +import { canCopyImage, copyImageFromUrl } from '../../utils/copyImage'; type GalleryTab = 'image' | 'video' | 'file' | 'audio'; @@ -160,6 +161,7 @@ function LightboxMedia({ onMouseDown, onTouchStart, onImageDoubleClick, + onUrl, }: { item: LightboxItem; useAuthentication: boolean; @@ -169,6 +171,7 @@ function LightboxMedia({ onMouseDown: React.MouseEventHandler; onTouchStart: React.TouchEventHandler; onImageDoubleClick: () => void; + onUrl: (url: string | undefined) => void; }) { const mx = useMatrixClient(); const media = useDecryptedMediaUrl( @@ -178,6 +181,11 @@ function LightboxMedia({ useAuthentication, item.mimeType, ); + const readyUrl = media.status === 'ok' ? media.url : undefined; + useEffect(() => { + onUrl(readyUrl); + return () => onUrl(undefined); + }, [readyUrl, onUrl]); return ( setZoom((z) => (z === 1 ? 2 : 1)), [setZoom]); + // [Gitea #164] Copy image. No "open in new tab": an E2EE image is a blob URL + // revoked when the viewer closes, and authenticated media 401s in a bare tab. + const [mediaUrl, setMediaUrl] = useState(); + const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle'); + useEffect(() => { + if (copyState === 'idle') return undefined; + const t = window.setTimeout(() => setCopyState('idle'), 2000); + return () => window.clearTimeout(t); + }, [copyState]); + useEffect(() => setCopyState('idle'), [index]); + const handleCopyImage = useCallback(() => { + if (!mediaUrl) return; + copyImageFromUrl(mediaUrl).then( + () => setCopyState('copied'), + () => setCopyState('failed'), + ); + }, [mediaUrl]); + // Reset zoom when navigating to another item (and thus pan, via usePan). useEffect(() => { setZoom(1); @@ -409,6 +435,40 @@ export function Lightbox({ )} + {isImage && canCopyImage() && ( + + + {copyState === 'copied' && 'Copied'} + {copyState === 'failed' && 'Could not copy image'} + {copyState === 'idle' && 'Copy image'} + + + } + > + {(ref) => ( + + + + )} + + )} {item.mxcUrl && ( {index < items.length - 1 && ( diff --git a/src/app/features/room/thread/ThreadTimeline.tsx b/src/app/features/room/thread/ThreadTimeline.tsx index e9cc9848e..b24101603 100644 --- a/src/app/features/room/thread/ThreadTimeline.tsx +++ b/src/app/features/room/thread/ThreadTimeline.tsx @@ -73,7 +73,7 @@ import { useSetting } from '../../../state/hooks/settings'; import { MessageLayout, settingsAtom } from '../../../state/settings'; import { Message, Reactions, EncryptedContent } from '../message'; import { RenderMessageContent } from '../../../components/RenderMessageContent'; -import { RoomMediaLightbox } from '../RoomMediaLightbox'; +import { Lightbox, toLightboxItems } from '../MediaGallery'; import { Image } from '../../../components/media'; import { ImageViewer } from '../../../components/image-viewer'; import * as css from './ThreadTimeline.css'; @@ -308,7 +308,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) { const [editId, setEditId] = useState(); const [editHistoryEvent, setEditHistoryEvent] = useState(); - // [Gitea #219] Thread images open the room's shared media lightbox too. + // [Gitea #219] Thread images open the shared media lightbox too. const [lightboxEventId, setLightboxEventId] = useState(); const linkifyOpts = useMemo( @@ -627,11 +627,9 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) { ), ); - const handleOpenReply: MouseEventHandler = useCallback( - (evt) => { - const targetId = evt.currentTarget.getAttribute('data-event-id'); - if (!targetId) return; - // best-effort: scroll to referenced event if it is inside the loaded thread window + // best-effort: scroll to an event if it is inside the loaded thread window + const scrollToThreadEvent = useCallback( + (targetId: string) => { let absIndex = -1; let acc = 0; timeline.linkedTimelines.some((tl) => { @@ -654,6 +652,39 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) { [timeline.linkedTimelines, scrollToItem], ); + const handleOpenReply: MouseEventHandler = useCallback( + (evt) => { + const targetId = evt.currentTarget.getAttribute('data-event-id'); + if (targetId) scrollToThreadEvent(targetId); + }, + [scrollToThreadEvent], + ); + + // [Gitea #164] The viewer steps through this thread's media (root + loaded + // replies). Thread replies never reach the room's media timeline, so the + // room lightbox could only ever show a thread image as "1 / 1". + const lightboxItems = useMemo(() => { + if (!lightboxEventId) return []; + const seen = new Set(); + const events = [thread.rootEvent, ...timeline.linkedTimelines.flatMap((tl) => tl.getEvents())] + .filter((ev): ev is MatrixEvent => !!ev) + .filter((ev) => { + const id = ev.getId(); + if (!id || seen.has(id) || ev.isRedacted()) return false; + seen.add(id); + return true; + }); + return toLightboxItems(room, events); + }, [lightboxEventId, thread.rootEvent, timeline.linkedTimelines, room]); + const lightboxIndex = lightboxItems.findIndex((it) => it.eventId === lightboxEventId); + const handleLightboxJump = useCallback( + (id: string) => { + setLightboxEventId(undefined); + scrollToThreadEvent(id); + }, + [scrollToThreadEvent], + ); + const renderMessageContent = useCallback( (mEvent: MatrixEvent, mEventId: string, timelineSet: EventTimelineSet): ReactNode => { // Evaluated lazily so EncryptedContent can re-run it (re-reading getType()) @@ -1051,11 +1082,13 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) { onClose={() => setEditHistoryEvent(undefined)} /> )} - {lightboxEventId && ( - setLightboxEventId(undefined)} + onJump={handleLightboxJump} /> )} diff --git a/src/app/utils/copyImage.test.ts b/src/app/utils/copyImage.test.ts new file mode 100644 index 000000000..c5169a90f --- /dev/null +++ b/src/app/utils/copyImage.test.ts @@ -0,0 +1,72 @@ +import { afterEach, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { canCopyImage, copyImageFromUrl, toPngBlob } from './copyImage'; + +const g = globalThis as Record; +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>) {} +} + +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/); +}); diff --git a/src/app/utils/copyImage.ts b/src/app/utils/copyImage.ts new file mode 100644 index 000000000..94146c9cf --- /dev/null +++ b/src/app/utils/copyImage.ts @@ -0,0 +1,43 @@ +/** + * [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 { + 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((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 { + 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 })]); +}