mirror of
https://github.com/element-hq/element-web
synced 2024-11-24 10:15:43 +03:00
Simple POC for moving download button to action bar
This commit is contained in:
parent
aaa9040634
commit
0a99f76e7f
6 changed files with 237 additions and 24 deletions
32
src/components/views/messages/IMediaBody.ts
Normal file
32
src/components/views/messages/IMediaBody.ts
Normal 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;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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'),
|
||||
|
|
59
src/utils/LazyValue.ts
Normal file
59
src/utils/LazyValue.ts
Normal 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;
|
||||
}
|
||||
}
|
90
src/utils/MediaEventHelper.ts
Normal file
90
src/utils/MediaEventHelper.ts
Normal 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());
|
||||
};
|
||||
}
|
Loading…
Reference in a new issue