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

Commit

Permalink
Merge pull request #6386 from matrix-org/travis/voice-messages/download
Browse files Browse the repository at this point in the history
Move download button for media to the action bar
  • Loading branch information
turt2live authored Jul 20, 2021
2 parents 4a87730 + 7892539 commit 7ea17ae
Show file tree
Hide file tree
Showing 22 changed files with 591 additions and 375 deletions.
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

0 comments on commit 7ea17ae

Please sign in to comment.