Merge remote-tracking branch 'upstream/develop' into fix/file-label-behaviour
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
This commit is contained in:
commit
e8760736ba
327 changed files with 4637 additions and 3095 deletions
218
src/components/views/messages/CallEvent.tsx
Normal file
218
src/components/views/messages/CallEvent.tsx
Normal file
|
@ -0,0 +1,218 @@
|
|||
/*
|
||||
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||
|
||||
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 React from 'react';
|
||||
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { _t, _td } from '../../../languageHandler';
|
||||
import MemberAvatar from '../avatars/MemberAvatar';
|
||||
import CallEventGrouper, { CallEventGrouperEvent, CustomCallState } from '../../structures/CallEventGrouper';
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
import { CallErrorCode, CallState } from 'matrix-js-sdk/src/webrtc/call';
|
||||
import InfoTooltip, { InfoTooltipKind } from '../elements/InfoTooltip';
|
||||
import classNames from 'classnames';
|
||||
import AccessibleTooltipButton from '../elements/AccessibleTooltipButton';
|
||||
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
callEventGrouper: CallEventGrouper;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
callState: CallState | CustomCallState;
|
||||
silenced: boolean;
|
||||
}
|
||||
|
||||
const TEXTUAL_STATES: Map<CallState | CustomCallState, string> = new Map([
|
||||
[CallState.Connected, _td("Connected")],
|
||||
[CallState.Connecting, _td("Connecting")],
|
||||
]);
|
||||
|
||||
export default class CallEvent extends React.Component<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
callState: this.props.callEventGrouper.state,
|
||||
silenced: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.callEventGrouper.addListener(CallEventGrouperEvent.StateChanged, this.onStateChanged);
|
||||
this.props.callEventGrouper.addListener(CallEventGrouperEvent.SilencedChanged, this.onSilencedChanged);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.callEventGrouper.removeListener(CallEventGrouperEvent.StateChanged, this.onStateChanged);
|
||||
this.props.callEventGrouper.removeListener(CallEventGrouperEvent.SilencedChanged, this.onSilencedChanged);
|
||||
}
|
||||
|
||||
private onSilencedChanged = (newState) => {
|
||||
this.setState({ silenced: newState });
|
||||
};
|
||||
|
||||
private onStateChanged = (newState: CallState) => {
|
||||
this.setState({ callState: newState });
|
||||
};
|
||||
|
||||
private renderContent(state: CallState | CustomCallState): JSX.Element {
|
||||
if (state === CallState.Ringing) {
|
||||
const silenceClass = classNames({
|
||||
"mx_CallEvent_iconButton": true,
|
||||
"mx_CallEvent_unSilence": this.state.silenced,
|
||||
"mx_CallEvent_silence": !this.state.silenced,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx_CallEvent_content">
|
||||
<AccessibleTooltipButton
|
||||
className={silenceClass}
|
||||
onClick={this.props.callEventGrouper.toggleSilenced}
|
||||
title={this.state.silenced ? _t("Sound on"): _t("Silence call")}
|
||||
/>
|
||||
<AccessibleButton
|
||||
className="mx_CallEvent_content_button mx_CallEvent_content_button_reject"
|
||||
onClick={this.props.callEventGrouper.rejectCall}
|
||||
kind="danger"
|
||||
>
|
||||
<span> { _t("Decline") } </span>
|
||||
</AccessibleButton>
|
||||
<AccessibleButton
|
||||
className="mx_CallEvent_content_button mx_CallEvent_content_button_answer"
|
||||
onClick={this.props.callEventGrouper.answerCall}
|
||||
kind="primary"
|
||||
>
|
||||
<span> { _t("Accept") } </span>
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state === CallState.Ended) {
|
||||
const hangupReason = this.props.callEventGrouper.hangupReason;
|
||||
|
||||
if ([CallErrorCode.UserHangup, "user hangup"].includes(hangupReason) || !hangupReason) {
|
||||
// workaround for https://github.com/vector-im/element-web/issues/5178
|
||||
// it seems Android randomly sets a reason of "user hangup" which is
|
||||
// interpreted as an error code :(
|
||||
// https://github.com/vector-im/riot-android/issues/2623
|
||||
// Also the correct hangup code as of VoIP v1 (with underscore)
|
||||
// Also, if we don't have a reason
|
||||
return (
|
||||
<div className="mx_CallEvent_content">
|
||||
{ _t("This call has ended") }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let reason;
|
||||
if (hangupReason === CallErrorCode.IceFailed) {
|
||||
// We couldn't establish a connection at all
|
||||
reason = _t("Could not connect media");
|
||||
} else if (hangupReason === "ice_timeout") {
|
||||
// We established a connection but it died
|
||||
reason = _t("Connection failed");
|
||||
} else if (hangupReason === CallErrorCode.NoUserMedia) {
|
||||
// The other side couldn't open capture devices
|
||||
reason = _t("Their device couldn't start the camera or microphone");
|
||||
} else if (hangupReason === "unknown_error") {
|
||||
// An error code the other side doesn't have a way to express
|
||||
// (as opposed to an error code they gave but we don't know about,
|
||||
// in which case we show the error code)
|
||||
reason = _t("An unknown error occurred");
|
||||
} else if (hangupReason === CallErrorCode.InviteTimeout) {
|
||||
reason = _t("No answer");
|
||||
} else if (hangupReason === CallErrorCode.UserBusy) {
|
||||
reason = _t("The user you called is busy.");
|
||||
} else {
|
||||
reason = _t('Unknown failure: %(reason)s)', { reason: hangupReason });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_CallEvent_content">
|
||||
<InfoTooltip
|
||||
tooltip={reason}
|
||||
className="mx_CallEvent_content_tooltip"
|
||||
kind={InfoTooltipKind.Warning}
|
||||
/>
|
||||
{ _t("This call has failed") }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (Array.from(TEXTUAL_STATES.keys()).includes(state)) {
|
||||
return (
|
||||
<div className="mx_CallEvent_content">
|
||||
{ TEXTUAL_STATES.get(state) }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state === CustomCallState.Missed) {
|
||||
return (
|
||||
<div className="mx_CallEvent_content">
|
||||
{ _t("You missed this call") }
|
||||
<AccessibleButton
|
||||
className="mx_CallEvent_content_button mx_CallEvent_content_button_callBack"
|
||||
onClick={this.props.callEventGrouper.callBack}
|
||||
kind="primary"
|
||||
>
|
||||
<span> { _t("Call back") } </span>
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_CallEvent_content">
|
||||
{ _t("The call is in an unknown state!") }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const event = this.props.mxEvent;
|
||||
const sender = event.sender ? event.sender.name : event.getSender();
|
||||
const isVoice = this.props.callEventGrouper.isVoice;
|
||||
const callType = isVoice ? _t("Voice call") : _t("Video call");
|
||||
const content = this.renderContent(this.state.callState);
|
||||
const className = classNames({
|
||||
mx_CallEvent: true,
|
||||
mx_CallEvent_voice: isVoice,
|
||||
mx_CallEvent_video: !isVoice,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="mx_CallEvent_info">
|
||||
<MemberAvatar
|
||||
member={event.sender}
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
<div className="mx_CallEvent_info_basic">
|
||||
<div className="mx_CallEvent_sender">
|
||||
{ sender }
|
||||
</div>
|
||||
<div className="mx_CallEvent_type">
|
||||
<div className="mx_CallEvent_type_icon"></div>
|
||||
{ callType }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{ content }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
109
src/components/views/messages/DownloadActionButton.tsx
Normal file
109
src/components/views/messages/DownloadActionButton.tsx
Normal file
|
@ -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>;
|
||||
}
|
||||
}
|
|
@ -110,20 +110,20 @@ export default class EditHistoryMessage extends React.PureComponent {
|
|||
if (!this.props.mxEvent.isRedacted() && !this.props.isBaseEvent && this.state.canRedact) {
|
||||
redactButton = (
|
||||
<AccessibleButton onClick={this._onRedactClick}>
|
||||
{_t("Remove")}
|
||||
{ _t("Remove") }
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
const viewSourceButton = (
|
||||
<AccessibleButton onClick={this._onViewSourceClick}>
|
||||
{_t("View Source")}
|
||||
{ _t("View Source") }
|
||||
</AccessibleButton>
|
||||
);
|
||||
// disabled remove button when not allowed
|
||||
return (
|
||||
<div className="mx_MessageActionBar">
|
||||
{redactButton}
|
||||
{viewSourceButton}
|
||||
{ redactButton }
|
||||
{ viewSourceButton }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -146,11 +146,11 @@ export default class EditHistoryMessage extends React.PureComponent {
|
|||
contentContainer = (
|
||||
<div className="mx_EventTile_content" ref={this._content}>*
|
||||
<span className="mx_MEmoteBody_sender">{ name }</span>
|
||||
{contentElements}
|
||||
{ contentElements }
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
contentContainer = <div className="mx_EventTile_content" ref={this._content}>{contentElements}</div>;
|
||||
contentContainer = <div className="mx_EventTile_content" ref={this._content}>{ contentElements }</div>;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -165,7 +165,7 @@ export default class EditHistoryMessage extends React.PureComponent {
|
|||
<li>
|
||||
<div className={classes}>
|
||||
<div className="mx_EventTile_line">
|
||||
<span className="mx_MessageTimestamp">{timestamp}</span>
|
||||
<span className="mx_MessageTimestamp">{ timestamp }</span>
|
||||
{ contentContainer }
|
||||
{ this._renderActionBar() }
|
||||
</div>
|
||||
|
|
43
src/components/views/messages/IBodyProps.ts
Normal file
43
src/components/views/messages/IBodyProps.ts
Normal file
|
@ -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
src/components/views/messages/IMediaBody.ts
Normal file
21
src/components/views/messages/IMediaBody.ts
Normal file
|
@ -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;
|
||||
}
|
|
@ -15,30 +15,23 @@ 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 = {};
|
||||
|
@ -46,33 +39,34 @@ export default class MAudioBody extends React.PureComponent<IProps, IState> {
|
|||
|
||||
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() {
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -15,10 +15,8 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React, { createRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import filesize from 'filesize';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { decryptFile } from '../../../utils/DecryptFile';
|
||||
import Modal from '../../../Modal';
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
@ -26,16 +24,20 @@ import { mediaFromContent } from "../../../customisations/Media";
|
|||
import ErrorDialog from "../dialogs/ErrorDialog";
|
||||
import { TileShape } from "../rooms/EventTile";
|
||||
import { presentableTextForFile } from "../../../utils/FileUtils";
|
||||
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
|
||||
import { IBodyProps } from "./IBodyProps";
|
||||
|
||||
let downloadIconUrl; // cached copy of the download.svg asset for the sandboxed iframe later on
|
||||
export let DOWNLOAD_ICON_URL; // cached copy of the download.svg asset for the sandboxed iframe later on
|
||||
|
||||
async function cacheDownloadIcon() {
|
||||
if (downloadIconUrl) return; // cached already
|
||||
if (DOWNLOAD_ICON_URL) return; // cached already
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const svg = await fetch(require("../../../../res/img/download.svg")).then(r => r.text());
|
||||
downloadIconUrl = "data:image/svg+xml;base64," + window.btoa(svg);
|
||||
DOWNLOAD_ICON_URL = "data:image/svg+xml;base64," + window.btoa(svg);
|
||||
}
|
||||
|
||||
// Cache the asset immediately
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
cacheDownloadIcon();
|
||||
|
||||
// User supplied content can contain scripts, we have to be careful that
|
||||
|
@ -73,7 +75,7 @@ cacheDownloadIcon();
|
|||
* @param {HTMLElement} element The element to get the current style of.
|
||||
* @return {string} The CSS style encoded as a string.
|
||||
*/
|
||||
function computedStyle(element) {
|
||||
export function computedStyle(element: HTMLElement) {
|
||||
if (!element) {
|
||||
return "";
|
||||
}
|
||||
|
@ -91,53 +93,48 @@ function computedStyle(element) {
|
|||
return cssText;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.messages.MFileBody")
|
||||
export default class MFileBody extends React.Component {
|
||||
static propTypes = {
|
||||
/* the MatrixEvent to show */
|
||||
mxEvent: PropTypes.object.isRequired,
|
||||
/* already decrypted blob */
|
||||
decryptedBlob: PropTypes.object,
|
||||
/* called when the download link iframe is shown */
|
||||
onHeightChanged: PropTypes.func,
|
||||
/* the shape of the tile, used */
|
||||
tileShape: PropTypes.string,
|
||||
/* whether or not to show the default placeholder for the file. Defaults to true. */
|
||||
showGenericPlaceholder: PropTypes.bool,
|
||||
};
|
||||
interface IProps extends IBodyProps {
|
||||
/* whether or not to show the default placeholder for the file. Defaults to true. */
|
||||
showGenericPlaceholder: boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
decryptedBlob?: Blob;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.messages.MFileBody")
|
||||
export default class MFileBody extends React.Component<IProps, IState> {
|
||||
static defaultProps = {
|
||||
showGenericPlaceholder: true,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
private iframe: React.RefObject<HTMLIFrameElement> = createRef();
|
||||
private dummyLink: React.RefObject<HTMLAnchorElement> = createRef();
|
||||
private userDidClick = false;
|
||||
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
decryptedBlob: (this.props.decryptedBlob ? this.props.decryptedBlob : null),
|
||||
};
|
||||
|
||||
this._iframe = createRef();
|
||||
this._dummyLink = createRef();
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
_getContentUrl() {
|
||||
private getContentUrl(): string {
|
||||
const media = mediaFromContent(this.props.mxEvent.getContent());
|
||||
return media.srcHttp;
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
public componentDidUpdate(prevProps, prevState) {
|
||||
if (this.props.onHeightChanged && !prevState.decryptedBlob && this.state.decryptedBlob) {
|
||||
this.props.onHeightChanged();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const content = this.props.mxEvent.getContent();
|
||||
public render() {
|
||||
const content = this.props.mxEvent.getContent<IMediaEventContent>();
|
||||
const text = presentableTextForFile(content);
|
||||
const isEncrypted = content.file !== undefined;
|
||||
const isEncrypted = this.props.mediaEventHelper.media.isEncrypted;
|
||||
const fileName = content.body && content.body.length > 0 ? content.body : _t("Attachment");
|
||||
const contentUrl = this._getContentUrl();
|
||||
const contentUrl = this.getContentUrl();
|
||||
const fileSize = content.info ? content.info.size : null;
|
||||
const fileType = content.info ? content.info.mimetype : "application/octet-stream";
|
||||
|
||||
|
@ -147,48 +144,44 @@ export default class MFileBody extends React.Component {
|
|||
<div className="mx_MFileBody_info">
|
||||
<span className="mx_MFileBody_info_icon" />
|
||||
<span className="mx_MFileBody_info_filename">
|
||||
{ presentableTextForFile(content, false) }
|
||||
{ presentableTextForFile(content, _t("Attachment"), false) }
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const showDownloadLink = this.props.tileShape || !this.props.showGenericPlaceholder;
|
||||
|
||||
if (isEncrypted) {
|
||||
if (this.state.decryptedBlob === null) {
|
||||
if (!this.state.decryptedBlob) {
|
||||
// Need to decrypt the attachment
|
||||
// Wait for the user to click on the link before downloading
|
||||
// and decrypting the attachment.
|
||||
let decrypting = false;
|
||||
const decrypt = (e) => {
|
||||
if (decrypting) {
|
||||
return false;
|
||||
}
|
||||
decrypting = true;
|
||||
decryptFile(content.file).then((blob) => {
|
||||
const decrypt = async () => {
|
||||
try {
|
||||
this.userDidClick = true;
|
||||
this.setState({
|
||||
decryptedBlob: blob,
|
||||
decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
|
||||
});
|
||||
}).catch((err) => {
|
||||
} catch (err) {
|
||||
console.warn("Unable to decrypt attachment: ", err);
|
||||
Modal.createTrackedDialog('Error decrypting attachment', '', ErrorDialog, {
|
||||
title: _t("Error"),
|
||||
description: _t("Error decrypting attachment"),
|
||||
});
|
||||
}).finally(() => {
|
||||
decrypting = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// This button should actually Download because usercontent/ will try to click itself
|
||||
// but it is not guaranteed between various browsers' settings.
|
||||
return (
|
||||
<span className="mx_MFileBody">
|
||||
{placeholder}
|
||||
<div className="mx_MFileBody_download">
|
||||
{ placeholder }
|
||||
{ showDownloadLink && <div className="mx_MFileBody_download">
|
||||
<AccessibleButton onClick={decrypt}>
|
||||
{ _t("Decrypt %(text)s", { text: text }) }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div> }
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
@ -196,9 +189,9 @@ export default class MFileBody extends React.Component {
|
|||
// When the iframe loads we tell it to render a download link
|
||||
const onIframeLoad = (ev) => {
|
||||
ev.target.contentWindow.postMessage({
|
||||
imgSrc: downloadIconUrl,
|
||||
imgSrc: DOWNLOAD_ICON_URL,
|
||||
imgStyle: null, // it handles this internally for us. Useful if a downstream changes the icon.
|
||||
style: computedStyle(this._dummyLink.current),
|
||||
style: computedStyle(this.dummyLink.current),
|
||||
blob: this.state.decryptedBlob,
|
||||
// Set a download attribute for encrypted files so that the file
|
||||
// will have the correct name when the user tries to download it.
|
||||
|
@ -206,7 +199,7 @@ export default class MFileBody extends React.Component {
|
|||
download: fileName,
|
||||
textContent: _t("Download %(text)s", { text: text }),
|
||||
// only auto-download if a user triggered this iframe explicitly
|
||||
auto: !this.props.decryptedBlob,
|
||||
auto: this.userDidClick,
|
||||
}, "*");
|
||||
};
|
||||
|
||||
|
@ -215,22 +208,22 @@ export default class MFileBody extends React.Component {
|
|||
// If the attachment is encrypted then put the link inside an iframe.
|
||||
return (
|
||||
<span className="mx_MFileBody">
|
||||
{placeholder}
|
||||
<div className="mx_MFileBody_download">
|
||||
{ placeholder }
|
||||
{ showDownloadLink && <div className="mx_MFileBody_download">
|
||||
<div style={{ display: "none" }}>
|
||||
{ /*
|
||||
* Add dummy copy of the "a" tag
|
||||
* We'll use it to learn how the download link
|
||||
* would have been styled if it was rendered inline.
|
||||
*/ }
|
||||
<a ref={this._dummyLink} />
|
||||
<a ref={this.dummyLink} />
|
||||
</div>
|
||||
<iframe
|
||||
src={url}
|
||||
onLoad={onIframeLoad}
|
||||
ref={this._iframe}
|
||||
ref={this.iframe}
|
||||
sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation" />
|
||||
</div>
|
||||
</div> }
|
||||
</span>
|
||||
);
|
||||
} else if (contentUrl) {
|
||||
|
@ -261,7 +254,7 @@ export default class MFileBody extends React.Component {
|
|||
|
||||
// Start a fetch for the download
|
||||
// Based upon https://stackoverflow.com/a/49500465
|
||||
fetch(contentUrl).then((response) => response.blob()).then((blob) => {
|
||||
this.props.mediaEventHelper.sourceBlob.value.then((blob) => {
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
// We have to create an anchor to download the file
|
||||
|
@ -278,40 +271,24 @@ export default class MFileBody extends React.Component {
|
|||
downloadProps["download"] = fileName;
|
||||
}
|
||||
|
||||
// If the attachment is not encrypted then we check whether we
|
||||
// are being displayed in the room timeline or in a list of
|
||||
// files in the right hand side of the screen.
|
||||
if (this.props.tileShape === TileShape.FileGrid) {
|
||||
return (
|
||||
<span className="mx_MFileBody">
|
||||
{placeholder}
|
||||
<div className="mx_MFileBody_download">
|
||||
<a className="mx_MFileBody_downloadLink" {...downloadProps}>
|
||||
{ fileName }
|
||||
</a>
|
||||
<div className="mx_MImageBody_size">
|
||||
{ content.info && content.info.size ? filesize(content.info.size) : "" }
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<span className="mx_MFileBody">
|
||||
{placeholder}
|
||||
<div className="mx_MFileBody_download">
|
||||
<a {...downloadProps}>
|
||||
<span className="mx_MFileBody_download_icon" />
|
||||
{ _t("Download %(text)s", { text: text }) }
|
||||
</a>
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="mx_MFileBody">
|
||||
{ placeholder }
|
||||
{ showDownloadLink && <div className="mx_MFileBody_download">
|
||||
<a {...downloadProps}>
|
||||
<span className="mx_MFileBody_download_icon" />
|
||||
{ _t("Download %(text)s", { text: text }) }
|
||||
</a>
|
||||
{ this.props.tileShape === TileShape.FileGrid && <div className="mx_MImageBody_size">
|
||||
{ content.info && content.info.size ? filesize(content.info.size) : "" }
|
||||
</div> }
|
||||
</div> }
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
const extra = text ? (': ' + text) : '';
|
||||
return <span className="mx_MFileBody">
|
||||
{placeholder}
|
||||
{ placeholder }
|
||||
{ _t("Invalid file%(extra)s", { extra: extra }) }
|
||||
</span>;
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2018, 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
@ -21,7 +20,6 @@ import { Blurhash } from "react-blurhash";
|
|||
|
||||
import MFileBody from './MFileBody';
|
||||
import Modal from '../../../Modal';
|
||||
import { decryptFile } from '../../../utils/DecryptFile';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
|
@ -29,24 +27,10 @@ import InlineSpinner from '../elements/InlineSpinner';
|
|||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { mediaFromContent } from "../../../customisations/Media";
|
||||
import { BLURHASH_FIELD } from "../../../ContentMessages";
|
||||
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
|
||||
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
|
||||
import { IMediaEventContent } from '../../../customisations/models/IMediaEventContent';
|
||||
import ImageView from '../elements/ImageView';
|
||||
import { SyncState } from 'matrix-js-sdk/src/sync.api';
|
||||
|
||||
export interface IProps {
|
||||
/* the MatrixEvent to show */
|
||||
mxEvent: MatrixEvent;
|
||||
/* called when the image has loaded */
|
||||
onHeightChanged(): void;
|
||||
|
||||
/* the maximum image height to use */
|
||||
maxImageHeight?: number;
|
||||
|
||||
/* the permalinkCreator */
|
||||
permalinkCreator?: RoomPermalinkCreator;
|
||||
}
|
||||
import { IBodyProps } from "./IBodyProps";
|
||||
|
||||
interface IState {
|
||||
decryptedUrl?: string;
|
||||
|
@ -64,12 +48,12 @@ interface IState {
|
|||
}
|
||||
|
||||
@replaceableComponent("views.messages.MImageBody")
|
||||
export default class MImageBody extends React.Component<IProps, IState> {
|
||||
export default class MImageBody extends React.Component<IBodyProps, IState> {
|
||||
static contextType = MatrixClientContext;
|
||||
private unmounted = true;
|
||||
private image = createRef<HTMLImageElement>();
|
||||
|
||||
constructor(props: IProps) {
|
||||
constructor(props: IBodyProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
|
@ -257,38 +241,23 @@ export default class MImageBody extends React.Component<IProps, IState> {
|
|||
}
|
||||
}
|
||||
|
||||
private downloadImage(): void {
|
||||
const content = this.props.mxEvent.getContent();
|
||||
if (content.file !== undefined && this.state.decryptedUrl === null) {
|
||||
let thumbnailPromise = Promise.resolve(null);
|
||||
if (content.info && content.info.thumbnail_file) {
|
||||
thumbnailPromise = decryptFile(
|
||||
content.info.thumbnail_file,
|
||||
).then(function(blob) {
|
||||
return URL.createObjectURL(blob);
|
||||
private async downloadImage() {
|
||||
if (this.props.mediaEventHelper.media.isEncrypted && this.state.decryptedUrl === null) {
|
||||
try {
|
||||
const thumbnailUrl = await this.props.mediaEventHelper.thumbnailUrl.value;
|
||||
this.setState({
|
||||
decryptedUrl: await this.props.mediaEventHelper.sourceUrl.value,
|
||||
decryptedThumbnailUrl: thumbnailUrl,
|
||||
decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
|
||||
});
|
||||
}
|
||||
let decryptedBlob;
|
||||
thumbnailPromise.then((thumbnailUrl) => {
|
||||
return decryptFile(content.file).then(function(blob) {
|
||||
decryptedBlob = blob;
|
||||
return URL.createObjectURL(blob);
|
||||
}).then((contentUrl) => {
|
||||
if (this.unmounted) return;
|
||||
this.setState({
|
||||
decryptedUrl: contentUrl,
|
||||
decryptedThumbnailUrl: thumbnailUrl,
|
||||
decryptedBlob: decryptedBlob,
|
||||
});
|
||||
});
|
||||
}).catch((err) => {
|
||||
} catch (err) {
|
||||
if (this.unmounted) return;
|
||||
console.warn("Unable to decrypt attachment: ", err);
|
||||
// Set a placeholder image when we can't decrypt the image.
|
||||
this.setState({
|
||||
error: err,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -300,29 +269,15 @@ export default class MImageBody extends React.Component<IProps, IState> {
|
|||
localStorage.getItem("mx_ShowImage_" + this.props.mxEvent.getId()) === "true";
|
||||
|
||||
if (showImage) {
|
||||
// Don't download anything becaue we don't want to display anything.
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.downloadImage();
|
||||
this.setState({ showImage: true });
|
||||
}
|
||||
|
||||
this._afterComponentDidMount();
|
||||
}
|
||||
|
||||
// To be overridden by subclasses (e.g. MStickerBody) for further
|
||||
// initialisation after componentDidMount
|
||||
_afterComponentDidMount() {
|
||||
} // else don't download anything because we don't want to display anything.
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unmounted = true;
|
||||
this.context.removeListener('sync', this.onClientSync);
|
||||
|
||||
if (this.state.decryptedUrl) {
|
||||
URL.revokeObjectURL(this.state.decryptedUrl);
|
||||
}
|
||||
if (this.state.decryptedThumbnailUrl) {
|
||||
URL.revokeObjectURL(this.state.decryptedThumbnailUrl);
|
||||
}
|
||||
}
|
||||
|
||||
protected messageContent(
|
||||
|
@ -406,8 +361,6 @@ export default class MImageBody extends React.Component<IProps, IState> {
|
|||
|
||||
const thumbnail = (
|
||||
<div className="mx_MImageBody_thumbnail_container" style={{ maxHeight: maxHeight + "px", maxWidth: maxWidth + "px" }} >
|
||||
{ /* Calculate aspect ratio, using %padding will size _container correctly */ }
|
||||
<div style={{ paddingBottom: forcedHeight ? (forcedHeight + "px") : ((100 * infoHeight / infoWidth) + '%') }} />
|
||||
{ showPlaceholder &&
|
||||
<div className="mx_MImageBody_thumbnail" style={{
|
||||
// Constrain width here so that spinner appears central to the loaded thumbnail
|
||||
|
@ -432,7 +385,7 @@ export default class MImageBody extends React.Component<IProps, IState> {
|
|||
// Overidden by MStickerBody
|
||||
protected wrapImage(contentUrl: string, children: JSX.Element): JSX.Element {
|
||||
return <a href={contentUrl} onClick={this.onClick}>
|
||||
{children}
|
||||
{ children }
|
||||
</a>;
|
||||
}
|
||||
|
||||
|
@ -440,9 +393,9 @@ export default class MImageBody extends React.Component<IProps, IState> {
|
|||
protected getPlaceholder(width: number, height: number): JSX.Element {
|
||||
const blurhash = this.props.mxEvent.getContent().info[BLURHASH_FIELD];
|
||||
if (blurhash) return <Blurhash hash={blurhash} width={width} height={height} />;
|
||||
return <div className="mx_MImageBody_thumbnail_spinner">
|
||||
return (
|
||||
<InlineSpinner w={32} h={32} />
|
||||
</div>;
|
||||
);
|
||||
}
|
||||
|
||||
// Overidden by MStickerBody
|
||||
|
@ -452,7 +405,10 @@ export default class MImageBody extends React.Component<IProps, IState> {
|
|||
|
||||
// Overidden by MStickerBody
|
||||
protected getFileBody(): string | JSX.Element {
|
||||
return <MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />;
|
||||
// We only ever need the download bar if we're appearing outside of the timeline
|
||||
if (this.props.tileShape) {
|
||||
return <MFileBody {...this.props} showGenericPlaceholder={false} />;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
|
@ -499,7 +455,7 @@ export class HiddenImagePlaceholder extends React.PureComponent<PlaceholderIProp
|
|||
<div className={className} style={{ maxWidth: maxWidth }}>
|
||||
<div className='mx_HiddenImagePlaceholder_button'>
|
||||
<span className='mx_HiddenImagePlaceholder_eye' />
|
||||
<span>{_t("Show image")}</span>
|
||||
<span>{ _t("Show image") }</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -131,7 +131,7 @@ export default class MKeyVerificationRequest extends React.Component<IProps> {
|
|||
const accepted = request.ready || request.started || request.done;
|
||||
if (accepted) {
|
||||
stateLabel = (<AccessibleButton onClick={this.openRequest}>
|
||||
{this.acceptedLabel(request.receivingUserId)}
|
||||
{ this.acceptedLabel(request.receivingUserId) }
|
||||
</AccessibleButton>);
|
||||
} else if (request.cancelled) {
|
||||
stateLabel = this.cancelledLabel(request.cancellingUserId);
|
||||
|
@ -140,7 +140,7 @@ export default class MKeyVerificationRequest extends React.Component<IProps> {
|
|||
} else if (request.declining) {
|
||||
stateLabel = _t("Declining …");
|
||||
}
|
||||
stateNode = (<div className="mx_cryptoEvent_state">{stateLabel}</div>);
|
||||
stateNode = (<div className="mx_cryptoEvent_state">{ stateLabel }</div>);
|
||||
}
|
||||
|
||||
if (!request.initiatedByMe) {
|
||||
|
@ -150,10 +150,10 @@ export default class MKeyVerificationRequest extends React.Component<IProps> {
|
|||
if (request.canAccept) {
|
||||
stateNode = (<div className="mx_cryptoEvent_buttons">
|
||||
<AccessibleButton kind="danger" onClick={this.onRejectClicked}>
|
||||
{_t("Decline")}
|
||||
{ _t("Decline") }
|
||||
</AccessibleButton>
|
||||
<AccessibleButton kind="primary" onClick={this.onAcceptClicked}>
|
||||
{_t("Accept")}
|
||||
{ _t("Accept") }
|
||||
</AccessibleButton>
|
||||
</div>);
|
||||
}
|
||||
|
|
|
@ -23,16 +23,16 @@ import { BLURHASH_FIELD } from "../../../ContentMessages";
|
|||
@replaceableComponent("views.messages.MStickerBody")
|
||||
export default class MStickerBody extends MImageBody {
|
||||
// Mostly empty to prevent default behaviour of MImageBody
|
||||
onClick(ev) {
|
||||
protected onClick = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
if (!this.state.showImage) {
|
||||
this.showImage();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// MStickerBody doesn't need a wrapping `<a href=...>`, but it does need extra padding
|
||||
// which is added by mx_MStickerBody_wrapper
|
||||
wrapImage(contentUrl, children) {
|
||||
protected wrapImage(contentUrl: string, children: React.ReactNode): JSX.Element {
|
||||
let onClick = null;
|
||||
if (!this.state.showImage) {
|
||||
onClick = this.onClick;
|
||||
|
@ -42,13 +42,13 @@ export default class MStickerBody extends MImageBody {
|
|||
|
||||
// Placeholder to show in place of the sticker image if
|
||||
// img onLoad hasn't fired yet.
|
||||
getPlaceholder(width, height) {
|
||||
protected getPlaceholder(width: number, height: number): JSX.Element {
|
||||
if (this.props.mxEvent.getContent().info[BLURHASH_FIELD]) return super.getPlaceholder(width, height);
|
||||
return <img src={require("../../../../res/img/icons-show-stickers.svg")} width="75" height="75" />;
|
||||
}
|
||||
|
||||
// Tooltip to show on mouse over
|
||||
getTooltip() {
|
||||
protected getTooltip(): JSX.Element {
|
||||
const content = this.props.mxEvent && this.props.mxEvent.getContent();
|
||||
|
||||
if (!content || !content.body || !content.info || !content.info.w) return null;
|
||||
|
@ -60,7 +60,7 @@ export default class MStickerBody extends MImageBody {
|
|||
}
|
||||
|
||||
// Don't show "Download this_file.png ..."
|
||||
getFileBody() {
|
||||
protected getFileBody() {
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2015 - 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.
|
||||
|
@ -18,21 +17,15 @@ limitations under the License.
|
|||
import React from 'react';
|
||||
import { decode } from "blurhash";
|
||||
|
||||
import MFileBody from './MFileBody';
|
||||
import { decryptFile } from '../../../utils/DecryptFile';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import InlineSpinner from '../elements/InlineSpinner';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { mediaFromContent } from "../../../customisations/Media";
|
||||
import { BLURHASH_FIELD } from "../../../ContentMessages";
|
||||
|
||||
interface IProps {
|
||||
/* the MatrixEvent to show */
|
||||
mxEvent: any;
|
||||
/* called when the video has loaded */
|
||||
onHeightChanged: () => void;
|
||||
}
|
||||
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
|
||||
import { IBodyProps } from "./IBodyProps";
|
||||
import MFileBody from "./MFileBody";
|
||||
|
||||
interface IState {
|
||||
decryptedUrl?: string;
|
||||
|
@ -45,11 +38,12 @@ interface IState {
|
|||
}
|
||||
|
||||
@replaceableComponent("views.messages.MVideoBody")
|
||||
export default class MVideoBody extends React.PureComponent<IProps, IState> {
|
||||
export default class MVideoBody extends React.PureComponent<IBodyProps, IState> {
|
||||
private videoRef = React.createRef<HTMLVideoElement>();
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
fetchingData: false,
|
||||
decryptedUrl: null,
|
||||
|
@ -97,7 +91,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 +133,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 +146,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.props.mediaEventHelper.media.isEncrypted && this.state.decryptedUrl === null) {
|
||||
try {
|
||||
const thumbnailUrl = await thumbnailPromise;
|
||||
const thumbnailUrl = await this.props.mediaEventHelper.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.props.mediaEventHelper.sourceUrl.value,
|
||||
decryptedThumbnailUrl: thumbnailUrl,
|
||||
decryptedBlob: decryptedBlob,
|
||||
decryptedBlob: await this.props.mediaEventHelper.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
|
||||
|
@ -195,15 +181,6 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
|
|||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.state.decryptedUrl) {
|
||||
URL.revokeObjectURL(this.state.decryptedUrl);
|
||||
}
|
||||
if (this.state.decryptedThumbnailUrl) {
|
||||
URL.revokeObjectURL(this.state.decryptedThumbnailUrl);
|
||||
}
|
||||
}
|
||||
|
||||
private videoOnPlay = async () => {
|
||||
if (this.hasContentUrl() || this.state.fetchingData || this.state.error) {
|
||||
// We have the file, we are fetching the file, or there is an error.
|
||||
|
@ -213,18 +190,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.props.mediaEventHelper.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.props.mediaEventHelper.sourceUrl.value,
|
||||
decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
|
||||
fetchingData: false,
|
||||
}, () => {
|
||||
if (!this.videoRef.current) return;
|
||||
|
@ -295,7 +269,7 @@ export default class MVideoBody extends React.PureComponent<IProps, IState> {
|
|||
onPlay={this.videoOnPlay}
|
||||
>
|
||||
</video>
|
||||
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />
|
||||
{ this.props.tileShape && <MFileBody {...this.props} showGenericPlaceholder={false} /> }
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -15,73 +15,16 @@ 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 RecordingPlayback from "../audio_messages/RecordingPlayback";
|
||||
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
|
||||
import { TileShape } from "../rooms/EventTile";
|
||||
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
tileShape?: TileShape;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
error?: Error;
|
||||
playback?: Playback;
|
||||
decryptedBlob?: Blob;
|
||||
}
|
||||
import MAudioBody from "./MAudioBody";
|
||||
import MFileBody from "./MFileBody";
|
||||
|
||||
@replaceableComponent("views.messages.MVoiceMessageBody")
|
||||
export default class MVoiceMessageBody extends React.PureComponent<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
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 {
|
||||
const blob = await decryptFile(content.file);
|
||||
buffer = await blob.arrayBuffer();
|
||||
this.setState({ decryptedBlob: blob });
|
||||
} catch (e) {
|
||||
this.setState({ error: e });
|
||||
console.warn("Unable to decrypt voice 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 voice message", e);
|
||||
return; // stop processing the audio file
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
this.setState({ playback });
|
||||
// Note: the RecordingPlayback component will handle preparing the Playback class for us.
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
this.state.playback?.destroy();
|
||||
}
|
||||
|
||||
export default class MVoiceMessageBody extends MAudioBody {
|
||||
// A voice message is an audio file but rendered in a special way.
|
||||
public render() {
|
||||
if (this.state.error) {
|
||||
// TODO: @@TR: Verify error state
|
||||
|
@ -106,7 +49,7 @@ export default class MVoiceMessageBody extends React.PureComponent<IProps, IStat
|
|||
return (
|
||||
<span className="mx_MVoiceMessageBody">
|
||||
<RecordingPlayback playback={this.state.playback} tileShape={this.props.tileShape} />
|
||||
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />
|
||||
{ this.props.tileShape && <MFileBody {...this.props} showGenericPlaceholder={false} /> }
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -15,18 +15,14 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import MAudioBody from "./MAudioBody";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import MVoiceMessageBody from "./MVoiceMessageBody";
|
||||
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
}
|
||||
import { IBodyProps } from "./IBodyProps";
|
||||
|
||||
@replaceableComponent("views.messages.MVoiceOrAudioBody")
|
||||
export default class MVoiceOrAudioBody extends React.PureComponent<IProps> {
|
||||
export default class MVoiceOrAudioBody extends React.PureComponent<IBodyProps> {
|
||||
public render() {
|
||||
// MSC2516 is a legacy identifier. See https://github.com/matrix-org/matrix-doc/pull/3245
|
||||
const isVoiceMessage = !!this.props.mxEvent.getContent()['org.matrix.msc2516.voice']
|
||||
|
|
|
@ -32,6 +32,8 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
|
|||
import { canCancel } from "../context_menus/MessageContextMenu";
|
||||
import Resend from "../../../Resend";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
||||
import DownloadActionButton from "./DownloadActionButton";
|
||||
|
||||
const OptionsButton = ({ mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange }) => {
|
||||
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
|
||||
|
@ -267,6 +269,15 @@ export default class MessageActionBar extends React.PureComponent {
|
|||
key="react"
|
||||
/>);
|
||||
}
|
||||
|
||||
// XXX: Assuming that the underlying tile will be a media event if it is eligible media.
|
||||
if (MediaEventHelper.isEligible(this.props.mxEvent)) {
|
||||
toolbarOpts.splice(0, 0, <DownloadActionButton
|
||||
mxEvent={this.props.mxEvent}
|
||||
mediaEventHelperGet={() => this.props.getTile?.().getMediaHelper?.()}
|
||||
key="download"
|
||||
/>);
|
||||
}
|
||||
}
|
||||
|
||||
if (allowCancel) {
|
||||
|
@ -286,7 +297,7 @@ export default class MessageActionBar extends React.PureComponent {
|
|||
|
||||
// aria-live=off to not have this read out automatically as navigating around timeline, gets repetitive.
|
||||
return <Toolbar className="mx_MessageActionBar" aria-label={_t("Message Actions")} aria-live="off">
|
||||
{toolbarOpts}
|
||||
{ toolbarOpts }
|
||||
</Toolbar>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2015 - 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.
|
||||
|
@ -15,90 +15,98 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React, { createRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import * as sdk from '../../../index';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { Mjolnir } from "../../../mjolnir/Mjolnir";
|
||||
import RedactedBody from "./RedactedBody";
|
||||
import UnknownBody from "./UnknownBody";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { IMediaBody } from "./IMediaBody";
|
||||
import { IOperableEventTile } from "../context_menus/MessageContextMenu";
|
||||
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
||||
import { ReactAnyComponent } from "../../../@types/common";
|
||||
import { EventType, MsgType } from "matrix-js-sdk/src/@types/event";
|
||||
import { IBodyProps } from "./IBodyProps";
|
||||
|
||||
// onMessageAllowed is handled internally
|
||||
interface IProps extends Omit<IBodyProps, "onMessageAllowed"> {
|
||||
/* overrides for the msgtype-specific components, used by ReplyTile to override file rendering */
|
||||
overrideBodyTypes?: Record<string, React.Component>;
|
||||
overrideEventTypes?: Record<string, React.Component>;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.messages.MessageEvent")
|
||||
export default class MessageEvent extends React.Component {
|
||||
static propTypes = {
|
||||
/* the MatrixEvent to show */
|
||||
mxEvent: PropTypes.object.isRequired,
|
||||
export default class MessageEvent extends React.Component<IProps> implements IMediaBody, IOperableEventTile {
|
||||
private body: React.RefObject<React.Component | IOperableEventTile> = createRef();
|
||||
private mediaHelper: MediaEventHelper;
|
||||
|
||||
/* a list of words to highlight */
|
||||
highlights: PropTypes.array,
|
||||
|
||||
/* link URL for the highlights */
|
||||
highlightLink: PropTypes.string,
|
||||
|
||||
/* should show URL previews for this event */
|
||||
showUrlPreview: PropTypes.bool,
|
||||
|
||||
/* callback called when dynamic content in events are loaded */
|
||||
onHeightChanged: PropTypes.func,
|
||||
|
||||
/* the shape of the tile, used */
|
||||
tileShape: PropTypes.string, // TODO: Use TileShape enum
|
||||
|
||||
/* the maximum image height to use, if the event is an image */
|
||||
maxImageHeight: PropTypes.number,
|
||||
|
||||
/* overrides for the msgtype-specific components, used by ReplyTile to override file rendering */
|
||||
overrideBodyTypes: PropTypes.object,
|
||||
overrideEventTypes: PropTypes.object,
|
||||
|
||||
/* the permalinkCreator */
|
||||
permalinkCreator: PropTypes.object,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this._body = createRef();
|
||||
if (MediaEventHelper.isEligible(this.props.mxEvent)) {
|
||||
this.mediaHelper = new MediaEventHelper(this.props.mxEvent);
|
||||
}
|
||||
}
|
||||
|
||||
getEventTileOps = () => {
|
||||
return this._body.current && this._body.current.getEventTileOps ? this._body.current.getEventTileOps() : null;
|
||||
};
|
||||
public componentWillUnmount() {
|
||||
this.mediaHelper?.destroy();
|
||||
}
|
||||
|
||||
onTileUpdate = () => {
|
||||
this.forceUpdate();
|
||||
};
|
||||
public componentDidUpdate(prevProps: Readonly<IProps>) {
|
||||
if (this.props.mxEvent !== prevProps.mxEvent && MediaEventHelper.isEligible(this.props.mxEvent)) {
|
||||
this.mediaHelper?.destroy();
|
||||
this.mediaHelper = new MediaEventHelper(this.props.mxEvent);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const bodyTypes = {
|
||||
'm.text': sdk.getComponent('messages.TextualBody'),
|
||||
'm.notice': sdk.getComponent('messages.TextualBody'),
|
||||
'm.emote': sdk.getComponent('messages.TextualBody'),
|
||||
'm.image': sdk.getComponent('messages.MImageBody'),
|
||||
'm.file': sdk.getComponent('messages.MFileBody'),
|
||||
'm.audio': sdk.getComponent('messages.MVoiceOrAudioBody'),
|
||||
'm.video': sdk.getComponent('messages.MVideoBody'),
|
||||
private get bodyTypes(): Record<string, React.Component> {
|
||||
return {
|
||||
[MsgType.Text]: sdk.getComponent('messages.TextualBody'),
|
||||
[MsgType.Notice]: sdk.getComponent('messages.TextualBody'),
|
||||
[MsgType.Emote]: sdk.getComponent('messages.TextualBody'),
|
||||
[MsgType.Image]: sdk.getComponent('messages.MImageBody'),
|
||||
[MsgType.File]: sdk.getComponent('messages.MFileBody'),
|
||||
[MsgType.Audio]: sdk.getComponent('messages.MVoiceOrAudioBody'),
|
||||
[MsgType.Video]: sdk.getComponent('messages.MVideoBody'),
|
||||
|
||||
...(this.props.overrideBodyTypes || {}),
|
||||
};
|
||||
const evTypes = {
|
||||
'm.sticker': sdk.getComponent('messages.MStickerBody'),
|
||||
}
|
||||
|
||||
private get evTypes(): Record<string, React.Component> {
|
||||
return {
|
||||
[EventType.Sticker]: sdk.getComponent('messages.MStickerBody'),
|
||||
|
||||
...(this.props.overrideEventTypes || {}),
|
||||
};
|
||||
}
|
||||
|
||||
public getEventTileOps = () => {
|
||||
return (this.body.current as IOperableEventTile)?.getEventTileOps?.() || null;
|
||||
};
|
||||
|
||||
public getMediaHelper() {
|
||||
return this.mediaHelper;
|
||||
}
|
||||
|
||||
private onTileUpdate = () => {
|
||||
this.forceUpdate();
|
||||
};
|
||||
|
||||
public render() {
|
||||
const content = this.props.mxEvent.getContent();
|
||||
const type = this.props.mxEvent.getType();
|
||||
const msgtype = content.msgtype;
|
||||
let BodyType = RedactedBody;
|
||||
let BodyType: ReactAnyComponent = RedactedBody;
|
||||
if (!this.props.mxEvent.isRedacted()) {
|
||||
// only resolve BodyType if event is not redacted
|
||||
if (type && evTypes[type]) {
|
||||
BodyType = evTypes[type];
|
||||
} else if (msgtype && bodyTypes[msgtype]) {
|
||||
BodyType = bodyTypes[msgtype];
|
||||
if (type && this.evTypes[type]) {
|
||||
BodyType = this.evTypes[type];
|
||||
} else if (msgtype && this.bodyTypes[msgtype]) {
|
||||
BodyType = this.bodyTypes[msgtype];
|
||||
} else if (content.url) {
|
||||
// Fallback to MFileBody if there's a content URL
|
||||
BodyType = bodyTypes['m.file'];
|
||||
BodyType = this.bodyTypes[MsgType.File];
|
||||
} else {
|
||||
// Fallback to UnknownBody otherwise if not redacted
|
||||
BodyType = UnknownBody;
|
||||
|
@ -120,8 +128,9 @@ export default class MessageEvent extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
// @ts-ignore - this is a dynamic react component
|
||||
return BodyType ? <BodyType
|
||||
ref={this._body}
|
||||
ref={this.body}
|
||||
mxEvent={this.props.mxEvent}
|
||||
highlights={this.props.highlights}
|
||||
highlightLink={this.props.highlightLink}
|
||||
|
@ -133,6 +142,7 @@ export default class MessageEvent extends React.Component {
|
|||
onHeightChanged={this.props.onHeightChanged}
|
||||
onMessageAllowed={this.onTileUpdate}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
mediaEventHelper={this.mediaHelper}
|
||||
/> : null;
|
||||
}
|
||||
}
|
|
@ -45,7 +45,7 @@ export default class MessageTimestamp extends React.Component<IProps> {
|
|||
title={formatFullDate(date, this.props.showTwelveHour)}
|
||||
aria-hidden={true}
|
||||
>
|
||||
{timestamp}
|
||||
{ timestamp }
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -41,10 +41,10 @@ export default class MjolnirBody extends React.Component {
|
|||
|
||||
render() {
|
||||
return (
|
||||
<div className='mx_MjolnirBody'><i>{_t(
|
||||
<div className='mx_MjolnirBody'><i>{ _t(
|
||||
"You have ignored this user, so their message is hidden. <a>Show anyways.</a>",
|
||||
{}, { a: (sub) => <a href="#" onClick={this._onAllowClick}>{sub}</a> },
|
||||
)}</i></div>
|
||||
{}, { a: (sub) => <a href="#" onClick={this._onAllowClick}>{ sub }</a> },
|
||||
) }</i></div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -199,7 +199,7 @@ export default class ReactionsRow extends React.PureComponent<IProps, IState> {
|
|||
href="#"
|
||||
onClick={this.onShowAllClick}
|
||||
>
|
||||
{_t("Show all")}
|
||||
{ _t("Show all") }
|
||||
</a>;
|
||||
}
|
||||
|
||||
|
|
|
@ -142,12 +142,12 @@ export default class ReactionsRowButton extends React.PureComponent<IProps, ISta
|
|||
onMouseLeave={this.onMouseLeave}
|
||||
>
|
||||
<span className="mx_ReactionsRowButton_content" aria-hidden="true">
|
||||
{content}
|
||||
{ content }
|
||||
</span>
|
||||
<span className="mx_ReactionsRowButton_count" aria-hidden="true">
|
||||
{count}
|
||||
{ count }
|
||||
</span>
|
||||
{tooltip}
|
||||
{ tooltip }
|
||||
</AccessibleButton>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -51,7 +51,7 @@ export default class ReactionsRowButtonTooltip extends React.PureComponent<IProp
|
|||
senders.push(name);
|
||||
}
|
||||
const shortName = unicodeToShortcode(content);
|
||||
tooltipLabel = <div>{_t(
|
||||
tooltipLabel = <div>{ _t(
|
||||
"<reactors/><reactedWith>reacted with %(shortName)s</reactedWith>",
|
||||
{
|
||||
shortName,
|
||||
|
@ -59,7 +59,7 @@ export default class ReactionsRowButtonTooltip extends React.PureComponent<IProp
|
|||
{
|
||||
reactors: () => {
|
||||
return <div className="mx_Tooltip_title">
|
||||
{formatCommaSeparatedList(senders, 6)}
|
||||
{ formatCommaSeparatedList(senders, 6) }
|
||||
</div>;
|
||||
},
|
||||
reactedWith: (sub) => {
|
||||
|
@ -67,11 +67,11 @@ export default class ReactionsRowButtonTooltip extends React.PureComponent<IProp
|
|||
return null;
|
||||
}
|
||||
return <div className="mx_Tooltip_sub">
|
||||
{sub}
|
||||
{ sub }
|
||||
</div>;
|
||||
},
|
||||
},
|
||||
)}</div>;
|
||||
) }</div>;
|
||||
}
|
||||
|
||||
let tooltip;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2020 - 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.
|
||||
|
@ -16,17 +16,13 @@ limitations under the License.
|
|||
|
||||
import React, { useContext } from "react";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { formatFullDate } from "../../../DateUtils";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { IBodyProps } from "./IBodyProps";
|
||||
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
}
|
||||
|
||||
const RedactedBody = React.forwardRef<any, IProps>(({ mxEvent }, ref) => {
|
||||
const RedactedBody = React.forwardRef<any, IBodyProps>(({ mxEvent }, ref) => {
|
||||
const cli: MatrixClient = useContext(MatrixClientContext);
|
||||
|
||||
let text = _t("Message deleted");
|
||||
|
|
|
@ -56,7 +56,7 @@ export default class RoomCreate extends React.Component {
|
|||
const predecessorPermalink = permalinkCreator.forEvent(predecessor['event_id']);
|
||||
const link = (
|
||||
<a href={predecessorPermalink} onClick={this._onLinkClicked}>
|
||||
{_t("Click here to see older messages.")}
|
||||
{ _t("Click here to see older messages.") }
|
||||
</a>
|
||||
);
|
||||
|
||||
|
|
|
@ -17,7 +17,6 @@ limitations under the License.
|
|||
import React, { createRef, SyntheticEvent } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import highlight from 'highlight.js';
|
||||
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
|
||||
import { MsgType } from "matrix-js-sdk/src/@types/event";
|
||||
|
||||
import * as HtmlUtils from '../../../HtmlUtils';
|
||||
|
@ -38,37 +37,13 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
|
|||
import UIStore from "../../../stores/UIStore";
|
||||
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { TileShape } from '../rooms/EventTile';
|
||||
import EditorStateTransfer from "../../../utils/EditorStateTransfer";
|
||||
import GenericTextContextMenu from "../context_menus/GenericTextContextMenu";
|
||||
import Spoiler from "../elements/Spoiler";
|
||||
import QuestionDialog from "../dialogs/QuestionDialog";
|
||||
import MessageEditHistoryDialog from "../dialogs/MessageEditHistoryDialog";
|
||||
import EditMessageComposer from '../rooms/EditMessageComposer';
|
||||
import LinkPreviewGroup from '../rooms/LinkPreviewGroup';
|
||||
|
||||
interface IProps {
|
||||
/* the MatrixEvent to show */
|
||||
mxEvent: MatrixEvent;
|
||||
|
||||
/* a list of words to highlight */
|
||||
highlights?: string[];
|
||||
|
||||
/* link URL for the highlights */
|
||||
highlightLink?: string;
|
||||
|
||||
/* should show URL previews for this event */
|
||||
showUrlPreview?: boolean;
|
||||
|
||||
/* the shape of the tile, used */
|
||||
tileShape?: TileShape;
|
||||
|
||||
editState?: EditorStateTransfer;
|
||||
replacingEventId?: string;
|
||||
|
||||
/* callback for when our widget has loaded */
|
||||
onHeightChanged(): void;
|
||||
}
|
||||
import { IBodyProps } from "./IBodyProps";
|
||||
|
||||
interface IState {
|
||||
// the URLs (if any) to be previewed with a LinkPreviewWidget inside this TextualBody.
|
||||
|
@ -79,7 +54,7 @@ interface IState {
|
|||
}
|
||||
|
||||
@replaceableComponent("views.messages.TextualBody")
|
||||
export default class TextualBody extends React.Component<IProps, IState> {
|
||||
export default class TextualBody extends React.Component<IBodyProps, IState> {
|
||||
private readonly contentRef = createRef<HTMLSpanElement>();
|
||||
|
||||
private unmounted = false;
|
||||
|
@ -475,10 +450,10 @@ export default class TextualBody extends React.Component<IProps, IState> {
|
|||
|
||||
const tooltip = <div>
|
||||
<div className="mx_Tooltip_title">
|
||||
{_t("Edited at %(date)s", { date: dateString })}
|
||||
{ _t("Edited at %(date)s", { date: dateString }) }
|
||||
</div>
|
||||
<div className="mx_Tooltip_sub">
|
||||
{_t("Click to view edits")}
|
||||
{ _t("Click to view edits") }
|
||||
</div>
|
||||
</div>;
|
||||
|
||||
|
@ -489,7 +464,7 @@ export default class TextualBody extends React.Component<IProps, IState> {
|
|||
title={_t("Edited at %(date)s. Click to view edits.", { date: dateString })}
|
||||
tooltip={tooltip}
|
||||
>
|
||||
<span>{`(${_t("edited")})`}</span>
|
||||
<span>{ `(${_t("edited")})` }</span>
|
||||
</AccessibleTooltipButton>
|
||||
);
|
||||
}
|
||||
|
@ -513,8 +488,8 @@ export default class TextualBody extends React.Component<IProps, IState> {
|
|||
});
|
||||
if (this.props.replacingEventId) {
|
||||
body = <>
|
||||
{body}
|
||||
{this.renderEditedMarker()}
|
||||
{ body }
|
||||
{ this.renderEditedMarker() }
|
||||
</>;
|
||||
}
|
||||
|
||||
|
|
|
@ -67,14 +67,14 @@ export default class TileErrorBoundary extends React.Component<IProps, IState> {
|
|||
let submitLogsButton;
|
||||
if (SdkConfig.get().bug_report_endpoint_url) {
|
||||
submitLogsButton = <a onClick={this.onBugReport} href="#">
|
||||
{_t("Submit logs")}
|
||||
{ _t("Submit logs") }
|
||||
</a>;
|
||||
}
|
||||
|
||||
return (<div className={classNames(classes)}>
|
||||
<div className="mx_EventTile_line">
|
||||
<span>
|
||||
{_t("Can't load this message")}
|
||||
{ _t("Can't load this message") }
|
||||
{ mxEvent && ` (${mxEvent.getType()})` }
|
||||
{ submitLogsButton }
|
||||
</span>
|
||||
|
|
|
@ -16,12 +16,19 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React, { forwardRef } from "react";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src";
|
||||
|
||||
export default forwardRef(({ mxEvent }, ref) => {
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default forwardRef(({ mxEvent, children }: IProps, ref: React.RefObject<HTMLSpanElement>) => {
|
||||
const text = mxEvent.getContent().body;
|
||||
return (
|
||||
<span className="mx_UnknownBody" ref={ref}>
|
||||
{ text }
|
||||
{ children }
|
||||
</span>
|
||||
);
|
||||
});
|
|
@ -60,9 +60,9 @@ export default class ViewSourceEvent extends React.PureComponent {
|
|||
|
||||
let content;
|
||||
if (expanded) {
|
||||
content = <pre>{JSON.stringify(mxEvent, null, 4)}</pre>;
|
||||
content = <pre>{ JSON.stringify(mxEvent, null, 4) }</pre>;
|
||||
} else {
|
||||
content = <code>{`{ "type": ${mxEvent.getType()} }`}</code>;
|
||||
content = <code>{ `{ "type": ${mxEvent.getType()} }` }</code>;
|
||||
}
|
||||
|
||||
const classes = classNames("mx_ViewSourceEvent mx_EventTile_content", {
|
||||
|
@ -70,7 +70,7 @@ export default class ViewSourceEvent extends React.PureComponent {
|
|||
});
|
||||
|
||||
return <span className={classes}>
|
||||
{content}
|
||||
{ content }
|
||||
<a
|
||||
className="mx_ViewSourceEvent_toggle"
|
||||
href="#"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue