diff --git a/src/components/views/messages/IMediaBody.ts b/src/components/views/messages/IMediaBody.ts new file mode 100644 index 0000000000..dcbdfff284 --- /dev/null +++ b/src/components/views/messages/IMediaBody.ts @@ -0,0 +1,32 @@ +/* +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 EventTile from "../rooms/EventTile"; +import { MediaEventHelper } from "../../../utils/MediaEventHelper"; + +export interface IMediaBody { + getMediaHelper(): MediaEventHelper; +} + +export function canTileDownload(tile: EventTile): boolean { + if (!tile) return false; + + // Cast so we can check for IMediaBody interface safely. + // Note that we don't cast to the IMediaBody interface as that causes IDEs + // to complain about conditions always being true. + const tileAsAny = tile; + return !!tileAsAny.getMediaHelper; +} diff --git a/src/components/views/messages/MVideoBody.tsx b/src/components/views/messages/MVideoBody.tsx index d882bb1eb0..bb58a13c4d 100644 --- a/src/components/views/messages/MVideoBody.tsx +++ b/src/components/views/messages/MVideoBody.tsx @@ -26,10 +26,14 @@ import InlineSpinner from '../elements/InlineSpinner'; import { replaceableComponent } from "../../../utils/replaceableComponent"; import { mediaFromContent } from "../../../customisations/Media"; import { BLURHASH_FIELD } from "../../../ContentMessages"; +import { IMediaBody } from "./IMediaBody"; +import { MediaEventHelper } from "../../../utils/MediaEventHelper"; +import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent"; +import { MatrixEvent } from "matrix-js-sdk/src"; interface IProps { /* the MatrixEvent to show */ - mxEvent: any; + mxEvent: MatrixEvent; /* called when the video has loaded */ onHeightChanged: () => void; } @@ -45,11 +49,13 @@ interface IState { } @replaceableComponent("views.messages.MVideoBody") -export default class MVideoBody extends React.PureComponent { +export default class MVideoBody extends React.PureComponent implements IMediaBody { private videoRef = React.createRef(); + private mediaHelper: MediaEventHelper; constructor(props) { super(props); + this.state = { fetchingData: false, decryptedUrl: null, @@ -59,6 +65,8 @@ export default class MVideoBody extends React.PureComponent { posterLoading: false, blurhashUrl: null, }; + + this.mediaHelper = new MediaEventHelper(this.props.mxEvent); } thumbScale(fullWidth: number, fullHeight: number, thumbWidth = 480, thumbHeight = 360) { @@ -82,6 +90,10 @@ export default class MVideoBody extends React.PureComponent { } } + public getMediaHelper(): MediaEventHelper { + return this.mediaHelper; + } + private getContentUrl(): string|null { const media = mediaFromContent(this.props.mxEvent.getContent()); if (media.isEncrypted) { @@ -97,7 +109,7 @@ export default class MVideoBody extends React.PureComponent { } private getThumbUrl(): string|null { - const content = this.props.mxEvent.getContent(); + const content = this.props.mxEvent.getContent(); const media = mediaFromContent(content); if (media.isEncrypted && this.state.decryptedThumbnailUrl) { @@ -139,7 +151,7 @@ export default class MVideoBody extends React.PureComponent { posterLoading: true, }); - const content = this.props.mxEvent.getContent(); + const content = this.props.mxEvent.getContent(); const media = mediaFromContent(content); if (media.hasThumbnail) { const image = new Image(); @@ -152,30 +164,22 @@ export default class MVideoBody extends React.PureComponent { 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.mediaHelper.media.isEncrypted && this.state.decryptedUrl === null) { try { - const thumbnailUrl = await thumbnailPromise; + const thumbnailUrl = await this.mediaHelper.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.mediaHelper.sourceUrl.value, decryptedThumbnailUrl: thumbnailUrl, - decryptedBlob: decryptedBlob, + decryptedBlob: await this.mediaHelper.sourceBlob.value, }); this.props.onHeightChanged(); } else { console.log("NOT preloading video"); + const content = this.props.mxEvent.getContent(); 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 @@ -202,6 +206,7 @@ export default class MVideoBody extends React.PureComponent { if (this.state.decryptedThumbnailUrl) { URL.revokeObjectURL(this.state.decryptedThumbnailUrl); } + this.mediaHelper.destroy(); } private videoOnPlay = async () => { @@ -213,18 +218,15 @@ export default class MVideoBody extends React.PureComponent { // To stop subsequent download attempts fetchingData: true, }); - const content = this.props.mxEvent.getContent(); - if (!content.file) { + if (!this.mediaHelper.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.mediaHelper.sourceUrl.value, + decryptedBlob: await this.mediaHelper.sourceBlob.value, fetchingData: false, }, () => { if (!this.videoRef.current) return; @@ -295,7 +297,7 @@ export default class MVideoBody extends React.PureComponent { onPlay={this.videoOnPlay} > - + {/**/} ); } diff --git a/src/components/views/messages/MessageActionBar.js b/src/components/views/messages/MessageActionBar.js index 7532554666..13854aebfc 100644 --- a/src/components/views/messages/MessageActionBar.js +++ b/src/components/views/messages/MessageActionBar.js @@ -32,6 +32,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent"; import { canCancel } from "../context_menus/MessageContextMenu"; import Resend from "../../../Resend"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { canTileDownload } from "./IMediaBody"; const OptionsButton = ({ mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange }) => { const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); @@ -175,6 +176,16 @@ export default class MessageActionBar extends React.PureComponent { }); }; + onDownloadClick = async (ev) => { + // TODO: Maybe just call into MFileBody and render it as null + const src = this.props.getTile().getMediaHelper(); + const a = document.createElement("a"); + a.href = await src.sourceUrl.value; + a.download = "todo.png"; + a.target = "_blank"; + a.click(); + }; + /** * Runs a given fn on the set of possible events to test. The first event * that passes the checkFn will have fn executed on it. Both functions take @@ -267,6 +278,17 @@ export default class MessageActionBar extends React.PureComponent { key="react" />); } + + const tile = this.props.getTile && this.props.getTile(); + if (canTileDownload(tile)) { + toolbarOpts.splice(0, 0, ); + } } if (allowCancel) { diff --git a/src/components/views/messages/MessageEvent.js b/src/components/views/messages/MessageEvent.js index cd071ebb34..49b50b610c 100644 --- a/src/components/views/messages/MessageEvent.js +++ b/src/components/views/messages/MessageEvent.js @@ -22,6 +22,7 @@ import { Mjolnir } from "../../../mjolnir/Mjolnir"; import RedactedBody from "./RedactedBody"; import UnknownBody from "./UnknownBody"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { IMediaBody } from "./IMediaBody"; @replaceableComponent("views.messages.MessageEvent") export default class MessageEvent extends React.Component { @@ -69,6 +70,13 @@ export default class MessageEvent extends React.Component { this.forceUpdate(); }; + getMediaHelper() { + if (!this._body.current || !this._body.current.getMediaHelper) { + return null; + } + return this._body.current.getMediaHelper(); + } + render() { const bodyTypes = { 'm.text': sdk.getComponent('messages.TextualBody'), diff --git a/src/utils/LazyValue.ts b/src/utils/LazyValue.ts new file mode 100644 index 0000000000..9cdcda489a --- /dev/null +++ b/src/utils/LazyValue.ts @@ -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 { + private val: T; + private prom: Promise; + private done = false; + + public constructor(private getFn: () => Promise) { + } + + /** + * 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 { + 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; + } +} diff --git a/src/utils/MediaEventHelper.ts b/src/utils/MediaEventHelper.ts new file mode 100644 index 0000000000..316ee54edf --- /dev/null +++ b/src/utils/MediaEventHelper.ts @@ -0,0 +1,90 @@ +/* +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"; + +// TODO: We should consider caching the blobs. https://github.com/vector-im/element-web/issues/17192 + +export class MediaEventHelper implements IDestroyable { + public readonly sourceUrl: LazyValue; + public readonly thumbnailUrl: LazyValue; + public readonly sourceBlob: LazyValue; + public readonly thumbnailBlob: LazyValue; + 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 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().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(); + 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()); + }; +}