@@ -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}
/>
)}
+
+
+
+
+
+
+ }
+ />
+
{
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