From 9f313fcc1492f25d898c1d9a9402fb6d0bfcbc95 Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Mon, 7 Jul 2025 10:03:46 +0100 Subject: [PATCH] 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 --- package.json | 2 +- .../e2e/modules/custom-component.spec.ts | 48 +++++++++++++++++++ .../sample-files/custom-component-module.js | 19 ++++++++ src/components/views/elements/ImageView.tsx | 34 ++++++++++++- .../views/messages/DownloadActionButton.tsx | 31 ++++++++++++ src/modules/customComponentApi.ts | 19 ++++++-- yarn.lock | 10 ++-- 7 files changed, 151 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index c889f31d77..e7a468f586 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ }, "dependencies": { "@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/inter": "^5", "@formatjs/intl-segmenter": "^11.5.7", diff --git a/playwright/e2e/modules/custom-component.spec.ts b/playwright/e2e/modules/custom-component.spec.ts index b0cdfe5855..f263ac8b9c 100644 --- a/playwright/e2e/modules/custom-component.spec.ts +++ b/playwright/e2e/modules/custom-component.spec.ts @@ -6,6 +6,7 @@ Please see LICENSE files in the repository root for full details. */ import { type Page } from "@playwright/test"; +import fs from "node:fs"; 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.use({ displayName: "Manny", @@ -84,6 +88,50 @@ test.describe("Custom Component API", () => { await page.getByRole("toolbar", { name: "Message Actions" }).getByRole("button", { name: "Edit" }), ).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( "should render the next registered component if the filter function throws", { tag: "@screenshot" }, diff --git a/playwright/sample-files/custom-component-module.js b/playwright/sample-files/custom-component-module.js index 8d4d1b3c1f..be2ab5928d 100644 --- a/playwright/sample-files/custom-component-module.js +++ b/playwright/sample-files/custom-component-module.js @@ -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. */ +// 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 { static moduleApiVersion = "^1.2.0"; + /** + * Basic module for testing. + * @param {Api} api API object + */ constructor(api) { this.api = api; this.api.customComponents.registerMessageRenderer( @@ -40,6 +50,15 @@ export default class CustomComponentModule { 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 this.api.customComponents.registerMessageRenderer("m.room.message", (props, originalComponent) => { const body = props.mxEvent.content.body; diff --git a/src/components/views/elements/ImageView.tsx b/src/components/views/elements/ImageView.tsx index e3ee6f20d5..f236e5193e 100644 --- a/src/components/views/elements/ImageView.tsx +++ b/src/components/views/elements/ImageView.tsx @@ -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. */ -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 { type MatrixEvent, parseErrorResponse } from "matrix-js-sdk/src/matrix"; +import { logger } from "matrix-js-sdk/src/logger"; import { _t } from "../../../languageHandler"; import MemberAvatar from "../avatars/MemberAvatar"; @@ -34,6 +35,7 @@ import Modal from "../../../Modal"; import ErrorDialog from "../dialogs/ErrorDialog"; import { FileDownloader } from "../../../utils/FileDownloader"; import { MediaEventHelper } from "../../../utils/MediaEventHelper.ts"; +import ModuleApi from "../../../modules/Api"; // Max scale to keep gaps around the image const MAX_SCALE = 0.95; @@ -591,12 +593,36 @@ function DownloadButton({ url: string; fileName?: string; mxEvent?: MatrixEvent; -}): JSX.Element { +}): JSX.Element | null { const downloader = useRef(new FileDownloader()).current; const [loading, setLoading] = useState(false); + const [canDownload, setCanDownload] = useState(false); const blobRef = useRef(undefined); 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 { Modal.createDialog(ErrorDialog, { title: _t("timeline|download_failed"), @@ -640,6 +666,10 @@ function DownloadButton({ setLoading(false); } + if (!canDownload) { + return null; + } + return ( = { 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 = { loading: false, tooltip: _td("timeline|download_action_downloading"), + ...downloadState, }; } @@ -97,6 +120,14 @@ export default class DownloadActionButton extends React.PureComponent; } + if (this.state.canDownload === null) { + spinner = ; + } + + if (this.state.canDownload === false) { + return null; + } + const classes = classNames({ mx_MessageActionBar_iconButton: true, mx_MessageActionBar_downloadButton: true, diff --git a/src/modules/customComponentApi.ts b/src/modules/customComponentApi.ts index ce75a70507..db2f9ab58a 100644 --- a/src/modules/customComponentApi.ts +++ b/src/modules/customComponentApi.ts @@ -13,7 +13,7 @@ import type { CustomMessageRenderFunction, CustomMessageComponentProps as ModuleCustomMessageComponentProps, OriginalComponentProps, - CustomMessageRenderHints, + CustomMessageRenderHints as ModuleCustomCustomMessageRenderHints, MatrixEvent as ModuleMatrixEvent, } from "@element-hq/element-web-module-api"; import type React from "react"; @@ -23,13 +23,18 @@ type EventTypeOrFilter = Parameters { mxEvent: MatrixEvent; } +interface CustomMessageRenderHints extends Omit { + // Note. This just makes it easier to use this API on Element Web as we already have the moduleized event stored. + allowDownloadingMedia?: () => Promise; +} + export class CustomComponentsApi implements ICustomComponentsApi { /** * Convert a matrix-js-sdk event into a ModuleMatrixEvent. @@ -63,7 +68,7 @@ export class CustomComponentsApi implements ICustomComponentsApi { public registerMessageRenderer( eventTypeOrFilter: EventTypeOrFilter, renderer: CustomMessageRenderFunction, - hints: CustomMessageRenderHints = {}, + hints: ModuleCustomCustomMessageRenderHints = {}, ): void { this.registeredMessageRenderers.push({ eventTypeOrFilter: eventTypeOrFilter, renderer, hints }); } @@ -119,7 +124,13 @@ export class CustomComponentsApi implements ICustomComponentsApi { const moduleEv = CustomComponentsApi.getModuleMatrixEvent(mxEvent); const renderer = moduleEv && this.selectRenderer(moduleEv); 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; } diff --git a/yarn.lock b/yarn.lock index 017a68f392..476aa4e387 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1672,10 +1672,10 @@ resolved "https://registry.yarnpkg.com/@element-hq/element-call-embedded/-/element-call-embedded-0.12.2.tgz#b6b6b7df69369b3088960b79591ce1bfd2b84a1a" integrity sha512-2u5/bOARcjc5TFq4929x1R0tvsNbeVA58FBtiW05GlIJCapxzPSOeeGhbqEcJ1TW3/hLGpiKMcw0QwRBQVNzQA== -"@element-hq/element-web-module-api@1.2.0": - version "1.2.0" - resolved "https://registry.yarnpkg.com/@element-hq/element-web-module-api/-/element-web-module-api-1.2.0.tgz#4d91c890a74f808a82759dcb00a8e47dcf131236" - integrity sha512-+2fjShcuFLWVWzhRVlveg4MHevcT7XiXie6JB2SIS89FoJWAnsr41eiSbUORAIHndBCrznU8a/lYz9Pf8BXYVA== +"@element-hq/element-web-module-api@1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@element-hq/element-web-module-api/-/element-web-module-api-1.3.0.tgz#6067fa654174d1dd0953447bb036e38f9dfa51a5" + integrity sha512-rEV0xnT/tNYPIdqHWWiz2KZo96UeZR0YChfoVLiPT46ZlEYyxqkjxT5bOm1eL2/CiYRe8t1yka3UDkIjq481/g== "@element-hq/element-web-playwright-common@^1.4.2": version "1.4.2" @@ -3920,7 +3920,7 @@ classnames "^2.5.1" 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" uid ""