Compare commits
1
Commits
502b9dfd83
...
f5d3c43b6c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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