mirror of
https://github.com/element-hq/element-web
synced 2024-11-28 04:21:57 +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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
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