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

@@ -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<boolean>(false);
const blobRef = useRef<Blob>(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 (
<AccessibleButton
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 classNames from "classnames";
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 { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex";
@@ -18,6 +19,7 @@ import { _t, _td, type TranslationKey } from "../../../languageHandler";
import { FileDownloader } from "../../../utils/FileDownloader";
import Modal from "../../../Modal";
import ErrorDialog from "../dialogs/ErrorDialog";
import ModuleApi from "../../../modules/Api";
interface IProps {
mxEvent: MatrixEvent;
@@ -29,6 +31,7 @@ interface IProps {
}
interface IState {
canDownload: null | boolean;
loading: boolean;
blob?: Blob;
tooltip: TranslationKey;
@@ -40,9 +43,29 @@ export default class DownloadActionButton extends React.PureComponent<IProps, IS
public constructor(props: IProps) {
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 = {
loading: false,
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} />;
}
if (this.state.canDownload === null) {
spinner = <Spinner w={18} h={18} />;
}
if (this.state.canDownload === false) {
return null;
}
const classes = classNames({
mx_MessageActionBar_iconButton: true,
mx_MessageActionBar_downloadButton: true,

View File

@@ -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<ICustomComponentsApi["registerMessageRendere
type EventRenderer = {
eventTypeOrFilter: EventTypeOrFilter;
renderer: CustomMessageRenderFunction;
hints: CustomMessageRenderHints;
hints: ModuleCustomCustomMessageRenderHints;
};
interface CustomMessageComponentProps extends Omit<ModuleCustomMessageComponentProps, "mxEvent"> {
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 {
/**
* 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;
}