Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Move download button for media to the action bar #6386

Merged
merged 17 commits into from
Jul 20, 2021
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions res/css/views/messages/_MessageActionBar.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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: 14px;
mask-image: url('$(res)/img/download.svg');
}

.mx_MessageActionBar_downloadButton.mx_MessageActionBar_downloadSpinnerButton::after {
background-color: transparent; // hide the download icon mask
}
3 changes: 2 additions & 1 deletion src/@types/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@ 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};
export type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;
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;
6 changes: 5 additions & 1 deletion src/components/views/context_menus/MessageContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
109 changes: 109 additions & 0 deletions src/components/views/messages/DownloadActionButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*
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";

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 default 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: "", // no image
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={spinner ? _t("Downloading") : _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>;
}
}
43 changes: 43 additions & 0 deletions src/components/views/messages/IBodyProps.ts
Original file line number Diff line number Diff line change
@@ -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;
}
21 changes: 21 additions & 0 deletions src/components/views/messages/IMediaBody.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
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 { MediaEventHelper } from "../../../utils/MediaEventHelper";

export interface IMediaBody {
getMediaHelper(): MediaEventHelper;
}
50 changes: 22 additions & 28 deletions src/components/views/messages/MAudioBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,64 +15,58 @@ 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 { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
import AudioPlayer from "../audio_messages/AudioPlayer";

interface IProps {
mxEvent: MatrixEvent;
}
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
import MFileBody from "./MFileBody";
import { IBodyProps } from "./IBodyProps";

interface IState {
error?: Error;
playback?: Playback;
decryptedBlob?: Blob;
}

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

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() {
Expand Down Expand Up @@ -103,7 +97,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} />
{ this.props.tileShape && <MFileBody {...this.props} showGenericPlaceholder={false} /> }
</span>
);
}
Expand Down
Loading