Merge remote-tracking branch 'upstream/develop' into compact-reply-rendering

This commit is contained in:
Tulir Asokan 2021-02-19 21:47:10 +02:00
commit dfcf701449
544 changed files with 43286 additions and 15544 deletions

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

@ -144,7 +144,7 @@ export default class MFileBody extends React.Component {
* 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.
* @return {string} the human readable link text for the attachment.
*/
presentableTextForFile(content) {
@ -288,7 +288,7 @@ export default class MFileBody extends React.Component {
<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" />

View file

@ -85,6 +85,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) {
@ -253,10 +254,7 @@ export default class MImageBody extends React.Component {
}
}
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);
@ -289,9 +287,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});
}
@ -355,7 +362,7 @@ export default class MImageBody extends React.Component {
}
// The maximum height of the thumbnail as it is rendered as an <img>
const maxHeight = Math.min(this.props.maxImageHeight || 600, infoHeight);
const maxHeight = Math.min(this.props.maxImageHeight || 240, infoHeight);
// The maximum width of the thumbnail, as dictated by its natural
// maximum height.
const maxWidth = infoWidth * maxHeight / infoHeight;

View file

@ -18,6 +18,9 @@ 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";
interface IProps {
mxEvent: MatrixEvent;
@ -32,45 +35,37 @@ export default class MJitsiWidgetEvent extends React.PureComponent<IProps> {
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 (!WidgetStore.instance.isPinned(this.props.mxEvent.getStateKey())) {
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 (
<div className='mx_EventTile_bubble mx_MJitsiWidgetEvent'>
<div className='mx_MJitsiWidgetEvent_title'>
{_t('Video conference ended by %(senderName)s', {senderName})}
</div>
</div>
);
return <EventTileBubble
className="mx_MJitsiWidgetEvent"
title={_t('Video conference ended by %(senderName)s', {senderName})}
/>;
} else if (prevUrl) {
// modified
return (
<div className='mx_EventTile_bubble mx_MJitsiWidgetEvent'>
<div className='mx_MJitsiWidgetEvent_title'>
{_t('Video conference updated by %(senderName)s', {senderName})}
</div>
<div className='mx_MJitsiWidgetEvent_subtitle'>
{joinCopy}
</div>
</div>
);
return <EventTileBubble
className="mx_MJitsiWidgetEvent"
title={_t('Video conference updated by %(senderName)s', {senderName})}
subtitle={joinCopy}
/>;
} else {
// assume added
return (
<div className='mx_EventTile_bubble mx_MJitsiWidgetEvent'>
<div className='mx_MJitsiWidgetEvent_title'>
{_t("Video conference started by %(senderName)s", {senderName})}
</div>
<div className='mx_MJitsiWidgetEvent_subtitle'>
{joinCopy}
</div>
</div>
);
return <EventTileBubble
className="mx_MJitsiWidgetEvent"
title={_t("Video conference started by %(senderName)s", {senderName})}
subtitle={joinCopy}
/>;
}
}
}

View file

@ -21,6 +21,7 @@ import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { _t } from '../../../languageHandler';
import {getNameForEventRoom, userLabelForEventRoom}
from '../../../utils/KeyVerificationStateObserver';
import EventTileBubble from "./EventTileBubble";
export default class MKeyVerificationConclusion extends React.Component {
constructor(props) {
@ -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,6 +24,7 @@ import {getNameForEventRoom, userLabelForEventRoom}
import dis from "../../../dispatcher/dispatcher";
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
import {Action} from "../../../dispatcher/actions";
import EventTileBubble from "./EventTileBubble";
export default class MKeyVerificationRequest extends React.Component {
constructor(props) {
@ -146,10 +147,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 +156,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

@ -16,7 +16,6 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import MFileBody from './MFileBody';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { decryptFile } from '../../../utils/DecryptFile';
@ -24,23 +23,36 @@ import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore";
import InlineSpinner from '../elements/InlineSpinner';
export default class MVideoBody extends React.Component {
static propTypes = {
/* the MatrixEvent to show */
mxEvent: PropTypes.object.isRequired,
interface IProps {
/* the MatrixEvent to show */
mxEvent: any;
/* called when the video has loaded */
onHeightChanged: () => void;
}
/* called when the video has loaded */
onHeightChanged: PropTypes.func.isRequired,
};
interface IState {
decryptedUrl: string|null,
decryptedThumbnailUrl: string|null,
decryptedBlob: Blob|null,
error: any|null,
fetchingData: boolean,
}
state = {
decryptedUrl: null,
decryptedThumbnailUrl: null,
decryptedBlob: null,
error: null,
};
export default class MVideoBody extends React.PureComponent<IProps, IState> {
private videoRef = React.createRef<HTMLVideoElement>();
thumbScale(fullWidth, fullHeight, thumbWidth, thumbHeight) {
constructor(props) {
super(props);
this.state = {
fetchingData: false,
decryptedUrl: null,
decryptedThumbnailUrl: null,
decryptedBlob: null,
error: null,
}
}
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
@ -61,7 +73,7 @@ export default class MVideoBody extends React.Component {
}
}
_getContentUrl() {
private getContentUrl(): string|null {
const content = this.props.mxEvent.getContent();
if (content.file !== undefined) {
return this.state.decryptedUrl;
@ -70,7 +82,12 @@ export default class MVideoBody extends React.Component {
}
}
_getThumbUrl() {
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) {
return this.state.decryptedThumbnailUrl;
@ -81,7 +98,8 @@ export default class MVideoBody extends React.Component {
}
}
componentDidMount() {
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);
@ -92,26 +110,36 @@ export default class MVideoBody extends React.Component {
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,
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,
});
});
}
}
}
@ -124,8 +152,38 @@ export default class MVideoBody extends React.Component {
}
}
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 (
@ -136,7 +194,8 @@ export default class MVideoBody extends React.Component {
);
}
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.
@ -149,9 +208,8 @@ export default class MVideoBody extends React.Component {
);
}
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;
@ -170,9 +228,20 @@ export default class MVideoBody extends React.Component {
}
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} />
</span>

View file

@ -22,6 +22,7 @@ import dis from '../../../dispatcher/dispatcher';
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
import { _t } from '../../../languageHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import EventTileBubble from "./EventTileBubble";
export default class RoomCreate extends React.Component {
static propTypes = {
@ -45,23 +46,22 @@ export default class RoomCreate extends React.Component {
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

@ -81,6 +81,7 @@ export default class TextualBody extends React.Component {
}
_applyFormatting() {
const showLineNumbers = SettingsStore.getValue("showCodeLineNumbers");
this.activateSpoilers([this._content.current]);
// pillifyLinks BEFORE linkifyElement because plain room/user URLs in the composer
@ -91,29 +92,140 @@ export default class TextualBody extends React.Component {
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;
// 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();
}
}
_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) {
pre.innerHTML = '<span class="mx_EventTile_lineNumbers"></span>' + pre.innerHTML + '<span></span>';
const lineNumbers = pre.getElementsByClassName("mx_EventTile_lineNumbers")[0];
// Calculate number of lines in pre
const number = pre.innerHTML.split(/\n/).length;
// 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);
}
}
}
@ -254,38 +366,6 @@ export default class TextualBody extends React.Component {
}
}
_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 = event => {
this.setState({ widgetHidden: true });
// FIXME: persist this somewhere smarter than local storage
@ -401,7 +481,8 @@ export default class TextualBody extends React.Component {
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
@ -409,13 +490,18 @@ export default class TextualBody extends React.Component {
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;