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
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:
co-authored by
Claude Opus 5.5
parent
502b9dfd83
commit
f5d3c43b6c
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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 })]);
|
||||
}
|
||||
Reference in New Issue
Block a user