fix(viewer): thread media steps through the thread; add Copy image (#164)
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

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
This commit is contained in:
Lotus CI
2026-09-26 19:02:49 -04:00
co-authored by Claude Opus 5.5
parent 502b9dfd83
commit f5d3c43b6c
4 changed files with 220 additions and 11 deletions
+61
View File
@@ -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<HTMLElement>;
onTouchStart: React.TouchEventHandler<HTMLElement>;
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 (
<Box
@@ -292,6 +300,24 @@ export function Lightbox({
);
const toggleZoom = useCallback(() => 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<string>();
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({
</IconButton>
</Box>
)}
{isImage && canCopyImage() && (
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>
{copyState === 'copied' && 'Copied'}
{copyState === 'failed' && 'Could not copy image'}
{copyState === 'idle' && 'Copy image'}
</Text>
</Tooltip>
}
>
{(ref) => (
<IconButton
ref={ref}
variant="Surface"
aria-label={copyState === 'copied' ? 'Image copied' : 'Copy image'}
onClick={handleCopyImage}
disabled={!mediaUrl}
>
<Icon
src={
(copyState === 'copied' && Icons.Check) ||
(copyState === 'failed' && Icons.Warning) ||
Icons.Photo
}
/>
</IconButton>
)}
</TooltipProvider>
)}
{item.mxcUrl && (
<TooltipProvider
position="Bottom"
@@ -512,6 +572,7 @@ export function Lightbox({
onMouseDown={onMouseDown}
onTouchStart={onTouchStart}
onImageDoubleClick={toggleZoom}
onUrl={setMediaUrl}
/>
</Box>
{index < items.length - 1 && (
+44 -11
View File
@@ -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<string>();
const [editHistoryEvent, setEditHistoryEvent] = useState<MatrixEvent | undefined>();
// [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<string | undefined>();
const linkifyOpts = useMemo<LinkifyOpts>(
@@ -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<string>();
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 && (
<RoomMediaLightbox
room={room}
eventId={lightboxEventId}
{lightboxEventId && lightboxIndex !== -1 && (
<Lightbox
items={lightboxItems}
initialIndex={lightboxIndex}
useAuthentication={useAuthentication}
onClose={() => setLightboxEventId(undefined)}
onJump={handleLightboxJump}
/>
)}
</Box>
+72
View File
@@ -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<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/);
});
+43
View File
@@ -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<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 })]);
}