From 0a99f76e7fc91be25a379a47f2217b2ae8f9fa4c Mon Sep 17 00:00:00 2001 From: Travis Ralston <travisr@matrix.org> Date: Wed, 14 Jul 2021 20:51:20 -0600 Subject: [PATCH 01/16] Simple POC for moving download button to action bar --- src/components/views/messages/IMediaBody.ts | 32 +++++++ src/components/views/messages/MVideoBody.tsx | 50 ++++++----- .../views/messages/MessageActionBar.js | 22 +++++ src/components/views/messages/MessageEvent.js | 8 ++ src/utils/LazyValue.ts | 59 ++++++++++++ src/utils/MediaEventHelper.ts | 90 +++++++++++++++++++ 6 files changed, 237 insertions(+), 24 deletions(-) create mode 100644 src/components/views/messages/IMediaBody.ts create mode 100644 src/utils/LazyValue.ts create mode 100644 src/utils/MediaEventHelper.ts 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 = <any>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<IProps, IState> { +export default class MVideoBody extends React.PureComponent<IProps, IState> implements IMediaBody { private videoRef = React.createRef<HTMLVideoElement>(); + private mediaHelper: MediaEventHelper; constructor(props) { super(props); + this.state = { fetchingData: false, decryptedUrl: null, @@ -59,6 +65,8 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> { 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<IProps, IState> { } } + 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<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 +151,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 +164,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.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<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 @@ -202,6 +206,7 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> { 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<IProps, IState> { // 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<IProps, IState> { onPlay={this.videoOnPlay} > </video> - <MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} /> + {/*<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />*/} </span> ); } 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, <RovingAccessibleTooltipButton + className="mx_MessageActionBar_maskButton mx_MessageActionBar_downloadButton" + title={_t("Download")} + onClick={this.onDownloadClick} + disabled={false} + key="download" + />); + } } 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<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; + } +} 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<string>; + public readonly thumbnailUrl: LazyValue<string>; + 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 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()); + }; +} From 703cf7375912898597ec42a92ca85833c242e79a Mon Sep 17 00:00:00 2001 From: Travis Ralston <travisr@matrix.org> Date: Thu, 15 Jul 2021 14:19:07 -0600 Subject: [PATCH 02/16] Convert MessageEvent to TS and hoist MediaEventHelper --- src/@types/common.ts | 3 +- .../context_menus/MessageContextMenu.tsx | 6 +- src/components/views/messages/MVideoBody.tsx | 37 +--- src/components/views/messages/MessageEvent.js | 146 --------------- .../views/messages/MessageEvent.tsx | 176 ++++++++++++++++++ src/utils/MediaEventHelper.ts | 21 +++ 6 files changed, 213 insertions(+), 176 deletions(-) delete mode 100644 src/components/views/messages/MessageEvent.js create mode 100644 src/components/views/messages/MessageEvent.tsx diff --git a/src/@types/common.ts b/src/@types/common.ts index 1fb9ba4303..36ef7a9ace 100644 --- a/src/@types/common.ts +++ b/src/@types/common.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { JSXElementConstructor } from "react"; +import React, { JSXElementConstructor } from "react"; // Based on https://stackoverflow.com/a/53229857/3532235 export type Without<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; diff --git a/src/components/views/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx index 999e98f4ad..7092be43e9 100644 --- a/src/components/views/context_menus/MessageContextMenu.tsx +++ b/src/components/views/context_menus/MessageContextMenu.tsx @@ -43,11 +43,15 @@ export function canCancel(eventStatus: EventStatus): boolean { return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT; } -interface IEventTileOps { +export interface IEventTileOps { isWidgetHidden(): boolean; unhideWidget(): void; } +export interface IOperableEventTile { + getEventTileOps(): IEventTileOps; +} + interface IProps { /* the MatrixEvent associated with the context menu */ mxEvent: MatrixEvent; diff --git a/src/components/views/messages/MVideoBody.tsx b/src/components/views/messages/MVideoBody.tsx index bb58a13c4d..2b873f6506 100644 --- a/src/components/views/messages/MVideoBody.tsx +++ b/src/components/views/messages/MVideoBody.tsx @@ -18,15 +18,12 @@ 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"; -import { IMediaBody } from "./IMediaBody"; import { MediaEventHelper } from "../../../utils/MediaEventHelper"; import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent"; import { MatrixEvent } from "matrix-js-sdk/src"; @@ -36,6 +33,7 @@ interface IProps { mxEvent: MatrixEvent; /* called when the video has loaded */ onHeightChanged: () => void; + mediaEventHelper: MediaEventHelper; } interface IState { @@ -49,9 +47,8 @@ interface IState { } @replaceableComponent("views.messages.MVideoBody") -export default class MVideoBody extends React.PureComponent<IProps, IState> implements IMediaBody { +export default class MVideoBody extends React.PureComponent<IProps, IState> { private videoRef = React.createRef<HTMLVideoElement>(); - private mediaHelper: MediaEventHelper; constructor(props) { super(props); @@ -65,8 +62,6 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> impl posterLoading: false, blurhashUrl: null, }; - - this.mediaHelper = new MediaEventHelper(this.props.mxEvent); } thumbScale(fullWidth: number, fullHeight: number, thumbWidth = 480, thumbHeight = 360) { @@ -90,10 +85,6 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> impl } } - public getMediaHelper(): MediaEventHelper { - return this.mediaHelper; - } - private getContentUrl(): string|null { const media = mediaFromContent(this.props.mxEvent.getContent()); if (media.isEncrypted) { @@ -166,15 +157,15 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> impl const autoplay = SettingsStore.getValue("autoplayGifsAndVideos") as boolean; this.loadBlurhash(); - if (this.mediaHelper.media.isEncrypted && this.state.decryptedUrl === null) { + if (this.props.mediaEventHelper.media.isEncrypted && this.state.decryptedUrl === null) { try { - const thumbnailUrl = await this.mediaHelper.thumbnailUrl.value; + const thumbnailUrl = await this.props.mediaEventHelper.thumbnailUrl.value; if (autoplay) { console.log("Preloading video"); this.setState({ - decryptedUrl: await this.mediaHelper.sourceUrl.value, + decryptedUrl: await this.props.mediaEventHelper.sourceUrl.value, decryptedThumbnailUrl: thumbnailUrl, - decryptedBlob: await this.mediaHelper.sourceBlob.value, + decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value, }); this.props.onHeightChanged(); } else { @@ -199,16 +190,6 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> impl } } - componentWillUnmount() { - if (this.state.decryptedUrl) { - URL.revokeObjectURL(this.state.decryptedUrl); - } - if (this.state.decryptedThumbnailUrl) { - URL.revokeObjectURL(this.state.decryptedThumbnailUrl); - } - this.mediaHelper.destroy(); - } - 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. @@ -218,15 +199,15 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> impl // To stop subsequent download attempts fetchingData: true, }); - if (!this.mediaHelper.media.isEncrypted) { + if (!this.props.mediaEventHelper.media.isEncrypted) { this.setState({ error: "No file given in content", }); return; } this.setState({ - decryptedUrl: await this.mediaHelper.sourceUrl.value, - decryptedBlob: await this.mediaHelper.sourceBlob.value, + decryptedUrl: await this.props.mediaEventHelper.sourceUrl.value, + decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value, fetchingData: false, }, () => { if (!this.videoRef.current) return; diff --git a/src/components/views/messages/MessageEvent.js b/src/components/views/messages/MessageEvent.js deleted file mode 100644 index 49b50b610c..0000000000 --- a/src/components/views/messages/MessageEvent.js +++ /dev/null @@ -1,146 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd - -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 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"; - -@replaceableComponent("views.messages.MessageEvent") -export default class MessageEvent extends React.Component { - 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 */ - overrideBodyTypes: PropTypes.object, - overrideEventTypes: PropTypes.object, - - /* the permalinkCreator */ - permalinkCreator: PropTypes.object, - }; - - constructor(props) { - super(props); - - this._body = createRef(); - } - - getEventTileOps = () => { - return this._body.current && this._body.current.getEventTileOps ? this._body.current.getEventTileOps() : null; - }; - - onTileUpdate = () => { - 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'), - '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'), - - ...(this.props.overrideBodyTypes || {}), - }; - const evTypes = { - 'm.sticker': sdk.getComponent('messages.MStickerBody'), - ...(this.props.overrideEventTypes || {}), - }; - - const content = this.props.mxEvent.getContent(); - const type = this.props.mxEvent.getType(); - const msgtype = content.msgtype; - let BodyType = 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]; - } else if (content.url) { - // Fallback to MFileBody if there's a content URL - BodyType = bodyTypes['m.file']; - } else { - // Fallback to UnknownBody otherwise if not redacted - BodyType = UnknownBody; - } - } - - if (SettingsStore.getValue("feature_mjolnir")) { - const key = `mx_mjolnir_render_${this.props.mxEvent.getRoomId()}__${this.props.mxEvent.getId()}`; - const allowRender = localStorage.getItem(key) === "true"; - - if (!allowRender) { - const userDomain = this.props.mxEvent.getSender().split(':').slice(1).join(':'); - const userBanned = Mjolnir.sharedInstance().isUserBanned(this.props.mxEvent.getSender()); - const serverBanned = Mjolnir.sharedInstance().isServerBanned(userDomain); - - if (userBanned || serverBanned) { - BodyType = sdk.getComponent('messages.MjolnirBody'); - } - } - } - - return BodyType ? <BodyType - ref={this._body} - mxEvent={this.props.mxEvent} - highlights={this.props.highlights} - highlightLink={this.props.highlightLink} - showUrlPreview={this.props.showUrlPreview} - tileShape={this.props.tileShape} - maxImageHeight={this.props.maxImageHeight} - replacingEventId={this.props.replacingEventId} - editState={this.props.editState} - onHeightChanged={this.props.onHeightChanged} - onMessageAllowed={this.onTileUpdate} - permalinkCreator={this.props.permalinkCreator} - /> : null; - } -} diff --git a/src/components/views/messages/MessageEvent.tsx b/src/components/views/messages/MessageEvent.tsx new file mode 100644 index 0000000000..3c59e68c8b --- /dev/null +++ b/src/components/views/messages/MessageEvent.tsx @@ -0,0 +1,176 @@ +/* +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. +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 React, { createRef } from 'react'; +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 { MatrixEvent } from "matrix-js-sdk/src"; +import { TileShape } from "../rooms/EventTile"; +import PermalinkConstructor from "../../../utils/permalinks/PermalinkConstructor"; +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"; + +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; + + /* callback called when dynamic content in events are loaded */ + onHeightChanged: () => void; + + /* the shape of the tile, used */ + tileShape: TileShape; + + /* the maximum image height to use, if the event is an image */ + maxImageHeight?: number; + + /* overrides for the msgtype-specific components, used by ReplyTile to override file rendering */ + overrideBodyTypes?: Record<string, React.Component>; + overrideEventTypes?: Record<string, React.Component>; + + /* the permalinkCreator */ + permalinkCreator: PermalinkConstructor; + + replacingEventId?: string; + editState?: unknown; +} + +@replaceableComponent("views.messages.MessageEvent") +export default class MessageEvent extends React.Component<IProps> implements IMediaBody, IOperableEventTile { + private body: React.RefObject<React.Component | IOperableEventTile> = createRef(); + private mediaHelper: MediaEventHelper; + + public constructor(props: IProps) { + super(props); + + if (MediaEventHelper.isEligible(this.props.mxEvent)) { + this.mediaHelper = new MediaEventHelper(this.props.mxEvent); + } + } + + public componentWillUnmount() { + this.mediaHelper?.destroy(); + } + + 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 || {}), + }; + } + + 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: ReactAnyComponent = RedactedBody; + if (!this.props.mxEvent.isRedacted()) { + // only resolve BodyType if event is not redacted + 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 = this.bodyTypes[MsgType.File]; + } else { + // Fallback to UnknownBody otherwise if not redacted + BodyType = UnknownBody; + } + } + + if (SettingsStore.getValue("feature_mjolnir")) { + const key = `mx_mjolnir_render_${this.props.mxEvent.getRoomId()}__${this.props.mxEvent.getId()}`; + const allowRender = localStorage.getItem(key) === "true"; + + if (!allowRender) { + const userDomain = this.props.mxEvent.getSender().split(':').slice(1).join(':'); + const userBanned = Mjolnir.sharedInstance().isUserBanned(this.props.mxEvent.getSender()); + const serverBanned = Mjolnir.sharedInstance().isServerBanned(userDomain); + + if (userBanned || serverBanned) { + BodyType = sdk.getComponent('messages.MjolnirBody'); + } + } + } + + // @ts-ignore - this is a dynamic react component + return BodyType ? <BodyType + ref={this.body} + mxEvent={this.props.mxEvent} + highlights={this.props.highlights} + highlightLink={this.props.highlightLink} + showUrlPreview={this.props.showUrlPreview} + tileShape={this.props.tileShape} + maxImageHeight={this.props.maxImageHeight} + replacingEventId={this.props.replacingEventId} + editState={this.props.editState} + onHeightChanged={this.props.onHeightChanged} + onMessageAllowed={this.onTileUpdate} + permalinkCreator={this.props.permalinkCreator} + mediaEventHelper={this.mediaHelper} + /> : null; + } +} diff --git a/src/utils/MediaEventHelper.ts b/src/utils/MediaEventHelper.ts index 316ee54edf..b4deb1a8ce 100644 --- a/src/utils/MediaEventHelper.ts +++ b/src/utils/MediaEventHelper.ts @@ -20,6 +20,7 @@ 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 @@ -87,4 +88,24 @@ export class MediaEventHelper implements IDestroyable { 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; + } } From 584ffbd32777199263ad67c6b5331edc1a03b6a2 Mon Sep 17 00:00:00 2001 From: Travis Ralston <travisr@matrix.org> Date: Thu, 15 Jul 2021 14:25:43 -0600 Subject: [PATCH 03/16] Fix refreshing the page not showing a download --- src/components/views/messages/MessageActionBar.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/components/views/messages/MessageActionBar.js b/src/components/views/messages/MessageActionBar.js index 13854aebfc..730a929ddd 100644 --- a/src/components/views/messages/MessageActionBar.js +++ b/src/components/views/messages/MessageActionBar.js @@ -33,6 +33,7 @@ import { canCancel } from "../context_menus/MessageContextMenu"; import Resend from "../../../Resend"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { canTileDownload } from "./IMediaBody"; +import {MediaEventHelper} from "../../../utils/MediaEventHelper"; const OptionsButton = ({ mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange }) => { const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); @@ -177,6 +178,11 @@ export default class MessageActionBar extends React.PureComponent { }; onDownloadClick = async (ev) => { + if (!this.props.getTile || !this.props.getTile().getMediaHelper) { + console.warn("Action bar triggered a download but the event tile is missing a media helper"); + return; + } + // TODO: Maybe just call into MFileBody and render it as null const src = this.props.getTile().getMediaHelper(); const a = document.createElement("a"); @@ -279,8 +285,8 @@ export default class MessageActionBar extends React.PureComponent { />); } - const tile = this.props.getTile && this.props.getTile(); - if (canTileDownload(tile)) { + // 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, <RovingAccessibleTooltipButton className="mx_MessageActionBar_maskButton mx_MessageActionBar_downloadButton" title={_t("Download")} From 040802e29f0d1935672a693ac2f83ca2d8db0786 Mon Sep 17 00:00:00 2001 From: Travis Ralston <travisr@matrix.org> Date: Thu, 15 Jul 2021 14:34:40 -0600 Subject: [PATCH 04/16] Clean up after POC --- src/components/views/messages/IMediaBody.ts | 11 ----------- src/components/views/messages/MessageActionBar.js | 3 +-- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/src/components/views/messages/IMediaBody.ts b/src/components/views/messages/IMediaBody.ts index dcbdfff284..27b5f24275 100644 --- a/src/components/views/messages/IMediaBody.ts +++ b/src/components/views/messages/IMediaBody.ts @@ -14,19 +14,8 @@ 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 = <any>tile; - return !!tileAsAny.getMediaHelper; -} diff --git a/src/components/views/messages/MessageActionBar.js b/src/components/views/messages/MessageActionBar.js index 730a929ddd..1cb86f168d 100644 --- a/src/components/views/messages/MessageActionBar.js +++ b/src/components/views/messages/MessageActionBar.js @@ -32,8 +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"; -import {MediaEventHelper} from "../../../utils/MediaEventHelper"; +import { MediaEventHelper } from "../../../utils/MediaEventHelper"; const OptionsButton = ({ mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange }) => { const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); From 5fce0ccd9d23e5dd8c5114b4cf2a50f506f608a5 Mon Sep 17 00:00:00 2001 From: Travis Ralston <travisr@matrix.org> Date: Thu, 15 Jul 2021 16:37:48 -0600 Subject: [PATCH 05/16] Convert images, audio, and voice messages over to the new helper --- src/components/views/messages/MAudioBody.tsx | 43 +++++++------ src/components/views/messages/MImageBody.tsx | 51 +++++---------- .../views/messages/MVoiceMessageBody.tsx | 64 ++----------------- 3 files changed, 41 insertions(+), 117 deletions(-) diff --git a/src/components/views/messages/MAudioBody.tsx b/src/components/views/messages/MAudioBody.tsx index bc7216f42c..4f688fd136 100644 --- a/src/components/views/messages/MAudioBody.tsx +++ b/src/components/views/messages/MAudioBody.tsx @@ -18,22 +18,22 @@ 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"; +import { MediaEventHelper } from "../../../utils/MediaEventHelper"; +import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent"; +import { TileShape } from "../rooms/EventTile"; interface IProps { mxEvent: MatrixEvent; + tileShape?: TileShape; + mediaEventHelper: MediaEventHelper; } interface IState { error?: Error; playback?: Playback; - decryptedBlob?: Blob; } @replaceableComponent("views.messages.MAudioBody") @@ -46,33 +46,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 +104,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} /> + {/*<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />*/} </span> ); } diff --git a/src/components/views/messages/MImageBody.tsx b/src/components/views/messages/MImageBody.tsx index 96c8652aee..9325c39982 100644 --- a/src/components/views/messages/MImageBody.tsx +++ b/src/components/views/messages/MImageBody.tsx @@ -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"; @@ -34,6 +32,7 @@ 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'; +import { MediaEventHelper } from "../../../utils/MediaEventHelper"; export interface IProps { /* the MatrixEvent to show */ @@ -46,6 +45,7 @@ export interface IProps { /* the permalinkCreator */ permalinkCreator?: RoomPermalinkCreator; + mediaEventHelper: MediaEventHelper; } interface IState { @@ -257,38 +257,24 @@ export default class MImageBody extends React.Component<IProps, IState> { } } - private downloadImage(): void { + private async downloadImage() { 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); + 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,10 +286,10 @@ 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. this._afterComponentDidMount(); } @@ -316,13 +302,6 @@ export default class MImageBody extends React.Component<IProps, IState> { 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( diff --git a/src/components/views/messages/MVoiceMessageBody.tsx b/src/components/views/messages/MVoiceMessageBody.tsx index bec224dd2d..65426cdad2 100644 --- a/src/components/views/messages/MVoiceMessageBody.tsx +++ b/src/components/views/messages/MVoiceMessageBody.tsx @@ -15,72 +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"; @replaceableComponent("views.messages.MVoiceMessageBody") -export default class MVoiceMessageBody extends React.PureComponent<IProps, IState> { - constructor(props: IProps) { - super(props); +export default class MVoiceMessageBody extends MAudioBody { - 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(); - } + // A voice message is an audio file but rendered in a special way. public render() { if (this.state.error) { @@ -106,7 +50,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} /> + {/*<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />*/} </span> ); } From ea7513fc16fd902d86069d45274f02dca2d292e8 Mon Sep 17 00:00:00 2001 From: Travis Ralston <travisr@matrix.org> Date: Thu, 15 Jul 2021 16:48:21 -0600 Subject: [PATCH 06/16] Convert MFileBody to TS and use media helper --- .../messages/{MFileBody.js => MFileBody.tsx} | 93 ++++++++++--------- 1 file changed, 47 insertions(+), 46 deletions(-) rename src/components/views/messages/{MFileBody.js => MFileBody.tsx} (86%) diff --git a/src/components/views/messages/MFileBody.js b/src/components/views/messages/MFileBody.tsx similarity index 86% rename from src/components/views/messages/MFileBody.js rename to src/components/views/messages/MFileBody.tsx index 9236c77e8d..f1f004ef21 100644 --- a/src/components/views/messages/MFileBody.js +++ b/src/components/views/messages/MFileBody.tsx @@ -25,6 +25,9 @@ import { replaceableComponent } from "../../../utils/replaceableComponent"; import { mediaFromContent } from "../../../customisations/Media"; import ErrorDialog from "../dialogs/ErrorDialog"; import { TileShape } from "../rooms/EventTile"; +import { IContent, MatrixEvent } from "matrix-js-sdk/src"; +import { MediaEventHelper } from "../../../utils/MediaEventHelper"; +import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent"; let downloadIconUrl; // cached copy of the download.svg asset for the sandboxed iframe later on @@ -35,6 +38,7 @@ async function cacheDownloadIcon() { } // Cache the asset immediately +// noinspection JSIgnoredPromiseFromCall cacheDownloadIcon(); // User supplied content can contain scripts, we have to be careful that @@ -98,7 +102,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 +123,56 @@ 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 { + /* the MatrixEvent to show */ + mxEvent: MatrixEvent; + /* called when the download link iframe is shown */ + onHeightChanged: () => void; + /* the shape of the tile, used */ + tileShape: TileShape; + /* whether or not to show the default placeholder for the file. Defaults to true. */ + showGenericPlaceholder: boolean; + /* helper which contains the file access */ + mediaEventHelper: MediaEventHelper; +} +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"; @@ -182,29 +189,23 @@ export default class MFileBody extends React.Component { } 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 @@ -226,7 +227,7 @@ export default class MFileBody extends React.Component { ev.target.contentWindow.postMessage({ imgSrc: downloadIconUrl, 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 +235,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, }, "*"); }; @@ -251,12 +252,12 @@ export default class MFileBody extends React.Component { * 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> </span> @@ -289,7 +290,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 From d156a56603d68e1835b416338e549aded59fd43b Mon Sep 17 00:00:00 2001 From: Travis Ralston <travisr@matrix.org> Date: Fri, 16 Jul 2021 10:57:14 -0600 Subject: [PATCH 07/16] Share body prop types with an interface --- src/components/views/messages/IBodyProps.ts | 43 +++++++++++++++++++ src/components/views/messages/MAudioBody.tsx | 15 ++----- src/components/views/messages/MFileBody.tsx | 11 +---- src/components/views/messages/MImageBody.tsx | 22 ++-------- src/components/views/messages/MVideoBody.tsx | 16 ++----- .../views/messages/MVoiceOrAudioBody.tsx | 8 +--- .../views/messages/MessageEvent.tsx | 34 ++------------- .../views/messages/RedactedBody.tsx | 10 ++--- src/components/views/messages/TextualBody.tsx | 29 +------------ 9 files changed, 65 insertions(+), 123 deletions(-) create mode 100644 src/components/views/messages/IBodyProps.ts diff --git a/src/components/views/messages/IBodyProps.ts b/src/components/views/messages/IBodyProps.ts new file mode 100644 index 0000000000..8aabd3080c --- /dev/null +++ b/src/components/views/messages/IBodyProps.ts @@ -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; +} diff --git a/src/components/views/messages/MAudioBody.tsx b/src/components/views/messages/MAudioBody.tsx index 4f688fd136..26982f242f 100644 --- a/src/components/views/messages/MAudioBody.tsx +++ b/src/components/views/messages/MAudioBody.tsx @@ -15,21 +15,14 @@ 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 InlineSpinner from '../elements/InlineSpinner'; import { _t } from "../../../languageHandler"; import AudioPlayer from "../audio_messages/AudioPlayer"; -import { MediaEventHelper } from "../../../utils/MediaEventHelper"; import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent"; -import { TileShape } from "../rooms/EventTile"; - -interface IProps { - mxEvent: MatrixEvent; - tileShape?: TileShape; - mediaEventHelper: MediaEventHelper; -} +import MFileBody from "./MFileBody"; +import { IBodyProps } from "./IBodyProps"; interface IState { error?: Error; @@ -37,8 +30,8 @@ interface IState { } @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 = {}; diff --git a/src/components/views/messages/MFileBody.tsx b/src/components/views/messages/MFileBody.tsx index f1f004ef21..e0a1119b62 100644 --- a/src/components/views/messages/MFileBody.tsx +++ b/src/components/views/messages/MFileBody.tsx @@ -28,6 +28,7 @@ import { TileShape } from "../rooms/EventTile"; import { IContent, MatrixEvent } from "matrix-js-sdk/src"; import { MediaEventHelper } from "../../../utils/MediaEventHelper"; 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 @@ -123,17 +124,9 @@ export function presentableTextForFile(content: IContent, withSize = true): stri return linkText; } -interface IProps { - /* the MatrixEvent to show */ - mxEvent: MatrixEvent; - /* called when the download link iframe is shown */ - onHeightChanged: () => void; - /* the shape of the tile, used */ - tileShape: TileShape; +interface IProps extends IBodyProps { /* whether or not to show the default placeholder for the file. Defaults to true. */ showGenericPlaceholder: boolean; - /* helper which contains the file access */ - mediaEventHelper: MediaEventHelper; } interface IState { diff --git a/src/components/views/messages/MImageBody.tsx b/src/components/views/messages/MImageBody.tsx index 9325c39982..a6e9816e35 100644 --- a/src/components/views/messages/MImageBody.tsx +++ b/src/components/views/messages/MImageBody.tsx @@ -27,26 +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'; -import { MediaEventHelper } from "../../../utils/MediaEventHelper"; - -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; - mediaEventHelper: MediaEventHelper; -} +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 = { diff --git a/src/components/views/messages/MVideoBody.tsx b/src/components/views/messages/MVideoBody.tsx index 2b873f6506..e58e3ef3c0 100644 --- a/src/components/views/messages/MVideoBody.tsx +++ b/src/components/views/messages/MVideoBody.tsx @@ -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. @@ -24,17 +23,8 @@ import InlineSpinner from '../elements/InlineSpinner'; import { replaceableComponent } from "../../../utils/replaceableComponent"; import { mediaFromContent } from "../../../customisations/Media"; import { BLURHASH_FIELD } from "../../../ContentMessages"; -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: MatrixEvent; - /* called when the video has loaded */ - onHeightChanged: () => void; - mediaEventHelper: MediaEventHelper; -} +import { IBodyProps } from "./IBodyProps"; interface IState { decryptedUrl?: string; @@ -47,7 +37,7 @@ 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) { diff --git a/src/components/views/messages/MVoiceOrAudioBody.tsx b/src/components/views/messages/MVoiceOrAudioBody.tsx index 676b5a2c47..adfd102e19 100644 --- a/src/components/views/messages/MVoiceOrAudioBody.tsx +++ b/src/components/views/messages/MVoiceOrAudioBody.tsx @@ -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'] diff --git a/src/components/views/messages/MessageEvent.tsx b/src/components/views/messages/MessageEvent.tsx index 3c59e68c8b..53592e3985 100644 --- a/src/components/views/messages/MessageEvent.tsx +++ b/src/components/views/messages/MessageEvent.tsx @@ -22,45 +22,17 @@ import RedactedBody from "./RedactedBody"; import UnknownBody from "./UnknownBody"; import { replaceableComponent } from "../../../utils/replaceableComponent"; import { IMediaBody } from "./IMediaBody"; -import { MatrixEvent } from "matrix-js-sdk/src"; -import { TileShape } from "../rooms/EventTile"; -import PermalinkConstructor from "../../../utils/permalinks/PermalinkConstructor"; 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"; -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; - - /* callback called when dynamic content in events are loaded */ - onHeightChanged: () => void; - - /* the shape of the tile, used */ - tileShape: TileShape; - - /* the maximum image height to use, if the event is an image */ - maxImageHeight?: number; - +// 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>; - - /* the permalinkCreator */ - permalinkCreator: PermalinkConstructor; - - replacingEventId?: string; - editState?: unknown; } @replaceableComponent("views.messages.MessageEvent") diff --git a/src/components/views/messages/RedactedBody.tsx b/src/components/views/messages/RedactedBody.tsx index 3e5da1dd43..c2e137c97b 100644 --- a/src/components/views/messages/RedactedBody.tsx +++ b/src/components/views/messages/RedactedBody.tsx @@ -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"); diff --git a/src/components/views/messages/TextualBody.tsx b/src/components/views/messages/TextualBody.tsx index 9009b9ee1b..c7277557a3 100644 --- a/src/components/views/messages/TextualBody.tsx +++ b/src/components/views/messages/TextualBody.tsx @@ -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; From 7ba0adba77b26a51a2054f6f23243157a0f79f2f Mon Sep 17 00:00:00 2001 From: Travis Ralston <travisr@matrix.org> Date: Fri, 16 Jul 2021 10:57:41 -0600 Subject: [PATCH 08/16] Appease type system --- src/components/views/messages/MImageReplyBody.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/messages/MImageReplyBody.tsx b/src/components/views/messages/MImageReplyBody.tsx index 44acf18004..5f7f0da3ca 100644 --- a/src/components/views/messages/MImageReplyBody.tsx +++ b/src/components/views/messages/MImageReplyBody.tsx @@ -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() { From 623f2e7613829d989154d7e6a709e1fa57a970dc Mon Sep 17 00:00:00 2001 From: Travis Ralston <travisr@matrix.org> Date: Fri, 16 Jul 2021 11:05:04 -0600 Subject: [PATCH 09/16] Only show download link below media if it's not in the timeline --- src/components/views/messages/MAudioBody.tsx | 2 +- src/components/views/messages/MFileBody.tsx | 65 +++++++------------ src/components/views/messages/MImageBody.tsx | 5 +- src/components/views/messages/MVideoBody.tsx | 3 +- .../views/messages/MVoiceMessageBody.tsx | 3 +- 5 files changed, 33 insertions(+), 45 deletions(-) diff --git a/src/components/views/messages/MAudioBody.tsx b/src/components/views/messages/MAudioBody.tsx index 26982f242f..3444c2a3d0 100644 --- a/src/components/views/messages/MAudioBody.tsx +++ b/src/components/views/messages/MAudioBody.tsx @@ -97,7 +97,7 @@ export default class MAudioBody extends React.PureComponent<IBodyProps, 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> ); } diff --git a/src/components/views/messages/MFileBody.tsx b/src/components/views/messages/MFileBody.tsx index e0a1119b62..6f81e4ae10 100644 --- a/src/components/views/messages/MFileBody.tsx +++ b/src/components/views/messages/MFileBody.tsx @@ -15,18 +15,15 @@ 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, MatrixEvent } from "matrix-js-sdk/src"; -import { MediaEventHelper } from "../../../utils/MediaEventHelper"; +import { IContent } from "matrix-js-sdk/src"; import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent"; import { IBodyProps } from "./IBodyProps"; @@ -181,6 +178,8 @@ export default class MFileBody extends React.Component<IProps, IState> { ); } + const showDownloadLink = this.props.tileShape || !this.props.showGenericPlaceholder; + if (isEncrypted) { if (!this.state.decryptedBlob) { // Need to decrypt the attachment @@ -205,12 +204,12 @@ export default class MFileBody extends React.Component<IProps, IState> { // but it is not guaranteed between various browsers' settings. return ( <span className="mx_MFileBody"> - {placeholder} - <div className="mx_MFileBody_download"> + { placeholder } + { showDownloadLink && <div className="mx_MFileBody_download"> <AccessibleButton onClick={decrypt}> { _t("Decrypt %(text)s", { text: text }) } </AccessibleButton> - </div> + </div> } </span> ); } @@ -237,8 +236,8 @@ export default class MFileBody extends React.Component<IProps, IState> { // If the attachment is encrypted then put the link inside an iframe. return ( <span className="mx_MFileBody"> - {placeholder} - <div className="mx_MFileBody_download"> + { placeholder } + { showDownloadLink && <div className="mx_MFileBody_download"> <div style={{ display: "none" }}> { /* * Add dummy copy of the "a" tag @@ -252,7 +251,7 @@ export default class MFileBody extends React.Component<IProps, IState> { onLoad={onIframeLoad} ref={this.iframe} sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation" /> - </div> + </div> } </span> ); } else if (contentUrl) { @@ -300,40 +299,24 @@ export default class MFileBody extends React.Component<IProps, IState> { 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"> - {placeholder} + { placeholder } { _t("Invalid file%(extra)s", { extra: extra }) } </span>; } diff --git a/src/components/views/messages/MImageBody.tsx b/src/components/views/messages/MImageBody.tsx index a6e9816e35..bb1ddd6dc6 100644 --- a/src/components/views/messages/MImageBody.tsx +++ b/src/components/views/messages/MImageBody.tsx @@ -415,7 +415,10 @@ export default class MImageBody extends React.Component<IBodyProps, 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() { diff --git a/src/components/views/messages/MVideoBody.tsx b/src/components/views/messages/MVideoBody.tsx index e58e3ef3c0..6121a23752 100644 --- a/src/components/views/messages/MVideoBody.tsx +++ b/src/components/views/messages/MVideoBody.tsx @@ -25,6 +25,7 @@ import { mediaFromContent } from "../../../customisations/Media"; import { BLURHASH_FIELD } from "../../../ContentMessages"; import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent"; import { IBodyProps } from "./IBodyProps"; +import MFileBody from "./MFileBody"; interface IState { decryptedUrl?: string; @@ -268,7 +269,7 @@ export default class MVideoBody extends React.PureComponent<IBodyProps, IState> onPlay={this.videoOnPlay} > </video> - {/*<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />*/} + { this.props.tileShape && <MFileBody {...this.props} showGenericPlaceholder={false} /> } </span> ); } diff --git a/src/components/views/messages/MVoiceMessageBody.tsx b/src/components/views/messages/MVoiceMessageBody.tsx index 65426cdad2..cfa4d145dc 100644 --- a/src/components/views/messages/MVoiceMessageBody.tsx +++ b/src/components/views/messages/MVoiceMessageBody.tsx @@ -20,6 +20,7 @@ import InlineSpinner from '../elements/InlineSpinner'; import { _t } from "../../../languageHandler"; import RecordingPlayback from "../audio_messages/RecordingPlayback"; import MAudioBody from "./MAudioBody"; +import MFileBody from "./MFileBody"; @replaceableComponent("views.messages.MVoiceMessageBody") export default class MVoiceMessageBody extends MAudioBody { @@ -50,7 +51,7 @@ export default class MVoiceMessageBody extends MAudioBody { 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> ); } From b57fff57395a6152a6b6443e58a9d3fbdf9b3928 Mon Sep 17 00:00:00 2001 From: Travis Ralston <travisr@matrix.org> Date: Fri, 16 Jul 2021 15:55:07 -0600 Subject: [PATCH 10/16] Update visual style and sandbox properly --- res/css/views/messages/_MessageActionBar.scss | 9 ++ .../views/messages/DownloadActionButton.tsx | 110 ++++++++++++++++++ src/components/views/messages/MFileBody.tsx | 10 +- .../views/messages/MessageActionBar.js | 24 +--- src/i18n/strings/en_EN.json | 2 +- src/usercontent/index.js | 7 ++ src/utils/MediaEventHelper.ts | 4 + 7 files changed, 140 insertions(+), 26 deletions(-) create mode 100644 src/components/views/messages/DownloadActionButton.tsx diff --git a/res/css/views/messages/_MessageActionBar.scss b/res/css/views/messages/_MessageActionBar.scss index e2fafe6c62..975fe24899 100644 --- a/res/css/views/messages/_MessageActionBar.scss +++ b/res/css/views/messages/_MessageActionBar.scss @@ -107,3 +107,12 @@ limitations under the License. .mx_MessageActionBar_cancelButton::after { mask-image: url('$(res)/img/element-icons/trashcan.svg'); } + +.mx_MessageActionBar_downloadButton::after { + mask-size: 16px; + mask-image: url('$(res)/img/download.svg'); +} + +.mx_MessageActionBar_downloadButton.mx_MessageActionBar_downloadSpinnerButton::after { + background-color: transparent; // hide the download icon mask +} diff --git a/src/components/views/messages/DownloadActionButton.tsx b/src/components/views/messages/DownloadActionButton.tsx new file mode 100644 index 0000000000..35413975b6 --- /dev/null +++ b/src/components/views/messages/DownloadActionButton.tsx @@ -0,0 +1,110 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixEvent } from "matrix-js-sdk/src"; +import { MediaEventHelper } from "../../../utils/MediaEventHelper"; +import React, { createRef } from "react"; +import { RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex"; +import Spinner from "../elements/Spinner"; +import classNames from "classnames"; +import { _t } from "../../../languageHandler"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { DOWNLOAD_ICON_URL } from "./MFileBody"; + +interface IProps { + mxEvent: MatrixEvent; + + // XXX: It can take a cycle or two for the MessageActionBar to have all the props/setup + // required to get us a MediaEventHelper, so we use a getter function instead to prod for + // one. + mediaEventHelperGet: () => MediaEventHelper; +} + +interface IState { + loading: boolean; + blob?: Blob; +} + +@replaceableComponent("views.messages.DownloadActionButton") +export class DownloadActionButton extends React.PureComponent<IProps, IState> { + private iframe: React.RefObject<HTMLIFrameElement> = createRef(); + + public constructor(props: IProps) { + super(props); + + this.state = { + loading: false, + }; + } + + private onDownloadClick = async () => { + if (this.state.loading) return; + + this.setState({ loading: true }); + + if (this.state.blob) { + // Cheat and trigger a download, again. + return this.onFrameLoad(); + } + + const blob = await this.props.mediaEventHelperGet().sourceBlob.value; + this.setState({ blob }); + }; + + private onFrameLoad = () => { + this.setState({ loading: false }); + + // we aren't showing the iframe, so we can send over the bare minimum styles and such. + this.iframe.current.contentWindow.postMessage({ + imgSrc: DOWNLOAD_ICON_URL, + imgStyle: null, + style: "", + blob: this.state.blob, + download: this.props.mediaEventHelperGet().fileName, + textContent: "", + auto: true, // autodownload + }, '*'); + }; + + public render() { + let spinner: JSX.Element; + if (this.state.loading) { + spinner = <Spinner w={18} h={18} />; + } + + const classes = classNames({ + 'mx_MessageActionBar_maskButton': true, + 'mx_MessageActionBar_downloadButton': true, + 'mx_MessageActionBar_downloadSpinnerButton': !!spinner, + }); + + return <RovingAccessibleTooltipButton + className={classes} + title={_t("Download")} + onClick={this.onDownloadClick} + disabled={!!spinner} + > + { spinner } + { this.state.blob && <iframe + src={"usercontent/" /* XXX: Like MFileBody, this should come from the skin */} + ref={this.iframe} + onLoad={this.onFrameLoad} + sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation" + style={{ display: "none" }} + /> } + </RovingAccessibleTooltipButton>; + } +} diff --git a/src/components/views/messages/MFileBody.tsx b/src/components/views/messages/MFileBody.tsx index 6f81e4ae10..8367b8ee32 100644 --- a/src/components/views/messages/MFileBody.tsx +++ b/src/components/views/messages/MFileBody.tsx @@ -27,12 +27,12 @@ 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 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 @@ -74,7 +74,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 ""; } @@ -217,7 +217,7 @@ export default class MFileBody extends React.Component<IProps, IState> { // When the iframe loads we tell it to render a download link 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), blob: this.state.decryptedBlob, diff --git a/src/components/views/messages/MessageActionBar.js b/src/components/views/messages/MessageActionBar.js index 1cb86f168d..cf4e93a21b 100644 --- a/src/components/views/messages/MessageActionBar.js +++ b/src/components/views/messages/MessageActionBar.js @@ -33,6 +33,7 @@ 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(); @@ -176,21 +177,6 @@ export default class MessageActionBar extends React.PureComponent { }); }; - onDownloadClick = async (ev) => { - if (!this.props.getTile || !this.props.getTile().getMediaHelper) { - console.warn("Action bar triggered a download but the event tile is missing a media helper"); - return; - } - - // TODO: Maybe just call into MFileBody and render it as null - const src = this.props.getTile().getMediaHelper(); - const a = document.createElement("a"); - a.href = await src.sourceUrl.value; - a.download = "todo.png"; - a.target = "_blank"; - a.click(); - }; - /** * Runs a given fn on the set of possible events to test. The first event * that passes the checkFn will have fn executed on it. Both functions take @@ -286,11 +272,9 @@ export default class MessageActionBar extends React.PureComponent { // XXX: Assuming that the underlying tile will be a media event if it is eligible media. if (MediaEventHelper.isEligible(this.props.mxEvent)) { - toolbarOpts.splice(0, 0, <RovingAccessibleTooltipButton - className="mx_MessageActionBar_maskButton mx_MessageActionBar_downloadButton" - title={_t("Download")} - onClick={this.onDownloadClick} - disabled={false} + toolbarOpts.splice(0, 0, <DownloadActionButton + mxEvent={this.props.mxEvent} + mediaEventHelperGet={() => this.props.getTile?.().getMediaHelper?.()} key="download" />); } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index abdb8c2fb2..ed137cdf6c 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1870,6 +1870,7 @@ "Saturday": "Saturday", "Today": "Today", "Yesterday": "Yesterday", + "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.", @@ -2002,7 +2003,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", diff --git a/src/usercontent/index.js b/src/usercontent/index.js index 13f38cc31a..c03126ec80 100644 --- a/src/usercontent/index.js +++ b/src/usercontent/index.js @@ -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"; diff --git a/src/utils/MediaEventHelper.ts b/src/utils/MediaEventHelper.ts index b4deb1a8ce..63616dbb12 100644 --- a/src/utils/MediaEventHelper.ts +++ b/src/utils/MediaEventHelper.ts @@ -40,6 +40,10 @@ export class MediaEventHelper implements IDestroyable { 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); From 31d7de628e85209c162bf91494906389842da6ea Mon Sep 17 00:00:00 2001 From: Travis Ralston <travisr@matrix.org> Date: Fri, 16 Jul 2021 16:04:23 -0600 Subject: [PATCH 11/16] Small tweaks --- res/css/views/messages/_MessageActionBar.scss | 2 +- src/components/views/messages/DownloadActionButton.tsx | 2 +- src/i18n/strings/en_EN.json | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/res/css/views/messages/_MessageActionBar.scss b/res/css/views/messages/_MessageActionBar.scss index 975fe24899..69f3c672b7 100644 --- a/res/css/views/messages/_MessageActionBar.scss +++ b/res/css/views/messages/_MessageActionBar.scss @@ -109,7 +109,7 @@ limitations under the License. } .mx_MessageActionBar_downloadButton::after { - mask-size: 16px; + mask-size: 14px; mask-image: url('$(res)/img/download.svg'); } diff --git a/src/components/views/messages/DownloadActionButton.tsx b/src/components/views/messages/DownloadActionButton.tsx index 35413975b6..eb25e50d83 100644 --- a/src/components/views/messages/DownloadActionButton.tsx +++ b/src/components/views/messages/DownloadActionButton.tsx @@ -93,7 +93,7 @@ export class DownloadActionButton extends React.PureComponent<IProps, IState> { return <RovingAccessibleTooltipButton className={classes} - title={_t("Download")} + title={spinner ? _t("Downloading") : _t("Download")} onClick={this.onDownloadClick} disabled={!!spinner} > diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index ed137cdf6c..8f00e70d97 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1870,6 +1870,7 @@ "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.", From 4d38218e24f7615bddb0f0de42f66662aac92167 Mon Sep 17 00:00:00 2001 From: Travis Ralston <travisr@matrix.org> Date: Fri, 16 Jul 2021 16:11:27 -0600 Subject: [PATCH 12/16] Appease the linter --- src/components/views/messages/MFileBody.tsx | 1 + src/components/views/messages/MImageBody.tsx | 1 - src/components/views/messages/MVoiceMessageBody.tsx | 2 -- src/components/views/rooms/PinnedEventTile.tsx | 1 + 4 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/views/messages/MFileBody.tsx b/src/components/views/messages/MFileBody.tsx index 8367b8ee32..419b45b908 100644 --- a/src/components/views/messages/MFileBody.tsx +++ b/src/components/views/messages/MFileBody.tsx @@ -31,6 +31,7 @@ export let DOWNLOAD_ICON_URL; // cached copy of the download.svg asset for the s async function cacheDownloadIcon() { 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()); DOWNLOAD_ICON_URL = "data:image/svg+xml;base64," + window.btoa(svg); } diff --git a/src/components/views/messages/MImageBody.tsx b/src/components/views/messages/MImageBody.tsx index bb1ddd6dc6..667f60cb22 100644 --- a/src/components/views/messages/MImageBody.tsx +++ b/src/components/views/messages/MImageBody.tsx @@ -242,7 +242,6 @@ export default class MImageBody extends React.Component<IBodyProps, IState> { } private async downloadImage() { - const content = this.props.mxEvent.getContent(); if (this.props.mediaEventHelper.media.isEncrypted && this.state.decryptedUrl === null) { try { const thumbnailUrl = await this.props.mediaEventHelper.thumbnailUrl.value; diff --git a/src/components/views/messages/MVoiceMessageBody.tsx b/src/components/views/messages/MVoiceMessageBody.tsx index cfa4d145dc..f184caf448 100644 --- a/src/components/views/messages/MVoiceMessageBody.tsx +++ b/src/components/views/messages/MVoiceMessageBody.tsx @@ -24,9 +24,7 @@ import MFileBody from "./MFileBody"; @replaceableComponent("views.messages.MVoiceMessageBody") 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 diff --git a/src/components/views/rooms/PinnedEventTile.tsx b/src/components/views/rooms/PinnedEventTile.tsx index 0e3396e9b0..250efc1278 100644 --- a/src/components/views/rooms/PinnedEventTile.tsx +++ b/src/components/views/rooms/PinnedEventTile.tsx @@ -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 From 70e525d672f1788cf4d22decb2bb12f7c688148a Mon Sep 17 00:00:00 2001 From: Travis Ralston <travisr@matrix.org> Date: Sun, 18 Jul 2021 17:06:30 -0600 Subject: [PATCH 13/16] Fix component export --- src/components/views/messages/DownloadActionButton.tsx | 2 +- src/components/views/messages/MessageActionBar.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/views/messages/DownloadActionButton.tsx b/src/components/views/messages/DownloadActionButton.tsx index eb25e50d83..4f81f1b14f 100644 --- a/src/components/views/messages/DownloadActionButton.tsx +++ b/src/components/views/messages/DownloadActionButton.tsx @@ -39,7 +39,7 @@ interface IState { } @replaceableComponent("views.messages.DownloadActionButton") -export class DownloadActionButton extends React.PureComponent<IProps, IState> { +export default class DownloadActionButton extends React.PureComponent<IProps, IState> { private iframe: React.RefObject<HTMLIFrameElement> = createRef(); public constructor(props: IProps) { diff --git a/src/components/views/messages/MessageActionBar.js b/src/components/views/messages/MessageActionBar.js index cf4e93a21b..dd8b3afcef 100644 --- a/src/components/views/messages/MessageActionBar.js +++ b/src/components/views/messages/MessageActionBar.js @@ -33,7 +33,7 @@ import { canCancel } from "../context_menus/MessageContextMenu"; import Resend from "../../../Resend"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MediaEventHelper } from "../../../utils/MediaEventHelper"; -import { DownloadActionButton } from "./DownloadActionButton"; +import DownloadActionButton from "./DownloadActionButton"; const OptionsButton = ({ mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange }) => { const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); From 32d8da2c74ce6b666a5a02b509875b5e91e30116 Mon Sep 17 00:00:00 2001 From: Travis Ralston <travisr@matrix.org> Date: Tue, 20 Jul 2021 08:52:43 -0600 Subject: [PATCH 14/16] Remove useless image reference --- src/components/views/messages/DownloadActionButton.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/views/messages/DownloadActionButton.tsx b/src/components/views/messages/DownloadActionButton.tsx index 4f81f1b14f..cc666485b1 100644 --- a/src/components/views/messages/DownloadActionButton.tsx +++ b/src/components/views/messages/DownloadActionButton.tsx @@ -22,7 +22,6 @@ import Spinner from "../elements/Spinner"; import classNames from "classnames"; import { _t } from "../../../languageHandler"; import { replaceableComponent } from "../../../utils/replaceableComponent"; -import { DOWNLOAD_ICON_URL } from "./MFileBody"; interface IProps { mxEvent: MatrixEvent; @@ -69,7 +68,7 @@ export default class DownloadActionButton extends React.PureComponent<IProps, IS // we aren't showing the iframe, so we can send over the bare minimum styles and such. this.iframe.current.contentWindow.postMessage({ - imgSrc: DOWNLOAD_ICON_URL, + imgSrc: "", // no image imgStyle: null, style: "", blob: this.state.blob, From 44e240e12d32c0e67bbf86741f017c265a4d0721 Mon Sep 17 00:00:00 2001 From: Travis Ralston <travisr@matrix.org> Date: Tue, 20 Jul 2021 08:55:07 -0600 Subject: [PATCH 15/16] Doc some values --- src/utils/MediaEventHelper.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/utils/MediaEventHelper.ts b/src/utils/MediaEventHelper.ts index 63616dbb12..cf34d5dea4 100644 --- a/src/utils/MediaEventHelper.ts +++ b/src/utils/MediaEventHelper.ts @@ -25,10 +25,14 @@ 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) { From 7892539a9b049d8d20def20da5e186645a11b8cc Mon Sep 17 00:00:00 2001 From: Travis Ralston <travisr@matrix.org> Date: Tue, 20 Jul 2021 09:03:26 -0600 Subject: [PATCH 16/16] delint --- src/components/views/messages/DownloadActionButton.tsx | 2 +- src/components/views/messages/MFileBody.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/views/messages/DownloadActionButton.tsx b/src/components/views/messages/DownloadActionButton.tsx index cc666485b1..2bdae04eda 100644 --- a/src/components/views/messages/DownloadActionButton.tsx +++ b/src/components/views/messages/DownloadActionButton.tsx @@ -98,7 +98,7 @@ export default class DownloadActionButton extends React.PureComponent<IProps, IS > { spinner } { this.state.blob && <iframe - src={"usercontent/" /* XXX: Like MFileBody, this should come from the skin */} + 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" diff --git a/src/components/views/messages/MFileBody.tsx b/src/components/views/messages/MFileBody.tsx index 419b45b908..b1e42976db 100644 --- a/src/components/views/messages/MFileBody.tsx +++ b/src/components/views/messages/MFileBody.tsx @@ -310,7 +310,7 @@ export default class MFileBody extends React.Component<IProps, IState> { </a> { this.props.tileShape === TileShape.FileGrid && <div className="mx_MImageBody_size"> { content.info && content.info.size ? filesize(content.info.size) : "" } - </div>} + </div> } </div> } </span> );