Simple POC for moving download button to action bar

This commit is contained in:
Travis Ralston 2021-07-14 20:51:20 -06:00
parent aaa9040634
commit 0a99f76e7f
6 changed files with 237 additions and 24 deletions

View file

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

View file

@ -26,10 +26,14 @@ import InlineSpinner from '../elements/InlineSpinner';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromContent } from "../../../customisations/Media"; import { mediaFromContent } from "../../../customisations/Media";
import { BLURHASH_FIELD } from "../../../ContentMessages"; import { BLURHASH_FIELD } from "../../../ContentMessages";
import { IMediaBody } from "./IMediaBody";
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
import { MatrixEvent } from "matrix-js-sdk/src";
interface IProps { interface IProps {
/* the MatrixEvent to show */ /* the MatrixEvent to show */
mxEvent: any; mxEvent: MatrixEvent;
/* called when the video has loaded */ /* called when the video has loaded */
onHeightChanged: () => void; onHeightChanged: () => void;
} }
@ -45,11 +49,13 @@ interface IState {
} }
@replaceableComponent("views.messages.MVideoBody") @replaceableComponent("views.messages.MVideoBody")
export default class MVideoBody extends React.PureComponent<IProps, IState> { export default class MVideoBody extends React.PureComponent<IProps, IState> implements IMediaBody {
private videoRef = React.createRef<HTMLVideoElement>(); private videoRef = React.createRef<HTMLVideoElement>();
private mediaHelper: MediaEventHelper;
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
fetchingData: false, fetchingData: false,
decryptedUrl: null, decryptedUrl: null,
@ -59,6 +65,8 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
posterLoading: false, posterLoading: false,
blurhashUrl: null, blurhashUrl: null,
}; };
this.mediaHelper = new MediaEventHelper(this.props.mxEvent);
} }
thumbScale(fullWidth: number, fullHeight: number, thumbWidth = 480, thumbHeight = 360) { 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 { private getContentUrl(): string|null {
const media = mediaFromContent(this.props.mxEvent.getContent()); const media = mediaFromContent(this.props.mxEvent.getContent());
if (media.isEncrypted) { if (media.isEncrypted) {
@ -97,7 +109,7 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
} }
private getThumbUrl(): string|null { private getThumbUrl(): string|null {
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent<IMediaEventContent>();
const media = mediaFromContent(content); const media = mediaFromContent(content);
if (media.isEncrypted && this.state.decryptedThumbnailUrl) { if (media.isEncrypted && this.state.decryptedThumbnailUrl) {
@ -139,7 +151,7 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
posterLoading: true, posterLoading: true,
}); });
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent<IMediaEventContent>();
const media = mediaFromContent(content); const media = mediaFromContent(content);
if (media.hasThumbnail) { if (media.hasThumbnail) {
const image = new Image(); const image = new Image();
@ -152,30 +164,22 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
async componentDidMount() { async componentDidMount() {
const autoplay = SettingsStore.getValue("autoplayGifsAndVideos") as boolean; const autoplay = SettingsStore.getValue("autoplayGifsAndVideos") as boolean;
const content = this.props.mxEvent.getContent();
this.loadBlurhash(); this.loadBlurhash();
if (content.file !== undefined && this.state.decryptedUrl === null) { if (this.mediaHelper.media.isEncrypted && this.state.decryptedUrl === null) {
let thumbnailPromise = Promise.resolve(null);
if (content?.info?.thumbnail_file) {
thumbnailPromise = decryptFile(content.info.thumbnail_file)
.then(blob => URL.createObjectURL(blob));
}
try { try {
const thumbnailUrl = await thumbnailPromise; const thumbnailUrl = await this.mediaHelper.thumbnailUrl.value;
if (autoplay) { if (autoplay) {
console.log("Preloading video"); console.log("Preloading video");
const decryptedBlob = await decryptFile(content.file);
const contentUrl = URL.createObjectURL(decryptedBlob);
this.setState({ this.setState({
decryptedUrl: contentUrl, decryptedUrl: await this.mediaHelper.sourceUrl.value,
decryptedThumbnailUrl: thumbnailUrl, decryptedThumbnailUrl: thumbnailUrl,
decryptedBlob: decryptedBlob, decryptedBlob: await this.mediaHelper.sourceBlob.value,
}); });
this.props.onHeightChanged(); this.props.onHeightChanged();
} else { } else {
console.log("NOT preloading video"); console.log("NOT preloading video");
const content = this.props.mxEvent.getContent<IMediaEventContent>();
this.setState({ this.setState({
// For Chrome and Electron, we need to set some non-empty `src` to // For Chrome and Electron, we need to set some non-empty `src` to
// enable the play button. Firefox does not seem to care either // enable the play button. Firefox does not seem to care either
@ -202,6 +206,7 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
if (this.state.decryptedThumbnailUrl) { if (this.state.decryptedThumbnailUrl) {
URL.revokeObjectURL(this.state.decryptedThumbnailUrl); URL.revokeObjectURL(this.state.decryptedThumbnailUrl);
} }
this.mediaHelper.destroy();
} }
private videoOnPlay = async () => { private videoOnPlay = async () => {
@ -213,18 +218,15 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
// To stop subsequent download attempts // To stop subsequent download attempts
fetchingData: true, fetchingData: true,
}); });
const content = this.props.mxEvent.getContent(); if (!this.mediaHelper.media.isEncrypted) {
if (!content.file) {
this.setState({ this.setState({
error: "No file given in content", error: "No file given in content",
}); });
return; return;
} }
const decryptedBlob = await decryptFile(content.file);
const contentUrl = URL.createObjectURL(decryptedBlob);
this.setState({ this.setState({
decryptedUrl: contentUrl, decryptedUrl: await this.mediaHelper.sourceUrl.value,
decryptedBlob: decryptedBlob, decryptedBlob: await this.mediaHelper.sourceBlob.value,
fetchingData: false, fetchingData: false,
}, () => { }, () => {
if (!this.videoRef.current) return; if (!this.videoRef.current) return;
@ -295,7 +297,7 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
onPlay={this.videoOnPlay} onPlay={this.videoOnPlay}
> >
</video> </video>
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} /> {/*<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />*/}
</span> </span>
); );
} }

View file

@ -32,6 +32,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
import { canCancel } from "../context_menus/MessageContextMenu"; import { canCancel } from "../context_menus/MessageContextMenu";
import Resend from "../../../Resend"; import Resend from "../../../Resend";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { canTileDownload } from "./IMediaBody";
const OptionsButton = ({ mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange }) => { const OptionsButton = ({ mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange }) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
@ -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 * Runs a given fn on the set of possible events to test. The first event
* that passes the checkFn will have fn executed on it. Both functions take * that passes the checkFn will have fn executed on it. Both functions take
@ -267,6 +278,17 @@ export default class MessageActionBar extends React.PureComponent {
key="react" 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) { if (allowCancel) {

View file

@ -22,6 +22,7 @@ import { Mjolnir } from "../../../mjolnir/Mjolnir";
import RedactedBody from "./RedactedBody"; import RedactedBody from "./RedactedBody";
import UnknownBody from "./UnknownBody"; import UnknownBody from "./UnknownBody";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { IMediaBody } from "./IMediaBody";
@replaceableComponent("views.messages.MessageEvent") @replaceableComponent("views.messages.MessageEvent")
export default class MessageEvent extends React.Component { export default class MessageEvent extends React.Component {
@ -69,6 +70,13 @@ export default class MessageEvent extends React.Component {
this.forceUpdate(); this.forceUpdate();
}; };
getMediaHelper() {
if (!this._body.current || !this._body.current.getMediaHelper) {
return null;
}
return this._body.current.getMediaHelper();
}
render() { render() {
const bodyTypes = { const bodyTypes = {
'm.text': sdk.getComponent('messages.TextualBody'), 'm.text': sdk.getComponent('messages.TextualBody'),

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

@ -0,0 +1,59 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* Utility class for lazily getting a variable.
*/
export class LazyValue<T> {
private val: T;
private prom: Promise<T>;
private done = false;
public constructor(private getFn: () => Promise<T>) {
}
/**
* Whether or not a cached value is present.
*/
public get present(): boolean {
// we use a tracking variable just in case the final value is falsey
return this.done;
}
/**
* Gets the value without invoking a get. May be undefined until the
* value is fetched properly.
*/
public get cachedValue(): T {
return this.val;
}
/**
* Gets a promise which resolves to the value, eventually.
*/
public get value(): Promise<T> {
if (this.prom) return this.prom;
this.prom = this.getFn();
// Fork the promise chain to avoid accidentally making it return undefined always.
this.prom.then(v => {
this.val = v;
this.done = true;
});
return this.prom;
}
}

View file

@ -0,0 +1,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());
};
}