Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into travis/blurhash

 Conflicts:
	package.json
	src/components/views/messages/MImageBody.js
	yarn.lock
This commit is contained in:
Michael Telatynski 2021-05-18 13:48:40 +01:00
commit bf01ebae6d
1121 changed files with 97896 additions and 34373 deletions

View file

@ -19,6 +19,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import {formatFullDateNoTime} from '../../../DateUtils';
import {replaceableComponent} from "../../../utils/replaceableComponent";
function getdaysArray() {
return [
@ -32,6 +33,7 @@ function getdaysArray() {
];
}
@replaceableComponent("views.messages.DateSeparator")
export default class DateSeparator extends React.Component {
static propTypes = {
ts: PropTypes.number.isRequired,

View file

@ -19,7 +19,7 @@ import PropTypes from 'prop-types';
import * as HtmlUtils from '../../../HtmlUtils';
import { editBodyDiffToHtml } from '../../../utils/MessageDiffUtils';
import {formatTime} from '../../../DateUtils';
import {MatrixEvent} from 'matrix-js-sdk';
import {MatrixEvent} from 'matrix-js-sdk/src/models/event';
import {pillifyLinks, unmountPills} from '../../../utils/pillify';
import { _t } from '../../../languageHandler';
import * as sdk from '../../../index';
@ -27,12 +27,14 @@ import {MatrixClientPeg} from '../../../MatrixClientPeg';
import Modal from '../../../Modal';
import classNames from 'classnames';
import RedactedBody from "./RedactedBody";
import {replaceableComponent} from "../../../utils/replaceableComponent";
function getReplacedContent(event) {
const originalContent = event.getOriginalContent();
return originalContent["m.new_content"] || originalContent;
}
@replaceableComponent("views.messages.EditHistoryMessage")
export default class EditHistoryMessage extends React.PureComponent {
static propTypes = {
// the message event being edited
@ -74,9 +76,7 @@ export default class EditHistoryMessage extends React.PureComponent {
_onViewSourceClick = () => {
const ViewSource = sdk.getComponent('structures.ViewSource');
Modal.createTrackedDialog('View Event Source', 'Edit history', ViewSource, {
roomId: this.props.mxEvent.getRoomId(),
eventId: this.props.mxEvent.getId(),
content: this.props.mxEvent.event,
mxEvent: this.props.mxEvent,
}, 'mx_Dialog_viewsource');
};
@ -158,8 +158,8 @@ export default class EditHistoryMessage extends React.PureComponent {
const isSending = (['sending', 'queued', 'encrypting'].indexOf(this.state.sendStatus) !== -1);
const classes = classNames({
"mx_EventTile": true,
// Note: we keep the `sending` state class for tests, not for our styles
"mx_EventTile_sending": isSending,
"mx_EventTile_notSent": this.state.sendStatus === 'not_sent',
});
return (
<li>

View file

@ -1,58 +0,0 @@
/*
Copyright 2020 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 React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
export default class EncryptionEvent extends React.Component {
render() {
const {mxEvent} = this.props;
let body;
let classes = "mx_EventTile_bubble mx_cryptoEvent mx_cryptoEvent_icon";
if (
mxEvent.getContent().algorithm === 'm.megolm.v1.aes-sha2' &&
MatrixClientPeg.get().isRoomEncrypted(mxEvent.getRoomId())
) {
body = <div>
<div className="mx_cryptoEvent_title">{_t("Encryption enabled")}</div>
<div className="mx_cryptoEvent_subtitle">
{_t(
"Messages in this room are end-to-end encrypted. " +
"Learn more & verify this user in their user profile.",
)}
</div>
</div>;
} else {
body = <div>
<div className="mx_cryptoEvent_title">{_t("Encryption not enabled")}</div>
<div className="mx_cryptoEvent_subtitle">{_t("The encryption used by this room isn't supported.")}</div>
</div>;
classes += " mx_cryptoEvent_icon_warning";
}
return (<div className={classes}>
{body}
</div>);
}
}
EncryptionEvent.propTypes = {
/* the MatrixEvent to show */
mxEvent: PropTypes.object.isRequired,
};

View file

@ -0,0 +1,68 @@
/*
Copyright 2020 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 React, {forwardRef, useContext} from 'react';
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import EventTileBubble from "./EventTileBubble";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import DMRoomMap from "../../../utils/DMRoomMap";
interface IProps {
mxEvent: MatrixEvent;
}
const EncryptionEvent = forwardRef<HTMLDivElement, IProps>(({mxEvent}, ref) => {
const cli = useContext(MatrixClientContext);
const roomId = mxEvent.getRoomId();
const isRoomEncrypted = MatrixClientPeg.get().isRoomEncrypted(roomId);
if (mxEvent.getContent().algorithm === 'm.megolm.v1.aes-sha2' && isRoomEncrypted) {
let subtitle: string;
const dmPartner = DMRoomMap.shared().getUserIdForRoomId(roomId);
if (dmPartner) {
const displayName = cli?.getRoom(roomId)?.getMember(dmPartner)?.rawDisplayName || dmPartner;
subtitle = _t("Messages here are end-to-end encrypted. " +
"Verify %(displayName)s in their profile - tap on their avatar.", { displayName });
} else {
subtitle = _t("Messages in this room are end-to-end encrypted. " +
"When people join, you can verify them in their profile, just tap on their avatar.");
}
return <EventTileBubble
className="mx_cryptoEvent mx_cryptoEvent_icon"
title={_t("Encryption enabled")}
subtitle={subtitle}
/>;
} else if (isRoomEncrypted) {
return <EventTileBubble
className="mx_cryptoEvent mx_cryptoEvent_icon"
title={_t("Encryption enabled")}
subtitle={_t("Ignored attempt to disable encryption")}
/>;
}
return <EventTileBubble
className="mx_cryptoEvent mx_cryptoEvent_icon mx_cryptoEvent_icon_warning"
title={_t("Encryption not enabled")}
subtitle={_t("The encryption used by this room isn't supported.")}
ref={ref}
/>;
});
export default EncryptionEvent;

View file

@ -0,0 +1,34 @@
/*
Copyright 2020 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 React, {forwardRef, ReactNode} from "react";
import classNames from "classnames";
interface IProps {
className: string;
title: string;
subtitle?: ReactNode;
}
const EventTileBubble = forwardRef<HTMLDivElement, IProps>(({ className, title, subtitle, children }, ref) => {
return <div className={classNames("mx_EventTileBubble", className)} ref={ref}>
<div className="mx_EventTileBubble_title">{ title }</div>
{ subtitle && <div className="mx_EventTileBubble_subtitle">{ subtitle }</div> }
{ children }
</div>;
});
export default EventTileBubble;

View file

@ -14,16 +14,16 @@
limitations under the License.
*/
'use strict';
import React from 'react';
import MFileBody from './MFileBody';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { decryptFile } from '../../../utils/DecryptFile';
import { _t } from '../../../languageHandler';
import InlineSpinner from '../elements/InlineSpinner';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromContent} from "../../../customisations/Media";
@replaceableComponent("views.messages.MAudioBody")
export default class MAudioBody extends React.Component {
constructor(props) {
super(props);
@ -41,11 +41,11 @@ export default class MAudioBody extends React.Component {
}
_getContentUrl() {
const content = this.props.mxEvent.getContent();
if (content.file !== undefined) {
const media = mediaFromContent(this.props.mxEvent.getContent());
if (media.isEncrypted) {
return this.state.decryptedUrl;
} else {
return MatrixClientPeg.get().mxcUrlToHttp(content.url);
return media.srcHttp;
}
}
@ -105,7 +105,7 @@ export default class MAudioBody extends React.Component {
return (
<span className="mx_MAudioBody">
<audio src={contentUrl} controls />
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} />
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />
</span>
);
}

View file

@ -1,6 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2015, 2016, 2018, 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.
@ -17,53 +16,25 @@ limitations under the License.
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import filesize from 'filesize';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import {decryptFile} from '../../../utils/DecryptFile';
import Tinter from '../../../Tinter';
import request from 'browser-request';
import Modal from '../../../Modal';
import AccessibleButton from "../elements/AccessibleButton";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromContent} from "../../../customisations/Media";
import ErrorDialog from "../dialogs/ErrorDialog";
let downloadIconUrl; // cached copy of the download.svg asset for the sandboxed iframe later on
// A cached tinted copy of require("../../../../res/img/download.svg")
let tintedDownloadImageURL;
// Track a list of mounted MFileBody instances so that we can update
// the require("../../../../res/img/download.svg") when the tint changes.
let nextMountId = 0;
const mounts = {};
/**
* Updates the tinted copy of require("../../../../res/img/download.svg") when the tint changes.
*/
function updateTintedDownloadImage() {
// Download the svg as an XML document.
// We could cache the XML response here, but since the tint rarely changes
// it's probably not worth it.
// Also note that we can't use fetch here because fetch doesn't support
// file URLs, which the download image will be if we're running from
// the filesystem (like in an Electron wrapper).
request({uri: require("../../../../res/img/download.svg")}, (err, response, body) => {
if (err) return;
const svg = new DOMParser().parseFromString(body, "image/svg+xml");
// Apply the fixups to the XML.
const fixups = Tinter.calcSvgFixups([{contentDocument: svg}]);
Tinter.applySvgFixups(fixups);
// Encoded the fixed up SVG as a data URL.
const svgString = new XMLSerializer().serializeToString(svg);
tintedDownloadImageURL = "data:image/svg+xml;base64," + window.btoa(svgString);
// Notify each mounted MFileBody that the URL has changed.
Object.keys(mounts).forEach(function(id) {
mounts[id].tint();
});
});
async function cacheDownloadIcon() {
if (downloadIconUrl) return; // cached already
const svg = await fetch(require("../../../../res/img/download.svg")).then(r => r.text());
downloadIconUrl = "data:image/svg+xml;base64," + window.btoa(svg);
}
Tinter.registerTintable(updateTintedDownloadImage);
// Cache the asset immediately
cacheDownloadIcon();
// User supplied content can contain scripts, we have to be careful that
// we don't accidentally run those script within the same origin as the
@ -106,6 +77,7 @@ function computedStyle(element) {
}
const style = window.getComputedStyle(element, null);
let cssText = style.cssText;
// noinspection EqualityComparisonWithCoercionJS
if (cssText == "") {
// Firefox doesn't implement ".cssText" for computed styles.
// https://bugzilla.mozilla.org/show_bug.cgi?id=137687
@ -117,16 +89,9 @@ function computedStyle(element) {
return cssText;
}
export default createReactClass({
displayName: 'MFileBody',
getInitialState: function() {
return {
decryptedBlob: (this.props.decryptedBlob ? this.props.decryptedBlob : null),
};
},
propTypes: {
@replaceableComponent("views.messages.MFileBody")
export default class MFileBody extends React.Component {
static propTypes = {
/* the MatrixEvent to show */
mxEvent: PropTypes.object.isRequired,
/* already decrypted blob */
@ -135,16 +100,34 @@ export default createReactClass({
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,
};
static defaultProps = {
showGenericPlaceholder: true,
};
constructor(props) {
super(props);
this.state = {
decryptedBlob: (this.props.decryptedBlob ? this.props.decryptedBlob : null),
};
this._iframe = createRef();
this._dummyLink = createRef();
}
/**
* Extracts a human readable label for the file attachment to use as
* link text.
*
* @params {Object} content The "content" key of the matrix event.
* @param {Object} content The "content" key of the matrix event.
* @param {boolean} withSize Whether to include size information. Default true.
* @return {string} the human readable link text for the attachment.
*/
presentableTextForFile: function(content) {
presentableTextForFile(content, withSize = true) {
let linkText = _t("Attachment");
if (content.body && content.body.length > 0) {
// The content body should be the name of the file including a
@ -152,7 +135,7 @@ export default createReactClass({
linkText = content.body;
}
if (content.info && content.info.size) {
if (content.info && content.info.size && withSize) {
// If we know the size of the file then add it as human readable
// string to the end of the link text so that the user knows how
// big a file they are downloading.
@ -163,65 +146,38 @@ export default createReactClass({
linkText += ' (' + filesize(content.info.size) + ')';
}
return linkText;
},
}
_getContentUrl: function() {
const content = this.props.mxEvent.getContent();
return MatrixClientPeg.get().mxcUrlToHttp(content.url);
},
_getContentUrl() {
const media = mediaFromContent(this.props.mxEvent.getContent());
return media.srcHttp;
}
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
UNSAFE_componentWillMount: function() {
this._iframe = createRef();
this._dummyLink = createRef();
this._downloadImage = createRef();
},
componentDidMount: function() {
// Add this to the list of mounted components to receive notifications
// when the tint changes.
this.id = nextMountId++;
mounts[this.id] = this;
this.tint();
},
componentDidUpdate: function(prevProps, prevState) {
componentDidUpdate(prevProps, prevState) {
if (this.props.onHeightChanged && !prevState.decryptedBlob && this.state.decryptedBlob) {
this.props.onHeightChanged();
}
},
}
componentWillUnmount: function() {
// Remove this from the list of mounted components
delete mounts[this.id];
},
tint: function() {
// Update our tinted copy of require("../../../../res/img/download.svg")
if (this._downloadImage.current) {
this._downloadImage.current.src = tintedDownloadImageURL;
}
if (this._iframe.current) {
// If the attachment is encrypted then the download image
// will be inside the iframe so we wont be able to update
// it directly.
this._iframe.current.contentWindow.postMessage({
imgSrc: tintedDownloadImageURL,
style: computedStyle(this._dummyLink.current),
}, "*");
}
},
render: function() {
render() {
const content = this.props.mxEvent.getContent();
const text = this.presentableTextForFile(content);
const isEncrypted = content.file !== undefined;
const fileName = content.body && content.body.length > 0 ? content.body : _t("Attachment");
const contentUrl = this._getContentUrl();
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
const fileSize = content.info ? content.info.size : null;
const fileType = content.info ? content.info.mimetype : "application/octet-stream";
let placeholder = null;
if (this.props.showGenericPlaceholder) {
placeholder = (
<div className="mx_MFileBody_info">
<span className="mx_MFileBody_info_icon" />
<span className="mx_MFileBody_info_filename">{this.presentableTextForFile(content, false)}</span>
</div>
);
}
if (isEncrypted) {
if (this.state.decryptedBlob === null) {
// Need to decrypt the attachment
@ -252,6 +208,7 @@ export default createReactClass({
// but it is not guaranteed between various browsers' settings.
return (
<span className="mx_MFileBody">
{placeholder}
<div className="mx_MFileBody_download">
<AccessibleButton onClick={decrypt}>
{ _t("Decrypt %(text)s", { text: text }) }
@ -264,7 +221,8 @@ export default createReactClass({
// When the iframe loads we tell it to render a download link
const onIframeLoad = (ev) => {
ev.target.contentWindow.postMessage({
imgSrc: tintedDownloadImageURL,
imgSrc: downloadIconUrl,
imgStyle: null, // it handles this internally for us. Useful if a downstream changes the icon.
style: computedStyle(this._dummyLink.current),
blob: this.state.decryptedBlob,
// Set a download attribute for encrypted files so that the file
@ -282,6 +240,7 @@ export default createReactClass({
// If the attachment is encrypted then put the link inside an iframe.
return (
<span className="mx_MFileBody">
{placeholder}
<div className="mx_MFileBody_download">
<div style={{display: "none"}}>
{ /*
@ -292,7 +251,7 @@ export default createReactClass({
<a ref={this._dummyLink} />
</div>
<iframe
src={`${url}?origin=${encodeURIComponent(window.location.origin)}`}
src={url}
onLoad={onIframeLoad}
ref={this._iframe}
sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation" />
@ -350,6 +309,7 @@ export default createReactClass({
if (this.props.tileShape === "file_grid") {
return (
<span className="mx_MFileBody">
{placeholder}
<div className="mx_MFileBody_download">
<a className="mx_MFileBody_downloadLink" {...downloadProps}>
{ fileName }
@ -363,9 +323,10 @@ export default createReactClass({
} else {
return (
<span className="mx_MFileBody">
{placeholder}
<div className="mx_MFileBody_download">
<a {...downloadProps}>
<img src={tintedDownloadImageURL} width="12" height="14" ref={this._downloadImage} />
<span className="mx_MFileBody_download_icon" />
{ _t("Download %(text)s", { text: text }) }
</a>
</div>
@ -375,8 +336,9 @@ export default createReactClass({
} else {
const extra = text ? (': ' + text) : '';
return <span className="mx_MFileBody">
{placeholder}
{ _t("Invalid file%(extra)s", { extra: extra }) }
</span>;
}
},
});
}
}

View file

@ -27,8 +27,11 @@ import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import InlineSpinner from '../elements/InlineSpinner';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromContent} from "../../../customisations/Media";
import BlurhashPlaceholder from "../elements/BlurhashPlaceholder";
@replaceableComponent("views.messages.MImageBody")
export default class MImageBody extends React.Component {
static propTypes = {
/* the MatrixEvent to show */
@ -39,6 +42,9 @@ export default class MImageBody extends React.Component {
/* the maximum image height to use */
maxImageHeight: PropTypes.number,
/* the permalinkCreator */
permalinkCreator: PropTypes.object,
};
static contextType = MatrixClientContext;
@ -72,7 +78,7 @@ export default class MImageBody extends React.Component {
this._image = createRef();
}
// FIXME: factor this out and aplpy it to MVideoBody and MAudioBody too!
// FIXME: factor this out and apply it to MVideoBody and MAudioBody too!
onClientSync(syncState, prevState) {
if (this.unmounted) return;
// Consider the client reconnected if there is no error with syncing.
@ -89,6 +95,7 @@ export default class MImageBody extends React.Component {
showImage() {
localStorage.setItem("mx_ShowImage_" + this.props.mxEvent.getId(), "true");
this.setState({showImage: true});
this._downloadImage();
}
onClick(ev) {
@ -106,6 +113,7 @@ export default class MImageBody extends React.Component {
src: httpUrl,
name: content.body && content.body.length > 0 ? content.body : _t('Attachment'),
mxEvent: this.props.mxEvent,
permalinkCreator: this.props.permalinkCreator,
};
if (content.info) {
@ -114,16 +122,16 @@ export default class MImageBody extends React.Component {
params.fileSize = content.info.size;
}
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox");
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", null, true);
}
}
_isGif() {
const content = this.props.mxEvent.getContent();
return (
content &&
content.info &&
content.info.mimetype === "image/gif"
content &&
content.info &&
content.info.mimetype === "image/gif"
);
}
@ -168,39 +176,36 @@ export default class MImageBody extends React.Component {
}
_getContentUrl() {
const content = this.props.mxEvent.getContent();
if (content.file !== undefined) {
const media = mediaFromContent(this.props.mxEvent.getContent());
if (media.isEncrypted) {
return this.state.decryptedUrl;
} else {
return this.context.mxcUrlToHttp(content.url);
return media.srcHttp;
}
}
_getThumbUrl() {
// FIXME: the dharma skin lets images grow as wide as you like, rather than capped to 800x600.
// FIXME: we let images grow as wide as you like, rather than capped to 800x600.
// So either we need to support custom timeline widths here, or reimpose the cap, otherwise the
// thumbnail resolution will be unnecessarily reduced.
// custom timeline widths seems preferable.
const pixelRatio = window.devicePixelRatio;
const thumbWidth = Math.round(800 * pixelRatio);
const thumbHeight = Math.round(600 * pixelRatio);
const thumbWidth = 800;
const thumbHeight = 600;
const content = this.props.mxEvent.getContent();
if (content.file !== undefined) {
const media = mediaFromContent(content);
if (media.isEncrypted) {
// Don't use the thumbnail for clients wishing to autoplay gifs.
if (this.state.decryptedThumbnailUrl) {
return this.state.decryptedThumbnailUrl;
}
return this.state.decryptedUrl;
} else if (content.info && content.info.mimetype === "image/svg+xml" && content.info.thumbnail_url) {
} else if (content.info && content.info.mimetype === "image/svg+xml" && media.hasThumbnail) {
// special case to return clientside sender-generated thumbnails for SVGs, if any,
// given we deliberately don't thumbnail them serverside to prevent
// billion lol attacks and similar
return this.context.mxcUrlToHttp(
content.info.thumbnail_url,
thumbWidth,
thumbHeight,
);
return media.getThumbnailHttp(thumbWidth, thumbHeight, 'scale');
} else {
// we try to download the correct resolution
// for hi-res images (like retina screenshots).
@ -216,10 +221,10 @@ export default class MImageBody extends React.Component {
const info = content.info;
if (
this._isGif() ||
pixelRatio === 1.0 ||
window.devicePixelRatio === 1.0 ||
(!info || !info.w || !info.h || !info.size)
) {
return this.context.mxcUrlToHttp(content.url, thumbWidth, thumbHeight);
return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight);
} else {
// we should only request thumbnails if the image is bigger than 800x600
// (or 1600x1200 on retina) otherwise the image in the timeline will just
@ -234,33 +239,23 @@ export default class MImageBody extends React.Component {
info.w > thumbWidth ||
info.h > thumbHeight
);
const isLargeFileSize = info.size > 1*1024*1024;
const isLargeFileSize = info.size > 1*1024*1024; // 1mb
if (isLargeFileSize && isLargerThanThumbnail) {
// image is too large physically and bytewise to clutter our timeline so
// we ask for a thumbnail, despite knowing that it will be max 800x600
// despite us being retina (as synapse doesn't do 1600x1200 thumbs yet).
return this.context.mxcUrlToHttp(
content.url,
thumbWidth,
thumbHeight,
);
return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight);
} else {
// download the original image otherwise, so we can scale it client side
// to take pixelRatio into account.
// ( no width/height means we want the original image)
return this.context.mxcUrlToHttp(
content.url,
);
return media.srcHttp;
}
}
}
}
componentDidMount() {
this.unmounted = false;
this.context.on('sync', this.onClientSync);
_downloadImage() {
const content = this.props.mxEvent.getContent();
if (content.file !== undefined && this.state.decryptedUrl === null) {
let thumbnailPromise = Promise.resolve(null);
@ -293,9 +288,18 @@ export default class MImageBody extends React.Component {
});
});
}
}
// Remember that the user wanted to show this particular image
if (!this.state.showImage && localStorage.getItem("mx_ShowImage_" + this.props.mxEvent.getId()) === "true") {
componentDidMount() {
this.unmounted = false;
this.context.on('sync', this.onClientSync);
const showImage = this.state.showImage ||
localStorage.getItem("mx_ShowImage_" + this.props.mxEvent.getId()) === "true";
if (showImage) {
// Don't download anything becaue we don't want to display anything.
this._downloadImage();
this.setState({showImage: true});
}
@ -347,9 +351,9 @@ export default class MImageBody extends React.Component {
} else {
imageElement = (
<img style={{display: 'none'}} src={thumbUrl} ref={this._image}
alt={content.body}
onError={this.onImageError}
onLoad={this.onImageLoad}
alt={content.body}
onError={this.onImageError}
onLoad={this.onImageLoad}
/>
);
}
@ -384,12 +388,12 @@ export default class MImageBody extends React.Component {
// mx_MImageBody_thumbnail resizes img to exactly container size
img = (
<img className="mx_MImageBody_thumbnail" src={thumbUrl} ref={this._image}
style={{ maxWidth: maxWidth + "px" }}
alt={content.body}
onError={this.onImageError}
onLoad={this.onImageLoad}
onMouseEnter={this.onImageEnter}
onMouseLeave={this.onImageLeave} />
style={{ maxWidth: maxWidth + "px" }}
alt={content.body}
onError={this.onImageError}
onLoad={this.onImageLoad}
onMouseEnter={this.onImageEnter}
onMouseLeave={this.onImageLeave} />
);
}
@ -449,7 +453,7 @@ export default class MImageBody extends React.Component {
// Overidden by MStickerBody
getFileBody() {
return <MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} />;
return <MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />;
}
render() {
@ -467,9 +471,9 @@ export default class MImageBody extends React.Component {
const contentUrl = this._getContentUrl();
let thumbUrl;
if (this._isGif() && SettingsStore.getValue("autoplayGifsAndVideos")) {
thumbUrl = contentUrl;
thumbUrl = contentUrl;
} else {
thumbUrl = this._getThumbUrl();
thumbUrl = this._getThumbUrl();
}
const thumbnail = this._messageContent(contentUrl, thumbUrl, content);

View file

@ -0,0 +1,73 @@
/*
Copyright 2020 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 React from 'react';
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { _t } from "../../../languageHandler";
import WidgetStore from "../../../stores/WidgetStore";
import EventTileBubble from "./EventTileBubble";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import {replaceableComponent} from "../../../utils/replaceableComponent";
interface IProps {
mxEvent: MatrixEvent;
}
@replaceableComponent("views.messages.MJitsiWidgetEvent")
export default class MJitsiWidgetEvent extends React.PureComponent<IProps> {
constructor(props) {
super(props);
}
render() {
const url = this.props.mxEvent.getContent()['url'];
const prevUrl = this.props.mxEvent.getPrevContent()['url'];
const senderName = this.props.mxEvent.sender?.name || this.props.mxEvent.getSender();
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
const widgetId = this.props.mxEvent.getStateKey();
const widget = WidgetStore.instance.getRoom(room.roomId, true).widgets.find(w => w.id === widgetId);
let joinCopy = _t('Join the conference at the top of this room');
if (widget && WidgetLayoutStore.instance.isInContainer(room, widget, Container.Right)) {
joinCopy = _t('Join the conference from the room information card on the right');
} else if (!widget) {
joinCopy = null;
}
if (!url) {
// removed
return <EventTileBubble
className="mx_MJitsiWidgetEvent"
title={_t('Video conference ended by %(senderName)s', {senderName})}
/>;
} else if (prevUrl) {
// modified
return <EventTileBubble
className="mx_MJitsiWidgetEvent"
title={_t('Video conference updated by %(senderName)s', {senderName})}
subtitle={joinCopy}
/>;
} else {
// assume added
return <EventTileBubble
className="mx_MJitsiWidgetEvent"
title={_t("Video conference started by %(senderName)s", {senderName})}
subtitle={joinCopy}
/>;
}
}
}

View file

@ -21,7 +21,10 @@ import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { _t } from '../../../languageHandler';
import {getNameForEventRoom, userLabelForEventRoom}
from '../../../utils/KeyVerificationStateObserver';
import EventTileBubble from "./EventTileBubble";
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.messages.MKeyVerificationConclusion")
export default class MKeyVerificationConclusion extends React.Component {
constructor(props) {
super(props);
@ -79,9 +82,7 @@ export default class MKeyVerificationConclusion extends React.Component {
}
// User isn't actually verified
if (!MatrixClientPeg.get()
.checkUserTrust(request.otherUserId)
.isCrossSigningVerified()) {
if (!MatrixClientPeg.get().checkUserTrust(request.otherUserId).isCrossSigningVerified()) {
return false;
}
@ -115,14 +116,14 @@ export default class MKeyVerificationConclusion extends React.Component {
}
if (title) {
const subtitle = userLabelForEventRoom(request.otherUserId, mxEvent.getRoomId());
const classes = classNames("mx_EventTile_bubble", "mx_cryptoEvent", "mx_cryptoEvent_icon", {
const classes = classNames("mx_cryptoEvent mx_cryptoEvent_icon", {
mx_cryptoEvent_icon_verified: request.done,
});
return (<div className={classes}>
<div className="mx_cryptoEvent_title">{title}</div>
<div className="mx_cryptoEvent_subtitle">{subtitle}</div>
</div>);
return <EventTileBubble
className={classes}
title={title}
subtitle={userLabelForEventRoom(request.otherUserId, mxEvent.getRoomId())}
/>;
}
return null;

View file

@ -24,7 +24,10 @@ import {getNameForEventRoom, userLabelForEventRoom}
import dis from "../../../dispatcher/dispatcher";
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
import {Action} from "../../../dispatcher/actions";
import EventTileBubble from "./EventTileBubble";
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.messages.MKeyVerificationRequest")
export default class MKeyVerificationRequest extends React.Component {
constructor(props) {
super(props);
@ -146,10 +149,8 @@ export default class MKeyVerificationRequest extends React.Component {
if (!request.initiatedByMe) {
const name = getNameForEventRoom(request.requestingUserId, mxEvent.getRoomId());
title = (<div className="mx_cryptoEvent_title">{
_t("%(name)s wants to verify", {name})}</div>);
subtitle = (<div className="mx_cryptoEvent_subtitle">{
userLabelForEventRoom(request.requestingUserId, mxEvent.getRoomId())}</div>);
title = _t("%(name)s wants to verify", {name});
subtitle = userLabelForEventRoom(request.requestingUserId, mxEvent.getRoomId());
if (request.canAccept) {
stateNode = (<div className="mx_cryptoEvent_buttons">
<FormButton kind="danger" onClick={this._onRejectClicked} label={_t("Decline")} />
@ -157,18 +158,18 @@ export default class MKeyVerificationRequest extends React.Component {
</div>);
}
} else { // request sent by us
title = (<div className="mx_cryptoEvent_title">{
_t("You sent a verification request")}</div>);
subtitle = (<div className="mx_cryptoEvent_subtitle">{
userLabelForEventRoom(request.receivingUserId, mxEvent.getRoomId())}</div>);
title = _t("You sent a verification request");
subtitle = userLabelForEventRoom(request.receivingUserId, mxEvent.getRoomId());
}
if (title) {
return (<div className="mx_EventTile_bubble mx_cryptoEvent mx_cryptoEvent_icon">
{title}
{subtitle}
{stateNode}
</div>);
return <EventTileBubble
className="mx_cryptoEvent mx_cryptoEvent_icon"
title={title}
subtitle={subtitle}
>
{ stateNode }
</EventTileBubble>;
}
return null;
}

View file

@ -17,7 +17,9 @@ limitations under the License.
import React from 'react';
import MImageBody from './MImageBody';
import * as sdk from '../../../index';
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.messages.MStickerBody")
export default class MStickerBody extends MImageBody {
// Mostly empty to prevent default behaviour of MImageBody
onClick(ev) {

View file

@ -16,36 +16,45 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import MFileBody from './MFileBody';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
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";
export default createReactClass({
displayName: 'MVideoBody',
interface IProps {
/* the MatrixEvent to show */
mxEvent: any;
/* called when the video has loaded */
onHeightChanged: () => void;
}
propTypes: {
/* the MatrixEvent to show */
mxEvent: PropTypes.object.isRequired,
interface IState {
decryptedUrl: string|null,
decryptedThumbnailUrl: string|null,
decryptedBlob: Blob|null,
error: any|null,
fetchingData: boolean,
}
/* called when the video has loaded */
onHeightChanged: PropTypes.func.isRequired,
},
@replaceableComponent("views.messages.MVideoBody")
export default class MVideoBody extends React.PureComponent<IProps, IState> {
private videoRef = React.createRef<HTMLVideoElement>();
getInitialState: function() {
return {
constructor(props) {
super(props);
this.state = {
fetchingData: false,
decryptedUrl: null,
decryptedThumbnailUrl: null,
decryptedBlob: null,
error: null,
};
},
}
}
thumbScale: function(fullWidth, fullHeight, thumbWidth, thumbHeight) {
thumbScale(fullWidth: number, fullHeight: number, thumbWidth: number, thumbHeight: number) {
if (!fullWidth || !fullHeight) {
// Cannot calculate thumbnail height for image: missing w/h in metadata. We can't even
// log this because it's spammy
@ -64,29 +73,36 @@ export default createReactClass({
// height is the dominant dimension so scaling will be fixed on that
return heightMulti;
}
},
}
_getContentUrl: function() {
const content = this.props.mxEvent.getContent();
if (content.file !== undefined) {
private getContentUrl(): string|null {
const media = mediaFromContent(this.props.mxEvent.getContent());
if (media.isEncrypted) {
return this.state.decryptedUrl;
} else {
return MatrixClientPeg.get().mxcUrlToHttp(content.url);
return media.srcHttp;
}
},
}
_getThumbUrl: function() {
private hasContentUrl(): boolean {
const url = this.getContentUrl();
return url && !url.startsWith("data:");
}
private getThumbUrl(): string|null {
const content = this.props.mxEvent.getContent();
if (content.file !== undefined) {
const media = mediaFromContent(content);
if (media.isEncrypted) {
return this.state.decryptedThumbnailUrl;
} else if (content.info && content.info.thumbnail_url) {
return MatrixClientPeg.get().mxcUrlToHttp(content.info.thumbnail_url);
} else if (media.hasThumbnail) {
return media.thumbnailHttp;
} else {
return null;
}
},
}
componentDidMount: function() {
async componentDidMount() {
const autoplay = SettingsStore.getValue("autoplayGifsAndVideos") as boolean;
const content = this.props.mxEvent.getContent();
if (content.file !== undefined && this.state.decryptedUrl === null) {
let thumbnailPromise = Promise.resolve(null);
@ -97,40 +113,80 @@ export default createReactClass({
return URL.createObjectURL(blob);
});
}
let decryptedBlob;
thumbnailPromise.then((thumbnailUrl) => {
return decryptFile(content.file).then(function(blob) {
decryptedBlob = blob;
return URL.createObjectURL(blob);
}).then((contentUrl) => {
try {
const thumbnailUrl = await thumbnailPromise;
if (autoplay) {
console.log("Preloading video");
const decryptedBlob = await decryptFile(content.file);
const contentUrl = URL.createObjectURL(decryptedBlob);
this.setState({
decryptedUrl: contentUrl,
decryptedThumbnailUrl: thumbnailUrl,
decryptedBlob: decryptedBlob,
});
this.props.onHeightChanged();
});
}).catch((err) => {
} else {
console.log("NOT preloading video");
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
// way, so it's fine to do for all browsers.
decryptedUrl: `data:${content?.info?.mimetype},`,
decryptedThumbnailUrl: thumbnailUrl || `data:${content?.info?.mimetype},`,
decryptedBlob: null,
});
}
} catch (err) {
console.warn("Unable to decrypt attachment: ", err);
// Set a placeholder image when we can't decrypt the image.
this.setState({
error: err,
});
});
}
}
},
}
componentWillUnmount: function() {
componentWillUnmount() {
if (this.state.decryptedUrl) {
URL.revokeObjectURL(this.state.decryptedUrl);
}
if (this.state.decryptedThumbnailUrl) {
URL.revokeObjectURL(this.state.decryptedThumbnailUrl);
}
},
}
render: function() {
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.
return;
}
this.setState({
// To stop subsequent download attempts
fetchingData: true,
});
const content = this.props.mxEvent.getContent();
if (!content.file) {
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,
fetchingData: false,
}, () => {
if (!this.videoRef.current) return;
this.videoRef.current.play();
});
this.props.onHeightChanged();
}
render() {
const content = this.props.mxEvent.getContent();
const autoplay = SettingsStore.getValue("autoplayGifsAndVideos");
if (this.state.error !== null) {
return (
@ -141,7 +197,8 @@ export default createReactClass({
);
}
if (content.file !== undefined && this.state.decryptedUrl === null) {
// Important: If we aren't autoplaying and we haven't decrypred it yet, show a video with a poster.
if (content.file !== undefined && this.state.decryptedUrl === null && autoplay) {
// Need to decrypt the attachment
// The attachment is decrypted in componentDidMount.
// For now add an img tag with a spinner.
@ -154,9 +211,8 @@ export default createReactClass({
);
}
const contentUrl = this._getContentUrl();
const thumbUrl = this._getThumbUrl();
const autoplay = SettingsStore.getValue("autoplayGifsAndVideos");
const contentUrl = this.getContentUrl();
const thumbUrl = this.getThumbUrl();
let height = null;
let width = null;
let poster = null;
@ -175,12 +231,23 @@ export default createReactClass({
}
return (
<span className="mx_MVideoBody">
<video className="mx_MVideoBody" src={contentUrl} alt={content.body}
controls preload={preload} muted={autoplay} autoPlay={autoplay}
height={height} width={width} poster={poster}>
<video
className="mx_MVideoBody"
ref={this.videoRef}
src={contentUrl}
title={content.body}
controls
preload={preload}
muted={autoplay}
autoPlay={autoplay}
height={height}
width={width}
poster={poster}
onPlay={this.videoOnPlay}
>
</video>
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} />
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />
</span>
);
},
});
}
}

View file

@ -0,0 +1,106 @@
/*
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 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 "../voice_messages/RecordingPlayback";
interface IProps {
mxEvent: MatrixEvent;
}
interface IState {
error?: Error;
playback?: Playback;
decryptedBlob?: Blob;
}
@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 = 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 render() {
if (this.state.error) {
// TODO: @@TR: Verify error state
return (
<span className="mx_MVoiceMessageBody">
<img src={require("../../../../res/img/warning.svg")} width="16" height="16" />
{ _t("Error processing voice message") }
</span>
);
}
if (!this.state.playback) {
// TODO: @@TR: Verify loading/decrypting state
return (
<span className="mx_MVoiceMessageBody">
<InlineSpinner />
</span>
);
}
// At this point we should have a playable state
return (
<span className="mx_MVoiceMessageBody">
<RecordingPlayback playback={this.state.playback} />
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />
</span>
)
}
}

View file

@ -0,0 +1,39 @@
/*
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 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;
}
@replaceableComponent("views.messages.MVoiceOrAudioBody")
export default class MVoiceOrAudioBody extends React.PureComponent<IProps> {
public render() {
const isVoiceMessage = !!this.props.mxEvent.getContent()['org.matrix.msc2516.voice'];
const voiceMessagesEnabled = SettingsStore.getValue("feature_voice_messages");
if (isVoiceMessage && voiceMessagesEnabled) {
return <MVoiceMessageBody {...this.props} />;
} else {
return <MAudioBody {...this.props} />;
}
}
}

View file

@ -18,6 +18,7 @@ limitations under the License.
import React, {useEffect} from 'react';
import PropTypes from 'prop-types';
import { EventStatus } from 'matrix-js-sdk/src/models/event';
import { _t } from '../../../languageHandler';
import * as sdk from '../../../index';
@ -27,6 +28,9 @@ import { isContentActionable, canEditContent } from '../../../utils/EventUtils';
import RoomContext from "../../../contexts/RoomContext";
import Toolbar from "../../../accessibility/Toolbar";
import {RovingAccessibleTooltipButton, useRovingTabIndex} from "../../../accessibility/RovingTabIndex";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {canCancel} from "../context_menus/MessageContextMenu";
import Resend from "../../../Resend";
const OptionsButton = ({mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange}) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
@ -100,6 +104,7 @@ const ReactButton = ({mxEvent, reactions, onFocusChange}) => {
</React.Fragment>;
};
@replaceableComponent("views.messages.MessageActionBar")
export default class MessageActionBar extends React.PureComponent {
static propTypes = {
mxEvent: PropTypes.object.isRequired,
@ -114,13 +119,19 @@ export default class MessageActionBar extends React.PureComponent {
static contextType = RoomContext;
componentDidMount() {
this.props.mxEvent.on("Event.decrypted", this.onDecrypted);
if (this.props.mxEvent.status && this.props.mxEvent.status !== EventStatus.SENT) {
this.props.mxEvent.on("Event.status", this.onSent);
}
if (this.props.mxEvent.isBeingDecrypted()) {
this.props.mxEvent.once("Event.decrypted", this.onDecrypted);
}
this.props.mxEvent.on("Event.beforeRedaction", this.onBeforeRedaction);
}
componentWillUnmount() {
this.props.mxEvent.removeListener("Event.decrypted", this.onDecrypted);
this.props.mxEvent.removeListener("Event.beforeRedaction", this.onBeforeRedaction);
this.props.mxEvent.off("Event.status", this.onSent);
this.props.mxEvent.off("Event.decrypted", this.onDecrypted);
this.props.mxEvent.off("Event.beforeRedaction", this.onBeforeRedaction);
}
onDecrypted = () => {
@ -134,6 +145,11 @@ export default class MessageActionBar extends React.PureComponent {
this.forceUpdate();
};
onSent = () => {
// When an event is sent and echoed the possible actions change.
this.forceUpdate();
};
onFocusChange = (focused) => {
if (!this.props.onFocusChange) {
return;
@ -155,45 +171,118 @@ export default class MessageActionBar extends React.PureComponent {
});
};
render() {
let reactButton;
let replyButton;
let editButton;
/**
* Runs a given fn on the set of possible events to test. The first event
* that passes the checkFn will have fn executed on it. Both functions take
* a MatrixEvent object. If no particular conditions are needed, checkFn can
* be null/undefined. If no functions pass the checkFn, no action will be
* taken.
* @param {Function} fn The execution function.
* @param {Function} checkFn The test function.
*/
runActionOnFailedEv(fn, checkFn) {
if (!checkFn) checkFn = () => true;
if (isContentActionable(this.props.mxEvent)) {
if (this.context.canReact) {
reactButton = (
<ReactButton mxEvent={this.props.mxEvent} reactions={this.props.reactions} onFocusChange={this.onFocusChange} />
);
}
if (this.context.canReply) {
replyButton = <RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton"
title={_t("Reply")}
onClick={this.onReplyClick}
/>;
const mxEvent = this.props.mxEvent;
const editEvent = mxEvent.replacingEvent();
const redactEvent = mxEvent.localRedactionEvent();
const tryOrder = [redactEvent, editEvent, mxEvent];
for (const ev of tryOrder) {
if (ev && checkFn(ev)) {
fn(ev);
break;
}
}
}
onResendClick = (ev) => {
this.runActionOnFailedEv((tarEv) => Resend.resend(tarEv));
};
onCancelClick = (ev) => {
this.runActionOnFailedEv(
(tarEv) => Resend.removeFromQueue(tarEv),
(testEv) => canCancel(testEv.status),
);
};
render() {
const toolbarOpts = [];
if (canEditContent(this.props.mxEvent)) {
editButton = <RovingAccessibleTooltipButton
toolbarOpts.push(<RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_editButton"
title={_t("Edit")}
onClick={this.onEditClick}
/>;
key="edit"
/>);
}
// 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">
{reactButton}
{replyButton}
{editButton}
<OptionsButton
const cancelSendingButton = <RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_cancelButton"
title={_t("Delete")}
onClick={this.onCancelClick}
key="cancel"
/>;
// We show a different toolbar for failed events, so detect that first.
const mxEvent = this.props.mxEvent;
const editStatus = mxEvent.replacingEvent() && mxEvent.replacingEvent().status;
const redactStatus = mxEvent.localRedactionEvent() && mxEvent.localRedactionEvent().status;
const allowCancel = canCancel(mxEvent.status) || canCancel(editStatus) || canCancel(redactStatus);
const isFailed = [mxEvent.status, editStatus, redactStatus].includes("not_sent");
if (allowCancel && isFailed) {
// The resend button needs to appear ahead of the edit button, so insert to the
// start of the opts
toolbarOpts.splice(0, 0, <RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_resendButton"
title={_t("Retry")}
onClick={this.onResendClick}
key="resend"
/>);
// The delete button should appear last, so we can just drop it at the end
toolbarOpts.push(cancelSendingButton);
} else {
if (isContentActionable(this.props.mxEvent)) {
// Like the resend button, the react and reply buttons need to appear before the edit.
// The only catch is we do the reply button first so that we can make sure the react
// button is the very first button without having to do length checks for `splice()`.
if (this.context.canReply) {
toolbarOpts.splice(0, 0, <RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton"
title={_t("Reply")}
onClick={this.onReplyClick}
key="reply"
/>);
}
if (this.context.canReact) {
toolbarOpts.splice(0, 0, <ReactButton
mxEvent={this.props.mxEvent}
reactions={this.props.reactions}
onFocusChange={this.onFocusChange}
key="react"
/>);
}
}
if (allowCancel) {
toolbarOpts.push(cancelSendingButton);
}
// The menu button should be last, so dump it there.
toolbarOpts.push(<OptionsButton
mxEvent={this.props.mxEvent}
getReplyThread={this.props.getReplyThread}
getTile={this.props.getTile}
permalinkCreator={this.props.permalinkCreator}
onFocusChange={this.onFocusChange}
/>
key="menu"
/>);
}
// 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}
</Toolbar>;
}
}

View file

@ -16,17 +16,16 @@ limitations under the License.
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
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";
export default createReactClass({
displayName: 'MessageEvent',
propTypes: {
@replaceableComponent("views.messages.MessageEvent")
export default class MessageEvent extends React.Component {
static propTypes = {
/* the MatrixEvent to show */
mxEvent: PropTypes.object.isRequired,
@ -47,29 +46,33 @@ export default createReactClass({
/* the maximum image height to use, if the event is an image */
maxImageHeight: PropTypes.number,
},
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
UNSAFE_componentWillMount: function() {
/* the permalinkCreator */
permalinkCreator: PropTypes.object,
};
constructor(props) {
super(props);
this._body = createRef();
},
}
getEventTileOps: function() {
getEventTileOps = () => {
return this._body.current && this._body.current.getEventTileOps ? this._body.current.getEventTileOps() : null;
},
};
onTileUpdate: function() {
onTileUpdate = () => {
this.forceUpdate();
},
};
render: function() {
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.MAudioBody'),
'm.audio': sdk.getComponent('messages.MVoiceOrAudioBody'),
'm.video': sdk.getComponent('messages.MVideoBody'),
};
const evTypes = {
@ -95,7 +98,7 @@ export default createReactClass({
}
}
if (SettingsStore.isFeatureEnabled("feature_mjolnir")) {
if (SettingsStore.getValue("feature_mjolnir")) {
const key = `mx_mjolnir_render_${this.props.mxEvent.getRoomId()}__${this.props.mxEvent.getId()}`;
const allowRender = localStorage.getItem(key) === "true";
@ -122,6 +125,7 @@ export default createReactClass({
editState={this.props.editState}
onHeightChanged={this.props.onHeightChanged}
onMessageAllowed={this.onTileUpdate}
permalinkCreator={this.props.permalinkCreator}
/>;
},
});
}
}

View file

@ -17,19 +17,32 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import {formatFullDate, formatTime} from '../../../DateUtils';
import {formatFullDate, formatTime, formatFullTime} from '../../../DateUtils';
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.messages.MessageTimestamp")
export default class MessageTimestamp extends React.Component {
static propTypes = {
ts: PropTypes.number.isRequired,
showTwelveHour: PropTypes.bool,
showFullDate: PropTypes.bool,
showSeconds: PropTypes.bool,
};
render() {
const date = new Date(this.props.ts);
let timestamp;
if (this.props.showFullDate) {
timestamp = formatFullDate(date, this.props.showTwelveHour, this.props.showSeconds);
} else if (this.props.showSeconds) {
timestamp = formatFullTime(date, this.props.showTwelveHour);
} else {
timestamp = formatTime(date, this.props.showTwelveHour);
}
return (
<span className="mx_MessageTimestamp" title={formatFullDate(date, this.props.showTwelveHour)} aria-hidden={true}>
{ formatTime(date, this.props.showTwelveHour) }
{timestamp}
</span>
);
}

View file

@ -17,7 +17,9 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import {_t} from '../../../languageHandler';
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.messages.MjolnirBody")
export default class MjolnirBody extends React.Component {
static propTypes = {
mxEvent: PropTypes.object.isRequired,

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019, 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.
@ -14,27 +14,72 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import React from "react";
import classNames from "classnames";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Relations } from "matrix-js-sdk/src/models/relations";
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import { isContentActionable } from '../../../utils/EventUtils';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { ContextMenuTooltipButton } from "../../../accessibility/context_menu/ContextMenuTooltipButton";
import { aboveLeftOf, ContextMenu, useContextMenu } from "../../structures/ContextMenu";
import ReactionPicker from "../emojipicker/ReactionPicker";
import ReactionsRowButton from "./ReactionsRowButton";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
// The maximum number of reactions to initially show on a message.
const MAX_ITEMS_WHEN_LIMITED = 8;
export default class ReactionsRow extends React.PureComponent {
static propTypes = {
// The event we're displaying reactions for
mxEvent: PropTypes.object.isRequired,
// The Relations model from the JS SDK for reactions to `mxEvent`
reactions: PropTypes.object,
const ReactButton = ({ mxEvent, reactions }: IProps) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
let contextMenu;
if (menuDisplayed) {
const buttonRect = button.current.getBoundingClientRect();
contextMenu = <ContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu} managed={false}>
<ReactionPicker mxEvent={mxEvent} reactions={reactions} onFinished={closeMenu} />
</ContextMenu>;
}
constructor(props) {
super(props);
return <React.Fragment>
<ContextMenuTooltipButton
className={classNames("mx_ReactionsRow_addReactionButton", {
mx_ReactionsRow_addReactionButton_active: menuDisplayed,
})}
title={_t("Add reaction")}
onClick={openMenu}
onContextMenu={e => {
e.preventDefault();
openMenu();
}}
isExpanded={menuDisplayed}
inputRef={button}
/>
{ contextMenu }
</React.Fragment>;
};
interface IProps {
// The event we're displaying reactions for
mxEvent: MatrixEvent;
// The Relations model from the JS SDK for reactions to `mxEvent`
reactions?: Relations;
}
interface IState {
myReactions: MatrixEvent[];
showAll: boolean;
}
@replaceableComponent("views.messages.ReactionsRow")
export default class ReactionsRow extends React.PureComponent<IProps, IState> {
static contextType = MatrixClientContext;
constructor(props, context) {
super(props, context);
if (props.reactions) {
props.reactions.on("Relations.add", this.onReactionsChange);
@ -90,7 +135,7 @@ export default class ReactionsRow extends React.PureComponent {
if (!reactions) {
return null;
}
const userId = MatrixClientPeg.get().getUserId();
const userId = this.context.getUserId();
const myReactions = reactions.getAnnotationsBySender()[userId];
if (!myReactions) {
return null;
@ -112,7 +157,6 @@ export default class ReactionsRow extends React.PureComponent {
return null;
}
const ReactionsRowButton = sdk.getComponent('messages.ReactionsRowButton');
let items = reactions.getSortedAnnotationsByKey().map(([content, events]) => {
const count = events.size;
if (!count) {
@ -134,6 +178,8 @@ export default class ReactionsRow extends React.PureComponent {
/>;
}).filter(item => !!item);
if (!items.length) return null;
// Show the first MAX_ITEMS if there are MAX_ITEMS + 1 or more items.
// The "+ 1" ensure that the "show all" reveals something that takes up
// more space than the button itself.
@ -149,13 +195,21 @@ export default class ReactionsRow extends React.PureComponent {
</a>;
}
const cli = this.context;
let addReactionButton;
if (cli.getRoom(mxEvent.getRoomId()).currentState.maySendEvent(EventType.Reaction, cli.getUserId())) {
addReactionButton = <ReactButton mxEvent={mxEvent} reactions={reactions} />;
}
return <div
className="mx_ReactionsRow"
role="toolbar"
aria-label={_t("Reactions")}
>
{items}
{showAllButton}
{ items }
{ showAllButton }
{ addReactionButton }
</div>;
}
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019, 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.
@ -14,47 +14,54 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import React from "react";
import classNames from "classnames";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
import dis from "../../../dispatcher/dispatcher";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import ReactionsRowButtonTooltip from "./ReactionsRowButtonTooltip";
import AccessibleButton from "../elements/AccessibleButton";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
export default class ReactionsRowButton extends React.PureComponent {
static propTypes = {
// The event we're displaying reactions for
mxEvent: PropTypes.object.isRequired,
// The reaction content / key / emoji
content: PropTypes.string.isRequired,
// The count of votes for this key
count: PropTypes.number.isRequired,
// A Set of Martix reaction events for this key
reactionEvents: PropTypes.object.isRequired,
// A possible Matrix event if the current user has voted for this type
myReactionEvent: PropTypes.object,
}
interface IProps {
// The event we're displaying reactions for
mxEvent: MatrixEvent;
// The reaction content / key / emoji
content: string;
// The count of votes for this key
count: number;
// A Set of Matrix reaction events for this key
reactionEvents: Set<MatrixEvent>;
// A possible Matrix event if the current user has voted for this type
myReactionEvent?: MatrixEvent;
}
constructor(props) {
super(props);
interface IState {
tooltipRendered: boolean;
tooltipVisible: boolean;
}
this.state = {
tooltipVisible: false,
};
}
@replaceableComponent("views.messages.ReactionsRowButton")
export default class ReactionsRowButton extends React.PureComponent<IProps, IState> {
static contextType = MatrixClientContext;
onClick = (ev) => {
state = {
tooltipRendered: false,
tooltipVisible: false,
};
onClick = () => {
const { mxEvent, myReactionEvent, content } = this.props;
if (myReactionEvent) {
MatrixClientPeg.get().redactEvent(
this.context.redactEvent(
mxEvent.getRoomId(),
myReactionEvent.getId(),
);
} else {
MatrixClientPeg.get().sendEvent(mxEvent.getRoomId(), "m.reaction", {
this.context.sendEvent(mxEvent.getRoomId(), "m.reaction", {
"m.relates_to": {
"rel_type": "m.annotation",
"event_id": mxEvent.getId(),
@ -81,8 +88,6 @@ export default class ReactionsRowButton extends React.PureComponent {
}
render() {
const ReactionsRowButtonTooltip =
sdk.getComponent('messages.ReactionsRowButtonTooltip');
const { mxEvent, content, count, reactionEvents, myReactionEvent } = this.props;
const classes = classNames({
@ -100,7 +105,7 @@ export default class ReactionsRowButton extends React.PureComponent {
/>;
}
const room = MatrixClientPeg.get().getRoom(mxEvent.getRoomId());
const room = this.context.getRoom(mxEvent.getRoomId());
let label;
if (room) {
const senders = [];
@ -127,12 +132,12 @@ export default class ReactionsRowButton extends React.PureComponent {
},
);
}
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const isPeeking = room.getMyMembership() !== "join";
return <AccessibleButton
className={classes}
aria-label={label}
onClick={this.onClick}
disabled={isPeeking}
onMouseOver={this.onMouseOver}
onMouseLeave={this.onMouseLeave}
>

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 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.
@ -14,31 +14,34 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import React from "react";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import * as sdk from '../../../index';
import { unicodeToShortcode } from '../../../HtmlUtils';
import { _t } from '../../../languageHandler';
import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Tooltip from "../elements/Tooltip";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
export default class ReactionsRowButtonTooltip extends React.PureComponent {
static propTypes = {
// The event we're displaying reactions for
mxEvent: PropTypes.object.isRequired,
// The reaction content / key / emoji
content: PropTypes.string.isRequired,
// A Set of Martix reaction events for this key
reactionEvents: PropTypes.object.isRequired,
visible: PropTypes.bool.isRequired,
}
interface IProps {
// The event we're displaying reactions for
mxEvent: MatrixEvent;
// The reaction content / key / emoji
content: string;
// A Set of Matrix reaction events for this key
reactionEvents: Set<MatrixEvent>;
visible: boolean;
}
@replaceableComponent("views.messages.ReactionsRowButtonTooltip")
export default class ReactionsRowButtonTooltip extends React.PureComponent<IProps> {
static contextType = MatrixClientContext;
render() {
const Tooltip = sdk.getComponent('elements.Tooltip');
const { content, reactionEvents, mxEvent, visible } = this.props;
const room = MatrixClientPeg.get().getRoom(mxEvent.getRoomId());
const room = this.context.getRoom(mxEvent.getRoomId());
let tooltipLabel;
if (room) {
const senders = [];

View file

@ -18,25 +18,25 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { _t } from '../../../languageHandler';
import * as sdk from '../../../index';
import Modal from '../../../Modal';
import AccessibleButton from '../elements/AccessibleButton';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
export default createReactClass({
displayName: 'RoomAvatarEvent',
propTypes: {
@replaceableComponent("views.messages.RoomAvatarEvent")
export default class RoomAvatarEvent extends React.Component {
static propTypes = {
/* the MatrixEvent to show */
mxEvent: PropTypes.object.isRequired,
},
};
onAvatarClick: function() {
onAvatarClick = () => {
const cli = MatrixClientPeg.get();
const ev = this.props.mxEvent;
const httpUrl = cli.mxcUrlToHttp(ev.getContent().url);
const httpUrl = mediaFromMxc(ev.getContent().url).srcHttp;
const room = cli.getRoom(this.props.mxEvent.getRoomId());
const text = _t('%(senderDisplayName)s changed the avatar for %(roomName)s', {
@ -49,10 +49,10 @@ export default createReactClass({
src: httpUrl,
name: text,
};
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox");
},
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", null, true);
};
render: function() {
render() {
const ev = this.props.mxEvent;
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
const RoomAvatar = sdk.getComponent("avatars.RoomAvatar");
@ -86,5 +86,5 @@ export default createReactClass({
}
</div>
);
},
});
}
}

View file

@ -17,22 +17,22 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import dis from '../../../dispatcher/dispatcher';
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
import { _t } from '../../../languageHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import EventTileBubble from "./EventTileBubble";
import {replaceableComponent} from "../../../utils/replaceableComponent";
export default createReactClass({
displayName: 'RoomCreate',
propTypes: {
@replaceableComponent("views.messages.RoomCreate")
export default class RoomCreate extends React.Component {
static propTypes = {
/* the MatrixEvent to show */
mxEvent: PropTypes.object.isRequired,
},
};
_onLinkClicked: function(e) {
_onLinkClicked = e => {
e.preventDefault();
const predecessor = this.props.mxEvent.getContent()['predecessor'];
@ -43,28 +43,27 @@ export default createReactClass({
highlighted: true,
room_id: predecessor['room_id'],
});
},
};
render: function() {
render() {
const predecessor = this.props.mxEvent.getContent()['predecessor'];
if (predecessor === undefined) {
return <div />; // We should never have been instaniated in this case
return <div />; // We should never have been instantiated in this case
}
const prevRoom = MatrixClientPeg.get().getRoom(predecessor['room_id']);
const permalinkCreator = new RoomPermalinkCreator(prevRoom, predecessor['room_id']);
permalinkCreator.load();
const predecessorPermalink = permalinkCreator.forEvent(predecessor['event_id']);
return <div className="mx_CreateEvent">
<div className="mx_CreateEvent_image" />
<div className="mx_CreateEvent_header">
{_t("This room is a continuation of another conversation.")}
</div>
<a className="mx_CreateEvent_link"
href={predecessorPermalink}
onClick={this._onLinkClicked}
>
const link = (
<a href={predecessorPermalink} onClick={this._onLinkClicked}>
{_t("Click here to see older messages.")}
</a>
</div>;
},
});
);
return <EventTileBubble
className="mx_CreateEvent"
title={_t("This room is a continuation of another conversation.")}
subtitle={link}
/>;
}
}

View file

@ -16,31 +16,25 @@
import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import Flair from '../elements/Flair.js';
import FlairStore from '../../../stores/FlairStore';
import { _t } from '../../../languageHandler';
import {getUserNameColorClass} from '../../../utils/FormattingUtils';
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {replaceableComponent} from "../../../utils/replaceableComponent";
export default createReactClass({
displayName: 'SenderProfile',
propTypes: {
@replaceableComponent("views.messages.SenderProfile")
export default class SenderProfile extends React.Component {
static propTypes = {
mxEvent: PropTypes.object.isRequired, // event whose sender we're showing
text: PropTypes.string, // Text to show. Defaults to sender name
onClick: PropTypes.func,
},
};
statics: {
contextType: MatrixClientContext,
},
static contextType = MatrixClientContext;
getInitialState() {
return {
userGroups: null,
relatedGroups: [],
};
},
state = {
userGroups: null,
relatedGroups: [],
};
componentDidMount() {
this.unmounted = false;
@ -54,20 +48,20 @@ export default createReactClass({
});
this.context.on('RoomState.events', this.onRoomStateEvents);
},
}
componentWillUnmount() {
this.unmounted = true;
this.context.removeListener('RoomState.events', this.onRoomStateEvents);
},
}
onRoomStateEvents(event) {
onRoomStateEvents = event => {
if (event.getType() === 'm.room.related_groups' &&
event.getRoomId() === this.props.mxEvent.getRoomId()
) {
this._updateRelatedGroups();
}
},
};
_updateRelatedGroups() {
if (this.unmounted) return;
@ -78,7 +72,7 @@ export default createReactClass({
this.setState({
relatedGroups: relatedGroupsEvent ? relatedGroupsEvent.getContent().groups || [] : [],
});
},
}
_getDisplayedGroups(userGroups, relatedGroups) {
let displayedGroups = userGroups || [];
@ -90,7 +84,7 @@ export default createReactClass({
displayedGroups = [];
}
return displayedGroups;
},
}
render() {
const {mxEvent} = this.props;
@ -124,19 +118,12 @@ export default createReactClass({
{ flair }
</span>;
const content = this.props.text ?
<span>
<span className="mx_SenderProfile_aux">
{ _t(this.props.text, { senderName: () => nameElem }) }
</span>
</span> : nameFlair;
return (
<div className="mx_SenderProfile" dir="auto" onClick={this.props.onClick}>
<div className="mx_SenderProfile_hover">
{ content }
{ nameFlair }
</div>
</div>
);
},
});
}
}

View file

@ -19,7 +19,6 @@ limitations under the License.
import React, {createRef} from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import highlight from 'highlight.js';
import * as HtmlUtils from '../../../HtmlUtils';
import {formatDate} from '../../../DateUtils';
@ -36,11 +35,11 @@ import {isPermalinkHost} from "../../../utils/permalinks/Permalinks";
import {toRightOf} from "../../structures/ContextMenu";
import {copyPlaintext} from "../../../utils/strings";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import {replaceableComponent} from "../../../utils/replaceableComponent";
export default createReactClass({
displayName: 'TextualBody',
propTypes: {
@replaceableComponent("views.messages.TextualBody")
export default class TextualBody extends React.Component {
static propTypes = {
/* the MatrixEvent to show */
mxEvent: PropTypes.object.isRequired,
@ -58,10 +57,14 @@ export default createReactClass({
/* the shape of the tile, used */
tileShape: PropTypes.string,
},
};
getInitialState: function() {
return {
constructor(props) {
super(props);
this._content = createRef();
this.state = {
// the URLs (if any) to be previewed with a LinkPreviewWidget
// inside this TextualBody.
links: [],
@ -69,22 +72,18 @@ export default createReactClass({
// track whether the preview widget is hidden
widgetHidden: false,
};
},
}
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
UNSAFE_componentWillMount: function() {
this._content = createRef();
},
componentDidMount: function() {
componentDidMount() {
this._unmounted = false;
this._pills = [];
if (!this.props.editState) {
this._applyFormatting();
}
},
}
_applyFormatting() {
const showLineNumbers = SettingsStore.getValue("showCodeLineNumbers");
this.activateSpoilers([this._content.current]);
// pillifyLinks BEFORE linkifyElement because plain room/user URLs in the composer
@ -95,33 +94,154 @@ export default createReactClass({
this.calculateUrlPreview();
if (this.props.mxEvent.getContent().format === "org.matrix.custom.html") {
const blocks = ReactDOM.findDOMNode(this).getElementsByTagName("code");
if (blocks.length > 0) {
// Handle expansion and add buttons
const pres = ReactDOM.findDOMNode(this).getElementsByTagName("pre");
if (pres.length > 0) {
for (let i = 0; i < pres.length; i++) {
// If there already is a div wrapping the codeblock we want to skip this.
// This happens after the codeblock was edited.
if (pres[i].parentNode.className == "mx_EventTile_pre_container") continue;
// Add code element if it's missing since we depend on it
if (pres[i].getElementsByTagName("code").length == 0) {
this._addCodeElement(pres[i]);
}
// Wrap a div around <pre> so that the copy button can be correctly positioned
// when the <pre> overflows and is scrolled horizontally.
const div = this._wrapInDiv(pres[i]);
this._handleCodeBlockExpansion(pres[i]);
this._addCodeExpansionButton(div, pres[i]);
this._addCodeCopyButton(div);
if (showLineNumbers) {
this._addLineNumbers(pres[i]);
}
}
}
// Highlight code
const codes = ReactDOM.findDOMNode(this).getElementsByTagName("code");
if (codes.length > 0) {
// Do this asynchronously: parsing code takes time and we don't
// need to block the DOM update on it.
setTimeout(() => {
if (this._unmounted) return;
for (let i = 0; i < blocks.length; i++) {
if (SettingsStore.getValue("enableSyntaxHighlightLanguageDetection")) {
highlight.highlightBlock(blocks[i]);
} else {
// Only syntax highlight if there's a class starting with language-
const classes = blocks[i].className.split(/\s+/).filter(function(cl) {
return cl.startsWith('language-') && !cl.startsWith('language-_');
});
if (classes.length != 0) {
highlight.highlightBlock(blocks[i]);
}
}
for (let i = 0; i < codes.length; i++) {
// If the code already has the hljs class we want to skip this.
// This happens after the codeblock was edited.
if (codes[i].className.includes("hljs")) continue;
this._highlightCode(codes[i]);
}
}, 10);
}
this._addCodeCopyButton();
}
},
}
componentDidUpdate: function(prevProps) {
_addCodeElement(pre) {
const code = document.createElement("code");
code.append(...pre.childNodes);
pre.appendChild(code);
}
_addCodeExpansionButton(div, pre) {
// Calculate how many percent does the pre element take up.
// If it's less than 30% we don't add the expansion button.
const percentageOfViewport = pre.offsetHeight / window.innerHeight * 100;
if (percentageOfViewport < 30) return;
const button = document.createElement("span");
button.className = "mx_EventTile_button ";
if (pre.className == "mx_EventTile_collapsedCodeBlock") {
button.className += "mx_EventTile_expandButton";
} else {
button.className += "mx_EventTile_collapseButton";
}
button.onclick = async () => {
button.className = "mx_EventTile_button ";
if (pre.className == "mx_EventTile_collapsedCodeBlock") {
pre.className = "";
button.className += "mx_EventTile_collapseButton";
} else {
pre.className = "mx_EventTile_collapsedCodeBlock";
button.className += "mx_EventTile_expandButton";
}
// By expanding/collapsing we changed
// the height, therefore we call this
this.props.onHeightChanged();
};
div.appendChild(button);
}
_addCodeCopyButton(div) {
const button = document.createElement("span");
button.className = "mx_EventTile_button mx_EventTile_copyButton ";
// Check if expansion button exists. If so
// we put the copy button to the bottom
const expansionButtonExists = div.getElementsByClassName("mx_EventTile_button");
if (expansionButtonExists.length > 0) button.className += "mx_EventTile_buttonBottom";
button.onclick = async () => {
const copyCode = button.parentNode.getElementsByTagName("code")[0];
const successful = await copyPlaintext(copyCode.textContent);
const buttonRect = button.getBoundingClientRect();
const GenericTextContextMenu = sdk.getComponent('context_menus.GenericTextContextMenu');
const {close} = ContextMenu.createMenu(GenericTextContextMenu, {
...toRightOf(buttonRect, 2),
message: successful ? _t('Copied!') : _t('Failed to copy'),
});
button.onmouseleave = close;
};
div.appendChild(button);
}
_wrapInDiv(pre) {
const div = document.createElement("div");
div.className = "mx_EventTile_pre_container";
// Insert containing div in place of <pre> block
pre.parentNode.replaceChild(div, pre);
// Append <pre> block and copy button to container
div.appendChild(pre);
return div;
}
_handleCodeBlockExpansion(pre) {
if (!SettingsStore.getValue("expandCodeByDefault")) {
pre.className = "mx_EventTile_collapsedCodeBlock";
}
}
_addLineNumbers(pre) {
// Calculate number of lines in pre
const number = pre.innerHTML.replace(/\n(<\/code>)?$/, "").split(/\n/).length;
pre.innerHTML = '<span class="mx_EventTile_lineNumbers"></span>' + pre.innerHTML + '<span></span>';
const lineNumbers = pre.getElementsByClassName("mx_EventTile_lineNumbers")[0];
// Iterate through lines starting with 1 (number of the first line is 1)
for (let i = 1; i <= number; i++) {
lineNumbers.innerHTML += '<span class="mx_EventTile_lineNumber">' + i + '</span>';
}
}
_highlightCode(code) {
if (SettingsStore.getValue("enableSyntaxHighlightLanguageDetection")) {
highlight.highlightBlock(code);
} else {
// Only syntax highlight if there's a class starting with language-
const classes = code.className.split(/\s+/).filter(function(cl) {
return cl.startsWith('language-') && !cl.startsWith('language-_');
});
if (classes.length != 0) {
highlight.highlightBlock(code);
}
}
}
componentDidUpdate(prevProps) {
if (!this.props.editState) {
const stoppedEditing = prevProps.editState && !this.props.editState;
const messageWasEdited = prevProps.replacingEventId !== this.props.replacingEventId;
@ -129,14 +249,14 @@ export default createReactClass({
this._applyFormatting();
}
}
},
}
componentWillUnmount: function() {
componentWillUnmount() {
this._unmounted = true;
unmountPills(this._pills);
},
}
shouldComponentUpdate: function(nextProps, nextState) {
shouldComponentUpdate(nextProps, nextState) {
//console.info("shouldComponentUpdate: ShowUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview);
// exploit that events are immutable :)
@ -148,9 +268,9 @@ export default createReactClass({
nextProps.editState !== this.props.editState ||
nextState.links !== this.state.links ||
nextState.widgetHidden !== this.state.widgetHidden);
},
}
calculateUrlPreview: function() {
calculateUrlPreview() {
//console.info("calculateUrlPreview: ShowUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview);
if (this.props.showUrlPreview) {
@ -172,11 +292,13 @@ export default createReactClass({
const hidden = global.localStorage.getItem("hide_preview_" + this.props.mxEvent.getId());
this.setState({ widgetHidden: hidden });
}
} else if (this.state.links.length) {
this.setState({ links: [] });
}
}
},
}
activateSpoilers: function(nodes) {
activateSpoilers(nodes) {
let node = nodes[0];
while (node) {
if (node.tagName === "SPAN" && typeof node.getAttribute("data-mx-spoiler") === "string") {
@ -202,9 +324,9 @@ export default createReactClass({
node = node.nextSibling;
}
},
}
findLinks: function(nodes) {
findLinks(nodes) {
let links = [];
for (let i = 0; i < nodes.length; i++) {
@ -221,9 +343,9 @@ export default createReactClass({
}
}
return links;
},
}
isLinkPreviewable: function(node) {
isLinkPreviewable(node) {
// don't try to preview relative links
if (!node.getAttribute("href").startsWith("http://") &&
!node.getAttribute("href").startsWith("https://")) {
@ -254,73 +376,39 @@ export default createReactClass({
return true;
}
}
},
}
_addCodeCopyButton() {
// Add 'copy' buttons to pre blocks
Array.from(ReactDOM.findDOMNode(this).querySelectorAll('.mx_EventTile_body pre')).forEach((p) => {
const button = document.createElement("span");
button.className = "mx_EventTile_copyButton";
button.onclick = async () => {
const copyCode = button.parentNode.getElementsByTagName("pre")[0];
const successful = await copyPlaintext(copyCode.textContent);
const buttonRect = button.getBoundingClientRect();
const GenericTextContextMenu = sdk.getComponent('context_menus.GenericTextContextMenu');
const {close} = ContextMenu.createMenu(GenericTextContextMenu, {
...toRightOf(buttonRect, 2),
message: successful ? _t('Copied!') : _t('Failed to copy'),
});
button.onmouseleave = close;
};
// Wrap a div around <pre> so that the copy button can be correctly positioned
// when the <pre> overflows and is scrolled horizontally.
const div = document.createElement("div");
div.className = "mx_EventTile_pre_container";
// Insert containing div in place of <pre> block
p.parentNode.replaceChild(div, p);
// Append <pre> block and copy button to container
div.appendChild(p);
div.appendChild(button);
});
},
onCancelClick: function(event) {
onCancelClick = event => {
this.setState({ widgetHidden: true });
// FIXME: persist this somewhere smarter than local storage
if (global.localStorage) {
global.localStorage.setItem("hide_preview_" + this.props.mxEvent.getId(), "1");
}
this.forceUpdate();
},
};
onEmoteSenderClick: function(event) {
onEmoteSenderClick = event => {
const mxEvent = this.props.mxEvent;
dis.dispatch({
action: 'insert_mention',
user_id: mxEvent.getSender(),
});
},
};
getEventTileOps: function() {
return {
isWidgetHidden: () => {
return this.state.widgetHidden;
},
getEventTileOps = () => ({
isWidgetHidden: () => {
return this.state.widgetHidden;
},
unhideWidget: () => {
this.setState({ widgetHidden: false });
if (global.localStorage) {
global.localStorage.removeItem("hide_preview_" + this.props.mxEvent.getId());
}
},
};
},
unhideWidget: () => {
this.setState({widgetHidden: false});
if (global.localStorage) {
global.localStorage.removeItem("hide_preview_" + this.props.mxEvent.getId());
}
},
});
onStarterLinkClick: function(starterLink, ev) {
onStarterLinkClick = (starterLink, ev) => {
ev.preventDefault();
// We need to add on our scalar token to the starter link, but we may not have one!
// In addition, we can't fetch one on click and then go to it immediately as that
@ -351,7 +439,7 @@ export default createReactClass({
"Do you wish to continue?", { integrationsUrl: integrationsUrl }) }
</div>,
button: _t("Continue"),
onFinished: function(confirmed) {
onFinished(confirmed) {
if (!confirmed) {
return;
}
@ -365,14 +453,14 @@ export default createReactClass({
},
});
});
},
};
_openHistoryDialog: async function() {
_openHistoryDialog = async () => {
const MessageEditHistoryDialog = sdk.getComponent("views.dialogs.MessageEditHistoryDialog");
Modal.createDialog(MessageEditHistoryDialog, {mxEvent: this.props.mxEvent});
},
};
_renderEditedMarker: function() {
_renderEditedMarker() {
const date = this.props.mxEvent.replacingEventDate();
const dateString = date && formatDate(date);
@ -395,9 +483,9 @@ export default createReactClass({
<span>{`(${_t("edited")})`}</span>
</AccessibleTooltipButton>
);
},
}
render: function() {
render() {
if (this.props.editState) {
const EditMessageComposer = sdk.getComponent('rooms.EditMessageComposer');
return <EditMessageComposer editState={this.props.editState} className="mx_EventTile_content" />;
@ -405,7 +493,8 @@ export default createReactClass({
const mxEvent = this.props.mxEvent;
const content = mxEvent.getContent();
const stripReply = ReplyThread.getParentEventId(mxEvent);
// only strip reply if this is the original replying event, edits thereafter do not have the fallback
const stripReply = !mxEvent.replacingEvent() && ReplyThread.getParentEventId(mxEvent);
let body = HtmlUtils.bodyToHtml(content, this.props.highlights, {
disableBigEmoji: content.msgtype === "m.emote" || !SettingsStore.getValue('TextualBody.enableBigEmoji'),
// Part of Replies fallback support
@ -413,13 +502,18 @@ export default createReactClass({
ref: this._content,
});
if (this.props.replacingEventId) {
body = [body, this._renderEditedMarker()];
body = <>
{body}
{this._renderEditedMarker()}
</>;
}
if (this.props.highlightLink) {
body = <a href={this.props.highlightLink}>{ body }</a>;
} else if (content.data && typeof content.data["org.matrix.neb.starter_link"] === "string") {
body = <a href="#" onClick={this.onStarterLinkClick.bind(this, content.data["org.matrix.neb.starter_link"])}>{ body }</a>;
body = <a href="#"
onClick={this.onStarterLinkClick.bind(this, content.data["org.matrix.neb.starter_link"])}
>{ body }</a>;
}
let widgets;
@ -427,11 +521,12 @@ export default createReactClass({
const LinkPreviewWidget = sdk.getComponent('rooms.LinkPreviewWidget');
widgets = this.state.links.map((link)=>{
return <LinkPreviewWidget
key={link}
link={link}
mxEvent={this.props.mxEvent}
onCancelClick={this.onCancelClick}
onHeightChanged={this.props.onHeightChanged} />;
key={link}
link={link}
mxEvent={this.props.mxEvent}
onCancelClick={this.onCancelClick}
onHeightChanged={this.props.onHeightChanged}
/>;
});
}
@ -466,5 +561,5 @@ export default createReactClass({
</span>
);
}
},
});
}
}

View file

@ -17,22 +17,21 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import * as TextForEvent from "../../../TextForEvent";
import {replaceableComponent} from "../../../utils/replaceableComponent";
export default createReactClass({
displayName: 'TextualEvent',
propTypes: {
@replaceableComponent("views.messages.TextualEvent")
export default class TextualEvent extends React.Component {
static propTypes = {
/* the MatrixEvent to show */
mxEvent: PropTypes.object.isRequired,
},
};
render: function() {
render() {
const text = TextForEvent.textForEvent(this.props.mxEvent);
if (text == null || text.length === 0) return null;
return (
<div className="mx_TextualEvent">{ text }</div>
);
},
});
}
}

View file

@ -19,7 +19,10 @@ import classNames from 'classnames';
import { _t } from '../../../languageHandler';
import * as sdk from '../../../index';
import Modal from '../../../Modal';
import SdkConfig from "../../../SdkConfig";
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.messages.TileErrorBoundary")
export default class TileErrorBoundary extends React.Component {
constructor(props) {
super(props);
@ -54,14 +57,20 @@ export default class TileErrorBoundary extends React.Component {
mx_EventTile_content: true,
mx_EventTile_tileError: true,
};
let submitLogsButton;
if (SdkConfig.get().bug_report_endpoint_url) {
submitLogsButton = <a onClick={this._onBugReport} href="#">
{_t("Submit logs")}
</a>;
}
return (<div className={classNames(classes)}>
<div className="mx_EventTile_line">
<span>
{_t("Can't load this message")}
{ mxEvent && ` (${mxEvent.getType()})` }
<a onClick={this._onBugReport} href="#">
{_t("Submit logs")}
</a>
{ submitLogsButton }
</span>
</div>
</div>);

View file

@ -15,13 +15,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, {forwardRef} from "react";
export default ({mxEvent}) => {
export default forwardRef(({mxEvent}, ref) => {
const text = mxEvent.getContent().body;
return (
<span className="mx_UnknownBody">
<span className="mx_UnknownBody" ref={ref}>
{ text }
</span>
);
};
});

View file

@ -17,7 +17,9 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.messages.ViewSourceEvent")
export default class ViewSourceEvent extends React.PureComponent {
static propTypes = {
/* the MatrixEvent to show */