From 963f1efc81ed3b26b2d65368ee2c17dcb948349a Mon Sep 17 00:00:00 2001 From: Th-Underscore Date: Wed, 26 Aug 2026 15:55:30 -0400 Subject: [PATCH] feat(message): copy and save message images and link actions in the context menus --- .changeset/message-image-actions.md | 5 + .../components/image-viewer/ImageViewer.tsx | 6 +- .../message/attachment/Attachment.tsx | 1 + .../components/message/modals/Options.css.ts | 8 +- .../message/modals/Options.test.tsx | 224 ++++++++++++++++++ src/app/components/message/modals/Options.tsx | 213 ++++++++++++++++- src/app/features/room/message/Message.tsx | 106 ++++++++- src/app/utils/download.test.ts | 23 ++ src/app/utils/download.ts | 24 +- src/app/utils/mediaUrl.test.ts | 42 ++++ src/app/utils/mediaUrl.ts | 25 +- 11 files changed, 663 insertions(+), 14 deletions(-) create mode 100644 .changeset/message-image-actions.md create mode 100644 src/app/components/message/modals/Options.test.tsx diff --git a/.changeset/message-image-actions.md b/.changeset/message-image-actions.md new file mode 100644 index 0000000000..e434b680d7 --- /dev/null +++ b/.changeset/message-image-actions.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add "Copy Image" and "Save Image" actions for message images (attachments, avatars, URL-preview thumbnails) with smarter save filenames, plus "Copy URL" / "Open Link" on links. diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index b33ac6bed5..62c3b2ae78 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -157,7 +157,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( const galleryMimeType = info?.mimetype?.toLowerCase(); // On iOS the primary action saves trusted images straight to Photos (PhotoKit). const iosSaveToPhotos = iosApp() && (galleryMimeType?.startsWith('image/') ?? false); - const downloadFilename = getDownloadFilename(filename, alt, 'image'); + const downloadFilename = getDownloadFilename(filename, alt, 'image', galleryMimeType); const canSaveToGallery = isAndroidTauri() && (galleryMimeType?.startsWith('image/') ?? false); const loadDownloadBlob = () => (getDownloadBlob ? getDownloadBlob() : downloadMedia(src)); @@ -188,7 +188,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( } await saveFileToDevice( fileContent, - downloadFilename, + getDownloadFilename(filename, alt, 'image', fileContent.type), galleryMimeType || fileContent.type || undefined ); }; @@ -347,7 +347,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( {...copyImageActivation} > - Copy image + Copy Image {canSaveToGallery && ( diff --git a/src/app/components/message/attachment/Attachment.tsx b/src/app/components/message/attachment/Attachment.tsx index d0c737caba..f05c6f2c58 100644 --- a/src/app/components/message/attachment/Attachment.tsx +++ b/src/app/components/message/attachment/Attachment.tsx @@ -7,6 +7,7 @@ export const Attachment = as<'div', css.AttachmentVariants>( ({ + mxcUrlToHttp: vi.fn<() => string | undefined>(() => 'https://media.example/resolved'), + downloadMedia: vi.fn<() => Promise>(async () => new Blob(['img'], { type: 'image/png' })), + downloadEncryptedMedia: vi.fn< + (src: string, decrypt: (buf: ArrayBuffer) => Promise) => Promise + >((_src, decrypt) => decrypt(new ArrayBuffer(8))), + decryptFile: vi.fn<(buf: ArrayBuffer, type: string) => Promise>( + async (_buf, type) => new Blob(['dec'], { type }) + ), +})); + +vi.mock('$utils/dom', () => ({ + copyToClipboard: vi.fn<() => Promise>(async () => true), + copyImageToClipboard: vi.fn<() => Promise>(async () => true), +})); + +vi.mock('$utils/download', () => ({ + getDownloadFilename: vi.fn< + (filename: unknown, body?: unknown, fallback?: string, mimeType?: string) => string + >((filename, body, fallback) => { + const primary = typeof filename === 'string' && filename ? filename : undefined; + const secondary = typeof body === 'string' && body ? body : undefined; + return primary ?? secondary ?? fallback ?? 'image'; + }), + saveFileToDevice: vi.fn< + (input: Blob | string, filename: string, mimeType?: string) => Promise + >(async () => 'saved'), +})); + +const mx = {} as MatrixClient; +const encFile = { + url: 'mxc://example.org/enc', + key: { kty: 'oct', k: 'key', alg: 'A256CTR' }, + iv: 'iv', + hashes: { sha256: 'hash' }, + v: 'v2', +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('copyMessageImageToClipboard', () => { + it('downloads and copies an unencrypted mxc image', async () => { + await copyMessageImageToClipboard(mx, true, { + msgtype: 'm.image', + url: 'mxc://example.org/a', + info: { mimetype: 'image/jpeg' }, + }); + + expect(matrix.mxcUrlToHttp).toHaveBeenCalledWith(mx, 'mxc://example.org/a', true); + expect(matrix.downloadMedia).toHaveBeenCalledWith('https://media.example/resolved'); + expect(matrix.downloadEncryptedMedia).not.toHaveBeenCalled(); + expect(dom.copyImageToClipboard).toHaveBeenCalledWith(expect.any(Blob)); + }); + + it('decrypts and copies an encrypted image', async () => { + await copyMessageImageToClipboard(mx, true, { + msgtype: 'm.image', + url: 'mxc://example.org/a', + file: encFile, + info: { mimetype: 'image/png' }, + }); + + expect(matrix.downloadMedia).not.toHaveBeenCalled(); + expect(matrix.downloadEncryptedMedia).toHaveBeenCalledTimes(1); + expect(matrix.decryptFile).toHaveBeenCalledWith(expect.any(ArrayBuffer), 'image/png', encFile); + expect(dom.copyImageToClipboard).toHaveBeenCalledWith(expect.any(Blob)); + }); + + it('falls back to the default mimetype when the info block is missing', async () => { + await copyMessageImageToClipboard(mx, true, { + msgtype: 'm.image', + url: 'mxc://example.org/a', + file: encFile, + }); + + expect(matrix.decryptFile).toHaveBeenCalledWith( + expect.any(ArrayBuffer), + FALLBACK_MIMETYPE, + encFile + ); + }); + + it('does nothing for non-mxc urls', async () => { + await copyMessageImageToClipboard(mx, true, { + msgtype: 'm.image', + url: 'https://example.org/not-media.png', + }); + + expect(matrix.mxcUrlToHttp).not.toHaveBeenCalled(); + expect(matrix.downloadMedia).not.toHaveBeenCalled(); + expect(dom.copyImageToClipboard).not.toHaveBeenCalled(); + }); + + it('throws when the clipboard write fails', async () => { + vi.mocked(dom.copyImageToClipboard).mockResolvedValueOnce(false); + + await expect( + copyMessageImageToClipboard(mx, true, { + msgtype: 'm.image', + url: 'mxc://example.org/a', + }) + ).rejects.toThrow('Failed to write to clipboard'); + }); +}); + +describe('saveMessageImageToDevice', () => { + it('downloads and saves an image with a filename from the content', async () => { + await saveMessageImageToDevice(mx, true, { + msgtype: 'm.image', + url: 'mxc://example.org/a', + info: { filename: 'photo.png' }, + }); + + expect(matrix.downloadMedia).toHaveBeenCalledWith('https://media.example/resolved'); + expect(download.getDownloadFilename).toHaveBeenCalledWith( + 'photo.png', + undefined, + 'image', + 'image/png' + ); + expect(download.saveFileToDevice).toHaveBeenCalledWith(expect.any(Blob), 'photo.png'); + }); + + it('does nothing for non-mxc urls', async () => { + await saveMessageImageToDevice(mx, true, { + msgtype: 'm.image', + url: 'https://example.org/not-media.png', + }); + + expect(matrix.downloadMedia).not.toHaveBeenCalled(); + expect(download.saveFileToDevice).not.toHaveBeenCalled(); + }); +}); + +describe('copyImageFromSrcToClipboard', () => { + it('downloads the given src and copies it', async () => { + await copyImageFromSrcToClipboard('https://example.org/image.png'); + + expect(matrix.downloadMedia).toHaveBeenCalledWith('https://example.org/image.png'); + expect(dom.copyImageToClipboard).toHaveBeenCalledWith(expect.any(Blob)); + }); + + it('throws when the clipboard write fails', async () => { + vi.mocked(dom.copyImageToClipboard).mockResolvedValueOnce(false); + + await expect(copyImageFromSrcToClipboard('https://example.org/image.png')).rejects.toThrow( + 'Failed to write to clipboard' + ); + }); +}); + +describe('saveImageFromSrcToDevice', () => { + it('downloads the given src and saves the URL basename as the filename', async () => { + await saveImageFromSrcToDevice('https://example.org/path/to/photo.png?w=100'); + + expect(matrix.downloadMedia).toHaveBeenCalledWith( + 'https://example.org/path/to/photo.png?w=100' + ); + expect(download.saveFileToDevice).toHaveBeenCalledWith(expect.any(Blob), 'photo.png'); + }); + + it('decodes a sable-media src and saves the media id as the filename', async () => { + const encoded = encodeURIComponent( + 'https://media.example/_matrix/client/v1/media/download/server/2026-08-09_abc?allow_redirect=true' + ); + const src = `sable-media://${encoded}`; + + await saveImageFromSrcToDevice(src); + + expect(matrix.downloadMedia).toHaveBeenCalledWith(src); + expect(download.saveFileToDevice).toHaveBeenCalledWith(expect.any(Blob), '2026-08-09_abc'); + }); + + it('decodes a sable-media.localhost src and saves the media id as the filename', async () => { + const encoded = encodeURIComponent( + 'https://media.example/_matrix/client/v1/media/download/server/2026-08-09_abc?allow_redirect=true' + ); + const src = `https://sable-media.localhost/${encoded}?__sable_media_cache=3&__sable_media_session=anon`; + + await saveImageFromSrcToDevice(src); + + expect(matrix.downloadMedia).toHaveBeenCalledWith(src); + expect(download.saveFileToDevice).toHaveBeenCalledWith(expect.any(Blob), '2026-08-09_abc'); + }); +}); + +describe('resolveImageSaveFilename', () => { + const src = 'https://sable-media.localhost/https%3A%2F%2Fmedia.example%2Fimg%2F2026-08-09_abc'; + + it('prefers the image title over the URL basename', () => { + expect( + resolveImageSaveFilename( + { isAttachment: false, src, title: 'Six Moments musicaux op. 16 | HN1492' }, + 'image/jpeg' + ) + ).toBe('Six Moments musicaux op. 16 | HN1492'); + }); + + it('falls back to the URL basename when there is no title', () => { + expect(resolveImageSaveFilename({ isAttachment: false, src }, 'image/jpeg')).toBe( + '2026-08-09_abc' + ); + }); + + it('uses the generic fallback for a deliberate message menu open', () => { + expect(resolveImageSaveFilename('message', 'image/png')).toBe('image'); + }); +}); diff --git a/src/app/components/message/modals/Options.tsx b/src/app/components/message/modals/Options.tsx index 86c8890e25..93da76e118 100644 --- a/src/app/components/message/modals/Options.tsx +++ b/src/app/components/message/modals/Options.tsx @@ -4,7 +4,7 @@ import type { RoomPinnedEventsEventContent, StateEvents, } from '$types/matrix-sdk'; -import { type Room, type MatrixEvent, type Relations, EventType } from '$types/matrix-sdk'; +import { type Room, type MatrixEvent, type Relations, EventType, MsgType } from '$types/matrix-sdk'; import { canEditEvent, canForwardEvent, @@ -47,9 +47,14 @@ import { MessageDeleteItem } from './MessageDelete'; import FocusTrap from 'focus-trap-react'; import { stopPropagation } from '$utils/keyboard'; import { modalAtom, ModalType, pushModalAtom } from '$state/modal'; -import { copyToClipboard } from '$utils/dom'; +import { copyImageToClipboard, copyToClipboard } from '$utils/dom'; import { getMatrixToRoomEvent } from '$plugins/matrix-to'; import { getViaServers } from '$plugins/via-servers'; +import { decryptFile, downloadEncryptedMedia, downloadMedia, mxcUrlToHttp } from '$utils/matrix'; +import { getTauriMediaHttpTarget } from '$utils/mediaUrl'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { FALLBACK_MIMETYPE } from '$utils/mimeTypes'; +import { showToast } from '$state/toast'; import { useRoomPinnedEvents } from '$hooks/useRoomPinnedEvents'; import { EmojiBoard } from '$components/emoji-board'; import { MemoizedBody, type ReactionHandler } from '$features/room/message'; @@ -61,13 +66,16 @@ import { useBookmarkActions, useIsBookmarked, } from '$features/bookmarks'; -import { CopyIcon } from '@phosphor-icons/react'; +import { CopyIcon, DownloadIcon } from '@phosphor-icons/react'; import * as OptionsCss from './Options.css'; +import { getDownloadFilename, saveFileToDevice } from '$utils/download'; import { MATRIX_SABLE_UNSTABLE_FAVORITE_GIFS, MATRIX_UNSTABLE_PER_MESSAGE_PROFILE_PROPERTY_NAME, } from '$unstable/prefixes'; import { useFavoriteGifs } from '$hooks/useFavoriteGifs'; +import type { IEncryptedFile } from '$types/matrix/common'; +import { getIncomingMediaMxcUrl } from '../MsgTypeRenderers'; import { getFavoriteGifFromMessageContent } from '$utils/favoriteGif'; import { TemporaryPersonaPicker } from '$features/room/persona-picker/PersonaPicker'; import { type PerMessageProfileMsc4461 } from '$hooks/usePerMessageProfile'; @@ -198,6 +206,172 @@ const MessageCopyTextItem = as< ); }); +export type ImageMenuContext = { isAttachment: boolean; src: string; title?: string } | 'message'; + +const contentFilename = ( + content: Record +): { filename?: string; body?: string } => { + const info = content.info as { filename?: string } | undefined; + return { + filename: + info?.filename ?? (typeof content.filename === 'string' ? content.filename : undefined), + body: typeof content.body === 'string' ? content.body : undefined, + }; +}; + +const filenameFromSrc = (src: string): string | undefined => { + const url = getTauriMediaHttpTarget(src); + if (!url) return undefined; + try { + const pathname = new URL(url).pathname; + const segments = pathname.split('/'); + const segment = decodeURIComponent(segments[segments.length - 1] || ''); + return segment || undefined; + } catch { + return undefined; + } +}; + +export const downloadMessageImage = async ( + mx: MatrixClient, + useAuthentication: boolean, + content: Record +): Promise => { + const mxc = getIncomingMediaMxcUrl( + (content.file as { url?: unknown } | undefined)?.url ?? content.url + ); + if (!mxc) return null; + const src = mxcUrlToHttp(mx, mxc, useAuthentication); + if (!src) return null; + const encInfo = content.file as IEncryptedFile | undefined; + if (encInfo) + return downloadEncryptedMedia(src, (buf) => + decryptFile( + buf, + (content.info as { mimetype?: string } | undefined)?.mimetype ?? FALLBACK_MIMETYPE, + encInfo + ) + ); + return downloadMedia(src); +}; + +export const copyMessageImageToClipboard = async ( + mx: MatrixClient, + useAuthentication: boolean, + content: Record +): Promise => { + const blob = await downloadMessageImage(mx, useAuthentication, content); + if (!blob) return; + const copied = await copyImageToClipboard(blob); + if (!copied) throw new Error('Failed to write to clipboard'); +}; + +export const saveMessageImageToDevice = async ( + mx: MatrixClient, + useAuthentication: boolean, + content: Record +): Promise => { + const blob = await downloadMessageImage(mx, useAuthentication, content); + if (!blob) return; + const { filename, body } = contentFilename(content); + await saveFileToDevice(blob, getDownloadFilename(filename, body, 'image', blob.type)); +}; + +export const copyImageFromSrcToClipboard = async (src: string): Promise => { + const blob = await downloadMedia(src); + const copied = await copyImageToClipboard(blob); + if (!copied) throw new Error('Failed to write to clipboard'); +}; + +export const saveImageFromSrcToDevice = async (src: string, title?: string): Promise => { + const blob = await downloadMedia(src); + await saveFileToDevice( + blob, + resolveImageSaveFilename({ isAttachment: false, src, title }, blob.type) + ); +}; + +export const resolveImageSaveFilename = ( + imageMenuContext: Exclude, + blobType: string +): string => { + if (imageMenuContext === 'message') { + return getDownloadFilename(undefined, undefined, 'image', blobType); + } + return getDownloadFilename( + imageMenuContext.title, + filenameFromSrc(imageMenuContext.src), + 'image', + blobType + ); +}; + +type MessageImageAction = 'copy' | 'save'; + +const MessageImageActionItem = as< + 'button', + { + mEvent: MatrixEvent; + onClose: () => void; + imageMenuContext: Exclude; + action: MessageImageAction; + } +>(({ mEvent, onClose, imageMenuContext, action, ...props }, ref) => { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const content = mEvent.getContent(); + const isImageMessage = + content.msgtype === MsgType.Image && + getIncomingMediaMxcUrl((content.file as { url?: unknown } | undefined)?.url ?? content.url) !== + undefined; + const useContentPath = + (imageMenuContext === 'message' || imageMenuContext.isAttachment) && isImageMessage; + + const isCopy = action === 'copy'; + + const runAction = async () => { + if (useContentPath) { + if (isCopy) await copyMessageImageToClipboard(mx, useAuthentication, content); + else await saveMessageImageToDevice(mx, useAuthentication, content); + return; + } + if (imageMenuContext === 'message') return; + if (isCopy) await copyImageFromSrcToClipboard(imageMenuContext.src); + else await saveImageFromSrcToDevice(imageMenuContext.src, imageMenuContext.title); + }; + + const handleAction = () => { + onClose(); + document.body.classList.add(OptionsCss.BusyCursor); + void runAction() + .then(() => { + if (isCopy) showToast('Image copied to clipboard'); + }) + .catch((error) => { + const message = error instanceof Error ? error.message : 'unknown error'; + showToast(isCopy ? `Failed to copy image: ${message}` : `Failed to save image: ${message}`); + }) + .finally(() => { + document.body.classList.remove(OptionsCss.BusyCursor); + }); + }; + + return ( + + + {isCopy ? 'Copy Image' : 'Save Image'} + + + ); +}); + const MessagePinItem = as< 'button', { @@ -442,6 +616,7 @@ export function OptionQuickMenu({ imagePackRooms, setIsEmoji, isGif, + imageMenuContext, }: OptionMenuProps) { const mx = useMatrixClient(); const isThreadedMessage = isThreadRelationEvent(mEvent, mEvent.threadRootId); @@ -548,6 +723,7 @@ export function OptionQuickMenu({ emojiBoardAnchor={menuAnchor} canSendReaction={canSendReaction} isGif={isGif} + imageMenuContext={imageMenuContext} /> } > @@ -604,6 +780,7 @@ export type OptionMenuProps = { setIsEmoji?: Dispatch>; ActualMessage?: ReactNode; isModal?: boolean; + imageMenuContext?: ImageMenuContext | null; closeMessageMenu?: () => void; }; @@ -626,6 +803,7 @@ function OptionMenu({ ActualMessage, isModal, isGif, + imageMenuContext, closeMessageMenu, }: OptionMenuProps) { const mobileSheetClose = useMobileSheetClose(); @@ -643,6 +821,19 @@ function OptionMenu({ getEventEdits(evtTimeline.getTimelineSet(), evtId, mEvent.getType())?.getRelations(); const isEdited = !!edits?.length; const [showPersonaSetting] = useSetting(settingsAtom, 'showPersonaSetting'); + const imageContent = mEvent.getContent(); + const hasCopyableImage = + imageContent.msgtype === MsgType.Image && + getIncomingMediaMxcUrl(imageContent.file?.url ?? imageContent.url) !== undefined; + const resolvedImageContext: ImageMenuContext | null = + imageMenuContext === undefined ? 'message' : imageMenuContext; + const showCopySaveImage = + resolvedImageContext === null + ? false + : resolvedImageContext === 'message' + ? hasCopyableImage + : (resolvedImageContext.isAttachment && hasCopyableImage) || + resolvedImageContext.src.length > 0; const closeAfterHandOff = closeMessageMenu ?? requestClose; @@ -885,6 +1076,22 @@ function OptionMenu({ + {resolvedImageContext !== null && showCopySaveImage && ( + <> + + + + )} {canForwardEvent(mEvent) && ( )} diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 2297045b11..b7995d6995 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -1,6 +1,6 @@ // oxlint-disable no-console import type { RectCords } from 'folds'; -import { Avatar, Box, Chip, Text, Tooltip, as, config, toRem } from 'folds'; +import { Avatar, Box, Chip, Menu, MenuItem, Text, Tooltip, as, config, toRem } from 'folds'; import { TooltipProvider } from '$components/overlay-stack'; import { PopOut } from '$components/overlay-stack'; import type { JSX, KeyboardEventHandler, MouseEventHandler, MouseEvent, ReactNode } from 'react'; @@ -44,7 +44,7 @@ import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import type { MemberPowerTag } from '$types/matrix/room'; import { PowerIcon } from '$components/power'; -import { Info, menuIcon, userFallbackIcon } from '$components/icons/phosphor'; +import { ArrowSquareOut, Info, menuIcon, userFallbackIcon } from '$components/icons/phosphor'; import { getPowerTagIconSrc } from '$hooks/useMemberPowerTag'; import { useSableCosmetics } from '$hooks/useSableCosmetics'; import { SwipeableMessageWrapper, type SwipeActionMode } from '$components/SwipeableMessageWrapper'; @@ -60,7 +60,11 @@ import { convertBeeperFormatToOurPerMessageProfile } from '$hooks/usePerMessageP import { MessageEditor } from './MessageEditor'; import * as css from './styles.css'; import { modalAtom, ModalType } from '$state/modal'; -import { OptionQuickMenu } from '$components/message/modals/Options'; +import { OptionQuickMenu, type ImageMenuContext } from '$components/message/modals/Options'; +import { ResponsiveMenu } from '$components/ResponsiveMenu'; +import { useRenderableMediaUrl } from '$hooks/useRenderableMediaUrl'; +import { copyToClipboard } from '$utils/dom'; +import { CopyIcon } from '@phosphor-icons/react'; export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void; @@ -384,6 +388,7 @@ function MessageInternal( }, []); const [isEmoji, setIsEmoji] = useState(false); + const [imageMenuContext, setImageMenuContext] = useState(null); const setModal = useSetAtom(modalAtom); const [contentVersion, setContentVersion] = useState(0); @@ -525,6 +530,29 @@ function MessageInternal( }, }); + const linkMenu = useMenuAnchor(); + const linkHrefRef = useRef(''); + const handleOpenLink = () => { + const href = linkHrefRef.current; + if (href) { + // Mirror a real link click: window.open doesn't route to the system + // browser from Sable's desktop webview, but a target=_blank anchor does. + const link = document.createElement('a'); + link.href = href; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + link.style.display = 'none'; + document.body.appendChild(link); + link.click(); + link.remove(); + } + linkMenu.close(); + }; + const handleCopyLinkUrl = () => { + if (linkHrefRef.current) copyToClipboard(linkHrefRef.current); + linkMenu.close(); + }; + const tagIconSrc = memberPowerTag?.icon ? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon) : undefined; @@ -884,6 +912,7 @@ function MessageInternal( canPinEvent: canPinEvent, canDelete: canDelete, setIsEmoji: setIsEmoji, + imageMenuContext: 'message', imagePackRooms: imagePackRooms, ActualMessage: (
@@ -904,8 +933,39 @@ function MessageInternal( const contextMenuHandler: MouseEventHandler = (evt) => { if (evt.altKey || !window.getSelection()?.isCollapsed || edit) return; - const tag = (evt.target as HTMLElement).tagName; + const target = evt.target as HTMLElement; + // Image contexts take priority over links, so a right-click on a linked + // image offers Copy/Save Image instead of the URL actions. + const imageTarget = target.closest('[data-message-attachment], img'); + if (!imageTarget) { + const anchor = target instanceof Element ? target.closest('a[href]') : undefined; + if (anchor) { + linkHrefRef.current = anchor.getAttribute('href') ?? ''; + evt.preventDefault(); + evt.stopPropagation(); + linkMenu.triggerProps.onContextMenu(evt); + return; + } + } + const tag = target.tagName; if (typeof tag === 'string' && tag.toLowerCase() === 'a') return; + setImageMenuContext( + imageTarget + ? { + isAttachment: imageTarget.closest('[data-message-attachment]') !== null, + src: + imageTarget instanceof HTMLImageElement + ? (imageTarget.getAttribute('src') ?? '') + : '', + title: + imageTarget instanceof HTMLImageElement + ? (imageTarget.getAttribute('title') ?? + imageTarget.getAttribute('alt') ?? + undefined) + : undefined, + } + : null + ); if (isMobileOrTablet()) { evt.preventDefault(); // The long-press timer already opened the sheet; this is its synthetic follow-up. @@ -918,6 +978,7 @@ function MessageInternal( const handleOpenMenu: MouseEventHandler = (evt) => { const target = evt.currentTarget.parentElement?.parentElement ?? evt.currentTarget; + setImageMenuContext('message'); window.requestAnimationFrame(() => { menu.openAt(target); }); @@ -998,10 +1059,47 @@ function MessageInternal( setIsEmoji={setIsEmoji} canSendReaction={canSendReaction} isGif={isGif} + imageMenuContext={imageMenuContext} />
)} + + + + + Copy URL + + + + + Open Link + + + + + } + /> +
{ vi.clearAllMocks(); }); +describe('getDownloadFilename', () => { + it('appends the MIME extension when the filename has none', () => { + expect( + getDownloadFilename('Six Moments musicaux op. 16', undefined, 'image', 'image/jpeg') + ).toBe('Six Moments musicaux op. 16.jpg'); + }); + + it('keeps the existing extension when present', () => { + expect(getDownloadFilename('photo.png', undefined, 'image', 'image/jpeg')).toBe('photo.png'); + }); + + it('applies the fallback name with the MIME extension when no filename exists', () => { + expect(getDownloadFilename(undefined, undefined, 'image', 'image/png')).toBe('image.png'); + }); + + it('does not append an extension for an unknown MIME type', () => { + expect(getDownloadFilename('cover', undefined, 'image', 'application/octet-stream')).toBe( + 'cover' + ); + }); +}); + describe('saveFileToDevice', () => { it('scans an Android Downloads file after making it public', async () => { const result = await saveFileToDevice(new Blob(['data'], { type: 'text/plain' }), 'file.txt'); diff --git a/src/app/utils/download.ts b/src/app/utils/download.ts index c9478311fc..91def6d603 100644 --- a/src/app/utils/download.ts +++ b/src/app/utils/download.ts @@ -51,11 +51,31 @@ const sanitizeDownloadFilename = (filename: string, fallback = 'download'): stri return safeName; }; +const MIME_EXTENSIONS: Record = { + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/gif': '.gif', + 'image/webp': '.webp', + 'image/avif': '.avif', + 'image/apng': '.png', + 'image/svg+xml': '.svg', +}; + +const HAS_EXTENSION = /\.[a-z0-9]{1,10}$/i; + export const getDownloadFilename = ( filename: unknown, body?: unknown, - fallback = 'download' -): string => sanitizeDownloadFilename(getAttachmentFilename(filename, body, fallback), fallback); + fallback = 'download', + mimeType?: string +): string => { + let name = sanitizeDownloadFilename(getAttachmentFilename(filename, body, fallback), fallback); + if (mimeType) { + const extension = MIME_EXTENSIONS[mimeType.trim().toLowerCase()]; + if (extension && !HAS_EXTENSION.test(name)) name += extension; + } + return name; +}; const splitExtension = (filename: string): [stem: string, extension: string] => { const at = filename.lastIndexOf('.'); diff --git a/src/app/utils/mediaUrl.test.ts b/src/app/utils/mediaUrl.test.ts index 47435af3e9..69e9d7ed83 100644 --- a/src/app/utils/mediaUrl.test.ts +++ b/src/app/utils/mediaUrl.test.ts @@ -31,6 +31,7 @@ vi.mock('./platform', () => ({ import { addMediaRetryRevision, addTauriMediaRetryRevision, + getTauriMediaHttpTarget, getTauriMediaRetryTarget, prepareLoopbackImageSource, prepareLoopbackMedia, @@ -301,6 +302,47 @@ describe('getTauriMediaRetryTarget', () => { expect(getTauriMediaRetryTarget(url, 1)).toBe(`${INNER}#__sable_media_retry=1`); } ); + + describe('getTauriMediaHttpTarget', () => { + const INNER_HTTP = + 'https://matrix.example.com/_matrix/client/v1/media/download/example.com/abc123'; + + it('returns the inner http target for the sable-media protocol form', () => { + expect( + getTauriMediaHttpTarget( + `sable-media://${encodeURIComponent(INNER_HTTP)}?__sable_media_cache=3` + ) + ).toBe(INNER_HTTP); + }); + + it('returns the inner http target for the sable-media.localhost host form', () => { + expect( + getTauriMediaHttpTarget( + `https://sable-media.localhost/${encodeURIComponent(INNER_HTTP)}?__sable_media_cache=3&__sable_media_session=session_abc` + ) + ).toBe(INNER_HTTP); + }); + + it('strips the outer cache markers only', () => { + const innerWithQuery = `${INNER_HTTP}?allow_redirect=true`; + expect( + getTauriMediaHttpTarget( + `https://sable-media.localhost/${encodeURIComponent(innerWithQuery)}?__sable_media_cache=3` + ) + ).toBe(innerWithQuery); + }); + + it('passes through a plain http src', () => { + expect(getTauriMediaHttpTarget('https://example.org/image.png?w=100')).toBe( + 'https://example.org/image.png?w=100' + ); + }); + + it('returns null for non-http srcs', () => { + expect(getTauriMediaHttpTarget('blob:https://example.org/uuid')).toBeNull(); + expect(getTauriMediaHttpTarget('data:image/png;base64,abc')).toBeNull(); + }); + }); }); describe('prepareLoopbackMedia', () => { diff --git a/src/app/utils/mediaUrl.ts b/src/app/utils/mediaUrl.ts index d7dd55bad1..d31fbe8608 100644 --- a/src/app/utils/mediaUrl.ts +++ b/src/app/utils/mediaUrl.ts @@ -76,6 +76,30 @@ export const addTauriMediaRetryRevision = (mediaUrl: string, revision: number): return rewriteAuthenticatedMediaUrl(target) ?? mediaUrl; }; +/** + * Returns the inner http(s) target of a renderable media URL, stripped of the + * outer cache/session markers. Null when the URL isn't a http(s) media URL. + */ +export const getTauriMediaHttpTarget = (src: string): string | null => { + const innerTarget = getTauriMediaSourceUrl(src); + if (!innerTarget) return null; + let url: string; + try { + url = decodeURIComponent(innerTarget); + } catch { + return null; + } + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch { + return null; + } + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') return null; + TAURI_MEDIA_OUTER_QUERY_PARAMS.forEach((param) => parsedUrl.searchParams.delete(param)); + return parsedUrl.toString(); +}; + // Without it the retried `src` is identical, so the browser never re-requests. const addWebMediaRetryRevision = (mediaUrl: string, revision: number): string => { let parsedUrl: URL; @@ -114,7 +138,6 @@ export const prepareLoopbackImageSource = async (source: string): Promise