Merge branch 'develop' into matthew/slate
This commit is contained in:
commit
02947063d3
89 changed files with 2884 additions and 1622 deletions
|
@ -18,6 +18,7 @@ limitations under the License.
|
|||
'use strict';
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import MatrixClientPeg from '../../../MatrixClientPeg';
|
||||
import dis from '../../../dispatcher';
|
||||
|
@ -34,13 +35,16 @@ module.exports = React.createClass({
|
|||
|
||||
propTypes: {
|
||||
/* the MatrixEvent associated with the context menu */
|
||||
mxEvent: React.PropTypes.object.isRequired,
|
||||
mxEvent: PropTypes.object.isRequired,
|
||||
|
||||
/* an optional EventTileOps implementation that can be used to unhide preview widgets */
|
||||
eventTileOps: React.PropTypes.object,
|
||||
eventTileOps: PropTypes.object,
|
||||
|
||||
/* an optional function to be called when the user clicks collapse thread, if not provided hide button */
|
||||
collapseReplyThread: PropTypes.func,
|
||||
|
||||
/* callback called when the menu is dismissed */
|
||||
onFinished: React.PropTypes.func,
|
||||
onFinished: PropTypes.func,
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
|
@ -182,12 +186,17 @@ module.exports = React.createClass({
|
|||
|
||||
onReplyClick: function() {
|
||||
dis.dispatch({
|
||||
action: 'quote_event',
|
||||
action: 'reply_to_event',
|
||||
event: this.props.mxEvent,
|
||||
});
|
||||
this.closeMenu();
|
||||
},
|
||||
|
||||
onCollapseReplyThreadClick: function() {
|
||||
this.props.collapseReplyThread();
|
||||
this.closeMenu();
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const eventStatus = this.props.mxEvent.status;
|
||||
let resendButton;
|
||||
|
@ -200,6 +209,7 @@ module.exports = React.createClass({
|
|||
let externalURLButton;
|
||||
let quoteButton;
|
||||
let replyButton;
|
||||
let collapseReplyThread;
|
||||
|
||||
if (eventStatus === 'not_sent') {
|
||||
resendButton = (
|
||||
|
@ -305,6 +315,13 @@ module.exports = React.createClass({
|
|||
);
|
||||
}
|
||||
|
||||
if (this.props.collapseReplyThread) {
|
||||
collapseReplyThread = (
|
||||
<div className="mx_MessageContextMenu_field" onClick={this.onCollapseReplyThreadClick}>
|
||||
{ _t('Collapse Reply Thread') }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
@ -320,6 +337,7 @@ module.exports = React.createClass({
|
|||
{ quoteButton }
|
||||
{ replyButton }
|
||||
{ externalURLButton }
|
||||
{ collapseReplyThread }
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
|
@ -22,7 +22,7 @@ import sdk from '../../../index';
|
|||
import MatrixClientPeg from '../../../MatrixClientPeg';
|
||||
import Promise from 'bluebird';
|
||||
import { addressTypes, getAddressType } from '../../../UserAddress.js';
|
||||
import GroupStoreCache from '../../../stores/GroupStoreCache';
|
||||
import GroupStore from '../../../stores/GroupStore';
|
||||
|
||||
const TRUNCATE_QUERY_LIST = 40;
|
||||
const QUERY_USER_DIRECTORY_DEBOUNCE_MS = 200;
|
||||
|
@ -243,9 +243,8 @@ module.exports = React.createClass({
|
|||
|
||||
_doNaiveGroupRoomSearch: function(query) {
|
||||
const lowerCaseQuery = query.toLowerCase();
|
||||
const groupStore = GroupStoreCache.getGroupStore(this.props.groupId);
|
||||
const results = [];
|
||||
groupStore.getGroupRooms().forEach((r) => {
|
||||
GroupStore.getGroupRooms(this.props.groupId).forEach((r) => {
|
||||
const nameMatch = (r.name || '').toLowerCase().includes(lowerCaseQuery);
|
||||
const topicMatch = (r.topic || '').toLowerCase().includes(lowerCaseQuery);
|
||||
const aliasMatch = (r.canonical_alias || '').toLowerCase().includes(lowerCaseQuery);
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -36,8 +37,18 @@ export default React.createClass({
|
|||
|
||||
propTypes: {
|
||||
// onFinished callback to call when Escape is pressed
|
||||
// Take a boolean which is true if the dialog was dismissed
|
||||
// with a positive / confirm action or false if it was
|
||||
// cancelled (BaseDialog itself only calls this with false).
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
|
||||
// Whether the dialog should have a 'close' button that will
|
||||
// cause the dialog to be cancelled. This should only be set
|
||||
// to false if there is nothing the app can sensibly do if the
|
||||
// dialog is cancelled, eg. "We can't restore your session and
|
||||
// the app cannot work". Default: true.
|
||||
hasCancel: PropTypes.bool,
|
||||
|
||||
// called when a key is pressed
|
||||
onKeyDown: PropTypes.func,
|
||||
|
||||
|
@ -56,6 +67,12 @@ export default React.createClass({
|
|||
contentId: React.PropTypes.string,
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
hasCancel: true,
|
||||
};
|
||||
},
|
||||
|
||||
childContextTypes: {
|
||||
matrixClient: PropTypes.instanceOf(MatrixClient),
|
||||
},
|
||||
|
@ -74,15 +91,15 @@ export default React.createClass({
|
|||
if (this.props.onKeyDown) {
|
||||
this.props.onKeyDown(e);
|
||||
}
|
||||
if (e.keyCode === KeyCode.ESCAPE) {
|
||||
if (this.props.hasCancel && e.keyCode === KeyCode.ESCAPE) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
this.props.onFinished();
|
||||
this.props.onFinished(false);
|
||||
}
|
||||
},
|
||||
|
||||
_onCancelClick: function(e) {
|
||||
this.props.onFinished();
|
||||
this.props.onFinished(false);
|
||||
},
|
||||
|
||||
render: function() {
|
||||
|
@ -101,11 +118,11 @@ export default React.createClass({
|
|||
// AT users can skip its presentation.
|
||||
aria-describedby={this.props.contentId}
|
||||
>
|
||||
<AccessibleButton onClick={this._onCancelClick}
|
||||
{ this.props.hasCancel ? <AccessibleButton onClick={this._onCancelClick}
|
||||
className="mx_Dialog_cancelButton"
|
||||
>
|
||||
<TintableSvg src="img/icons-close-button.svg" width="35" height="35" />
|
||||
</AccessibleButton>
|
||||
</AccessibleButton> : null }
|
||||
<div className={'mx_Dialog_title ' + this.props.titleClass} id='mx_BaseDialog_title'>
|
||||
{ this.props.title }
|
||||
</div>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2017 OpenMarket Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -105,6 +106,8 @@ export default class BugReportDialog extends React.Component {
|
|||
|
||||
render() {
|
||||
const Loader = sdk.getComponent("elements.Spinner");
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
|
||||
let error = null;
|
||||
if (this.state.err) {
|
||||
|
@ -113,13 +116,6 @@ export default class BugReportDialog extends React.Component {
|
|||
</div>;
|
||||
}
|
||||
|
||||
let cancelButton = null;
|
||||
if (!this.state.busy) {
|
||||
cancelButton = <button onClick={this._onCancel}>
|
||||
{ _t("Cancel") }
|
||||
</button>;
|
||||
}
|
||||
|
||||
let progress = null;
|
||||
if (this.state.busy) {
|
||||
progress = (
|
||||
|
@ -131,11 +127,11 @@ export default class BugReportDialog extends React.Component {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="mx_BugReportDialog">
|
||||
<div className="mx_Dialog_title">
|
||||
{ _t("Submit debug logs") }
|
||||
</div>
|
||||
<div className="mx_Dialog_content">
|
||||
<BaseDialog className="mx_BugReportDialog" onFinished={this._onCancel}
|
||||
title={_t('Submit debug logs')}
|
||||
contentId='mx_Dialog_content'
|
||||
>
|
||||
<div className="mx_Dialog_content" id='mx_Dialog_content'>
|
||||
<p>
|
||||
{ _t(
|
||||
"Debug logs contain application usage data including your " +
|
||||
|
@ -146,7 +142,7 @@ export default class BugReportDialog extends React.Component {
|
|||
</p>
|
||||
<p>
|
||||
{ _t(
|
||||
"<a>Click here</a> to create a GitHub issue.",
|
||||
"Riot bugs are tracked on GitHub: <a>create a GitHub issue</a>.",
|
||||
{},
|
||||
{
|
||||
a: (sub) => <a
|
||||
|
@ -191,19 +187,13 @@ export default class BugReportDialog extends React.Component {
|
|||
{progress}
|
||||
{error}
|
||||
</div>
|
||||
<div className="mx_Dialog_buttons">
|
||||
<button
|
||||
className="mx_Dialog_primary danger"
|
||||
onClick={this._onSubmit}
|
||||
autoFocus={true}
|
||||
disabled={this.state.busy}
|
||||
>
|
||||
{ _t("Send logs") }
|
||||
</button>
|
||||
|
||||
{cancelButton}
|
||||
</div>
|
||||
</div>
|
||||
<DialogButtons primaryButton={_t("Send logs")}
|
||||
onPrimaryButtonClick={this._onSubmit}
|
||||
focus={true}
|
||||
onCancel={this._onCancel}
|
||||
disabled={this.state.busy}
|
||||
/>
|
||||
</BaseDialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -30,59 +31,79 @@ export default React.createClass({
|
|||
onFinished: PropTypes.func.isRequired,
|
||||
},
|
||||
|
||||
componentDidMount: function() {
|
||||
if (this.refs.bugreportLink) {
|
||||
this.refs.bugreportLink.focus();
|
||||
}
|
||||
},
|
||||
|
||||
_sendBugReport: function() {
|
||||
const BugReportDialog = sdk.getComponent("dialogs.BugReportDialog");
|
||||
Modal.createTrackedDialog('Session Restore Error', 'Send Bug Report Dialog', BugReportDialog, {});
|
||||
},
|
||||
|
||||
_continueClicked: function() {
|
||||
this.props.onFinished(true);
|
||||
_onClearStorageClick: function() {
|
||||
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
|
||||
Modal.createTrackedDialog('Session Restore Confirm Logout', '', QuestionDialog, {
|
||||
title: _t("Sign out"),
|
||||
description:
|
||||
<div>{ _t("Log out and remove encryption keys?") }</div>,
|
||||
button: _t("Sign out"),
|
||||
danger: true,
|
||||
onFinished: this.props.onFinished,
|
||||
});
|
||||
},
|
||||
|
||||
_onRefreshClick: function() {
|
||||
// Is this likely to help? Probably not, but giving only one button
|
||||
// that clears your storage seems awful.
|
||||
window.location.reload(true);
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
||||
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
|
||||
let bugreport;
|
||||
|
||||
const clearStorageButton = (
|
||||
<button onClick={this._onClearStorageClick} className="danger">
|
||||
{ _t("Clear Storage and Sign Out") }
|
||||
</button>
|
||||
);
|
||||
|
||||
let dialogButtons;
|
||||
if (SdkConfig.get().bug_report_endpoint_url) {
|
||||
bugreport = (
|
||||
<p>
|
||||
{ _t(
|
||||
"Otherwise, <a>click here</a> to send a bug report.",
|
||||
{},
|
||||
{ 'a': (sub) => <a ref="bugreportLink" onClick={this._sendBugReport}
|
||||
key="bugreport" href='#'>{ sub }</a> },
|
||||
) }
|
||||
</p>
|
||||
);
|
||||
dialogButtons = <DialogButtons primaryButton={_t("Send Logs")}
|
||||
onPrimaryButtonClick={this._sendBugReport}
|
||||
focus={true}
|
||||
hasCancel={false}
|
||||
>
|
||||
{ clearStorageButton }
|
||||
</DialogButtons>;
|
||||
} else {
|
||||
dialogButtons = <DialogButtons primaryButton={_t("Refresh")}
|
||||
onPrimaryButtonClick={this._onRefreshClick}
|
||||
focus={true}
|
||||
hasCancel={false}
|
||||
>
|
||||
{ clearStorageButton }
|
||||
</DialogButtons>;
|
||||
}
|
||||
const shouldFocusContinueButton =!(bugreport==true);
|
||||
|
||||
return (
|
||||
<BaseDialog className="mx_ErrorDialog" onFinished={this.props.onFinished}
|
||||
title={_t('Unable to restore session')}
|
||||
title={_t('Unable to restore session')}
|
||||
contentId='mx_Dialog_content'
|
||||
hasCancel={false}
|
||||
>
|
||||
<div className="mx_Dialog_content" id='mx_Dialog_content'>
|
||||
<p>{ _t("We encountered an error trying to restore your previous session. If " +
|
||||
"you continue, you will need to log in again, and encrypted chat " +
|
||||
"history will be unreadable.") }</p>
|
||||
<p>{ _t("We encountered an error trying to restore your previous session.") }</p>
|
||||
|
||||
<p>{ _t("If you have previously used a more recent version of Riot, your session " +
|
||||
"may be incompatible with this version. Close this window and return " +
|
||||
"to the more recent version.") }</p>
|
||||
<p>{ _t(
|
||||
"If you have previously used a more recent version of Riot, your session " +
|
||||
"may be incompatible with this version. Close this window and return " +
|
||||
"to the more recent version.",
|
||||
) }</p>
|
||||
|
||||
{ bugreport }
|
||||
<p>{ _t(
|
||||
"Clearing your browser's storage may fix the problem, but will sign you " +
|
||||
"out and cause any encrypted chat history to become unreadable.",
|
||||
) }</p>
|
||||
</div>
|
||||
<DialogButtons primaryButton={_t("Continue anyway")}
|
||||
onPrimaryButtonClick={this._continueClicked} focus={shouldFocusContinueButton}
|
||||
onCancel={this.props.onFinished} />
|
||||
{ dialogButtons }
|
||||
</BaseDialog>
|
||||
);
|
||||
},
|
||||
|
|
|
@ -54,6 +54,7 @@ export default class AppTile extends React.Component {
|
|||
this._onInitialLoad = this._onInitialLoad.bind(this);
|
||||
this._grantWidgetPermission = this._grantWidgetPermission.bind(this);
|
||||
this._revokeWidgetPermission = this._revokeWidgetPermission.bind(this);
|
||||
this._onPopoutWidgetClick = this._onPopoutWidgetClick.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -499,6 +500,13 @@ export default class AppTile extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
_onPopoutWidgetClick(e) {
|
||||
// Using Object.assign workaround as the following opens in a new window instead of a new tab.
|
||||
// window.open(this._getSafeUrl(), '_blank', 'noopener=yes,noreferrer=yes');
|
||||
Object.assign(document.createElement('a'),
|
||||
{ target: '_blank', href: this._getSafeUrl(), rel: 'noopener noreferrer'}).click();
|
||||
}
|
||||
|
||||
render() {
|
||||
let appTileBody;
|
||||
|
||||
|
@ -581,6 +589,7 @@ export default class AppTile extends React.Component {
|
|||
// Picture snapshot - only show button when apps are maximised.
|
||||
const showPictureSnapshotButton = this._hasCapability('screenshot') && this.props.show;
|
||||
const showPictureSnapshotIcon = 'img/camera_green.svg';
|
||||
const popoutWidgetIcon = 'img/button-new-window.svg';
|
||||
const windowStateIcon = (this.props.show ? 'img/minimize.svg' : 'img/maximize.svg');
|
||||
|
||||
return (
|
||||
|
@ -599,15 +608,25 @@ export default class AppTile extends React.Component {
|
|||
{ this.props.showTitle && this._getTileTitle() }
|
||||
</span>
|
||||
<span className="mx_AppTileMenuBarWidgets">
|
||||
{ /* Snapshot widget */ }
|
||||
{ showPictureSnapshotButton && <TintableSvgButton
|
||||
src={showPictureSnapshotIcon}
|
||||
className="mx_AppTileMenuBarWidget mx_AppTileMenuBarWidgetPadding"
|
||||
title={_t('Picture')}
|
||||
onClick={this._onSnapshotClick}
|
||||
width="10"
|
||||
height="10"
|
||||
/> }
|
||||
{ /* Popout widget */ }
|
||||
{ this.props.showPopout && <TintableSvgButton
|
||||
src={popoutWidgetIcon}
|
||||
className="mx_AppTileMenuBarWidget mx_AppTileMenuBarWidgetPadding"
|
||||
title={_t('Popout widget')}
|
||||
onClick={this._onPopoutWidgetClick}
|
||||
width="10"
|
||||
height="10"
|
||||
/> }
|
||||
|
||||
{ /* Snapshot widget */ }
|
||||
{ showPictureSnapshotButton && <TintableSvgButton
|
||||
src={showPictureSnapshotIcon}
|
||||
className="mx_AppTileMenuBarWidget mx_AppTileMenuBarWidgetPadding"
|
||||
title={_t('Picture')}
|
||||
onClick={this._onSnapshotClick}
|
||||
width="10"
|
||||
height="10"
|
||||
/> }
|
||||
|
||||
{ /* Edit widget */ }
|
||||
{ showEditButton && <TintableSvgButton
|
||||
|
@ -670,6 +689,8 @@ AppTile.propTypes = {
|
|||
handleMinimisePointerEvents: PropTypes.bool,
|
||||
// Optionally hide the delete icon
|
||||
showDelete: PropTypes.bool,
|
||||
// Optionally hide the popout widget icon
|
||||
showPopout: PropTypes.bool,
|
||||
// Widget apabilities to allow by default (without user confirmation)
|
||||
// NOTE -- Use with caution. This is intended to aid better integration / UX
|
||||
// basic widget capabilities, e.g. injecting sticker message events.
|
||||
|
@ -686,6 +707,7 @@ AppTile.defaultProps = {
|
|||
showTitle: true,
|
||||
showMinimise: true,
|
||||
showDelete: true,
|
||||
showPopout: true,
|
||||
handleMinimisePointerEvents: false,
|
||||
whitelistCapabilities: [],
|
||||
};
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2017 Aidan Gauland
|
||||
Copyright 2018 New Vector Ltd.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -14,8 +15,6 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
import React from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { _t } from '../../../languageHandler';
|
||||
|
@ -33,10 +32,26 @@ module.exports = React.createClass({
|
|||
// onClick handler for the primary button.
|
||||
onPrimaryButtonClick: PropTypes.func.isRequired,
|
||||
|
||||
// should there be a cancel button? default: true
|
||||
hasCancel: PropTypes.bool,
|
||||
|
||||
// onClick handler for the cancel button.
|
||||
onCancel: PropTypes.func.isRequired,
|
||||
onCancel: PropTypes.func,
|
||||
|
||||
focus: PropTypes.bool,
|
||||
|
||||
disabled: PropTypes.bool,
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
hasCancel: true,
|
||||
disabled: false,
|
||||
};
|
||||
},
|
||||
|
||||
_onCancelClick: function() {
|
||||
this.props.onCancel();
|
||||
},
|
||||
|
||||
render: function() {
|
||||
|
@ -44,18 +59,23 @@ module.exports = React.createClass({
|
|||
if (this.props.primaryButtonClass) {
|
||||
primaryButtonClassName += " " + this.props.primaryButtonClass;
|
||||
}
|
||||
let cancelButton;
|
||||
if (this.props.hasCancel) {
|
||||
cancelButton = <button onClick={this._onCancelClick} disabled={this.props.disabled}>
|
||||
{ _t("Cancel") }
|
||||
</button>;
|
||||
}
|
||||
return (
|
||||
<div className="mx_Dialog_buttons">
|
||||
<button className={primaryButtonClassName}
|
||||
onClick={this.props.onPrimaryButtonClick}
|
||||
autoFocus={this.props.focus}
|
||||
disabled={this.props.disabled}
|
||||
>
|
||||
{ this.props.primaryButton }
|
||||
</button>
|
||||
{ this.props.children }
|
||||
<button onClick={this.props.onCancel}>
|
||||
{ _t("Cancel") }
|
||||
</button>
|
||||
{ cancelButton }
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
|
@ -1,188 +0,0 @@
|
|||
/*
|
||||
Copyright 2017 New Vector Ltd
|
||||
|
||||
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 sdk from '../../../index';
|
||||
import {_t} from '../../../languageHandler';
|
||||
import PropTypes from 'prop-types';
|
||||
import MatrixClientPeg from '../../../MatrixClientPeg';
|
||||
import {wantsDateSeparator} from '../../../DateUtils';
|
||||
import {MatrixEvent} from 'matrix-js-sdk';
|
||||
import {makeUserPermalink} from "../../../matrix-to";
|
||||
|
||||
// For URLs of matrix.to links in the timeline which have been reformatted by
|
||||
// HttpUtils transformTags to relative links. This excludes event URLs (with `[^\/]*`)
|
||||
const REGEX_LOCAL_MATRIXTO = /^#\/room\/([\#\!][^\/]*)\/(\$[^\/]*)$/;
|
||||
|
||||
export default class Quote extends React.Component {
|
||||
static isMessageUrl(url) {
|
||||
return !!REGEX_LOCAL_MATRIXTO.exec(url);
|
||||
}
|
||||
|
||||
static childContextTypes = {
|
||||
matrixClient: PropTypes.object,
|
||||
addRichQuote: PropTypes.func,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
// The matrix.to url of the event
|
||||
url: PropTypes.string,
|
||||
// The original node that was rendered
|
||||
node: PropTypes.instanceOf(Element),
|
||||
// The parent event
|
||||
parentEv: PropTypes.instanceOf(MatrixEvent),
|
||||
};
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
// The event related to this quote and their nested rich quotes
|
||||
events: [],
|
||||
// Whether the top (oldest) event should be shown or spoilered
|
||||
show: true,
|
||||
// Whether an error was encountered fetching nested older event, show node if it does
|
||||
err: false,
|
||||
};
|
||||
|
||||
this.onQuoteClick = this.onQuoteClick.bind(this);
|
||||
this.addRichQuote = this.addRichQuote.bind(this);
|
||||
}
|
||||
|
||||
getChildContext() {
|
||||
return {
|
||||
matrixClient: MatrixClientPeg.get(),
|
||||
addRichQuote: this.addRichQuote,
|
||||
};
|
||||
}
|
||||
|
||||
parseUrl(url) {
|
||||
if (!url) return;
|
||||
|
||||
// Default to the empty array if no match for simplicity
|
||||
// resource and prefix will be undefined instead of throwing
|
||||
const matrixToMatch = REGEX_LOCAL_MATRIXTO.exec(url) || [];
|
||||
|
||||
const [, roomIdentifier, eventId] = matrixToMatch;
|
||||
return {roomIdentifier, eventId};
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
const {roomIdentifier, eventId} = this.parseUrl(nextProps.url);
|
||||
if (!roomIdentifier || !eventId) return;
|
||||
|
||||
const room = this.getRoom(roomIdentifier);
|
||||
if (!room) return;
|
||||
|
||||
// Only try and load the event if we know about the room
|
||||
// otherwise we just leave a `Quote` anchor which can be used to navigate/join the room manually.
|
||||
this.setState({ events: [] });
|
||||
if (room) this.getEvent(room, eventId, true);
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this.componentWillReceiveProps(this.props);
|
||||
}
|
||||
|
||||
getRoom(id) {
|
||||
const cli = MatrixClientPeg.get();
|
||||
if (id[0] === '!') return cli.getRoom(id);
|
||||
|
||||
return cli.getRooms().find((r) => {
|
||||
return r.getAliases().includes(id);
|
||||
});
|
||||
}
|
||||
|
||||
async getEvent(room, eventId, show) {
|
||||
const event = room.findEventById(eventId);
|
||||
if (event) {
|
||||
this.addEvent(event, show);
|
||||
return;
|
||||
}
|
||||
|
||||
await MatrixClientPeg.get().getEventTimeline(room.getUnfilteredTimelineSet(), eventId);
|
||||
this.addEvent(room.findEventById(eventId), show);
|
||||
}
|
||||
|
||||
addEvent(event, show) {
|
||||
const events = [event].concat(this.state.events);
|
||||
this.setState({events, show});
|
||||
}
|
||||
|
||||
// addRichQuote(roomId, eventId) {
|
||||
addRichQuote(href) {
|
||||
const {roomIdentifier, eventId} = this.parseUrl(href);
|
||||
if (!roomIdentifier || !eventId) {
|
||||
this.setState({ err: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const room = this.getRoom(roomIdentifier);
|
||||
if (!room) {
|
||||
this.setState({ err: true });
|
||||
return;
|
||||
}
|
||||
|
||||
this.getEvent(room, eventId, false);
|
||||
}
|
||||
|
||||
onQuoteClick() {
|
||||
this.setState({ show: true });
|
||||
}
|
||||
|
||||
render() {
|
||||
const events = this.state.events.slice();
|
||||
if (events.length) {
|
||||
const evTiles = [];
|
||||
|
||||
if (!this.state.show) {
|
||||
const oldestEv = events.shift();
|
||||
const Pill = sdk.getComponent('elements.Pill');
|
||||
const room = MatrixClientPeg.get().getRoom(oldestEv.getRoomId());
|
||||
|
||||
evTiles.push(<blockquote className="mx_Quote" key="load">
|
||||
{
|
||||
_t('<a>In reply to</a> <pill>', {}, {
|
||||
'a': (sub) => <a onClick={this.onQuoteClick} className="mx_Quote_show">{ sub }</a>,
|
||||
'pill': <Pill type={Pill.TYPE_USER_MENTION} room={room}
|
||||
url={makeUserPermalink(oldestEv.getSender())} shouldShowPillAvatar={true} />,
|
||||
})
|
||||
}
|
||||
</blockquote>);
|
||||
}
|
||||
|
||||
const EventTile = sdk.getComponent('views.rooms.EventTile');
|
||||
const DateSeparator = sdk.getComponent('messages.DateSeparator');
|
||||
events.forEach((ev) => {
|
||||
let dateSep = null;
|
||||
|
||||
if (wantsDateSeparator(this.props.parentEv.getDate(), ev.getDate())) {
|
||||
dateSep = <a href={this.props.url}><DateSeparator ts={ev.getTs()} /></a>;
|
||||
}
|
||||
|
||||
evTiles.push(<blockquote className="mx_Quote" key={ev.getId()}>
|
||||
{ dateSep }
|
||||
<EventTile mxEvent={ev} tileShape="quote" />
|
||||
</blockquote>);
|
||||
});
|
||||
|
||||
return <div>{ evTiles }</div>;
|
||||
}
|
||||
|
||||
// Deliberately render nothing if the URL isn't recognised
|
||||
// in case we get an undefined/falsey node, replace it with null to make React happy
|
||||
return this.props.node || null;
|
||||
}
|
||||
}
|
306
src/components/views/elements/ReplyThread.js
Normal file
306
src/components/views/elements/ReplyThread.js
Normal file
|
@ -0,0 +1,306 @@
|
|||
/*
|
||||
Copyright 2017 New Vector Ltd
|
||||
|
||||
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 sdk from '../../../index';
|
||||
import {_t} from '../../../languageHandler';
|
||||
import PropTypes from 'prop-types';
|
||||
import dis from '../../../dispatcher';
|
||||
import {wantsDateSeparator} from '../../../DateUtils';
|
||||
import {MatrixEvent, MatrixClient} from 'matrix-js-sdk';
|
||||
import {makeEventPermalink, makeUserPermalink} from "../../../matrix-to";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
|
||||
// This component does no cycle detection, simply because the only way to make such a cycle would be to
|
||||
// craft event_id's, using a homeserver that generates predictable event IDs; even then the impact would
|
||||
// be low as each event being loaded (after the first) is triggered by an explicit user action.
|
||||
export default class ReplyThread extends React.Component {
|
||||
static propTypes = {
|
||||
// the latest event in this chain of replies
|
||||
parentEv: PropTypes.instanceOf(MatrixEvent),
|
||||
// called when the ReplyThread contents has changed, including EventTiles thereof
|
||||
onWidgetLoad: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
static contextTypes = {
|
||||
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
|
||||
};
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
// The loaded events to be rendered as linear-replies
|
||||
events: [],
|
||||
|
||||
// The latest loaded event which has not yet been shown
|
||||
loadedEv: null,
|
||||
// Whether the component is still loading more events
|
||||
loading: true,
|
||||
|
||||
// Whether as error was encountered fetching a replied to event.
|
||||
err: false,
|
||||
};
|
||||
|
||||
this.onQuoteClick = this.onQuoteClick.bind(this);
|
||||
this.canCollapse = this.canCollapse.bind(this);
|
||||
this.collapse = this.collapse.bind(this);
|
||||
}
|
||||
|
||||
static getParentEventId(ev) {
|
||||
if (!ev || ev.isRedacted()) return;
|
||||
|
||||
const mRelatesTo = ev.getWireContent()['m.relates_to'];
|
||||
if (mRelatesTo && mRelatesTo['m.in_reply_to']) {
|
||||
const mInReplyTo = mRelatesTo['m.in_reply_to'];
|
||||
if (mInReplyTo && mInReplyTo['event_id']) return mInReplyTo['event_id'];
|
||||
}
|
||||
}
|
||||
|
||||
// Part of Replies fallback support
|
||||
static stripPlainReply(body) {
|
||||
// Removes lines beginning with `> ` until you reach one that doesn't.
|
||||
const lines = body.split('\n');
|
||||
while (lines.length && lines[0].startsWith('> ')) lines.shift();
|
||||
// Reply fallback has a blank line after it, so remove it to prevent leading newline
|
||||
if (lines[0] === '') lines.shift();
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Part of Replies fallback support
|
||||
static stripHTMLReply(html) {
|
||||
return html.replace(/^<blockquote data-mx-reply>[\s\S]+?<!--end-mx-reply--><\/blockquote>/, '');
|
||||
}
|
||||
|
||||
// Part of Replies fallback support
|
||||
static getNestedReplyText(ev) {
|
||||
if (!ev) return null;
|
||||
|
||||
let {body, formatted_body: html} = ev.getContent();
|
||||
if (this.getParentEventId(ev)) {
|
||||
if (body) body = this.stripPlainReply(body);
|
||||
if (html) html = this.stripHTMLReply(html);
|
||||
}
|
||||
|
||||
const evLink = makeEventPermalink(ev.getRoomId(), ev.getId());
|
||||
const userLink = makeUserPermalink(ev.getSender());
|
||||
const mxid = ev.getSender();
|
||||
|
||||
// This fallback contains text that is explicitly EN.
|
||||
switch (ev.getContent().msgtype) {
|
||||
case 'm.text':
|
||||
case 'm.notice': {
|
||||
html = `<blockquote data-mx-reply><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
|
||||
+ `<br>${html || body}<!--end-mx-reply--></blockquote>`;
|
||||
const lines = body.trim().split('\n');
|
||||
if (lines.length > 0) {
|
||||
lines[0] = `<${mxid}> ${lines[0]}`;
|
||||
body = lines.map((line) => `> ${line}`).join('\n') + '\n\n';
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'm.image':
|
||||
html = `<blockquote data-mx-reply><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
|
||||
+ `<br>sent an image.<!--end-mx-reply--></blockquote>`;
|
||||
body = `> <${mxid}> sent an image.\n\n`;
|
||||
break;
|
||||
case 'm.video':
|
||||
html = `<blockquote data-mx-reply><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
|
||||
+ `<br>sent a video.<!--end-mx-reply--></blockquote>`;
|
||||
body = `> <${mxid}> sent a video.\n\n`;
|
||||
break;
|
||||
case 'm.audio':
|
||||
html = `<blockquote data-mx-reply><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
|
||||
+ `<br>sent an audio file.<!--end-mx-reply--></blockquote>`;
|
||||
body = `> <${mxid}> sent an audio file.\n\n`;
|
||||
break;
|
||||
case 'm.file':
|
||||
html = `<blockquote data-mx-reply><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
|
||||
+ `<br>sent a file.<!--end-mx-reply--></blockquote>`;
|
||||
body = `> <${mxid}> sent a file.\n\n`;
|
||||
break;
|
||||
case 'm.emote': {
|
||||
html = `<blockquote data-mx-reply><a href="${evLink}">In reply to</a> * `
|
||||
+ `<a href="${userLink}">${mxid}</a><br>${html || body}<!--end-mx-reply--></blockquote>`;
|
||||
const lines = body.trim().split('\n');
|
||||
if (lines.length > 0) {
|
||||
lines[0] = `* <${mxid}> ${lines[0]}`;
|
||||
body = lines.map((line) => `> ${line}`).join('\n') + '\n\n';
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
return {body, html};
|
||||
}
|
||||
|
||||
static makeReplyMixIn(ev) {
|
||||
if (!ev) return {};
|
||||
return {
|
||||
'm.relates_to': {
|
||||
'm.in_reply_to': {
|
||||
'event_id': ev.getId(),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static makeThread(parentEv, onWidgetLoad, ref) {
|
||||
if (!SettingsStore.isFeatureEnabled("feature_rich_quoting") || !ReplyThread.getParentEventId(parentEv)) {
|
||||
return <div />;
|
||||
}
|
||||
return <ReplyThread parentEv={parentEv} onWidgetLoad={onWidgetLoad} ref={ref} />;
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this.unmounted = false;
|
||||
this.room = this.context.matrixClient.getRoom(this.props.parentEv.getRoomId());
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.props.onWidgetLoad();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unmounted = true;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
const {parentEv} = this.props;
|
||||
// at time of making this component we checked that props.parentEv has a parentEventId
|
||||
const ev = await this.getEvent(ReplyThread.getParentEventId(parentEv));
|
||||
if (this.unmounted) return;
|
||||
|
||||
if (ev) {
|
||||
this.setState({
|
||||
events: [ev],
|
||||
}, this.loadNextEvent);
|
||||
} else {
|
||||
this.setState({err: true});
|
||||
}
|
||||
}
|
||||
|
||||
async loadNextEvent() {
|
||||
if (this.unmounted) return;
|
||||
const ev = this.state.events[0];
|
||||
const inReplyToEventId = ReplyThread.getParentEventId(ev);
|
||||
|
||||
if (!inReplyToEventId) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const loadedEv = await this.getEvent(inReplyToEventId);
|
||||
if (this.unmounted) return;
|
||||
|
||||
if (loadedEv) {
|
||||
this.setState({loadedEv});
|
||||
} else {
|
||||
this.setState({err: true});
|
||||
}
|
||||
}
|
||||
|
||||
async getEvent(eventId) {
|
||||
const event = this.room.findEventById(eventId);
|
||||
if (event) return event;
|
||||
|
||||
try {
|
||||
// ask the client to fetch the event we want using the context API, only interface to do so is to ask
|
||||
// for a timeline with that event, but once it is loaded we can use findEventById to look up the ev map
|
||||
await this.context.matrixClient.getEventTimeline(this.room.getUnfilteredTimelineSet(), eventId);
|
||||
} catch (e) {
|
||||
// if it fails catch the error and return early, there's no point trying to find the event in this case.
|
||||
// Return null as it is falsey and thus should be treated as an error (as the event cannot be resolved).
|
||||
return null;
|
||||
}
|
||||
return this.room.findEventById(eventId);
|
||||
}
|
||||
|
||||
canCollapse() {
|
||||
return this.state.events.length > 1;
|
||||
}
|
||||
|
||||
collapse() {
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
onQuoteClick() {
|
||||
const events = [this.state.loadedEv, ...this.state.events];
|
||||
|
||||
this.setState({
|
||||
loadedEv: null,
|
||||
events,
|
||||
}, this.loadNextEvent);
|
||||
|
||||
dis.dispatch({action: 'focus_composer'});
|
||||
}
|
||||
|
||||
render() {
|
||||
let header = null;
|
||||
|
||||
if (this.state.err) {
|
||||
header = <blockquote className="mx_ReplyThread mx_ReplyThread_error">
|
||||
{
|
||||
_t('Unable to load event that was replied to, ' +
|
||||
'it either does not exist or you do not have permission to view it.')
|
||||
}
|
||||
</blockquote>;
|
||||
} else if (this.state.loadedEv) {
|
||||
const ev = this.state.loadedEv;
|
||||
const Pill = sdk.getComponent('elements.Pill');
|
||||
const room = this.context.matrixClient.getRoom(ev.getRoomId());
|
||||
header = <blockquote className="mx_ReplyThread">
|
||||
{
|
||||
_t('<a>In reply to</a> <pill>', {}, {
|
||||
'a': (sub) => <a onClick={this.onQuoteClick} className="mx_ReplyThread_show">{ sub }</a>,
|
||||
'pill': <Pill type={Pill.TYPE_USER_MENTION} room={room}
|
||||
url={makeUserPermalink(ev.getSender())} shouldShowPillAvatar={true} />,
|
||||
})
|
||||
}
|
||||
</blockquote>;
|
||||
} else if (this.state.loading) {
|
||||
const Spinner = sdk.getComponent("elements.Spinner");
|
||||
header = <Spinner w={16} h={16} />;
|
||||
}
|
||||
|
||||
const EventTile = sdk.getComponent('views.rooms.EventTile');
|
||||
const DateSeparator = sdk.getComponent('messages.DateSeparator');
|
||||
const evTiles = this.state.events.map((ev) => {
|
||||
let dateSep = null;
|
||||
|
||||
if (wantsDateSeparator(this.props.parentEv.getDate(), ev.getDate())) {
|
||||
dateSep = <a href={this.props.url}><DateSeparator ts={ev.getTs()} /></a>;
|
||||
}
|
||||
|
||||
return <blockquote className="mx_ReplyThread" key={ev.getId()}>
|
||||
{ dateSep }
|
||||
<EventTile mxEvent={ev}
|
||||
tileShape="reply"
|
||||
onWidgetLoad={this.props.onWidgetLoad}
|
||||
isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")} />
|
||||
</blockquote>;
|
||||
});
|
||||
|
||||
return <div>
|
||||
<div>{ header }</div>
|
||||
<div>{ evTiles }</div>
|
||||
</div>;
|
||||
}
|
||||
}
|
|
@ -24,6 +24,7 @@ import { isOnlyCtrlOrCmdIgnoreShiftKeyEvent } from '../../../Keyboard';
|
|||
import ContextualMenu from '../../structures/ContextualMenu';
|
||||
|
||||
import FlairStore from '../../../stores/FlairStore';
|
||||
import GroupStore from '../../../stores/GroupStore';
|
||||
|
||||
// A class for a child of TagPanel (possibly wrapped in a DNDTagTile) that represents
|
||||
// a thing to click on for the user to filter the visible rooms in the RoomList to:
|
||||
|
@ -57,6 +58,8 @@ export default React.createClass({
|
|||
if (this.props.tag[0] === '+') {
|
||||
FlairStore.addListener('updateGroupProfile', this._onFlairStoreUpdated);
|
||||
this._onFlairStoreUpdated();
|
||||
// New rooms or members may have been added to the group, fetch async
|
||||
this._refreshGroup(this.props.tag);
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -80,6 +83,11 @@ export default React.createClass({
|
|||
});
|
||||
},
|
||||
|
||||
_refreshGroup(groupId) {
|
||||
GroupStore.refreshGroupRooms(groupId);
|
||||
GroupStore.refreshGroupMembers(groupId);
|
||||
},
|
||||
|
||||
onClick: function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
@ -89,6 +97,10 @@ export default React.createClass({
|
|||
ctrlOrCmdKey: isOnlyCtrlOrCmdIgnoreShiftKeyEvent(e),
|
||||
shiftKey: e.shiftKey,
|
||||
});
|
||||
if (this.props.tag[0] === '+') {
|
||||
// New rooms or members may have been added to the group, fetch async
|
||||
this._refreshGroup(this.props.tag);
|
||||
}
|
||||
},
|
||||
|
||||
onContextButtonClick: function(e) {
|
||||
|
|
|
@ -17,6 +17,7 @@ limitations under the License.
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import TintableSvg from './TintableSvg';
|
||||
import AccessibleButton from './AccessibleButton';
|
||||
|
||||
export default class TintableSvgButton extends React.Component {
|
||||
|
||||
|
@ -39,9 +40,11 @@ export default class TintableSvgButton extends React.Component {
|
|||
width={this.props.width}
|
||||
height={this.props.height}
|
||||
></TintableSvg>
|
||||
<span
|
||||
<AccessibleButton
|
||||
onClick={this.props.onClick}
|
||||
element='span'
|
||||
title={this.props.title}
|
||||
onClick={this.props.onClick} />
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -23,7 +23,7 @@ import Modal from '../../../Modal';
|
|||
import sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { GroupMemberType } from '../../../groups';
|
||||
import GroupStoreCache from '../../../stores/GroupStoreCache';
|
||||
import GroupStore from '../../../stores/GroupStore';
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
|
||||
module.exports = React.createClass({
|
||||
|
@ -47,33 +47,37 @@ module.exports = React.createClass({
|
|||
},
|
||||
|
||||
componentWillMount: function() {
|
||||
this._unmounted = false;
|
||||
this._initGroupStore(this.props.groupId);
|
||||
},
|
||||
|
||||
componentWillReceiveProps(newProps) {
|
||||
if (newProps.groupId !== this.props.groupId) {
|
||||
this._unregisterGroupStore();
|
||||
this._unregisterGroupStore(this.props.groupId);
|
||||
this._initGroupStore(newProps.groupId);
|
||||
}
|
||||
},
|
||||
|
||||
_initGroupStore(groupId) {
|
||||
this._groupStore = GroupStoreCache.getGroupStore(this.props.groupId);
|
||||
this._groupStore.registerListener(this.onGroupStoreUpdated);
|
||||
componentWillUnmount() {
|
||||
this._unmounted = true;
|
||||
this._unregisterGroupStore(this.props.groupId);
|
||||
},
|
||||
|
||||
_unregisterGroupStore() {
|
||||
if (this._groupStore) {
|
||||
this._groupStore.unregisterListener(this.onGroupStoreUpdated);
|
||||
}
|
||||
_initGroupStore(groupId) {
|
||||
GroupStore.registerListener(groupId, this.onGroupStoreUpdated);
|
||||
},
|
||||
|
||||
_unregisterGroupStore(groupId) {
|
||||
GroupStore.unregisterListener(this.onGroupStoreUpdated);
|
||||
},
|
||||
|
||||
onGroupStoreUpdated: function() {
|
||||
if (this._unmounted) return;
|
||||
this.setState({
|
||||
isUserInvited: this._groupStore.getGroupInvitedMembers().some(
|
||||
isUserInvited: GroupStore.getGroupInvitedMembers(this.props.groupId).some(
|
||||
(m) => m.userId === this.props.groupMember.userId,
|
||||
),
|
||||
isUserPrivilegedInGroup: this._groupStore.isUserPrivileged(),
|
||||
isUserPrivilegedInGroup: GroupStore.isUserPrivileged(this.props.groupId),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
@ -17,7 +17,7 @@ limitations under the License.
|
|||
import React from 'react';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import sdk from '../../../index';
|
||||
import GroupStoreCache from '../../../stores/GroupStoreCache';
|
||||
import GroupStore from '../../../stores/GroupStore';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const INITIAL_LOAD_NUM_MEMBERS = 30;
|
||||
|
@ -42,9 +42,12 @@ export default React.createClass({
|
|||
this._initGroupStore(this.props.groupId);
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
this._unmounted = true;
|
||||
},
|
||||
|
||||
_initGroupStore: function(groupId) {
|
||||
this._groupStore = GroupStoreCache.getGroupStore(groupId);
|
||||
this._groupStore.registerListener(() => {
|
||||
GroupStore.registerListener(groupId, () => {
|
||||
this._fetchMembers();
|
||||
});
|
||||
},
|
||||
|
@ -52,8 +55,8 @@ export default React.createClass({
|
|||
_fetchMembers: function() {
|
||||
if (this._unmounted) return;
|
||||
this.setState({
|
||||
members: this._groupStore.getGroupMembers(),
|
||||
invitedMembers: this._groupStore.getGroupInvitedMembers(),
|
||||
members: GroupStore.getGroupMembers(this.props.groupId),
|
||||
invitedMembers: GroupStore.getGroupInvitedMembers(this.props.groupId),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
@ -17,7 +17,6 @@ limitations under the License.
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import sdk from '../../../index';
|
||||
import GroupStoreCache from '../../../stores/GroupStoreCache';
|
||||
import GroupStore from '../../../stores/GroupStore';
|
||||
import { _t } from '../../../languageHandler.js';
|
||||
|
||||
|
@ -41,15 +40,18 @@ export default React.createClass({
|
|||
},
|
||||
|
||||
_initGroupStore: function(groupId) {
|
||||
this._groupStore = GroupStoreCache.getGroupStore(groupId);
|
||||
this._groupStore.registerListener(() => {
|
||||
this._groupStoreToken = GroupStore.registerListener(groupId, () => {
|
||||
this.setState({
|
||||
isGroupPublicised: this._groupStore.getGroupPublicity(),
|
||||
ready: this._groupStore.isStateReady(GroupStore.STATE_KEY.Summary),
|
||||
isGroupPublicised: GroupStore.getGroupPublicity(groupId),
|
||||
ready: GroupStore.isStateReady(groupId, GroupStore.STATE_KEY.Summary),
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this._groupStoreToken) this._groupStoreToken.unregister();
|
||||
},
|
||||
|
||||
_onPublicityToggle: function(e) {
|
||||
e.stopPropagation();
|
||||
this.setState({
|
||||
|
@ -57,7 +59,7 @@ export default React.createClass({
|
|||
// Optimistic early update
|
||||
isGroupPublicised: !this.state.isGroupPublicised,
|
||||
});
|
||||
this._groupStore.setGroupPublicity(!this.state.isGroupPublicised).then(() => {
|
||||
GroupStore.setGroupPublicity(this.props.groupId, !this.state.isGroupPublicised).then(() => {
|
||||
this.setState({
|
||||
busy: false,
|
||||
});
|
||||
|
|
|
@ -21,7 +21,7 @@ import dis from '../../../dispatcher';
|
|||
import Modal from '../../../Modal';
|
||||
import sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import GroupStoreCache from '../../../stores/GroupStoreCache';
|
||||
import GroupStore from '../../../stores/GroupStore';
|
||||
|
||||
module.exports = React.createClass({
|
||||
displayName: 'GroupRoomInfo',
|
||||
|
@ -50,29 +50,26 @@ module.exports = React.createClass({
|
|||
|
||||
componentWillReceiveProps(newProps) {
|
||||
if (newProps.groupId !== this.props.groupId) {
|
||||
this._unregisterGroupStore();
|
||||
this._unregisterGroupStore(this.props.groupId);
|
||||
this._initGroupStore(newProps.groupId);
|
||||
}
|
||||
},
|
||||
|
||||
componentWillUnmount() {
|
||||
this._unregisterGroupStore();
|
||||
this._unregisterGroupStore(this.props.groupId);
|
||||
},
|
||||
|
||||
_initGroupStore(groupId) {
|
||||
this._groupStore = GroupStoreCache.getGroupStore(this.props.groupId);
|
||||
this._groupStore.registerListener(this.onGroupStoreUpdated);
|
||||
GroupStore.registerListener(groupId, this.onGroupStoreUpdated);
|
||||
},
|
||||
|
||||
_unregisterGroupStore() {
|
||||
if (this._groupStore) {
|
||||
this._groupStore.unregisterListener(this.onGroupStoreUpdated);
|
||||
}
|
||||
_unregisterGroupStore(groupId) {
|
||||
GroupStore.unregisterListener(this.onGroupStoreUpdated);
|
||||
},
|
||||
|
||||
_updateGroupRoom() {
|
||||
this.setState({
|
||||
groupRoom: this._groupStore.getGroupRooms().find(
|
||||
groupRoom: GroupStore.getGroupRooms(this.props.groupId).find(
|
||||
(r) => r.roomId === this.props.groupRoomId,
|
||||
),
|
||||
});
|
||||
|
@ -80,7 +77,7 @@ module.exports = React.createClass({
|
|||
|
||||
onGroupStoreUpdated: function() {
|
||||
this.setState({
|
||||
isUserPrivilegedInGroup: this._groupStore.isUserPrivileged(),
|
||||
isUserPrivilegedInGroup: GroupStore.isUserPrivileged(this.props.groupId),
|
||||
});
|
||||
this._updateGroupRoom();
|
||||
},
|
||||
|
@ -100,7 +97,7 @@ module.exports = React.createClass({
|
|||
this.setState({groupRoomRemoveLoading: true});
|
||||
const groupId = this.props.groupId;
|
||||
const roomId = this.props.groupRoomId;
|
||||
this._groupStore.removeRoomFromGroup(roomId).then(() => {
|
||||
GroupStore.removeRoomFromGroup(this.props.groupId, roomId).then(() => {
|
||||
dis.dispatch({
|
||||
action: "view_group_room_list",
|
||||
});
|
||||
|
@ -134,7 +131,7 @@ module.exports = React.createClass({
|
|||
const groupId = this.props.groupId;
|
||||
const roomId = this.props.groupRoomId;
|
||||
const roomName = this.state.groupRoom.displayname;
|
||||
this._groupStore.updateGroupRoomVisibility(roomId, isPublic).catch((err) => {
|
||||
GroupStore.updateGroupRoomVisibility(this.props.groupId, roomId, isPublic).catch((err) => {
|
||||
console.error(`Error whilst changing visibility of ${roomId} in ${groupId} to ${isPublic}`, err);
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Failed to remove room from group', '', ErrorDialog, {
|
||||
|
|
|
@ -16,7 +16,7 @@ limitations under the License.
|
|||
import React from 'react';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import sdk from '../../../index';
|
||||
import GroupStoreCache from '../../../stores/GroupStoreCache';
|
||||
import GroupStore from '../../../stores/GroupStore';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const INITIAL_LOAD_NUM_ROOMS = 30;
|
||||
|
@ -39,22 +39,31 @@ export default React.createClass({
|
|||
this._initGroupStore(this.props.groupId);
|
||||
},
|
||||
|
||||
componentWillUnmount() {
|
||||
this._unmounted = true;
|
||||
this._unregisterGroupStore();
|
||||
},
|
||||
|
||||
_unregisterGroupStore() {
|
||||
GroupStore.unregisterListener(this.onGroupStoreUpdated);
|
||||
},
|
||||
|
||||
_initGroupStore: function(groupId) {
|
||||
this._groupStore = GroupStoreCache.getGroupStore(groupId);
|
||||
this._groupStore.registerListener(() => {
|
||||
this._fetchRooms();
|
||||
});
|
||||
this._groupStore.on('error', (err) => {
|
||||
GroupStore.registerListener(groupId, this.onGroupStoreUpdated);
|
||||
// XXX: This should be more fluxy - let's get the error from GroupStore .getError or something
|
||||
// XXX: This is also leaked - we should remove it when unmounting
|
||||
GroupStore.on('error', (err, errorGroupId) => {
|
||||
if (errorGroupId !== groupId) return;
|
||||
this.setState({
|
||||
rooms: null,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
_fetchRooms: function() {
|
||||
onGroupStoreUpdated: function() {
|
||||
if (this._unmounted) return;
|
||||
this.setState({
|
||||
rooms: this._groupStore.getGroupRooms(),
|
||||
rooms: GroupStore.getGroupRooms(this.props.groupId),
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
@ -20,7 +20,7 @@ import React from 'react';
|
|||
import MFileBody from './MFileBody';
|
||||
|
||||
import MatrixClientPeg from '../../../MatrixClientPeg';
|
||||
import { decryptFile, readBlobAsDataUri } from '../../../utils/DecryptFile';
|
||||
import { decryptFile } from '../../../utils/DecryptFile';
|
||||
import { _t } from '../../../languageHandler';
|
||||
|
||||
export default class MAudioBody extends React.Component {
|
||||
|
@ -54,7 +54,7 @@ export default class MAudioBody extends React.Component {
|
|||
let decryptedBlob;
|
||||
decryptFile(content.file).then(function(blob) {
|
||||
decryptedBlob = blob;
|
||||
return readBlobAsDataUri(decryptedBlob);
|
||||
return URL.createObjectURL(decryptedBlob);
|
||||
}).done((url) => {
|
||||
this.setState({
|
||||
decryptedUrl: url,
|
||||
|
@ -69,6 +69,12 @@ export default class MAudioBody extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.state.decryptedUrl) {
|
||||
URL.revokeObjectURL(this.state.decryptedUrl);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const content = this.props.mxEvent.getContent();
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -99,16 +100,27 @@ Tinter.registerTintable(updateTintedDownloadImage);
|
|||
// overridable so that people running their own version of the client can
|
||||
// choose a different renderer.
|
||||
//
|
||||
// To that end the first version of the blob generation will be the following
|
||||
// To that end the current version of the blob generation is the following
|
||||
// html:
|
||||
//
|
||||
// <html><head><script>
|
||||
// window.onmessage=function(e){eval("("+e.data.code+")")(e)}
|
||||
// var params = window.location.search.substring(1).split('&');
|
||||
// var lockOrigin;
|
||||
// for (var i = 0; i < params.length; ++i) {
|
||||
// var parts = params[i].split('=');
|
||||
// if (parts[0] == 'origin') lockOrigin = decodeURIComponent(parts[1]);
|
||||
// }
|
||||
// window.onmessage=function(e){
|
||||
// if (lockOrigin === undefined || e.origin === lockOrigin) eval("("+e.data.code+")")(e);
|
||||
// }
|
||||
// </script></head><body></body></html>
|
||||
//
|
||||
// This waits to receive a message event sent using the window.postMessage API.
|
||||
// When it receives the event it evals a javascript function in data.code and
|
||||
// runs the function passing the event as an argument.
|
||||
// runs the function passing the event as an argument. This version adds
|
||||
// support for a query parameter controlling the origin from which messages
|
||||
// will be processed as an extra layer of security (note that the default URL
|
||||
// is still 'v1' since it is backwards compatible).
|
||||
//
|
||||
// In particular it means that the rendering function can be written as a
|
||||
// ordinary javascript function which then is turned into a string using
|
||||
|
@ -325,6 +337,7 @@ module.exports = React.createClass({
|
|||
if (this.context.appConfig && this.context.appConfig.cross_origin_renderer_url) {
|
||||
renderer_url = this.context.appConfig.cross_origin_renderer_url;
|
||||
}
|
||||
renderer_url += "?origin=" + encodeURIComponent(window.location.origin);
|
||||
return (
|
||||
<span className="mx_MFileBody">
|
||||
<div className="mx_MFileBody_download">
|
||||
|
@ -348,7 +361,7 @@ module.exports = React.createClass({
|
|||
return (
|
||||
<span className="mx_MFileBody">
|
||||
<div className="mx_MFileBody_download">
|
||||
<a className="mx_ImageBody_downloadLink" href={contentUrl} download={fileName} target="_blank">
|
||||
<a className="mx_MFileBody_downloadLink" href={contentUrl} download={fileName} target="_blank">
|
||||
{ fileName }
|
||||
</a>
|
||||
<div className="mx_MImageBody_size">
|
||||
|
|
|
@ -25,7 +25,7 @@ import ImageUtils from '../../../ImageUtils';
|
|||
import Modal from '../../../Modal';
|
||||
import sdk from '../../../index';
|
||||
import dis from '../../../dispatcher';
|
||||
import { decryptFile, readBlobAsDataUri } from '../../../utils/DecryptFile';
|
||||
import { decryptFile } from '../../../utils/DecryptFile';
|
||||
import Promise from 'bluebird';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
|
@ -49,6 +49,8 @@ export default class extends React.Component {
|
|||
super(props);
|
||||
|
||||
this.onAction = this.onAction.bind(this);
|
||||
this.onImageError = this.onImageError.bind(this);
|
||||
this.onImageLoad = this.onImageLoad.bind(this);
|
||||
this.onImageEnter = this.onImageEnter.bind(this);
|
||||
this.onImageLeave = this.onImageLeave.bind(this);
|
||||
this.onClientSync = this.onClientSync.bind(this);
|
||||
|
@ -70,6 +72,7 @@ export default class extends React.Component {
|
|||
this.context.matrixClient.on('sync', this.onClientSync);
|
||||
}
|
||||
|
||||
// FIXME: factor this out and aplpy it to MVideoBody and MAudioBody too!
|
||||
onClientSync(syncState, prevState) {
|
||||
if (this.unmounted) return;
|
||||
// Consider the client reconnected if there is no error with syncing.
|
||||
|
@ -136,6 +139,11 @@ export default class extends React.Component {
|
|||
});
|
||||
}
|
||||
|
||||
onImageLoad() {
|
||||
this.fixupHeight();
|
||||
this.props.onWidgetLoad();
|
||||
}
|
||||
|
||||
_getContentUrl() {
|
||||
const content = this.props.mxEvent.getContent();
|
||||
if (content.file !== undefined) {
|
||||
|
@ -153,6 +161,15 @@ export default class extends React.Component {
|
|||
return this.state.decryptedThumbnailUrl;
|
||||
}
|
||||
return this.state.decryptedUrl;
|
||||
} else if (content.info &&
|
||||
content.info.mimetype == "image/svg+xml" &&
|
||||
content.info.thumbnail_url) {
|
||||
// special case to return client-generated thumbnails for SVGs, if any,
|
||||
// given we deliberately don't thumbnail them serverside to prevent
|
||||
// billion lol attacks and similar
|
||||
return this.context.matrixClient.mxcUrlToHttp(
|
||||
content.info.thumbnail_url, 800, 600,
|
||||
);
|
||||
} else {
|
||||
return this.context.matrixClient.mxcUrlToHttp(content.url, 800, 600);
|
||||
}
|
||||
|
@ -160,7 +177,6 @@ export default class extends React.Component {
|
|||
|
||||
componentDidMount() {
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
this.fixupHeight();
|
||||
const content = this.props.mxEvent.getContent();
|
||||
if (content.file !== undefined && this.state.decryptedUrl === null) {
|
||||
let thumbnailPromise = Promise.resolve(null);
|
||||
|
@ -168,21 +184,20 @@ export default class extends React.Component {
|
|||
thumbnailPromise = decryptFile(
|
||||
content.info.thumbnail_file,
|
||||
).then(function(blob) {
|
||||
return readBlobAsDataUri(blob);
|
||||
return URL.createObjectURL(blob);
|
||||
});
|
||||
}
|
||||
let decryptedBlob;
|
||||
thumbnailPromise.then((thumbnailUrl) => {
|
||||
return decryptFile(content.file).then(function(blob) {
|
||||
decryptedBlob = blob;
|
||||
return readBlobAsDataUri(blob);
|
||||
return URL.createObjectURL(blob);
|
||||
}).then((contentUrl) => {
|
||||
this.setState({
|
||||
decryptedUrl: contentUrl,
|
||||
decryptedThumbnailUrl: thumbnailUrl,
|
||||
decryptedBlob: decryptedBlob,
|
||||
});
|
||||
this.props.onWidgetLoad();
|
||||
});
|
||||
}).catch((err) => {
|
||||
console.warn("Unable to decrypt attachment: ", err);
|
||||
|
@ -205,6 +220,13 @@ export default class extends React.Component {
|
|||
dis.unregister(this.dispatcherRef);
|
||||
this.context.matrixClient.removeListener('sync', this.onClientSync);
|
||||
this._afterComponentWillUnmount();
|
||||
|
||||
if (this.state.decryptedUrl) {
|
||||
URL.revokeObjectURL(this.state.decryptedUrl);
|
||||
}
|
||||
if (this.state.decryptedThumbnailUrl) {
|
||||
URL.revokeObjectURL(this.state.decryptedThumbnailUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// To be overridden by subclasses (e.g. MStickerBody) for further
|
||||
|
@ -229,7 +251,16 @@ export default class extends React.Component {
|
|||
const maxHeight = 600; // let images take up as much width as they can so long as the height doesn't exceed 600px.
|
||||
// the alternative here would be 600*timelineWidth/800; to scale them down to fit inside a 4:3 bounding box
|
||||
|
||||
//console.log("trying to fit image into timelineWidth of " + this.refs.body.offsetWidth + " or " + this.refs.body.clientWidth);
|
||||
// FIXME: this will break on clientside generated thumbnails (as per e2e rooms)
|
||||
// which may well be much smaller than the 800x600 bounding box.
|
||||
|
||||
// FIXME: It will also break really badly for images with broken or missing thumbnails
|
||||
|
||||
// FIXME: Because we don't know what size of thumbnail the server's actually going to send
|
||||
// us, we can't even really layout the page nicely for it. Instead we have to assume
|
||||
// it'll target 800x600 and we'll downsize if needed to make things fit.
|
||||
|
||||
// console.log("trying to fit image into timelineWidth of " + this.refs.body.offsetWidth + " or " + this.refs.body.clientWidth);
|
||||
let thumbHeight = null;
|
||||
if (content.info) {
|
||||
thumbHeight = ImageUtils.thumbHeight(content.info.w, content.info.h, timelineWidth, maxHeight);
|
||||
|
@ -239,18 +270,22 @@ export default class extends React.Component {
|
|||
}
|
||||
|
||||
_messageContent(contentUrl, thumbUrl, content) {
|
||||
const thumbnail = (
|
||||
<a href={contentUrl} onClick={this.onClick}>
|
||||
<img className="mx_MImageBody_thumbnail" src={thumbUrl} ref="image"
|
||||
alt={content.body}
|
||||
onError={this.onImageError}
|
||||
onLoad={this.onImageLoad}
|
||||
onMouseEnter={this.onImageEnter}
|
||||
onMouseLeave={this.onImageLeave} />
|
||||
</a>
|
||||
);
|
||||
|
||||
return (
|
||||
<span className="mx_MImageBody" ref="body">
|
||||
<a href={contentUrl} onClick={this.onClick}>
|
||||
<img className="mx_MImageBody_thumbnail" src={thumbUrl} ref="image"
|
||||
alt={content.body}
|
||||
onError={this.onImageError}
|
||||
onLoad={this.props.onWidgetLoad}
|
||||
onMouseEnter={this.onImageEnter}
|
||||
onMouseLeave={this.onImageLeave} />
|
||||
</a>
|
||||
{ thumbUrl && !this.state.imgError ? thumbnail : '' }
|
||||
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -285,14 +320,6 @@ export default class extends React.Component {
|
|||
);
|
||||
}
|
||||
|
||||
if (this.state.imgError) {
|
||||
return (
|
||||
<span className="mx_MImageBody">
|
||||
{ _t("This image cannot be displayed.") }
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const contentUrl = this._getContentUrl();
|
||||
let thumbUrl;
|
||||
if (this._isGif() && SettingsStore.getValue("autoplayGifsAndVideos")) {
|
||||
|
@ -301,20 +328,6 @@ export default class extends React.Component {
|
|||
thumbUrl = this._getThumbUrl();
|
||||
}
|
||||
|
||||
if (thumbUrl) {
|
||||
return this._messageContent(contentUrl, thumbUrl, content);
|
||||
} else if (content.body) {
|
||||
return (
|
||||
<span className="mx_MImageBody">
|
||||
{ _t("Image '%(Body)s' cannot be displayed.", {Body: content.body}) }
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<span className="mx_MImageBody">
|
||||
{ _t("This image cannot be displayed.") }
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return this._messageContent(contentUrl, thumbUrl, content);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,7 +20,7 @@ import React from 'react';
|
|||
import PropTypes from 'prop-types';
|
||||
import MFileBody from './MFileBody';
|
||||
import MatrixClientPeg from '../../../MatrixClientPeg';
|
||||
import { decryptFile, readBlobAsDataUri } from '../../../utils/DecryptFile';
|
||||
import { decryptFile } from '../../../utils/DecryptFile';
|
||||
import Promise from 'bluebird';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
|
@ -94,14 +94,14 @@ module.exports = React.createClass({
|
|||
thumbnailPromise = decryptFile(
|
||||
content.info.thumbnail_file,
|
||||
).then(function(blob) {
|
||||
return readBlobAsDataUri(blob);
|
||||
return URL.createObjectURL(blob);
|
||||
});
|
||||
}
|
||||
let decryptedBlob;
|
||||
thumbnailPromise.then((thumbnailUrl) => {
|
||||
return decryptFile(content.file).then(function(blob) {
|
||||
decryptedBlob = blob;
|
||||
return readBlobAsDataUri(blob);
|
||||
return URL.createObjectURL(blob);
|
||||
}).then((contentUrl) => {
|
||||
this.setState({
|
||||
decryptedUrl: contentUrl,
|
||||
|
@ -120,6 +120,15 @@ module.exports = React.createClass({
|
|||
}
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
if (this.state.decryptedUrl) {
|
||||
URL.revokeObjectURL(this.state.decryptedUrl);
|
||||
}
|
||||
if (this.state.decryptedThumbnailUrl) {
|
||||
URL.revokeObjectURL(this.state.decryptedThumbnailUrl);
|
||||
}
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const content = this.props.mxEvent.getContent();
|
||||
|
||||
|
|
|
@ -35,6 +35,7 @@ import MatrixClientPeg from '../../../MatrixClientPeg';
|
|||
import ContextualMenu from '../../structures/ContextualMenu';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import PushProcessor from 'matrix-js-sdk/lib/pushprocessor';
|
||||
import ReplyThread from "../elements/ReplyThread";
|
||||
|
||||
linkifyMatrix(linkify);
|
||||
|
||||
|
@ -61,10 +62,6 @@ module.exports = React.createClass({
|
|||
tileShape: PropTypes.string,
|
||||
},
|
||||
|
||||
contextTypes: {
|
||||
addRichQuote: PropTypes.func,
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
// the URLs (if any) to be previewed with a LinkPreviewWidget
|
||||
|
@ -186,7 +183,6 @@ module.exports = React.createClass({
|
|||
|
||||
// If the link is a (localised) matrix.to link, replace it with a pill
|
||||
const Pill = sdk.getComponent('elements.Pill');
|
||||
const Quote = sdk.getComponent('elements.Quote');
|
||||
if (Pill.isMessagePillUrl(href)) {
|
||||
const pillContainer = document.createElement('span');
|
||||
|
||||
|
@ -205,21 +201,6 @@ module.exports = React.createClass({
|
|||
|
||||
// update the current node with one that's now taken its place
|
||||
node = pillContainer;
|
||||
} else if (SettingsStore.isFeatureEnabled("feature_rich_quoting") && Quote.isMessageUrl(href)) {
|
||||
if (this.context.addRichQuote) { // We're already a Rich Quote so just append the next one above
|
||||
this.context.addRichQuote(href);
|
||||
node.remove();
|
||||
} else { // We're the first in the chain
|
||||
const quoteContainer = document.createElement('span');
|
||||
|
||||
const quote =
|
||||
<Quote url={href} parentEv={this.props.mxEvent} node={node} />;
|
||||
|
||||
ReactDOM.render(quote, quoteContainer);
|
||||
node.parentNode.replaceChild(quoteContainer, node);
|
||||
node = quoteContainer;
|
||||
}
|
||||
pillified = true;
|
||||
}
|
||||
} else if (node.nodeType == Node.TEXT_NODE) {
|
||||
const Pill = sdk.getComponent('elements.Pill');
|
||||
|
@ -441,8 +422,12 @@ module.exports = React.createClass({
|
|||
const mxEvent = this.props.mxEvent;
|
||||
const content = mxEvent.getContent();
|
||||
|
||||
const stripReply = SettingsStore.isFeatureEnabled("feature_rich_quoting") &&
|
||||
ReplyThread.getParentEventId(mxEvent);
|
||||
let body = HtmlUtils.bodyToHtml(content, this.props.highlights, {
|
||||
disableBigEmoji: SettingsStore.getValue('TextualBody.disableBigEmoji'),
|
||||
// Part of Replies fallback support
|
||||
stripReplyFallback: stripReply,
|
||||
});
|
||||
|
||||
if (this.props.highlightLink) {
|
||||
|
|
|
@ -18,6 +18,8 @@ limitations under the License.
|
|||
'use strict';
|
||||
|
||||
|
||||
import ReplyThread from "../elements/ReplyThread";
|
||||
|
||||
const React = require('react');
|
||||
import PropTypes from 'prop-types';
|
||||
const classNames = require("classnames");
|
||||
|
@ -31,6 +33,7 @@ import withMatrixClient from '../../../wrappers/withMatrixClient';
|
|||
const ContextualMenu = require('../../structures/ContextualMenu');
|
||||
import dis from '../../../dispatcher';
|
||||
import {makeEventPermalink} from "../../../matrix-to";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
|
||||
const ObjectUtils = require('../../../ObjectUtils');
|
||||
|
||||
|
@ -152,6 +155,13 @@ module.exports = withMatrixClient(React.createClass({
|
|||
isTwelveHour: PropTypes.bool,
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
// no-op function because onWidgetLoad is optional yet some sub-components assume its existence
|
||||
onWidgetLoad: function() {},
|
||||
};
|
||||
},
|
||||
|
||||
getInitialState: function() {
|
||||
return {
|
||||
// Whether the context menu is being displayed.
|
||||
|
@ -300,12 +310,16 @@ module.exports = withMatrixClient(React.createClass({
|
|||
const x = buttonRect.right + window.pageXOffset;
|
||||
const y = (buttonRect.top + (buttonRect.height / 2) + window.pageYOffset) - 19;
|
||||
const self = this;
|
||||
|
||||
const {tile, replyThread} = this.refs;
|
||||
|
||||
ContextualMenu.createMenu(MessageContextMenu, {
|
||||
chevronOffset: 10,
|
||||
mxEvent: this.props.mxEvent,
|
||||
left: x,
|
||||
top: y,
|
||||
eventTileOps: this.refs.tile && this.refs.tile.getEventTileOps ? this.refs.tile.getEventTileOps() : undefined,
|
||||
eventTileOps: tile && tile.getEventTileOps ? tile.getEventTileOps() : undefined,
|
||||
collapseReplyThread: replyThread && replyThread.canCollapse() ? replyThread.collapse : undefined,
|
||||
onFinished: function() {
|
||||
self.setState({menu: false});
|
||||
},
|
||||
|
@ -542,7 +556,7 @@ module.exports = withMatrixClient(React.createClass({
|
|||
|
||||
if (needsSenderProfile) {
|
||||
let text = null;
|
||||
if (!this.props.tileShape || this.props.tileShape === 'quote') {
|
||||
if (!this.props.tileShape) {
|
||||
if (msgtype === 'm.image') text = _td('%(senderName)s sent an image');
|
||||
else if (msgtype === 'm.video') text = _td('%(senderName)s sent a video');
|
||||
else if (msgtype === 'm.file') text = _td('%(senderName)s uploaded a file');
|
||||
|
@ -646,18 +660,23 @@ module.exports = withMatrixClient(React.createClass({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
case 'quote': {
|
||||
|
||||
case 'reply':
|
||||
case 'reply_preview': {
|
||||
return (
|
||||
<div className={classes}>
|
||||
{ avatar }
|
||||
{ sender }
|
||||
<div className="mx_EventTile_line mx_EventTile_quote">
|
||||
<div className="mx_EventTile_reply">
|
||||
<a href={permalink} onClick={this.onPermalinkClicked}>
|
||||
{ timestamp }
|
||||
</a>
|
||||
{ this._renderE2EPadlock() }
|
||||
{
|
||||
this.props.tileShape === 'reply_preview'
|
||||
&& ReplyThread.makeThread(this.props.mxEvent, this.props.onWidgetLoad, 'replyThread')
|
||||
}
|
||||
<EventTileType ref="tile"
|
||||
tileShape="quote"
|
||||
mxEvent={this.props.mxEvent}
|
||||
highlights={this.props.highlights}
|
||||
highlightLink={this.props.highlightLink}
|
||||
|
@ -680,6 +699,7 @@ module.exports = withMatrixClient(React.createClass({
|
|||
{ timestamp }
|
||||
</a>
|
||||
{ this._renderE2EPadlock() }
|
||||
{ ReplyThread.makeThread(this.props.mxEvent, this.props.onWidgetLoad, 'replyThread') }
|
||||
<EventTileType ref="tile"
|
||||
mxEvent={this.props.mxEvent}
|
||||
highlights={this.props.highlights}
|
||||
|
@ -742,7 +762,11 @@ function E2ePadlockUnencrypted(props) {
|
|||
}
|
||||
|
||||
function E2ePadlock(props) {
|
||||
return <img className="mx_EventTile_e2eIcon" {...props} />;
|
||||
if (SettingsStore.getValue("alwaysShowEncryptionIcons")) {
|
||||
return <img className="mx_EventTile_e2eIcon" {...props} />;
|
||||
} else {
|
||||
return <img className="mx_EventTile_e2eIcon mx_EventTile_e2eIcon_hidden" {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.getHandlerTile = getHandlerTile;
|
||||
|
|
|
@ -111,6 +111,14 @@ export default class MessageComposer extends React.Component {
|
|||
</li>);
|
||||
}
|
||||
|
||||
const isQuoting = Boolean(RoomViewStore.getQuotingEvent());
|
||||
let replyToWarning = null;
|
||||
if (isQuoting) {
|
||||
replyToWarning = <p>{
|
||||
_t('At this time it is not possible to reply with a file so this will be sent without being a reply.')
|
||||
}</p>;
|
||||
}
|
||||
|
||||
Modal.createTrackedDialog('Upload Files confirmation', '', QuestionDialog, {
|
||||
title: _t('Upload Files'),
|
||||
description: (
|
||||
|
@ -119,6 +127,7 @@ export default class MessageComposer extends React.Component {
|
|||
<ul style={{listStyle: 'none', textAlign: 'left'}}>
|
||||
{ fileList }
|
||||
</ul>
|
||||
{ replyToWarning }
|
||||
</div>
|
||||
),
|
||||
onFinished: (shouldUpload) => {
|
||||
|
|
|
@ -58,9 +58,11 @@ const REGEX_MATRIXTO_MARKDOWN_GLOBAL = new RegExp(MATRIXTO_MD_LINK_PATTERN, 'g')
|
|||
|
||||
import {asciiRegexp, shortnameToUnicode, emojioneList, asciiList, mapUnicodeToShort} from 'emojione';
|
||||
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
|
||||
import {makeEventPermalink, makeUserPermalink} from "../../../matrix-to";
|
||||
import QuotePreview from "./QuotePreview";
|
||||
import {makeUserPermalink} from "../../../matrix-to";
|
||||
import ReplyPreview from "./ReplyPreview";
|
||||
import RoomViewStore from '../../../stores/RoomViewStore';
|
||||
import ReplyThread from "../elements/ReplyThread";
|
||||
import {ContentHelpers} from 'matrix-js-sdk';
|
||||
|
||||
const EMOJI_SHORTNAMES = Object.keys(emojioneList);
|
||||
const EMOJI_UNICODE_TO_SHORTNAME = mapUnicodeToShort();
|
||||
|
@ -263,7 +265,7 @@ export default class MessageComposerInput extends React.Component {
|
|||
let contentState = this.state.editorState.getCurrentContent();
|
||||
|
||||
switch (payload.action) {
|
||||
case 'quote_event':
|
||||
case 'reply_to_event':
|
||||
case 'focus_composer':
|
||||
editor.focus();
|
||||
break;
|
||||
|
@ -760,17 +762,15 @@ export default class MessageComposerInput extends React.Component {
|
|||
return true;
|
||||
}
|
||||
|
||||
const quotingEv = RoomViewStore.getQuotingEvent();
|
||||
const replyingToEv = RoomViewStore.getQuotingEvent();
|
||||
const mustSendHTML = Boolean(replyingToEv);
|
||||
|
||||
if (this.state.isRichtextEnabled) {
|
||||
/*
|
||||
// We should only send HTML if any block is styled or contains inline style
|
||||
let shouldSendHTML = false;
|
||||
|
||||
// If we are quoting we need HTML Content
|
||||
if (quotingEv) {
|
||||
shouldSendHTML = true;
|
||||
}
|
||||
if (mustSendHTML) shouldSendHTML = true;
|
||||
|
||||
const blocks = contentState.getBlocksAsArray();
|
||||
if (blocks.some((block) => block.getType() !== 'unstyled')) {
|
||||
|
@ -833,15 +833,15 @@ export default class MessageComposerInput extends React.Component {
|
|||
*/
|
||||
const md = new Markdown(pt);
|
||||
// if contains no HTML and we're not quoting (needing HTML)
|
||||
if (md.isPlainText() && !quotingEv) {
|
||||
if (md.isPlainText() && !mustSendHTML) {
|
||||
contentText = md.toPlaintext();
|
||||
} else {
|
||||
contentHTML = md.toHTML();
|
||||
}
|
||||
}
|
||||
|
||||
let sendHtmlFn = this.client.sendHtmlMessage;
|
||||
let sendTextFn = this.client.sendTextMessage;
|
||||
let sendHtmlFn = ContentHelpers.makeHtmlMessage;
|
||||
let sendTextFn = ContentHelpers.makeTextMessage;
|
||||
|
||||
this.historyManager.save(
|
||||
contentState,
|
||||
|
@ -849,45 +849,54 @@ export default class MessageComposerInput extends React.Component {
|
|||
);
|
||||
|
||||
if (contentText.startsWith('/me')) {
|
||||
if (replyingToEv) {
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
Modal.createTrackedDialog('Emote Reply Fail', '', ErrorDialog, {
|
||||
title: _t("Unable to reply"),
|
||||
description: _t("At this time it is not possible to reply with an emote."),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
contentText = contentText.substring(4);
|
||||
// bit of a hack, but the alternative would be quite complicated
|
||||
if (contentHTML) contentHTML = contentHTML.replace(/\/me ?/, '');
|
||||
sendHtmlFn = this.client.sendHtmlEmote;
|
||||
sendTextFn = this.client.sendEmoteMessage;
|
||||
sendHtmlFn = ContentHelpers.makeHtmlEmote;
|
||||
sendTextFn = ContentHelpers.makeEmoteMessage;
|
||||
}
|
||||
|
||||
if (quotingEv) {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const room = cli.getRoom(quotingEv.getRoomId());
|
||||
const sender = room.currentState.getMember(quotingEv.getSender());
|
||||
|
||||
const {body/*, formatted_body*/} = quotingEv.getContent();
|
||||
let content = contentHTML ? sendHtmlFn(contentText, contentHTML) : sendTextFn(contentText);
|
||||
|
||||
const perma = makeEventPermalink(quotingEv.getRoomId(), quotingEv.getId());
|
||||
contentText = `${sender.name}:\n> ${body}\n\n${contentText}`;
|
||||
contentHTML = `<a href="${perma}">Quote<br></a>${contentHTML}`;
|
||||
if (replyingToEv) {
|
||||
const replyContent = ReplyThread.makeReplyMixIn(replyingToEv);
|
||||
content = Object.assign(replyContent, content);
|
||||
|
||||
// we have finished quoting, clear the quotingEvent
|
||||
// Part of Replies fallback support - prepend the text we're sending with the text we're replying to
|
||||
const nestedReply = ReplyThread.getNestedReplyText(replyingToEv);
|
||||
if (nestedReply) {
|
||||
if (content.formatted_body) {
|
||||
content.formatted_body = nestedReply.html + content.formatted_body;
|
||||
}
|
||||
content.body = nestedReply.body + content.body;
|
||||
}
|
||||
|
||||
// Clear reply_to_event as we put the message into the queue
|
||||
// if the send fails, retry will handle resending.
|
||||
dis.dispatch({
|
||||
action: 'quote_event',
|
||||
action: 'reply_to_event',
|
||||
event: null,
|
||||
});
|
||||
}
|
||||
|
||||
let sendMessagePromise;
|
||||
if (contentHTML) {
|
||||
sendMessagePromise = sendHtmlFn.call(
|
||||
this.client, this.props.room.roomId, contentText, contentHTML,
|
||||
);
|
||||
} else {
|
||||
sendMessagePromise = sendTextFn.call(this.client, this.props.room.roomId, contentText);
|
||||
}
|
||||
|
||||
sendMessagePromise.done((res) => {
|
||||
this.client.sendMessage(this.props.room.roomId, content).then((res) => {
|
||||
dis.dispatch({
|
||||
action: 'message_sent',
|
||||
});
|
||||
}, (e) => onSendMessageFailed(e, this.props.room));
|
||||
}).catch((e) => {
|
||||
onSendMessageFailed(e, this.props.room);
|
||||
});
|
||||
|
||||
this.setState({
|
||||
editorState: this.createEditorState(),
|
||||
|
@ -1192,7 +1201,7 @@ export default class MessageComposerInput extends React.Component {
|
|||
return (
|
||||
<div className="mx_MessageComposer_input_wrapper">
|
||||
<div className="mx_MessageComposer_autocomplete_wrapper">
|
||||
{ SettingsStore.isFeatureEnabled("feature_rich_quoting") && <QuotePreview /> }
|
||||
{ SettingsStore.isFeatureEnabled("feature_rich_quoting") && <ReplyPreview /> }
|
||||
<Autocomplete
|
||||
ref={(e) => this.autocomplete = e}
|
||||
room={this.props.room}
|
||||
|
|
|
@ -19,15 +19,16 @@ import dis from '../../../dispatcher';
|
|||
import sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import RoomViewStore from '../../../stores/RoomViewStore';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
|
||||
function cancelQuoting() {
|
||||
dis.dispatch({
|
||||
action: 'quote_event',
|
||||
action: 'reply_to_event',
|
||||
event: null,
|
||||
});
|
||||
}
|
||||
|
||||
export default class QuotePreview extends React.Component {
|
||||
export default class ReplyPreview extends React.Component {
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
|
@ -61,17 +62,20 @@ export default class QuotePreview extends React.Component {
|
|||
const EventTile = sdk.getComponent('rooms.EventTile');
|
||||
const EmojiText = sdk.getComponent('views.elements.EmojiText');
|
||||
|
||||
return <div className="mx_QuotePreview">
|
||||
<div className="mx_QuotePreview_section">
|
||||
<EmojiText element="div" className="mx_QuotePreview_header mx_QuotePreview_title">
|
||||
return <div className="mx_ReplyPreview">
|
||||
<div className="mx_ReplyPreview_section">
|
||||
<EmojiText element="div" className="mx_ReplyPreview_header mx_ReplyPreview_title">
|
||||
{ '💬 ' + _t('Replying') }
|
||||
</EmojiText>
|
||||
<div className="mx_QuotePreview_header mx_QuotePreview_cancel">
|
||||
<div className="mx_ReplyPreview_header mx_ReplyPreview_cancel">
|
||||
<img className="mx_filterFlipColor" src="img/cancel.svg" width="18" height="18"
|
||||
onClick={cancelQuoting} />
|
||||
</div>
|
||||
<div className="mx_QuotePreview_clear" />
|
||||
<EventTile mxEvent={this.state.event} last={true} tileShape="quote" />
|
||||
<div className="mx_ReplyPreview_clear" />
|
||||
<EventTile last={true}
|
||||
tileShape="reply_preview"
|
||||
mxEvent={this.state.event}
|
||||
isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")} />
|
||||
</div>
|
||||
</div>;
|
||||
}
|
|
@ -30,7 +30,7 @@ import DMRoomMap from '../../../utils/DMRoomMap';
|
|||
const Receipt = require('../../../utils/Receipt');
|
||||
import TagOrderStore from '../../../stores/TagOrderStore';
|
||||
import RoomListStore from '../../../stores/RoomListStore';
|
||||
import GroupStoreCache from '../../../stores/GroupStoreCache';
|
||||
import GroupStore from '../../../stores/GroupStore';
|
||||
|
||||
const HIDE_CONFERENCE_CHANS = true;
|
||||
const STANDARD_TAGS_REGEX = /^(m\.(favourite|lowpriority)|im\.vector\.fake\.(invite|recent|direct|archived))$/;
|
||||
|
@ -83,8 +83,6 @@ module.exports = React.createClass({
|
|||
cli.on("Group.myMembership", this._onGroupMyMembership);
|
||||
|
||||
const dmRoomMap = DMRoomMap.shared();
|
||||
this._groupStores = {};
|
||||
this._groupStoreTokens = [];
|
||||
// A map between tags which are group IDs and the room IDs of rooms that should be kept
|
||||
// in the room list when filtering by that tag.
|
||||
this._visibleRoomsForGroup = {
|
||||
|
@ -93,22 +91,22 @@ module.exports = React.createClass({
|
|||
// All rooms that should be kept in the room list when filtering.
|
||||
// By default, show all rooms.
|
||||
this._visibleRooms = MatrixClientPeg.get().getRooms();
|
||||
// When the selected tags are changed, initialise a group store if necessary
|
||||
this._tagStoreToken = TagOrderStore.addListener(() => {
|
||||
|
||||
// Listen to updates to group data. RoomList cares about members and rooms in order
|
||||
// to filter the room list when group tags are selected.
|
||||
this._groupStoreToken = GroupStore.registerListener(null, () => {
|
||||
(TagOrderStore.getOrderedTags() || []).forEach((tag) => {
|
||||
if (tag[0] !== '+' || this._groupStores[tag]) {
|
||||
if (tag[0] !== '+') {
|
||||
return;
|
||||
}
|
||||
this._groupStores[tag] = GroupStoreCache.getGroupStore(tag);
|
||||
this._groupStoreTokens.push(
|
||||
this._groupStores[tag].registerListener(() => {
|
||||
// This group's rooms or members may have updated, update rooms for its tag
|
||||
this.updateVisibleRoomsForTag(dmRoomMap, tag);
|
||||
this.updateVisibleRooms();
|
||||
}),
|
||||
);
|
||||
// This group's rooms or members may have updated, update rooms for its tag
|
||||
this.updateVisibleRoomsForTag(dmRoomMap, tag);
|
||||
this.updateVisibleRooms();
|
||||
});
|
||||
// Filters themselves have changed, refresh the selected tags
|
||||
});
|
||||
|
||||
this._tagStoreToken = TagOrderStore.addListener(() => {
|
||||
// Filters themselves have changed
|
||||
this.updateVisibleRooms();
|
||||
});
|
||||
|
||||
|
@ -183,9 +181,9 @@ module.exports = React.createClass({
|
|||
this._roomListStoreToken.remove();
|
||||
}
|
||||
|
||||
if (this._groupStoreTokens.length > 0) {
|
||||
// NB: GroupStore is not a Flux.Store
|
||||
this._groupStoreTokens.forEach((token) => token.unregister());
|
||||
// NB: GroupStore is not a Flux.Store
|
||||
if (this._groupStoreToken) {
|
||||
this._groupStoreToken.unregister();
|
||||
}
|
||||
|
||||
// cancel any pending calls to the rate_limited_funcs
|
||||
|
@ -259,12 +257,11 @@ module.exports = React.createClass({
|
|||
updateVisibleRoomsForTag: function(dmRoomMap, tag) {
|
||||
if (!this.mounted) return;
|
||||
// For now, only handle group tags
|
||||
const store = this._groupStores[tag];
|
||||
if (!store) return;
|
||||
if (tag[0] !== '+') return;
|
||||
|
||||
this._visibleRoomsForGroup[tag] = [];
|
||||
store.getGroupRooms().forEach((room) => this._visibleRoomsForGroup[tag].push(room.roomId));
|
||||
store.getGroupMembers().forEach((member) => {
|
||||
GroupStore.getGroupRooms(tag).forEach((room) => this._visibleRoomsForGroup[tag].push(room.roomId));
|
||||
GroupStore.getGroupMembers(tag).forEach((member) => {
|
||||
if (member.userId === MatrixClientPeg.get().credentials.userId) return;
|
||||
dmRoomMap.getDMRoomsForUserId(member.userId).forEach(
|
||||
(roomId) => this._visibleRoomsForGroup[tag].push(roomId),
|
||||
|
|
|
@ -174,6 +174,7 @@ export default class Stickerpicker extends React.Component {
|
|||
showTitle={false}
|
||||
showMinimise={true}
|
||||
showDelete={false}
|
||||
showPopout={false}
|
||||
onMinimiseClick={this._onHideStickersClick}
|
||||
handleMinimisePointerEvents={true}
|
||||
whitelistCapabilities={['m.sticker']}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue