You've already forked element-web
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:
@@ -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",
|
||||||
|
@@ -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" },
|
||||||
|
@@ -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;
|
||||||
|
@@ -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"
|
||||||
|
@@ -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,
|
||||||
|
@@ -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;
|
||||||
}
|
}
|
||||||
|
10
yarn.lock
10
yarn.lock
@@ -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 ""
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user