1
0
mirror of https://github.com/element-hq/element-web.git synced 2025-08-08 03:42:14 +03:00

Add support for module message hint allowDownloadingMedia (#30252)

* Add support for allowDownloadingMedia

* Add tests.

* Allow downloading when no event is associated.

* fix lint

* Update module API

* Update lock file too

* force CI
This commit is contained in:
Will Hunt
2025-07-07 10:03:46 +01:00
committed by GitHub
parent 1cb068a91e
commit 9f313fcc14
7 changed files with 151 additions and 12 deletions

View File

@@ -81,7 +81,7 @@
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "^7.12.5", "@babel/runtime": "^7.12.5",
"@element-hq/element-web-module-api": "1.2.0", "@element-hq/element-web-module-api": "1.3.0",
"@fontsource/inconsolata": "^5", "@fontsource/inconsolata": "^5",
"@fontsource/inter": "^5", "@fontsource/inter": "^5",
"@formatjs/intl-segmenter": "^11.5.7", "@formatjs/intl-segmenter": "^11.5.7",

View File

@@ -6,6 +6,7 @@ Please see LICENSE files in the repository root for full details.
*/ */
import { type Page } from "@playwright/test"; import { type Page } from "@playwright/test";
import fs from "node:fs";
import { test, expect } from "../../element-web-test"; import { test, expect } from "../../element-web-test";
@@ -22,6 +23,9 @@ const screenshotOptions = (page: Page) => ({
} }
`, `,
}); });
const IMAGE_FILE = fs.readFileSync("playwright/sample-files/element.png");
test.describe("Custom Component API", () => { test.describe("Custom Component API", () => {
test.use({ test.use({
displayName: "Manny", displayName: "Manny",
@@ -84,6 +88,50 @@ test.describe("Custom Component API", () => {
await page.getByRole("toolbar", { name: "Message Actions" }).getByRole("button", { name: "Edit" }), await page.getByRole("toolbar", { name: "Message Actions" }).getByRole("button", { name: "Edit" }),
).not.toBeVisible(); ).not.toBeVisible();
}); });
test("should disallow downloading media when the allowDownloading hint is set to false", async ({
page,
room,
app,
}) => {
await app.viewRoomById(room.roomId);
await app.viewRoomById(room.roomId);
const upload = await app.client.uploadContent(IMAGE_FILE, { name: "bad.png", type: "image/png" });
await app.client.sendEvent(room.roomId, null, "m.room.message", {
msgtype: "m.image",
body: "bad.png",
url: upload.content_uri,
});
await app.timeline.scrollToBottom();
const imgTile = page.locator(".mx_MImageBody").first();
await expect(imgTile).toBeVisible();
await imgTile.hover();
await expect(page.getByRole("button", { name: "Download" })).not.toBeVisible();
await imgTile.click();
await expect(page.getByLabel("Image view").getByLabel("Download")).not.toBeVisible();
});
test("should allow downloading media when the allowDownloading hint is set to true", async ({
page,
room,
app,
}) => {
await app.viewRoomById(room.roomId);
await app.viewRoomById(room.roomId);
const upload = await app.client.uploadContent(IMAGE_FILE, { name: "good.png", type: "image/png" });
await app.client.sendEvent(room.roomId, null, "m.room.message", {
msgtype: "m.image",
body: "good.png",
url: upload.content_uri,
});
await app.timeline.scrollToBottom();
const imgTile = page.locator(".mx_MImageBody").first();
await expect(imgTile).toBeVisible();
await imgTile.hover();
await expect(page.getByRole("button", { name: "Download" })).toBeVisible();
await imgTile.click();
await expect(page.getByLabel("Image view").getByLabel("Download")).toBeVisible();
});
test( test(
"should render the next registered component if the filter function throws", "should render the next registered component if the filter function throws",
{ tag: "@screenshot" }, { tag: "@screenshot" },

View File

@@ -5,8 +5,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
// Note: eslint-plugin-jsdoc doesn't like import types as parameters, so we
// get around it with @typedef
/**
* @typedef {import("@element-hq/element-web-module-api").Api} Api
*/
export default class CustomComponentModule { export default class CustomComponentModule {
static moduleApiVersion = "^1.2.0"; static moduleApiVersion = "^1.2.0";
/**
* Basic module for testing.
* @param {Api} api API object
*/
constructor(api) { constructor(api) {
this.api = api; this.api = api;
this.api.customComponents.registerMessageRenderer( this.api.customComponents.registerMessageRenderer(
@@ -40,6 +50,15 @@ export default class CustomComponentModule {
throw new Error("Fail test!"); throw new Error("Fail test!");
}, },
); );
this.api.customComponents.registerMessageRenderer(
(mxEvent) => mxEvent.type === "m.room.message" && mxEvent.content.msgtype === "m.image",
(_props, originalComponent) => {
return originalComponent();
},
{ allowDownloadingMedia: async (mxEvent) => mxEvent.content.body !== "bad.png" },
);
// Order is specific here to avoid this overriding the other renderers // Order is specific here to avoid this overriding the other renderers
this.api.customComponents.registerMessageRenderer("m.room.message", (props, originalComponent) => { this.api.customComponents.registerMessageRenderer("m.room.message", (props, originalComponent) => {
const body = props.mxEvent.content.body; const body = props.mxEvent.content.body;

View File

@@ -8,9 +8,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import React, { type JSX, createRef, type CSSProperties, useRef, useState, useMemo } from "react"; import React, { type JSX, createRef, type CSSProperties, useRef, useState, useMemo, useEffect } from "react";
import FocusLock from "react-focus-lock"; import FocusLock from "react-focus-lock";
import { type MatrixEvent, parseErrorResponse } from "matrix-js-sdk/src/matrix"; import { type MatrixEvent, parseErrorResponse } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import MemberAvatar from "../avatars/MemberAvatar"; import MemberAvatar from "../avatars/MemberAvatar";
@@ -34,6 +35,7 @@ import Modal from "../../../Modal";
import ErrorDialog from "../dialogs/ErrorDialog"; import ErrorDialog from "../dialogs/ErrorDialog";
import { FileDownloader } from "../../../utils/FileDownloader"; import { FileDownloader } from "../../../utils/FileDownloader";
import { MediaEventHelper } from "../../../utils/MediaEventHelper.ts"; import { MediaEventHelper } from "../../../utils/MediaEventHelper.ts";
import ModuleApi from "../../../modules/Api";
// Max scale to keep gaps around the image // Max scale to keep gaps around the image
const MAX_SCALE = 0.95; const MAX_SCALE = 0.95;
@@ -591,12 +593,36 @@ function DownloadButton({
url: string; url: string;
fileName?: string; fileName?: string;
mxEvent?: MatrixEvent; mxEvent?: MatrixEvent;
}): JSX.Element { }): JSX.Element | null {
const downloader = useRef(new FileDownloader()).current; const downloader = useRef(new FileDownloader()).current;
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [canDownload, setCanDownload] = useState<boolean>(false);
const blobRef = useRef<Blob>(undefined); const blobRef = useRef<Blob>(undefined);
const mediaEventHelper = useMemo(() => (mxEvent ? new MediaEventHelper(mxEvent) : undefined), [mxEvent]); const mediaEventHelper = useMemo(() => (mxEvent ? new MediaEventHelper(mxEvent) : undefined), [mxEvent]);
useEffect(() => {
if (!mxEvent) {
// If we have no event, we assume this is safe to download.
setCanDownload(true);
return;
}
const hints = ModuleApi.customComponents.getHintsForMessage(mxEvent);
if (hints?.allowDownloadingMedia) {
// Disable downloading as soon as we know there is a hint.
setCanDownload(false);
hints
.allowDownloadingMedia()
.then((downloadable) => {
setCanDownload(downloadable);
})
.catch((ex) => {
logger.error(`Failed to check if media from ${mxEvent.getId()} could be downloaded`, ex);
// Err on the side of safety.
setCanDownload(false);
});
}
}, [mxEvent]);
function showError(e: unknown): void { function showError(e: unknown): void {
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: _t("timeline|download_failed"), title: _t("timeline|download_failed"),
@@ -640,6 +666,10 @@ function DownloadButton({
setLoading(false); setLoading(false);
} }
if (!canDownload) {
return null;
}
return ( return (
<AccessibleButton <AccessibleButton
className="mx_ImageView_button mx_ImageView_button_download" className="mx_ImageView_button mx_ImageView_button_download"

View File

@@ -10,6 +10,7 @@ import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
import React, { type JSX } from "react"; import React, { type JSX } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { DownloadIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { DownloadIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { logger } from "matrix-js-sdk/src/logger";
import { type MediaEventHelper } from "../../../utils/MediaEventHelper"; import { type MediaEventHelper } from "../../../utils/MediaEventHelper";
import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex"; import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex";
@@ -18,6 +19,7 @@ import { _t, _td, type TranslationKey } from "../../../languageHandler";
import { FileDownloader } from "../../../utils/FileDownloader"; import { FileDownloader } from "../../../utils/FileDownloader";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import ErrorDialog from "../dialogs/ErrorDialog"; import ErrorDialog from "../dialogs/ErrorDialog";
import ModuleApi from "../../../modules/Api";
interface IProps { interface IProps {
mxEvent: MatrixEvent; mxEvent: MatrixEvent;
@@ -29,6 +31,7 @@ interface IProps {
} }
interface IState { interface IState {
canDownload: null | boolean;
loading: boolean; loading: boolean;
blob?: Blob; blob?: Blob;
tooltip: TranslationKey; tooltip: TranslationKey;
@@ -40,9 +43,29 @@ export default class DownloadActionButton extends React.PureComponent<IProps, IS
public constructor(props: IProps) { public constructor(props: IProps) {
super(props); super(props);
const moduleHints = ModuleApi.customComponents.getHintsForMessage(props.mxEvent);
const downloadState: Pick<IState, "canDownload"> = { canDownload: true };
if (moduleHints?.allowDownloadingMedia) {
downloadState.canDownload = null;
moduleHints
.allowDownloadingMedia()
.then((canDownload) => {
this.setState({
canDownload: canDownload,
});
})
.catch((ex) => {
logger.error(`Failed to check if media from ${props.mxEvent.getId()} could be downloaded`, ex);
this.setState({
canDownload: false,
});
});
}
this.state = { this.state = {
loading: false, loading: false,
tooltip: _td("timeline|download_action_downloading"), tooltip: _td("timeline|download_action_downloading"),
...downloadState,
}; };
} }
@@ -97,6 +120,14 @@ export default class DownloadActionButton extends React.PureComponent<IProps, IS
spinner = <Spinner w={18} h={18} />; spinner = <Spinner w={18} h={18} />;
} }
if (this.state.canDownload === null) {
spinner = <Spinner w={18} h={18} />;
}
if (this.state.canDownload === false) {
return null;
}
const classes = classNames({ const classes = classNames({
mx_MessageActionBar_iconButton: true, mx_MessageActionBar_iconButton: true,
mx_MessageActionBar_downloadButton: true, mx_MessageActionBar_downloadButton: true,

View File

@@ -13,7 +13,7 @@ import type {
CustomMessageRenderFunction, CustomMessageRenderFunction,
CustomMessageComponentProps as ModuleCustomMessageComponentProps, CustomMessageComponentProps as ModuleCustomMessageComponentProps,
OriginalComponentProps, OriginalComponentProps,
CustomMessageRenderHints, CustomMessageRenderHints as ModuleCustomCustomMessageRenderHints,
MatrixEvent as ModuleMatrixEvent, MatrixEvent as ModuleMatrixEvent,
} from "@element-hq/element-web-module-api"; } from "@element-hq/element-web-module-api";
import type React from "react"; import type React from "react";
@@ -23,13 +23,18 @@ type EventTypeOrFilter = Parameters<ICustomComponentsApi["registerMessageRendere
type EventRenderer = { type EventRenderer = {
eventTypeOrFilter: EventTypeOrFilter; eventTypeOrFilter: EventTypeOrFilter;
renderer: CustomMessageRenderFunction; renderer: CustomMessageRenderFunction;
hints: CustomMessageRenderHints; hints: ModuleCustomCustomMessageRenderHints;
}; };
interface CustomMessageComponentProps extends Omit<ModuleCustomMessageComponentProps, "mxEvent"> { interface CustomMessageComponentProps extends Omit<ModuleCustomMessageComponentProps, "mxEvent"> {
mxEvent: MatrixEvent; mxEvent: MatrixEvent;
} }
interface CustomMessageRenderHints extends Omit<ModuleCustomCustomMessageRenderHints, "allowDownloadingMedia"> {
// Note. This just makes it easier to use this API on Element Web as we already have the moduleized event stored.
allowDownloadingMedia?: () => Promise<boolean>;
}
export class CustomComponentsApi implements ICustomComponentsApi { export class CustomComponentsApi implements ICustomComponentsApi {
/** /**
* Convert a matrix-js-sdk event into a ModuleMatrixEvent. * Convert a matrix-js-sdk event into a ModuleMatrixEvent.
@@ -63,7 +68,7 @@ export class CustomComponentsApi implements ICustomComponentsApi {
public registerMessageRenderer( public registerMessageRenderer(
eventTypeOrFilter: EventTypeOrFilter, eventTypeOrFilter: EventTypeOrFilter,
renderer: CustomMessageRenderFunction, renderer: CustomMessageRenderFunction,
hints: CustomMessageRenderHints = {}, hints: ModuleCustomCustomMessageRenderHints = {},
): void { ): void {
this.registeredMessageRenderers.push({ eventTypeOrFilter: eventTypeOrFilter, renderer, hints }); this.registeredMessageRenderers.push({ eventTypeOrFilter: eventTypeOrFilter, renderer, hints });
} }
@@ -119,7 +124,13 @@ export class CustomComponentsApi implements ICustomComponentsApi {
const moduleEv = CustomComponentsApi.getModuleMatrixEvent(mxEvent); const moduleEv = CustomComponentsApi.getModuleMatrixEvent(mxEvent);
const renderer = moduleEv && this.selectRenderer(moduleEv); const renderer = moduleEv && this.selectRenderer(moduleEv);
if (renderer) { if (renderer) {
return renderer.hints; return {
...renderer.hints,
// Convert from js-sdk style events to module events automatically.
allowDownloadingMedia: renderer.hints.allowDownloadingMedia
? () => renderer.hints.allowDownloadingMedia!(moduleEv)
: undefined,
};
} }
return null; return null;
} }

View File

@@ -1672,10 +1672,10 @@
resolved "https://registry.yarnpkg.com/@element-hq/element-call-embedded/-/element-call-embedded-0.12.2.tgz#b6b6b7df69369b3088960b79591ce1bfd2b84a1a" resolved "https://registry.yarnpkg.com/@element-hq/element-call-embedded/-/element-call-embedded-0.12.2.tgz#b6b6b7df69369b3088960b79591ce1bfd2b84a1a"
integrity sha512-2u5/bOARcjc5TFq4929x1R0tvsNbeVA58FBtiW05GlIJCapxzPSOeeGhbqEcJ1TW3/hLGpiKMcw0QwRBQVNzQA== integrity sha512-2u5/bOARcjc5TFq4929x1R0tvsNbeVA58FBtiW05GlIJCapxzPSOeeGhbqEcJ1TW3/hLGpiKMcw0QwRBQVNzQA==
"@element-hq/element-web-module-api@1.2.0": "@element-hq/element-web-module-api@1.3.0":
version "1.2.0" version "1.3.0"
resolved "https://registry.yarnpkg.com/@element-hq/element-web-module-api/-/element-web-module-api-1.2.0.tgz#4d91c890a74f808a82759dcb00a8e47dcf131236" resolved "https://registry.yarnpkg.com/@element-hq/element-web-module-api/-/element-web-module-api-1.3.0.tgz#6067fa654174d1dd0953447bb036e38f9dfa51a5"
integrity sha512-+2fjShcuFLWVWzhRVlveg4MHevcT7XiXie6JB2SIS89FoJWAnsr41eiSbUORAIHndBCrznU8a/lYz9Pf8BXYVA== integrity sha512-rEV0xnT/tNYPIdqHWWiz2KZo96UeZR0YChfoVLiPT46ZlEYyxqkjxT5bOm1eL2/CiYRe8t1yka3UDkIjq481/g==
"@element-hq/element-web-playwright-common@^1.4.2": "@element-hq/element-web-playwright-common@^1.4.2":
version "1.4.2" version "1.4.2"
@@ -3920,7 +3920,7 @@
classnames "^2.5.1" classnames "^2.5.1"
vaul "^1.0.0" vaul "^1.0.0"
"@vector-im/matrix-wysiwyg-wasm@link:../../bindings/wysiwyg-wasm": "@vector-im/matrix-wysiwyg-wasm@link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.4-fb0001dea01010a1e3ffc7042596e2d001ce9389-integrity/node_modules/bindings/wysiwyg-wasm":
version "0.0.0" version "0.0.0"
uid "" uid ""