diff --git a/res/css/views/messages/_MessageActionBar.scss b/res/css/views/messages/_MessageActionBar.scss index e2fafe6c62..69f3c672b7 100644 --- a/res/css/views/messages/_MessageActionBar.scss +++ b/res/css/views/messages/_MessageActionBar.scss @@ -107,3 +107,12 @@ limitations under the License. .mx_MessageActionBar_cancelButton::after { mask-image: url('$(res)/img/element-icons/trashcan.svg'); } + +.mx_MessageActionBar_downloadButton::after { + mask-size: 14px; + mask-image: url('$(res)/img/download.svg'); +} + +.mx_MessageActionBar_downloadButton.mx_MessageActionBar_downloadSpinnerButton::after { + background-color: transparent; // hide the download icon mask +} diff --git a/src/@types/common.ts b/src/@types/common.ts index 1fb9ba4303..36ef7a9ace 100644 --- a/src/@types/common.ts +++ b/src/@types/common.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { JSXElementConstructor } from "react"; +import React, { JSXElementConstructor } from "react"; // Based on https://stackoverflow.com/a/53229857/3532235 export type Without = {[P in Exclude]?: never}; @@ -22,3 +22,4 @@ export type XOR = (T | U) extends object ? (Without & U) | (Without< export type Writeable = { -readonly [P in keyof T]: T[P] }; export type ComponentClass = keyof JSX.IntrinsicElements | JSXElementConstructor; +export type ReactAnyComponent = React.Component | React.ExoticComponent; diff --git a/src/components/views/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx index bf171353e8..8f5d3baa17 100644 --- a/src/components/views/context_menus/MessageContextMenu.tsx +++ b/src/components/views/context_menus/MessageContextMenu.tsx @@ -43,11 +43,15 @@ export function canCancel(eventStatus: EventStatus): boolean { return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT; } -interface IEventTileOps { +export interface IEventTileOps { isWidgetHidden(): boolean; unhideWidget(): void; } +export interface IOperableEventTile { + getEventTileOps(): IEventTileOps; +} + interface IProps { /* the MatrixEvent associated with the context menu */ mxEvent: MatrixEvent; diff --git a/src/components/views/messages/DownloadActionButton.tsx b/src/components/views/messages/DownloadActionButton.tsx new file mode 100644 index 0000000000..2bdae04eda --- /dev/null +++ b/src/components/views/messages/DownloadActionButton.tsx @@ -0,0 +1,109 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixEvent } from "matrix-js-sdk/src"; +import { MediaEventHelper } from "../../../utils/MediaEventHelper"; +import React, { createRef } from "react"; +import { RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex"; +import Spinner from "../elements/Spinner"; +import classNames from "classnames"; +import { _t } from "../../../languageHandler"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; + +interface IProps { + mxEvent: MatrixEvent; + + // XXX: It can take a cycle or two for the MessageActionBar to have all the props/setup + // required to get us a MediaEventHelper, so we use a getter function instead to prod for + // one. + mediaEventHelperGet: () => MediaEventHelper; +} + +interface IState { + loading: boolean; + blob?: Blob; +} + +@replaceableComponent("views.messages.DownloadActionButton") +export default class DownloadActionButton extends React.PureComponent { + private iframe: React.RefObject = createRef(); + + public constructor(props: IProps) { + super(props); + + this.state = { + loading: false, + }; + } + + private onDownloadClick = async () => { + if (this.state.loading) return; + + this.setState({ loading: true }); + + if (this.state.blob) { + // Cheat and trigger a download, again. + return this.onFrameLoad(); + } + + const blob = await this.props.mediaEventHelperGet().sourceBlob.value; + this.setState({ blob }); + }; + + private onFrameLoad = () => { + this.setState({ loading: false }); + + // we aren't showing the iframe, so we can send over the bare minimum styles and such. + this.iframe.current.contentWindow.postMessage({ + imgSrc: "", // no image + imgStyle: null, + style: "", + blob: this.state.blob, + download: this.props.mediaEventHelperGet().fileName, + textContent: "", + auto: true, // autodownload + }, '*'); + }; + + public render() { + let spinner: JSX.Element; + if (this.state.loading) { + spinner = ; + } + + const classes = classNames({ + 'mx_MessageActionBar_maskButton': true, + 'mx_MessageActionBar_downloadButton': true, + 'mx_MessageActionBar_downloadSpinnerButton': !!spinner, + }); + + return + { spinner } + { this.state.blob &&