Merge pull request #6386 from matrix-org/travis/voice-messages/download

Move download button for media to the action bar
This commit is contained in:
Travis Ralston 2021-07-20 09:08:50 -06:00 committed by GitHub
commit 7ea17aee3c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 593 additions and 377 deletions

View file

@ -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: 14px;
mask-image: url('$(res)/img/download.svg');
}
.mx_MessageActionBar_downloadButton.mx_MessageActionBar_downloadSpinnerButton::after {
background-color: transparent; // hide the download icon mask
}

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { JSXElementConstructor } from "react"; import React, { JSXElementConstructor } from "react";
// Based on https://stackoverflow.com/a/53229857/3532235 // Based on https://stackoverflow.com/a/53229857/3532235
export type Without<T, U> = {[P in Exclude<keyof T, keyof U>]?: never}; export type Without<T, U> = {[P in Exclude<keyof T, keyof U>]?: never};
@ -22,3 +22,4 @@ export type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<
export type Writeable<T> = { -readonly [P in keyof T]: T[P] }; export type Writeable<T> = { -readonly [P in keyof T]: T[P] };
export type ComponentClass = keyof JSX.IntrinsicElements | JSXElementConstructor<any>; export type ComponentClass = keyof JSX.IntrinsicElements | JSXElementConstructor<any>;
export type ReactAnyComponent = React.Component | React.ExoticComponent;

View file

@ -43,11 +43,15 @@ export function canCancel(eventStatus: EventStatus): boolean {
return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT; return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT;
} }
interface IEventTileOps { export interface IEventTileOps {
isWidgetHidden(): boolean; isWidgetHidden(): boolean;
unhideWidget(): void; unhideWidget(): void;
} }
export interface IOperableEventTile {
getEventTileOps(): IEventTileOps;
}
interface IProps { interface IProps {
/* the MatrixEvent associated with the context menu */ /* the MatrixEvent associated with the context menu */
mxEvent: MatrixEvent; mxEvent: MatrixEvent;

View file

@ -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<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: "", // 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 = <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={spinner ? _t("Downloading") : _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>;
}
}

View file

@ -0,0 +1,43 @@
/*
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 { TileShape } from "../rooms/EventTile";
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import EditorStateTransfer from "../../../utils/EditorStateTransfer";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
export interface IBodyProps {
mxEvent: MatrixEvent;
/* a list of words to highlight */
highlights: string[];
/* link URL for the highlights */
highlightLink: string;
/* callback called when dynamic content in events are loaded */
onHeightChanged: () => void;
showUrlPreview?: boolean;
tileShape: TileShape;
maxImageHeight?: number;
replacingEventId?: string;
editState?: EditorStateTransfer;
onMessageAllowed: () => void; // TODO: Docs
permalinkCreator: RoomPermalinkCreator;
mediaEventHelper: MediaEventHelper;
}

View file

@ -0,0 +1,21 @@
/*
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 { MediaEventHelper } from "../../../utils/MediaEventHelper";
export interface IMediaBody {
getMediaHelper(): MediaEventHelper;
}

View file

@ -15,30 +15,23 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { Playback } from "../../../voice/Playback"; import { Playback } from "../../../voice/Playback";
import MFileBody from "./MFileBody";
import InlineSpinner from '../elements/InlineSpinner'; import InlineSpinner from '../elements/InlineSpinner';
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { mediaFromContent } from "../../../customisations/Media";
import { decryptFile } from "../../../utils/DecryptFile";
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
import AudioPlayer from "../audio_messages/AudioPlayer"; import AudioPlayer from "../audio_messages/AudioPlayer";
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
interface IProps { import MFileBody from "./MFileBody";
mxEvent: MatrixEvent; import { IBodyProps } from "./IBodyProps";
}
interface IState { interface IState {
error?: Error; error?: Error;
playback?: Playback; playback?: Playback;
decryptedBlob?: Blob;
} }
@replaceableComponent("views.messages.MAudioBody") @replaceableComponent("views.messages.MAudioBody")
export default class MAudioBody extends React.PureComponent<IProps, IState> { export default class MAudioBody extends React.PureComponent<IBodyProps, IState> {
constructor(props: IProps) { constructor(props: IBodyProps) {
super(props); super(props);
this.state = {}; this.state = {};
@ -46,33 +39,34 @@ export default class MAudioBody extends React.PureComponent<IProps, IState> {
public async componentDidMount() { public async componentDidMount() {
let buffer: ArrayBuffer; let buffer: ArrayBuffer;
const content: IMediaEventContent = this.props.mxEvent.getContent();
const media = mediaFromContent(content);
if (media.isEncrypted) {
try { try {
const blob = await decryptFile(content.file); try {
const blob = await this.props.mediaEventHelper.sourceBlob.value;
buffer = await blob.arrayBuffer(); buffer = await blob.arrayBuffer();
this.setState({ decryptedBlob: blob });
} catch (e) { } catch (e) {
this.setState({ error: e }); this.setState({ error: e });
console.warn("Unable to decrypt audio message", e); console.warn("Unable to decrypt audio message", e);
return; // stop processing the audio file return; // stop processing the audio file
} }
} else {
try {
buffer = await media.downloadSource().then(r => r.blob()).then(r => r.arrayBuffer());
} catch (e) { } catch (e) {
this.setState({ error: e }); this.setState({ error: e });
console.warn("Unable to download audio message", e); console.warn("Unable to decrypt/download audio message", e);
return; // stop processing the audio file return; // stop processing the audio file
} }
}
// We should have a buffer to work with now: let's set it up // We should have a buffer to work with now: let's set it up
const playback = new Playback(buffer);
// Note: we don't actually need a waveform to render an audio event, but voice messages do.
const content = this.props.mxEvent.getContent<IMediaEventContent>();
const waveform = content?.["org.matrix.msc1767.audio"]?.waveform?.map(p => p / 1024);
// We should have a buffer to work with now: let's set it up
const playback = new Playback(buffer, waveform);
playback.clockInfo.populatePlaceholdersFrom(this.props.mxEvent); playback.clockInfo.populatePlaceholdersFrom(this.props.mxEvent);
this.setState({ playback }); this.setState({ playback });
// Note: the RecordingPlayback component will handle preparing the Playback class for us.
// Note: the components later on will handle preparing the Playback class for us.
} }
public componentWillUnmount() { public componentWillUnmount() {
@ -103,7 +97,7 @@ export default class MAudioBody extends React.PureComponent<IProps, IState> {
return ( return (
<span className="mx_MAudioBody"> <span className="mx_MAudioBody">
<AudioPlayer playback={this.state.playback} mediaName={this.props.mxEvent.getContent().body} /> <AudioPlayer playback={this.state.playback} mediaName={this.props.mxEvent.getContent().body} />
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} /> { this.props.tileShape && <MFileBody {...this.props} showGenericPlaceholder={false} /> }
</span> </span>
); );
} }

View file

@ -15,26 +15,29 @@ limitations under the License.
*/ */
import React, { createRef } from 'react'; import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import filesize from 'filesize'; import filesize from 'filesize';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { decryptFile } from '../../../utils/DecryptFile';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromContent } from "../../../customisations/Media"; import { mediaFromContent } from "../../../customisations/Media";
import ErrorDialog from "../dialogs/ErrorDialog"; import ErrorDialog from "../dialogs/ErrorDialog";
import { TileShape } from "../rooms/EventTile"; import { TileShape } from "../rooms/EventTile";
import { IContent } from "matrix-js-sdk/src";
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
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
// eslint-disable-next-line @typescript-eslint/no-var-requires
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
// noinspection JSIgnoredPromiseFromCall
cacheDownloadIcon(); cacheDownloadIcon();
// User supplied content can contain scripts, we have to be careful that // User supplied content can contain scripts, we have to be careful that
@ -72,7 +75,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 "";
} }
@ -98,7 +101,7 @@ function computedStyle(element) {
* @param {boolean} withSize Whether to include size information. Default true. * @param {boolean} withSize Whether to include size information. Default true.
* @return {string} the human readable link text for the attachment. * @return {string} the human readable link text for the attachment.
*/ */
export function presentableTextForFile(content, withSize = true) { export function presentableTextForFile(content: IContent, withSize = true): string {
let linkText = _t("Attachment"); let linkText = _t("Attachment");
if (content.body && content.body.length > 0) { if (content.body && content.body.length > 0) {
// The content body should be the name of the file including a // The content body should be the name of the file including a
@ -119,53 +122,48 @@ export function presentableTextForFile(content, withSize = true) {
return linkText; return linkText;
} }
@replaceableComponent("views.messages.MFileBody") interface IProps extends IBodyProps {
export default class MFileBody extends React.Component {
static propTypes = {
/* the MatrixEvent to show */
mxEvent: PropTypes.object.isRequired,
/* already decrypted blob */
decryptedBlob: PropTypes.object,
/* called when the download link iframe is shown */
onHeightChanged: PropTypes.func,
/* the shape of the tile, used */
tileShape: PropTypes.string,
/* whether or not to show the default placeholder for the file. Defaults to true. */ /* whether or not to show the default placeholder for the file. Defaults to true. */
showGenericPlaceholder: PropTypes.bool, showGenericPlaceholder: boolean;
}; }
interface IState {
decryptedBlob?: Blob;
}
@replaceableComponent("views.messages.MFileBody")
export default class MFileBody extends React.Component<IProps, IState> {
static defaultProps = { static defaultProps = {
showGenericPlaceholder: true, showGenericPlaceholder: true,
}; };
constructor(props) { private iframe: React.RefObject<HTMLIFrameElement> = createRef();
private dummyLink: React.RefObject<HTMLAnchorElement> = createRef();
private userDidClick = false;
public constructor(props: IProps) {
super(props); super(props);
this.state = { this.state = {};
decryptedBlob: (this.props.decryptedBlob ? this.props.decryptedBlob : null),
};
this._iframe = createRef();
this._dummyLink = createRef();
} }
_getContentUrl() { private getContentUrl(): string {
const media = mediaFromContent(this.props.mxEvent.getContent()); const media = mediaFromContent(this.props.mxEvent.getContent());
return media.srcHttp; return media.srcHttp;
} }
componentDidUpdate(prevProps, prevState) { public componentDidUpdate(prevProps, prevState) {
if (this.props.onHeightChanged && !prevState.decryptedBlob && this.state.decryptedBlob) { if (this.props.onHeightChanged && !prevState.decryptedBlob && this.state.decryptedBlob) {
this.props.onHeightChanged(); this.props.onHeightChanged();
} }
} }
render() { public render() {
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent<IMediaEventContent>();
const text = presentableTextForFile(content); const text = presentableTextForFile(content);
const isEncrypted = content.file !== undefined; const isEncrypted = this.props.mediaEventHelper.media.isEncrypted;
const fileName = content.body && content.body.length > 0 ? content.body : _t("Attachment"); const fileName = content.body && content.body.length > 0 ? content.body : _t("Attachment");
const contentUrl = this._getContentUrl(); const contentUrl = this.getContentUrl();
const fileSize = content.info ? content.info.size : null; const fileSize = content.info ? content.info.size : null;
const fileType = content.info ? content.info.mimetype : "application/octet-stream"; const fileType = content.info ? content.info.mimetype : "application/octet-stream";
@ -181,30 +179,26 @@ export default class MFileBody extends React.Component {
); );
} }
const showDownloadLink = this.props.tileShape || !this.props.showGenericPlaceholder;
if (isEncrypted) { if (isEncrypted) {
if (this.state.decryptedBlob === null) { if (!this.state.decryptedBlob) {
// Need to decrypt the attachment // Need to decrypt the attachment
// Wait for the user to click on the link before downloading // Wait for the user to click on the link before downloading
// and decrypting the attachment. // and decrypting the attachment.
let decrypting = false; const decrypt = async () => {
const decrypt = (e) => { try {
if (decrypting) { this.userDidClick = true;
return false;
}
decrypting = true;
decryptFile(content.file).then((blob) => {
this.setState({ this.setState({
decryptedBlob: blob, decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
}); });
}).catch((err) => { } catch (err) {
console.warn("Unable to decrypt attachment: ", err); console.warn("Unable to decrypt attachment: ", err);
Modal.createTrackedDialog('Error decrypting attachment', '', ErrorDialog, { Modal.createTrackedDialog('Error decrypting attachment', '', ErrorDialog, {
title: _t("Error"), title: _t("Error"),
description: _t("Error decrypting attachment"), description: _t("Error decrypting attachment"),
}); });
}).finally(() => { }
decrypting = false;
});
}; };
// This button should actually Download because usercontent/ will try to click itself // This button should actually Download because usercontent/ will try to click itself
@ -212,11 +206,11 @@ export default class MFileBody extends React.Component {
return ( return (
<span className="mx_MFileBody"> <span className="mx_MFileBody">
{ placeholder } { placeholder }
<div className="mx_MFileBody_download"> { showDownloadLink && <div className="mx_MFileBody_download">
<AccessibleButton onClick={decrypt}> <AccessibleButton onClick={decrypt}>
{ _t("Decrypt %(text)s", { text: text }) } { _t("Decrypt %(text)s", { text: text }) }
</AccessibleButton> </AccessibleButton>
</div> </div> }
</span> </span>
); );
} }
@ -224,9 +218,9 @@ export default class MFileBody extends React.Component {
// 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,
// Set a download attribute for encrypted files so that the file // Set a download attribute for encrypted files so that the file
// will have the correct name when the user tries to download it. // will have the correct name when the user tries to download it.
@ -234,7 +228,7 @@ export default class MFileBody extends React.Component {
download: fileName, download: fileName,
textContent: _t("Download %(text)s", { text: text }), textContent: _t("Download %(text)s", { text: text }),
// only auto-download if a user triggered this iframe explicitly // only auto-download if a user triggered this iframe explicitly
auto: !this.props.decryptedBlob, auto: this.userDidClick,
}, "*"); }, "*");
}; };
@ -244,21 +238,21 @@ export default class MFileBody extends React.Component {
return ( return (
<span className="mx_MFileBody"> <span className="mx_MFileBody">
{ placeholder } { placeholder }
<div className="mx_MFileBody_download"> { showDownloadLink && <div className="mx_MFileBody_download">
<div style={{ display: "none" }}> <div style={{ display: "none" }}>
{ /* { /*
* Add dummy copy of the "a" tag * Add dummy copy of the "a" tag
* We'll use it to learn how the download link * We'll use it to learn how the download link
* would have been styled if it was rendered inline. * would have been styled if it was rendered inline.
*/ } */ }
<a ref={this._dummyLink} /> <a ref={this.dummyLink} />
</div> </div>
<iframe <iframe
src={url} src={url}
onLoad={onIframeLoad} onLoad={onIframeLoad}
ref={this._iframe} ref={this.iframe}
sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation" /> sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation" />
</div> </div> }
</span> </span>
); );
} else if (contentUrl) { } else if (contentUrl) {
@ -289,7 +283,7 @@ export default class MFileBody extends React.Component {
// Start a fetch for the download // Start a fetch for the download
// Based upon https://stackoverflow.com/a/49500465 // Based upon https://stackoverflow.com/a/49500465
fetch(contentUrl).then((response) => response.blob()).then((blob) => { this.props.mediaEventHelper.sourceBlob.value.then((blob) => {
const blobUrl = URL.createObjectURL(blob); const blobUrl = URL.createObjectURL(blob);
// We have to create an anchor to download the file // We have to create an anchor to download the file
@ -306,36 +300,20 @@ export default class MFileBody extends React.Component {
downloadProps["download"] = fileName; downloadProps["download"] = fileName;
} }
// If the attachment is not encrypted then we check whether we
// are being displayed in the room timeline or in a list of
// files in the right hand side of the screen.
if (this.props.tileShape === TileShape.FileGrid) {
return ( return (
<span className="mx_MFileBody"> <span className="mx_MFileBody">
{ placeholder } { placeholder }
<div className="mx_MFileBody_download"> { showDownloadLink && <div className="mx_MFileBody_download">
<a className="mx_MFileBody_downloadLink" {...downloadProps}>
{ fileName }
</a>
<div className="mx_MImageBody_size">
{ content.info && content.info.size ? filesize(content.info.size) : "" }
</div>
</div>
</span>
);
} else {
return (
<span className="mx_MFileBody">
{ placeholder }
<div className="mx_MFileBody_download">
<a {...downloadProps}> <a {...downloadProps}>
<span className="mx_MFileBody_download_icon" /> <span className="mx_MFileBody_download_icon" />
{ _t("Download %(text)s", { text: text }) } { _t("Download %(text)s", { text: text }) }
</a> </a>
</div> { this.props.tileShape === TileShape.FileGrid && <div className="mx_MImageBody_size">
{ content.info && content.info.size ? filesize(content.info.size) : "" }
</div> }
</div> }
</span> </span>
); );
}
} else { } else {
const extra = text ? (': ' + text) : ''; const extra = text ? (': ' + text) : '';
return <span className="mx_MFileBody"> return <span className="mx_MFileBody">

View file

@ -1,6 +1,5 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Copyright 2018 New Vector Ltd
Copyright 2018, 2019 Michael Telatynski <7t3chguy@gmail.com> Copyright 2018, 2019 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
@ -21,7 +20,6 @@ import { Blurhash } from "react-blurhash";
import MFileBody from './MFileBody'; import MFileBody from './MFileBody';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import { decryptFile } from '../../../utils/DecryptFile';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
@ -29,24 +27,10 @@ import InlineSpinner from '../elements/InlineSpinner';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromContent } from "../../../customisations/Media"; import { mediaFromContent } from "../../../customisations/Media";
import { BLURHASH_FIELD } from "../../../ContentMessages"; import { BLURHASH_FIELD } from "../../../ContentMessages";
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
import { IMediaEventContent } from '../../../customisations/models/IMediaEventContent'; import { IMediaEventContent } from '../../../customisations/models/IMediaEventContent';
import ImageView from '../elements/ImageView'; import ImageView from '../elements/ImageView';
import { SyncState } from 'matrix-js-sdk/src/sync.api'; import { SyncState } from 'matrix-js-sdk/src/sync.api';
import { IBodyProps } from "./IBodyProps";
export interface IProps {
/* the MatrixEvent to show */
mxEvent: MatrixEvent;
/* called when the image has loaded */
onHeightChanged(): void;
/* the maximum image height to use */
maxImageHeight?: number;
/* the permalinkCreator */
permalinkCreator?: RoomPermalinkCreator;
}
interface IState { interface IState {
decryptedUrl?: string; decryptedUrl?: string;
@ -64,12 +48,12 @@ interface IState {
} }
@replaceableComponent("views.messages.MImageBody") @replaceableComponent("views.messages.MImageBody")
export default class MImageBody extends React.Component<IProps, IState> { export default class MImageBody extends React.Component<IBodyProps, IState> {
static contextType = MatrixClientContext; static contextType = MatrixClientContext;
private unmounted = true; private unmounted = true;
private image = createRef<HTMLImageElement>(); private image = createRef<HTMLImageElement>();
constructor(props: IProps) { constructor(props: IBodyProps) {
super(props); super(props);
this.state = { this.state = {
@ -257,38 +241,23 @@ export default class MImageBody extends React.Component<IProps, IState> {
} }
} }
private downloadImage(): void { private async downloadImage() {
const content = this.props.mxEvent.getContent(); if (this.props.mediaEventHelper.media.isEncrypted && this.state.decryptedUrl === null) {
if (content.file !== undefined && this.state.decryptedUrl === null) { try {
let thumbnailPromise = Promise.resolve(null); const thumbnailUrl = await this.props.mediaEventHelper.thumbnailUrl.value;
if (content.info && content.info.thumbnail_file) {
thumbnailPromise = decryptFile(
content.info.thumbnail_file,
).then(function(blob) {
return URL.createObjectURL(blob);
});
}
let decryptedBlob;
thumbnailPromise.then((thumbnailUrl) => {
return decryptFile(content.file).then(function(blob) {
decryptedBlob = blob;
return URL.createObjectURL(blob);
}).then((contentUrl) => {
if (this.unmounted) return;
this.setState({ this.setState({
decryptedUrl: contentUrl, decryptedUrl: await this.props.mediaEventHelper.sourceUrl.value,
decryptedThumbnailUrl: thumbnailUrl, decryptedThumbnailUrl: thumbnailUrl,
decryptedBlob: decryptedBlob, decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
}); });
}); } catch (err) {
}).catch((err) => {
if (this.unmounted) return; if (this.unmounted) return;
console.warn("Unable to decrypt attachment: ", err); console.warn("Unable to decrypt attachment: ", err);
// Set a placeholder image when we can't decrypt the image. // Set a placeholder image when we can't decrypt the image.
this.setState({ this.setState({
error: err, error: err,
}); });
}); }
} }
} }
@ -300,22 +269,15 @@ export default class MImageBody extends React.Component<IProps, IState> {
localStorage.getItem("mx_ShowImage_" + this.props.mxEvent.getId()) === "true"; localStorage.getItem("mx_ShowImage_" + this.props.mxEvent.getId()) === "true";
if (showImage) { if (showImage) {
// Don't download anything becaue we don't want to display anything. // noinspection JSIgnoredPromiseFromCall
this.downloadImage(); this.downloadImage();
this.setState({ showImage: true }); this.setState({ showImage: true });
} } // else don't download anything because we don't want to display anything.
} }
componentWillUnmount() { componentWillUnmount() {
this.unmounted = true; this.unmounted = true;
this.context.removeListener('sync', this.onClientSync); this.context.removeListener('sync', this.onClientSync);
if (this.state.decryptedUrl) {
URL.revokeObjectURL(this.state.decryptedUrl);
}
if (this.state.decryptedThumbnailUrl) {
URL.revokeObjectURL(this.state.decryptedThumbnailUrl);
}
} }
protected messageContent( protected messageContent(
@ -445,7 +407,10 @@ export default class MImageBody extends React.Component<IProps, IState> {
// Overidden by MStickerBody // Overidden by MStickerBody
protected getFileBody(): JSX.Element { protected getFileBody(): JSX.Element {
return <MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />; // We only ever need the download bar if we're appearing outside of the timeline
if (this.props.tileShape) {
return <MFileBody {...this.props} showGenericPlaceholder={false} />;
}
} }
render() { render() {

View file

@ -33,7 +33,7 @@ export default class MImageReplyBody extends MImageBody {
// Don't show "Download this_file.png ..." // Don't show "Download this_file.png ..."
public getFileBody(): JSX.Element { public getFileBody(): JSX.Element {
return presentableTextForFile(this.props.mxEvent.getContent()); return <>{ presentableTextForFile(this.props.mxEvent.getContent()) }</>;
} }
render() { render() {

View file

@ -1,6 +1,5 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -18,21 +17,15 @@ limitations under the License.
import React from 'react'; import React from 'react';
import { decode } from "blurhash"; import { decode } from "blurhash";
import MFileBody from './MFileBody';
import { decryptFile } from '../../../utils/DecryptFile';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import InlineSpinner from '../elements/InlineSpinner'; import InlineSpinner from '../elements/InlineSpinner';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromContent } from "../../../customisations/Media"; import { mediaFromContent } from "../../../customisations/Media";
import { BLURHASH_FIELD } from "../../../ContentMessages"; import { BLURHASH_FIELD } from "../../../ContentMessages";
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
interface IProps { import { IBodyProps } from "./IBodyProps";
/* the MatrixEvent to show */ import MFileBody from "./MFileBody";
mxEvent: any;
/* called when the video has loaded */
onHeightChanged: () => void;
}
interface IState { interface IState {
decryptedUrl?: string; decryptedUrl?: string;
@ -45,11 +38,12 @@ interface IState {
} }
@replaceableComponent("views.messages.MVideoBody") @replaceableComponent("views.messages.MVideoBody")
export default class MVideoBody extends React.PureComponent<IProps, IState> { export default class MVideoBody extends React.PureComponent<IBodyProps, IState> {
private videoRef = React.createRef<HTMLVideoElement>(); private videoRef = React.createRef<HTMLVideoElement>();
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
fetchingData: false, fetchingData: false,
decryptedUrl: null, decryptedUrl: null,
@ -97,7 +91,7 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
} }
private getThumbUrl(): string|null { private getThumbUrl(): string|null {
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent<IMediaEventContent>();
const media = mediaFromContent(content); const media = mediaFromContent(content);
if (media.isEncrypted && this.state.decryptedThumbnailUrl) { if (media.isEncrypted && this.state.decryptedThumbnailUrl) {
@ -139,7 +133,7 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
posterLoading: true, posterLoading: true,
}); });
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent<IMediaEventContent>();
const media = mediaFromContent(content); const media = mediaFromContent(content);
if (media.hasThumbnail) { if (media.hasThumbnail) {
const image = new Image(); const image = new Image();
@ -152,30 +146,22 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
async componentDidMount() { async componentDidMount() {
const autoplay = SettingsStore.getValue("autoplayGifsAndVideos") as boolean; const autoplay = SettingsStore.getValue("autoplayGifsAndVideos") as boolean;
const content = this.props.mxEvent.getContent();
this.loadBlurhash(); this.loadBlurhash();
if (content.file !== undefined && this.state.decryptedUrl === null) { if (this.props.mediaEventHelper.media.isEncrypted && this.state.decryptedUrl === null) {
let thumbnailPromise = Promise.resolve(null);
if (content?.info?.thumbnail_file) {
thumbnailPromise = decryptFile(content.info.thumbnail_file)
.then(blob => URL.createObjectURL(blob));
}
try { try {
const thumbnailUrl = await thumbnailPromise; const thumbnailUrl = await this.props.mediaEventHelper.thumbnailUrl.value;
if (autoplay) { if (autoplay) {
console.log("Preloading video"); console.log("Preloading video");
const decryptedBlob = await decryptFile(content.file);
const contentUrl = URL.createObjectURL(decryptedBlob);
this.setState({ this.setState({
decryptedUrl: contentUrl, decryptedUrl: await this.props.mediaEventHelper.sourceUrl.value,
decryptedThumbnailUrl: thumbnailUrl, decryptedThumbnailUrl: thumbnailUrl,
decryptedBlob: decryptedBlob, decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
}); });
this.props.onHeightChanged(); this.props.onHeightChanged();
} else { } else {
console.log("NOT preloading video"); console.log("NOT preloading video");
const content = this.props.mxEvent.getContent<IMediaEventContent>();
this.setState({ this.setState({
// For Chrome and Electron, we need to set some non-empty `src` to // For Chrome and Electron, we need to set some non-empty `src` to
// enable the play button. Firefox does not seem to care either // enable the play button. Firefox does not seem to care either
@ -195,15 +181,6 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
} }
} }
componentWillUnmount() {
if (this.state.decryptedUrl) {
URL.revokeObjectURL(this.state.decryptedUrl);
}
if (this.state.decryptedThumbnailUrl) {
URL.revokeObjectURL(this.state.decryptedThumbnailUrl);
}
}
private videoOnPlay = async () => { private videoOnPlay = async () => {
if (this.hasContentUrl() || this.state.fetchingData || this.state.error) { if (this.hasContentUrl() || this.state.fetchingData || this.state.error) {
// We have the file, we are fetching the file, or there is an error. // We have the file, we are fetching the file, or there is an error.
@ -213,18 +190,15 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
// To stop subsequent download attempts // To stop subsequent download attempts
fetchingData: true, fetchingData: true,
}); });
const content = this.props.mxEvent.getContent(); if (!this.props.mediaEventHelper.media.isEncrypted) {
if (!content.file) {
this.setState({ this.setState({
error: "No file given in content", error: "No file given in content",
}); });
return; return;
} }
const decryptedBlob = await decryptFile(content.file);
const contentUrl = URL.createObjectURL(decryptedBlob);
this.setState({ this.setState({
decryptedUrl: contentUrl, decryptedUrl: await this.props.mediaEventHelper.sourceUrl.value,
decryptedBlob: decryptedBlob, decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
fetchingData: false, fetchingData: false,
}, () => { }, () => {
if (!this.videoRef.current) return; if (!this.videoRef.current) return;
@ -295,7 +269,7 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
onPlay={this.videoOnPlay} onPlay={this.videoOnPlay}
> >
</video> </video>
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} /> { this.props.tileShape && <MFileBody {...this.props} showGenericPlaceholder={false} /> }
</span> </span>
); );
} }

View file

@ -15,73 +15,16 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { Playback } from "../../../voice/Playback";
import MFileBody from "./MFileBody";
import InlineSpinner from '../elements/InlineSpinner'; import InlineSpinner from '../elements/InlineSpinner';
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { mediaFromContent } from "../../../customisations/Media";
import { decryptFile } from "../../../utils/DecryptFile";
import RecordingPlayback from "../audio_messages/RecordingPlayback"; import RecordingPlayback from "../audio_messages/RecordingPlayback";
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent"; import MAudioBody from "./MAudioBody";
import { TileShape } from "../rooms/EventTile"; import MFileBody from "./MFileBody";
interface IProps {
mxEvent: MatrixEvent;
tileShape?: TileShape;
}
interface IState {
error?: Error;
playback?: Playback;
decryptedBlob?: Blob;
}
@replaceableComponent("views.messages.MVoiceMessageBody") @replaceableComponent("views.messages.MVoiceMessageBody")
export default class MVoiceMessageBody extends React.PureComponent<IProps, IState> { export default class MVoiceMessageBody extends MAudioBody {
constructor(props: IProps) { // A voice message is an audio file but rendered in a special way.
super(props);
this.state = {};
}
public async componentDidMount() {
let buffer: ArrayBuffer;
const content: IMediaEventContent = this.props.mxEvent.getContent();
const media = mediaFromContent(content);
if (media.isEncrypted) {
try {
const blob = await decryptFile(content.file);
buffer = await blob.arrayBuffer();
this.setState({ decryptedBlob: blob });
} catch (e) {
this.setState({ error: e });
console.warn("Unable to decrypt voice message", e);
return; // stop processing the audio file
}
} else {
try {
buffer = await media.downloadSource().then(r => r.blob()).then(r => r.arrayBuffer());
} catch (e) {
this.setState({ error: e });
console.warn("Unable to download voice message", e);
return; // stop processing the audio file
}
}
const waveform = content?.["org.matrix.msc1767.audio"]?.waveform?.map(p => p / 1024);
// We should have a buffer to work with now: let's set it up
const playback = new Playback(buffer, waveform);
this.setState({ playback });
// Note: the RecordingPlayback component will handle preparing the Playback class for us.
}
public componentWillUnmount() {
this.state.playback?.destroy();
}
public render() { public render() {
if (this.state.error) { if (this.state.error) {
// TODO: @@TR: Verify error state // TODO: @@TR: Verify error state
@ -106,7 +49,7 @@ export default class MVoiceMessageBody extends React.PureComponent<IProps, IStat
return ( return (
<span className="mx_MVoiceMessageBody"> <span className="mx_MVoiceMessageBody">
<RecordingPlayback playback={this.state.playback} tileShape={this.props.tileShape} /> <RecordingPlayback playback={this.state.playback} tileShape={this.props.tileShape} />
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} /> { this.props.tileShape && <MFileBody {...this.props} showGenericPlaceholder={false} /> }
</span> </span>
); );
} }

View file

@ -15,18 +15,14 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import MAudioBody from "./MAudioBody"; import MAudioBody from "./MAudioBody";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import MVoiceMessageBody from "./MVoiceMessageBody"; import MVoiceMessageBody from "./MVoiceMessageBody";
import { IBodyProps } from "./IBodyProps";
interface IProps {
mxEvent: MatrixEvent;
}
@replaceableComponent("views.messages.MVoiceOrAudioBody") @replaceableComponent("views.messages.MVoiceOrAudioBody")
export default class MVoiceOrAudioBody extends React.PureComponent<IProps> { export default class MVoiceOrAudioBody extends React.PureComponent<IBodyProps> {
public render() { public render() {
// MSC2516 is a legacy identifier. See https://github.com/matrix-org/matrix-doc/pull/3245 // MSC2516 is a legacy identifier. See https://github.com/matrix-org/matrix-doc/pull/3245
const isVoiceMessage = !!this.props.mxEvent.getContent()['org.matrix.msc2516.voice'] const isVoiceMessage = !!this.props.mxEvent.getContent()['org.matrix.msc2516.voice']

View file

@ -32,6 +32,8 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
import { canCancel } from "../context_menus/MessageContextMenu"; 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 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();
@ -267,6 +269,15 @@ export default class MessageActionBar extends React.PureComponent {
key="react" key="react"
/>); />);
} }
// XXX: Assuming that the underlying tile will be a media event if it is eligible media.
if (MediaEventHelper.isEligible(this.props.mxEvent)) {
toolbarOpts.splice(0, 0, <DownloadActionButton
mxEvent={this.props.mxEvent}
mediaEventHelperGet={() => this.props.getTile?.().getMediaHelper?.()}
key="download"
/>);
}
} }
if (allowCancel) { if (allowCancel) {

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,90 +15,98 @@ limitations under the License.
*/ */
import React, { createRef } from 'react'; import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { Mjolnir } from "../../../mjolnir/Mjolnir"; import { Mjolnir } from "../../../mjolnir/Mjolnir";
import RedactedBody from "./RedactedBody"; import RedactedBody from "./RedactedBody";
import UnknownBody from "./UnknownBody"; import UnknownBody from "./UnknownBody";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { IMediaBody } from "./IMediaBody";
import { IOperableEventTile } from "../context_menus/MessageContextMenu";
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import { ReactAnyComponent } from "../../../@types/common";
import { EventType, MsgType } from "matrix-js-sdk/src/@types/event";
import { IBodyProps } from "./IBodyProps";
@replaceableComponent("views.messages.MessageEvent") // onMessageAllowed is handled internally
export default class MessageEvent extends React.Component { interface IProps extends Omit<IBodyProps, "onMessageAllowed"> {
static propTypes = {
/* the MatrixEvent to show */
mxEvent: PropTypes.object.isRequired,
/* a list of words to highlight */
highlights: PropTypes.array,
/* link URL for the highlights */
highlightLink: PropTypes.string,
/* should show URL previews for this event */
showUrlPreview: PropTypes.bool,
/* callback called when dynamic content in events are loaded */
onHeightChanged: PropTypes.func,
/* the shape of the tile, used */
tileShape: PropTypes.string, // TODO: Use TileShape enum
/* the maximum image height to use, if the event is an image */
maxImageHeight: PropTypes.number,
/* overrides for the msgtype-specific components, used by ReplyTile to override file rendering */ /* overrides for the msgtype-specific components, used by ReplyTile to override file rendering */
overrideBodyTypes: PropTypes.object, overrideBodyTypes?: Record<string, React.Component>;
overrideEventTypes: PropTypes.object, overrideEventTypes?: Record<string, React.Component>;
/* the permalinkCreator */
permalinkCreator: PropTypes.object,
};
constructor(props) {
super(props);
this._body = createRef();
} }
getEventTileOps = () => { @replaceableComponent("views.messages.MessageEvent")
return this._body.current && this._body.current.getEventTileOps ? this._body.current.getEventTileOps() : null; export default class MessageEvent extends React.Component<IProps> implements IMediaBody, IOperableEventTile {
}; private body: React.RefObject<React.Component | IOperableEventTile> = createRef();
private mediaHelper: MediaEventHelper;
onTileUpdate = () => { public constructor(props: IProps) {
this.forceUpdate(); super(props);
};
render() { if (MediaEventHelper.isEligible(this.props.mxEvent)) {
const bodyTypes = { this.mediaHelper = new MediaEventHelper(this.props.mxEvent);
'm.text': sdk.getComponent('messages.TextualBody'), }
'm.notice': sdk.getComponent('messages.TextualBody'), }
'm.emote': sdk.getComponent('messages.TextualBody'),
'm.image': sdk.getComponent('messages.MImageBody'), public componentWillUnmount() {
'm.file': sdk.getComponent('messages.MFileBody'), this.mediaHelper?.destroy();
'm.audio': sdk.getComponent('messages.MVoiceOrAudioBody'), }
'm.video': sdk.getComponent('messages.MVideoBody'),
public componentDidUpdate(prevProps: Readonly<IProps>) {
if (this.props.mxEvent !== prevProps.mxEvent && MediaEventHelper.isEligible(this.props.mxEvent)) {
this.mediaHelper?.destroy();
this.mediaHelper = new MediaEventHelper(this.props.mxEvent);
}
}
private get bodyTypes(): Record<string, React.Component> {
return {
[MsgType.Text]: sdk.getComponent('messages.TextualBody'),
[MsgType.Notice]: sdk.getComponent('messages.TextualBody'),
[MsgType.Emote]: sdk.getComponent('messages.TextualBody'),
[MsgType.Image]: sdk.getComponent('messages.MImageBody'),
[MsgType.File]: sdk.getComponent('messages.MFileBody'),
[MsgType.Audio]: sdk.getComponent('messages.MVoiceOrAudioBody'),
[MsgType.Video]: sdk.getComponent('messages.MVideoBody'),
...(this.props.overrideBodyTypes || {}), ...(this.props.overrideBodyTypes || {}),
}; };
const evTypes = { }
'm.sticker': sdk.getComponent('messages.MStickerBody'),
private get evTypes(): Record<string, React.Component> {
return {
[EventType.Sticker]: sdk.getComponent('messages.MStickerBody'),
...(this.props.overrideEventTypes || {}), ...(this.props.overrideEventTypes || {}),
}; };
}
public getEventTileOps = () => {
return (this.body.current as IOperableEventTile)?.getEventTileOps?.() || null;
};
public getMediaHelper() {
return this.mediaHelper;
}
private onTileUpdate = () => {
this.forceUpdate();
};
public render() {
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
const type = this.props.mxEvent.getType(); const type = this.props.mxEvent.getType();
const msgtype = content.msgtype; const msgtype = content.msgtype;
let BodyType = RedactedBody; let BodyType: ReactAnyComponent = RedactedBody;
if (!this.props.mxEvent.isRedacted()) { if (!this.props.mxEvent.isRedacted()) {
// only resolve BodyType if event is not redacted // only resolve BodyType if event is not redacted
if (type && evTypes[type]) { if (type && this.evTypes[type]) {
BodyType = evTypes[type]; BodyType = this.evTypes[type];
} else if (msgtype && bodyTypes[msgtype]) { } else if (msgtype && this.bodyTypes[msgtype]) {
BodyType = bodyTypes[msgtype]; BodyType = this.bodyTypes[msgtype];
} else if (content.url) { } else if (content.url) {
// Fallback to MFileBody if there's a content URL // Fallback to MFileBody if there's a content URL
BodyType = bodyTypes['m.file']; BodyType = this.bodyTypes[MsgType.File];
} else { } else {
// Fallback to UnknownBody otherwise if not redacted // Fallback to UnknownBody otherwise if not redacted
BodyType = UnknownBody; BodyType = UnknownBody;
@ -120,8 +128,9 @@ export default class MessageEvent extends React.Component {
} }
} }
// @ts-ignore - this is a dynamic react component
return BodyType ? <BodyType return BodyType ? <BodyType
ref={this._body} ref={this.body}
mxEvent={this.props.mxEvent} mxEvent={this.props.mxEvent}
highlights={this.props.highlights} highlights={this.props.highlights}
highlightLink={this.props.highlightLink} highlightLink={this.props.highlightLink}
@ -133,6 +142,7 @@ export default class MessageEvent extends React.Component {
onHeightChanged={this.props.onHeightChanged} onHeightChanged={this.props.onHeightChanged}
onMessageAllowed={this.onTileUpdate} onMessageAllowed={this.onTileUpdate}
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
mediaEventHelper={this.mediaHelper}
/> : null; /> : null;
} }
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2020 The Matrix.org Foundation C.I.C. Copyright 2020 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -16,17 +16,13 @@ limitations under the License.
import React, { useContext } from "react"; import React, { useContext } from "react";
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { formatFullDate } from "../../../DateUtils"; import { formatFullDate } from "../../../DateUtils";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { IBodyProps } from "./IBodyProps";
interface IProps { const RedactedBody = React.forwardRef<any, IBodyProps>(({ mxEvent }, ref) => {
mxEvent: MatrixEvent;
}
const RedactedBody = React.forwardRef<any, IProps>(({ mxEvent }, ref) => {
const cli: MatrixClient = useContext(MatrixClientContext); const cli: MatrixClient = useContext(MatrixClientContext);
let text = _t("Message deleted"); let text = _t("Message deleted");

View file

@ -17,7 +17,6 @@ limitations under the License.
import React, { createRef, SyntheticEvent } from 'react'; import React, { createRef, SyntheticEvent } from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import highlight from 'highlight.js'; import highlight from 'highlight.js';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { MsgType } from "matrix-js-sdk/src/@types/event"; import { MsgType } from "matrix-js-sdk/src/@types/event";
import * as HtmlUtils from '../../../HtmlUtils'; import * as HtmlUtils from '../../../HtmlUtils';
@ -38,37 +37,13 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
import UIStore from "../../../stores/UIStore"; import UIStore from "../../../stores/UIStore";
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload"; import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import { TileShape } from '../rooms/EventTile';
import EditorStateTransfer from "../../../utils/EditorStateTransfer";
import GenericTextContextMenu from "../context_menus/GenericTextContextMenu"; import GenericTextContextMenu from "../context_menus/GenericTextContextMenu";
import Spoiler from "../elements/Spoiler"; import Spoiler from "../elements/Spoiler";
import QuestionDialog from "../dialogs/QuestionDialog"; import QuestionDialog from "../dialogs/QuestionDialog";
import MessageEditHistoryDialog from "../dialogs/MessageEditHistoryDialog"; import MessageEditHistoryDialog from "../dialogs/MessageEditHistoryDialog";
import EditMessageComposer from '../rooms/EditMessageComposer'; import EditMessageComposer from '../rooms/EditMessageComposer';
import LinkPreviewGroup from '../rooms/LinkPreviewGroup'; import LinkPreviewGroup from '../rooms/LinkPreviewGroup';
import { IBodyProps } from "./IBodyProps";
interface IProps {
/* the MatrixEvent to show */
mxEvent: MatrixEvent;
/* a list of words to highlight */
highlights?: string[];
/* link URL for the highlights */
highlightLink?: string;
/* should show URL previews for this event */
showUrlPreview?: boolean;
/* the shape of the tile, used */
tileShape?: TileShape;
editState?: EditorStateTransfer;
replacingEventId?: string;
/* callback for when our widget has loaded */
onHeightChanged(): void;
}
interface IState { interface IState {
// the URLs (if any) to be previewed with a LinkPreviewWidget inside this TextualBody. // the URLs (if any) to be previewed with a LinkPreviewWidget inside this TextualBody.
@ -79,7 +54,7 @@ interface IState {
} }
@replaceableComponent("views.messages.TextualBody") @replaceableComponent("views.messages.TextualBody")
export default class TextualBody extends React.Component<IProps, IState> { export default class TextualBody extends React.Component<IBodyProps, IState> {
private readonly contentRef = createRef<HTMLSpanElement>(); private readonly contentRef = createRef<HTMLSpanElement>();
private unmounted = false; private unmounted = false;

View file

@ -85,6 +85,7 @@ export default class PinnedEventTile extends React.Component<IProps> {
<div className="mx_PinnedEventTile_message"> <div className="mx_PinnedEventTile_message">
<MessageEvent <MessageEvent
mxEvent={this.props.event} mxEvent={this.props.event}
// @ts-ignore - complaining that className is invalid when it's not
className="mx_PinnedEventTile_body" className="mx_PinnedEventTile_body"
maxImageHeight={150} maxImageHeight={150}
onHeightChanged={() => {}} // we need to give this, apparently onHeightChanged={() => {}} // we need to give this, apparently

View file

@ -1863,6 +1863,8 @@
"Saturday": "Saturday", "Saturday": "Saturday",
"Today": "Today", "Today": "Today",
"Yesterday": "Yesterday", "Yesterday": "Yesterday",
"Downloading": "Downloading",
"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.",
@ -1995,7 +1997,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",

View file

@ -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";

59
src/utils/LazyValue.ts Normal file
View file

@ -0,0 +1,59 @@
/*
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.
*/
/**
* Utility class for lazily getting a variable.
*/
export class LazyValue<T> {
private val: T;
private prom: Promise<T>;
private done = false;
public constructor(private getFn: () => Promise<T>) {
}
/**
* Whether or not a cached value is present.
*/
public get present(): boolean {
// we use a tracking variable just in case the final value is falsey
return this.done;
}
/**
* Gets the value without invoking a get. May be undefined until the
* value is fetched properly.
*/
public get cachedValue(): T {
return this.val;
}
/**
* Gets a promise which resolves to the value, eventually.
*/
public get value(): Promise<T> {
if (this.prom) return this.prom;
this.prom = this.getFn();
// Fork the promise chain to avoid accidentally making it return undefined always.
this.prom.then(v => {
this.val = v;
this.done = true;
});
return this.prom;
}
}

View file

@ -0,0 +1,119 @@
/*
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 { LazyValue } from "./LazyValue";
import { Media, mediaFromContent } from "../customisations/Media";
import { decryptFile } from "./DecryptFile";
import { IMediaEventContent } from "../customisations/models/IMediaEventContent";
import { IDestroyable } from "./IDestroyable";
import { EventType, MsgType } from "matrix-js-sdk/src/@types/event";
// TODO: We should consider caching the blobs. https://github.com/vector-im/element-web/issues/17192
export class MediaEventHelper implements IDestroyable {
// Either an HTTP or Object URL (when encrypted) to the media.
public readonly sourceUrl: LazyValue<string>;
public readonly thumbnailUrl: LazyValue<string>;
// Either the raw or decrypted (when encrypted) contents of the file.
public readonly sourceBlob: LazyValue<Blob>;
public readonly thumbnailBlob: LazyValue<Blob>;
public readonly media: Media;
public constructor(private event: MatrixEvent) {
this.sourceUrl = new LazyValue(this.prepareSourceUrl);
this.thumbnailUrl = new LazyValue(this.prepareThumbnailUrl);
this.sourceBlob = new LazyValue(this.fetchSource);
this.thumbnailBlob = new LazyValue(this.fetchThumbnail);
this.media = mediaFromContent(this.event.getContent());
}
public get fileName(): string {
return this.event.getContent<IMediaEventContent>().body || "download";
}
public destroy() {
if (this.media.isEncrypted) {
if (this.sourceUrl.present) URL.revokeObjectURL(this.sourceUrl.cachedValue);
if (this.thumbnailUrl.present) URL.revokeObjectURL(this.thumbnailUrl.cachedValue);
}
}
private prepareSourceUrl = async () => {
if (this.media.isEncrypted) {
const blob = await this.sourceBlob.value;
return URL.createObjectURL(blob);
} else {
return this.media.srcHttp;
}
};
private prepareThumbnailUrl = async () => {
if (this.media.isEncrypted) {
const blob = await this.thumbnailBlob.value;
return URL.createObjectURL(blob);
} else {
return this.media.thumbnailHttp;
}
};
private fetchSource = () => {
if (this.media.isEncrypted) {
return decryptFile(this.event.getContent<IMediaEventContent>().file);
}
return this.media.downloadSource().then(r => r.blob());
};
private fetchThumbnail = () => {
if (!this.media.hasThumbnail) return Promise.resolve(null);
if (this.media.isEncrypted) {
const content = this.event.getContent<IMediaEventContent>();
if (content.info?.thumbnail_file) {
return decryptFile(content.info.thumbnail_file);
} else {
// "Should never happen"
console.warn("Media claims to have thumbnail and is encrypted, but no thumbnail_file found");
return Promise.resolve(null);
}
}
return fetch(this.media.thumbnailHttp).then(r => r.blob());
};
public static isEligible(event: MatrixEvent): boolean {
if (!event) return false;
if (event.isRedacted()) return false;
if (event.getType() === EventType.Sticker) return true;
if (event.getType() !== EventType.RoomMessage) return false;
const content = event.getContent();
const mediaMsgTypes: string[] = [
MsgType.Video,
MsgType.Audio,
MsgType.Image,
MsgType.File,
];
if (mediaMsgTypes.includes(content.msgtype)) return true;
if (typeof(content.url) === 'string') return true;
// Finally, it's probably not media
return false;
}
}