Update visual style and sandbox properly
parent
623f2e7613
commit
b57fff5739
|
@ -107,3 +107,12 @@ limitations under the License.
|
||||||
.mx_MessageActionBar_cancelButton::after {
|
.mx_MessageActionBar_cancelButton::after {
|
||||||
mask-image: url('$(res)/img/element-icons/trashcan.svg');
|
mask-image: url('$(res)/img/element-icons/trashcan.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mx_MessageActionBar_downloadButton::after {
|
||||||
|
mask-size: 16px;
|
||||||
|
mask-image: url('$(res)/img/download.svg');
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_MessageActionBar_downloadButton.mx_MessageActionBar_downloadSpinnerButton::after {
|
||||||
|
background-color: transparent; // hide the download icon mask
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,110 @@
|
||||||
|
/*
|
||||||
|
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";
|
||||||
|
import { DOWNLOAD_ICON_URL } from "./MFileBody";
|
||||||
|
|
||||||
|
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 class DownloadActionButton extends React.PureComponent<IProps, IState> {
|
||||||
|
private iframe: React.RefObject<HTMLIFrameElement> = 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: DOWNLOAD_ICON_URL,
|
||||||
|
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 = <Spinner w={18} h={18} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const classes = classNames({
|
||||||
|
'mx_MessageActionBar_maskButton': true,
|
||||||
|
'mx_MessageActionBar_downloadButton': true,
|
||||||
|
'mx_MessageActionBar_downloadSpinnerButton': !!spinner,
|
||||||
|
});
|
||||||
|
|
||||||
|
return <RovingAccessibleTooltipButton
|
||||||
|
className={classes}
|
||||||
|
title={_t("Download")}
|
||||||
|
onClick={this.onDownloadClick}
|
||||||
|
disabled={!!spinner}
|
||||||
|
>
|
||||||
|
{ spinner }
|
||||||
|
{ this.state.blob && <iframe
|
||||||
|
src={"usercontent/" /* XXX: Like MFileBody, this should come from the skin */}
|
||||||
|
ref={this.iframe}
|
||||||
|
onLoad={this.onFrameLoad}
|
||||||
|
sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
/> }
|
||||||
|
</RovingAccessibleTooltipButton>;
|
||||||
|
}
|
||||||
|
}
|
|
@ -27,12 +27,12 @@ import { IContent } from "matrix-js-sdk/src";
|
||||||
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
|
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
|
||||||
import { IBodyProps } from "./IBodyProps";
|
import { IBodyProps } from "./IBodyProps";
|
||||||
|
|
||||||
let downloadIconUrl; // cached copy of the download.svg asset for the sandboxed iframe later on
|
export let DOWNLOAD_ICON_URL; // cached copy of the download.svg asset for the sandboxed iframe later on
|
||||||
|
|
||||||
async function cacheDownloadIcon() {
|
async function cacheDownloadIcon() {
|
||||||
if (downloadIconUrl) return; // cached already
|
if (DOWNLOAD_ICON_URL) return; // cached already
|
||||||
const svg = await fetch(require("../../../../res/img/download.svg")).then(r => r.text());
|
const svg = await fetch(require("../../../../res/img/download.svg")).then(r => r.text());
|
||||||
downloadIconUrl = "data:image/svg+xml;base64," + window.btoa(svg);
|
DOWNLOAD_ICON_URL = "data:image/svg+xml;base64," + window.btoa(svg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the asset immediately
|
// Cache the asset immediately
|
||||||
|
@ -74,7 +74,7 @@ cacheDownloadIcon();
|
||||||
* @param {HTMLElement} element The element to get the current style of.
|
* @param {HTMLElement} element The element to get the current style of.
|
||||||
* @return {string} The CSS style encoded as a string.
|
* @return {string} The CSS style encoded as a string.
|
||||||
*/
|
*/
|
||||||
function computedStyle(element) {
|
export function computedStyle(element: HTMLElement) {
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
@ -217,7 +217,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
|
||||||
// When the iframe loads we tell it to render a download link
|
// When the iframe loads we tell it to render a download link
|
||||||
const onIframeLoad = (ev) => {
|
const onIframeLoad = (ev) => {
|
||||||
ev.target.contentWindow.postMessage({
|
ev.target.contentWindow.postMessage({
|
||||||
imgSrc: downloadIconUrl,
|
imgSrc: DOWNLOAD_ICON_URL,
|
||||||
imgStyle: null, // it handles this internally for us. Useful if a downstream changes the icon.
|
imgStyle: null, // it handles this internally for us. Useful if a downstream changes the icon.
|
||||||
style: computedStyle(this.dummyLink.current),
|
style: computedStyle(this.dummyLink.current),
|
||||||
blob: this.state.decryptedBlob,
|
blob: this.state.decryptedBlob,
|
||||||
|
|
|
@ -33,6 +33,7 @@ import { canCancel } from "../context_menus/MessageContextMenu";
|
||||||
import Resend from "../../../Resend";
|
import Resend from "../../../Resend";
|
||||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||||
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
||||||
|
import { DownloadActionButton } from "./DownloadActionButton";
|
||||||
|
|
||||||
const OptionsButton = ({ mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange }) => {
|
const OptionsButton = ({ mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange }) => {
|
||||||
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
|
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
|
||||||
|
@ -176,21 +177,6 @@ export default class MessageActionBar extends React.PureComponent {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
onDownloadClick = async (ev) => {
|
|
||||||
if (!this.props.getTile || !this.props.getTile().getMediaHelper) {
|
|
||||||
console.warn("Action bar triggered a download but the event tile is missing a media helper");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Maybe just call into MFileBody and render it as null
|
|
||||||
const src = this.props.getTile().getMediaHelper();
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = await src.sourceUrl.value;
|
|
||||||
a.download = "todo.png";
|
|
||||||
a.target = "_blank";
|
|
||||||
a.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs a given fn on the set of possible events to test. The first event
|
* Runs a given fn on the set of possible events to test. The first event
|
||||||
* that passes the checkFn will have fn executed on it. Both functions take
|
* that passes the checkFn will have fn executed on it. Both functions take
|
||||||
|
@ -286,11 +272,9 @@ export default class MessageActionBar extends React.PureComponent {
|
||||||
|
|
||||||
// XXX: Assuming that the underlying tile will be a media event if it is eligible media.
|
// XXX: Assuming that the underlying tile will be a media event if it is eligible media.
|
||||||
if (MediaEventHelper.isEligible(this.props.mxEvent)) {
|
if (MediaEventHelper.isEligible(this.props.mxEvent)) {
|
||||||
toolbarOpts.splice(0, 0, <RovingAccessibleTooltipButton
|
toolbarOpts.splice(0, 0, <DownloadActionButton
|
||||||
className="mx_MessageActionBar_maskButton mx_MessageActionBar_downloadButton"
|
mxEvent={this.props.mxEvent}
|
||||||
title={_t("Download")}
|
mediaEventHelperGet={() => this.props.getTile?.().getMediaHelper?.()}
|
||||||
onClick={this.onDownloadClick}
|
|
||||||
disabled={false}
|
|
||||||
key="download"
|
key="download"
|
||||||
/>);
|
/>);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1870,6 +1870,7 @@
|
||||||
"Saturday": "Saturday",
|
"Saturday": "Saturday",
|
||||||
"Today": "Today",
|
"Today": "Today",
|
||||||
"Yesterday": "Yesterday",
|
"Yesterday": "Yesterday",
|
||||||
|
"Download": "Download",
|
||||||
"View Source": "View Source",
|
"View Source": "View Source",
|
||||||
"Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.": "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.",
|
"Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.": "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.",
|
||||||
"Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.": "Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.",
|
"Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.": "Messages in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their avatar.",
|
||||||
|
@ -2002,7 +2003,6 @@
|
||||||
"Zoom in": "Zoom in",
|
"Zoom in": "Zoom in",
|
||||||
"Rotate Left": "Rotate Left",
|
"Rotate Left": "Rotate Left",
|
||||||
"Rotate Right": "Rotate Right",
|
"Rotate Right": "Rotate Right",
|
||||||
"Download": "Download",
|
|
||||||
"Information": "Information",
|
"Information": "Information",
|
||||||
"Language Dropdown": "Language Dropdown",
|
"Language Dropdown": "Language Dropdown",
|
||||||
"%(nameList)s %(transitionList)s": "%(nameList)s %(transitionList)s",
|
"%(nameList)s %(transitionList)s": "%(nameList)s %(transitionList)s",
|
||||||
|
|
|
@ -1,6 +1,13 @@
|
||||||
|
let hasCalled = false;
|
||||||
function remoteRender(event) {
|
function remoteRender(event) {
|
||||||
const data = event.data;
|
const data = event.data;
|
||||||
|
|
||||||
|
// If we're handling secondary calls, start from scratch
|
||||||
|
if (hasCalled) {
|
||||||
|
document.body.replaceWith(document.createElement("BODY"));
|
||||||
|
}
|
||||||
|
hasCalled = true;
|
||||||
|
|
||||||
const img = document.createElement("span"); // we'll mask it as an image
|
const img = document.createElement("span"); // we'll mask it as an image
|
||||||
img.id = "img";
|
img.id = "img";
|
||||||
|
|
||||||
|
|
|
@ -40,6 +40,10 @@ export class MediaEventHelper implements IDestroyable {
|
||||||
this.media = mediaFromContent(this.event.getContent());
|
this.media = mediaFromContent(this.event.getContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get fileName(): string {
|
||||||
|
return this.event.getContent<IMediaEventContent>().body || "download";
|
||||||
|
}
|
||||||
|
|
||||||
public destroy() {
|
public destroy() {
|
||||||
if (this.media.isEncrypted) {
|
if (this.media.isEncrypted) {
|
||||||
if (this.sourceUrl.present) URL.revokeObjectURL(this.sourceUrl.cachedValue);
|
if (this.sourceUrl.present) URL.revokeObjectURL(this.sourceUrl.cachedValue);
|
||||||
|
|
Loading…
Reference in New Issue