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 {
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.
*/
import { JSXElementConstructor } from "react";
import React, { JSXElementConstructor } from "react";
// Based on https://stackoverflow.com/a/53229857/3532235
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 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;
}
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;

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 { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { Playback } from "../../../voice/Playback";
import MFileBody from "./MFileBody";
import InlineSpinner from '../elements/InlineSpinner';
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";
interface IProps {
mxEvent: MatrixEvent;
}
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
import MFileBody from "./MFileBody";
import { IBodyProps } from "./IBodyProps";
interface IState {
error?: Error;
playback?: Playback;
decryptedBlob?: Blob;
}
@replaceableComponent("views.messages.MAudioBody")
export default class MAudioBody extends React.PureComponent<IProps, IState> {
constructor(props: IProps) {
export default class MAudioBody extends React.PureComponent<IBodyProps, IState> {
constructor(props: IBodyProps) {
super(props);
this.state = {};
@ -46,33 +39,34 @@ export default class MAudioBody extends React.PureComponent<IProps, IState> {
public async componentDidMount() {
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);
const blob = await this.props.mediaEventHelper.sourceBlob.value;
buffer = await blob.arrayBuffer();
this.setState({ decryptedBlob: blob });
} catch (e) {
this.setState({ error: e });
console.warn("Unable to decrypt audio 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 audio message", e);
return; // stop processing the audio file
}
} catch (e) {
this.setState({ error: e });
console.warn("Unable to decrypt/download audio message", e);
return; // stop processing the audio file
}
// 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);
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() {
@ -103,7 +97,7 @@ export default class MAudioBody extends React.PureComponent<IProps, IState> {
return (
<span className="mx_MAudioBody">
<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>
);
}

View file

@ -15,26 +15,29 @@ limitations under the License.
*/
import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import filesize from 'filesize';
import { _t } from '../../../languageHandler';
import { decryptFile } from '../../../utils/DecryptFile';
import Modal from '../../../Modal';
import AccessibleButton from "../elements/AccessibleButton";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromContent } from "../../../customisations/Media";
import ErrorDialog from "../dialogs/ErrorDialog";
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() {
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());
downloadIconUrl = "data:image/svg+xml;base64," + window.btoa(svg);
DOWNLOAD_ICON_URL = "data:image/svg+xml;base64," + window.btoa(svg);
}
// Cache the asset immediately
// noinspection JSIgnoredPromiseFromCall
cacheDownloadIcon();
// 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.
* @return {string} The CSS style encoded as a string.
*/
function computedStyle(element) {
export function computedStyle(element: HTMLElement) {
if (!element) {
return "";
}
@ -98,7 +101,7 @@ function computedStyle(element) {
* @param {boolean} withSize Whether to include size information. Default true.
* @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");
if (content.body && content.body.length > 0) {
// The content body should be the name of the file including a
@ -119,53 +122,48 @@ export function presentableTextForFile(content, withSize = true) {
return linkText;
}
@replaceableComponent("views.messages.MFileBody")
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. */
showGenericPlaceholder: PropTypes.bool,
};
interface IProps extends IBodyProps {
/* whether or not to show the default placeholder for the file. Defaults to true. */
showGenericPlaceholder: boolean;
}
interface IState {
decryptedBlob?: Blob;
}
@replaceableComponent("views.messages.MFileBody")
export default class MFileBody extends React.Component<IProps, IState> {
static defaultProps = {
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);
this.state = {
decryptedBlob: (this.props.decryptedBlob ? this.props.decryptedBlob : null),
};
this._iframe = createRef();
this._dummyLink = createRef();
this.state = {};
}
_getContentUrl() {
private getContentUrl(): string {
const media = mediaFromContent(this.props.mxEvent.getContent());
return media.srcHttp;
}
componentDidUpdate(prevProps, prevState) {
public componentDidUpdate(prevProps, prevState) {
if (this.props.onHeightChanged && !prevState.decryptedBlob && this.state.decryptedBlob) {
this.props.onHeightChanged();
}
}
render() {
const content = this.props.mxEvent.getContent();
public render() {
const content = this.props.mxEvent.getContent<IMediaEventContent>();
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 contentUrl = this._getContentUrl();
const contentUrl = this.getContentUrl();
const fileSize = content.info ? content.info.size : null;
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 (this.state.decryptedBlob === null) {
if (!this.state.decryptedBlob) {
// Need to decrypt the attachment
// Wait for the user to click on the link before downloading
// and decrypting the attachment.
let decrypting = false;
const decrypt = (e) => {
if (decrypting) {
return false;
}
decrypting = true;
decryptFile(content.file).then((blob) => {
const decrypt = async () => {
try {
this.userDidClick = true;
this.setState({
decryptedBlob: blob,
decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
});
}).catch((err) => {
} catch (err) {
console.warn("Unable to decrypt attachment: ", err);
Modal.createTrackedDialog('Error decrypting attachment', '', ErrorDialog, {
title: _t("Error"),
description: _t("Error decrypting attachment"),
});
}).finally(() => {
decrypting = false;
});
}
};
// This button should actually Download because usercontent/ will try to click itself
@ -212,11 +206,11 @@ export default class MFileBody extends React.Component {
return (
<span className="mx_MFileBody">
{ placeholder }
<div className="mx_MFileBody_download">
{ showDownloadLink && <div className="mx_MFileBody_download">
<AccessibleButton onClick={decrypt}>
{ _t("Decrypt %(text)s", { text: text }) }
</AccessibleButton>
</div>
</div> }
</span>
);
}
@ -224,9 +218,9 @@ export default class MFileBody extends React.Component {
// When the iframe loads we tell it to render a download link
const onIframeLoad = (ev) => {
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.
style: computedStyle(this._dummyLink.current),
style: computedStyle(this.dummyLink.current),
blob: this.state.decryptedBlob,
// Set a download attribute for encrypted files so that the file
// 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,
textContent: _t("Download %(text)s", { text: text }),
// 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 (
<span className="mx_MFileBody">
{ placeholder }
<div className="mx_MFileBody_download">
{ showDownloadLink && <div className="mx_MFileBody_download">
<div style={{ display: "none" }}>
{ /*
* Add dummy copy of the "a" tag
* We'll use it to learn how the download link
* would have been styled if it was rendered inline.
*/ }
<a ref={this._dummyLink} />
<a ref={this.dummyLink} />
</div>
<iframe
src={url}
onLoad={onIframeLoad}
ref={this._iframe}
ref={this.iframe}
sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation" />
</div>
</div> }
</span>
);
} else if (contentUrl) {
@ -289,7 +283,7 @@ export default class MFileBody extends React.Component {
// Start a fetch for the download
// 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);
// We have to create an anchor to download the file
@ -306,36 +300,20 @@ export default class MFileBody extends React.Component {
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 (
<span className="mx_MFileBody">
{ placeholder }
<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}>
<span className="mx_MFileBody_download_icon" />
{ _t("Download %(text)s", { text: text }) }
</a>
</div>
</span>
);
}
return (
<span className="mx_MFileBody">
{ placeholder }
{ showDownloadLink && <div className="mx_MFileBody_download">
<a {...downloadProps}>
<span className="mx_MFileBody_download_icon" />
{ _t("Download %(text)s", { text: text }) }
</a>
{ this.props.tileShape === TileShape.FileGrid && <div className="mx_MImageBody_size">
{ content.info && content.info.size ? filesize(content.info.size) : "" }
</div> }
</div> }
</span>
);
} else {
const extra = text ? (': ' + text) : '';
return <span className="mx_MFileBody">

View file

@ -1,6 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Copyright 2018, 2019 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
@ -21,7 +20,6 @@ import { Blurhash } from "react-blurhash";
import MFileBody from './MFileBody';
import Modal from '../../../Modal';
import { decryptFile } from '../../../utils/DecryptFile';
import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
@ -29,24 +27,10 @@ import InlineSpinner from '../elements/InlineSpinner';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromContent } from "../../../customisations/Media";
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 ImageView from '../elements/ImageView';
import { SyncState } from 'matrix-js-sdk/src/sync.api';
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;
}
import { IBodyProps } from "./IBodyProps";
interface IState {
decryptedUrl?: string;
@ -64,12 +48,12 @@ interface IState {
}
@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;
private unmounted = true;
private image = createRef<HTMLImageElement>();
constructor(props: IProps) {
constructor(props: IBodyProps) {
super(props);
this.state = {
@ -257,38 +241,23 @@ export default class MImageBody extends React.Component<IProps, IState> {
}
}
private downloadImage(): void {
const content = this.props.mxEvent.getContent();
if (content.file !== undefined && this.state.decryptedUrl === null) {
let thumbnailPromise = Promise.resolve(null);
if (content.info && content.info.thumbnail_file) {
thumbnailPromise = decryptFile(
content.info.thumbnail_file,
).then(function(blob) {
return URL.createObjectURL(blob);
private async downloadImage() {
if (this.props.mediaEventHelper.media.isEncrypted && this.state.decryptedUrl === null) {
try {
const thumbnailUrl = await this.props.mediaEventHelper.thumbnailUrl.value;
this.setState({
decryptedUrl: await this.props.mediaEventHelper.sourceUrl.value,
decryptedThumbnailUrl: thumbnailUrl,
decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
});
}
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({
decryptedUrl: contentUrl,
decryptedThumbnailUrl: thumbnailUrl,
decryptedBlob: decryptedBlob,
});
});
}).catch((err) => {
} catch (err) {
if (this.unmounted) return;
console.warn("Unable to decrypt attachment: ", err);
// Set a placeholder image when we can't decrypt the image.
this.setState({
error: err,
});
});
}
}
}
@ -300,22 +269,15 @@ export default class MImageBody extends React.Component<IProps, IState> {
localStorage.getItem("mx_ShowImage_" + this.props.mxEvent.getId()) === "true";
if (showImage) {
// Don't download anything becaue we don't want to display anything.
// noinspection JSIgnoredPromiseFromCall
this.downloadImage();
this.setState({ showImage: true });
}
} // else don't download anything because we don't want to display anything.
}
componentWillUnmount() {
this.unmounted = true;
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(
@ -445,7 +407,10 @@ export default class MImageBody extends React.Component<IProps, IState> {
// Overidden by MStickerBody
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() {

View file

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

View file

@ -1,6 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2015 - 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.
@ -18,21 +17,15 @@ limitations under the License.
import React from 'react';
import { decode } from "blurhash";
import MFileBody from './MFileBody';
import { decryptFile } from '../../../utils/DecryptFile';
import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore";
import InlineSpinner from '../elements/InlineSpinner';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromContent } from "../../../customisations/Media";
import { BLURHASH_FIELD } from "../../../ContentMessages";
interface IProps {
/* the MatrixEvent to show */
mxEvent: any;
/* called when the video has loaded */
onHeightChanged: () => void;
}
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
import { IBodyProps } from "./IBodyProps";
import MFileBody from "./MFileBody";
interface IState {
decryptedUrl?: string;
@ -45,11 +38,12 @@ interface IState {
}
@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>();
constructor(props) {
super(props);
this.state = {
fetchingData: false,
decryptedUrl: null,
@ -97,7 +91,7 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
}
private getThumbUrl(): string|null {
const content = this.props.mxEvent.getContent();
const content = this.props.mxEvent.getContent<IMediaEventContent>();
const media = mediaFromContent(content);
if (media.isEncrypted && this.state.decryptedThumbnailUrl) {
@ -139,7 +133,7 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
posterLoading: true,
});
const content = this.props.mxEvent.getContent();
const content = this.props.mxEvent.getContent<IMediaEventContent>();
const media = mediaFromContent(content);
if (media.hasThumbnail) {
const image = new Image();
@ -152,30 +146,22 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
async componentDidMount() {
const autoplay = SettingsStore.getValue("autoplayGifsAndVideos") as boolean;
const content = this.props.mxEvent.getContent();
this.loadBlurhash();
if (content.file !== undefined && 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));
}
if (this.props.mediaEventHelper.media.isEncrypted && this.state.decryptedUrl === null) {
try {
const thumbnailUrl = await thumbnailPromise;
const thumbnailUrl = await this.props.mediaEventHelper.thumbnailUrl.value;
if (autoplay) {
console.log("Preloading video");
const decryptedBlob = await decryptFile(content.file);
const contentUrl = URL.createObjectURL(decryptedBlob);
this.setState({
decryptedUrl: contentUrl,
decryptedUrl: await this.props.mediaEventHelper.sourceUrl.value,
decryptedThumbnailUrl: thumbnailUrl,
decryptedBlob: decryptedBlob,
decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
});
this.props.onHeightChanged();
} else {
console.log("NOT preloading video");
const content = this.props.mxEvent.getContent<IMediaEventContent>();
this.setState({
// For Chrome and Electron, we need to set some non-empty `src` to
// 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 () => {
if (this.hasContentUrl() || this.state.fetchingData || this.state.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
fetchingData: true,
});
const content = this.props.mxEvent.getContent();
if (!content.file) {
if (!this.props.mediaEventHelper.media.isEncrypted) {
this.setState({
error: "No file given in content",
});
return;
}
const decryptedBlob = await decryptFile(content.file);
const contentUrl = URL.createObjectURL(decryptedBlob);
this.setState({
decryptedUrl: contentUrl,
decryptedBlob: decryptedBlob,
decryptedUrl: await this.props.mediaEventHelper.sourceUrl.value,
decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
fetchingData: false,
}, () => {
if (!this.videoRef.current) return;
@ -295,7 +269,7 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
onPlay={this.videoOnPlay}
>
</video>
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />
{ this.props.tileShape && <MFileBody {...this.props} showGenericPlaceholder={false} /> }
</span>
);
}

View file

@ -15,73 +15,16 @@ limitations under the License.
*/
import React from "react";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { Playback } from "../../../voice/Playback";
import MFileBody from "./MFileBody";
import InlineSpinner from '../elements/InlineSpinner';
import { _t } from "../../../languageHandler";
import { mediaFromContent } from "../../../customisations/Media";
import { decryptFile } from "../../../utils/DecryptFile";
import RecordingPlayback from "../audio_messages/RecordingPlayback";
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
import { TileShape } from "../rooms/EventTile";
interface IProps {
mxEvent: MatrixEvent;
tileShape?: TileShape;
}
interface IState {
error?: Error;
playback?: Playback;
decryptedBlob?: Blob;
}
import MAudioBody from "./MAudioBody";
import MFileBody from "./MFileBody";
@replaceableComponent("views.messages.MVoiceMessageBody")
export default class MVoiceMessageBody extends React.PureComponent<IProps, IState> {
constructor(props: IProps) {
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();
}
export default class MVoiceMessageBody extends MAudioBody {
// A voice message is an audio file but rendered in a special way.
public render() {
if (this.state.error) {
// TODO: @@TR: Verify error state
@ -106,7 +49,7 @@ export default class MVoiceMessageBody extends React.PureComponent<IProps, IStat
return (
<span className="mx_MVoiceMessageBody">
<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>
);
}

View file

@ -15,18 +15,14 @@ limitations under the License.
*/
import React from "react";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import MAudioBody from "./MAudioBody";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import SettingsStore from "../../../settings/SettingsStore";
import MVoiceMessageBody from "./MVoiceMessageBody";
interface IProps {
mxEvent: MatrixEvent;
}
import { IBodyProps } from "./IBodyProps";
@replaceableComponent("views.messages.MVoiceOrAudioBody")
export default class MVoiceOrAudioBody extends React.PureComponent<IProps> {
export default class MVoiceOrAudioBody extends React.PureComponent<IBodyProps> {
public render() {
// 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']

View file

@ -32,6 +32,8 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
import { canCancel } from "../context_menus/MessageContextMenu";
import Resend from "../../../Resend";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import DownloadActionButton from "./DownloadActionButton";
const OptionsButton = ({ mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange }) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
@ -267,6 +269,15 @@ export default class MessageActionBar extends React.PureComponent {
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) {

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");
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 PropTypes from 'prop-types';
import * as sdk from '../../../index';
import SettingsStore from "../../../settings/SettingsStore";
import { Mjolnir } from "../../../mjolnir/Mjolnir";
import RedactedBody from "./RedactedBody";
import UnknownBody from "./UnknownBody";
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";
// onMessageAllowed is handled internally
interface IProps extends Omit<IBodyProps, "onMessageAllowed"> {
/* overrides for the msgtype-specific components, used by ReplyTile to override file rendering */
overrideBodyTypes?: Record<string, React.Component>;
overrideEventTypes?: Record<string, React.Component>;
}
@replaceableComponent("views.messages.MessageEvent")
export default class MessageEvent extends React.Component {
static propTypes = {
/* the MatrixEvent to show */
mxEvent: PropTypes.object.isRequired,
export default class MessageEvent extends React.Component<IProps> implements IMediaBody, IOperableEventTile {
private body: React.RefObject<React.Component | IOperableEventTile> = createRef();
private mediaHelper: MediaEventHelper;
/* 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 */
overrideBodyTypes: PropTypes.object,
overrideEventTypes: PropTypes.object,
/* the permalinkCreator */
permalinkCreator: PropTypes.object,
};
constructor(props) {
public constructor(props: IProps) {
super(props);
this._body = createRef();
if (MediaEventHelper.isEligible(this.props.mxEvent)) {
this.mediaHelper = new MediaEventHelper(this.props.mxEvent);
}
}
getEventTileOps = () => {
return this._body.current && this._body.current.getEventTileOps ? this._body.current.getEventTileOps() : null;
};
public componentWillUnmount() {
this.mediaHelper?.destroy();
}
onTileUpdate = () => {
this.forceUpdate();
};
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);
}
}
render() {
const bodyTypes = {
'm.text': sdk.getComponent('messages.TextualBody'),
'm.notice': sdk.getComponent('messages.TextualBody'),
'm.emote': sdk.getComponent('messages.TextualBody'),
'm.image': sdk.getComponent('messages.MImageBody'),
'm.file': sdk.getComponent('messages.MFileBody'),
'm.audio': sdk.getComponent('messages.MVoiceOrAudioBody'),
'm.video': sdk.getComponent('messages.MVideoBody'),
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 || {}),
};
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 || {}),
};
}
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 type = this.props.mxEvent.getType();
const msgtype = content.msgtype;
let BodyType = RedactedBody;
let BodyType: ReactAnyComponent = RedactedBody;
if (!this.props.mxEvent.isRedacted()) {
// only resolve BodyType if event is not redacted
if (type && evTypes[type]) {
BodyType = evTypes[type];
} else if (msgtype && bodyTypes[msgtype]) {
BodyType = bodyTypes[msgtype];
if (type && this.evTypes[type]) {
BodyType = this.evTypes[type];
} else if (msgtype && this.bodyTypes[msgtype]) {
BodyType = this.bodyTypes[msgtype];
} else if (content.url) {
// Fallback to MFileBody if there's a content URL
BodyType = bodyTypes['m.file'];
BodyType = this.bodyTypes[MsgType.File];
} else {
// Fallback to UnknownBody otherwise if not redacted
BodyType = UnknownBody;
@ -120,8 +128,9 @@ export default class MessageEvent extends React.Component {
}
}
// @ts-ignore - this is a dynamic react component
return BodyType ? <BodyType
ref={this._body}
ref={this.body}
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
@ -133,6 +142,7 @@ export default class MessageEvent extends React.Component {
onHeightChanged={this.props.onHeightChanged}
onMessageAllowed={this.onTileUpdate}
permalinkCreator={this.props.permalinkCreator}
mediaEventHelper={this.mediaHelper}
/> : 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");
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 { MatrixClient } from "matrix-js-sdk/src/client";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { _t } from "../../../languageHandler";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { formatFullDate } from "../../../DateUtils";
import SettingsStore from "../../../settings/SettingsStore";
import { IBodyProps } from "./IBodyProps";
interface IProps {
mxEvent: MatrixEvent;
}
const RedactedBody = React.forwardRef<any, IProps>(({ mxEvent }, ref) => {
const RedactedBody = React.forwardRef<any, IBodyProps>(({ mxEvent }, ref) => {
const cli: MatrixClient = useContext(MatrixClientContext);
let text = _t("Message deleted");

View file

@ -17,7 +17,6 @@ limitations under the License.
import React, { createRef, SyntheticEvent } from 'react';
import ReactDOM from 'react-dom';
import highlight from 'highlight.js';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { MsgType } from "matrix-js-sdk/src/@types/event";
import * as HtmlUtils from '../../../HtmlUtils';
@ -38,37 +37,13 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
import UIStore from "../../../stores/UIStore";
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
import { Action } from "../../../dispatcher/actions";
import { TileShape } from '../rooms/EventTile';
import EditorStateTransfer from "../../../utils/EditorStateTransfer";
import GenericTextContextMenu from "../context_menus/GenericTextContextMenu";
import Spoiler from "../elements/Spoiler";
import QuestionDialog from "../dialogs/QuestionDialog";
import MessageEditHistoryDialog from "../dialogs/MessageEditHistoryDialog";
import EditMessageComposer from '../rooms/EditMessageComposer';
import LinkPreviewGroup from '../rooms/LinkPreviewGroup';
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;
}
import { IBodyProps } from "./IBodyProps";
interface IState {
// the URLs (if any) to be previewed with a LinkPreviewWidget inside this TextualBody.
@ -79,7 +54,7 @@ interface IState {
}
@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 unmounted = false;

View file

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

View file

@ -1863,6 +1863,8 @@
"Saturday": "Saturday",
"Today": "Today",
"Yesterday": "Yesterday",
"Downloading": "Downloading",
"Download": "Download",
"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 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",
"Rotate Left": "Rotate Left",
"Rotate Right": "Rotate Right",
"Download": "Download",
"Information": "Information",
"Language Dropdown": "Language Dropdown",
"%(nameList)s %(transitionList)s": "%(nameList)s %(transitionList)s",

View file

@ -1,6 +1,13 @@
let hasCalled = false;
function remoteRender(event) {
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
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;
}
}