Merge remote-tracking branch 'origin/develop' into t3chguy/clear_notifications

This commit is contained in:
Travis Ralston 2019-11-27 19:27:52 -07:00
commit 96ffe94876
733 changed files with 50889 additions and 9584 deletions

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,15 +15,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
const React = require('react');
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import { _t } from '../../languageHandler';
module.exports = React.createClass({
module.exports = createReactClass({
displayName: 'CompatibilityPage',
propTypes: {
onAccept: React.PropTypes.func,
onAccept: PropTypes.func,
},
getDefaultProps: function() {

View file

@ -1,6 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,11 +16,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import {focusCapturedRef} from "../../utils/Accessibility";
import {KeyCode} from "../../Keyboard";
// Shamelessly ripped off Modal.js. There's probably a better way
// of doing reusable widgets like dialog boxes & menus where we go and
@ -48,7 +50,6 @@ export default class ContextualMenu extends React.Component {
menuWidth: PropTypes.number,
menuHeight: PropTypes.number,
chevronOffset: PropTypes.number,
menuColour: PropTypes.string,
chevronFace: PropTypes.string, // top, bottom, left, right or none
// Function to be called on menu close
onFinished: PropTypes.func,
@ -67,7 +68,7 @@ export default class ContextualMenu extends React.Component {
// on resize callback
windowResize: PropTypes.func,
// method to close menu
closeMenu: PropTypes.func,
closeMenu: PropTypes.func.isRequired,
};
constructor() {
@ -84,6 +85,9 @@ export default class ContextualMenu extends React.Component {
// We don't need to clean up when unmounting, so ignore
if (!element) return;
// For screen readers to find the thing
focusCapturedRef(element);
this.setState({
contextMenuRect: element.getBoundingClientRect(),
});
@ -111,6 +115,14 @@ export default class ContextualMenu extends React.Component {
}
}
_onKeyDown = (ev) => {
if (ev.keyCode === KeyCode.ESCAPE) {
ev.stopPropagation();
ev.preventDefault();
this.props.closeMenu();
}
};
render() {
const position = {};
let chevronFace = null;
@ -157,25 +169,6 @@ export default class ContextualMenu extends React.Component {
chevronOffset.top = Math.max(props.chevronOffset, props.chevronOffset + target - adjusted);
}
// To override the default chevron colour, if it's been set
let chevronCSS = "";
if (props.menuColour) {
chevronCSS = `
.mx_ContextualMenu_chevron_left:after {
border-right-color: ${props.menuColour};
}
.mx_ContextualMenu_chevron_right:after {
border-left-color: ${props.menuColour};
}
.mx_ContextualMenu_chevron_top:after {
border-left-color: ${props.menuColour};
}
.mx_ContextualMenu_chevron_bottom:after {
border-left-color: ${props.menuColour};
}
`;
}
const chevron = hasChevron ?
<div style={chevronOffset} className={"mx_ContextualMenu_chevron_" + chevronFace} /> :
undefined;
@ -183,11 +176,14 @@ export default class ContextualMenu extends React.Component {
const menuClasses = classNames({
'mx_ContextualMenu': true,
'mx_ContextualMenu_noChevron': chevronFace === 'none',
'mx_ContextualMenu_left': chevronFace === 'left',
'mx_ContextualMenu_right': chevronFace === 'right',
'mx_ContextualMenu_top': chevronFace === 'top',
'mx_ContextualMenu_bottom': chevronFace === 'bottom',
'mx_ContextualMenu_left': !hasChevron && position.left,
'mx_ContextualMenu_right': !hasChevron && position.right,
'mx_ContextualMenu_top': !hasChevron && position.top,
'mx_ContextualMenu_bottom': !hasChevron && position.bottom,
'mx_ContextualMenu_withChevron_left': chevronFace === 'left',
'mx_ContextualMenu_withChevron_right': chevronFace === 'right',
'mx_ContextualMenu_withChevron_top': chevronFace === 'top',
'mx_ContextualMenu_withChevron_bottom': chevronFace === 'bottom',
});
const menuStyle = {};
@ -199,10 +195,6 @@ export default class ContextualMenu extends React.Component {
menuStyle.height = props.menuHeight;
}
if (props.menuColour) {
menuStyle["backgroundColor"] = props.menuColour;
}
if (!isNaN(Number(props.menuPaddingTop))) {
menuStyle["paddingTop"] = props.menuPaddingTop;
}
@ -226,14 +218,13 @@ export default class ContextualMenu extends React.Component {
// FIXME: If a menu uses getDefaultProps it clobbers the onFinished
// property set here so you can't close the menu from a button click!
return <div className={className} style={{...position, ...wrapperStyle}}>
<div className={menuClasses} style={menuStyle} ref={this.collectContextMenuRect}>
return <div className={className} style={{...position, ...wrapperStyle}} onKeyDown={this._onKeyDown}>
<div className={menuClasses} style={menuStyle} ref={this.collectContextMenuRect} tabIndex={0}>
{ chevron }
<ElementClass {...props} onFinished={props.closeMenu} onResize={props.windowResize} />
</div>
{ props.hasBackground && <div className="mx_ContextualMenu_background" style={wrapperStyle}
onClick={props.closeMenu} onContextMenu={this.onContextMenu} /> }
<style>{ chevronCSS }</style>
</div>;
}
}

View file

@ -24,6 +24,8 @@ import request from 'browser-request';
import { _t } from '../../languageHandler';
import sanitizeHtml from 'sanitize-html';
import sdk from '../../index';
import dis from '../../dispatcher';
import MatrixClientPeg from '../../MatrixClientPeg';
import { MatrixClient } from 'matrix-js-sdk';
import classnames from 'classnames';
@ -44,6 +46,8 @@ export default class EmbeddedPage extends React.PureComponent {
constructor(props) {
super(props);
this._dispatcherRef = null;
this.state = {
page: '',
};
@ -82,19 +86,31 @@ export default class EmbeddedPage extends React.PureComponent {
this.setState({ page: body });
},
);
this._dispatcherRef = dis.register(this.onAction);
}
componentWillUnmount() {
this._unmounted = true;
if (this._dispatcherRef !== null) dis.unregister(this._dispatcherRef);
}
onAction = (payload) => {
// HACK: Workaround for the context's MatrixClient not being set up at render time.
if (payload.action === 'client_started') {
this.forceUpdate();
}
};
render() {
const client = this.context.matrixClient;
// HACK: Workaround for the context's MatrixClient not updating.
const client = this.context.matrixClient || MatrixClientPeg.get();
const isGuest = client ? client.isGuest() : true;
const className = this.props.className;
const classes = classnames({
[className]: true,
[`${className}_guest`]: isGuest,
[`${className}_loggedIn`]: !!client,
});
const content = <div className={`${className}_body`}

View file

@ -15,6 +15,7 @@ limitations under the License.
*/
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import Matrix from 'matrix-js-sdk';
@ -25,7 +26,7 @@ import { _t } from '../../languageHandler';
/*
* Component which shows the filtered file using a TimelinePanel
*/
const FilePanel = React.createClass({
const FilePanel = createReactClass({
displayName: 'FilePanel',
propTypes: {
@ -38,23 +39,10 @@ const FilePanel = React.createClass({
};
},
componentWillMount: function() {
componentDidMount: function() {
this.updateTimelineSet(this.props.roomId);
},
componentWillReceiveProps: function(nextProps) {
if (nextProps.roomId !== this.props.roomId) {
// otherwise we race between re-rendering the TimelinePanel and setting the new timelineSet.
//
// FIXME: this race only happens because of the promise returned by getOrCreateFilter().
// We should only need to create the containsUrl filter once per login session, so in practice
// it shouldn't be being done here at all. Then we could just update the timelineSet directly
// without resetting it first, and speed up room-change.
this.setState({ timelineSet: null });
this.updateTimelineSet(nextProps.roomId);
}
},
updateTimelineSet: function(roomId) {
const client = MatrixClientPeg.get();
const room = client.getRoom(roomId);
@ -116,20 +104,21 @@ const FilePanel = React.createClass({
// console.log("rendering TimelinePanel for timelineSet " + this.state.timelineSet.room.roomId + " " +
// "(" + this.state.timelineSet._timelines.join(", ") + ")" + " with key " + this.props.roomId);
return (
<TimelinePanel key={"filepanel_" + this.props.roomId}
className="mx_FilePanel"
manageReadReceipts={false}
manageReadMarkers={false}
timelineSet={this.state.timelineSet}
showUrlPreview = {false}
tileShape="file_grid"
resizeNotifier={this.props.resizeNotifier}
empty={_t('There are no visible files in this room')}
/>
<div className="mx_FilePanel" role="tabpanel">
<TimelinePanel key={"filepanel_" + this.props.roomId}
manageReadReceipts={false}
manageReadMarkers={false}
timelineSet={this.state.timelineSet}
showUrlPreview = {false}
tileShape="file_grid"
resizeNotifier={this.props.resizeNotifier}
empty={_t('There are no visible files in this room')}
/>
</div>
);
} else {
return (
<div className="mx_FilePanel">
<div className="mx_FilePanel" role="tabpanel">
<Loader />
</div>
);

View file

@ -16,22 +16,18 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import {_t} from "../../languageHandler";
export default class GenericErrorPage extends React.PureComponent {
static propTypes = {
message: PropTypes.string.isRequired,
title: PropTypes.object.isRequired, // jsx for title
message: PropTypes.object.isRequired, // jsx to display
};
render() {
return <div className='mx_GenericErrorPage'>
<div className='mx_GenericErrorPage_box'>
<h1>{_t("Error loading Riot")}</h1>
<h1>{this.props.title}</h1>
<p>{this.props.message}</p>
<p>{_t(
"If this is unexpected, please contact your system administrator " +
"or technical support representative.",
)}</p>
</div>
</div>;
}

View file

@ -1,6 +1,7 @@
/*
Copyright 2017 Vector Creations Ltd.
Copyright 2017, 2018 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,8 +17,8 @@ limitations under the License.
*/
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import Promise from 'bluebird';
import MatrixClientPeg from '../../MatrixClientPeg';
import sdk from '../../index';
import dis from '../../dispatcher';
@ -34,8 +35,9 @@ import classnames from 'classnames';
import GroupStore from '../../stores/GroupStore';
import FlairStore from '../../stores/FlairStore';
import { showGroupAddRoomDialog } from '../../GroupAddressPicker';
import {makeGroupPermalink, makeUserPermalink} from "../../matrix-to";
import {makeGroupPermalink, makeUserPermalink} from "../../utils/permalinks/Permalinks";
import {Group} from "matrix-js-sdk";
import {allSettled, sleep} from "../../utils/promise";
const LONG_DESC_PLACEHOLDER = _td(
`<h1>HTML for your community's page</h1>
@ -66,7 +68,7 @@ const UserSummaryType = PropTypes.shape({
}).isRequired,
});
const CategoryRoomList = React.createClass({
const CategoryRoomList = createReactClass({
displayName: 'CategoryRoomList',
props: {
@ -96,11 +98,10 @@ const CategoryRoomList = React.createClass({
onFinished: (success, addrs) => {
if (!success) return;
const errorList = [];
Promise.all(addrs.map((addr) => {
allSettled(addrs.map((addr) => {
return GroupStore
.addRoomToGroupSummary(this.props.groupId, addr.address)
.catch(() => { errorList.push(addr.address); })
.reflect();
.catch(() => { errorList.push(addr.address); });
})).then(() => {
if (errorList.length === 0) {
return;
@ -118,7 +119,7 @@ const CategoryRoomList = React.createClass({
});
});
},
});
}, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
},
render: function() {
@ -155,7 +156,7 @@ const CategoryRoomList = React.createClass({
},
});
const FeaturedRoom = React.createClass({
const FeaturedRoom = createReactClass({
displayName: 'FeaturedRoom',
props: {
@ -243,7 +244,7 @@ const FeaturedRoom = React.createClass({
},
});
const RoleUserList = React.createClass({
const RoleUserList = createReactClass({
displayName: 'RoleUserList',
props: {
@ -265,7 +266,7 @@ const RoleUserList = React.createClass({
Modal.createTrackedDialog('Add Users to Group Summary', '', AddressPickerDialog, {
title: _t('Add users to the community summary'),
description: _t("Who would you like to add to this summary?"),
placeholder: _t("Name or matrix ID"),
placeholder: _t("Name or Matrix ID"),
button: _t("Add to summary"),
validAddressTypes: ['mx-user-id'],
groupId: this.props.groupId,
@ -273,11 +274,10 @@ const RoleUserList = React.createClass({
onFinished: (success, addrs) => {
if (!success) return;
const errorList = [];
Promise.all(addrs.map((addr) => {
allSettled(addrs.map((addr) => {
return GroupStore
.addUserToGroupSummary(addr.address)
.catch(() => { errorList.push(addr.address); })
.reflect();
.catch(() => { errorList.push(addr.address); });
})).then(() => {
if (errorList.length === 0) {
return;
@ -295,7 +295,7 @@ const RoleUserList = React.createClass({
});
});
},
});
}, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
},
render: function() {
@ -326,7 +326,7 @@ const RoleUserList = React.createClass({
},
});
const FeaturedUser = React.createClass({
const FeaturedUser = createReactClass({
displayName: 'FeaturedUser',
props: {
@ -342,7 +342,6 @@ const FeaturedUser = React.createClass({
dis.dispatch({
action: 'view_start_chat_or_reuse',
user_id: this.props.summaryInfo.user_id,
go_home_on_cancel: false,
});
},
@ -399,7 +398,7 @@ const FeaturedUser = React.createClass({
const GROUP_JOINPOLICY_OPEN = "open";
const GROUP_JOINPOLICY_INVITE = "invite";
export default React.createClass({
export default createReactClass({
displayName: 'GroupView',
propTypes: {
@ -408,10 +407,6 @@ export default React.createClass({
groupIsNew: PropTypes.bool,
},
childContextTypes: {
groupStore: PropTypes.instanceOf(GroupStore),
},
getInitialState: function() {
return {
summary: null,
@ -641,7 +636,7 @@ export default React.createClass({
title: _t('Error'),
description: _t('Failed to upload image'),
});
}).done();
});
},
_onJoinableChange: function(ev) {
@ -680,7 +675,7 @@ export default React.createClass({
this.setState({
avatarChanged: false,
});
}).done();
});
},
_saveGroup: async function() {
@ -695,7 +690,7 @@ export default React.createClass({
// Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the
// spinner disappearing after we have fetched new group data.
await Promise.delay(500);
await sleep(500);
GroupStore.acceptGroupInvite(this.props.groupId).then(() => {
// don't reset membershipBusy here: wait for the membership change to come down the sync
@ -714,7 +709,7 @@ export default React.createClass({
// Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the
// spinner disappearing after we have fetched new group data.
await Promise.delay(500);
await sleep(500);
GroupStore.leaveGroup(this.props.groupId).then(() => {
// don't reset membershipBusy here: wait for the membership change to come down the sync
@ -738,7 +733,7 @@ export default React.createClass({
// Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the
// spinner disappearing after we have fetched new group data.
await Promise.delay(500);
await sleep(500);
GroupStore.joinGroup(this.props.groupId).then(() => {
// don't reset membershipBusy here: wait for the membership change to come down the sync
@ -790,7 +785,7 @@ export default React.createClass({
// Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the
// spinner disappearing after we have fetched new group data.
await Promise.delay(500);
await sleep(500);
GroupStore.leaveGroup(this.props.groupId).then(() => {
// don't reset membershipBusy here: wait for the membership change to come down the sync
@ -861,9 +856,9 @@ export default React.createClass({
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const TintableSvg = sdk.getComponent('elements.TintableSvg');
const Spinner = sdk.getComponent('elements.Spinner');
const ToolTipButton = sdk.getComponent('elements.ToolTipButton');
const TooltipButton = sdk.getComponent('elements.TooltipButton');
const roomsHelpNode = this.state.editing ? <ToolTipButton helpText={
const roomsHelpNode = this.state.editing ? <TooltipButton helpText={
_t(
'These rooms are displayed to community members on the community page. '+
'Community members can join the rooms by clicking on them.',
@ -1226,7 +1221,7 @@ export default React.createClass({
blurToCancel={false}
initialValue={this.state.profileForm.name}
onValueChanged={this._onNameChange}
tabIndex="1"
tabIndex="0"
dir="auto" />;
shortDescNode = <EditableText ref="descriptionEditor"
@ -1236,7 +1231,7 @@ export default React.createClass({
blurToCancel={false}
initialValue={this.state.profileForm.short_description}
onValueChanged={this._onShortDescChange}
tabIndex="2"
tabIndex="0"
dir="auto" />;
} else {
const onGroupHeaderItemClick = this.state.isUserMember ? this._onEditClick : null;

View file

@ -19,7 +19,7 @@ import PropTypes from "prop-types";
import AutoHideScrollbar from "./AutoHideScrollbar";
export default class IndicatorScrollbar extends React.Component {
static PropTypes = {
static propTypes = {
// If true, the scrollbar will append mx_IndicatorScrollbar_leftOverflowIndicator
// and mx_IndicatorScrollbar_rightOverflowIndicator elements to the list for positioning
// by the parent element.
@ -38,6 +38,8 @@ export default class IndicatorScrollbar extends React.Component {
this.checkOverflow = this.checkOverflow.bind(this);
this._scrollElement = null;
this._autoHideScrollbar = null;
this._likelyTrackpadUser = null;
this._checkAgainForTrackpad = 0; // ts in milliseconds to recheck this._likelyTrackpadUser
this.state = {
leftIndicatorOffset: 0,
@ -129,9 +131,39 @@ export default class IndicatorScrollbar extends React.Component {
// the harshness of the scroll behaviour. Should be a value between 0 and 1.
const yRetention = 1.0;
if (Math.abs(e.deltaX) < xyThreshold) {
// whenever we see horizontal scrolling, assume the user is on a trackpad
// for at least the next 1 minute.
const now = new Date().getTime();
if (Math.abs(e.deltaX) > 0) {
this._likelyTrackpadUser = true;
this._checkAgainForTrackpad = now + (1 * 60 * 1000);
} else {
// if we haven't seen any horizontal scrolling for a while, assume
// the user might have plugged in a mousewheel
if (this._likelyTrackpadUser && now >= this._checkAgainForTrackpad) {
this._likelyTrackpadUser = false;
}
}
// don't mess with the horizontal scroll for trackpad users
// See https://github.com/vector-im/riot-web/issues/10005
if (this._likelyTrackpadUser) {
return;
}
if (Math.abs(e.deltaX) <= xyThreshold) { // we are vertically scrolling.
// HACK: We increase the amount of scroll to counteract smooth scrolling browsers.
// Smooth scrolling browsers (Firefox) use the relative area to determine the scroll
// amount, which means the likely small area of content results in a small amount of
// movement - not what people expect. We pick arbitrary values for when to apply more
// scroll, and how much to apply. On Windows 10, Chrome scrolls 100 units whereas
// Firefox scrolls just 3 due to smooth scrolling.
const additionalScroll = e.deltaY < 0 ? -50 : 50;
// noinspection JSSuspiciousNameCombination
this._scrollElement.scrollLeft += e.deltaY * yRetention;
const val = Math.abs(e.deltaY) < 25 ? (e.deltaY + additionalScroll) : e.deltaY;
this._scrollElement.scrollLeft += val * yRetention;
}
}
};

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 Vector Creations Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -18,11 +19,14 @@ import Matrix from 'matrix-js-sdk';
const InteractiveAuth = Matrix.InteractiveAuth;
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import {getEntryComponentForLoginType} from '../views/auth/InteractiveAuthEntryComponents';
export default React.createClass({
import sdk from '../../index';
export default createReactClass({
displayName: 'InteractiveAuth',
propTypes: {
@ -60,7 +64,7 @@ export default React.createClass({
inputs: PropTypes.object,
// As js-sdk interactive-auth
makeRegistrationUrl: PropTypes.func,
requestEmailToken: PropTypes.func,
sessionId: PropTypes.string,
clientSecret: PropTypes.string,
emailSid: PropTypes.string,
@ -90,12 +94,14 @@ export default React.createClass({
this._authLogic = new InteractiveAuth({
authData: this.props.authData,
doRequest: this._requestCallback,
busyChanged: this._onBusyChanged,
inputs: this.props.inputs,
stateUpdated: this._authStateUpdated,
matrixClient: this.props.matrixClient,
sessionId: this.props.sessionId,
clientSecret: this.props.clientSecret,
emailSid: this.props.emailSid,
requestEmailToken: this._requestEmailToken,
});
this._authLogic.attemptAuth().then((result) => {
@ -115,7 +121,7 @@ export default React.createClass({
this.setState({
errorText: msg,
});
}).done();
});
this._intervalId = null;
if (this.props.poll) {
@ -133,6 +139,19 @@ export default React.createClass({
}
},
_requestEmailToken: async function(...args) {
this.setState({
busy: true,
});
try {
return await this.props.requestEmailToken(...args);
} finally {
this.setState({
busy: false,
});
}
},
tryContinue: function() {
if (this.refs.stageComponent && this.refs.stageComponent.tryContinue) {
this.refs.stageComponent.tryContinue();
@ -150,27 +169,26 @@ export default React.createClass({
});
},
_requestCallback: function(auth, background) {
const makeRequestPromise = this.props.makeRequest(auth);
_requestCallback: function(auth) {
// This wrapper just exists because the js-sdk passes a second
// 'busy' param for backwards compat. This throws the tests off
// so discard it here.
return this.props.makeRequest(auth);
},
// if it's a background request, just do it: we don't want
// it to affect the state of our UI.
if (background) return makeRequestPromise;
// otherwise, manage the state of the spinner and error messages
this.setState({
busy: true,
errorText: null,
stageErrorText: null,
});
return makeRequestPromise.finally(() => {
if (this._unmounted) {
return;
}
_onBusyChanged: function(busy) {
// if we've started doing stuff, reset the error messages
if (busy) {
this.setState({
busy: true,
errorText: null,
stageErrorText: null,
});
} else {
this.setState({
busy: false,
});
});
}
},
_setFocus: function() {
@ -185,7 +203,14 @@ export default React.createClass({
_renderCurrentStage: function() {
const stage = this.state.authStage;
if (!stage) return null;
if (!stage) {
if (this.state.busy) {
const Loader = sdk.getComponent("elements.Spinner");
return <Loader />;
} else {
return null;
}
}
const StageComponent = getEntryComponentForLoginType(stage);
return (
@ -202,7 +227,6 @@ export default React.createClass({
stageState={this.state.stageState}
fail={this._onAuthStageFailed}
setEmailSid={this._setEmailSid}
makeRegistrationUrl={this.props.makeRegistrationUrl}
showContinue={!this.props.continueIsManaged}
/>
);

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,13 +15,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { MatrixClient } from 'matrix-js-sdk';
import { KeyCode } from '../../Keyboard';
import { Key } from '../../Keyboard';
import sdk from '../../index';
import dis from '../../dispatcher';
import VectorConferenceHandler from '../../VectorConferenceHandler';
@ -30,7 +30,7 @@ import {_t} from "../../languageHandler";
import Analytics from "../../Analytics";
const LeftPanel = React.createClass({
const LeftPanel = createReactClass({
displayName: 'LeftPanel',
// NB. If you add props, don't forget to update
@ -53,16 +53,19 @@ const LeftPanel = React.createClass({
componentWillMount: function() {
this.focusedElement = null;
this._settingWatchRef = SettingsStore.watchSetting(
"feature_room_breadcrumbs", null, this._onBreadcrumbsChanged);
this._breadcrumbsWatcherRef = SettingsStore.watchSetting(
"breadcrumbs", null, this._onBreadcrumbsChanged);
this._tagPanelWatcherRef = SettingsStore.watchSetting(
"TagPanel.enableTagPanel", null, () => this.forceUpdate());
const useBreadcrumbs = SettingsStore.isFeatureEnabled("feature_room_breadcrumbs");
const useBreadcrumbs = !!SettingsStore.getValue("breadcrumbs");
Analytics.setBreadcrumbs(useBreadcrumbs);
this.setState({breadcrumbs: useBreadcrumbs});
},
componentWillUnmount: function() {
SettingsStore.unwatchSetting(this._settingWatchRef);
SettingsStore.unwatchSetting(this._breadcrumbsWatcherRef);
SettingsStore.unwatchSetting(this._tagPanelWatcherRef);
},
shouldComponentUpdate: function(nextProps, nextState) {
@ -82,6 +85,9 @@ const LeftPanel = React.createClass({
if (this.state.searchFilter !== nextState.searchFilter) {
return true;
}
if (this.state.searchExpanded !== nextState.searchExpanded) {
return true;
}
return false;
},
@ -110,37 +116,38 @@ const LeftPanel = React.createClass({
this.focusedElement = null;
},
_onKeyDown: function(ev) {
_onFilterKeyDown: function(ev) {
if (!this.focusedElement) return;
let handled = true;
switch (ev.keyCode) {
case KeyCode.TAB:
this._onMoveFocus(ev.shiftKey);
break;
case KeyCode.UP:
this._onMoveFocus(true);
break;
case KeyCode.DOWN:
this._onMoveFocus(false);
break;
case KeyCode.ENTER:
this._onMoveFocus(false);
if (this.focusedElement) {
this.focusedElement.click();
switch (ev.key) {
// On enter of rooms filter select and activate first room if such one exists
case Key.ENTER: {
const firstRoom = ev.target.closest(".mx_LeftPanel").querySelector(".mx_RoomTile");
if (firstRoom) {
firstRoom.click();
}
break;
default:
handled = false;
}
if (handled) {
ev.stopPropagation();
ev.preventDefault();
}
}
},
_onMoveFocus: function(up) {
_onKeyDown: function(ev) {
if (!this.focusedElement) return;
switch (ev.key) {
case Key.TAB:
this._onMoveFocus(ev, ev.shiftKey);
break;
case Key.ARROW_UP:
this._onMoveFocus(ev, true, true);
break;
case Key.ARROW_DOWN:
this._onMoveFocus(ev, false, true);
break;
}
},
_onMoveFocus: function(ev, up, trap) {
let element = this.focusedElement;
// unclear why this isn't needed
@ -174,28 +181,24 @@ const LeftPanel = React.createClass({
if (element) {
classes = element.classList;
if (classes.contains("mx_LeftPanel")) { // we hit the top
element = up ? element.lastElementChild : element.firstElementChild;
descending = true;
}
}
} while (element && !(
classes.contains("mx_RoomTile") ||
classes.contains("mx_textinput_search")));
classes.contains("mx_RoomSubList_label") ||
classes.contains("mx_LeftPanel_filterRooms")));
if (element) {
ev.stopPropagation();
ev.preventDefault();
element.focus();
this.focusedElement = element;
this.focusedDescending = descending;
} else if (trap) {
// if navigation is via up/down arrow-keys, trap in the widget so it doesn't send to composer
ev.stopPropagation();
ev.preventDefault();
}
},
onHideClick: function() {
dis.dispatch({
action: 'hide_left_panel',
});
},
onSearch: function(term) {
this.setState({ searchFilter: term });
},
@ -204,12 +207,23 @@ const LeftPanel = React.createClass({
if (source === "keyboard") {
dis.dispatch({action: 'focus_composer'});
}
this.setState({searchExpanded: false});
},
collectRoomList: function(ref) {
this._roomList = ref;
},
_onSearchFocus: function() {
this.setState({searchExpanded: true});
},
_onSearchBlur: function(event) {
if (event.target.value.length === 0) {
this.setState({searchExpanded: false});
}
},
render: function() {
const RoomList = sdk.getComponent('rooms.RoomList');
const RoomBreadcrumbs = sdk.getComponent('rooms.RoomBreadcrumbs');
@ -218,6 +232,7 @@ const LeftPanel = React.createClass({
const TopLeftMenuButton = sdk.getComponent('structures.TopLeftMenuButton');
const SearchBox = sdk.getComponent('structures.SearchBox');
const CallPreview = sdk.getComponent('voip.CallPreview');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const tagPanelEnabled = SettingsStore.getValue("TagPanel.enableTagPanel");
let tagPanelContainer;
@ -241,11 +256,25 @@ const LeftPanel = React.createClass({
},
);
let exploreButton;
if (!this.props.collapsed) {
exploreButton = (
<div className={classNames("mx_LeftPanel_explore", {"mx_LeftPanel_explore_hidden": this.state.searchExpanded})}>
<AccessibleButton onClick={() => dis.dispatch({action: 'view_room_directory'})}>{_t("Explore")}</AccessibleButton>
</div>
);
}
const searchBox = (<SearchBox
className="mx_LeftPanel_filterRooms"
enableRoomSearchFocus={true}
placeholder={ _t('Filter room names') }
blurredPlaceholder={ _t('Filter') }
placeholder={ _t('Filter rooms…') }
onKeyDown={this._onFilterKeyDown}
onSearch={ this.onSearch }
onCleared={ this.onSearchCleared }
onFocus={this._onSearchFocus}
onBlur={this._onSearchBlur}
collapsed={this.props.collapsed} />);
let breadcrumbs;
@ -256,12 +285,18 @@ const LeftPanel = React.createClass({
return (
<div className={containerClasses}>
{ tagPanelContainer }
<aside className={"mx_LeftPanel dark-panel"} onKeyDown={ this._onKeyDown } onFocus={ this._onFocus } onBlur={ this._onBlur }>
<TopLeftMenuButton collapsed={ this.props.collapsed } />
<aside className="mx_LeftPanel dark-panel">
<TopLeftMenuButton collapsed={this.props.collapsed} />
{ breadcrumbs }
{ searchBox }
<CallPreview ConferenceHandler={VectorConferenceHandler} />
<div className="mx_LeftPanel_exploreAndFilterRow" onKeyDown={this._onKeyDown} onFocus={this._onFocus} onBlur={this._onBlur}>
{ exploreButton }
{ searchBox }
</div>
<RoomList
onKeyDown={this._onKeyDown}
onFocus={this._onFocus}
onBlur={this._onBlur}
ref={this.collectRoomList}
resizeNotifier={this.props.resizeNotifier}
collapsed={this.props.collapsed}

View file

@ -16,14 +16,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as Matrix from 'matrix-js-sdk';
import { MatrixClient } from 'matrix-js-sdk';
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import { DragDropContext } from 'react-beautiful-dnd';
import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard';
import { Key, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard';
import PageTypes from '../../PageTypes';
import CallMediaHandler from '../../CallMediaHandler';
import { fixupColorFonts } from '../../utils/FontManager';
import sdk from '../../index';
import dis from '../../dispatcher';
import sessionStore from '../../stores/SessionStore';
@ -41,6 +43,13 @@ import {Resizer, CollapseDistributor} from '../../resizer';
// NB. this is just for server notices rather than pinned messages in general.
const MAX_PINNED_NOTICES_PER_ROOM = 2;
function canElementReceiveInput(el) {
return el.tagName === "INPUT" ||
el.tagName === "TEXTAREA" ||
el.tagName === "SELECT" ||
!!el.getAttribute("contenteditable");
}
/**
* This is what our MatrixChat shows when we are logged in. The precise view is
* determined by the page_type property.
@ -50,11 +59,11 @@ const MAX_PINNED_NOTICES_PER_ROOM = 2;
*
* Components mounted below us can access the matrix client via the react context.
*/
const LoggedInView = React.createClass({
const LoggedInView = createReactClass({
displayName: 'LoggedInView',
propTypes: {
matrixClient: PropTypes.instanceOf(Matrix.MatrixClient).isRequired,
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
page_type: PropTypes.string.isRequired,
onRoomCreated: PropTypes.func,
@ -70,7 +79,7 @@ const LoggedInView = React.createClass({
},
childContextTypes: {
matrixClient: PropTypes.instanceOf(Matrix.MatrixClient),
matrixClient: PropTypes.instanceOf(MatrixClient),
authCache: PropTypes.object,
},
@ -105,7 +114,7 @@ const LoggedInView = React.createClass({
CallMediaHandler.loadDevices();
document.addEventListener('keydown', this._onKeyDown);
document.addEventListener('keydown', this._onNativeKeyDown, false);
this._sessionStore = sessionStore;
this._sessionStoreToken = this._sessionStore.addListener(
@ -118,6 +127,8 @@ const LoggedInView = React.createClass({
this._matrixClient.on("accountData", this.onAccountData);
this._matrixClient.on("sync", this.onSync);
this._matrixClient.on("RoomState.events", this.onRoomStateEvents);
fixupColorFonts();
},
componentDidUpdate(prevProps) {
@ -133,7 +144,7 @@ const LoggedInView = React.createClass({
},
componentWillUnmount: function() {
document.removeEventListener('keydown', this._onKeyDown);
document.removeEventListener('keydown', this._onNativeKeyDown, false);
this._matrixClient.removeListener("accountData", this.onAccountData);
this._matrixClient.removeListener("sync", this.onSync);
this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents);
@ -269,6 +280,58 @@ const LoggedInView = React.createClass({
});
},
_onPaste: function(ev) {
let canReceiveInput = false;
let element = ev.target;
// test for all parents because the target can be a child of a contenteditable element
while (!canReceiveInput && element) {
canReceiveInput = canElementReceiveInput(element);
element = element.parentElement;
}
if (!canReceiveInput) {
// refocusing during a paste event will make the
// paste end up in the newly focused element,
// so dispatch synchronously before paste happens
dis.dispatch({action: 'focus_composer'}, true);
}
},
/*
SOME HACKERY BELOW:
React optimizes event handlers, by always attaching only 1 handler to the document for a given type.
It then internally determines the order in which React event handlers should be called,
emulating the capture and bubbling phases the DOM also has.
But, as the native handler for React is always attached on the document,
it will always run last for bubbling (first for capturing) handlers,
and thus React basically has its own event phases, and will always run
after (before for capturing) any native other event handlers (as they tend to be attached last).
So ideally one wouldn't mix React and native event handlers to have bubbling working as expected,
but we do need a native event handler here on the document,
to get keydown events when there is no focused element (target=body).
We also do need bubbling here to give child components a chance to call `stopPropagation()`,
for keydown events it can handle itself, and shouldn't be redirected to the composer.
So we listen with React on this component to get any events on focused elements, and get bubbling working as expected.
We also listen with a native listener on the document to get keydown events when no element is focused.
Bubbling is irrelevant here as the target is the body element.
*/
_onReactKeyDown: function(ev) {
// events caught while bubbling up on the root element
// of this component, so something must be focused.
this._onKeyDown(ev);
},
_onNativeKeyDown: function(ev) {
// only pass this if there is no focused element.
// if there is, _onKeyDown will be called by the
// react keydown handler that respects the react bubbling order.
if (ev.target === document.body) {
this._onKeyDown(ev);
}
},
_onKeyDown: function(ev) {
/*
@ -287,34 +350,26 @@ const LoggedInView = React.createClass({
let handled = false;
const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
const hasModifier = ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey ||
ev.key === Key.ALT || ev.key === Key.CONTROL || ev.key === Key.META || ev.key === Key.SHIFT;
switch (ev.keyCode) {
case KeyCode.UP:
case KeyCode.DOWN:
if (ev.altKey && !ev.shiftKey && !ev.ctrlKey && !ev.metaKey) {
const action = ev.keyCode == KeyCode.UP ?
'view_prev_room' : 'view_next_room';
dis.dispatch({action: action});
handled = true;
}
break;
case KeyCode.PAGE_UP:
case KeyCode.PAGE_DOWN:
if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
switch (ev.key) {
case Key.PAGE_UP:
case Key.PAGE_DOWN:
if (!hasModifier) {
this._onScrollKeyPressed(ev);
handled = true;
}
break;
case KeyCode.HOME:
case KeyCode.END:
case Key.HOME:
case Key.END:
if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
this._onScrollKeyPressed(ev);
handled = true;
}
break;
case KeyCode.KEY_K:
case Key.K:
if (ctrlCmdOnly) {
dis.dispatch({
action: 'focus_room_filter',
@ -322,11 +377,44 @@ const LoggedInView = React.createClass({
handled = true;
}
break;
case Key.BACKTICK:
if (ev.key !== "`") break;
// Ideally this would be CTRL+P for "Profile", but that's
// taken by the print dialog. CTRL+I for "Information"
// was previously chosen but conflicted with italics in
// composer, so CTRL+` it is
if (ctrlCmdOnly) {
dis.dispatch({
action: 'toggle_top_left_menu',
});
handled = true;
}
break;
}
if (handled) {
ev.stopPropagation();
ev.preventDefault();
} else if (!hasModifier) {
const isClickShortcut = ev.target !== document.body &&
(ev.key === Key.SPACE || ev.key === Key.ENTER);
// XXX: Remove after CIDER replaces Slate completely: https://github.com/vector-im/riot-web/issues/11036
// If using Slate, consume the Backspace without first focusing as it causes an implosion
if (ev.key === Key.BACKSPACE && !SettingsStore.getValue("useCiderComposer")) {
ev.stopPropagation();
return;
}
if (!isClickShortcut && ev.key !== Key.TAB && !canElementReceiveInput(ev.target)) {
// synchronous dispatch so we focus before key generates input
dis.dispatch({action: 'focus_composer'}, true);
ev.stopPropagation();
// we should *not* preventDefault() here as
// that would prevent typing in the now-focussed composer
}
}
},
@ -437,6 +525,7 @@ const LoggedInView = React.createClass({
const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage');
const GroupView = sdk.getComponent('structures.GroupView');
const MyGroups = sdk.getComponent('structures.MyGroups');
const ToastContainer = sdk.getComponent('structures.ToastContainer');
const MatrixToolbar = sdk.getComponent('globals.MatrixToolbar');
const CookieBar = sdk.getComponent('globals.CookieBar');
const NewVersionBar = sdk.getComponent('globals.NewVersionBar');
@ -538,8 +627,9 @@ const LoggedInView = React.createClass({
}
return (
<div className='mx_MatrixChat_wrapper' aria-hidden={this.props.hideToSRUsers} onMouseDown={this._onMouseDown} onMouseUp={this._onMouseUp}>
<div onPaste={this._onPaste} onKeyDown={this._onReactKeyDown} className='mx_MatrixChat_wrapper' aria-hidden={this.props.hideToSRUsers} onMouseDown={this._onMouseDown} onMouseUp={this._onMouseUp}>
{ topBar }
<ToastContainer />
<DragDropContext onDragEnd={this._onDragEnd}>
<div ref={this._setResizeContainerRef} className={bodyClasses}>
<LeftPanel

View file

@ -1,5 +1,6 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2019 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.
@ -79,9 +80,12 @@ export default class MainSplit extends React.Component {
const wasPanelSet = this.props.panel && !prevProps.panel;
const wasPanelCleared = !this.props.panel && prevProps.panel;
if (wasExpanded || wasPanelSet) {
if (this.resizeContainer && (wasExpanded || wasPanelSet)) {
// The resizer can only be created when **both** expanded and the panel is
// set. Once both are true, the container ref will mount, which is required
// for the resizer to work.
this._createResizer();
} else if (wasCollapsed || wasPanelCleared) {
} else if (this.resizer && (wasCollapsed || wasPanelCleared)) {
this.resizer.detach();
this.resizer = null;
}

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,7 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 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.
@ -24,16 +25,18 @@ import {wantsDateSeparator} from '../../DateUtils';
import sdk from '../../index';
import MatrixClientPeg from '../../MatrixClientPeg';
import SettingsStore from '../../settings/SettingsStore';
import {_t} from "../../languageHandler";
const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
const continuedTypes = ['m.sticker', 'm.room.message'];
const isMembershipChange = (e) => e.getType() === 'm.room.member' || e.getType() === 'm.room.third_party_invite';
/* (almost) stateless UI component which builds the event tiles in the room timeline.
*/
module.exports = React.createClass({
displayName: 'MessagePanel',
propTypes: {
export default class MessagePanel extends React.Component {
static propTypes = {
// true to give the component a 'display: none' style.
hidden: PropTypes.bool,
@ -51,6 +54,10 @@ module.exports = React.createClass({
// ID of an event to highlight. If undefined, no event will be highlighted.
highlightedEventId: PropTypes.string,
// The room these events are all in together, if any.
// (The notification panel won't have a room here, for example.)
room: PropTypes.object,
// Should we show URL Previews
showUrlPreview: PropTypes.bool,
@ -92,58 +99,111 @@ module.exports = React.createClass({
// show timestamps always
alwaysShowTimestamps: PropTypes.bool,
},
componentWillMount: function() {
// the event after which we put a visible unread marker on the last
// render cycle; null if readMarkerVisible was false or the RM was
// suppressed (eg because it was at the end of the timeline)
this.currentReadMarkerEventId = null;
// helper function to access relations for an event
getRelationsForEvent: PropTypes.func,
// the event after which we are showing a disappearing read marker
// animation
this.currentGhostEventId = null;
// whether to show reactions for an event
showReactions: PropTypes.bool,
};
constructor() {
super();
this.state = {
// previous positions the read marker has been in, so we can
// display 'ghost' read markers that are animating away
ghostReadMarkers: [],
};
// opaque readreceipt info for each userId; used by ReadReceiptMarker
// to manage its animations
this._readReceiptMap = {};
// Remember the read marker ghost node so we can do the cleanup that
// Velocity requires
this._readMarkerGhostNode = null;
// Track read receipts by event ID. For each _shown_ event ID, we store
// the list of read receipts to display:
// [
// {
// userId: string,
// member: RoomMember,
// ts: number,
// },
// ]
// This is recomputed on each render. It's only stored on the component
// for ease of passing the data around since it's computed in one pass
// over all events.
this._readReceiptsByEvent = {};
this._isMounted = true;
},
// Track read receipts by user ID. For each user ID we've ever shown a
// a read receipt for, we store an object:
// {
// lastShownEventId: string,
// receipt: {
// userId: string,
// member: RoomMember,
// ts: number,
// },
// }
// so that we can always keep receipts displayed by reverting back to
// the last shown event for that user ID when needed. This may feel like
// it duplicates the receipt storage in the room, but at this layer, we
// are tracking _shown_ event IDs, which the JS SDK knows nothing about.
// This is recomputed on each render, using the data from the previous
// render as our fallback for any user IDs we can't match a receipt to a
// displayed event in the current render cycle.
this._readReceiptsByUserId = {};
// Cache hidden events setting on mount since Settings is expensive to
// query, and we check this in a hot code path.
this._showHiddenEventsInTimeline =
SettingsStore.getValue("showHiddenEventsInTimeline");
componentWillUnmount: function() {
this._isMounted = false;
},
}
componentDidMount() {
this._isMounted = true;
}
componentWillUnmount() {
this._isMounted = false;
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.readMarkerVisible && this.props.readMarkerEventId !== prevProps.readMarkerEventId) {
const ghostReadMarkers = this.state.ghostReadMarkers;
ghostReadMarkers.push(prevProps.readMarkerEventId);
this.setState({
ghostReadMarkers,
});
}
}
/* get the DOM node representing the given event */
getNodeForEventId: function(eventId) {
getNodeForEventId(eventId) {
if (!this.eventNodes) {
return undefined;
}
return this.eventNodes[eventId];
},
}
/* return true if the content is fully scrolled down right now; else false.
*/
isAtBottom: function() {
isAtBottom() {
return this.refs.scrollPanel
&& this.refs.scrollPanel.isAtBottom();
},
}
/* get the current scroll state. See ScrollPanel.getScrollState for
* details.
*
* returns null if we are not mounted.
*/
getScrollState: function() {
getScrollState() {
if (!this.refs.scrollPanel) { return null; }
return this.refs.scrollPanel.getScrollState();
},
}
// returns one of:
//
@ -151,7 +211,7 @@ module.exports = React.createClass({
// -1: read marker is above the window
// 0: read marker is within the window
// +1: read marker is below the window
getReadMarkerPosition: function() {
getReadMarkerPosition() {
const readMarker = this.refs.readMarkerNode;
const messageWrapper = this.refs.scrollPanel;
@ -171,45 +231,45 @@ module.exports = React.createClass({
} else {
return 1;
}
},
}
/* jump to the top of the content.
*/
scrollToTop: function() {
scrollToTop() {
if (this.refs.scrollPanel) {
this.refs.scrollPanel.scrollToTop();
}
},
}
/* jump to the bottom of the content.
*/
scrollToBottom: function() {
scrollToBottom() {
if (this.refs.scrollPanel) {
this.refs.scrollPanel.scrollToBottom();
}
},
}
/**
* Page up/down.
*
* @param {number} mult: -1 to page up, +1 to page down
*/
scrollRelative: function(mult) {
scrollRelative(mult) {
if (this.refs.scrollPanel) {
this.refs.scrollPanel.scrollRelative(mult);
}
},
}
/**
* Scroll up/down in response to a scroll key
*
* @param {KeyboardEvent} ev: the keyboard event to handle
*/
handleScrollKey: function(ev) {
handleScrollKey(ev) {
if (this.refs.scrollPanel) {
this.refs.scrollPanel.handleScrollKey(ev);
}
},
}
/* jump to the given event id.
*
@ -221,30 +281,41 @@ module.exports = React.createClass({
* node (specifically, the bottom of it) will be positioned. If omitted, it
* defaults to 0.
*/
scrollToEvent: function(eventId, pixelOffset, offsetBase) {
scrollToEvent(eventId, pixelOffset, offsetBase) {
if (this.refs.scrollPanel) {
this.refs.scrollPanel.scrollToToken(eventId, pixelOffset, offsetBase);
}
},
}
scrollToEventIfNeeded(eventId) {
const node = this.eventNodes[eventId];
if (node) {
node.scrollIntoView({block: "nearest", behavior: "instant"});
}
}
/* check the scroll state and send out pagination requests if necessary.
*/
checkFillState: function() {
checkFillState() {
if (this.refs.scrollPanel) {
this.refs.scrollPanel.checkFillState();
}
},
}
_isUnmounting: function() {
_isUnmounting() {
return !this._isMounted;
},
}
// TODO: Implement granular (per-room) hide options
_shouldShowEvent: function(mxEv) {
_shouldShowEvent(mxEv) {
if (mxEv.sender && MatrixClientPeg.get().isUserIgnored(mxEv.sender.userId)) {
return false; // ignored = no show (only happens if the ignore happens after an event was received)
}
if (this._showHiddenEventsInTimeline) {
return true;
}
const EventTile = sdk.getComponent('rooms.EventTile');
if (!EventTile.haveTileForEvent(mxEv)) {
return false; // no tile = no show
@ -254,15 +325,87 @@ module.exports = React.createClass({
if (this.props.highlightedEventId === mxEv.getId()) return true;
return !shouldHideEvent(mxEv);
},
}
_getEventTiles: function() {
_readMarkerForEvent(eventId, isLastEvent) {
const visible = !isLastEvent && this.props.readMarkerVisible;
if (this.props.readMarkerEventId === eventId) {
let hr;
// if the read marker comes at the end of the timeline (except
// for local echoes, which are excluded from RMs, because they
// don't have useful event ids), we don't want to show it, but
// we still want to create the <li/> for it so that the
// algorithms which depend on its position on the screen aren't
// confused.
if (visible) {
hr = <hr className="mx_RoomView_myReadMarker"
style={{opacity: 1, width: '99%'}}
/>;
}
return (
<li key={"readMarker_"+eventId} ref="readMarkerNode"
className="mx_RoomView_myReadMarker_container">
{ hr }
</li>
);
} else if (this.state.ghostReadMarkers.includes(eventId)) {
// We render 'ghost' read markers in the DOM while they
// transition away. This allows the actual read marker
// to be in the right place straight away without having
// to wait for the transition to finish.
// There are probably much simpler ways to do this transition,
// possibly using react-transition-group which handles keeping
// elements in the DOM whilst they transition out, although our
// case is a little more complex because only some of the items
// transition (ie. the read markers do but the event tiles do not)
// and TransitionGroup requires that all its children are Transitions.
const hr = <hr className="mx_RoomView_myReadMarker"
ref={this._collectGhostReadMarker}
onTransitionEnd={this._onGhostTransitionEnd}
data-eventid={eventId}
/>;
// give it a key which depends on the event id. That will ensure that
// we get a new DOM node (restarting the animation) when the ghost
// moves to a different event.
return (
<li key={"_readuptoghost_"+eventId}
className="mx_RoomView_myReadMarker_container">
{ hr }
</li>
);
}
return null;
}
_collectGhostReadMarker = (node) => {
if (node) {
// now the element has appeared, change the style which will trigger the CSS transition
requestAnimationFrame(() => {
node.style.width = '10%';
node.style.opacity = '0';
});
}
};
_onGhostTransitionEnd = (ev) => {
// we can now clean up the ghost element
const finishedEventId = ev.target.dataset.eventid;
this.setState({
ghostReadMarkers: this.state.ghostReadMarkers.filter(eid => eid !== finishedEventId),
});
};
_getEventTiles() {
const DateSeparator = sdk.getComponent('messages.DateSeparator');
const EventListSummary = sdk.getComponent('views.elements.EventListSummary');
const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary');
this.eventNodes = {};
let visible = false;
let i;
// first figure out which is the last event in the list which we're
@ -297,28 +440,104 @@ module.exports = React.createClass({
let prevEvent = null; // the last event we showed
// assume there is no read marker until proven otherwise
let readMarkerVisible = false;
// if the readmarker has moved, cancel any active ghost.
if (this.currentReadMarkerEventId && this.props.readMarkerEventId &&
this.props.readMarkerVisible &&
this.currentReadMarkerEventId !== this.props.readMarkerEventId) {
this.currentGhostEventId = null;
this._readReceiptsByEvent = {};
if (this.props.showReadReceipts) {
this._readReceiptsByEvent = this._getReadReceiptsByShownEvent();
}
const isMembershipChange = (e) => e.getType() === 'm.room.member';
for (i = 0; i < this.props.events.length; i++) {
const mxEv = this.props.events[i];
const eventId = mxEv.getId();
const last = (mxEv === lastShownEvent);
// Wrap initial room creation events into an EventListSummary
// Grouping only events sent by the same user that sent the `m.room.create` and only until
// the first non-state event or membership event which is not regarding the sender of the `m.room.create` event
const shouldGroup = (ev) => {
if (ev.getType() === "m.room.member"
&& (ev.getStateKey() !== mxEv.getSender() || ev.getContent()["membership"] !== "join")) {
return false;
}
if (ev.isState() && ev.getSender() === mxEv.getSender()) {
return true;
}
return false;
};
if (mxEv.getType() === "m.room.create") {
let summaryReadMarker = null;
const ts1 = mxEv.getTs();
if (this._wantsDateSeparator(prevEvent, mxEv.getDate())) {
const dateSeparator = <li key={ts1+'~'}><DateSeparator key={ts1+'~'} ts={ts1} /></li>;
ret.push(dateSeparator);
}
// If RM event is the first in the summary, append the RM after the summary
summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(mxEv.getId());
// If this m.room.create event should be shown (room upgrade) then show it before the summary
if (this._shouldShowEvent(mxEv)) {
// pass in the mxEv as prevEvent as well so no extra DateSeparator is rendered
ret.push(...this._getTilesForEvent(mxEv, mxEv, false));
}
const summarisedEvents = []; // Don't add m.room.create here as we don't want it inside the summary
for (;i + 1 < this.props.events.length; i++) {
const collapsedMxEv = this.props.events[i + 1];
// Ignore redacted/hidden member events
if (!this._shouldShowEvent(collapsedMxEv)) {
// If this hidden event is the RM and in or at end of a summary put RM after the summary.
summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(collapsedMxEv.getId());
continue;
}
if (!shouldGroup(collapsedMxEv) || this._wantsDateSeparator(mxEv, collapsedMxEv.getDate())) {
break;
}
// If RM event is in the summary, mark it as such and the RM will be appended after the summary.
summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(collapsedMxEv.getId());
summarisedEvents.push(collapsedMxEv);
}
// At this point, i = the index of the last event in the summary sequence
const eventTiles = summarisedEvents.map((e) => {
// In order to prevent DateSeparators from appearing in the expanded form
// of EventListSummary, render each member event as if the previous
// one was itself. This way, the timestamp of the previous event === the
// timestamp of the current event, and no DateSeparator is inserted.
return this._getTilesForEvent(e, e, e === lastShownEvent);
}).reduce((a, b) => a.concat(b), []);
// Get sender profile from the latest event in the summary as the m.room.create doesn't contain one
const ev = this.props.events[i];
ret.push(<EventListSummary
key="roomcreationsummary"
events={summarisedEvents}
onToggle={this._onHeightChanged} // Update scroll state
summaryMembers={[ev.sender]}
summaryText={_t("%(creator)s created and configured the room.", {
creator: ev.sender ? ev.sender.name : ev.getSender(),
})}
>
{ eventTiles }
</EventListSummary>);
if (summaryReadMarker) {
ret.push(summaryReadMarker);
}
prevEvent = mxEv;
continue;
}
const wantTile = this._shouldShowEvent(mxEv);
// Wrap consecutive member events in a ListSummary, ignore if redacted
if (isMembershipChange(mxEv) && wantTile) {
let readMarkerInMels = false;
let summaryReadMarker = null;
const ts1 = mxEv.getTs();
// Ensure that the key of the MemberEventListSummary does not change with new
// member events. This will prevent it from being re-created unnecessarily, and
@ -336,9 +555,7 @@ module.exports = React.createClass({
}
// If RM event is the first in the MELS, append the RM after MELS
if (mxEv.getId() === this.props.readMarkerEventId) {
readMarkerInMels = true;
}
summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(mxEv.getId());
const summarisedEvents = [mxEv];
for (;i + 1 < this.props.events.length; i++) {
@ -347,9 +564,7 @@ module.exports = React.createClass({
// Ignore redacted/hidden member events
if (!this._shouldShowEvent(collapsedMxEv)) {
// If this hidden event is the RM and in or at end of a MELS put RM after MELS.
if (collapsedMxEv.getId() === this.props.readMarkerEventId) {
readMarkerInMels = true;
}
summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(collapsedMxEv.getId());
continue;
}
@ -359,9 +574,7 @@ module.exports = React.createClass({
}
// If RM event is in MELS mark it as such and the RM will be appended after MELS.
if (collapsedMxEv.getId() === this.props.readMarkerEventId) {
readMarkerInMels = true;
}
summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(collapsedMxEv.getId());
summarisedEvents.push(collapsedMxEv);
}
@ -376,9 +589,9 @@ module.exports = React.createClass({
// In order to prevent DateSeparators from appearing in the expanded form
// of MemberEventListSummary, render each member event as if the previous
// one was itself. This way, the timestamp of the previous event === the
// timestamp of the current event, and no DateSeperator is inserted.
// timestamp of the current event, and no DateSeparator is inserted.
return this._getTilesForEvent(e, e, e === lastShownEvent);
}).reduce((a, b) => a.concat(b));
}).reduce((a, b) => a.concat(b), []);
if (eventTiles.length === 0) {
eventTiles = null;
@ -392,8 +605,8 @@ module.exports = React.createClass({
{ eventTiles }
</MemberEventListSummary>);
if (readMarkerInMels) {
ret.push(this._getReadMarkerTile(visible));
if (summaryReadMarker) {
ret.push(summaryReadMarker);
}
prevEvent = mxEv;
@ -408,48 +621,20 @@ module.exports = React.createClass({
prevEvent = mxEv;
}
let isVisibleReadMarker = false;
if (eventId === this.props.readMarkerEventId) {
visible = this.props.readMarkerVisible;
// if the read marker comes at the end of the timeline (except
// for local echoes, which are excluded from RMs, because they
// don't have useful event ids), we don't want to show it, but
// we still want to create the <li/> for it so that the
// algorithms which depend on its position on the screen aren't
// confused.
if (i >= lastShownNonLocalEchoIndex) {
visible = false;
}
ret.push(this._getReadMarkerTile(visible));
readMarkerVisible = visible;
isVisibleReadMarker = visible;
}
// XXX: there should be no need for a ghost tile - we should just use a
// a dispatch (user_activity_end) to start the RM animation.
if (eventId === this.currentGhostEventId) {
// if we're showing an animation, continue to show it.
ret.push(this._getReadMarkerGhostTile());
} else if (!isVisibleReadMarker &&
eventId === this.currentReadMarkerEventId) {
// there is currently a read-up-to marker at this point, but no
// more. Show an animation of it disappearing.
ret.push(this._getReadMarkerGhostTile());
this.currentGhostEventId = eventId;
}
const readMarker = this._readMarkerForEvent(eventId, i >= lastShownNonLocalEchoIndex);
if (readMarker) ret.push(readMarker);
}
this.currentReadMarkerEventId = readMarkerVisible ? this.props.readMarkerEventId : null;
return ret;
},
}
_getTilesForEvent: function(prevEvent, mxEv, last) {
_getTilesForEvent(prevEvent, mxEv, last) {
const EventTile = sdk.getComponent('rooms.EventTile');
const DateSeparator = sdk.getComponent('messages.DateSeparator');
const ret = [];
const isEditing = this.props.editState &&
this.props.editState.getEvent().getId() === mxEv.getId();
// is this a continuation of the previous message?
let continuation = false;
@ -506,48 +691,54 @@ module.exports = React.createClass({
// Local echos have a send "status".
const scrollToken = mxEv.status ? undefined : eventId;
let readReceipts;
if (this.props.showReadReceipts) {
readReceipts = this._getReadReceiptsForEvent(mxEv);
}
const readReceipts = this._readReceiptsByEvent[eventId];
ret.push(
<li key={eventId}
ref={this._collectEventNode.bind(this, eventId)}
data-scroll-tokens={scrollToken}>
<EventTile mxEvent={mxEv} continuation={continuation}
isRedacted={mxEv.isRedacted()}
onHeightChanged={this._onHeightChanged}
readReceipts={readReceipts}
readReceiptMap={this._readReceiptMap}
showUrlPreview={this.props.showUrlPreview}
checkUnmounting={this._isUnmounting}
eventSendStatus={mxEv.status}
tileShape={this.props.tileShape}
isTwelveHour={this.props.isTwelveHour}
permalinkCreator={this.props.permalinkCreator}
last={last} isSelectedEvent={highlight} />
</li>,
<li key={eventId}
ref={this._collectEventNode.bind(this, eventId)}
data-scroll-tokens={scrollToken}
>
<EventTile mxEvent={mxEv}
continuation={continuation}
isRedacted={mxEv.isRedacted()}
replacingEventId={mxEv.replacingEventId()}
editState={isEditing && this.props.editState}
onHeightChanged={this._onHeightChanged}
readReceipts={readReceipts}
readReceiptMap={this._readReceiptMap}
showUrlPreview={this.props.showUrlPreview}
checkUnmounting={this._isUnmounting}
eventSendStatus={mxEv.getAssociatedStatus()}
tileShape={this.props.tileShape}
isTwelveHour={this.props.isTwelveHour}
permalinkCreator={this.props.permalinkCreator}
last={last}
isSelectedEvent={highlight}
getRelationsForEvent={this.props.getRelationsForEvent}
showReactions={this.props.showReactions}
/>
</li>,
);
return ret;
},
}
_wantsDateSeparator: function(prevEvent, nextEventDate) {
_wantsDateSeparator(prevEvent, nextEventDate) {
if (prevEvent == null) {
// first event in the panel: depends if we could back-paginate from
// here.
return !this.props.suppressFirstDateSeparator;
}
return wantsDateSeparator(prevEvent.getDate(), nextEventDate);
},
}
// get a list of read receipts that should be shown next to this event
// Get a list of read receipts that should be shown next to this event
// Receipts are objects which have a 'userId', 'roomMember' and 'ts'.
_getReadReceiptsForEvent: function(event) {
_getReadReceiptsForEvent(event) {
const myUserId = MatrixClientPeg.get().credentials.userId;
// get list of read receipts, sorted most recent first
const room = MatrixClientPeg.get().getRoom(event.getRoomId());
const { room } = this.props;
if (!room) {
return null;
}
@ -566,81 +757,90 @@ module.exports = React.createClass({
ts: r.data ? r.data.ts : 0,
});
});
return receipts;
}
return receipts.sort((r1, r2) => {
return r2.ts - r1.ts;
});
},
// Get an object that maps from event ID to a list of read receipts that
// should be shown next to that event. If a hidden event has read receipts,
// they are folded into the receipts of the last shown event.
_getReadReceiptsByShownEvent() {
const receiptsByEvent = {};
const receiptsByUserId = {};
_getReadMarkerTile: function(visible) {
let hr;
if (visible) {
hr = <hr className="mx_RoomView_myReadMarker"
style={{opacity: 1, width: '99%'}}
/>;
let lastShownEventId;
for (const event of this.props.events) {
if (this._shouldShowEvent(event)) {
lastShownEventId = event.getId();
}
if (!lastShownEventId) {
continue;
}
const existingReceipts = receiptsByEvent[lastShownEventId] || [];
const newReceipts = this._getReadReceiptsForEvent(event);
receiptsByEvent[lastShownEventId] = existingReceipts.concat(newReceipts);
// Record these receipts along with their last shown event ID for
// each associated user ID.
for (const receipt of newReceipts) {
receiptsByUserId[receipt.userId] = {
lastShownEventId,
receipt,
};
}
}
return (
<li key="_readupto" ref="readMarkerNode"
className="mx_RoomView_myReadMarker_container">
{ hr }
</li>
);
},
_startAnimation: function(ghostNode) {
if (this._readMarkerGhostNode) {
Velocity.Utilities.removeData(this._readMarkerGhostNode);
// It's possible in some cases (for example, when a read receipt
// advances before we have paginated in the new event that it's marking
// received) that we can temporarily not have a matching event for
// someone which had one in the last. By looking through our previous
// mapping of receipts by user ID, we can cover recover any receipts
// that would have been lost by using the same event ID from last time.
for (const userId in this._readReceiptsByUserId) {
if (receiptsByUserId[userId]) {
continue;
}
const { lastShownEventId, receipt } = this._readReceiptsByUserId[userId];
const existingReceipts = receiptsByEvent[lastShownEventId] || [];
receiptsByEvent[lastShownEventId] = existingReceipts.concat(receipt);
receiptsByUserId[userId] = { lastShownEventId, receipt };
}
this._readMarkerGhostNode = ghostNode;
this._readReceiptsByUserId = receiptsByUserId;
if (ghostNode) {
Velocity(ghostNode, {opacity: '0', width: '10%'},
{duration: 400, easing: 'easeInSine',
delay: 1000});
// After grouping receipts by shown events, do another pass to sort each
// receipt list.
for (const eventId in receiptsByEvent) {
receiptsByEvent[eventId].sort((r1, r2) => {
return r2.ts - r1.ts;
});
}
},
_getReadMarkerGhostTile: function() {
const hr = <hr className="mx_RoomView_myReadMarker"
style={{opacity: 1, width: '99%'}}
ref={this._startAnimation}
/>;
return receiptsByEvent;
}
// give it a key which depends on the event id. That will ensure that
// we get a new DOM node (restarting the animation) when the ghost
// moves to a different event.
return (
<li key={"_readuptoghost_"+this.currentGhostEventId}
className="mx_RoomView_myReadMarker_container">
{ hr }
</li>
);
},
_collectEventNode: function(eventId, node) {
_collectEventNode = (eventId, node) => {
this.eventNodes[eventId] = node;
},
}
// once dynamic content in the events load, make the scrollPanel check the
// scroll offsets.
_onHeightChanged: function() {
_onHeightChanged = () => {
const scrollPanel = this.refs.scrollPanel;
if (scrollPanel) {
scrollPanel.checkScroll();
}
},
};
_onTypingShown: function() {
_onTypingShown = () => {
const scrollPanel = this.refs.scrollPanel;
// this will make the timeline grow, so checkScroll
scrollPanel.checkScroll();
if (scrollPanel && scrollPanel.getScrollState().stuckAtBottom) {
scrollPanel.preventShrinking();
}
},
};
_onTypingHidden: function() {
_onTypingHidden = () => {
const scrollPanel = this.refs.scrollPanel;
if (scrollPanel) {
// as hiding the typing notifications doesn't
@ -651,9 +851,9 @@ module.exports = React.createClass({
// reveal added padding to balance the notifs disappearing.
scrollPanel.checkScroll();
}
},
};
updateTimelineMinHeight: function() {
updateTimelineMinHeight() {
const scrollPanel = this.refs.scrollPanel;
if (scrollPanel) {
@ -668,16 +868,16 @@ module.exports = React.createClass({
scrollPanel.preventShrinking();
}
}
},
}
onTimelineReset: function() {
onTimelineReset() {
const scrollPanel = this.refs.scrollPanel;
if (scrollPanel) {
scrollPanel.clearPreventShrinking();
}
},
}
render: function() {
render() {
const ScrollPanel = sdk.getComponent("structures.ScrollPanel");
const WhoIsTypingTile = sdk.getComponent("rooms.WhoIsTypingTile");
const Spinner = sdk.getComponent("elements.Spinner");
@ -700,7 +900,7 @@ module.exports = React.createClass({
);
let whoIsTyping;
if (this.props.room) {
if (this.props.room && !this.props.tileShape) {
whoIsTyping = (<WhoIsTypingTile
room={this.props.room}
onShown={this._onTypingShown}
@ -724,5 +924,5 @@ module.exports = React.createClass({
{ bottomSpinner }
</ScrollPanel>
);
},
});
}
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,20 +16,17 @@ limitations under the License.
*/
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk';
import sdk from '../../index';
import { _t } from '../../languageHandler';
import dis from '../../dispatcher';
import withMatrixClient from '../../wrappers/withMatrixClient';
import AccessibleButton from '../views/elements/AccessibleButton';
export default withMatrixClient(React.createClass({
export default createReactClass({
displayName: 'MyGroups',
propTypes: {
matrixClient: PropTypes.object.isRequired,
},
getInitialState: function() {
return {
groups: null,
@ -36,6 +34,10 @@ export default withMatrixClient(React.createClass({
};
},
contextTypes: {
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
},
componentWillMount: function() {
this._fetch();
},
@ -45,7 +47,7 @@ export default withMatrixClient(React.createClass({
},
_fetch: function() {
this.props.matrixClient.getJoinedGroups().done((result) => {
this.context.matrixClient.getJoinedGroups().then((result) => {
this.setState({groups: result.groups, error: null});
}, (err) => {
if (err.errcode === 'M_GUEST_ACCESS_FORBIDDEN') {
@ -146,4 +148,4 @@ export default withMatrixClient(React.createClass({
</div>
</div>;
},
}));
});

View file

@ -1,5 +1,6 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2019 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,18 +15,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
const React = require('react');
const ReactDOM = require("react-dom");
import React from 'react';
import createReactClass from 'create-react-class';
import { _t } from '../../languageHandler';
const Matrix = require("matrix-js-sdk");
const sdk = require('../../index');
const MatrixClientPeg = require("../../MatrixClientPeg");
const dis = require("../../dispatcher");
/*
* Component which shows the global notification list using a TimelinePanel
*/
const NotificationPanel = React.createClass({
const NotificationPanel = createReactClass({
displayName: 'NotificationPanel',
propTypes: {
@ -39,20 +38,21 @@ const NotificationPanel = React.createClass({
const timelineSet = MatrixClientPeg.get().getNotifTimelineSet();
if (timelineSet) {
return (
<TimelinePanel key={"NotificationPanel_" + this.props.roomId}
className="mx_NotificationPanel"
manageReadReceipts={false}
manageReadMarkers={false}
timelineSet={timelineSet}
showUrlPreview = {false}
tileShape="notif"
empty={_t('You have no visible notifications')}
/>
<div className="mx_NotificationPanel" role="tabpanel">
<TimelinePanel key={"NotificationPanel_" + this.props.roomId}
manageReadReceipts={false}
manageReadMarkers={false}
timelineSet={timelineSet}
showUrlPreview={false}
tileShape="notif"
empty={_t('You have no visible notifications')}
/>
</div>
);
} else {
console.error("No notifTimelineSet available!");
return (
<div className="mx_NotificationPanel">
<div className="mx_NotificationPanel" role="tabpanel">
<Loader />
</div>
);

View file

@ -3,6 +3,7 @@ Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -26,13 +27,14 @@ import { MatrixClient } from 'matrix-js-sdk';
import RateLimitedFunc from '../../ratelimitedfunc';
import { showGroupInviteDialog, showGroupAddRoomDialog } from '../../GroupAddressPicker';
import GroupStore from '../../stores/GroupStore';
import SettingsStore from "../../settings/SettingsStore";
export default class RightPanel extends React.Component {
static get propTypes() {
return {
roomId: React.PropTypes.string, // if showing panels for a given room, this is set
groupId: React.PropTypes.string, // if showing panels for a given group, this is set
user: React.PropTypes.object,
roomId: PropTypes.string, // if showing panels for a given room, this is set
groupId: PropTypes.string, // if showing panels for a given group, this is set
user: PropTypes.object,
};
}
@ -164,6 +166,7 @@ export default class RightPanel extends React.Component {
render() {
const MemberList = sdk.getComponent('rooms.MemberList');
const MemberInfo = sdk.getComponent('rooms.MemberInfo');
const UserInfo = sdk.getComponent('right_panel.UserInfo');
const ThirdPartyMemberInfo = sdk.getComponent('rooms.ThirdPartyMemberInfo');
const NotificationPanel = sdk.getComponent('structures.NotificationPanel');
const FilePanel = sdk.getComponent('structures.FilePanel');
@ -182,14 +185,46 @@ export default class RightPanel extends React.Component {
} else if (this.state.phase === RightPanel.Phase.GroupRoomList) {
panel = <GroupRoomList groupId={this.props.groupId} key={this.props.groupId} />;
} else if (this.state.phase === RightPanel.Phase.RoomMemberInfo) {
panel = <MemberInfo member={this.state.member} key={this.props.roomId || this.state.member.userId} />;
if (SettingsStore.isFeatureEnabled("feature_dm_verification")) {
const onClose = () => {
dis.dispatch({
action: "view_user",
member: null,
});
};
panel = <UserInfo
user={this.state.member}
roomId={this.props.roomId}
key={this.props.roomId || this.state.member.userId}
onClose={onClose}
/>;
} else {
panel = <MemberInfo member={this.state.member} key={this.props.roomId || this.state.member.userId} />;
}
} else if (this.state.phase === RightPanel.Phase.Room3pidMemberInfo) {
panel = <ThirdPartyMemberInfo event={this.state.event} key={this.props.roomId} />;
} else if (this.state.phase === RightPanel.Phase.GroupMemberInfo) {
panel = <GroupMemberInfo
groupMember={this.state.member}
groupId={this.props.groupId}
key={this.state.member.user_id} />;
if (SettingsStore.isFeatureEnabled("feature_dm_verification")) {
const onClose = () => {
dis.dispatch({
action: "view_user",
member: null,
});
};
panel = <UserInfo
user={this.state.member}
groupId={this.props.groupId}
key={this.state.member.userId}
onClose={onClose} />;
} else {
panel = (
<GroupMemberInfo
groupMember={this.state.member}
groupId={this.props.groupId}
key={this.state.member.user_id}
/>
);
}
} else if (this.state.phase === RightPanel.Phase.GroupRoomInfo) {
panel = <GroupRoomInfo
groupRoomId={this.state.groupRoomId}

View file

@ -1,5 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,9 +16,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
const React = require('react');
import React from 'react';
import createReactClass from 'create-react-class';
const MatrixClientPeg = require('../../MatrixClientPeg');
const ContentRepo = require("matrix-js-sdk").ContentRepo;
@ -25,7 +26,7 @@ const sdk = require('../../index');
const dis = require('../../dispatcher');
import { linkifyAndSanitizeHtml } from '../../HtmlUtils';
import Promise from 'bluebird';
import PropTypes from 'prop-types';
import { _t } from '../../languageHandler';
import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils';
import Analytics from '../../Analytics';
@ -37,12 +38,12 @@ function track(action) {
Analytics.trackEvent('RoomDirectory', action);
}
module.exports = React.createClass({
module.exports = createReactClass({
displayName: 'RoomDirectory',
propTypes: {
config: React.PropTypes.object,
onFinished: React.PropTypes.func.isRequired,
config: PropTypes.object,
onFinished: PropTypes.func.isRequired,
},
getDefaultProps: function() {
@ -65,7 +66,7 @@ module.exports = React.createClass({
},
childContextTypes: {
matrixClient: React.PropTypes.object,
matrixClient: PropTypes.object,
},
getChildContext: function() {
@ -87,7 +88,7 @@ module.exports = React.createClass({
this.setState({protocolsLoading: false});
return;
}
MatrixClientPeg.get().getThirdpartyProtocols().done((response) => {
MatrixClientPeg.get().getThirdpartyProtocols().then((response) => {
this.protocols = response;
this.setState({protocolsLoading: false});
}, (err) => {
@ -133,19 +134,23 @@ module.exports = React.createClass({
publicRooms: [],
loading: true,
});
this.getMoreRooms().done();
this.getMoreRooms();
},
getMoreRooms: function() {
if (!MatrixClientPeg.get()) return Promise.resolve();
this.setState({
loading: true,
});
const my_filter_string = this.state.filterString;
const my_server = this.state.roomServer;
// remember the next batch token when we sent the request
// too. If it's changed, appending to the list will corrupt it.
const my_next_batch = this.nextBatch;
const opts = {limit: 20};
if (my_server != MatrixClientPeg.getHomeServerName()) {
if (my_server != MatrixClientPeg.getHomeserverName()) {
opts.server = my_server;
}
if (this.state.instanceId) {
@ -240,7 +245,7 @@ module.exports = React.createClass({
if (!alias) return;
step = _t('delete the alias.');
return MatrixClientPeg.get().deleteAlias(alias);
}).done(() => {
}).then(() => {
modal.close();
this.refreshRoomList();
}, (err) => {
@ -320,12 +325,7 @@ module.exports = React.createClass({
}
},
onCreateRoomClicked: function() {
this.props.onFinished();
dis.dispatch({action: 'view_create_room'});
},
onJoinClick: function(alias) {
onJoinFromSearchClick: function(alias) {
// If we don't have a particular instance id selected, just show that rooms alias
if (!this.state.instanceId) {
// If the user specified an alias without a domain, add on whichever server is selected
@ -333,7 +333,7 @@ module.exports = React.createClass({
if (alias.indexOf(':') == -1) {
alias = alias + ':' + this.state.roomServer;
}
this.showRoomAlias(alias);
this.showRoomAlias(alias, true);
} else {
// This is a 3rd party protocol. Let's see if we can join it
const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId);
@ -347,9 +347,9 @@ module.exports = React.createClass({
});
return;
}
MatrixClientPeg.get().getThirdpartyLocation(protocolName, fields).done((resp) => {
MatrixClientPeg.get().getThirdpartyLocation(protocolName, fields).then((resp) => {
if (resp.length > 0 && resp[0].alias) {
this.showRoomAlias(resp[0].alias);
this.showRoomAlias(resp[0].alias, true);
} else {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Room not found', '', ErrorDialog, {
@ -367,13 +367,46 @@ module.exports = React.createClass({
}
},
showRoomAlias: function(alias) {
this.showRoom(null, alias);
onPreviewClick: function(ev, room) {
this.props.onFinished();
dis.dispatch({
action: 'view_room',
room_id: room.room_id,
should_peek: true,
});
ev.stopPropagation();
},
showRoom: function(room, room_alias) {
onViewClick: function(ev, room) {
this.props.onFinished();
const payload = {action: 'view_room'};
dis.dispatch({
action: 'view_room',
room_id: room.room_id,
should_peek: false,
});
ev.stopPropagation();
},
onJoinClick: function(ev, room) {
this.showRoom(room, null, true);
ev.stopPropagation();
},
onCreateRoomClick: function(room) {
this.props.onFinished();
dis.dispatch({action: 'view_create_room'});
},
showRoomAlias: function(alias, autoJoin=false) {
this.showRoom(null, alias, autoJoin);
},
showRoom: function(room, room_alias, autoJoin=false) {
this.props.onFinished();
const payload = {
action: 'view_room',
auto_join: autoJoin,
};
if (room) {
// Don't let the user view a room they won't be able to either
// peek or join: fail earlier so they don't have to click back
@ -408,74 +441,70 @@ module.exports = React.createClass({
dis.dispatch(payload);
},
getRows: function() {
getRow(room) {
const client = MatrixClientPeg.get();
const clientRoom = client.getRoom(room.room_id);
const hasJoinedRoom = clientRoom && clientRoom.getMyMembership() === "join";
const isGuest = client.isGuest();
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let previewButton;
let joinOrViewButton;
if (!this.state.publicRooms) return [];
const rooms = this.state.publicRooms;
const rows = [];
const self = this;
let guestRead; let guestJoin; let perms;
for (let i = 0; i < rooms.length; i++) {
guestRead = null;
guestJoin = null;
if (rooms[i].world_readable) {
guestRead = (
<div className="mx_RoomDirectory_perm">{ _t('World readable') }</div>
);
}
if (rooms[i].guest_can_join) {
guestJoin = (
<div className="mx_RoomDirectory_perm">{ _t('Guests can join') }</div>
);
}
perms = null;
if (guestRead || guestJoin) {
perms = <div className="mx_RoomDirectory_perms">{guestRead}{guestJoin}</div>;
}
let name = rooms[i].name || get_display_alias_for_room(rooms[i]) || _t('Unnamed room');
if (name.length > MAX_NAME_LENGTH) {
name = `${name.substring(0, MAX_NAME_LENGTH)}...`;
}
let topic = rooms[i].topic || '';
if (topic.length > MAX_TOPIC_LENGTH) {
topic = `${topic.substring(0, MAX_TOPIC_LENGTH)}...`;
}
topic = linkifyAndSanitizeHtml(topic);
rows.push(
<tr key={ rooms[i].room_id }
onClick={self.onRoomClicked.bind(self, rooms[i])}
// cancel onMouseDown otherwise shift-clicking highlights text
onMouseDown={(ev) => {ev.preventDefault();}}
>
<td className="mx_RoomDirectory_roomAvatar">
<BaseAvatar width={24} height={24} resizeMethod='crop'
name={ name } idName={ name }
url={ ContentRepo.getHttpUriForMxc(
MatrixClientPeg.get().getHomeserverUrl(),
rooms[i].avatar_url, 24, 24, "crop") } />
</td>
<td className="mx_RoomDirectory_roomDescription">
<div className="mx_RoomDirectory_name">{ name }</div>&nbsp;
{ perms }
<div className="mx_RoomDirectory_topic"
onClick={ function(e) { e.stopPropagation(); } }
dangerouslySetInnerHTML={{ __html: topic }} />
<div className="mx_RoomDirectory_alias">{ get_display_alias_for_room(rooms[i]) }</div>
</td>
<td className="mx_RoomDirectory_roomMemberCount">
{ rooms[i].num_joined_members }
</td>
</tr>,
if (room.world_readable && !hasJoinedRoom) {
previewButton = (
<AccessibleButton kind="secondary" onClick={(ev) => this.onPreviewClick(ev, room)}>{_t("Preview")}</AccessibleButton>
);
}
return rows;
if (hasJoinedRoom) {
joinOrViewButton = (
<AccessibleButton kind="secondary" onClick={(ev) => this.onViewClick(ev, room)}>{_t("View")}</AccessibleButton>
);
} else if (!isGuest || room.guest_can_join) {
joinOrViewButton = (
<AccessibleButton kind="primary" onClick={(ev) => this.onJoinClick(ev, room)}>{_t("Join")}</AccessibleButton>
);
}
let name = room.name || get_display_alias_for_room(room) || _t('Unnamed room');
if (name.length > MAX_NAME_LENGTH) {
name = `${name.substring(0, MAX_NAME_LENGTH)}...`;
}
let topic = room.topic || '';
if (topic.length > MAX_TOPIC_LENGTH) {
topic = `${topic.substring(0, MAX_TOPIC_LENGTH)}...`;
}
topic = linkifyAndSanitizeHtml(topic);
const avatarUrl = ContentRepo.getHttpUriForMxc(
MatrixClientPeg.get().getHomeserverUrl(),
room.avatar_url, 32, 32, "crop",
);
return (
<tr key={ room.room_id }
onClick={(ev) => this.onRoomClicked(room, ev)}
// cancel onMouseDown otherwise shift-clicking highlights text
onMouseDown={(ev) => {ev.preventDefault();}}
>
<td className="mx_RoomDirectory_roomAvatar">
<BaseAvatar width={32} height={32} resizeMethod='crop'
name={ name } idName={ name }
url={ avatarUrl } />
</td>
<td className="mx_RoomDirectory_roomDescription">
<div className="mx_RoomDirectory_name">{ name }</div>&nbsp;
<div className="mx_RoomDirectory_topic"
onClick={ (ev) => { ev.stopPropagation(); } }
dangerouslySetInnerHTML={{ __html: topic }} />
<div className="mx_RoomDirectory_alias">{ get_display_alias_for_room(room) }</div>
</td>
<td className="mx_RoomDirectory_roomMemberCount">
{ room.num_joined_members }
</td>
<td className="mx_RoomDirectory_preview">{previewButton}</td>
<td className="mx_RoomDirectory_join">{joinOrViewButton}</td>
</tr>
);
},
collectScrollPanel: function(element) {
@ -526,20 +555,26 @@ module.exports = React.createClass({
let content;
if (this.state.error) {
content = this.state.error;
} else if (this.state.protocolsLoading || this.state.loading) {
} else if (this.state.protocolsLoading) {
content = <Loader />;
} else {
const rows = this.getRows();
const rows = (this.state.publicRooms || []).map(room => this.getRow(room));
// we still show the scrollpanel, at least for now, because
// otherwise we don't fetch more because we don't get a fill
// request from the scrollpanel because there isn't one
let spinner;
if (this.state.loading) {
spinner = <Loader />;
}
let scrollpanel_content;
if (rows.length == 0) {
if (rows.length === 0 && !this.state.loading) {
scrollpanel_content = <i>{ _t('No rooms to show') }</i>;
} else {
scrollpanel_content = <table ref="directory_table" className="mx_RoomDirectory_table">
<tbody>
{ this.getRows() }
{ rows }
</tbody>
</table>;
}
@ -551,6 +586,7 @@ module.exports = React.createClass({
startAtBottom={false}
>
{ scrollpanel_content }
{ spinner }
</ScrollPanel>;
}
@ -572,10 +608,9 @@ module.exports = React.createClass({
instance_expected_field_type = this.protocols[protocolName].field_types[last_field];
}
let placeholder = _t('Search for a room');
let placeholder = _t('Find a room…');
if (!this.state.instanceId) {
placeholder = _t('Search for a room like #example') + ':' + this.state.roomServer;
placeholder = _t("Find a room… (e.g. %(exampleRoom)s)", {exampleRoom: "#example:" + this.state.roomServer});
} else if (instance_expected_field_type) {
placeholder = instance_expected_field_type.placeholder;
}
@ -591,27 +626,31 @@ module.exports = React.createClass({
listHeader = <div className="mx_RoomDirectory_listheader">
<DirectorySearchBox
className="mx_RoomDirectory_searchbox"
onChange={this.onFilterChange} onClear={this.onFilterClear} onJoinClick={this.onJoinClick}
onChange={this.onFilterChange} onClear={this.onFilterClear} onJoinClick={this.onJoinFromSearchClick}
placeholder={placeholder} showJoinButton={showJoinButton}
/>
<NetworkDropdown config={this.props.config} protocols={this.protocols} onOptionChange={this.onOptionChange} />
</div>;
}
const createRoomButton = (<AccessibleButton
onClick={this.onCreateRoomClicked}
className="mx_RoomDirectory_createRoom"
>{_t("Create new room")}</AccessibleButton>);
const explanation =
_t("If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.", null,
{a: sub => {
return (<AccessibleButton
kind="secondary"
onClick={this.onCreateRoomClick}
>{sub}</AccessibleButton>);
}},
);
return (
<BaseDialog
className={'mx_RoomDirectory_dialog'}
hasCancel={true}
onFinished={this.props.onFinished}
headerButton={createRoomButton}
title={_t("Room directory")}
title={_t("Explore rooms")}
>
<div className="mx_RoomDirectory">
<p>{explanation}</p>
<div className="mx_RoomDirectory_list">
{listHeader}
{content}

View file

@ -16,13 +16,12 @@ limitations under the License.
*/
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import Matrix from 'matrix-js-sdk';
import { _t, _td } from '../../languageHandler';
import sdk from '../../index';
import WhoIsTyping from '../../WhoIsTyping';
import MatrixClientPeg from '../../MatrixClientPeg';
import MemberAvatar from '../views/avatars/MemberAvatar';
import Resend from '../../Resend';
import * as cryptodevices from '../../cryptodevices';
import dis from '../../dispatcher';
@ -39,7 +38,7 @@ function getUnsentMessages(room) {
});
}
module.exports = React.createClass({
module.exports = createReactClass({
displayName: 'RoomStatusBar',
propTypes: {
@ -290,7 +289,7 @@ module.exports = React.createClass({
}
return <div className="mx_RoomStatusBar_connectionLostBar">
<img src={require("../../../res/img/e2e/warning.svg")} width="24" height="24" title={_t("Warning")} alt="" />
<img src={require("../../../res/img/feather-customised/warning-triangle.svg")} width="24" height="24" title={_t("Warning")} alt="" />
<div>
<div className="mx_RoomStatusBar_connectionLostBar_title">
{ title }
@ -304,12 +303,10 @@ module.exports = React.createClass({
// return suitable content for the main (text) part of the status bar.
_getContent: function() {
const EmojiText = sdk.getComponent('elements.EmojiText');
if (this._shouldShowConnectionError()) {
return (
<div className="mx_RoomStatusBar_connectionLostBar">
<img src={require("../../../res/img/e2e/warning.svg")} width="24" height="24" title="/!\ " alt="/!\ " />
<img src={require("../../../res/img/feather-customised/warning-triangle.svg")} width="24" height="24" title="/!\ " alt="/!\ " />
<div>
<div className="mx_RoomStatusBar_connectionLostBar_title">
{ _t('Connectivity to the server has been lost.') }

View file

@ -2,6 +2,7 @@
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018, 2019 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,7 +17,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React, {createRef} from 'react';
import createReactClass from 'create-react-class';
import classNames from 'classnames';
import sdk from '../../index';
import dis from '../../dispatcher';
@ -24,16 +26,17 @@ import Unread from '../../Unread';
import * as RoomNotifs from '../../RoomNotifs';
import * as FormattingUtils from '../../utils/FormattingUtils';
import IndicatorScrollbar from './IndicatorScrollbar';
import { KeyCode } from '../../Keyboard';
import {Key, KeyCode} from '../../Keyboard';
import { Group } from 'matrix-js-sdk';
import PropTypes from 'prop-types';
import RoomTile from "../views/rooms/RoomTile";
import LazyRenderList from "../views/elements/LazyRenderList";
import {_t} from "../../languageHandler";
// turn this on for drop & drag console debugging galore
const debug = false;
const RoomSubList = React.createClass({
const RoomSubList = createReactClass({
displayName: 'RoomSubList',
debug: debug,
@ -42,6 +45,7 @@ const RoomSubList = React.createClass({
list: PropTypes.arrayOf(PropTypes.object).isRequired,
label: PropTypes.string.isRequired,
tagName: PropTypes.string,
addRoomLabel: PropTypes.string,
order: PropTypes.string.isRequired,
@ -53,9 +57,8 @@ const RoomSubList = React.createClass({
collapsed: PropTypes.bool.isRequired, // is LeftPanel collapsed?
onHeaderClick: PropTypes.func,
incomingCall: PropTypes.object,
isFiltered: PropTypes.bool,
headerItems: PropTypes.node, // content shown in the sublist header
extraTiles: PropTypes.arrayOf(PropTypes.node), // extra elements added beneath tiles
forceExpand: PropTypes.bool,
},
getInitialState: function() {
@ -64,6 +67,9 @@ const RoomSubList = React.createClass({
// some values to get LazyRenderList starting
scrollerHeight: 800,
scrollTop: 0,
// React 16's getDerivedStateFromProps(props, state) doesn't give the previous props so
// we have to store the length of the list here so we can see if it's changed or not...
listLength: null,
};
},
@ -76,17 +82,27 @@ const RoomSubList = React.createClass({
};
},
componentWillMount: function() {
componentDidMount: function() {
this._headerButton = createRef();
this.dispatcherRef = dis.register(this.onAction);
},
statics: {
getDerivedStateFromProps: function(props, state) {
return {
listLength: props.list.length,
scrollTop: props.list.length === state.listLength ? state.scrollTop : 0,
};
},
},
componentWillUnmount: function() {
dis.unregister(this.dispatcherRef);
},
// The header is collapsable if it is hidden or not stuck
// The header is collapsible if it is hidden or not stuck
// The dataset elements are added in the RoomList _initAndPositionStickyHeaders method
isCollapsableOnClick: function() {
isCollapsibleOnClick: function() {
const stuck = this.refs.header.dataset.stuck;
if (!this.props.forceExpand && (this.state.hidden || stuck === undefined || stuck === "none")) {
return true;
@ -111,8 +127,8 @@ const RoomSubList = React.createClass({
},
onClick: function(ev) {
if (this.isCollapsableOnClick()) {
// The header isCollapsable, so the click is to be interpreted as collapse and truncation logic
if (this.isCollapsibleOnClick()) {
// The header isCollapsible, so the click is to be interpreted as collapse and truncation logic
const isHidden = !this.state.hidden;
this.setState({hidden: isHidden}, () => {
this.props.onHeaderClick(isHidden);
@ -123,6 +139,49 @@ const RoomSubList = React.createClass({
}
},
onHeaderKeyDown: function(ev) {
switch (ev.key) {
case Key.TAB:
// Prevent LeftPanel handling Tab if focus is on the sublist header itself
ev.stopPropagation();
break;
case Key.ARROW_LEFT:
// On ARROW_LEFT collapse the room sublist
if (!this.state.hidden && !this.props.forceExpand) {
this.onClick();
}
ev.stopPropagation();
break;
case Key.ARROW_RIGHT: {
ev.stopPropagation();
if (this.state.hidden && !this.props.forceExpand) {
// sublist is collapsed, expand it
this.onClick();
} else if (!this.props.forceExpand) {
// sublist is expanded, go to first room
const element = this.refs.subList && this.refs.subList.querySelector(".mx_RoomTile");
if (element) {
element.focus();
}
}
break;
}
}
},
onKeyDown: function(ev) {
switch (ev.key) {
// On ARROW_LEFT go to the sublist header
case Key.ARROW_LEFT:
ev.stopPropagation();
this._headerButton.current.focus();
break;
// Consume ARROW_RIGHT so it doesn't cause focus to get sent to composer
case Key.ARROW_RIGHT:
ev.stopPropagation();
}
},
onRoomTileClick(roomId, ev) {
dis.dispatch({
action: 'view_room',
@ -190,8 +249,14 @@ const RoomSubList = React.createClass({
}
},
onAddRoom: function(e) {
e.stopPropagation();
if (this.props.onAddRoom) this.props.onAddRoom();
},
_getHeaderJsx: function(isCollapsed) {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const AccessibleTooltipButton = sdk.getComponent('elements.AccessibleTooltipButton');
const subListNotifications = !this.props.isInvite ?
RoomNotifs.aggregateNotificationCount(this.props.list) :
{count: 0, highlight: true};
@ -204,13 +269,24 @@ const RoomSubList = React.createClass({
'mx_RoomSubList_badge': true,
'mx_RoomSubList_badgeHighlight': subListNotifHighlight,
});
// Wrap the contents in a div and apply styles to the child div so that the browser default outline works
if (subListNotifCount > 0) {
badge = <div className={badgeClasses} onClick={this._onNotifBadgeClick}>
{ FormattingUtils.formatCount(subListNotifCount) }
</div>;
badge = (
<AccessibleButton className={badgeClasses} onClick={this._onNotifBadgeClick} aria-label={_t("Jump to first unread room.")}>
<div>
{ FormattingUtils.formatCount(subListNotifCount) }
</div>
</AccessibleButton>
);
} else if (this.props.isInvite && this.props.list.length) {
// no notifications but highlight anyway because this is an invite badge
badge = <div className={badgeClasses} onClick={this._onInviteBadgeClick}>{this.props.list.length}</div>;
badge = (
<AccessibleButton className={badgeClasses} onClick={this._onInviteBadgeClick} aria-label={_t("Jump to first invite.")}>
<div>
{ this.props.list.length }
</div>
</AccessibleButton>
);
}
}
@ -232,7 +308,11 @@ const RoomSubList = React.createClass({
let addRoomButton;
if (this.props.onAddRoom) {
addRoomButton = (
<AccessibleButton onClick={ this.props.onAddRoom } className="mx_RoomSubList_addRoom" />
<AccessibleTooltipButton
onClick={this.onAddRoom}
className="mx_RoomSubList_addRoom"
title={this.props.addRoomLabel || _t("Add room")}
/>
);
}
@ -244,13 +324,20 @@ const RoomSubList = React.createClass({
'mx_RoomSubList_chevronRight': isCollapsed,
'mx_RoomSubList_chevronDown': !isCollapsed,
});
chevron = (<div className={chevronClasses}></div>);
chevron = (<div className={chevronClasses} />);
}
const tabindex = this.props.isFiltered ? "0" : "-1";
return (
<div className="mx_RoomSubList_labelContainer" title={ title } ref="header">
<AccessibleButton onClick={ this.onClick } className="mx_RoomSubList_label" tabIndex={tabindex}>
<div className="mx_RoomSubList_labelContainer" title={title} ref="header" onKeyDown={this.onHeaderKeyDown}>
<AccessibleButton
onClick={this.onClick}
className="mx_RoomSubList_label"
tabIndex={0}
aria-expanded={!isCollapsed}
inputRef={this._headerButton}
role="treeitem"
aria-level="1"
>
{ chevron }
<span>{this.props.label}</span>
{ incomingCall }
@ -291,21 +378,20 @@ const RoomSubList = React.createClass({
render: function() {
const len = this.props.list.length + this.props.extraTiles.length;
const isCollapsed = this.state.hidden && !this.props.forceExpand;
if (len) {
const subListClasses = classNames({
"mx_RoomSubList": true,
"mx_RoomSubList_hidden": isCollapsed,
"mx_RoomSubList_nonEmpty": len && !isCollapsed,
});
const subListClasses = classNames({
"mx_RoomSubList": true,
"mx_RoomSubList_hidden": len && isCollapsed,
"mx_RoomSubList_nonEmpty": len && !isCollapsed,
});
let content;
if (len) {
if (isCollapsed) {
return <div ref="subList" className={subListClasses}>
{this._getHeaderJsx(isCollapsed)}
</div>;
// no body
} else if (this._canUseLazyListRendering()) {
return <div ref="subList" className={subListClasses}>
{this._getHeaderJsx(isCollapsed)}
<IndicatorScrollbar ref="scroller" className="mx_RoomSubList_scroll" onScroll={ this._onScroll }>
content = (
<IndicatorScrollbar ref="scroller" className="mx_RoomSubList_scroll" onScroll={this._onScroll}>
<LazyRenderList
scrollTop={this.state.scrollTop }
height={ this.state.scrollerHeight }
@ -313,31 +399,35 @@ const RoomSubList = React.createClass({
itemHeight={34}
items={ this.props.list } />
</IndicatorScrollbar>
</div>;
);
} else {
const roomTiles = this.props.list.map(r => this.makeRoomTile(r));
const tiles = roomTiles.concat(this.props.extraTiles);
return <div ref="subList" className={subListClasses}>
{this._getHeaderJsx(isCollapsed)}
<IndicatorScrollbar ref="scroller" className="mx_RoomSubList_scroll" onScroll={ this._onScroll }>
content = (
<IndicatorScrollbar ref="scroller" className="mx_RoomSubList_scroll" onScroll={this._onScroll}>
{ tiles }
</IndicatorScrollbar>
</div>;
);
}
} else {
const Loader = sdk.getComponent("elements.Spinner");
let content;
if (this.props.showSpinner && !isCollapsed) {
const Loader = sdk.getComponent("elements.Spinner");
content = <Loader />;
}
return (
<div ref="subList" className="mx_RoomSubList">
{ this._getHeaderJsx(isCollapsed) }
{ content }
</div>
);
}
return (
<div
ref="subList"
className={subListClasses}
role="group"
aria-label={this.props.label}
onKeyDown={this.onKeyDown}
>
{ this._getHeaderJsx(isCollapsed) }
{ content }
</div>
);
},
});

View file

@ -2,6 +2,7 @@
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018, 2019 New Vector Ltd
Copyright 2019 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.
@ -23,13 +24,13 @@ limitations under the License.
import shouldHideEvent from '../../shouldHideEvent';
import React from 'react';
import createReactClass from 'create-react-class';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import Promise from 'bluebird';
import filesize from 'filesize';
import classNames from 'classnames';
import {Room} from "matrix-js-sdk";
import { _t } from '../../languageHandler';
import {RoomPermalinkCreator} from '../../matrix-to';
import {RoomPermalinkCreator} from '../../utils/permalinks/Permalinks';
import MatrixClientPeg from '../../MatrixClientPeg';
import ContentMessages from '../../ContentMessages';
@ -41,6 +42,7 @@ import Tinter from '../../Tinter';
import rate_limited_func from '../../ratelimitedfunc';
import ObjectUtils from '../../ObjectUtils';
import * as Rooms from '../../Rooms';
import eventSearch from '../../Searching';
import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard';
@ -63,7 +65,13 @@ if (DEBUG) {
debuglog = console.log.bind(console);
}
module.exports = React.createClass({
const RoomContext = PropTypes.shape({
canReact: PropTypes.bool.isRequired,
canReply: PropTypes.bool.isRequired,
room: PropTypes.instanceOf(Room),
});
module.exports = createReactClass({
displayName: 'RoomView',
propTypes: {
ConferenceHandler: PropTypes.any,
@ -87,7 +95,7 @@ module.exports = React.createClass({
// * name (string) The room's name
// * avatarUrl (string) The mxc:// avatar URL for the room
// * inviterName (string) The display name of the person who
// * invited us tovthe room
// * invited us to the room
oobData: PropTypes.object,
// is the RightPanel collapsed?
@ -155,6 +163,26 @@ module.exports = React.createClass({
// We load this later by asking the js-sdk to suggest a version for us.
// This object is the result of Room#getRecommendedVersion()
upgradeRecommendation: null,
canReact: false,
canReply: false,
useCider: false,
};
},
childContextTypes: {
room: RoomContext,
},
getChildContext: function() {
const {canReact, canReply, room} = this.state;
return {
room: {
canReact,
canReply,
room,
},
};
},
@ -164,6 +192,7 @@ module.exports = React.createClass({
MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().on("Room.name", this.onRoomName);
MatrixClientPeg.get().on("Room.accountData", this.onRoomAccountData);
MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents);
MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember);
MatrixClientPeg.get().on("Room.myMembership", this.onMyMembership);
MatrixClientPeg.get().on("accountData", this.onAccountData);
@ -174,6 +203,14 @@ module.exports = React.createClass({
this._onRoomViewStoreUpdate(true);
WidgetEchoStore.on('update', this._onWidgetEchoStoreUpdate);
this._onCiderUpdated();
this._ciderWatcherRef = SettingsStore.watchSetting(
"useCiderComposer", null, this._onCiderUpdated);
},
_onCiderUpdated: function() {
this.setState({useCider: SettingsStore.getValue("useCiderComposer")});
},
_onRoomViewStoreUpdate: function(initial) {
@ -320,7 +357,7 @@ module.exports = React.createClass({
if (this.props.autoJoin) {
this.onJoinButtonClicked();
} else if (!room && shouldPeek) {
console.log("Attempting to peek into room %s", roomId);
console.info("Attempting to peek into room %s", roomId);
this.setState({
peekLoading: true,
isPeeking: true, // this will change to false if peeking fails
@ -452,6 +489,8 @@ module.exports = React.createClass({
// (We could use isMounted, but facebook have deprecated that.)
this.unmounted = true;
SettingsStore.unwatchSetting(this._ciderWatcherRef);
// update the scroll map before we get unmounted
if (this.state.roomId) {
RoomScrollStateStore.setScrollState(this.state.roomId, this._getScrollState());
@ -556,7 +595,7 @@ module.exports = React.createClass({
payload.data.description || payload.data.name);
break;
case 'picture_snapshot':
return ContentMessages.sharedInstance().sendContentListToRoom(
ContentMessages.sharedInstance().sendContentListToRoom(
[payload.file], this.state.room.roomId, MatrixClientPeg.get(),
);
break;
@ -597,6 +636,11 @@ module.exports = React.createClass({
showApps: payload.show,
});
break;
case 'reply_to_event':
if (this.state.searchResults && payload.event.getRoomId() === this.state.roomId && !this.unmounted) {
this.onCancelSearchClick();
}
break;
}
},
@ -671,6 +715,7 @@ module.exports = React.createClass({
this._loadMembersIfJoined(room);
this._calculateRecommendedVersion(room);
this._updateE2EStatus(room);
this._updatePermissions(room);
},
_calculateRecommendedVersion: async function(room) {
@ -794,6 +839,15 @@ module.exports = React.createClass({
}
},
onRoomStateEvents: function(ev, state) {
// ignore if we don't have a room yet
if (!this.state.room || this.state.room.roomId !== state.roomId) {
return;
}
this._updatePermissions(this.state.room);
},
onRoomStateMember: function(ev, state, member) {
// ignore if we don't have a room yet
if (!this.state.room) {
@ -812,6 +866,17 @@ module.exports = React.createClass({
if (room.roomId === this.state.roomId) {
this.forceUpdate();
this._loadMembersIfJoined(room);
this._updatePermissions(room);
}
},
_updatePermissions: function(room) {
if (room) {
const me = MatrixClientPeg.get().getUserId();
const canReact = room.getMyMembership() === "join" && room.currentState.maySendEvent("m.reaction", me);
const canReply = room.maySendMessage();
this.setState({canReact, canReply});
}
},
@ -1036,7 +1101,7 @@ module.exports = React.createClass({
}
ContentMessages.sharedInstance().sendStickerContentToRoom(url, this.state.room.roomId, info, text, MatrixClientPeg.get())
.done(undefined, (error) => {
.then(undefined, (error) => {
if (error.name === "UnknownDeviceError") {
// Let the staus bar handle this
return;
@ -1064,23 +1129,12 @@ module.exports = React.createClass({
// todo: should cancel any previous search requests.
this.searchId = new Date().getTime();
let filter;
if (scope === "Room") {
filter = {
// XXX: it's unintuitive that the filter for searching doesn't have the same shape as the v2 filter API :(
rooms: [
this.state.room.roomId,
],
};
}
let roomId;
if (scope === "Room") roomId = this.state.room.roomId;
debuglog("sending search request");
const searchPromise = MatrixClientPeg.get().searchRoomEvents({
filter: filter,
term: term,
});
this._handleSearchResult(searchPromise).done();
const searchPromise = eventSearch(term, roomId);
this._handleSearchResult(searchPromise);
},
_handleSearchResult: function(searchPromise) {
@ -1251,7 +1305,7 @@ module.exports = React.createClass({
},
onForgetClick: function() {
MatrixClientPeg.get().forget(this.state.room.roomId).done(function() {
MatrixClientPeg.get().forget(this.state.room.roomId).then(function() {
dis.dispatch({ action: 'view_next_room' });
}, function(err) {
const errCode = err.errcode || _t("unknown error code");
@ -1268,7 +1322,7 @@ module.exports = React.createClass({
this.setState({
rejecting: true,
});
MatrixClientPeg.get().leave(this.state.roomId).done(function() {
MatrixClientPeg.get().leave(this.state.roomId).then(function() {
dis.dispatch({ action: 'view_next_room' });
self.setState({
rejecting: false,
@ -1364,7 +1418,8 @@ module.exports = React.createClass({
const scrollState = messagePanel.getScrollState();
if (scrollState.stuckAtBottom) {
// getScrollState on TimelinePanel *may* return null, so guard against that
if (!scrollState || scrollState.stuckAtBottom) {
// we don't really expect to be in this state, but it will
// occasionally happen when no scroll state has been set on the
// messagePanel (ie, we didn't have an initial event (so it's
@ -1503,7 +1558,6 @@ module.exports = React.createClass({
render: function() {
const RoomHeader = sdk.getComponent('rooms.RoomHeader');
const MessageComposer = sdk.getComponent('rooms.MessageComposer');
const ForwardMessage = sdk.getComponent("rooms.ForwardMessage");
const AuxPanel = sdk.getComponent("rooms.AuxPanel");
const SearchBar = sdk.getComponent("rooms.SearchBar");
@ -1514,18 +1568,23 @@ module.exports = React.createClass({
const TimelinePanel = sdk.getComponent("structures.TimelinePanel");
const RoomUpgradeWarningBar = sdk.getComponent("rooms.RoomUpgradeWarningBar");
const RoomRecoveryReminder = sdk.getComponent("rooms.RoomRecoveryReminder");
const ErrorBoundary = sdk.getComponent("elements.ErrorBoundary");
if (!this.state.room) {
const loading = this.state.roomLoading || this.state.peekLoading;
if (loading) {
return (
<div className="mx_RoomView">
<RoomPreviewBar
canPreview={false}
error={this.state.roomLoadError}
loading={loading}
joining={this.state.joining}
/>
<ErrorBoundary>
<RoomPreviewBar
canPreview={false}
previewLoading={this.state.peekLoading}
error={this.state.roomLoadError}
loading={loading}
joining={this.state.joining}
oobData={this.props.oobData}
/>
</ErrorBoundary>
</div>
);
} else {
@ -1543,16 +1602,20 @@ module.exports = React.createClass({
const roomAlias = this.state.roomAlias;
return (
<div className="mx_RoomView">
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
onForgetClick={this.onForgetClick}
onRejectClick={this.onRejectThreepidInviteButtonClicked}
canPreview={false} error={this.state.roomLoadError}
roomAlias={roomAlias}
joining={this.state.joining}
inviterName={inviterName}
invitedEmail={invitedEmail}
room={this.state.room}
/>
<ErrorBoundary>
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
onForgetClick={this.onForgetClick}
onRejectClick={this.onRejectThreepidInviteButtonClicked}
canPreview={false} error={this.state.roomLoadError}
roomAlias={roomAlias}
joining={this.state.joining}
inviterName={inviterName}
invitedEmail={invitedEmail}
oobData={this.props.oobData}
signUrl={this.props.thirdPartyInvite ? this.props.thirdPartyInvite.inviteSignUrl : null}
room={this.state.room}
/>
</ErrorBoundary>
</div>
);
}
@ -1562,12 +1625,14 @@ module.exports = React.createClass({
if (myMembership == 'invite') {
if (this.state.joining || this.state.rejecting) {
return (
<RoomPreviewBar
<ErrorBoundary>
<RoomPreviewBar
canPreview={false}
error={this.state.roomLoadError}
joining={this.state.joining}
rejecting={this.state.rejecting}
/>
</ErrorBoundary>
);
} else {
const myUserId = MatrixClientPeg.get().credentials.userId;
@ -1582,14 +1647,16 @@ module.exports = React.createClass({
// We have a regular invite for this room.
return (
<div className="mx_RoomView">
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
onForgetClick={this.onForgetClick}
onRejectClick={this.onRejectButtonClicked}
inviterName={inviterName}
canPreview={false}
joining={this.state.joining}
room={this.state.room}
/>
<ErrorBoundary>
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
onForgetClick={this.onForgetClick}
onRejectClick={this.onRejectButtonClicked}
inviterName={inviterName}
canPreview={false}
joining={this.state.joining}
room={this.state.room}
/>
</ErrorBoundary>
</div>
);
}
@ -1681,6 +1748,7 @@ module.exports = React.createClass({
joining={this.state.joining}
inviterName={inviterName}
invitedEmail={invitedEmail}
oobData={this.props.oobData}
canPreview={this.state.canPeek}
room={this.state.room}
/>
@ -1726,15 +1794,29 @@ module.exports = React.createClass({
myMembership === 'join' && !this.state.searchResults
);
if (canSpeak) {
messageComposer =
<MessageComposer
room={this.state.room}
callState={this.state.callState}
disabled={this.props.disabled}
showApps={this.state.showApps}
e2eStatus={this.state.e2eStatus}
permalinkCreator={this._getPermalinkCreatorForRoom(this.state.room)}
/>;
if (this.state.useCider) {
const MessageComposer = sdk.getComponent('rooms.MessageComposer');
messageComposer =
<MessageComposer
room={this.state.room}
callState={this.state.callState}
disabled={this.props.disabled}
showApps={this.state.showApps}
e2eStatus={this.state.e2eStatus}
permalinkCreator={this._getPermalinkCreatorForRoom(this.state.room)}
/>;
} else {
const SlateMessageComposer = sdk.getComponent('rooms.SlateMessageComposer');
messageComposer =
<SlateMessageComposer
room={this.state.room}
callState={this.state.callState}
disabled={this.props.disabled}
showApps={this.state.showApps}
e2eStatus={this.state.e2eStatus}
permalinkCreator={this._getPermalinkCreatorForRoom(this.state.room)}
/>;
}
}
// TODO: Why aren't we storing the term/scope/count in this format
@ -1814,7 +1896,7 @@ module.exports = React.createClass({
highlightedEventId = this.state.initialEventId;
}
// console.log("ShowUrlPreview for %s is %s", this.state.room.roomId, this.state.showUrlPreview);
// console.info("ShowUrlPreview for %s is %s", this.state.room.roomId, this.state.showUrlPreview);
const messagePanel = (
<TimelinePanel ref={this._gatherTimelinePanelRef}
timelineSet={this.state.room.getUnfilteredTimelineSet()}
@ -1832,6 +1914,7 @@ module.exports = React.createClass({
membersLoaded={this.state.membersLoaded}
permalinkCreator={this._getPermalinkCreatorForRoom(this.state.room)}
resizeNotifier={this.props.resizeNotifier}
showReactions={true}
/>);
let topUnreadMessagesBar = null;
@ -1870,42 +1953,46 @@ module.exports = React.createClass({
return (
<main className={"mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "")} ref="roomView">
<RoomHeader ref="header" room={this.state.room} searchInfo={searchInfo}
oobData={this.props.oobData}
inRoom={myMembership === 'join'}
collapsedRhs={collapsedRhs}
onSearchClick={this.onSearchClick}
onSettingsClick={this.onSettingsClick}
onPinnedClick={this.onPinnedClick}
onCancelClick={(aux && !hideCancel) ? this.onCancelClick : null}
onForgetClick={(myMembership === "leave") ? this.onForgetClick : null}
onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null}
e2eStatus={this.state.e2eStatus}
/>
<MainSplit
panel={rightPanel}
collapsedRhs={collapsedRhs}
resizeNotifier={this.props.resizeNotifier}
>
<div className={fadableSectionClasses}>
{ auxPanel }
<div className="mx_RoomView_timeline">
{ topUnreadMessagesBar }
{ jumpToBottom }
{ messagePanel }
{ searchResultsPanel }
</div>
<div className={statusBarAreaClass}>
<div className="mx_RoomView_statusAreaBox">
<div className="mx_RoomView_statusAreaBox_line"></div>
{ statusBar }
<ErrorBoundary>
<RoomHeader ref="header" room={this.state.room} searchInfo={searchInfo}
oobData={this.props.oobData}
inRoom={myMembership === 'join'}
collapsedRhs={collapsedRhs}
onSearchClick={this.onSearchClick}
onSettingsClick={this.onSettingsClick}
onPinnedClick={this.onPinnedClick}
onCancelClick={(aux && !hideCancel) ? this.onCancelClick : null}
onForgetClick={(myMembership === "leave") ? this.onForgetClick : null}
onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null}
e2eStatus={this.state.e2eStatus}
/>
<MainSplit
panel={rightPanel}
collapsedRhs={collapsedRhs}
resizeNotifier={this.props.resizeNotifier}
>
<div className={fadableSectionClasses}>
{auxPanel}
<div className="mx_RoomView_timeline">
{topUnreadMessagesBar}
{jumpToBottom}
{messagePanel}
{searchResultsPanel}
</div>
<div className={statusBarAreaClass}>
<div className="mx_RoomView_statusAreaBox">
<div className="mx_RoomView_statusAreaBox_line"></div>
{statusBar}
</div>
</div>
{previewBar}
{messageComposer}
</div>
{ previewBar }
{ messageComposer }
</div>
</MainSplit>
</MainSplit>
</ErrorBoundary>
</main>
);
},
});
module.exports.RoomContext = RoomContext;

View file

@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
const React = require("react");
import React from "react";
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import Promise from 'bluebird';
import { KeyCode } from '../../Keyboard';
import Timer from '../../utils/Timer';
import AutoHideScrollbar from "./AutoHideScrollbar";
@ -84,7 +84,7 @@ if (DEBUG_SCROLL) {
* offset as normal.
*/
module.exports = React.createClass({
module.exports = createReactClass({
displayName: 'ScrollPanel',
propTypes: {
@ -214,6 +214,9 @@ module.exports = React.createClass({
// after an update to the contents of the panel, check that the scroll is
// where it ought to be, and set off pagination requests if necessary.
checkScroll: function() {
if (this.unmounted) {
return;
}
this._restoreSavedScrollState();
this.checkFillState();
},
@ -673,6 +676,11 @@ module.exports = React.createClass({
debuglog("updateHeight getting straight to business, no scrolling going on.");
}
// We might have unmounted since the timer finished, so abort if so.
if (this.unmounted) {
return;
}
const sn = this._getScrollNode();
const itemlist = this.refs.itemlist;
const contentHeight = this._getMessagesHeight();
@ -750,8 +758,10 @@ module.exports = React.createClass({
_getMessagesHeight() {
const itemlist = this.refs.itemlist;
const lastNode = itemlist.lastElementChild;
const lastNodeBottom = lastNode ? lastNode.offsetTop + lastNode.clientHeight : 0;
const firstNodeTop = itemlist.firstElementChild ? itemlist.firstElementChild.offsetTop : 0;
// 18 is itemlist padding
return (lastNode.offsetTop + lastNode.clientHeight) - itemlist.firstElementChild.offsetTop + (18 * 2);
return lastNodeBottom - firstNodeTop + (18 * 2);
},
_topFromBottom(node) {

View file

@ -16,18 +16,21 @@ limitations under the License.
*/
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import { KeyCode } from '../../Keyboard';
import dis from '../../dispatcher';
import { throttle } from 'lodash';
import AccessibleButton from '../../components/views/elements/AccessibleButton';
import classNames from 'classnames';
module.exports = React.createClass({
module.exports = createReactClass({
displayName: 'SearchBox',
propTypes: {
onSearch: PropTypes.func,
onCleared: PropTypes.func,
onKeyDown: PropTypes.func,
className: PropTypes.string,
placeholder: PropTypes.string.isRequired,
@ -46,6 +49,7 @@ module.exports = React.createClass({
getInitialState: function() {
return {
searchTerm: "",
blurred: true,
};
},
@ -90,10 +94,22 @@ module.exports = React.createClass({
this._clearSearch("keyboard");
break;
}
if (this.props.onKeyDown) this.props.onKeyDown(ev);
},
_onFocus: function(ev) {
this.setState({blurred: false});
ev.target.select();
if (this.props.onFocus) {
this.props.onFocus(ev);
}
},
_onBlur: function(ev) {
this.setState({blurred: true});
if (this.props.onBlur) {
this.props.onBlur(ev);
}
},
_clearSearch: function(source) {
@ -112,15 +128,21 @@ module.exports = React.createClass({
if (this.props.collapsed) {
return null;
}
const clearButton = this.state.searchTerm.length > 0 ?
const clearButton = (!this.state.blurred || this.state.searchTerm) ?
(<AccessibleButton key="button"
className="mx_SearchBox_closeButton"
onClick={ () => {this._clearSearch("button"); } }>
</AccessibleButton>) : undefined;
// show a shorter placeholder when blurred, if requested
// this is used for the room filter field that has
// the explore button next to it when blurred
const placeholder = this.state.blurred ?
(this.props.blurredPlaceholder || this.props.placeholder) :
this.props.placeholder;
const className = this.props.className || "";
return (
<div className="mx_SearchBox mx_textinput">
<div className={classNames("mx_SearchBox", "mx_textinput", {"mx_SearchBox_blurred": this.state.blurred})}>
<input
key="searchfield"
type="text"
@ -130,7 +152,8 @@ module.exports = React.createClass({
onFocus={ this._onFocus }
onChange={ this.onChange }
onKeyDown={ this._onKeyDown }
placeholder={ this.props.placeholder }
onBlur={this._onBlur}
placeholder={ placeholder }
/>
{ clearButton }
</div>

View file

@ -1,6 +1,7 @@
/*
Copyright 2017 Travis Ralston
Copyright 2019 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -18,6 +19,7 @@ limitations under the License.
import * as React from "react";
import {_t} from '../../languageHandler';
import PropTypes from "prop-types";
import sdk from "../../index";
/**
* Represents a tab for the TabbedView.
@ -70,6 +72,8 @@ export class TabbedView extends React.Component {
}
_renderTabLabel(tab) {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let classes = "mx_TabbedView_tabLabel ";
const idx = this.props.tabs.indexOf(tab);
@ -82,14 +86,14 @@ export class TabbedView extends React.Component {
const onClickHandler = () => this._setActiveTab(tab);
const label = _t(tab.label);
return (
<span className={classes} key={"tab_label_" + tab.label}
onClick={onClickHandler}>
<AccessibleButton className={classes} key={"tab_label_" + tab.label} onClick={onClickHandler}>
{tabIcon}
<span className="mx_TabbedView_tabLabel_text">
{_t(tab.label)}
{ label }
</span>
</span>
</AccessibleButton>
);
}

View file

@ -15,6 +15,7 @@ limitations under the License.
*/
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk';
import TagOrderStore from '../../stores/TagOrderStore';
@ -28,7 +29,7 @@ import { _t } from '../../languageHandler';
import { Droppable } from 'react-beautiful-dnd';
import classNames from 'classnames';
const TagPanel = React.createClass({
const TagPanel = createReactClass({
displayName: 'TagPanel',
contextTypes: {

View file

@ -15,16 +15,17 @@ limitations under the License.
*/
import React from 'react';
import createReactClass from 'create-react-class';
import sdk from '../../index';
import dis from '../../dispatcher';
import Modal from '../../Modal';
import { _t } from '../../languageHandler';
const TagPanelButtons = React.createClass({
const TagPanelButtons = createReactClass({
displayName: 'TagPanelButtons',
componentWillMount: function() {
componentDidMount: function() {
this._dispatcherRef = dis.register(this._onAction);
},

View file

@ -2,6 +2,7 @@
Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2019 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -18,10 +19,10 @@ limitations under the License.
import SettingsStore from "../../settings/SettingsStore";
const React = require('react');
const ReactDOM = require("react-dom");
import React from 'react';
import createReactClass from 'create-react-class';
import ReactDOM from "react-dom";
import PropTypes from 'prop-types';
import Promise from 'bluebird';
const Matrix = require("matrix-js-sdk");
const EventTimeline = Matrix.EventTimeline;
@ -35,11 +36,11 @@ const Modal = require("../../Modal");
const UserActivity = require("../../UserActivity");
import { KeyCode } from '../../Keyboard';
import Timer from '../../utils/Timer';
import shouldHideEvent from '../../shouldHideEvent';
import EditorStateTransfer from '../../utils/EditorStateTransfer';
const PAGINATE_SIZE = 20;
const INITIAL_SIZE = 20;
const READ_MARKER_INVIEW_THRESHOLD_MS = 1 * 1000;
const READ_MARKER_OUTOFVIEW_THRESHOLD_MS = 30 * 1000;
const READ_RECEIPT_INTERVAL_MS = 500;
const DEBUG = false;
@ -55,7 +56,7 @@ if (DEBUG) {
*
* Also responsible for handling and sending read receipts.
*/
const TimelinePanel = React.createClass({
const TimelinePanel = createReactClass({
displayName: 'TimelinePanel',
propTypes: {
@ -106,6 +107,9 @@ const TimelinePanel = React.createClass({
// placeholder text to use if the timeline is empty
empty: PropTypes.string,
// whether to show reactions for an event
showReactions: PropTypes.bool,
},
statics: {
@ -137,6 +141,7 @@ const TimelinePanel = React.createClass({
return {
events: [],
liveEvents: [],
timelineLoading: true, // track whether our room timeline is loading
// canBackPaginate == false may mean:
@ -183,6 +188,12 @@ const TimelinePanel = React.createClass({
// always show timestamps on event tiles?
alwaysShowTimestamps: SettingsStore.getValue("alwaysShowTimestamps"),
// how long to show the RM for when it's visible in the window
readMarkerInViewThresholdMs: SettingsStore.getValue("readMarkerInViewThresholdMs"),
// how long to show the RM for when it's scrolled off-screen
readMarkerOutOfViewThresholdMs: SettingsStore.getValue("readMarkerOutOfViewThresholdMs"),
};
},
@ -204,10 +215,13 @@ const TimelinePanel = React.createClass({
MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().on("Room.timelineReset", this.onRoomTimelineReset);
MatrixClientPeg.get().on("Room.redaction", this.onRoomRedaction);
// same event handler as Room.redaction as for both we just do forceUpdate
MatrixClientPeg.get().on("Room.redactionCancelled", this.onRoomRedaction);
MatrixClientPeg.get().on("Room.receipt", this.onRoomReceipt);
MatrixClientPeg.get().on("Room.localEchoUpdated", this.onLocalEchoUpdated);
MatrixClientPeg.get().on("Room.accountData", this.onAccountData);
MatrixClientPeg.get().on("Event.decrypted", this.onEventDecrypted);
MatrixClientPeg.get().on("Event.replaced", this.onEventReplaced);
MatrixClientPeg.get().on("sync", this.onSync);
this._initTimeline(this.props);
@ -282,10 +296,12 @@ const TimelinePanel = React.createClass({
client.removeListener("Room.timeline", this.onRoomTimeline);
client.removeListener("Room.timelineReset", this.onRoomTimelineReset);
client.removeListener("Room.redaction", this.onRoomRedaction);
client.removeListener("Room.redactionCancelled", this.onRoomRedaction);
client.removeListener("Room.receipt", this.onRoomReceipt);
client.removeListener("Room.localEchoUpdated", this.onLocalEchoUpdated);
client.removeListener("Room.accountData", this.onAccountData);
client.removeListener("Event.decrypted", this.onEventDecrypted);
client.removeListener("Event.replaced", this.onEventReplaced);
client.removeListener("sync", this.onSync);
}
},
@ -313,9 +329,11 @@ const TimelinePanel = React.createClass({
// We can now paginate in the unpaginated direction
const canPaginateKey = (backwards) ? 'canBackPaginate' : 'canForwardPaginate';
const { events, liveEvents } = this._getEvents();
this.setState({
[canPaginateKey]: true,
events: this._getEvents(),
events,
liveEvents,
});
}
},
@ -347,10 +365,12 @@ const TimelinePanel = React.createClass({
debuglog("TimelinePanel: paginate complete backwards:"+backwards+"; success:"+r);
const { events, liveEvents } = this._getEvents();
const newState = {
[paginatingKey]: false,
[canPaginateKey]: r,
events: this._getEvents(),
events,
liveEvents,
};
// moving the window in this direction may mean that we can now
@ -402,6 +422,16 @@ const TimelinePanel = React.createClass({
if (payload.action === 'ignore_state_changed') {
this.forceUpdate();
}
if (payload.action === "edit_event") {
const editState = payload.event ? new EditorStateTransfer(payload.event) : null;
this.setState({editState}, () => {
if (payload.event && this.refs.messagePanel) {
this.refs.messagePanel.scrollToEventIfNeeded(
payload.event.getId(),
);
}
});
}
},
onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) {
@ -431,18 +461,16 @@ const TimelinePanel = React.createClass({
// timeline window.
//
// see https://github.com/vector-im/vector-web/issues/1035
this._timelineWindow.paginate(EventTimeline.FORWARDS, 1, false).done(() => {
this._timelineWindow.paginate(EventTimeline.FORWARDS, 1, false).then(() => {
if (this.unmounted) { return; }
const events = this._timelineWindow.getEvents();
const lastEv = events[events.length-1];
const { events, liveEvents } = this._getEvents();
const lastLiveEvent = liveEvents[liveEvents.length - 1];
// if we're at the end of the live timeline, append the pending events
if (this.props.timelineSet.room && !this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) {
events.push(...this.props.timelineSet.room.getPendingEvents());
}
const updatedState = {events: events};
const updatedState = {
events,
liveEvents,
};
let callRMUpdated;
if (this.props.manageReadMarkers) {
@ -459,13 +487,13 @@ const TimelinePanel = React.createClass({
callRMUpdated = false;
if (sender != myUserId && !UserActivity.sharedInstance().userActiveRecently()) {
updatedState.readMarkerVisible = true;
} else if (lastEv && this.getReadMarkerPosition() === 0) {
} else if (lastLiveEvent && this.getReadMarkerPosition() === 0) {
// we know we're stuckAtBottom, so we can advance the RM
// immediately, to save a later render cycle
this._setReadMarker(lastEv.getId(), lastEv.getTs(), true);
this._setReadMarker(lastLiveEvent.getId(), lastLiveEvent.getTs(), true);
updatedState.readMarkerVisible = false;
updatedState.readMarkerEventId = lastEv.getId();
updatedState.readMarkerEventId = lastLiveEvent.getId();
callRMUpdated = true;
}
}
@ -502,6 +530,17 @@ const TimelinePanel = React.createClass({
this.forceUpdate();
},
onEventReplaced: function(replacedEvent, room) {
if (this.unmounted) return;
// ignore events for other rooms
if (room !== this.props.timelineSet.room) return;
// we could skip an update if the event isn't in our timeline,
// but that's probably an early optimisation.
this.forceUpdate();
},
onRoomReceipt: function(ev, room) {
if (this.unmounted) return;
@ -537,6 +576,9 @@ const TimelinePanel = React.createClass({
},
onEventDecrypted: function(ev) {
// Can be null for the notification timeline, etc.
if (!this.props.timelineSet.room) return;
// Need to update as we don't display event tiles for events that
// haven't yet been decrypted. The event will have just been updated
// in place so we just need to re-render.
@ -554,8 +596,8 @@ const TimelinePanel = React.createClass({
_readMarkerTimeout(readMarkerPosition) {
return readMarkerPosition === 0 ?
READ_MARKER_INVIEW_THRESHOLD_MS :
READ_MARKER_OUTOFVIEW_THRESHOLD_MS;
this.state.readMarkerInViewThresholdMs :
this.state.readMarkerOutOfViewThresholdMs;
},
updateReadMarkerOnUserActivity: async function() {
@ -585,6 +627,8 @@ const TimelinePanel = React.createClass({
},
sendReadReceipt: function() {
if (SettingsStore.getValue("lowBandwidth")) return;
if (!this.refs.messagePanel) return;
if (!this.props.manageReadReceipts) return;
// This happens on user_activity_end which is delayed, and it's
@ -644,23 +688,32 @@ const TimelinePanel = React.createClass({
}
this.lastRMSentEventId = this.state.readMarkerEventId;
const roomId = this.props.timelineSet.room.roomId;
const hiddenRR = !SettingsStore.getValue("sendReadReceipts", roomId);
debuglog('TimelinePanel: Sending Read Markers for ',
this.props.timelineSet.room.roomId,
'rm', this.state.readMarkerEventId,
lastReadEvent ? 'rr ' + lastReadEvent.getId() : '',
' hidden:' + hiddenRR,
);
MatrixClientPeg.get().setRoomReadMarkers(
this.props.timelineSet.room.roomId,
this.state.readMarkerEventId,
lastReadEvent, // Could be null, in which case no RR is sent
{hidden: hiddenRR},
).catch((e) => {
// /read_markers API is not implemented on this HS, fallback to just RR
if (e.errcode === 'M_UNRECOGNIZED' && lastReadEvent) {
return MatrixClientPeg.get().sendReadReceipt(
lastReadEvent,
).catch(() => {
{hidden: hiddenRR},
).catch((e) => {
console.error(e);
this.lastRRSentEventId = undefined;
});
} else {
console.error(e);
}
// it failed, so allow retries next time the user is active
this.lastRRSentEventId = undefined;
@ -695,14 +748,8 @@ const TimelinePanel = React.createClass({
// move the RM to *after* the message at the bottom of the screen. This
// avoids a problem whereby we never advance the RM if there is a huge
// message which doesn't fit on the screen.
//
// But ignore local echoes for this - they have a temporary event ID
// and we'll get confused when their ID changes and we can't figure out
// where the RM is pointing to. The read marker will be invisible for
// now anyway, so this doesn't really matter.
const lastDisplayedIndex = this._getLastDisplayedEventIndex({
allowPartial: true,
ignoreEchoes: true,
});
if (lastDisplayedIndex === null) {
@ -726,9 +773,9 @@ const TimelinePanel = React.createClass({
_advanceReadMarkerPastMyEvents: function() {
if (!this.props.manageReadMarkers) return;
// we call _timelineWindow.getEvents() rather than using
// this.state.events, because react batches the update to the latter, so it
// may not have been updated yet.
// we call `_timelineWindow.getEvents()` rather than using
// `this.state.liveEvents`, because React batches the update to the
// latter, so it may not have been updated yet.
const events = this._timelineWindow.getEvents();
// first find where the current RM is
@ -1016,8 +1063,6 @@ const TimelinePanel = React.createClass({
});
};
let prom = this._timelineWindow.load(eventId, INITIAL_SIZE);
// if we already have the event in question, TimelineWindow.load
// returns a resolved promise.
//
@ -1026,20 +1071,23 @@ const TimelinePanel = React.createClass({
// quite slow. So we detect that situation and shortcut straight to
// calling _reloadEvents and updating the state.
if (prom.isFulfilled()) {
const timeline = this.props.timelineSet.getTimelineForEvent(eventId);
if (timeline) {
// This is a hot-path optimization by skipping a promise tick
// by repeating a no-op sync branch in TimelineSet.getTimelineForEvent & MatrixClient.getEventTimeline
this._timelineWindow.load(eventId, INITIAL_SIZE); // in this branch this method will happen in sync time
onLoaded();
} else {
const prom = this._timelineWindow.load(eventId, INITIAL_SIZE);
this.setState({
events: [],
liveEvents: [],
canBackPaginate: false,
canForwardPaginate: false,
timelineLoading: true,
});
prom = prom.then(onLoaded, onError);
prom.then(onLoaded, onError);
}
prom.done();
},
// handle the completion of a timeline load or localEchoUpdate, by
@ -1050,21 +1098,26 @@ const TimelinePanel = React.createClass({
// the results if so.
if (this.unmounted) return;
this.setState({
events: this._getEvents(),
});
this.setState(this._getEvents());
},
// get the list of events from the timeline window and the pending event list
_getEvents: function() {
const events = this._timelineWindow.getEvents();
// Hold onto the live events separately. The read receipt and read marker
// should use this list, so that they don't advance into pending events.
const liveEvents = [...events];
// if we're at the end of the live timeline, append the pending events
if (!this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) {
events.push(...this.props.timelineSet.getPendingEvents());
}
return events;
return {
events,
liveEvents,
};
},
_indexForEventId: function(evId) {
@ -1079,36 +1132,76 @@ const TimelinePanel = React.createClass({
_getLastDisplayedEventIndex: function(opts) {
opts = opts || {};
const ignoreOwn = opts.ignoreOwn || false;
const ignoreEchoes = opts.ignoreEchoes || false;
const allowPartial = opts.allowPartial || false;
const messagePanel = this.refs.messagePanel;
if (messagePanel === undefined) return null;
const EventTile = sdk.getComponent('rooms.EventTile');
const wrapperRect = ReactDOM.findDOMNode(messagePanel).getBoundingClientRect();
const myUserId = MatrixClientPeg.get().credentials.userId;
for (let i = this.state.events.length-1; i >= 0; --i) {
const ev = this.state.events[i];
if (ignoreOwn && ev.sender && ev.sender.userId == myUserId) {
continue;
const isNodeInView = (node) => {
if (node) {
const boundingRect = node.getBoundingClientRect();
if ((allowPartial && boundingRect.top < wrapperRect.bottom) ||
(!allowPartial && boundingRect.bottom < wrapperRect.bottom)) {
return true;
}
}
return false;
};
// local echoes have a fake event ID
if (ignoreEchoes && ev.status) {
continue;
}
// We keep track of how many of the adjacent events didn't have a tile
// but should have the read receipt moved past them, so
// we can include those once we find the last displayed (visible) event.
// The counter is not started for events we don't want
// to send a read receipt for (our own events, local echos).
let adjacentInvisibleEventCount = 0;
// Use `liveEvents` here because we don't want the read marker or read
// receipt to advance into pending events.
for (let i = this.state.liveEvents.length - 1; i >= 0; --i) {
const ev = this.state.liveEvents[i];
const node = messagePanel.getNodeForEventId(ev.getId());
if (!node) continue;
const isInView = isNodeInView(node);
const boundingRect = node.getBoundingClientRect();
if ((allowPartial && boundingRect.top < wrapperRect.bottom) ||
(!allowPartial && boundingRect.bottom < wrapperRect.bottom)) {
// when we've reached the first visible event, and the previous
// events were all invisible (with the first one not being ignored),
// return the index of the first invisible event.
if (isInView && adjacentInvisibleEventCount !== 0) {
return i + adjacentInvisibleEventCount;
}
if (node && !isInView) {
// has node but not in view, so reset adjacent invisible events
adjacentInvisibleEventCount = 0;
}
const shouldIgnore = !!ev.status || // local echo
(ignoreOwn && ev.sender && ev.sender.userId == myUserId); // own message
const isWithoutTile = !EventTile.haveTileForEvent(ev) || shouldHideEvent(ev);
if (isWithoutTile || !node) {
// don't start counting if the event should be ignored,
// but continue counting if we were already so the offset
// to the previous invisble event that didn't need to be ignored
// doesn't get messed up
if (!shouldIgnore || (shouldIgnore && adjacentInvisibleEventCount !== 0)) {
++adjacentInvisibleEventCount;
}
continue;
}
if (shouldIgnore) {
continue;
}
if (isInView) {
return i;
}
}
return null;
},
@ -1168,6 +1261,10 @@ const TimelinePanel = React.createClass({
});
},
getRelationsForEvent(...args) {
return this.props.timelineSet.getRelationsForEvent(...args);
},
render: function() {
const MessagePanel = sdk.getComponent("structures.MessagePanel");
const Loader = sdk.getComponent("elements.Spinner");
@ -1193,9 +1290,9 @@ const TimelinePanel = React.createClass({
if (this.state.events.length == 0 && !this.state.canBackPaginate && this.props.empty) {
return (
<div className={this.props.className + " mx_RoomView_messageListWrapper"}>
<div className="mx_RoomView_empty">{ this.props.empty }</div>
</div>
<div className={this.props.className + " mx_RoomView_messageListWrapper"}>
<div className="mx_RoomView_empty">{this.props.empty}</div>
</div>
);
}
@ -1217,28 +1314,31 @@ const TimelinePanel = React.createClass({
);
return (
<MessagePanel ref="messagePanel"
room={this.props.timelineSet.room}
permalinkCreator={this.props.permalinkCreator}
hidden={this.props.hidden}
backPaginating={this.state.backPaginating}
forwardPaginating={forwardPaginating}
events={this.state.events}
highlightedEventId={this.props.highlightedEventId}
readMarkerEventId={this.state.readMarkerEventId}
readMarkerVisible={this.state.readMarkerVisible}
suppressFirstDateSeparator={this.state.canBackPaginate}
showUrlPreview={this.props.showUrlPreview}
showReadReceipts={this.props.showReadReceipts}
ourUserId={MatrixClientPeg.get().credentials.userId}
stickyBottom={stickyBottom}
onScroll={this.onMessageListScroll}
onFillRequest={this.onMessageListFillRequest}
onUnfillRequest={this.onMessageListUnfillRequest}
isTwelveHour={this.state.isTwelveHour}
alwaysShowTimestamps={this.state.alwaysShowTimestamps}
className={this.props.className}
tileShape={this.props.tileShape}
resizeNotifier={this.props.resizeNotifier}
room={this.props.timelineSet.room}
permalinkCreator={this.props.permalinkCreator}
hidden={this.props.hidden}
backPaginating={this.state.backPaginating}
forwardPaginating={forwardPaginating}
events={this.state.events}
highlightedEventId={this.props.highlightedEventId}
readMarkerEventId={this.state.readMarkerEventId}
readMarkerVisible={this.state.readMarkerVisible}
suppressFirstDateSeparator={this.state.canBackPaginate}
showUrlPreview={this.props.showUrlPreview}
showReadReceipts={this.props.showReadReceipts}
ourUserId={MatrixClientPeg.get().credentials.userId}
stickyBottom={stickyBottom}
onScroll={this.onMessageListScroll}
onFillRequest={this.onMessageListFillRequest}
onUnfillRequest={this.onMessageListUnfillRequest}
isTwelveHour={this.state.isTwelveHour}
alwaysShowTimestamps={this.state.alwaysShowTimestamps}
className={this.props.className}
tileShape={this.props.tileShape}
resizeNotifier={this.props.resizeNotifier}
getRelationsForEvent={this.getRelationsForEvent}
editState={this.state.editState}
showReactions={this.props.showReactions}
/>
);
},

View file

@ -0,0 +1,84 @@
/*
Copyright 2019 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 * as React from "react";
import dis from "../../dispatcher";
import { _t } from '../../languageHandler';
import classNames from "classnames";
export default class ToastContainer extends React.Component {
constructor() {
super();
this.state = {toasts: []};
}
componentDidMount() {
this._dispatcherRef = dis.register(this.onAction);
}
componentWillUnmount() {
dis.unregister(this._dispatcherRef);
}
onAction = (payload) => {
if (payload.action === "show_toast") {
this._addToast(payload.toast);
}
};
_addToast(toast) {
this.setState({toasts: this.state.toasts.concat(toast)});
}
dismissTopToast = () => {
const [, ...remaining] = this.state.toasts;
this.setState({toasts: remaining});
};
render() {
const totalCount = this.state.toasts.length;
const isStacked = totalCount > 1;
let toast;
if (totalCount !== 0) {
const topToast = this.state.toasts[0];
const {title, icon, key, component, props} = topToast;
const toastClasses = classNames("mx_Toast_toast", {
"mx_Toast_hasIcon": icon,
[`mx_Toast_icon_${icon}`]: icon,
});
const countIndicator = isStacked ? _t(" (1/%(totalCount)s)", {totalCount}) : null;
const toastProps = Object.assign({}, props, {
dismiss: this.dismissTopToast,
key,
});
toast = (<div className={toastClasses}>
<h2>{title}{countIndicator}</h2>
<div className="mx_Toast_body">{React.createElement(component, toastProps)}</div>
</div>);
}
const containerClasses = classNames("mx_ToastContainer", {
"mx_ToastContainer_stacked": isStacked,
});
return (
<div className={containerClasses} role="alert">
{toast}
</div>
);
}
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2019 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.
@ -23,6 +24,8 @@ import BaseAvatar from '../views/avatars/BaseAvatar';
import MatrixClientPeg from '../../MatrixClientPeg';
import Avatar from '../../Avatar';
import { _t } from '../../languageHandler';
import dis from "../../dispatcher";
import {focusCapturedRef} from "../../utils/Accessibility";
const AVATAR_SIZE = 28;
@ -37,6 +40,7 @@ export default class TopLeftMenuButton extends React.Component {
super();
this.state = {
menuDisplayed: false,
menuFunctions: null, // should be { close: fn }
profileInfo: null,
};
@ -59,6 +63,8 @@ export default class TopLeftMenuButton extends React.Component {
}
async componentDidMount() {
this._dispatcherRef = dis.register(this.onAction);
try {
const profileInfo = await this._getProfileInfo();
this.setState({profileInfo});
@ -68,6 +74,17 @@ export default class TopLeftMenuButton extends React.Component {
}
}
componentWillUnmount() {
dis.unregister(this._dispatcherRef);
}
onAction = (payload) => {
// For accessibility
if (payload.action === "toggle_top_left_menu") {
if (this._buttonRef) this._buttonRef.click();
}
};
_getDisplayName() {
if (MatrixClientPeg.get().isGuest()) {
return _t("Guest");
@ -81,14 +98,23 @@ export default class TopLeftMenuButton extends React.Component {
render() {
const name = this._getDisplayName();
let nameElement;
let chevronElement;
if (!this.props.collapsed) {
nameElement = <div className="mx_TopLeftMenuButton_name">
{ name }
</div>;
chevronElement = <span className="mx_TopLeftMenuButton_chevron" />;
}
return (
<AccessibleButton className="mx_TopLeftMenuButton" onClick={this.onToggleMenu}>
<AccessibleButton
className="mx_TopLeftMenuButton"
onClick={this.onToggleMenu}
inputRef={(r) => this._buttonRef = r}
aria-label={_t("Your profile")}
aria-haspopup={true}
aria-expanded={this.state.menuDisplayed}
>
<BaseAvatar
idName={MatrixClientPeg.get().getUserId()}
name={name}
@ -98,7 +124,7 @@ export default class TopLeftMenuButton extends React.Component {
resizeMethod="crop"
/>
{ nameElement }
<span className="mx_TopLeftMenuButton_chevron"></span>
{ chevronElement }
</AccessibleButton>
);
}
@ -107,20 +133,26 @@ export default class TopLeftMenuButton extends React.Component {
e.preventDefault();
e.stopPropagation();
if (this.state.menuDisplayed && this.state.menuFunctions) {
this.state.menuFunctions.close();
return;
}
const elementRect = e.currentTarget.getBoundingClientRect();
const x = elementRect.left;
const y = elementRect.top + elementRect.height;
ContextualMenu.createMenu(TopLeftMenu, {
const menuFunctions = ContextualMenu.createMenu(TopLeftMenu, {
chevronFace: "none",
left: x,
top: y,
userId: MatrixClientPeg.get().getUserId(),
displayName: this._getDisplayName(),
containerRef: focusCapturedRef, // Focus the TopLeftMenu on first render
onFinished: () => {
this.setState({ menuDisplayed: false });
this.setState({ menuDisplayed: false, menuFunctions: null });
},
});
this.setState({ menuDisplayed: true });
this.setState({ menuDisplayed: true, menuFunctions });
}
}

View file

@ -14,14 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
const React = require('react');
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import ContentMessages from '../../ContentMessages';
const dis = require('../../dispatcher');
const filesize = require('filesize');
import { _t } from '../../languageHandler';
module.exports = React.createClass({displayName: 'UploadBar',
module.exports = createReactClass({
displayName: 'UploadBar',
propTypes: {
room: PropTypes.object,
},

View file

@ -1,5 +1,6 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,6 +16,7 @@ limitations under the License.
*/
import React from "react";
import PropTypes from "prop-types";
import Matrix from "matrix-js-sdk";
import MatrixClientPeg from "../../MatrixClientPeg";
import sdk from "../../index";
@ -24,7 +26,7 @@ import { _t } from '../../languageHandler';
export default class UserView extends React.Component {
static get propTypes() {
return {
userId: React.PropTypes.string,
userId: PropTypes.string,
};
}

View file

@ -16,13 +16,14 @@ limitations under the License.
*/
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import SyntaxHighlight from '../views/elements/SyntaxHighlight';
import {_t} from "../../languageHandler";
import sdk from "../../index";
module.exports = React.createClass({
module.exports = createReactClass({
displayName: 'ViewSource',
propTypes: {

View file

@ -1,6 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017, 2018, 2019 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,13 +17,15 @@ limitations under the License.
*/
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import sdk from '../../../index';
import Modal from "../../../Modal";
import SdkConfig from "../../../SdkConfig";
import PasswordReset from "../../../PasswordReset";
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import classNames from 'classnames';
// Phases
// Show controls to configure server details
@ -36,46 +39,73 @@ const PHASE_EMAIL_SENT = 3;
// User has clicked the link in email and completed reset
const PHASE_DONE = 4;
module.exports = React.createClass({
module.exports = createReactClass({
displayName: 'ForgotPassword',
propTypes: {
// The default server name to use when the user hasn't specified
// one. If set, `defaultHsUrl` and `defaultHsUrl` were derived for this
// via `.well-known` discovery. The server name is used instead of the
// HS URL when talking about "your account".
defaultServerName: PropTypes.string,
// An error passed along from higher up explaining that something
// went wrong when finding the defaultHsUrl.
defaultServerDiscoveryError: PropTypes.string,
defaultHsUrl: PropTypes.string,
defaultIsUrl: PropTypes.string,
customHsUrl: PropTypes.string,
customIsUrl: PropTypes.string,
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
onServerConfigChange: PropTypes.func.isRequired,
onLoginClick: PropTypes.func,
onComplete: PropTypes.func.isRequired,
},
getInitialState: function() {
return {
enteredHsUrl: this.props.customHsUrl || this.props.defaultHsUrl,
enteredIsUrl: this.props.customIsUrl || this.props.defaultIsUrl,
phase: PHASE_FORGOT,
email: "",
password: "",
password2: "",
errorText: null,
// We perform liveliness checks later, but for now suppress the errors.
// We also track the server dead errors independently of the regular errors so
// that we can render it differently, and override any other error the user may
// be seeing.
serverIsAlive: true,
serverErrorIsFatal: false,
serverDeadError: "",
serverRequiresIdServer: null,
};
},
submitPasswordReset: function(hsUrl, identityUrl, email, password) {
componentWillMount: function() {
this.reset = null;
this._checkServerLiveliness(this.props.serverConfig);
},
componentWillReceiveProps: function(newProps) {
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
// Do a liveliness check on the new URLs
this._checkServerLiveliness(newProps.serverConfig);
},
_checkServerLiveliness: async function(serverConfig) {
try {
await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(
serverConfig.hsUrl,
serverConfig.isUrl,
);
const pwReset = new PasswordReset(serverConfig.hsUrl, serverConfig.isUrl);
const serverRequiresIdServer = await pwReset.doesServerRequireIdServerParam();
this.setState({
serverIsAlive: true,
serverRequiresIdServer,
});
} catch (e) {
this.setState(AutoDiscoveryUtils.authComponentStateForError(e, "forgot_password"));
}
},
submitPasswordReset: function(email, password) {
this.setState({
phase: PHASE_SENDING_EMAIL,
});
this.reset = new PasswordReset(hsUrl, identityUrl);
this.reset.resetPassword(email, password).done(() => {
this.reset = new PasswordReset(this.props.serverConfig.hsUrl, this.props.serverConfig.isUrl);
this.reset.resetPassword(email, password).then(() => {
this.setState({
phase: PHASE_EMAIL_SENT,
});
@ -87,28 +117,25 @@ module.exports = React.createClass({
});
},
onVerify: function(ev) {
onVerify: async function(ev) {
ev.preventDefault();
if (!this.reset) {
console.error("onVerify called before submitPasswordReset!");
return;
}
this.reset.checkEmailLinkClicked().done((res) => {
try {
await this.reset.checkEmailLinkClicked();
this.setState({ phase: PHASE_DONE });
}, (err) => {
} catch (err) {
this.showErrorDialog(err.message);
});
}
},
onSubmitForm: function(ev) {
onSubmitForm: async function(ev) {
ev.preventDefault();
// Don't allow the user to register if there's a discovery error
// Without this, the user could end up registering on the wrong homeserver.
if (this.props.defaultServerDiscoveryError) {
this.setState({errorText: this.props.defaultServerDiscoveryError});
return;
}
// refresh the server errors, just in case the server came back online
await this._checkServerLiveliness(this.props.serverConfig);
if (!this.state.email) {
this.showErrorDialog(_t('The email address linked to your account must be entered.'));
@ -132,10 +159,7 @@ module.exports = React.createClass({
button: _t('Continue'),
onFinished: (confirmed) => {
if (confirmed) {
this.submitPasswordReset(
this.state.enteredHsUrl, this.state.enteredIsUrl,
this.state.email, this.state.password,
);
this.submitPasswordReset(this.state.email, this.state.password);
}
},
});
@ -148,19 +172,7 @@ module.exports = React.createClass({
});
},
onServerConfigChange: function(config) {
const newState = {};
if (config.hsUrl !== undefined) {
newState.enteredHsUrl = config.hsUrl;
}
if (config.isUrl !== undefined) {
newState.enteredIsUrl = config.isUrl;
}
this.setState(newState);
},
onServerDetailsNextPhaseClick(ev) {
ev.stopPropagation();
async onServerDetailsNextPhaseClick() {
this.setState({
phase: PHASE_FORGOT,
});
@ -190,56 +202,61 @@ module.exports = React.createClass({
renderServerDetails() {
const ServerConfig = sdk.getComponent("auth.ServerConfig");
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
if (SdkConfig.get()['disable_custom_urls']) {
return null;
}
return <div>
<ServerConfig
defaultHsUrl={this.props.defaultHsUrl}
defaultIsUrl={this.props.defaultIsUrl}
customHsUrl={this.state.enteredHsUrl}
customIsUrl={this.state.enteredIsUrl}
onServerConfigChange={this.onServerConfigChange}
delayTimeMs={0} />
<AccessibleButton className="mx_Login_submit"
onClick={this.onServerDetailsNextPhaseClick}
>
{_t("Next")}
</AccessibleButton>
</div>;
return <ServerConfig
serverConfig={this.props.serverConfig}
onServerConfigChange={this.props.onServerConfigChange}
delayTimeMs={0}
showIdentityServerIfRequiredByHomeserver={true}
onAfterSubmit={this.onServerDetailsNextPhaseClick}
submitText={_t("Next")}
submitClass="mx_Login_submit"
/>;
},
renderForgot() {
const Field = sdk.getComponent('elements.Field');
let errorText = null;
const err = this.state.errorText || this.props.defaultServerDiscoveryError;
const err = this.state.errorText;
if (err) {
errorText = <div className="mx_Login_error">{ err }</div>;
}
let yourMatrixAccountText = _t('Your Matrix account');
if (this.state.enteredHsUrl === this.props.defaultHsUrl && this.props.defaultServerName) {
yourMatrixAccountText = _t('Your Matrix account on %(serverName)s', {
serverName: this.props.defaultServerName,
let serverDeadSection;
if (!this.state.serverIsAlive) {
const classes = classNames({
"mx_Login_error": true,
"mx_Login_serverError": true,
"mx_Login_serverErrorNonFatal": !this.state.serverErrorIsFatal,
});
serverDeadSection = (
<div className={classes}>
{this.state.serverDeadError}
</div>
);
}
let yourMatrixAccountText = _t('Your Matrix account on %(serverName)s', {
serverName: this.props.serverConfig.hsName,
});
if (this.props.serverConfig.hsNameIsDifferent) {
const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip");
yourMatrixAccountText = _t('Your Matrix account on <underlinedServerName />', {}, {
'underlinedServerName': () => {
return <TextWithTooltip
class="mx_Login_underlinedServerName"
tooltip={this.props.serverConfig.hsUrl}
>
{this.props.serverConfig.hsName}
</TextWithTooltip>;
},
});
} else {
try {
const parsedHsUrl = new URL(this.state.enteredHsUrl);
yourMatrixAccountText = _t('Your Matrix account on %(serverName)s', {
serverName: parsedHsUrl.hostname,
});
} catch (e) {
errorText = <div className="mx_Login_error">{_t(
"The homeserver URL %(hsUrl)s doesn't seem to be valid URL. Please " +
"enter a valid URL including the protocol prefix.",
{
hsUrl: this.state.enteredHsUrl,
})}</div>;
}
}
// If custom URLs are allowed, wire up the server details edit link.
@ -252,12 +269,29 @@ module.exports = React.createClass({
</a>;
}
if (!this.props.serverConfig.isUrl && this.state.serverRequiresIdServer) {
return <div>
<h3>
{yourMatrixAccountText}
{editLink}
</h3>
{_t(
"No identity server is configured: " +
"add one in server settings to reset your password.",
)}
<a className="mx_AuthBody_changeFlow" onClick={this.onLoginClick} href="#">
{_t('Sign in instead')}
</a>
</div>;
}
return <div>
{errorText}
{serverDeadSection}
<h3>
{yourMatrixAccountText}
{editLink}
</h3>
{errorText}
<form onSubmit={this.onSubmitForm}>
<div className="mx_AuthBody_fieldRow">
<Field
@ -292,7 +326,11 @@ module.exports = React.createClass({
'A verification email will be sent to your inbox to confirm ' +
'setting your new password.',
)}</span>
<input className="mx_Login_submit" type="submit" value={_t('Send Reset Email')} />
<input
className="mx_Login_submit"
type="submit"
value={_t('Send Reset Email')}
/>
</form>
<a className="mx_AuthBody_changeFlow" onClick={this.onLoginClick} href="#">
{_t('Sign in instead')}

View file

@ -16,16 +16,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import { _t, _td } from '../../../languageHandler';
import {_t, _td} from '../../../languageHandler';
import sdk from '../../../index';
import Login from '../../../Login';
import SdkConfig from '../../../SdkConfig';
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
import { AutoDiscovery } from "matrix-js-sdk";
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import classNames from "classnames";
// For validating phone numbers without country codes
const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/;
@ -53,25 +53,15 @@ _td("General failure");
/**
* A wire component which glues together login UI components and Login logic
*/
module.exports = React.createClass({
module.exports = createReactClass({
displayName: 'Login',
propTypes: {
onLoggedIn: PropTypes.func.isRequired,
// The default server name to use when the user hasn't specified
// one. If set, `defaultHsUrl` and `defaultHsUrl` were derived for this
// via `.well-known` discovery. The server name is used instead of the
// HS URL when talking about where to "sign in to".
defaultServerName: PropTypes.string,
// An error passed along from higher up explaining that something
// went wrong when finding the defaultHsUrl.
defaultServerDiscoveryError: PropTypes.string,
// If true, the component will consider itself busy.
busy: PropTypes.bool,
customHsUrl: PropTypes.string,
customIsUrl: PropTypes.string,
defaultHsUrl: PropTypes.string,
defaultIsUrl: PropTypes.string,
// Secondary HS which we try to log into if the user is using
// the default HS but login fails. Useful for migrating to a
// different homeserver without confusing users.
@ -79,12 +69,13 @@ module.exports = React.createClass({
defaultDeviceDisplayName: PropTypes.string,
// login shouldn't know or care how registration is done.
// login shouldn't know or care how registration, password recovery,
// etc is done.
onRegisterClick: PropTypes.func.isRequired,
// login shouldn't care how password recovery is done.
onForgotPasswordClick: PropTypes.func,
onServerConfigChange: PropTypes.func.isRequired,
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
},
getInitialState: function() {
@ -92,9 +83,7 @@ module.exports = React.createClass({
busy: false,
errorText: null,
loginIncorrect: false,
enteredHsUrl: this.props.customHsUrl || this.props.defaultHsUrl,
enteredIsUrl: this.props.customIsUrl || this.props.defaultIsUrl,
canTryLogin: true, // can we attempt to log in or are there validation errors?
// used for preserving form values when changing homeserver
username: "",
@ -104,11 +93,15 @@ module.exports = React.createClass({
// Phase of the overall login dialog.
phase: PHASE_LOGIN,
// The current login flow, such as password, SSO, etc.
currentFlow: "m.login.password",
currentFlow: null, // we need to load the flows from the server
// .well-known discovery
discoveryError: "",
findingHomeserver: false,
// We perform liveliness checks later, but for now suppress the errors.
// We also track the server dead errors independently of the regular errors so
// that we can render it differently, and override any other error the user may
// be seeing.
serverIsAlive: true,
serverErrorIsFatal: false,
serverDeadError: "",
};
},
@ -132,6 +125,14 @@ module.exports = React.createClass({
this._unmounted = true;
},
componentWillReceiveProps(newProps) {
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
// Ensure that we end up actually logging in to the right place
this._initLoginLogic(newProps.serverConfig.hsUrl, newProps.serverConfig.isUrl);
},
onPasswordLoginError: function(errorText) {
this.setState({
errorText,
@ -139,10 +140,35 @@ module.exports = React.createClass({
});
},
onPasswordLogin: function(username, phoneCountry, phoneNumber, password) {
// Prevent people from submitting their password when homeserver
// discovery went wrong
if (this.state.discoveryError || this.props.defaultServerDiscoveryError) return;
isBusy: function() {
return this.state.busy || this.props.busy;
},
onPasswordLogin: async function(username, phoneCountry, phoneNumber, password) {
if (!this.state.serverIsAlive) {
this.setState({busy: true});
// Do a quick liveliness check on the URLs
let aliveAgain = true;
try {
await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(
this.props.serverConfig.hsUrl,
this.props.serverConfig.isUrl,
);
this.setState({serverIsAlive: true, errorText: ""});
} catch (e) {
const componentState = AutoDiscoveryUtils.authComponentStateForError(e);
this.setState({
busy: false,
...componentState,
});
aliveAgain = !componentState.serverErrorIsFatal;
}
// Prevent people from submitting their password when something isn't right.
if (!aliveAgain) {
return;
}
}
this.setState({
busy: true,
@ -153,6 +179,7 @@ module.exports = React.createClass({
this._loginLogic.loginViaPassword(
username, phoneCountry, phoneNumber, password,
).then((data) => {
this.setState({serverIsAlive: true}); // it must be, we logged in.
this.props.onLoggedIn(data);
}, (error) => {
if (this._unmounted) {
@ -164,7 +191,7 @@ module.exports = React.createClass({
const usingEmail = username.indexOf("@") > 0;
if (error.httpStatus === 400 && usingEmail) {
errorText = _t('This homeserver does not support login using email address.');
} else if (error.errcode == 'M_RESOURCE_LIMIT_EXCEEDED') {
} else if (error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
const errorTop = messageForResourceLimitError(
error.data.limit_type,
error.data.admin_contact, {
@ -189,16 +216,17 @@ module.exports = React.createClass({
</div>
);
} else if (error.httpStatus === 401 || error.httpStatus === 403) {
if (SdkConfig.get()['disable_custom_urls']) {
if (error.errcode === 'M_USER_DEACTIVATED') {
errorText = _t('This account has been deactivated.');
} else if (SdkConfig.get()['disable_custom_urls']) {
errorText = (
<div>
<div>{ _t('Incorrect username and/or password.') }</div>
<div className="mx_Login_smallError">
{ _t('Please note you are logging into the %(hs)s server, not matrix.org.',
{
hs: this.props.defaultHsUrl.replace(/^https?:\/\//, ''),
})
}
{_t(
'Please note you are logging into the %(hs)s server, not matrix.org.',
{hs: this.props.serverConfig.hsName},
)}
</div>
</div>
);
@ -225,28 +253,56 @@ module.exports = React.createClass({
this.setState({
busy: false,
});
}).done();
});
},
onUsernameChanged: function(username) {
this.setState({ username: username });
},
onUsernameBlur: function(username) {
onUsernameBlur: async function(username) {
const doWellknownLookup = username[0] === "@";
this.setState({
username: username,
discoveryError: null,
busy: doWellknownLookup,
errorText: null,
canTryLogin: true,
});
if (username[0] === "@") {
if (doWellknownLookup) {
const serverName = username.split(':').slice(1).join(':');
try {
// we have to append 'https://' to make the URL constructor happy
// otherwise we get things like 'protocol: matrix.org, pathname: 8448'
const url = new URL("https://" + serverName);
this._tryWellKnownDiscovery(url.hostname);
const result = await AutoDiscoveryUtils.validateServerName(serverName);
this.props.onServerConfigChange(result);
// We'd like to rely on new props coming in via `onServerConfigChange`
// so that we know the servers have definitely updated before clearing
// the busy state. In the case of a full MXID that resolves to the same
// HS as Riot's default HS though, there may not be any server change.
// To avoid this trap, we clear busy here. For cases where the server
// actually has changed, `_initLoginLogic` will be called and manages
// busy state for its own liveness check.
this.setState({
busy: false,
});
} catch (e) {
console.error("Problem parsing URL or unhandled error doing .well-known discovery:", e);
this.setState({discoveryError: _t("Failed to perform homeserver discovery")});
let message = _t("Failed to perform homeserver discovery");
if (e.translatedMessage) {
message = e.translatedMessage;
}
let errorText = message;
let discoveryState = {};
if (AutoDiscoveryUtils.isLivelinessError(e)) {
errorText = this.state.errorText;
discoveryState = AutoDiscoveryUtils.authComponentStateForError(e);
}
this.setState({
busy: false,
errorText,
...discoveryState,
});
}
}
},
@ -262,44 +318,27 @@ module.exports = React.createClass({
},
onPhoneNumberBlur: function(phoneNumber) {
this.setState({
errorText: null,
});
// Validate the phone number entered
if (!PHONE_NUMBER_REGEX.test(phoneNumber)) {
this.setState({
errorText: _t('The phone number entered looks invalid'),
canTryLogin: false,
});
} else {
this.setState({
errorText: null,
canTryLogin: true,
});
}
},
onServerConfigChange: function(config) {
const self = this;
const newState = {
errorText: null, // reset err messages
};
if (config.hsUrl !== undefined) {
newState.enteredHsUrl = config.hsUrl;
}
if (config.isUrl !== undefined) {
newState.enteredIsUrl = config.isUrl;
}
this.props.onServerConfigChange(config);
this.setState(newState, function() {
self._initLoginLogic(config.hsUrl || null, config.isUrl);
});
},
onRegisterClick: function(ev) {
ev.preventDefault();
ev.stopPropagation();
this.props.onRegisterClick();
},
onServerDetailsNextPhaseClick(ev) {
ev.stopPropagation();
async onServerDetailsNextPhaseClick() {
this.setState({
phase: PHASE_LOGIN,
});
@ -313,64 +352,18 @@ module.exports = React.createClass({
});
},
_tryWellKnownDiscovery: async function(serverName) {
if (!serverName.trim()) {
// Nothing to discover
this.setState({
discoveryError: "",
findingHomeserver: false,
});
return;
_initLoginLogic: async function(hsUrl, isUrl) {
hsUrl = hsUrl || this.props.serverConfig.hsUrl;
isUrl = isUrl || this.props.serverConfig.isUrl;
let isDefaultServer = false;
if (this.props.serverConfig.isDefault
&& hsUrl === this.props.serverConfig.hsUrl
&& isUrl === this.props.serverConfig.isUrl) {
isDefaultServer = true;
}
this.setState({findingHomeserver: true});
try {
const discovery = await AutoDiscovery.findClientConfig(serverName);
const state = discovery["m.homeserver"].state;
if (state !== AutoDiscovery.SUCCESS && state !== AutoDiscovery.PROMPT) {
this.setState({
discoveryError: discovery["m.homeserver"].error,
findingHomeserver: false,
});
} else if (state === AutoDiscovery.PROMPT) {
this.setState({
discoveryError: "",
findingHomeserver: false,
});
} else if (state === AutoDiscovery.SUCCESS) {
this.setState({
discoveryError: "",
findingHomeserver: false,
});
this.onServerConfigChange({
hsUrl: discovery["m.homeserver"].base_url,
isUrl: discovery["m.identity_server"].state === AutoDiscovery.SUCCESS
? discovery["m.identity_server"].base_url
: "",
});
} else {
console.warn("Unknown state for m.homeserver in discovery response: ", discovery);
this.setState({
discoveryError: _t("Unknown failure discovering homeserver"),
findingHomeserver: false,
});
}
} catch (e) {
console.error(e);
this.setState({
findingHomeserver: false,
discoveryError: _t("Unknown error discovering homeserver"),
});
}
},
_initLoginLogic: function(hsUrl, isUrl) {
const self = this;
hsUrl = hsUrl || this.state.enteredHsUrl;
isUrl = isUrl || this.state.enteredIsUrl;
const fallbackHsUrl = hsUrl === this.props.defaultHsUrl ? this.props.fallbackHsUrl : null;
const fallbackHsUrl = isDefaultServer ? this.props.fallbackHsUrl : null;
const loginLogic = new Login(hsUrl, isUrl, fallbackHsUrl, {
defaultDeviceDisplayName: this.props.defaultDeviceDisplayName,
@ -378,12 +371,40 @@ module.exports = React.createClass({
this._loginLogic = loginLogic;
this.setState({
enteredHsUrl: hsUrl,
enteredIsUrl: isUrl,
busy: true,
currentFlow: null, // reset flow
loginIncorrect: false,
});
// Do a quick liveliness check on the URLs
try {
const { warning } =
await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl);
if (warning) {
this.setState({
...AutoDiscoveryUtils.authComponentStateForError(warning),
errorText: "",
});
} else {
this.setState({
serverIsAlive: true,
errorText: "",
});
}
} catch (e) {
this.setState({
busy: false,
...AutoDiscoveryUtils.authComponentStateForError(e),
});
if (this.state.serverErrorIsFatal) {
// Server is dead: show server details prompt instead
this.setState({
phase: PHASE_SERVER_DETAILS,
});
return;
}
}
loginLogic.getFlows().then((flows) => {
// look for a flow where we understand all of the steps.
for (let i = 0; i < flows.length; i++ ) {
@ -408,16 +429,17 @@ module.exports = React.createClass({
"supported by this client.",
),
});
}, function(err) {
self.setState({
errorText: self._errorTextFromError(err),
}, (err) => {
this.setState({
errorText: this._errorTextFromError(err),
loginIncorrect: false,
canTryLogin: false,
});
}).finally(function() {
self.setState({
}).finally(() => {
this.setState({
busy: false,
});
}).done();
});
},
_isSupportedFlow: function(flow) {
@ -445,8 +467,8 @@ module.exports = React.createClass({
if (err.cors === 'rejected') {
if (window.location.protocol === 'https:' &&
(this.state.enteredHsUrl.startsWith("http:") ||
!this.state.enteredHsUrl.startsWith("http"))
(this.props.serverConfig.hsUrl.startsWith("http:") ||
!this.props.serverConfig.hsUrl.startsWith("http"))
) {
errorText = <span>
{ _t("Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. " +
@ -469,9 +491,9 @@ module.exports = React.createClass({
"is not blocking requests.", {},
{
'a': (sub) => {
return <a target="_blank" rel="noopener"
href={this.state.enteredHsUrl}
>{ sub }</a>;
return <a target="_blank" rel="noopener" href={this.props.serverConfig.hsUrl}>
{ sub }
</a>;
},
},
) }
@ -484,7 +506,6 @@ module.exports = React.createClass({
renderServerComponent() {
const ServerConfig = sdk.getComponent("auth.ServerConfig");
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
if (SdkConfig.get()['disable_custom_urls']) {
return null;
@ -494,28 +515,19 @@ module.exports = React.createClass({
return null;
}
const serverDetails = <ServerConfig
customHsUrl={this.state.enteredHsUrl}
customIsUrl={this.state.enteredIsUrl}
defaultHsUrl={this.props.defaultHsUrl}
defaultIsUrl={this.props.defaultIsUrl}
onServerConfigChange={this.onServerConfigChange}
delayTimeMs={250}
/>;
let nextButton = null;
const serverDetailsProps = {};
if (PHASES_ENABLED) {
nextButton = <AccessibleButton className="mx_Login_submit"
onClick={this.onServerDetailsNextPhaseClick}
>
{_t("Next")}
</AccessibleButton>;
serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick;
serverDetailsProps.submitText = _t("Next");
serverDetailsProps.submitClass = "mx_Login_submit";
}
return <div>
{serverDetails}
{nextButton}
</div>;
return <ServerConfig
serverConfig={this.props.serverConfig}
onServerConfigChange={this.props.onServerConfigChange}
delayTimeMs={250}
{...serverDetailsProps}
/>;
},
renderLoginComponentForStep() {
@ -547,13 +559,6 @@ module.exports = React.createClass({
onEditServerDetailsClick = this.onEditServerDetailsClick;
}
// If the current HS URL is the default HS URL, then we can label it
// with the default HS name (if it exists).
let hsName;
if (this.state.enteredHsUrl === this.props.defaultHsUrl) {
hsName = this.props.defaultServerName;
}
return (
<PasswordLogin
onSubmit={this.onPasswordLogin}
@ -569,14 +574,20 @@ module.exports = React.createClass({
onPhoneNumberBlur={this.onPhoneNumberBlur}
onForgotPasswordClick={this.props.onForgotPasswordClick}
loginIncorrect={this.state.loginIncorrect}
hsName={hsName}
hsUrl={this.state.enteredHsUrl}
disableSubmit={this.state.findingHomeserver}
/>
serverConfig={this.props.serverConfig}
disableSubmit={this.isBusy()}
/>
);
},
_renderSsoStep: function(url) {
const SignInToText = sdk.getComponent('views.auth.SignInToText');
let onEditServerDetailsClick = null;
// If custom URLs are allowed, wire up the server details edit link.
if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) {
onEditServerDetailsClick = this.onEditServerDetailsClick;
}
// XXX: This link does *not* have a target="_blank" because single sign-on relies on
// redirecting the user back to a URI once they're logged in. On the web, this means
// we use the same window and redirect back to riot. On electron, this actually
@ -586,7 +597,12 @@ module.exports = React.createClass({
// user's browser, let them log into their SSO provider, then redirect their browser
// to vector://vector which, of course, will not work.
return (
<a href={url} className="mx_Login_sso_link mx_Login_submit">{ _t('Sign in with single sign-on') }</a>
<div>
<SignInToText serverConfig={this.props.serverConfig}
onEditServerDetailsClick={onEditServerDetailsClick} />
<a href={url} className="mx_Login_sso_link mx_Login_submit">{ _t('Sign in with single sign-on') }</a>
</div>
);
},
@ -595,9 +611,9 @@ module.exports = React.createClass({
const AuthPage = sdk.getComponent("auth.AuthPage");
const AuthHeader = sdk.getComponent("auth.AuthHeader");
const AuthBody = sdk.getComponent("auth.AuthBody");
const loader = this.state.busy ? <div className="mx_Login_loader"><Loader /></div> : null;
const loader = this.isBusy() ? <div className="mx_Login_loader"><Loader /></div> : null;
const errorText = this.props.defaultServerDiscoveryError || this.state.discoveryError || this.state.errorText;
const errorText = this.state.errorText;
let errorTextSection;
if (errorText) {
@ -608,6 +624,20 @@ module.exports = React.createClass({
);
}
let serverDeadSection;
if (!this.state.serverIsAlive) {
const classes = classNames({
"mx_Login_error": true,
"mx_Login_serverError": true,
"mx_Login_serverErrorNonFatal": !this.state.serverErrorIsFatal,
});
serverDeadSection = (
<div className={classes}>
{this.state.serverDeadError}
</div>
);
}
return (
<AuthPage>
<AuthHeader />
@ -617,6 +647,7 @@ module.exports = React.createClass({
{loader}
</h2>
{ errorTextSection }
{ serverDeadSection }
{ this.renderServerComponent() }
{ this.renderLoginComponentForStep() }
<a className="mx_AuthBody_changeFlow" onClick={this.onRegisterClick} href="#">

View file

@ -14,15 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
import { _t } from '../../../languageHandler';
module.exports = React.createClass({
module.exports = createReactClass({
displayName: 'PostRegistration',
propTypes: {
@ -44,7 +43,7 @@ module.exports = React.createClass({
const cli = MatrixClientPeg.get();
this.setState({busy: true});
const self = this;
cli.getProfileInfo(cli.credentials.userId).done(function(result) {
cli.getProfileInfo(cli.credentials.userId).then(function(result) {
self.setState({
avatarUrl: MatrixClientPeg.get().mxcUrlToHttp(result.avatar_url),
busy: false,

View file

@ -2,6 +2,7 @@
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018, 2019 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -17,16 +18,18 @@ limitations under the License.
*/
import Matrix from 'matrix-js-sdk';
import Promise from 'bluebird';
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import { _t, _td } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
import * as ServerType from '../../views/auth/ServerTypeSelector';
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import classNames from "classnames";
import * as Lifecycle from '../../../Lifecycle';
import MatrixClientPeg from "../../../MatrixClientPeg";
// Phases
// Show controls to configure server details
@ -37,7 +40,7 @@ const PHASE_REGISTRATION = 1;
// Enable phases for registration
const PHASES_ENABLED = true;
module.exports = React.createClass({
module.exports = createReactClass({
displayName: 'Registration',
propTypes: {
@ -46,18 +49,7 @@ module.exports = React.createClass({
sessionId: PropTypes.string,
makeRegistrationUrl: PropTypes.func.isRequired,
idSid: PropTypes.string,
// The default server name to use when the user hasn't specified
// one. If set, `defaultHsUrl` and `defaultHsUrl` were derived for this
// via `.well-known` discovery. The server name is used instead of the
// HS URL when talking about "your account".
defaultServerName: PropTypes.string,
// An error passed along from higher up explaining that something
// went wrong when finding the defaultHsUrl.
defaultServerDiscoveryError: PropTypes.string,
customHsUrl: PropTypes.string,
customIsUrl: PropTypes.string,
defaultHsUrl: PropTypes.string,
defaultIsUrl: PropTypes.string,
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
brand: PropTypes.string,
email: PropTypes.string,
// registration shouldn't know or care how login is done.
@ -66,7 +58,7 @@ module.exports = React.createClass({
},
getInitialState: function() {
const serverType = ServerType.getTypeFromHsUrl(this.props.customHsUrl);
const serverType = ServerType.getTypeFromServerConfig(this.props.serverConfig);
return {
busy: false,
@ -87,11 +79,34 @@ module.exports = React.createClass({
// straight back into UI auth
doingUIAuth: Boolean(this.props.sessionId),
serverType,
hsUrl: this.props.customHsUrl,
isUrl: this.props.customIsUrl,
// Phase of the overall registration dialog.
phase: PHASE_REGISTRATION,
flows: null,
// If set, we've registered but are not going to log
// the user in to their new account automatically.
completedNoSignin: false,
// We perform liveliness checks later, but for now suppress the errors.
// We also track the server dead errors independently of the regular errors so
// that we can render it differently, and override any other error the user may
// be seeing.
serverIsAlive: true,
serverErrorIsFatal: false,
serverDeadError: "",
// Our matrix client - part of state because we can't render the UI auth
// component without it.
matrixClient: null,
// whether the HS requires an ID server to register with a threepid
serverRequiresIdServer: null,
// The user ID we've just registered
registeredUsername: null,
// if a different user ID to the one we just registered is logged in,
// this is the user ID that's logged in.
differentLoggedInUserId: null,
};
},
@ -100,18 +115,22 @@ module.exports = React.createClass({
this._replaceClient();
},
onServerConfigChange: function(config) {
const newState = {};
if (config.hsUrl !== undefined) {
newState.hsUrl = config.hsUrl;
componentWillReceiveProps(newProps) {
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
this._replaceClient(newProps.serverConfig);
// Handle cases where the user enters "https://matrix.org" for their server
// from the advanced option - we should default to FREE at that point.
const serverType = ServerType.getTypeFromServerConfig(newProps.serverConfig);
if (serverType !== this.state.serverType) {
// Reset the phase to default phase for the server type.
this.setState({
serverType,
phase: this.getDefaultPhaseForServerType(serverType),
});
}
if (config.isUrl !== undefined) {
newState.isUrl = config.isUrl;
}
this.props.onServerConfigChange(config);
this.setState(newState, () => {
this._replaceClient();
});
},
getDefaultPhaseForServerType(type) {
@ -136,19 +155,17 @@ module.exports = React.createClass({
// the new type.
switch (type) {
case ServerType.FREE: {
const { hsUrl, isUrl } = ServerType.TYPES.FREE;
this.onServerConfigChange({
hsUrl,
isUrl,
});
const { serverConfig } = ServerType.TYPES.FREE;
this.props.onServerConfigChange(serverConfig);
break;
}
case ServerType.PREMIUM:
// We can accept whatever server config was the default here as this essentially
// acts as a slightly different "custom server"/ADVANCED option.
break;
case ServerType.ADVANCED:
this.onServerConfigChange({
hsUrl: this.props.defaultHsUrl,
isUrl: this.props.defaultIsUrl,
});
// Use the default config from the config
this.props.onServerConfigChange(SdkConfig.get()["validated_server_config"]);
break;
}
@ -158,13 +175,54 @@ module.exports = React.createClass({
});
},
_replaceClient: async function() {
_replaceClient: async function(serverConfig) {
this.setState({
errorText: null,
serverDeadError: null,
serverErrorIsFatal: false,
// busy while we do liveness check (we need to avoid trying to render
// the UI auth component while we don't have a matrix client)
busy: true,
});
this._matrixClient = Matrix.createClient({
baseUrl: this.state.hsUrl,
idBaseUrl: this.state.isUrl,
if (!serverConfig) serverConfig = this.props.serverConfig;
// Do a liveliness check on the URLs
try {
await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(
serverConfig.hsUrl,
serverConfig.isUrl,
);
this.setState({
serverIsAlive: true,
serverErrorIsFatal: false,
});
} catch (e) {
this.setState({
busy: false,
...AutoDiscoveryUtils.authComponentStateForError(e, "register"),
});
if (this.state.serverErrorIsFatal) {
return; // Server is dead - do not continue.
}
}
const {hsUrl, isUrl} = serverConfig;
const cli = Matrix.createClient({
baseUrl: hsUrl,
idBaseUrl: isUrl,
});
let serverRequiresIdServer = true;
try {
serverRequiresIdServer = await cli.doesServerRequireIdServerParam();
} catch (e) {
console.log("Unable to determine is server needs id_server param", e);
}
this.setState({
matrixClient: cli,
serverRequiresIdServer,
busy: false,
});
try {
await this._makeRegisterRequest({});
@ -179,22 +237,21 @@ module.exports = React.createClass({
} else if (e.httpStatus === 403 && e.errcode === "M_UNKNOWN") {
this.setState({
errorText: _t("Registration has been disabled on this homeserver."),
// add empty flows array to get rid of spinner
flows: [],
});
} else {
console.log("Unable to query for supported registration methods.", e);
this.setState({
errorText: _t("Unable to query for supported registration methods."),
// add empty flows array to get rid of spinner
flows: [],
});
}
}
},
onFormSubmit: function(formVals) {
// Don't allow the user to register if there's a discovery error
// Without this, the user could end up registering on the wrong homeserver.
if (this.props.defaultServerDiscoveryError) {
this.setState({errorText: this.props.defaultServerDiscoveryError});
return;
}
this.setState({
errorText: "",
busy: true,
@ -203,11 +260,25 @@ module.exports = React.createClass({
});
},
_requestEmailToken: function(emailAddress, clientSecret, sendAttempt, sessionId) {
return this.state.matrixClient.requestRegisterEmailToken(
emailAddress,
clientSecret,
sendAttempt,
this.props.makeRegistrationUrl({
client_secret: clientSecret,
hs_url: this.state.matrixClient.getHomeserverUrl(),
is_url: this.state.matrixClient.getIdentityServerUrl(),
session_id: sessionId,
}),
);
},
_onUIAuthFinished: async function(success, response, extra) {
if (!success) {
let msg = response.message || response.toString();
// can we give a better error message?
if (response.errcode == 'M_RESOURCE_LIMIT_EXCEEDED') {
if (response.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
const errorTop = messageForResourceLimitError(
response.data.limit_type,
response.data.admin_contact, {
@ -246,21 +317,47 @@ module.exports = React.createClass({
return;
}
this.setState({
// we're still busy until we get unmounted: don't show the registration form again
busy: true,
MatrixClientPeg.setJustRegisteredUserId(response.user_id);
const newState = {
doingUIAuth: false,
});
registeredUsername: response.user_id,
};
const cli = await this.props.onLoggedIn({
userId: response.user_id,
deviceId: response.device_id,
homeserverUrl: this._matrixClient.getHomeserverUrl(),
identityServerUrl: this._matrixClient.getIdentityServerUrl(),
accessToken: response.access_token,
});
// The user came in through an email validation link. To avoid overwriting
// their session, check to make sure the session isn't someone else, and
// isn't a guest user since we'll usually have set a guest user session before
// starting the registration process. This isn't perfect since it's possible
// the user had a separate guest session they didn't actually mean to replace.
const sessionOwner = Lifecycle.getStoredSessionOwner();
const sessionIsGuest = Lifecycle.getStoredSessionIsGuest();
if (sessionOwner && !sessionIsGuest && sessionOwner !== response.userId) {
console.log(
`Found a session for ${sessionOwner} but ${response.userId} has just registered.`,
);
newState.differentLoggedInUserId = sessionOwner;
} else {
newState.differentLoggedInUserId = null;
}
this._setupPushers(cli);
if (response.access_token) {
const cli = await this.props.onLoggedIn({
userId: response.user_id,
deviceId: response.device_id,
homeserverUrl: this.state.matrixClient.getHomeserverUrl(),
identityServerUrl: this.state.matrixClient.getIdentityServerUrl(),
accessToken: response.access_token,
});
this._setupPushers(cli);
// we're still busy until we get unmounted: don't show the registration form again
newState.busy = true;
} else {
newState.busy = false;
newState.completedNoSignin = true;
}
this.setState(newState);
},
_setupPushers: function(matrixClient) {
@ -273,7 +370,7 @@ module.exports = React.createClass({
if (pushers[i].kind === 'email') {
const emailPusher = pushers[i];
emailPusher.data = { brand: this.props.brand };
matrixClient.setPusher(emailPusher).done(() => {
matrixClient.setPusher(emailPusher).then(() => {
console.log("Set email branding to " + this.props.brand);
}, (error) => {
console.error("Couldn't set email branding: " + error);
@ -302,8 +399,7 @@ module.exports = React.createClass({
});
},
onServerDetailsNextPhaseClick(ev) {
ev.stopPropagation();
async onServerDetailsNextPhaseClick() {
this.setState({
phase: PHASE_REGISTRATION,
});
@ -318,21 +414,25 @@ module.exports = React.createClass({
},
_makeRegisterRequest: function(auth) {
// Only send the bind params if we're sending username / pw params
// We inhibit login if we're trying to register with an email address: this
// avoids a lot of complex race conditions that can occur if we try to log
// the user in one one or both of the tabs they might end up with after
// clicking the email link.
let inhibitLogin = Boolean(this.state.formVals.email);
// Only send inhibitLogin if we're sending username / pw params
// (Since we need to send no params at all to use the ones saved in the
// session).
const bindThreepids = this.state.formVals.password ? {
email: true,
msisdn: true,
} : {};
if (!this.state.formVals.password) inhibitLogin = null;
return this._matrixClient.register(
return this.state.matrixClient.register(
this.state.formVals.username,
this.state.formVals.password,
undefined, // session id: included in the auth dict already
auth,
bindThreepids,
null,
null,
inhibitLogin,
);
},
@ -344,11 +444,23 @@ module.exports = React.createClass({
};
},
// Links to the login page shown after registration is completed are routed through this
// which checks the user hasn't already logged in somewhere else (perhaps we should do
// this more generally?)
_onLoginClickWithCheck: async function(ev) {
ev.preventDefault();
const sessionLoaded = await Lifecycle.loadSession({ignoreGuest: true});
if (!sessionLoaded) {
// ok fine, there's still no session: really go to the login page
this.props.onLoginClick();
}
},
renderServerComponent() {
const ServerTypeSelector = sdk.getComponent("auth.ServerTypeSelector");
const ServerConfig = sdk.getComponent("auth.ServerConfig");
const ModularServerConfig = sdk.getComponent("auth.ModularServerConfig");
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
if (SdkConfig.get()['disable_custom_urls']) {
return null;
@ -356,7 +468,9 @@ module.exports = React.createClass({
// If we're on a different phase, we only show the server type selector,
// which is always shown if we allow custom URLs at all.
if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS) {
// (if there's a fatal server error, we need to show the full server
// config as the user may need to change servers to resolve the error).
if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS && !this.state.serverErrorIsFatal) {
return <div>
<ServerTypeSelector
selected={this.state.serverType}
@ -365,47 +479,42 @@ module.exports = React.createClass({
</div>;
}
const serverDetailsProps = {};
if (PHASES_ENABLED) {
serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick;
serverDetailsProps.submitText = _t("Next");
serverDetailsProps.submitClass = "mx_Login_submit";
}
let serverDetails = null;
switch (this.state.serverType) {
case ServerType.FREE:
break;
case ServerType.PREMIUM:
serverDetails = <ModularServerConfig
customHsUrl={this.state.discoveredHsUrl || this.props.customHsUrl}
defaultHsUrl={this.props.defaultHsUrl}
defaultIsUrl={this.props.defaultIsUrl}
onServerConfigChange={this.onServerConfigChange}
serverConfig={this.props.serverConfig}
onServerConfigChange={this.props.onServerConfigChange}
delayTimeMs={250}
{...serverDetailsProps}
/>;
break;
case ServerType.ADVANCED:
serverDetails = <ServerConfig
customHsUrl={this.state.discoveredHsUrl || this.props.customHsUrl}
customIsUrl={this.state.discoveredIsUrl || this.props.customIsUrl}
defaultHsUrl={this.props.defaultHsUrl}
defaultIsUrl={this.props.defaultIsUrl}
onServerConfigChange={this.onServerConfigChange}
serverConfig={this.props.serverConfig}
onServerConfigChange={this.props.onServerConfigChange}
delayTimeMs={250}
showIdentityServerIfRequiredByHomeserver={true}
{...serverDetailsProps}
/>;
break;
}
let nextButton = null;
if (PHASES_ENABLED) {
nextButton = <AccessibleButton className="mx_Login_submit"
onClick={this.onServerDetailsNextPhaseClick}
>
{_t("Next")}
</AccessibleButton>;
}
return <div>
<ServerTypeSelector
selected={this.state.serverType}
onChange={this.onServerTypeChange}
/>
{serverDetails}
{nextButton}
</div>;
},
@ -418,23 +527,25 @@ module.exports = React.createClass({
const Spinner = sdk.getComponent('elements.Spinner');
const RegistrationForm = sdk.getComponent('auth.RegistrationForm');
if (this.state.doingUIAuth) {
if (this.state.matrixClient && this.state.doingUIAuth) {
return <InteractiveAuth
matrixClient={this._matrixClient}
matrixClient={this.state.matrixClient}
makeRequest={this._makeRegisterRequest}
onAuthFinished={this._onUIAuthFinished}
inputs={this._getUIAuthInputs()}
makeRegistrationUrl={this.props.makeRegistrationUrl}
requestEmailToken={this._requestEmailToken}
sessionId={this.props.sessionId}
clientSecret={this.props.clientSecret}
emailSid={this.props.idSid}
poll={true}
/>;
} else if (!this.state.matrixClient && !this.state.busy) {
return null;
} else if (this.state.busy || !this.state.flows) {
return <div className="mx_AuthBody_spinner">
<Spinner />
</div>;
} else {
} else if (this.state.flows.length) {
let onEditServerDetailsClick = null;
// If custom URLs are allowed and we haven't selected the Free server type, wire
// up the server details edit link.
@ -446,13 +557,6 @@ module.exports = React.createClass({
onEditServerDetailsClick = this.onEditServerDetailsClick;
}
// If the current HS URL is the default HS URL, then we can label it
// with the default HS name (if it exists).
let hsName;
if (this.state.hsUrl === this.props.defaultHsUrl) {
hsName = this.props.defaultServerName;
}
return <RegistrationForm
defaultUsername={this.state.formVals.username}
defaultEmail={this.state.formVals.email}
@ -462,8 +566,9 @@ module.exports = React.createClass({
onRegisterClick={this.onFormSubmit}
onEditServerDetailsClick={onEditServerDetailsClick}
flows={this.state.flows}
hsName={hsName}
hsUrl={this.state.hsUrl}
serverConfig={this.props.serverConfig}
canSubmit={!this.state.serverErrorIsFatal}
serverRequiresIdServer={this.state.serverRequiresIdServer}
/>;
}
},
@ -472,13 +577,28 @@ module.exports = React.createClass({
const AuthHeader = sdk.getComponent('auth.AuthHeader');
const AuthBody = sdk.getComponent("auth.AuthBody");
const AuthPage = sdk.getComponent('auth.AuthPage');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let errorText;
const err = this.state.errorText || this.props.defaultServerDiscoveryError;
const err = this.state.errorText;
if (err) {
errorText = <div className="mx_Login_error">{ err }</div>;
}
let serverDeadSection;
if (!this.state.serverIsAlive) {
const classes = classNames({
"mx_Login_error": true,
"mx_Login_serverError": true,
"mx_Login_serverErrorNonFatal": !this.state.serverErrorIsFatal,
});
serverDeadSection = (
<div className={classes}>
{this.state.serverDeadError}
</div>
);
}
const signIn = <a className="mx_AuthBody_changeFlow" onClick={this.onLoginClick} href="#">
{ _t('Sign in instead') }
</a>;
@ -491,16 +611,62 @@ module.exports = React.createClass({
</a>;
}
let body;
if (this.state.completedNoSignin) {
let regDoneText;
if (this.state.differentLoggedInUserId) {
regDoneText = <div>
<p>{_t(
"Your new account (%(newAccountId)s) is registered, but you're already " +
"logged into a different account (%(loggedInUserId)s).", {
newAccountId: this.state.registeredUsername,
loggedInUserId: this.state.differentLoggedInUserId,
},
)}</p>
<p><AccessibleButton element="span" className="mx_linkButton" onClick={this._onLoginClickWithCheck}>
{_t("Continue with previous account")}
</AccessibleButton></p>
</div>;
} else if (this.state.formVals.password) {
// We're the client that started the registration
regDoneText = <h3>{_t(
"<a>Log in</a> to your new account.", {},
{
a: (sub) => <a href="#/login" onClick={this._onLoginClickWithCheck}>{sub}</a>,
},
)}</h3>;
} else {
// We're not the original client: the user probably got to us by clicking the
// email validation link. We can't offer a 'go straight to your account' link
// as we don't have the original creds.
regDoneText = <h3>{_t(
"You can now close this window or <a>log in</a> to your new account.", {},
{
a: (sub) => <a href="#/login" onClick={this._onLoginClickWithCheck}>{sub}</a>,
},
)}</h3>;
}
body = <div>
<h2>{_t("Registration Successful")}</h2>
{ regDoneText }
</div>;
} else {
body = <div>
<h2>{ _t('Create your account') }</h2>
{ errorText }
{ serverDeadSection }
{ this.renderServerComponent() }
{ this.renderRegisterComponent() }
{ goBack }
{ signIn }
</div>;
}
return (
<AuthPage>
<AuthHeader />
<AuthBody>
<h2>{ _t('Create your account') }</h2>
{ errorText }
{ this.renderServerComponent() }
{ this.renderRegisterComponent() }
{ goBack }
{ signIn }
{ body }
</AuthBody>
</AuthPage>
);

View file

@ -0,0 +1,322 @@
/*
Copyright 2019 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 sdk from '../../../index';
import dis from '../../../dispatcher';
import * as Lifecycle from '../../../Lifecycle';
import Modal from '../../../Modal';
import MatrixClientPeg from "../../../MatrixClientPeg";
import {sendLoginRequest} from "../../../Login";
import url from 'url';
const LOGIN_VIEW = {
LOADING: 1,
PASSWORD: 2,
CAS: 3, // SSO, but old
SSO: 4,
UNSUPPORTED: 5,
};
const FLOWS_TO_VIEWS = {
"m.login.password": LOGIN_VIEW.PASSWORD,
"m.login.cas": LOGIN_VIEW.CAS,
"m.login.sso": LOGIN_VIEW.SSO,
};
export default class SoftLogout extends React.Component {
static propTypes = {
// Query parameters from MatrixChat
realQueryParams: PropTypes.object, // {homeserver, identityServer, loginToken}
// Called when the SSO login completes
onTokenLoginCompleted: PropTypes.func,
};
constructor() {
super();
this.state = {
loginView: LOGIN_VIEW.LOADING,
keyBackupNeeded: true, // assume we do while we figure it out (see componentWillMount)
ssoUrl: null,
busy: false,
password: "",
errorText: "",
};
}
componentDidMount(): void {
// We've ended up here when we don't need to - navigate to login
if (!Lifecycle.isSoftLogout()) {
dis.dispatch({action: "on_logged_in"});
return;
}
this._initLogin();
MatrixClientPeg.get().flagAllGroupSessionsForBackup().then(remaining => {
this.setState({keyBackupNeeded: remaining > 0});
});
}
onClearAll = () => {
const ConfirmWipeDeviceDialog = sdk.getComponent('dialogs.ConfirmWipeDeviceDialog');
Modal.createTrackedDialog('Clear Data', 'Soft Logout', ConfirmWipeDeviceDialog, {
onFinished: (wipeData) => {
if (!wipeData) return;
console.log("Clearing data from soft-logged-out device");
Lifecycle.logout();
},
});
};
async _initLogin() {
const queryParams = this.props.realQueryParams;
const hasAllParams = queryParams && queryParams['homeserver'] && queryParams['loginToken'];
if (hasAllParams) {
this.setState({loginView: LOGIN_VIEW.LOADING});
this.trySsoLogin();
return;
}
// Note: we don't use the existing Login class because it is heavily flow-based. We don't
// care about login flows here, unless it is the single flow we support.
const client = MatrixClientPeg.get();
const loginViews = (await client.loginFlows()).flows.map(f => FLOWS_TO_VIEWS[f.type]);
const chosenView = loginViews.filter(f => !!f)[0] || LOGIN_VIEW.UNSUPPORTED;
this.setState({loginView: chosenView});
if (chosenView === LOGIN_VIEW.CAS || chosenView === LOGIN_VIEW.SSO) {
const client = MatrixClientPeg.get();
const appUrl = url.parse(window.location.href, true);
appUrl.hash = ""; // Clear #/soft_logout off the URL
appUrl.query["homeserver"] = client.getHomeserverUrl();
appUrl.query["identityServer"] = client.getIdentityServerUrl();
const ssoUrl = client.getSsoLoginUrl(url.format(appUrl), chosenView === LOGIN_VIEW.CAS ? "cas" : "sso");
this.setState({ssoUrl});
}
}
onPasswordChange = (ev) => {
this.setState({password: ev.target.value});
};
onForgotPassword = () => {
dis.dispatch({action: 'start_password_recovery'});
};
onPasswordLogin = async (ev) => {
ev.preventDefault();
ev.stopPropagation();
this.setState({busy: true});
const hsUrl = MatrixClientPeg.get().getHomeserverUrl();
const isUrl = MatrixClientPeg.get().getIdentityServerUrl();
const loginType = "m.login.password";
const loginParams = {
identifier: {
type: "m.id.user",
user: MatrixClientPeg.get().getUserId(),
},
password: this.state.password,
device_id: MatrixClientPeg.get().getDeviceId(),
};
let credentials = null;
try {
credentials = await sendLoginRequest(hsUrl, isUrl, loginType, loginParams);
} catch (e) {
let errorText = _t("Failed to re-authenticate due to a homeserver problem");
if (e.errcode === "M_FORBIDDEN" && (e.httpStatus === 401 || e.httpStatus === 403)) {
errorText = _t("Incorrect password");
}
this.setState({
busy: false,
errorText: errorText,
});
return;
}
Lifecycle.hydrateSession(credentials).catch((e) => {
console.error(e);
this.setState({busy: false, errorText: _t("Failed to re-authenticate")});
});
};
async trySsoLogin() {
this.setState({busy: true});
const hsUrl = this.props.realQueryParams['homeserver'];
const isUrl = this.props.realQueryParams['identityServer'] || MatrixClientPeg.get().getIdentityServerUrl();
const loginType = "m.login.token";
const loginParams = {
token: this.props.realQueryParams['loginToken'],
device_id: MatrixClientPeg.get().getDeviceId(),
};
let credentials = null;
try {
credentials = await sendLoginRequest(hsUrl, isUrl, loginType, loginParams);
} catch (e) {
console.error(e);
this.setState({busy: false, loginView: LOGIN_VIEW.UNSUPPORTED});
return;
}
Lifecycle.hydrateSession(credentials).then(() => {
if (this.props.onTokenLoginCompleted) this.props.onTokenLoginCompleted();
}).catch((e) => {
console.error(e);
this.setState({busy: false, loginView: LOGIN_VIEW.UNSUPPORTED});
});
}
onSsoLogin = async (ev) => {
ev.preventDefault();
ev.stopPropagation();
this.setState({busy: true});
window.location.href = this.state.ssoUrl;
};
_renderSignInSection() {
if (this.state.loginView === LOGIN_VIEW.LOADING) {
const Spinner = sdk.getComponent("elements.Spinner");
return <Spinner />;
}
let introText = null; // null is translated to something area specific in this function
if (this.state.keyBackupNeeded) {
introText = _t(
"Regain access to your account and recover encryption keys stored on this device. " +
"Without them, you wont be able to read all of your secure messages on any device.");
}
if (this.state.loginView === LOGIN_VIEW.PASSWORD) {
const Field = sdk.getComponent("elements.Field");
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let error = null;
if (this.state.errorText) {
error = <span className='mx_Login_error'>{this.state.errorText}</span>;
}
if (!introText) {
introText = _t("Enter your password to sign in and regain access to your account.");
} // else we already have a message and should use it (key backup warning)
return (
<form onSubmit={this.onPasswordLogin}>
<p>{introText}</p>
{error}
<Field
id="softlogout_password"
type="password"
label={_t("Password")}
onChange={this.onPasswordChange}
value={this.state.password}
disabled={this.state.busy}
/>
<AccessibleButton
onClick={this.onPasswordLogin}
kind="primary"
type="submit"
disabled={this.state.busy}
>
{_t("Sign In")}
</AccessibleButton>
<AccessibleButton onClick={this.onForgotPassword} kind="link">
{_t("Forgotten your password?")}
</AccessibleButton>
</form>
);
}
if (this.state.loginView === LOGIN_VIEW.SSO || this.state.loginView === LOGIN_VIEW.CAS) {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
if (!introText) {
introText = _t("Sign in and regain access to your account.");
} // else we already have a message and should use it (key backup warning)
return (
<div>
<p>{introText}</p>
<AccessibleButton kind='primary' onClick={this.onSsoLogin}>
{_t('Sign in with single sign-on')}
</AccessibleButton>
</div>
);
}
// Default: assume unsupported/error
return (
<p>
{_t(
"You cannot sign in to your account. Please contact your " +
"homeserver admin for more information.",
)}
</p>
);
}
render() {
const AuthPage = sdk.getComponent("auth.AuthPage");
const AuthHeader = sdk.getComponent("auth.AuthHeader");
const AuthBody = sdk.getComponent("auth.AuthBody");
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<AuthPage>
<AuthHeader />
<AuthBody>
<h2>
{_t("You're signed out")}
</h2>
<h3>{_t("Sign in")}</h3>
<div>
{this._renderSignInSection()}
</div>
<h3>{_t("Clear personal data")}</h3>
<p>
{_t(
"Warning: Your personal data (including encryption keys) is still stored " +
"on this device. Clear it if you're finished using this device, or want to sign " +
"in to another account.",
)}
</p>
<div>
<AccessibleButton onClick={this.onClearAll} kind="danger">
{_t("Clear all data")}
</AccessibleButton>
</div>
</AuthBody>
</AuthPage>
);
}
}

View file

@ -15,12 +15,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import { _t } from '../../../languageHandler';
import React from 'react';
import createReactClass from 'create-react-class';
module.exports = React.createClass({
module.exports = createReactClass({
displayName: 'AuthFooter',
render: function() {

View file

@ -15,12 +15,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
const React = require('react');
import React from 'react';
import createReactClass from 'create-react-class';
import sdk from '../../../index';
module.exports = React.createClass({
module.exports = createReactClass({
displayName: 'AuthHeader',
render: function() {

View file

@ -15,12 +15,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
const React = require('react');
import React from 'react';
import createReactClass from 'create-react-class';
import sdk from '../../../index';
module.exports = React.createClass({
module.exports = createReactClass({
displayName: 'AuthPage',
render: function() {

View file

@ -14,9 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
@ -25,7 +24,7 @@ const DIV_ID = 'mx_recaptcha';
/**
* A pure UI component which displays a captcha form.
*/
module.exports = React.createClass({
module.exports = createReactClass({
displayName: 'CaptchaForm',
propTypes: {
@ -90,7 +89,7 @@ module.exports = React.createClass({
+ "authentication");
}
console.log("Rendering to %s", divId);
console.info("Rendering to %s", divId);
this._captchaWidgetId = global.grecaptcha.render(divId, {
sitekey: publicKey,
callback: this.props.onCaptchaResponse,

View file

@ -20,6 +20,7 @@ import PropTypes from 'prop-types';
import sdk from '../../../index';
import { COUNTRIES } from '../../../phonenumber';
import SdkConfig from "../../../SdkConfig";
const COUNTRIES_BY_ISO2 = {};
for (const c of COUNTRIES) {
@ -45,17 +46,25 @@ export default class CountryDropdown extends React.Component {
this._onOptionChange = this._onOptionChange.bind(this);
this._getShortOption = this._getShortOption.bind(this);
let defaultCountry = COUNTRIES[0];
const defaultCountryCode = SdkConfig.get()["defaultCountryCode"];
if (defaultCountryCode) {
const country = COUNTRIES.find(c => c.iso2 === defaultCountryCode.toUpperCase());
if (country) defaultCountry = country;
}
this.state = {
searchQuery: '',
defaultCountry,
};
}
componentWillMount() {
if (!this.props.value) {
// If no value is given, we start with the first
// If no value is given, we start with the default
// country selected, but our parent component
// doesn't know this, therefore we do this.
this.props.onOptionChange(COUNTRIES[0]);
this.props.onOptionChange(this.state.defaultCountry);
}
}
@ -119,7 +128,7 @@ export default class CountryDropdown extends React.Component {
// default value here too, otherwise we need to handle null / undefined
// values between mounting and the initial value propgating
const value = this.props.value || COUNTRIES[0].iso2;
const value = this.props.value || this.state.defaultCountry.iso2;
return <Dropdown className={this.props.className + " mx_CountryDropdown"}
onOptionChange={this._onOptionChange} onSearchChange={this._onSearchChange}

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,9 +16,10 @@ limitations under the License.
*/
import React from 'react';
import createReactClass from 'create-react-class';
import { _t } from '../../../languageHandler';
module.exports = React.createClass({
module.exports = createReactClass({
displayName: 'CustomServerDialog',
render: function() {
@ -33,11 +35,6 @@ module.exports = React.createClass({
"allows you to use this app with an existing Matrix account on a " +
"different homeserver.",
)}</p>
<p>{_t(
"You can also set a custom identity server, but you won't be " +
"able to invite users by email address, or be invited by email " +
"address yourself.",
)}</p>
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.props.onFinished} autoFocus={true}>

View file

@ -1,6 +1,7 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,6 +17,7 @@ limitations under the License.
*/
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import url from 'url';
import classnames from 'classnames';
@ -57,13 +59,12 @@ import SettingsStore from "../../../settings/SettingsStore";
* session to be failed and the process to go back to the start.
* setEmailSid: m.login.email.identity only: a function to be called with the
* email sid after a token is requested.
* makeRegistrationUrl A function that makes a registration URL
*
* Each component may also provide the following functions (beyond the standard React ones):
* focus: set the input focus appropriately in the form.
*/
export const PasswordAuthEntry = React.createClass({
export const PasswordAuthEntry = createReactClass({
displayName: 'PasswordAuthEntry',
statics: {
@ -81,40 +82,38 @@ export const PasswordAuthEntry = React.createClass({
getInitialState: function() {
return {
passwordValid: false,
password: "",
};
},
focus: function() {
if (this.refs.passwordField) {
this.refs.passwordField.focus();
}
},
_onSubmit: function(e) {
e.preventDefault();
if (this.props.busy) return;
this.props.submitAuthDict({
type: PasswordAuthEntry.LOGIN_TYPE,
// TODO: Remove `user` once servers support proper UIA
// See https://github.com/vector-im/riot-web/issues/10312
user: this.props.matrixClient.credentials.userId,
password: this.refs.passwordField.value,
identifier: {
type: "m.id.user",
user: this.props.matrixClient.credentials.userId,
},
password: this.state.password,
});
},
_onPasswordFieldChange: function(ev) {
// enable the submit button iff the password is non-empty
this.setState({
passwordValid: Boolean(this.refs.passwordField.value),
password: ev.target.value,
});
},
render: function() {
let passwordBoxClass = null;
if (this.props.errorText) {
passwordBoxClass = 'error';
}
const passwordBoxClass = classnames({
"error": this.props.errorText,
});
let submitButtonOrSpinner;
if (this.props.busy) {
@ -124,7 +123,8 @@ export const PasswordAuthEntry = React.createClass({
submitButtonOrSpinner = (
<input type="submit"
className="mx_Dialog_primary"
disabled={!this.state.passwordValid}
disabled={!this.state.password}
value={_t("Continue")}
/>
);
}
@ -138,17 +138,21 @@ export const PasswordAuthEntry = React.createClass({
);
}
const Field = sdk.getComponent('elements.Field');
return (
<div>
<p>{ _t("To continue, please enter your password.") }</p>
<form onSubmit={this._onSubmit}>
<label htmlFor="passwordField">{ _t("Password:") }</label>
<input
name="passwordField"
ref="passwordField"
<form onSubmit={this._onSubmit} className="mx_InteractiveAuthEntryComponents_passwordSection">
<Field
id="mx_InteractiveAuthEntryComponents_password"
className={passwordBoxClass}
onChange={this._onPasswordFieldChange}
type="password"
name="passwordField"
label={_t('Password')}
autoFocus={true}
value={this.state.password}
onChange={this._onPasswordFieldChange}
/>
<div className="mx_button_row">
{ submitButtonOrSpinner }
@ -160,7 +164,7 @@ export const PasswordAuthEntry = React.createClass({
},
});
export const RecaptchaAuthEntry = React.createClass({
export const RecaptchaAuthEntry = createReactClass({
displayName: 'RecaptchaAuthEntry',
statics: {
@ -187,14 +191,24 @@ export const RecaptchaAuthEntry = React.createClass({
return <Loader />;
}
let errorText = this.props.errorText;
const CaptchaForm = sdk.getComponent("views.auth.CaptchaForm");
const sitePublicKey = this.props.stageParams.public_key;
let sitePublicKey;
if (!this.props.stageParams || !this.props.stageParams.public_key) {
errorText = _t(
"Missing captcha public key in homeserver configuration. Please report " +
"this to your homeserver administrator.",
);
} else {
sitePublicKey = this.props.stageParams.public_key;
}
let errorSection;
if (this.props.errorText) {
if (errorText) {
errorSection = (
<div className="error" role="alert">
{ this.props.errorText }
{ errorText }
</div>
);
}
@ -210,7 +224,7 @@ export const RecaptchaAuthEntry = React.createClass({
},
});
export const TermsAuthEntry = React.createClass({
export const TermsAuthEntry = createReactClass({
displayName: 'TermsAuthEntry',
statics: {
@ -316,7 +330,7 @@ export const TermsAuthEntry = React.createClass({
checkboxes.push(
<label key={"policy_checkbox_" + policy.id} className="mx_InteractiveAuthEntryComponents_termsPolicy">
<input type="checkbox" onClick={() => this._togglePolicy(policy.id)} checked={checked} />
<input type="checkbox" onChange={() => this._togglePolicy(policy.id)} checked={checked} />
<a href={policy.url} target="_blank" rel="noopener">{ policy.name }</a>
</label>,
);
@ -349,7 +363,7 @@ export const TermsAuthEntry = React.createClass({
},
});
export const EmailIdentityAuthEntry = React.createClass({
export const EmailIdentityAuthEntry = createReactClass({
displayName: 'EmailIdentityAuthEntry',
statics: {
@ -365,7 +379,6 @@ export const EmailIdentityAuthEntry = React.createClass({
stageState: PropTypes.object.isRequired,
fail: PropTypes.func.isRequired,
setEmailSid: PropTypes.func.isRequired,
makeRegistrationUrl: PropTypes.func.isRequired,
},
getInitialState: function() {
@ -374,38 +387,6 @@ export const EmailIdentityAuthEntry = React.createClass({
};
},
componentWillMount: function() {
if (this.props.stageState.emailSid === null) {
this.setState({requestingToken: true});
this._requestEmailToken().catch((e) => {
this.props.fail(e);
}).finally(() => {
this.setState({requestingToken: false});
}).done();
}
},
/*
* Requests a verification token by email.
*/
_requestEmailToken: function() {
const nextLink = this.props.makeRegistrationUrl({
client_secret: this.props.clientSecret,
hs_url: this.props.matrixClient.getHomeserverUrl(),
is_url: this.props.matrixClient.getIdentityServerUrl(),
session_id: this.props.authSessionId,
});
return this.props.matrixClient.requestRegisterEmailToken(
this.props.inputs.emailAddress,
this.props.clientSecret,
1, // TODO: Multiple send attempts?
nextLink,
).then((result) => {
this.props.setEmailSid(result.sid);
});
},
render: function() {
if (this.state.requestingToken) {
const Loader = sdk.getComponent("elements.Spinner");
@ -424,7 +405,7 @@ export const EmailIdentityAuthEntry = React.createClass({
},
});
export const MsisdnAuthEntry = React.createClass({
export const MsisdnAuthEntry = createReactClass({
displayName: 'MsisdnAuthEntry',
statics: {
@ -450,6 +431,7 @@ export const MsisdnAuthEntry = React.createClass({
},
componentWillMount: function() {
this._submitUrl = null;
this._sid = null;
this._msisdn = null;
this._tokenBox = null;
@ -459,7 +441,7 @@ export const MsisdnAuthEntry = React.createClass({
this.props.fail(e);
}).finally(() => {
this.setState({requestingToken: false});
}).done();
});
},
/*
@ -472,6 +454,7 @@ export const MsisdnAuthEntry = React.createClass({
this.props.clientSecret,
1, // TODO: Multiple send attempts?
).then((result) => {
this._submitUrl = result.submit_url;
this._sid = result.sid;
this._msisdn = result.msisdn;
});
@ -483,38 +466,56 @@ export const MsisdnAuthEntry = React.createClass({
});
},
_onFormSubmit: function(e) {
_onFormSubmit: async function(e) {
e.preventDefault();
if (this.state.token == '') return;
this.setState({
errorText: null,
});
this.setState({
errorText: null,
});
this.props.matrixClient.submitMsisdnToken(
this._sid, this.props.clientSecret, this.state.token,
).then((result) => {
if (result.success) {
const idServerParsedUrl = url.parse(
this.props.matrixClient.getIdentityServerUrl(),
try {
const requiresIdServerParam =
await this.props.matrixClient.doesServerRequireIdServerParam();
let result;
if (this._submitUrl) {
result = await this.props.matrixClient.submitMsisdnTokenOtherUrl(
this._submitUrl, this._sid, this.props.clientSecret, this.state.token,
);
} else if (requiresIdServerParam) {
result = await this.props.matrixClient.submitMsisdnToken(
this._sid, this.props.clientSecret, this.state.token,
);
} else {
throw new Error("The registration with MSISDN flow is misconfigured");
}
if (result.success) {
const creds = {
sid: this._sid,
client_secret: this.props.clientSecret,
};
if (requiresIdServerParam) {
const idServerParsedUrl = url.parse(
this.props.matrixClient.getIdentityServerUrl(),
);
creds.id_server = idServerParsedUrl.host;
}
this.props.submitAuthDict({
type: MsisdnAuthEntry.LOGIN_TYPE,
threepid_creds: {
sid: this._sid,
client_secret: this.props.clientSecret,
id_server: idServerParsedUrl.host,
},
// TODO: Remove `threepid_creds` once servers support proper UIA
// See https://github.com/vector-im/riot-web/issues/10312
threepid_creds: creds,
threepidCreds: creds,
});
} else {
this.setState({
errorText: _t("Token incorrect"),
});
}
}).catch((e) => {
} catch (e) {
this.props.fail(e);
console.log("Failed to submit msisdn token");
}).done();
}
},
render: function() {
@ -564,7 +565,7 @@ export const MsisdnAuthEntry = React.createClass({
},
});
export const FallbackAuthEntry = React.createClass({
export const FallbackAuthEntry = createReactClass({
displayName: 'FallbackAuthEntry',
propTypes: {

View file

@ -15,91 +15,82 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import SdkConfig from "../../../SdkConfig";
import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
import * as ServerType from '../../views/auth/ServerTypeSelector';
import ServerConfig from "./ServerConfig";
const MODULAR_URL = 'https://modular.im/?utm_source=riot-web&utm_medium=web&utm_campaign=riot-web-authentication';
// TODO: TravisR - Can this extend ServerConfig for most things?
/*
* Configure the Modular server name.
*
* This is a variant of ServerConfig with only the HS field and different body
* text that is specific to the Modular case.
*/
export default class ModularServerConfig extends React.PureComponent {
static propTypes = {
onServerConfigChange: PropTypes.func,
export default class ModularServerConfig extends ServerConfig {
static propTypes = ServerConfig.propTypes;
// default URLs are defined in config.json (or the hardcoded defaults)
// they are used if the user has not overridden them with a custom URL.
// In other words, if the custom URL is blank, the default is used.
defaultHsUrl: PropTypes.string, // e.g. https://matrix.org
// This component always uses the default IS URL and doesn't allow it
// to be changed. We still receive it as a prop here to simplify
// consumers by still passing the IS URL via onServerConfigChange.
defaultIsUrl: PropTypes.string, // e.g. https://vector.im
// custom URLs are explicitly provided by the user and override the
// default URLs. The user enters them via the component's input fields,
// which is reflected on these properties whenever on..UrlChanged fires.
// They are persisted in localStorage by MatrixClientPeg, and so can
// override the default URLs when the component initially loads.
customHsUrl: PropTypes.string,
delayTimeMs: PropTypes.number, // time to wait before invoking onChanged
}
static defaultProps = {
onServerConfigChange: function() {},
customHsUrl: "",
delayTimeMs: 0,
}
constructor(props) {
super(props);
this.state = {
hsUrl: props.customHsUrl,
};
}
componentWillReceiveProps(newProps) {
if (newProps.customHsUrl === this.state.hsUrl) return;
async validateAndApplyServer(hsUrl, isUrl) {
// Always try and use the defaults first
const defaultConfig: ValidatedServerConfig = SdkConfig.get()["validated_server_config"];
if (defaultConfig.hsUrl === hsUrl && defaultConfig.isUrl === isUrl) {
this.setState({busy: false, errorText: ""});
this.props.onServerConfigChange(defaultConfig);
return defaultConfig;
}
this.setState({
hsUrl: newProps.customHsUrl,
hsUrl,
isUrl,
busy: true,
errorText: "",
});
this.props.onServerConfigChange({
hsUrl: newProps.customHsUrl,
isUrl: this.props.defaultIsUrl,
});
}
onHomeserverBlur = (ev) => {
this._hsTimeoutId = this._waitThenInvoke(this._hsTimeoutId, () => {
this.props.onServerConfigChange({
hsUrl: this.state.hsUrl,
isUrl: this.props.defaultIsUrl,
try {
const result = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl);
this.setState({busy: false, errorText: ""});
this.props.onServerConfigChange(result);
return result;
} catch (e) {
console.error(e);
let message = _t("Unable to validate homeserver/identity server");
if (e.translatedMessage) {
message = e.translatedMessage;
}
this.setState({
busy: false,
errorText: message,
});
});
}
onHomeserverChange = (ev) => {
const hsUrl = ev.target.value;
this.setState({ hsUrl });
}
_waitThenInvoke(existingTimeoutId, fn) {
if (existingTimeoutId) {
clearTimeout(existingTimeoutId);
return null;
}
return setTimeout(fn.bind(this), this.props.delayTimeMs);
}
async validateServer() {
// TODO: Do we want to support .well-known lookups here?
// If for some reason someone enters "matrix.org" for a URL, we could do a lookup to
// find their homeserver without demanding they use "https://matrix.org"
return this.validateAndApplyServer(this.state.hsUrl, ServerType.TYPES.PREMIUM.identityServerUrl);
}
render() {
const Field = sdk.getComponent('elements.Field');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const submitButton = this.props.submitText
? <AccessibleButton
element="button"
type="submit"
className={this.props.submitClass}
onClick={this.onSubmit}
disabled={this.state.busy}>{this.props.submitText}</AccessibleButton>
: null;
return (
<div className="mx_ServerConfig">
@ -113,15 +104,18 @@ export default class ModularServerConfig extends React.PureComponent {
</a>,
},
)}
<div className="mx_ServerConfig_fields">
<Field id="mx_ServerConfig_hsUrl"
label={_t("Server Name")}
placeholder={this.props.defaultHsUrl}
value={this.state.hsUrl}
onBlur={this.onHomeserverBlur}
onChange={this.onHomeserverChange}
/>
</div>
<form onSubmit={this.onSubmit} autoComplete="off" action={null}>
<div className="mx_ServerConfig_fields">
<Field id="mx_ServerConfig_hsUrl"
label={_t("Server Name")}
placeholder={this.props.serverConfig.hsUrl}
value={this.state.hsUrl}
onBlur={this.onHomeserverBlur}
onChange={this.onHomeserverChange}
/>
</div>
{submitButton}
</form>
</div>
);
}

View file

@ -1,6 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2019 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.
@ -21,11 +22,30 @@ import classNames from 'classnames';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
/**
* A pure UI component which displays a username/password form.
*/
class PasswordLogin extends React.Component {
export default class PasswordLogin extends React.Component {
static propTypes = {
onSubmit: PropTypes.func.isRequired, // fn(username, password)
onError: PropTypes.func,
onEditServerDetailsClick: PropTypes.func,
onForgotPasswordClick: PropTypes.func, // fn()
initialUsername: PropTypes.string,
initialPhoneCountry: PropTypes.string,
initialPhoneNumber: PropTypes.string,
initialPassword: PropTypes.string,
onUsernameChanged: PropTypes.func,
onPhoneCountryChanged: PropTypes.func,
onPhoneNumberChanged: PropTypes.func,
onPasswordChanged: PropTypes.func,
loginIncorrect: PropTypes.bool,
disableSubmit: PropTypes.bool,
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
};
static defaultProps = {
onError: function() {},
onEditServerDetailsClick: null,
@ -40,13 +60,12 @@ class PasswordLogin extends React.Component {
initialPhoneNumber: "",
initialPassword: "",
loginIncorrect: false,
// This is optional and only set if we used a server name to determine
// the HS URL via `.well-known` discovery. The server name is used
// instead of the HS URL when talking about where to "sign in to".
hsName: null,
hsUrl: "",
disableSubmit: false,
}
};
static LOGIN_FIELD_EMAIL = "login_field_email";
static LOGIN_FIELD_MXID = "login_field_mxid";
static LOGIN_FIELD_PHONE = "login_field_phone";
constructor(props) {
super(props);
@ -193,10 +212,7 @@ class PasswordLogin extends React.Component {
name="username" // make it a little easier for browser's remember-password
key="username_input"
type="text"
label={SdkConfig.get().disable_custom_urls ?
_t("Username on %(hs)s", {
hs: this.props.hsUrl.replace(/^https?:\/\//, ''),
}) : _t("Username")}
label={_t("Username")}
value={this.state.username}
onChange={this.onUsernameChanged}
onBlur={this.onUsernameBlur}
@ -242,6 +258,7 @@ class PasswordLogin extends React.Component {
render() {
const Field = sdk.getComponent('elements.Field');
const SignInToText = sdk.getComponent('views.auth.SignInToText');
let forgotPasswordJsx;
@ -258,31 +275,6 @@ class PasswordLogin extends React.Component {
</span>;
}
let signInToText = _t('Sign in to your Matrix account');
if (this.props.hsName) {
signInToText = _t('Sign in to your Matrix account on %(serverName)s', {
serverName: this.props.hsName,
});
} else {
try {
const parsedHsUrl = new URL(this.props.hsUrl);
signInToText = _t('Sign in to your Matrix account on %(serverName)s', {
serverName: parsedHsUrl.hostname,
});
} catch (e) {
// ignore
}
}
let editLink = null;
if (this.props.onEditServerDetailsClick) {
editLink = <a className="mx_AuthBody_editServerDetails"
href="#" onClick={this.props.onEditServerDetailsClick}
>
{_t('Change')}
</a>;
}
const pwFieldClass = classNames({
error: this.props.loginIncorrect && !this.isLoginEmpty(), // only error password if error isn't top field
});
@ -295,7 +287,6 @@ class PasswordLogin extends React.Component {
<div className="mx_Login_type_container">
<label className="mx_Login_type_label">{ _t('Sign in with') }</label>
<Field
className="mx_Login_type_dropdown"
id="mx_PasswordLogin_type"
element="select"
value={this.state.loginType}
@ -326,10 +317,8 @@ class PasswordLogin extends React.Component {
return (
<div>
<h3>
{signInToText}
{editLink}
</h3>
<SignInToText serverConfig={this.props.serverConfig}
onEditServerDetailsClick={this.props.onEditServerDetailsClick} />
<form onSubmit={this.onSubmitForm}>
{loginType}
{loginField}
@ -353,27 +342,3 @@ class PasswordLogin extends React.Component {
);
}
}
PasswordLogin.LOGIN_FIELD_EMAIL = "login_field_email";
PasswordLogin.LOGIN_FIELD_MXID = "login_field_mxid";
PasswordLogin.LOGIN_FIELD_PHONE = "login_field_phone";
PasswordLogin.propTypes = {
onSubmit: PropTypes.func.isRequired, // fn(username, password)
onError: PropTypes.func,
onForgotPasswordClick: PropTypes.func, // fn()
initialUsername: PropTypes.string,
initialPhoneCountry: PropTypes.string,
initialPhoneNumber: PropTypes.string,
initialPassword: PropTypes.string,
onUsernameChanged: PropTypes.func,
onPhoneCountryChanged: PropTypes.func,
onPhoneNumberChanged: PropTypes.func,
onPasswordChanged: PropTypes.func,
loginIncorrect: PropTypes.bool,
hsName: PropTypes.string,
hsUrl: PropTypes.string,
disableSubmit: PropTypes.bool,
};
module.exports = PasswordLogin;

View file

@ -1,7 +1,8 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Copyright 2018, 2019 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -17,6 +18,7 @@ limitations under the License.
*/
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import Email from '../../../email';
@ -26,6 +28,7 @@ import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import { SAFE_LOCALPART_REGEX } from '../../../Registration';
import withValidation from '../elements/Validation';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
const FIELD_EMAIL = 'field_email';
const FIELD_PHONE_NUMBER = 'field_phone_number';
@ -38,7 +41,7 @@ const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from of
/**
* A pure UI component which displays a registration form.
*/
module.exports = React.createClass({
module.exports = createReactClass({
displayName: 'RegistrationForm',
propTypes: {
@ -51,16 +54,15 @@ module.exports = React.createClass({
onRegisterClick: PropTypes.func.isRequired, // onRegisterClick(Object) => ?Promise
onEditServerDetailsClick: PropTypes.func,
flows: PropTypes.arrayOf(PropTypes.object).isRequired,
// This is optional and only set if we used a server name to determine
// the HS URL via `.well-known` discovery. The server name is used
// instead of the HS URL when talking about "your account".
hsName: PropTypes.string,
hsUrl: PropTypes.string,
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
canSubmit: PropTypes.bool,
serverRequiresIdServer: PropTypes.bool,
},
getDefaultProps: function() {
return {
onValidationChange: console.error,
canSubmit: true,
};
},
@ -70,33 +72,47 @@ module.exports = React.createClass({
fieldValid: {},
// The ISO2 country code selected in the phone number entry
phoneCountry: this.props.defaultPhoneCountry,
username: "",
email: "",
phoneNumber: "",
password: "",
username: this.props.defaultUsername || "",
email: this.props.defaultEmail || "",
phoneNumber: this.props.defaultPhoneNumber || "",
password: this.props.defaultPassword || "",
passwordConfirm: "",
passwordComplexity: null,
passwordSafe: false,
};
},
onSubmit: async function(ev) {
ev.preventDefault();
if (!this.props.canSubmit) return;
const allFieldsValid = await this.verifyFieldsBeforeSubmit();
if (!allFieldsValid) {
return;
}
const self = this;
if (this.state.email == '') {
if (this.state.email === '') {
const haveIs = Boolean(this.props.serverConfig.isUrl);
let desc;
if (this.props.serverRequiresIdServer && !haveIs) {
desc = _t(
"No identity server is configured so you cannot add an email address in order to " +
"reset your password in the future.",
);
} else {
desc = _t(
"If you don't specify an email address, you won't be able to reset your password. " +
"Are you sure?",
);
}
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('If you don\'t specify an email address...', '', QuestionDialog, {
title: _t("Warning!"),
description:
<div>
{ _t("If you don't specify an email address, you won't be able to reset your password. " +
"Are you sure?") }
</div>,
description: desc,
button: _t("Continue"),
onFinished: function(confirmed) {
if (confirmed) {
@ -150,7 +166,11 @@ module.exports = React.createClass({
if (!field) {
continue;
}
field.validate({ allowEmpty: false });
// We must wait for these validations to finish before queueing
// up the setState below so our setState goes in the queue after
// all the setStates from these validate calls (that's how we
// know they've finished).
await field.validate({ allowEmpty: false });
}
// Validation and state updates are async, so we need to wait for them to complete
@ -270,12 +290,23 @@ module.exports = React.createClass({
}
const { scorePassword } = await import('../../../utils/PasswordScorer');
const complexity = scorePassword(value);
const safe = complexity.score >= PASSWORD_MIN_SCORE;
const allowUnsafe = SdkConfig.get()["dangerously_allow_unsafe_and_insecure_passwords"];
this.setState({
passwordComplexity: complexity,
passwordSafe: safe,
});
return complexity.score >= PASSWORD_MIN_SCORE;
return allowUnsafe || safe;
},
valid: function() {
// Unsafe passwords that are valid are only possible through a
// configuration flag. We'll print some helper text to signal
// to the user that their password is allowed, but unsafe.
if (!this.state.passwordSafe) {
return _t("Password is allowed, but unsafe");
}
return _t("Nice, strong password!");
},
valid: () => _t("Nice, strong password!"),
invalid: function() {
const complexity = this.state.passwordComplexity;
if (!complexity) {
@ -367,7 +398,7 @@ module.exports = React.createClass({
},
validateUsernameRules: withValidation({
description: () => _t("Use letters, numbers, dashes and underscores only"),
description: () => _t("Use lowercase letters, numbers, dashes and underscores only"),
rules: [
{
key: "required",
@ -406,8 +437,32 @@ module.exports = React.createClass({
});
},
_showEmail() {
const haveIs = Boolean(this.props.serverConfig.isUrl);
if (
(this.props.serverRequiresIdServer && !haveIs) ||
!this._authStepIsUsed('m.login.email.identity')
) {
return false;
}
return true;
},
_showPhoneNumber() {
const threePidLogin = !SdkConfig.get().disable_3pid_login;
const haveIs = Boolean(this.props.serverConfig.isUrl);
if (
!threePidLogin ||
(this.props.serverRequiresIdServer && !haveIs) ||
!this._authStepIsUsed('m.login.msisdn')
) {
return false;
}
return true;
},
renderEmail() {
if (!this._authStepIsUsed('m.login.email.identity')) {
if (!this._showEmail()) {
return null;
}
const Field = sdk.getComponent('elements.Field');
@ -419,7 +474,6 @@ module.exports = React.createClass({
ref={field => this[FIELD_EMAIL] = field}
type="text"
label={emailPlaceholder}
defaultValue={this.props.defaultEmail}
value={this.state.email}
onChange={this.onEmailChange}
onValidate={this.onEmailValidate}
@ -433,7 +487,6 @@ module.exports = React.createClass({
ref={field => this[FIELD_PASSWORD] = field}
type="password"
label={_t("Password")}
defaultValue={this.props.defaultPassword}
value={this.state.password}
onChange={this.onPasswordChange}
onValidate={this.onPasswordValidate}
@ -447,7 +500,6 @@ module.exports = React.createClass({
ref={field => this[FIELD_PASSWORD_CONFIRM] = field}
type="password"
label={_t("Confirm")}
defaultValue={this.props.defaultPassword}
value={this.state.passwordConfirm}
onChange={this.onPasswordConfirmChange}
onValidate={this.onPasswordConfirmValidate}
@ -455,8 +507,7 @@ module.exports = React.createClass({
},
renderPhoneNumber() {
const threePidLogin = !SdkConfig.get().disable_3pid_login;
if (!threePidLogin || !this._authStepIsUsed('m.login.msisdn')) {
if (!this._showPhoneNumber()) {
return null;
}
const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown');
@ -475,7 +526,6 @@ module.exports = React.createClass({
ref={field => this[FIELD_PHONE_NUMBER] = field}
type="text"
label={phoneLabel}
defaultValue={this.props.defaultPhoneNumber}
value={this.state.phoneNumber}
prefix={phoneCountry}
onChange={this.onPhoneNumberChange}
@ -491,7 +541,6 @@ module.exports = React.createClass({
type="text"
autoFocus={true}
label={_t("Username")}
defaultValue={this.props.defaultUsername}
value={this.state.username}
onChange={this.onUsernameChange}
onValidate={this.onUsernameValidate}
@ -499,20 +548,22 @@ module.exports = React.createClass({
},
render: function() {
let yourMatrixAccountText = _t('Create your Matrix account');
if (this.props.hsName) {
yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', {
serverName: this.props.hsName,
let yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', {
serverName: this.props.serverConfig.hsName,
});
if (this.props.serverConfig.hsNameIsDifferent) {
const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip");
yourMatrixAccountText = _t('Create your Matrix account on <underlinedServerName />', {}, {
'underlinedServerName': () => {
return <TextWithTooltip
class="mx_Login_underlinedServerName"
tooltip={this.props.serverConfig.hsUrl}
>
{this.props.serverConfig.hsName}
</TextWithTooltip>;
},
});
} else {
try {
const parsedHsUrl = new URL(this.props.hsUrl);
yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', {
serverName: parsedHsUrl.hostname,
});
} catch (e) {
// ignore
}
}
let editLink = null;
@ -525,9 +576,38 @@ module.exports = React.createClass({
}
const registerButton = (
<input className="mx_Login_submit" type="submit" value={_t("Register")} />
<input className="mx_Login_submit" type="submit" value={_t("Register")} disabled={!this.props.canSubmit} />
);
let emailHelperText = null;
if (this._showEmail()) {
if (this._showPhoneNumber()) {
emailHelperText = <div>
{_t(
"Set an email for account recovery. " +
"Use email or phone to optionally be discoverable by existing contacts.",
)}
</div>;
} else {
emailHelperText = <div>
{_t(
"Set an email for account recovery. " +
"Use email to optionally be discoverable by existing contacts.",
)}
</div>;
}
}
const haveIs = Boolean(this.props.serverConfig.isUrl);
let noIsText = null;
if (this.props.serverRequiresIdServer && !haveIs) {
noIsText = <div>
{_t(
"No identity server is configured so you cannot add an email address in order to " +
"reset your password in the future.",
)}
</div>;
}
return (
<div>
<h3>
@ -546,8 +626,8 @@ module.exports = React.createClass({
{this.renderEmail()}
{this.renderPhoneNumber()}
</div>
{_t("Use an email address to recover your account.") + " "}
{_t("Other users can invite you to rooms using your contact details.")}
{ emailHelperText }
{ noIsText }
{ registerButton }
</form>
</div>

View file

@ -1,6 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 New Vector Ltd
Copyright 2019 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.
@ -20,6 +21,11 @@ import PropTypes from 'prop-types';
import Modal from '../../../Modal';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
import SdkConfig from "../../../SdkConfig";
import { createClient } from 'matrix-js-sdk/lib/matrix';
import classNames from 'classnames';
/*
* A pure UI component which displays the HS and IS to use.
@ -27,82 +33,175 @@ import { _t } from '../../../languageHandler';
export default class ServerConfig extends React.PureComponent {
static propTypes = {
onServerConfigChange: PropTypes.func,
onServerConfigChange: PropTypes.func.isRequired,
// default URLs are defined in config.json (or the hardcoded defaults)
// they are used if the user has not overridden them with a custom URL.
// In other words, if the custom URL is blank, the default is used.
defaultHsUrl: PropTypes.string, // e.g. https://matrix.org
defaultIsUrl: PropTypes.string, // e.g. https://vector.im
// custom URLs are explicitly provided by the user and override the
// default URLs. The user enters them via the component's input fields,
// which is reflected on these properties whenever on..UrlChanged fires.
// They are persisted in localStorage by MatrixClientPeg, and so can
// override the default URLs when the component initially loads.
customHsUrl: PropTypes.string,
customIsUrl: PropTypes.string,
// The current configuration that the user is expecting to change.
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
delayTimeMs: PropTypes.number, // time to wait before invoking onChanged
}
// Called after the component calls onServerConfigChange
onAfterSubmit: PropTypes.func,
// Optional text for the submit button. If falsey, no button will be shown.
submitText: PropTypes.string,
// Optional class for the submit button. Only applies if the submit button
// is to be rendered.
submitClass: PropTypes.string,
// Whether the flow this component is embedded in requires an identity
// server when the homeserver says it will need one. Default false.
showIdentityServerIfRequiredByHomeserver: PropTypes.bool,
};
static defaultProps = {
onServerConfigChange: function() {},
customHsUrl: "",
customIsUrl: "",
delayTimeMs: 0,
}
};
constructor(props) {
super(props);
this.state = {
hsUrl: props.customHsUrl,
isUrl: props.customIsUrl,
busy: false,
errorText: "",
hsUrl: props.serverConfig.hsUrl,
isUrl: props.serverConfig.isUrl,
showIdentityServer: false,
};
}
componentWillReceiveProps(newProps) {
if (newProps.customHsUrl === this.state.hsUrl &&
newProps.customIsUrl === this.state.isUrl) return;
if (newProps.serverConfig.hsUrl === this.state.hsUrl &&
newProps.serverConfig.isUrl === this.state.isUrl) return;
this.validateAndApplyServer(newProps.serverConfig.hsUrl, newProps.serverConfig.isUrl);
}
async validateServer() {
// TODO: Do we want to support .well-known lookups here?
// If for some reason someone enters "matrix.org" for a URL, we could do a lookup to
// find their homeserver without demanding they use "https://matrix.org"
const result = this.validateAndApplyServer(this.state.hsUrl, this.state.isUrl);
if (!result) {
return result;
}
// If the UI flow this component is embedded in requires an identity
// server when the homeserver says it will need one, check first and
// reveal this field if not already shown.
// XXX: This a backward compatibility path for homeservers that require
// an identity server to be passed during certain flows.
// See also https://github.com/matrix-org/synapse/pull/5868.
if (
this.props.showIdentityServerIfRequiredByHomeserver &&
!this.state.showIdentityServer &&
await this.isIdentityServerRequiredByHomeserver()
) {
this.setState({
showIdentityServer: true,
});
return null;
}
return result;
}
async validateAndApplyServer(hsUrl, isUrl) {
// Always try and use the defaults first
const defaultConfig: ValidatedServerConfig = SdkConfig.get()["validated_server_config"];
if (defaultConfig.hsUrl === hsUrl && defaultConfig.isUrl === isUrl) {
this.setState({
hsUrl: defaultConfig.hsUrl,
isUrl: defaultConfig.isUrl,
busy: false,
errorText: "",
});
this.props.onServerConfigChange(defaultConfig);
return defaultConfig;
}
this.setState({
hsUrl: newProps.customHsUrl,
isUrl: newProps.customIsUrl,
});
this.props.onServerConfigChange({
hsUrl: newProps.customHsUrl,
isUrl: newProps.customIsUrl,
hsUrl,
isUrl,
busy: true,
errorText: "",
});
try {
const result = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl);
this.setState({busy: false, errorText: ""});
this.props.onServerConfigChange(result);
return result;
} catch (e) {
console.error(e);
const stateForError = AutoDiscoveryUtils.authComponentStateForError(e);
if (!stateForError.isFatalError) {
this.setState({
busy: false,
});
// carry on anyway
const result = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl, true);
this.props.onServerConfigChange(result);
return result;
} else {
let message = _t("Unable to validate homeserver/identity server");
if (e.translatedMessage) {
message = e.translatedMessage;
}
this.setState({
busy: false,
errorText: message,
});
return null;
}
}
}
async isIdentityServerRequiredByHomeserver() {
// XXX: We shouldn't have to create a whole new MatrixClient just to
// check if the homeserver requires an identity server... Should it be
// extracted to a static utils function...?
return createClient({
baseUrl: this.state.hsUrl,
}).doesServerRequireIdServerParam();
}
onHomeserverBlur = (ev) => {
this._hsTimeoutId = this._waitThenInvoke(this._hsTimeoutId, () => {
this.props.onServerConfigChange({
hsUrl: this.state.hsUrl,
isUrl: this.state.isUrl,
});
this.validateServer();
});
}
};
onHomeserverChange = (ev) => {
const hsUrl = ev.target.value;
this.setState({ hsUrl });
}
};
onIdentityServerBlur = (ev) => {
this._isTimeoutId = this._waitThenInvoke(this._isTimeoutId, () => {
this.props.onServerConfigChange({
hsUrl: this.state.hsUrl,
isUrl: this.state.isUrl,
});
this.validateServer();
});
}
};
onIdentityServerChange = (ev) => {
const isUrl = ev.target.value;
this.setState({ isUrl });
}
};
onSubmit = async (ev) => {
ev.preventDefault();
ev.stopPropagation();
const result = await this.validateServer();
if (!result) return; // Do not continue.
if (this.props.onAfterSubmit) {
this.props.onAfterSubmit();
}
};
_waitThenInvoke(existingTimeoutId, fn) {
if (existingTimeoutId) {
@ -114,35 +213,75 @@ export default class ServerConfig extends React.PureComponent {
showHelpPopup = () => {
const CustomServerDialog = sdk.getComponent('auth.CustomServerDialog');
Modal.createTrackedDialog('Custom Server Dialog', '', CustomServerDialog);
};
_renderHomeserverSection() {
const Field = sdk.getComponent('elements.Field');
return <div>
{_t("Enter your custom homeserver URL <a>What does this mean?</a>", {}, {
a: sub => <a className="mx_ServerConfig_help" href="#" onClick={this.showHelpPopup}>
{sub}
</a>,
})}
<Field id="mx_ServerConfig_hsUrl"
label={_t("Homeserver URL")}
placeholder={this.props.serverConfig.hsUrl}
value={this.state.hsUrl}
onBlur={this.onHomeserverBlur}
onChange={this.onHomeserverChange}
disabled={this.state.busy}
/>
</div>;
}
_renderIdentityServerSection() {
const Field = sdk.getComponent('elements.Field');
const classes = classNames({
"mx_ServerConfig_identityServer": true,
"mx_ServerConfig_identityServer_shown": this.state.showIdentityServer,
});
return <div className={classes}>
{_t("Enter your custom identity server URL <a>What does this mean?</a>", {}, {
a: sub => <a className="mx_ServerConfig_help" href="#" onClick={this.showHelpPopup}>
{sub}
</a>,
})}
<Field id="mx_ServerConfig_isUrl"
label={_t("Identity Server URL")}
placeholder={this.props.serverConfig.isUrl}
value={this.state.isUrl || ''}
onBlur={this.onIdentityServerBlur}
onChange={this.onIdentityServerChange}
disabled={this.state.busy}
/>
</div>;
}
render() {
const Field = sdk.getComponent('elements.Field');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const errorText = this.state.errorText
? <span className='mx_ServerConfig_error'>{this.state.errorText}</span>
: null;
const submitButton = this.props.submitText
? <AccessibleButton
element="button"
type="submit"
className={this.props.submitClass}
onClick={this.onSubmit}
disabled={this.state.busy}>{this.props.submitText}</AccessibleButton>
: null;
return (
<div className="mx_ServerConfig">
<h3>{_t("Other servers")}</h3>
{_t("Enter custom server URLs <a>What does this mean?</a>", {}, {
a: sub => <a className="mx_ServerConfig_help" href="#" onClick={this.showHelpPopup}>
{ sub }
</a>,
})}
<div className="mx_ServerConfig_fields">
<Field id="mx_ServerConfig_hsUrl"
label={_t("Homeserver URL")}
placeholder={this.props.defaultHsUrl}
value={this.state.hsUrl}
onBlur={this.onHomeserverBlur}
onChange={this.onHomeserverChange}
/>
<Field id="mx_ServerConfig_isUrl"
label={_t("Identity Server URL")}
placeholder={this.props.defaultIsUrl}
value={this.state.isUrl}
onBlur={this.onIdentityServerBlur}
onChange={this.onIdentityServerChange}
/>
</div>
{errorText}
{this._renderHomeserverSection()}
{this._renderIdentityServerSection()}
<form onSubmit={this.onSubmit} autoComplete="off" action={null}>
{submitButton}
</form>
</div>
);
}

View file

@ -19,6 +19,8 @@ import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import sdk from '../../../index';
import classnames from 'classnames';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import {makeType} from "../../../utils/TypeUtils";
const MODULAR_URL = 'https://modular.im/?utm_source=riot-web&utm_medium=web&utm_campaign=riot-web-authentication';
@ -32,8 +34,12 @@ export const TYPES = {
label: () => _t('Free'),
logo: () => <img src={require('../../../../res/img/matrix-org-bw-logo.svg')} />,
description: () => _t('Join millions for free on the largest public server'),
hsUrl: 'https://matrix.org',
isUrl: 'https://vector.im',
serverConfig: makeType(ValidatedServerConfig, {
hsUrl: "https://matrix-client.matrix.org",
hsName: "matrix.org",
hsNameIsDifferent: false,
isUrl: "https://vector.im",
}),
},
PREMIUM: {
id: PREMIUM,
@ -44,6 +50,7 @@ export const TYPES = {
{sub}
</a>,
}),
identityServerUrl: "https://vector.im",
},
ADVANCED: {
id: ADVANCED,
@ -56,10 +63,11 @@ export const TYPES = {
},
};
export function getTypeFromHsUrl(hsUrl) {
export function getTypeFromServerConfig(config) {
const {hsUrl} = config;
if (!hsUrl) {
return null;
} else if (hsUrl === TYPES.FREE.hsUrl) {
} else if (hsUrl === TYPES.FREE.serverConfig.hsUrl) {
return FREE;
} else if (new URL(hsUrl).hostname.endsWith('.modular.im')) {
// This is an unlikely case to reach, as Modular defaults to hiding the
@ -76,7 +84,7 @@ export default class ServerTypeSelector extends React.PureComponent {
selected: PropTypes.string,
// Handler called when the selected type changes.
onChange: PropTypes.func.isRequired,
}
};
constructor(props) {
super(props);
@ -106,7 +114,7 @@ export default class ServerTypeSelector extends React.PureComponent {
e.stopPropagation();
const type = e.currentTarget.dataset.id;
this.updateSelectedType(type);
}
};
render() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');

View file

@ -0,0 +1,62 @@
/*
Copyright 2019 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 {_t} from "../../../languageHandler";
import sdk from "../../../index";
import PropTypes from "prop-types";
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
export default class SignInToText extends React.PureComponent {
static propTypes = {
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
onEditServerDetailsClick: PropTypes.func,
};
render() {
let signInToText = _t('Sign in to your Matrix account on %(serverName)s', {
serverName: this.props.serverConfig.hsName,
});
if (this.props.serverConfig.hsNameIsDifferent) {
const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip");
signInToText = _t('Sign in to your Matrix account on <underlinedServerName />', {}, {
'underlinedServerName': () => {
return <TextWithTooltip
class="mx_Login_underlinedServerName"
tooltip={this.props.serverConfig.hsUrl}
>
{this.props.serverConfig.hsName}
</TextWithTooltip>;
},
});
}
let editLink = null;
if (this.props.onEditServerDetailsClick) {
editLink = <a className="mx_AuthBody_editServerDetails"
href="#" onClick={this.props.onEditServerDetailsClick}
>
{_t('Change')}
</a>;
}
return <h3>
{signInToText}
{editLink}
</h3>;
}
}

View file

@ -1,6 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -17,12 +18,13 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import { MatrixClient } from 'matrix-js-sdk';
import AvatarLogic from '../../../Avatar';
import sdk from '../../../index';
import SettingsStore from "../../../settings/SettingsStore";
import AccessibleButton from '../elements/AccessibleButton';
module.exports = React.createClass({
module.exports = createReactClass({
displayName: 'BaseAvatar',
propTypes: {
@ -55,7 +57,7 @@ module.exports = React.createClass({
return this._getState(this.props);
},
componentWillMount() {
componentDidMount() {
this.unmounted = false;
this.context.matrixClient.on('sync', this.onClientSync);
},
@ -104,9 +106,13 @@ module.exports = React.createClass({
// work out the full set of urls to try to load. This is formed like so:
// imageUrls: [ props.url, props.urls, default image ]
const urls = props.urls || [];
if (props.url) {
urls.unshift(props.url); // put in urls[0]
let urls = [];
if (!SettingsStore.getValue("lowBandwidth")) {
urls = props.urls || [];
if (props.url) {
urls.unshift(props.url); // put in urls[0]
}
}
let defaultImageUrl = null;
@ -116,6 +122,10 @@ module.exports = React.createClass({
);
urls.push(defaultImageUrl); // lowest priority
}
// deduplicate URLs
urls = Array.from(new Set(urls));
return {
imageUrls: urls,
defaultImageUrl: defaultImageUrl,
@ -133,40 +143,7 @@ module.exports = React.createClass({
}
},
/**
* returns the first (non-sigil) character of 'name',
* converted to uppercase
*/
_getInitialLetter: function(name) {
if (name.length < 1) {
return undefined;
}
let idx = 0;
const initial = name[0];
if ((initial === '@' || initial === '#' || initial === '+') && name[1]) {
idx++;
}
// string.codePointAt(0) would do this, but that isn't supported by
// some browsers (notably PhantomJS).
let chars = 1;
const first = name.charCodeAt(idx);
// check if its the start of a surrogate pair
if (first >= 0xD800 && first <= 0xDBFF && name[idx+1]) {
const second = name.charCodeAt(idx+1);
if (second >= 0xDC00 && second <= 0xDFFF) {
chars++;
}
}
const firstChar = name.substring(idx, idx+chars);
return firstChar.toUpperCase();
},
render: function() {
const EmojiText = sdk.getComponent('elements.EmojiText');
const imageUrl = this.state.imageUrls[this.state.urlsIndex];
const {
@ -176,20 +153,20 @@ module.exports = React.createClass({
} = this.props;
if (imageUrl === this.state.defaultImageUrl) {
const initialLetter = this._getInitialLetter(name);
const initialLetter = AvatarLogic.getInitialLetter(name);
const textNode = (
<EmojiText className="mx_BaseAvatar_initial" aria-hidden="true"
<span className="mx_BaseAvatar_initial" aria-hidden="true"
style={{ fontSize: (width * 0.65) + "px",
width: width + "px",
lineHeight: height + "px" }}
>
{ initialLetter }
</EmojiText>
</span>
);
const imgNode = (
<img className="mx_BaseAvatar_image" src={imageUrl}
alt="" title={title} onError={this.onError}
width={width} height={height} />
width={width} height={height} aria-hidden="true" />
);
if (onClick != null) {
return (

View file

@ -16,10 +16,11 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
export default React.createClass({
export default createReactClass({
displayName: 'GroupAvatar',
propTypes: {

View file

@ -14,15 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
const React = require('react');
import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
const Avatar = require('../../../Avatar');
const sdk = require("../../../index");
const dispatcher = require("../../../dispatcher");
module.exports = React.createClass({
module.exports = createReactClass({
displayName: 'MemberAvatar',
propTypes: {

View file

@ -15,13 +15,14 @@ limitations under the License.
*/
import React from "react";
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import {ContentRepo} from "matrix-js-sdk";
import MatrixClientPeg from "../../../MatrixClientPeg";
import Modal from '../../../Modal';
import sdk from "../../../index";
import DMRoomMap from '../../../utils/DMRoomMap';
import Avatar from '../../../Avatar';
module.exports = React.createClass({
module.exports = createReactClass({
displayName: 'RoomAvatar',
// Room may be left unset here, but if it is,
@ -51,7 +52,7 @@ module.exports = React.createClass({
};
},
componentWillMount: function() {
componentDidMount: function() {
MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents);
},
@ -89,7 +90,6 @@ module.exports = React.createClass({
props.resizeMethod,
), // highest priority
this.getRoomAvatarUrl(props),
this.getOneToOneAvatar(props), // lowest priority
].filter(function(url) {
return (url != null && url != "");
});
@ -98,41 +98,14 @@ module.exports = React.createClass({
getRoomAvatarUrl: function(props) {
if (!props.room) return null;
return props.room.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
return Avatar.avatarUrlForRoom(
props.room,
Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio),
props.resizeMethod,
false,
);
},
getOneToOneAvatar: function(props) {
const room = props.room;
if (!room) {
return null;
}
let otherMember = null;
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
if (otherUserId) {
otherMember = room.getMember(otherUserId);
} else {
// if the room is not marked as a 1:1, but only has max 2 members
// then still try to show any avatar (pref. other member)
otherMember = room.getAvatarFallbackMember();
}
if (otherMember) {
return otherMember.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio),
props.resizeMethod,
false,
);
}
return null;
},
onRoomAvatarClick: function() {
const avatarUrl = this.props.room.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),

View file

@ -14,8 +14,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';
@ -26,7 +24,7 @@ import PropTypes from 'prop-types';
export default class GenericElementContextMenu extends React.Component {
static PropTypes = {
static propTypes = {
element: PropTypes.element.isRequired,
// Function to be called when the parent window is resized
// This can be used to reposition or close the menu on resize and

View file

@ -14,13 +14,11 @@ 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';
export default class GenericTextContextMenu extends React.Component {
static PropTypes = {
static propTypes = {
message: PropTypes.string.isRequired,
};

View file

@ -1,5 +1,6 @@
/*
Copyright 2018 Vector Creations Ltd
Copyright 2019 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.
@ -77,11 +78,12 @@ export default class GroupInviteTileContextMenu extends React.Component {
}
render() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return <div>
<div className="mx_RoomTileContextMenu_leave" onClick={this._onClickReject} >
<AccessibleButton className="mx_RoomTileContextMenu_leave" onClick={this._onClickReject} >
<img className="mx_RoomTileContextMenu_tag_icon" src={require("../../../../res/img/icon_context_delete.svg")} width="15" height="15" />
{ _t('Reject') }
</div>
</AccessibleButton>
</div>;
}
}

View file

@ -1,6 +1,8 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -17,6 +19,7 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import {EventStatus} from 'matrix-js-sdk';
import MatrixClientPeg from '../../../MatrixClientPeg';
@ -29,7 +32,11 @@ import SettingsStore from '../../../settings/SettingsStore';
import { isUrlPermitted } from '../../../HtmlUtils';
import { isContentActionable } from '../../../utils/EventUtils';
module.exports = React.createClass({
function canCancel(eventStatus) {
return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT;
}
module.exports = createReactClass({
displayName: 'MessageContextMenu',
propTypes: {
@ -90,11 +97,36 @@ module.exports = React.createClass({
this.closeMenu();
},
onResendEditClick: function() {
Resend.resend(this.props.mxEvent.replacingEvent());
this.closeMenu();
},
onResendRedactionClick: function() {
Resend.resend(this.props.mxEvent.localRedactionEvent());
this.closeMenu();
},
onResendReactionsClick: function() {
for (const reaction of this._getUnsentReactions()) {
Resend.resend(reaction);
}
this.closeMenu();
},
e2eInfoClicked: function() {
this.props.e2eInfoCallback();
this.closeMenu();
},
onReportEventClick: function() {
const ReportEventDialog = sdk.getComponent("dialogs.ReportEventDialog");
Modal.createTrackedDialog('Report Event', '', ReportEventDialog, {
mxEvent: this.props.mxEvent,
}, 'mx_Dialog_reportEvent');
this.closeMenu();
},
onViewSourceClick: function() {
const ViewSource = sdk.getComponent('structures.ViewSource');
Modal.createTrackedDialog('View Event Source', '', ViewSource, {
@ -119,26 +151,54 @@ module.exports = React.createClass({
onRedactClick: function() {
const ConfirmRedactDialog = sdk.getComponent("dialogs.ConfirmRedactDialog");
Modal.createTrackedDialog('Confirm Redact Dialog', '', ConfirmRedactDialog, {
onFinished: (proceed) => {
onFinished: async (proceed) => {
if (!proceed) return;
const cli = MatrixClientPeg.get();
cli.redactEvent(this.props.mxEvent.getRoomId(), this.props.mxEvent.getId()).catch(function(e) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
// display error message stating you couldn't delete this.
try {
await cli.redactEvent(
this.props.mxEvent.getRoomId(),
this.props.mxEvent.getId(),
);
} catch (e) {
const code = e.errcode || e.statusCode;
Modal.createTrackedDialog('You cannot delete this message', '', ErrorDialog, {
title: _t('Error'),
description: _t('You cannot delete this message. (%(code)s)', {code}),
});
}).done();
// only show the dialog if failing for something other than a network error
// (e.g. no errcode or statusCode) as in that case the redactions end up in the
// detached queue and we show the room status bar to allow retry
if (typeof code !== "undefined") {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
// display error message stating you couldn't delete this.
Modal.createTrackedDialog('You cannot delete this message', '', ErrorDialog, {
title: _t('Error'),
description: _t('You cannot delete this message. (%(code)s)', {code}),
});
}
}
},
}, 'mx_Dialog_confirmredact');
this.closeMenu();
},
onCancelSendClick: function() {
Resend.removeFromQueue(this.props.mxEvent);
const mxEvent = this.props.mxEvent;
const editEvent = mxEvent.replacingEvent();
const redactEvent = mxEvent.localRedactionEvent();
const pendingReactions = this._getPendingReactions();
if (editEvent && canCancel(editEvent.status)) {
Resend.removeFromQueue(editEvent);
}
if (redactEvent && canCancel(redactEvent.status)) {
Resend.removeFromQueue(redactEvent);
}
if (pendingReactions.length) {
for (const reaction of pendingReactions) {
Resend.removeFromQueue(reaction);
}
}
if (canCancel(mxEvent.status)) {
Resend.removeFromQueue(this.props.mxEvent);
}
this.closeMenu();
},
@ -207,10 +267,46 @@ module.exports = React.createClass({
this.closeMenu();
},
_getReactions(filter) {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.mxEvent.getRoomId());
const eventId = this.props.mxEvent.getId();
return room.getPendingEvents().filter(e => {
const relation = e.getRelation();
return relation &&
relation.rel_type === "m.annotation" &&
relation.event_id === eventId &&
filter(e);
});
},
_getPendingReactions() {
return this._getReactions(e => canCancel(e.status));
},
_getUnsentReactions() {
return this._getReactions(e => e.status === EventStatus.NOT_SENT);
},
render: function() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const cli = MatrixClientPeg.get();
const me = cli.getUserId();
const mxEvent = this.props.mxEvent;
const eventStatus = mxEvent.status;
const editStatus = mxEvent.replacingEvent() && mxEvent.replacingEvent().status;
const redactStatus = mxEvent.localRedactionEvent() && mxEvent.localRedactionEvent().status;
const unsentReactionsCount = this._getUnsentReactions().length;
const pendingReactionsCount = this._getPendingReactions().length;
const allowCancel = canCancel(mxEvent.status) ||
canCancel(editStatus) ||
canCancel(redactStatus) ||
pendingReactionsCount !== 0;
let resendButton;
let resendEditButton;
let resendReactionsButton;
let resendRedactionButton;
let redactButton;
let cancelButton;
let forwardButton;
@ -223,67 +319,92 @@ module.exports = React.createClass({
// status is SENT before remote-echo, null after
const isSent = !eventStatus || eventStatus === EventStatus.SENT;
if (!mxEvent.isRedacted()) {
if (eventStatus === EventStatus.NOT_SENT) {
resendButton = (
<AccessibleButton className="mx_MessageContextMenu_field" onClick={this.onResendClick}>
{ _t('Resend') }
</AccessibleButton>
);
}
if (eventStatus === EventStatus.NOT_SENT) {
resendButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onResendClick}>
{ _t('Resend') }
</div>
if (editStatus === EventStatus.NOT_SENT) {
resendEditButton = (
<AccessibleButton className="mx_MessageContextMenu_field" onClick={this.onResendEditClick}>
{ _t('Resend edit') }
</AccessibleButton>
);
}
if (unsentReactionsCount !== 0) {
resendReactionsButton = (
<AccessibleButton className="mx_MessageContextMenu_field" onClick={this.onResendReactionsClick}>
{ _t('Resend %(unsentCount)s reaction(s)', {unsentCount: unsentReactionsCount}) }
</AccessibleButton>
);
}
}
if (redactStatus === EventStatus.NOT_SENT) {
resendRedactionButton = (
<AccessibleButton className="mx_MessageContextMenu_field" onClick={this.onResendRedactionClick}>
{ _t('Resend removal') }
</AccessibleButton>
);
}
if (isSent && this.state.canRedact) {
redactButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onRedactClick}>
<AccessibleButton className="mx_MessageContextMenu_field" onClick={this.onRedactClick}>
{ _t('Remove') }
</div>
</AccessibleButton>
);
}
if (eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT) {
if (allowCancel) {
cancelButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onCancelSendClick}>
<AccessibleButton className="mx_MessageContextMenu_field" onClick={this.onCancelSendClick}>
{ _t('Cancel Sending') }
</div>
</AccessibleButton>
);
}
if (isContentActionable(mxEvent)) {
forwardButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onForwardClick}>
<AccessibleButton className="mx_MessageContextMenu_field" onClick={this.onForwardClick}>
{ _t('Forward Message') }
</div>
</AccessibleButton>
);
if (this.state.canPin) {
pinButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onPinClick}>
<AccessibleButton className="mx_MessageContextMenu_field" onClick={this.onPinClick}>
{ this._isPinned() ? _t('Unpin Message') : _t('Pin Message') }
</div>
</AccessibleButton>
);
}
}
const viewSourceButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onViewSourceClick}>
<AccessibleButton className="mx_MessageContextMenu_field" onClick={this.onViewSourceClick}>
{ _t('View Source') }
</div>
</AccessibleButton>
);
if (mxEvent.getType() !== mxEvent.getWireType()) {
viewClearSourceButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onViewClearSourceClick}>
<AccessibleButton className="mx_MessageContextMenu_field" onClick={this.onViewClearSourceClick}>
{ _t('View Decrypted Source') }
</div>
</AccessibleButton>
);
}
if (this.props.eventTileOps) {
if (this.props.eventTileOps.isWidgetHidden()) {
unhidePreviewButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onUnhidePreviewClick}>
<AccessibleButton className="mx_MessageContextMenu_field" onClick={this.onUnhidePreviewClick}>
{ _t('Unhide Preview') }
</div>
</AccessibleButton>
);
}
}
@ -294,20 +415,19 @@ module.exports = React.createClass({
}
// XXX: if we use room ID, we should also include a server where the event can be found (other than in the domain of the event ID)
const permalinkButton = (
<div className="mx_MessageContextMenu_field">
<a href={permalink}
target="_blank" rel="noopener" onClick={this.onPermalinkClick}>
<AccessibleButton className="mx_MessageContextMenu_field">
<a href={permalink} target="_blank" rel="noopener" onClick={this.onPermalinkClick} tabIndex={-1}>
{ mxEvent.isRedacted() || mxEvent.getType() !== 'm.room.message'
? _t('Share Permalink') : _t('Share Message') }
</a>
</div>
</AccessibleButton>
);
if (this.props.eventTileOps && this.props.eventTileOps.getInnerText) {
quoteButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onQuoteClick}>
<AccessibleButton className="mx_MessageContextMenu_field" onClick={this.onQuoteClick}>
{ _t('Quote') }
</div>
</AccessibleButton>
);
}
@ -317,31 +437,52 @@ module.exports = React.createClass({
isUrlPermitted(mxEvent.event.content.external_url)
) {
externalURLButton = (
<div className="mx_MessageContextMenu_field">
<a href={mxEvent.event.content.external_url}
rel="noopener" target="_blank" onClick={this.closeMenu}>{ _t('Source URL') }</a>
</div>
<AccessibleButton className="mx_MessageContextMenu_field">
<a
href={mxEvent.event.content.external_url}
target="_blank"
rel="noopener"
onClick={this.closeMenu}
tabIndex={-1}
>
{ _t('Source URL') }
</a>
</AccessibleButton>
);
}
if (this.props.collapseReplyThread) {
collapseReplyThread = (
<div className="mx_MessageContextMenu_field" onClick={this.onCollapseReplyThreadClick}>
<AccessibleButton className="mx_MessageContextMenu_field" onClick={this.onCollapseReplyThreadClick}>
{ _t('Collapse Reply Thread') }
</div>
</AccessibleButton>
);
}
let e2eInfo;
if (this.props.e2eInfoCallback) {
e2eInfo = <div className="mx_MessageContextMenu_field" onClick={this.e2eInfoClicked}>
e2eInfo = (
<AccessibleButton className="mx_MessageContextMenu_field" onClick={this.e2eInfoClicked}>
{ _t('End-to-end encryption information') }
</div>;
</AccessibleButton>
);
}
let reportEventButton;
if (mxEvent.getSender() !== me) {
reportEventButton = (
<AccessibleButton className="mx_MessageContextMenu_field" onClick={this.onReportEventClick}>
{ _t('Report Content') }
</AccessibleButton>
);
}
return (
<div className="mx_MessageContextMenu">
{ resendButton }
{ resendEditButton }
{ resendReactionsButton }
{ resendRedactionButton }
{ redactButton }
{ cancelButton }
{ forwardButton }
@ -354,6 +495,7 @@ module.exports = React.createClass({
{ externalURLButton }
{ collapseReplyThread }
{ e2eInfo }
{ reportEventButton }
</div>
);
},

View file

@ -1,6 +1,8 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,12 +17,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import Promise from 'bluebird';
import React from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import classNames from 'classnames';
import sdk from '../../../index';
import { _t, _td } from '../../../languageHandler';
import MatrixClientPeg from '../../../MatrixClientPeg';
@ -30,8 +30,10 @@ import * as Rooms from '../../../Rooms';
import * as RoomNotifs from '../../../RoomNotifs';
import Modal from '../../../Modal';
import RoomListActions from '../../../actions/RoomListActions';
import RoomViewStore from '../../../stores/RoomViewStore';
import {sleep} from "../../../utils/promise";
module.exports = React.createClass({
module.exports = createReactClass({
displayName: 'RoomTileContextMenu',
propTypes: {
@ -60,7 +62,7 @@ module.exports = React.createClass({
_toggleTag: function(tagNameOn, tagNameOff) {
if (!MatrixClientPeg.get().isGuest()) {
Promise.delay(500).then(() => {
sleep(500).then(() => {
dis.dispatch(RoomListActions.tagRoom(
MatrixClientPeg.get(),
this.props.room,
@ -117,7 +119,7 @@ module.exports = React.createClass({
Rooms.guessAndSetDMRoom(
this.props.room, newIsDirectMessage,
).delay(500).finally(() => {
).then(sleep(500)).finally(() => {
// Close the context menu
if (this.props.onFinished) {
this.props.onFinished();
@ -158,8 +160,12 @@ module.exports = React.createClass({
_onClickForget: function() {
// FIXME: duplicated with RoomSettings (and dead code in RoomView)
MatrixClientPeg.get().forget(this.props.room.roomId).done(function() {
dis.dispatch({ action: 'view_next_room' });
MatrixClientPeg.get().forget(this.props.room.roomId).then(() => {
// Switch to another room view if we're currently viewing the
// historical room
if (RoomViewStore.getRoomId() === this.props.room.roomId) {
dis.dispatch({ action: 'view_next_room' });
}
}, function(err) {
const errCode = err.errcode || _td("unknown error code");
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
@ -184,10 +190,10 @@ module.exports = React.createClass({
this.setState({
roomNotifState: newState,
});
RoomNotifs.setRoomNotifsState(roomId, newState).done(() => {
RoomNotifs.setRoomNotifsState(roomId, newState).then(() => {
// delay slightly so that the user can see their state change
// before closing the menu
return Promise.delay(500).then(() => {
return sleep(500).then(() => {
if (this._unmounted) return;
// Close the context menu
if (this.props.onFinished) {
@ -222,6 +228,8 @@ module.exports = React.createClass({
},
_renderNotifMenu: function() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const alertMeClasses = classNames({
'mx_RoomTileContextMenu_notif_field': true,
'mx_RoomTileContextMenu_notif_fieldSet': this.state.roomNotifState == RoomNotifs.ALL_MESSAGES_LOUD,
@ -244,29 +252,29 @@ module.exports = React.createClass({
return (
<div className="mx_RoomTileContextMenu">
<div className="mx_RoomTileContextMenu_notif_picker" >
<div className="mx_RoomTileContextMenu_notif_picker">
<img src={require("../../../../res/img/notif-slider.svg")} width="20" height="107" />
</div>
<div className={alertMeClasses} onClick={this._onClickAlertMe} >
<AccessibleButton className={alertMeClasses} onClick={this._onClickAlertMe}>
<img className="mx_RoomTileContextMenu_notif_activeIcon" src={require("../../../../res/img/notif-active.svg")} width="12" height="12" />
<img className="mx_RoomTileContextMenu_notif_icon mx_filterFlipColor" src={require("../../../../res/img/icon-context-mute-off-copy.svg")} width="16" height="12" />
{ _t('All messages (noisy)') }
</div>
<div className={allNotifsClasses} onClick={this._onClickAllNotifs} >
</AccessibleButton>
<AccessibleButton className={allNotifsClasses} onClick={this._onClickAllNotifs}>
<img className="mx_RoomTileContextMenu_notif_activeIcon" src={require("../../../../res/img/notif-active.svg")} width="12" height="12" />
<img className="mx_RoomTileContextMenu_notif_icon mx_filterFlipColor" src={require("../../../../res/img/icon-context-mute-off.svg")} width="16" height="12" />
{ _t('All messages') }
</div>
<div className={mentionsClasses} onClick={this._onClickMentions} >
</AccessibleButton>
<AccessibleButton className={mentionsClasses} onClick={this._onClickMentions}>
<img className="mx_RoomTileContextMenu_notif_activeIcon" src={require("../../../../res/img/notif-active.svg")} width="12" height="12" />
<img className="mx_RoomTileContextMenu_notif_icon mx_filterFlipColor" src={require("../../../../res/img/icon-context-mute-mentions.svg")} width="16" height="12" />
{ _t('Mentions only') }
</div>
<div className={muteNotifsClasses} onClick={this._onClickMute} >
</AccessibleButton>
<AccessibleButton className={muteNotifsClasses} onClick={this._onClickMute}>
<img className="mx_RoomTileContextMenu_notif_activeIcon" src={require("../../../../res/img/notif-active.svg")} width="12" height="12" />
<img className="mx_RoomTileContextMenu_notif_icon mx_filterFlipColor" src={require("../../../../res/img/icon-context-mute.svg")} width="16" height="12" />
{ _t('Mute') }
</div>
</AccessibleButton>
</div>
);
},
@ -282,12 +290,13 @@ module.exports = React.createClass({
},
_renderSettingsMenu: function() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<div>
<div className="mx_RoomTileContextMenu_tag_field" onClick={this._onClickSettings} >
<AccessibleButton className="mx_RoomTileContextMenu_tag_field" onClick={this._onClickSettings} >
<img className="mx_RoomTileContextMenu_tag_icon" src={require("../../../../res/img/icons-settings-room.svg")} width="15" height="15" />
{ _t('Settings') }
</div>
</AccessibleButton>
</div>
);
},
@ -297,6 +306,8 @@ module.exports = React.createClass({
return null;
}
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let leaveClickHandler = null;
let leaveText = null;
@ -318,15 +329,17 @@ module.exports = React.createClass({
return (
<div>
<div className="mx_RoomTileContextMenu_leave" onClick={leaveClickHandler} >
<AccessibleButton className="mx_RoomTileContextMenu_leave" onClick={leaveClickHandler} >
<img className="mx_RoomTileContextMenu_tag_icon" src={require("../../../../res/img/icon_context_delete.svg")} width="15" height="15" />
{ leaveText }
</div>
</AccessibleButton>
</div>
);
},
_renderRoomTagMenu: function() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const favouriteClasses = classNames({
'mx_RoomTileContextMenu_tag_field': true,
'mx_RoomTileContextMenu_tag_fieldSet': this.state.isFavourite,
@ -347,21 +360,21 @@ module.exports = React.createClass({
return (
<div>
<div className={favouriteClasses} onClick={this._onClickFavourite} >
<AccessibleButton className={favouriteClasses} onClick={this._onClickFavourite} >
<img className="mx_RoomTileContextMenu_tag_icon" src={require("../../../../res/img/icon_context_fave.svg")} width="15" height="15" />
<img className="mx_RoomTileContextMenu_tag_icon_set" src={require("../../../../res/img/icon_context_fave_on.svg")} width="15" height="15" />
{ _t('Favourite') }
</div>
<div className={lowPriorityClasses} onClick={this._onClickLowPriority} >
</AccessibleButton>
<AccessibleButton className={lowPriorityClasses} onClick={this._onClickLowPriority} >
<img className="mx_RoomTileContextMenu_tag_icon" src={require("../../../../res/img/icon_context_low.svg")} width="15" height="15" />
<img className="mx_RoomTileContextMenu_tag_icon_set" src={require("../../../../res/img/icon_context_low_on.svg")} width="15" height="15" />
{ _t('Low Priority') }
</div>
<div className={dmClasses} onClick={this._onClickDM} >
</AccessibleButton>
<AccessibleButton className={dmClasses} onClick={this._onClickDM} >
<img className="mx_RoomTileContextMenu_tag_icon" src={require("../../../../res/img/icon_context_person.svg")} width="15" height="15" />
<img className="mx_RoomTileContextMenu_tag_icon_set" src={require("../../../../res/img/icon_context_person_on.svg")} width="15" height="15" />
{ _t('Direct Chat') }
</div>
</AccessibleButton>
</div>
);
},
@ -369,25 +382,27 @@ module.exports = React.createClass({
render: function() {
const myMembership = this.props.room.getMyMembership();
// Can't set notif level or tags on non-join rooms
if (myMembership !== 'join') {
return <div>
{ this._renderLeaveMenu(myMembership) }
<hr className="mx_RoomTileContextMenu_separator" />
{ this._renderSettingsMenu() }
</div>;
switch (myMembership) {
case 'join':
return <div>
{ this._renderNotifMenu() }
<hr className="mx_RoomTileContextMenu_separator" />
{ this._renderLeaveMenu(myMembership) }
<hr className="mx_RoomTileContextMenu_separator" />
{ this._renderRoomTagMenu() }
<hr className="mx_RoomTileContextMenu_separator" />
{ this._renderSettingsMenu() }
</div>;
case 'invite':
return <div>
{ this._renderLeaveMenu(myMembership) }
</div>;
default:
return <div>
{ this._renderLeaveMenu(myMembership) }
<hr className="mx_RoomTileContextMenu_separator" />
{ this._renderSettingsMenu() }
</div>;
}
return (
<div>
{ this._renderNotifMenu() }
<hr className="mx_RoomTileContextMenu_separator" />
{ this._renderLeaveMenu(myMembership) }
<hr className="mx_RoomTileContextMenu_separator" />
{ this._renderRoomTagMenu() }
<hr className="mx_RoomTileContextMenu_separator" />
{ this._renderSettingsMenu() }
</div>
);
},
});

View file

@ -1,5 +1,6 @@
/*
Copyright 2018, 2019 New Vector Ltd
Copyright 2019 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.
@ -23,12 +24,17 @@ import Modal from "../../../Modal";
import SdkConfig from '../../../SdkConfig';
import { getHostingLink } from '../../../utils/HostingLink';
import MatrixClientPeg from '../../../MatrixClientPeg';
import sdk from "../../../index";
export class TopLeftMenu extends React.Component {
static propTypes = {
displayName: PropTypes.string.isRequired,
userId: PropTypes.string.isRequired,
onFinished: PropTypes.func,
// Optional function to collect a reference to the container
// of this component directly.
containerRef: PropTypes.func,
};
constructor() {
@ -52,6 +58,8 @@ export class TopLeftMenu extends React.Component {
}
render() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const isGuest = MatrixClientPeg.get().isGuest();
const hostingSignupLink = getHostingLink('user-context-menu');
@ -61,44 +69,56 @@ export class TopLeftMenu extends React.Component {
{_t(
"<a>Upgrade</a> to your own domain", {},
{
a: sub => <a href={hostingSignupLink} target="_blank" rel="noopener">{sub}</a>,
a: sub => <a href={hostingSignupLink} target="_blank" rel="noopener" tabIndex="0">{sub}</a>,
},
)}
<a href={hostingSignupLink} target="_blank" rel="noopener">
<a href={hostingSignupLink} target="_blank" rel="noopener" aria-hidden={true}>
<img src={require("../../../../res/img/external-link.svg")} width="11" height="10" alt='' />
</a>
</div>;
}
let homePageSection = null;
let homePageItem = null;
if (this.hasHomePage()) {
homePageSection = <ul className="mx_TopLeftMenu_section_withIcon">
<li className="mx_TopLeftMenu_icon_home" onClick={this.viewHomePage}>{_t("Home")}</li>
</ul>;
homePageItem = (
<AccessibleButton element="li" className="mx_TopLeftMenu_icon_home" onClick={this.viewHomePage}>
{_t("Home")}
</AccessibleButton>
);
}
let signInOutSection;
let signInOutItem;
if (isGuest) {
signInOutSection = <ul className="mx_TopLeftMenu_section_withIcon">
<li className="mx_TopLeftMenu_icon_signin" onClick={this.signIn}>{_t("Sign in")}</li>
</ul>;
signInOutItem = (
<AccessibleButton element="li" className="mx_TopLeftMenu_icon_signin" onClick={this.signIn}>
{_t("Sign in")}
</AccessibleButton>
);
} else {
signInOutSection = <ul className="mx_TopLeftMenu_section_withIcon">
<li className="mx_TopLeftMenu_icon_signout" onClick={this.signOut}>{_t("Sign out")}</li>
</ul>;
signInOutItem = (
<AccessibleButton element="li" className="mx_TopLeftMenu_icon_signout" onClick={this.signOut}>
{_t("Sign out")}
</AccessibleButton>
);
}
return <div className="mx_TopLeftMenu">
<div className="mx_TopLeftMenu_section_noIcon">
const settingsItem = (
<AccessibleButton element="li" className="mx_TopLeftMenu_icon_settings" onClick={this.openSettings}>
{_t("Settings")}
</AccessibleButton>
);
return <div className="mx_TopLeftMenu" ref={this.props.containerRef}>
<div className="mx_TopLeftMenu_section_noIcon" aria-readonly={true}>
<div>{this.props.displayName}</div>
<div className="mx_TopLeftMenu_greyedText">{this.props.userId}</div>
<div className="mx_TopLeftMenu_greyedText" aria-hidden={true}>{this.props.userId}</div>
{hostingSignup}
</div>
{homePageSection}
<ul className="mx_TopLeftMenu_section_withIcon">
<li className="mx_TopLeftMenu_icon_settings" onClick={this.openSettings}>{_t("Settings")}</li>
{homePageItem}
{settingsItem}
{signInOutItem}
</ul>
{signInOutSection}
</div>;
}

View file

@ -0,0 +1,134 @@
/*
Copyright 2019 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 sdk from '../../../index';
import {_t} from '../../../languageHandler';
export default class WidgetContextMenu extends React.Component {
static propTypes = {
onFinished: PropTypes.func,
// Callback for when the revoke button is clicked. Required.
onRevokeClicked: PropTypes.func.isRequired,
// Callback for when the snapshot button is clicked. Button not shown
// without a callback.
onSnapshotClicked: PropTypes.func,
// Callback for when the reload button is clicked. Button not shown
// without a callback.
onReloadClicked: PropTypes.func,
// Callback for when the edit button is clicked. Button not shown
// without a callback.
onEditClicked: PropTypes.func,
// Callback for when the delete button is clicked. Button not shown
// without a callback.
onDeleteClicked: PropTypes.func,
};
proxyClick(fn) {
fn();
if (this.props.onFinished) this.props.onFinished();
}
// XXX: It's annoying that our context menus require us to hit onFinished() to close :(
onEditClicked = () => {
this.proxyClick(this.props.onEditClicked);
};
onReloadClicked = () => {
this.proxyClick(this.props.onReloadClicked);
};
onSnapshotClicked = () => {
this.proxyClick(this.props.onSnapshotClicked);
};
onDeleteClicked = () => {
this.proxyClick(this.props.onDeleteClicked);
};
onRevokeClicked = () => {
this.proxyClick(this.props.onRevokeClicked);
};
render() {
const AccessibleButton = sdk.getComponent("views.elements.AccessibleButton");
const options = [];
if (this.props.onEditClicked) {
options.push(
<AccessibleButton className='mx_WidgetContextMenu_option' onClick={this.onEditClicked} key='edit'>
{_t("Edit")}
</AccessibleButton>,
);
}
if (this.props.onReloadClicked) {
options.push(
<AccessibleButton className='mx_WidgetContextMenu_option' onClick={this.onReloadClicked}
key='reload'>
{_t("Reload")}
</AccessibleButton>,
);
}
if (this.props.onSnapshotClicked) {
options.push(
<AccessibleButton className='mx_WidgetContextMenu_option' onClick={this.onSnapshotClicked}
key='snap'>
{_t("Take picture")}
</AccessibleButton>,
);
}
if (this.props.onDeleteClicked) {
options.push(
<AccessibleButton className='mx_WidgetContextMenu_option' onClick={this.onDeleteClicked}
key='delete'>
{_t("Remove for everyone")}
</AccessibleButton>,
);
}
// Push this last so it appears last. It's always present.
options.push(
<AccessibleButton className='mx_WidgetContextMenu_option' onClick={this.onRevokeClicked} key='revoke'>
{_t("Remove for me")}
</AccessibleButton>,
);
// Put separators between the options
if (options.length > 1) {
const length = options.length;
for (let i = 0; i < length - 1; i++) {
const sep = <hr key={i} className="mx_WidgetContextMenu_separator" />;
// Insert backwards so the insertions don't affect our math on where to place them.
// We also use our cached length to avoid worrying about options.length changing
options.splice(length - 1 - i, 0, sep);
}
}
return <div className="mx_WidgetContextMenu">{options}</div>;
}
}

View file

@ -14,12 +14,12 @@ 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 createReactClass from 'create-react-class';
import { _t } from '../../../languageHandler';
module.exports = React.createClass({
module.exports = createReactClass({
displayName: 'CreateRoomButton',
propTypes: {
onCreateRoom: PropTypes.func,

View file

@ -14,10 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
const React = require('react');
import React from "react";
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import { _t } from '../../../languageHandler';
const Presets = {
@ -26,7 +25,7 @@ const Presets = {
Custom: "custom",
};
module.exports = React.createClass({
module.exports = createReactClass({
displayName: 'CreateRoomPresets',
propTypes: {
onChange: PropTypes.func,

View file

@ -14,11 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
const React = require('react');
import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import { _t } from '../../../languageHandler';
module.exports = React.createClass({
module.exports = createReactClass({
displayName: 'RoomAlias',
propTypes: {
// Specifying a homeserver will make magical things happen when you,

View file

@ -1,6 +1,8 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017, 2018, 2019 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -17,13 +19,19 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import { _t, _td } from '../../../languageHandler';
import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
import Promise from 'bluebird';
import dis from '../../../dispatcher';
import { addressTypes, getAddressType } from '../../../UserAddress.js';
import GroupStore from '../../../stores/GroupStore';
import * as Email from "../../../email";
import * as Email from '../../../email';
import IdentityAuthClient from '../../../IdentityAuthClient';
import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from '../../../utils/IdentityServerUtils';
import { abbreviateUrl } from '../../../utils/UrlUtils';
import {sleep} from "../../../utils/promise";
const TRUNCATE_QUERY_LIST = 40;
const QUERY_USER_DIRECTORY_DEBOUNCE_MS = 200;
@ -35,7 +43,7 @@ const addressTypeName = {
};
module.exports = React.createClass({
module.exports = createReactClass({
displayName: "AddressPickerDialog",
propTypes: {
@ -44,7 +52,7 @@ module.exports = React.createClass({
// Extra node inserted after picker input, dropdown and errors
extraNode: PropTypes.node,
value: PropTypes.string,
placeholder: PropTypes.string,
placeholder: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
roomId: PropTypes.string,
button: PropTypes.string,
focus: PropTypes.bool,
@ -69,13 +77,18 @@ module.exports = React.createClass({
},
getInitialState: function() {
return {
error: false,
let validAddressTypes = this.props.validAddressTypes;
// Remove email from validAddressTypes if no IS is configured. It may be added at a later stage by the user
if (!MatrixClientPeg.get().getIdentityServerUrl() && validAddressTypes.includes("email")) {
validAddressTypes = validAddressTypes.filter(type => type !== "email");
}
return {
// Whether to show an error message because of an invalid address
invalidAddressError: false,
// List of UserAddressType objects representing
// the list of addresses we're going to invite
selectedList: [],
// Whether a search is ongoing
busy: false,
// An error message generated during the user directory search
@ -87,6 +100,9 @@ module.exports = React.createClass({
// List of UserAddressType objects representing the set of
// auto-completion results for the current search query.
suggestedList: [],
// List of address types initialised from props, but may change while the
// dialog is open and represents the supported list of address types at this time.
validAddressTypes,
};
},
@ -97,12 +113,21 @@ module.exports = React.createClass({
}
},
getPlaceholder() {
const { placeholder } = this.props;
if (typeof placeholder === "string") {
return placeholder;
}
// Otherwise it's a function, as checked by prop types.
return placeholder(this.state.validAddressTypes);
},
onButtonClick: function() {
let selectedList = this.state.selectedList.slice();
// Check the text input field to see if user has an unconverted address
// If there is and it's valid add it to the local selectedList
if (this.refs.textinput.value !== '') {
selectedList = this._addInputToList();
selectedList = this._addAddressesToList([this.refs.textinput.value]);
if (selectedList === null) return;
}
this.props.onFinished(true, selectedList);
@ -140,12 +165,12 @@ module.exports = React.createClass({
// if there's nothing in the input box, submit the form
this.onButtonClick();
} else {
this._addInputToList();
this._addAddressesToList([this.refs.textinput.value]);
}
} else if (e.keyCode === 188 || e.keyCode === 9) { // comma or tab
e.stopPropagation();
e.preventDefault();
this._addInputToList();
this._addAddressesToList([this.refs.textinput.value]);
}
},
@ -205,7 +230,7 @@ module.exports = React.createClass({
onSelected: function(index) {
const selectedList = this.state.selectedList.slice();
selectedList.push(this.state.suggestedList[index]);
selectedList.push(this._getFilteredSuggestions()[index]);
this.setState({
selectedList,
suggestedList: [],
@ -241,7 +266,7 @@ module.exports = React.createClass({
this.setState({
searchError: err.errcode ? err.message : _t('Something went wrong!'),
});
}).done(() => {
}).then(() => {
this.setState({
busy: false,
});
@ -354,7 +379,7 @@ module.exports = React.createClass({
// Do a local search immediately
this._doLocalSearch(query);
}
}).done(() => {
}).then(() => {
this.setState({
busy: false,
});
@ -430,7 +455,7 @@ module.exports = React.createClass({
// This is important, otherwise there's no way to invite
// a perfectly valid address if there are close matches.
const addrType = getAddressType(query);
if (this.props.validAddressTypes.includes(addrType)) {
if (this.state.validAddressTypes.includes(addrType)) {
if (addrType === 'email' && !Email.looksValid(query)) {
this.setState({searchError: _t("That doesn't look like a valid email address")});
return;
@ -442,56 +467,62 @@ module.exports = React.createClass({
});
if (this._cancelThreepidLookup) this._cancelThreepidLookup();
if (addrType === 'email') {
this._lookupThreepid(addrType, query).done();
this._lookupThreepid(addrType, query);
}
}
this.setState({
suggestedList,
error: false,
invalidAddressError: false,
}, () => {
if (this.addressSelector) this.addressSelector.moveSelectionTop();
});
},
_addInputToList: function() {
const addressText = this.refs.textinput.value.trim();
const addrType = getAddressType(addressText);
const addrObj = {
addressType: addrType,
address: addressText,
isKnown: false,
};
if (!this.props.validAddressTypes.includes(addrType)) {
this.setState({ error: true });
return null;
} else if (addrType === 'mx-user-id') {
const user = MatrixClientPeg.get().getUser(addrObj.address);
if (user) {
addrObj.displayName = user.displayName;
addrObj.avatarMxc = user.avatarUrl;
addrObj.isKnown = true;
}
} else if (addrType === 'mx-room-id') {
const room = MatrixClientPeg.get().getRoom(addrObj.address);
if (room) {
addrObj.displayName = room.name;
addrObj.avatarMxc = room.avatarUrl;
addrObj.isKnown = true;
}
}
_addAddressesToList: function(addressTexts) {
const selectedList = this.state.selectedList.slice();
selectedList.push(addrObj);
let hasError = false;
addressTexts.forEach((addressText) => {
addressText = addressText.trim();
const addrType = getAddressType(addressText);
const addrObj = {
addressType: addrType,
address: addressText,
isKnown: false,
};
if (!this.state.validAddressTypes.includes(addrType)) {
hasError = true;
} else if (addrType === 'mx-user-id') {
const user = MatrixClientPeg.get().getUser(addrObj.address);
if (user) {
addrObj.displayName = user.displayName;
addrObj.avatarMxc = user.avatarUrl;
addrObj.isKnown = true;
}
} else if (addrType === 'mx-room-id') {
const room = MatrixClientPeg.get().getRoom(addrObj.address);
if (room) {
addrObj.displayName = room.name;
addrObj.avatarMxc = room.avatarUrl;
addrObj.isKnown = true;
}
}
selectedList.push(addrObj);
});
this.setState({
selectedList,
suggestedList: [],
query: "",
invalidAddressError: hasError ? true : this.state.invalidAddressError,
});
if (this._cancelThreepidLookup) this._cancelThreepidLookup();
return selectedList;
return hasError ? null : selectedList;
},
_lookupThreepid: function(medium, address) {
_lookupThreepid: async function(medium, address) {
let cancelled = false;
// Note that we can't safely remove this after we're done
// because we don't know that it's the same one, so we just
@ -502,36 +533,44 @@ module.exports = React.createClass({
};
// wait a bit to let the user finish typing
return Promise.delay(500).then(() => {
if (cancelled) return null;
return MatrixClientPeg.get().lookupThreePid(medium, address);
}).then((res) => {
if (res === null || !res.mxid) return null;
await sleep(500);
if (cancelled) return null;
try {
const authClient = new IdentityAuthClient();
const identityAccessToken = await authClient.getAccessToken();
if (cancelled) return null;
return MatrixClientPeg.get().getProfileInfo(res.mxid);
}).then((res) => {
if (res === null) return null;
if (cancelled) return null;
const lookup = await MatrixClientPeg.get().lookupThreePid(
medium,
address,
undefined /* callback */,
identityAccessToken,
);
if (cancelled || lookup === null || !lookup.mxid) return null;
const profile = await MatrixClientPeg.get().getProfileInfo(lookup.mxid);
if (cancelled || profile === null) return null;
this.setState({
suggestedList: [{
// a UserAddressType
addressType: medium,
address: address,
displayName: res.displayname,
avatarMxc: res.avatar_url,
displayName: profile.displayname,
avatarMxc: profile.avatar_url,
isKnown: true,
}],
});
});
} catch (e) {
console.error(e);
this.setState({
searchError: _t('Something went wrong!'),
});
}
},
render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const AddressSelector = sdk.getComponent("elements.AddressSelector");
this.scrollElement = null;
_getFilteredSuggestions: function() {
// map addressType => set of addresses to avoid O(n*m) operation
const selectedAddresses = {};
this.state.selectedList.forEach(({address, addressType}) => {
@ -540,9 +579,50 @@ module.exports = React.createClass({
});
// Filter out any addresses in the above already selected addresses (matching both type and address)
const filteredSuggestedList = this.state.suggestedList.filter(({address, addressType}) => {
return this.state.suggestedList.filter(({address, addressType}) => {
return !(selectedAddresses[addressType] && selectedAddresses[addressType].has(address));
});
},
_onPaste: function(e) {
// Prevent the text being pasted into the textarea
e.preventDefault();
const text = e.clipboardData.getData("text");
// Process it as a list of addresses to add instead
this._addAddressesToList(text.split(/[\s,]+/));
},
onUseDefaultIdentityServerClick(e) {
e.preventDefault();
// Update the IS in account data. Actually using it may trigger terms.
// eslint-disable-next-line react-hooks/rules-of-hooks
useDefaultIdentityServer();
// Add email as a valid address type.
const { validAddressTypes } = this.state;
validAddressTypes.push('email');
this.setState({ validAddressTypes });
},
onManageSettingsClick(e) {
e.preventDefault();
dis.dispatch({ action: 'view_user_settings' });
this.onCancel();
},
render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const AddressSelector = sdk.getComponent("elements.AddressSelector");
this.scrollElement = null;
let inputLabel;
if (this.props.description) {
inputLabel = <div className="mx_AddressPickerDialog_label">
<label htmlFor="textinput">{this.props.description}</label>
</div>;
}
const query = [];
// create the invite list
@ -562,22 +642,26 @@ module.exports = React.createClass({
// Add the query at the end
query.push(
<textarea key={this.state.selectedList.length}
<textarea
key={this.state.selectedList.length}
onPaste={this._onPaste}
rows="1"
id="textinput"
ref="textinput"
className="mx_AddressPickerDialog_input"
onChange={this.onQueryChanged}
placeholder={this.props.placeholder}
placeholder={this.getPlaceholder()}
defaultValue={this.props.value}
autoFocus={this.props.focus}>
</textarea>,
);
const filteredSuggestedList = this._getFilteredSuggestions();
let error;
let addressSelector;
if (this.state.error) {
const validTypeDescriptions = this.props.validAddressTypes.map((t) => _t(addressTypeName[t]));
if (this.state.invalidAddressError) {
const validTypeDescriptions = this.state.validAddressTypes.map((t) => _t(addressTypeName[t]));
error = <div className="mx_AddressPickerDialog_error">
{ _t("You have entered an invalid address.") }
<br />
@ -600,17 +684,45 @@ module.exports = React.createClass({
);
}
let identityServer;
// If picker cannot currently accept e-mail but should be able to
if (this.props.pickerType === 'user' && !this.state.validAddressTypes.includes('email')
&& this.props.validAddressTypes.includes('email')) {
const defaultIdentityServerUrl = getDefaultIdentityServerUrl();
if (defaultIdentityServerUrl) {
identityServer = <div className="mx_AddressPickerDialog_identityServer">{_t(
"Use an identity server to invite by email. " +
"<default>Use the default (%(defaultIdentityServerName)s)</default> " +
"or manage in <settings>Settings</settings>.",
{
defaultIdentityServerName: abbreviateUrl(defaultIdentityServerUrl),
},
{
default: sub => <a href="#" onClick={this.onUseDefaultIdentityServerClick}>{sub}</a>,
settings: sub => <a href="#" onClick={this.onManageSettingsClick}>{sub}</a>,
},
)}</div>;
} else {
identityServer = <div className="mx_AddressPickerDialog_identityServer">{_t(
"Use an identity server to invite by email. " +
"Manage in <settings>Settings</settings>.",
{}, {
settings: sub => <a href="#" onClick={this.onManageSettingsClick}>{sub}</a>,
},
)}</div>;
}
}
return (
<BaseDialog className="mx_AddressPickerDialog" onKeyDown={this.onKeyDown}
onFinished={this.props.onFinished} title={this.props.title}>
<div className="mx_AddressPickerDialog_label">
<label htmlFor="textinput">{ this.props.description }</label>
</div>
{inputLabel}
<div className="mx_Dialog_content">
<div className="mx_AddressPickerDialog_inputContainer">{ query }</div>
{ error }
{ addressSelector }
{ this.props.extraNode }
{ identityServer }
</div>
<DialogButtons primaryButton={this.props.button}
onPrimaryButtonClick={this.onButtonClick}

View file

@ -16,12 +16,13 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import {SettingLevel} from "../../../settings/SettingsStore";
import SettingsStore from "../../../settings/SettingsStore";
export default React.createClass({
export default createReactClass({
propTypes: {
unknownProfileUsers: PropTypes.array.isRequired, // [ {userId, errorText}... ]
onInviteAnyways: PropTypes.func.isRequired,

View file

@ -16,6 +16,7 @@ limitations under the License.
*/
import React from 'react';
import createReactClass from 'create-react-class';
import FocusTrap from 'focus-trap-react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
@ -25,6 +26,7 @@ import { MatrixClient } from 'matrix-js-sdk';
import { KeyCode } from '../../../Keyboard';
import AccessibleButton from '../elements/AccessibleButton';
import MatrixClientPeg from '../../../MatrixClientPeg';
import { _t } from "../../../languageHandler";
/**
* Basic container for modal dialogs.
@ -32,7 +34,7 @@ import MatrixClientPeg from '../../../MatrixClientPeg';
* Includes a div for the title, and a keypress handler which cancels the
* dialog on escape.
*/
export default React.createClass({
export default createReactClass({
displayName: 'BaseDialog',
propTypes: {
@ -113,8 +115,9 @@ export default React.createClass({
render: function() {
let cancelButton;
if (this.props.hasCancel) {
cancelButton = <AccessibleButton onClick={this._onCancelClick} className="mx_Dialog_cancelButton">
</AccessibleButton>;
cancelButton = (
<AccessibleButton onClick={this._onCancelClick} className="mx_Dialog_cancelButton" aria-label={_t("Close dialog")} />
);
}
return (

View file

@ -1,6 +1,8 @@
/*
Copyright 2017 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,6 +18,7 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import SdkConfig from '../../../SdkConfig';
import Modal from '../../../Modal';
@ -50,6 +53,13 @@ export default class BugReportDialog extends React.Component {
}
_onSubmit(ev) {
if ((!this.state.text || !this.state.text.trim()) && (!this.state.issueUrl || !this.state.issueUrl.trim())) {
this.setState({
err: _t("Please tell us what went wrong or, better, create a GitHub issue that describes the problem."),
});
return;
}
const userText =
(this.state.text.length > 0 ? this.state.text + '\n\n': '') + 'Issue: ' +
(this.state.issueUrl.length > 0 ? this.state.issueUrl : 'No issue link given');
@ -62,6 +72,7 @@ export default class BugReportDialog extends React.Component {
userText,
sendLogs: true,
progressCallback: this._sendProgressCallback,
label: this.props.label,
}).then(() => {
if (!this._unmounted) {
this.props.onFinished(false);
@ -93,7 +104,7 @@ export default class BugReportDialog extends React.Component {
this.setState({ issueUrl: ev.target.value });
}
_onSendLogsChange(ev) {
_onSendLogsChange(ev) {
this.setState({ sendLogs: ev.target.checked });
}
@ -165,6 +176,7 @@ export default class BugReportDialog extends React.Component {
placeholder="https://github.com/vector-im/riot-web/issues/..."
/>
<Field
id="mx_BugReportDialog_notes"
className="mx_BugReportDialog_field_input"
element="textarea"
label={_t("Notes")}
@ -193,5 +205,5 @@ export default class BugReportDialog extends React.Component {
}
BugReportDialog.propTypes = {
onFinished: React.PropTypes.func.isRequired,
onFinished: PropTypes.func.isRequired,
};

View file

@ -1,5 +1,6 @@
/*
Copyright 2016 Aviral Dasgupta
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,6 +16,7 @@
*/
import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import request from 'browser-request';
import { _t } from '../../../languageHandler';
@ -99,7 +101,7 @@ export default class ChangelogDialog extends React.Component {
}
ChangelogDialog.propTypes = {
version: React.PropTypes.string.isRequired,
newVersion: React.PropTypes.string.isRequired,
onFinished: React.PropTypes.func.isRequired,
version: PropTypes.string.isRequired,
newVersion: PropTypes.string.isRequired,
onFinished: PropTypes.func.isRequired,
};

View file

@ -1,198 +0,0 @@
/*
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.
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 sdk from '../../../index';
import { _t } from '../../../languageHandler';
import MatrixClientPeg from '../../../MatrixClientPeg';
import DMRoomMap from '../../../utils/DMRoomMap';
import AccessibleButton from '../elements/AccessibleButton';
import Unread from '../../../Unread';
import classNames from 'classnames';
export default class ChatCreateOrReuseDialog extends React.Component {
constructor(props) {
super(props);
this.onFinished = this.onFinished.bind(this);
this.onRoomTileClick = this.onRoomTileClick.bind(this);
this.state = {
tiles: [],
profile: {
displayName: null,
avatarUrl: null,
},
profileError: null,
};
}
componentWillMount() {
const client = MatrixClientPeg.get();
const dmRoomMap = new DMRoomMap(client);
const dmRooms = dmRoomMap.getDMRoomsForUserId(this.props.userId);
const RoomTile = sdk.getComponent("rooms.RoomTile");
const tiles = [];
for (const roomId of dmRooms) {
const room = client.getRoom(roomId);
if (room) {
const isInvite = room.getMyMembership() === "invite";
const highlight = room.getUnreadNotificationCount('highlight') > 0 || isInvite;
tiles.push(
<RoomTile key={room.roomId} room={room}
transparent={true}
collapsed={false}
selected={false}
unread={Unread.doesRoomHaveUnreadMessages(room)}
highlight={highlight}
isInvite={isInvite}
onClick={this.onRoomTileClick}
/>,
);
}
}
this.setState({
tiles: tiles,
});
if (tiles.length === 0) {
this.setState({
busyProfile: true,
});
MatrixClientPeg.get().getProfileInfo(this.props.userId).done((resp) => {
const profile = {
displayName: resp.displayname,
avatarUrl: null,
};
if (resp.avatar_url) {
profile.avatarUrl = MatrixClientPeg.get().mxcUrlToHttp(
resp.avatar_url, 48, 48, "crop",
);
}
this.setState({
busyProfile: false,
profile: profile,
});
}, (err) => {
console.error(
'Unable to get profile for user ' + this.props.userId + ':',
err,
);
this.setState({
busyProfile: false,
profileError: err,
});
});
}
}
onRoomTileClick(roomId) {
this.props.onExistingRoomSelected(roomId);
}
onFinished() {
this.props.onFinished(false);
}
render() {
let title = '';
let content = null;
if (this.state.tiles.length > 0) {
// Show the existing rooms with a "+" to add a new dm
title = _t('Create a new chat or reuse an existing one');
const labelClasses = classNames({
mx_MemberInfo_createRoom_label: true,
mx_RoomTile_name: true,
});
const startNewChat = <AccessibleButton
className="mx_MemberInfo_createRoom"
onClick={this.props.onNewDMClick}
>
<div className="mx_RoomTile_avatar">
<img src={require("../../../../res/img/create-big.svg")} width="26" height="26" />
</div>
<div className={labelClasses}><i>{ _t("Start new chat") }</i></div>
</AccessibleButton>;
content = <div className="mx_Dialog_content" id='mx_Dialog_content'>
{ _t('You already have existing direct chats with this user:') }
<div className="mx_ChatCreateOrReuseDialog_tiles">
{ this.state.tiles }
{ startNewChat }
</div>
</div>;
} else {
// Show the avatar, name and a button to confirm that a new chat is requested
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const Spinner = sdk.getComponent('elements.Spinner');
title = _t('Start chatting');
let profile = null;
if (this.state.busyProfile) {
profile = <Spinner />;
} else if (this.state.profileError) {
profile = <div className="error" role="alert">
Unable to load profile information for { this.props.userId }
</div>;
} else {
profile = <div className="mx_ChatCreateOrReuseDialog_profile">
<BaseAvatar
name={this.state.profile.displayName || this.props.userId}
url={this.state.profile.avatarUrl}
width={48} height={48}
/>
<div className="mx_ChatCreateOrReuseDialog_profile_name">
{ this.state.profile.displayName || this.props.userId }
</div>
</div>;
}
content = <div>
<div className="mx_Dialog_content" id='mx_Dialog_content'>
<p>
{ _t('Click on the button below to start chatting!') }
</p>
{ profile }
</div>
<DialogButtons primaryButton={_t('Start Chatting')}
onPrimaryButtonClick={this.props.onNewDMClick} focus={true} />
</div>;
}
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return (
<BaseDialog className='mx_ChatCreateOrReuseDialog'
onFinished={this.onFinished}
title={title}
contentId='mx_Dialog_content'
>
{ content }
</BaseDialog>
);
}
}
ChatCreateOrReuseDialog.propTypes = {
userId: PropTypes.string.isRequired,
// Called when clicking outside of the dialog
onFinished: PropTypes.func.isRequired,
onNewDMClick: PropTypes.func.isRequired,
onExistingRoomSelected: PropTypes.func.isRequired,
};

View file

@ -0,0 +1,90 @@
/*
Copyright 2019 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 sdk from '../../../index';
import { _t } from '../../../languageHandler';
/*
* A dialog for confirming a redaction.
* Also shows a spinner (and possible error) while the redaction is ongoing,
* and only closes the dialog when the redaction is done or failed.
*
* This is done to prevent the edit history dialog racing with the redaction:
* if this dialog closes and the MessageEditHistoryDialog is shown again,
* it will fetch the relations again, which will race with the ongoing /redact request.
* which will cause the edit to appear unredacted.
*
* To avoid this, we keep the dialog open as long as /redact is in progress.
*/
export default class ConfirmAndWaitRedactDialog extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
isRedacting: false,
redactionErrorCode: null,
};
}
onParentFinished = async (proceed) => {
if (proceed) {
this.setState({isRedacting: true});
try {
await this.props.redact();
this.props.onFinished(true);
} catch (error) {
const code = error.errcode || error.statusCode;
if (typeof code !== "undefined") {
this.setState({redactionErrorCode: code});
} else {
this.props.onFinished(true);
}
}
} else {
this.props.onFinished(false);
}
};
render() {
if (this.state.isRedacting) {
if (this.state.redactionErrorCode) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
const code = this.state.redactionErrorCode;
return (
<ErrorDialog
onFinished={this.props.onFinished}
title={_t('Error')}
description={_t('You cannot delete this message. (%(code)s)', {code})}
/>
);
} else {
const BaseDialog = sdk.getComponent("dialogs.BaseDialog");
const Spinner = sdk.getComponent('elements.Spinner');
return (
<BaseDialog
onFinished={this.props.onFinished}
hasCancel={false}
title={_t("Removing…")}>
<Spinner />
</BaseDialog>
);
}
} else {
const ConfirmRedactDialog = sdk.getComponent("dialogs.ConfirmRedactDialog");
return <ConfirmRedactDialog onFinished={this.onParentFinished} />;
}
}
}

View file

@ -15,13 +15,14 @@ limitations under the License.
*/
import React from 'react';
import createReactClass from 'create-react-class';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
/*
* A dialog for confirming a redaction.
*/
export default React.createClass({
export default createReactClass({
displayName: 'ConfirmRedactDialog',
render: function() {

View file

@ -15,6 +15,7 @@ limitations under the License.
*/
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk';
import sdk from '../../../index';
@ -29,7 +30,7 @@ import { GroupMemberType } from '../../../groups';
* to make it obvious what is going to happen.
* Also tweaks the style for 'dangerous' actions (albeit only with colour)
*/
export default React.createClass({
export default createReactClass({
displayName: 'ConfirmUserActionDialog',
propTypes: {
// matrix-js-sdk (room) member object. Supply either this or 'groupMember'
@ -49,10 +50,10 @@ export default React.createClass({
onFinished: PropTypes.func.isRequired,
},
defaultProps: {
getDefaultProps: () => ({
danger: false,
askReason: false,
},
}),
componentWillMount: function() {
this._reasonField = null;

View file

@ -0,0 +1,61 @@
/*
Copyright 2019 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 sdk from "../../../index";
export default class ConfirmWipeDeviceDialog extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
};
_onConfirm = () => {
this.props.onFinished(true);
};
_onDecline = () => {
this.props.onFinished(false);
};
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return (
<BaseDialog className='mx_ConfirmWipeDeviceDialog' hasCancel={true}
onFinished={this.props.onFinished}
title={_t("Clear all data on this device?")}>
<div className='mx_ConfirmWipeDeviceDialog_content'>
<p>
{_t(
"Clearing all data from this device is permanent. Encrypted messages will be lost " +
"unless their keys have been backed up.",
)}
</p>
</div>
<DialogButtons
primaryButton={_t("Clear all data")}
onPrimaryButtonClick={this._onConfirm}
primaryButtonClass="danger"
cancelButton={_t("Cancel")}
onCancel={this._onDecline}
/>
</BaseDialog>
);
}
}

View file

@ -15,13 +15,14 @@ limitations under the License.
*/
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import dis from '../../../dispatcher';
import { _t } from '../../../languageHandler';
import MatrixClientPeg from '../../../MatrixClientPeg';
export default React.createClass({
export default createReactClass({
displayName: 'CreateGroupDialog',
propTypes: {
onFinished: PropTypes.func.isRequired,
@ -92,7 +93,7 @@ export default React.createClass({
this.setState({createError: e});
}).finally(() => {
this.setState({creating: false});
}).done();
});
},
_onCancel: function() {

View file

@ -15,58 +15,187 @@ limitations under the License.
*/
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import SdkConfig from '../../../SdkConfig';
import withValidation from '../elements/Validation';
import { _t } from '../../../languageHandler';
import MatrixClientPeg from '../../../MatrixClientPeg';
import {Key} from "../../../Keyboard";
export default React.createClass({
export default createReactClass({
displayName: 'CreateRoomDialog',
propTypes: {
onFinished: PropTypes.func.isRequired,
},
componentWillMount: function() {
getInitialState() {
const config = SdkConfig.get();
// Dialog shows inverse of m.federate (noFederate) strict false check to skip undefined check (default = true)
this.defaultNoFederate = config.default_federate === false;
return {
isPublic: false,
name: "",
topic: "",
alias: "",
detailsOpen: false,
noFederate: config.default_federate === false,
nameIsValid: false,
};
},
onOk: function() {
this.props.onFinished(true, this.refs.textinput.value, this.refs.checkbox.checked);
_roomCreateOptions() {
const createOpts = {};
createOpts.name = this.state.name;
if (this.state.isPublic) {
createOpts.visibility = "public";
createOpts.preset = "public_chat";
// to prevent createRoom from enabling guest access
createOpts['initial_state'] = [];
const {alias} = this.state;
const localPart = alias.substr(1, alias.indexOf(":") - 1);
createOpts['room_alias_name'] = localPart;
}
if (this.state.topic) {
createOpts.topic = this.state.topic;
}
if (this.state.noFederate) {
createOpts.creation_content = {'m.federate': false};
}
return createOpts;
},
componentDidMount() {
this._detailsRef.addEventListener("toggle", this.onDetailsToggled);
// move focus to first field when showing dialog
this._nameFieldRef.focus();
},
componentWillUnmount() {
this._detailsRef.removeEventListener("toggle", this.onDetailsToggled);
},
_onKeyDown: function(event) {
if (event.key === Key.ENTER) {
this.onOk();
event.preventDefault();
event.stopPropagation();
}
},
onOk: async function() {
const activeElement = document.activeElement;
if (activeElement) {
activeElement.blur();
}
await this._nameFieldRef.validate({allowEmpty: false});
if (this._aliasFieldRef) {
await this._aliasFieldRef.validate({allowEmpty: false});
}
// Validation and state updates are async, so we need to wait for them to complete
// first. Queue a `setState` callback and wait for it to resolve.
await new Promise(resolve => this.setState({}, resolve));
if (this.state.nameIsValid && (!this._aliasFieldRef || this._aliasFieldRef.isValid)) {
this.props.onFinished(true, this._roomCreateOptions());
} else {
let field;
if (!this.state.nameIsValid) {
field = this._nameFieldRef;
} else if (this._aliasFieldRef && !this._aliasFieldRef.isValid) {
field = this._aliasFieldRef;
}
if (field) {
field.focus();
field.validate({ allowEmpty: false, focused: true });
}
}
},
onCancel: function() {
this.props.onFinished(false);
},
onNameChange(ev) {
this.setState({name: ev.target.value});
},
onTopicChange(ev) {
this.setState({topic: ev.target.value});
},
onPublicChange(isPublic) {
this.setState({isPublic});
},
onAliasChange(alias) {
this.setState({alias});
},
onDetailsToggled(ev) {
this.setState({detailsOpen: ev.target.open});
},
onNoFederateChange(noFederate) {
this.setState({noFederate});
},
collectDetailsRef(ref) {
this._detailsRef = ref;
},
async onNameValidate(fieldState) {
const result = await this._validateRoomName(fieldState);
this.setState({nameIsValid: result.valid});
return result;
},
_validateRoomName: withValidation({
rules: [
{
key: "required",
test: async ({ value }) => !!value,
invalid: () => _t("Please enter a name for the room"),
},
],
}),
render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const Field = sdk.getComponent('views.elements.Field');
const LabelledToggleSwitch = sdk.getComponent('views.elements.LabelledToggleSwitch');
const RoomAliasField = sdk.getComponent('views.elements.RoomAliasField');
let privateLabel;
let publicLabel;
let aliasField;
if (this.state.isPublic) {
publicLabel = (<p>{_t("Set a room alias to easily share your room with other people.")}</p>);
const domain = MatrixClientPeg.get().getDomain();
aliasField = (
<div className="mx_CreateRoomDialog_aliasContainer">
<RoomAliasField id="alias" ref={ref => this._aliasFieldRef = ref} onChange={this.onAliasChange} domain={domain} />
</div>
);
} else {
privateLabel = (<p>{_t("This room is private, and can only be joined by invitation.")}</p>);
}
const title = this.state.isPublic ? _t('Create a public room') : _t('Create a private room');
return (
<BaseDialog className="mx_CreateRoomDialog" onFinished={this.props.onFinished}
title={_t('Create Room')}
title={title}
>
<form onSubmit={this.onOk}>
<form onSubmit={this.onOk} onKeyDown={this._onKeyDown}>
<div className="mx_Dialog_content">
<div className="mx_CreateRoomDialog_label">
<label htmlFor="textinput"> { _t('Room name (optional)') } </label>
</div>
<div className="mx_CreateRoomDialog_input_container">
<input id="textinput" ref="textinput" className="mx_CreateRoomDialog_input" autoFocus={true} />
</div>
<br />
<details className="mx_CreateRoomDialog_details">
<summary className="mx_CreateRoomDialog_details_summary">{ _t('Advanced options') }</summary>
<div>
<input type="checkbox" id="checkbox" ref="checkbox" defaultChecked={this.defaultNoFederate} />
<label htmlFor="checkbox">
{ _t('Block users on other matrix homeservers from joining this room') }
<br />
({ _t('This setting cannot be changed later!') })
</label>
</div>
<Field id="name" ref={ref => this._nameFieldRef = ref} label={ _t('Name') } onChange={this.onNameChange} onValidate={this.onNameValidate} value={this.state.name} className="mx_CreateRoomDialog_name" />
<Field id="topic" label={ _t('Topic (optional)') } onChange={this.onTopicChange} value={this.state.topic} />
<LabelledToggleSwitch label={ _t("Make this room public")} onChange={this.onPublicChange} value={this.state.isPublic} />
{ privateLabel }
{ publicLabel }
{ aliasField }
<details ref={this.collectDetailsRef} className="mx_CreateRoomDialog_details">
<summary className="mx_CreateRoomDialog_details_summary">{ this.state.detailsOpen ? _t('Hide advanced') : _t('Show advanced') }</summary>
<LabelledToggleSwitch label={ _t('Block users on other matrix homeservers from joining this room (This setting cannot be changed later!)')} onChange={this.onNoFederateChange} value={this.state.noFederate} />
</details>
</div>
</form>

View file

@ -36,7 +36,7 @@ export default class DeactivateAccountDialog extends React.Component {
this._onEraseFieldChange = this._onEraseFieldChange.bind(this);
this.state = {
confirmButtonEnabled: false,
password: "",
busy: false,
shouldErase: false,
errStr: null,
@ -45,7 +45,7 @@ export default class DeactivateAccountDialog extends React.Component {
_onPasswordFieldChange(ev) {
this.setState({
confirmButtonEnabled: Boolean(ev.target.value),
password: ev.target.value,
});
}
@ -63,14 +63,20 @@ export default class DeactivateAccountDialog extends React.Component {
// for this endpoint. In reality it could be any UI auth.
const auth = {
type: 'm.login.password',
// TODO: Remove `user` once servers support proper UIA
// See https://github.com/vector-im/riot-web/issues/10312
user: MatrixClientPeg.get().credentials.userId,
password: this._passwordField.value,
identifier: {
type: "m.id.user",
user: MatrixClientPeg.get().credentials.userId,
},
password: this.state.password,
};
await MatrixClientPeg.get().deactivateAccount(auth, this.state.shouldErase);
} catch (err) {
let errStr = _t('Unknown error');
// https://matrix.org/jira/browse/SYN-744
if (err.httpStatus == 401 || err.httpStatus == 403) {
if (err.httpStatus === 401 || err.httpStatus === 403) {
errStr = _t('Incorrect password');
Velocity(this._passwordField, "callout.shake", 300);
}
@ -83,7 +89,7 @@ export default class DeactivateAccountDialog extends React.Component {
Analytics.trackEvent('Account', 'Deactivate Account');
Lifecycle.onLoggedOut();
this.props.onFinished(false);
this.props.onFinished(true);
}
_onCancel() {
@ -104,7 +110,7 @@ export default class DeactivateAccountDialog extends React.Component {
}
const okLabel = this.state.busy ? <Loader /> : _t('Deactivate Account');
const okEnabled = this.state.confirmButtonEnabled && !this.state.busy;
const okEnabled = this.state.password && !this.state.busy;
let cancelButton = null;
if (!this.state.busy) {
@ -113,6 +119,8 @@ export default class DeactivateAccountDialog extends React.Component {
</button>;
}
const Field = sdk.getComponent('elements.Field');
return (
<BaseDialog className="mx_DeactivateAccountDialog"
onFinished={this.props.onFinished}
@ -167,10 +175,12 @@ export default class DeactivateAccountDialog extends React.Component {
</p>
<p>{ _t("To continue, please enter your password:") }</p>
<input
<Field
id="mx_DeactivateAccountDialog_password"
type="password"
placeholder={_t("password")}
label={_t('Password')}
onChange={this._onPasswordFieldChange}
value={this.state.password}
ref={(e) => {this._passwordField = e;}}
className={passwordBoxClass}
/>

View file

@ -2,6 +2,7 @@
Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2019 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -23,6 +24,10 @@ import sdk from '../../../index';
import * as FormattingUtils from '../../../utils/FormattingUtils';
import { _t } from '../../../languageHandler';
import {verificationMethods} from 'matrix-js-sdk/lib/crypto';
import DMRoomMap from '../../../utils/DMRoomMap';
import createRoom from "../../../createRoom";
import dis from "../../../dispatcher";
import SettingsStore from '../../../settings/SettingsStore';
const MODE_LEGACY = 'legacy';
const MODE_SAS = 'sas';
@ -85,25 +90,37 @@ export default class DeviceVerifyDialog extends React.Component {
this.props.onFinished(confirm);
}
_onSasRequestClick = () => {
_onSasRequestClick = async () => {
this.setState({
phase: PHASE_WAIT_FOR_PARTNER_TO_ACCEPT,
});
this._verifier = MatrixClientPeg.get().beginKeyVerification(
verificationMethods.SAS, this.props.userId, this.props.device.deviceId,
);
this._verifier.on('show_sas', this._onVerifierShowSas);
this._verifier.verify().then(() => {
const client = MatrixClientPeg.get();
const verifyingOwnDevice = this.props.userId === client.getUserId();
try {
if (!verifyingOwnDevice && SettingsStore.getValue("feature_dm_verification")) {
const roomId = await ensureDMExistsAndOpen(this.props.userId);
// throws upon cancellation before having started
this._verifier = await client.requestVerificationDM(
this.props.userId, roomId, [verificationMethods.SAS],
);
} else {
this._verifier = client.beginKeyVerification(
verificationMethods.SAS, this.props.userId, this.props.device.deviceId,
);
}
this._verifier.on('show_sas', this._onVerifierShowSas);
// throws upon cancellation
await this._verifier.verify();
this.setState({phase: PHASE_VERIFIED});
this._verifier.removeListener('show_sas', this._onVerifierShowSas);
this._verifier = null;
}).catch((e) => {
} catch (e) {
console.log("Verification failed", e);
this.setState({
phase: PHASE_CANCELLED,
});
this._verifier = null;
});
}
}
_onSasMatchesClick = () => {
@ -241,6 +258,16 @@ export default class DeviceVerifyDialog extends React.Component {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const AccessibleButton = sdk.getComponent('views.elements.AccessibleButton');
let text;
if (MatrixClientPeg.get().getUserId() === this.props.userId) {
text = _t("To verify that this device can be trusted, please check that the key you see " +
"in User Settings on that device matches the key below:");
} else {
text = _t("To verify that this device can be trusted, please contact its owner using some other " +
"means (e.g. in person or a phone call) and ask them whether the key they see in their User Settings " +
"for this device matches the key below:");
}
const key = FormattingUtils.formatCryptoKey(this.props.device.getFingerprint());
const body = (
<div>
@ -250,10 +277,7 @@ export default class DeviceVerifyDialog extends React.Component {
{_t("Use two-way text verification")}
</AccessibleButton>
<p>
{ _t("To verify that this device can be trusted, please contact its " +
"owner using some other means (e.g. in person or a phone call) " +
"and ask them whether the key they see in their User Settings " +
"for this device matches the key below:") }
{ text }
</p>
<div className="mx_DeviceVerifyDialog_cryptoSection">
<ul>
@ -291,3 +315,30 @@ export default class DeviceVerifyDialog extends React.Component {
}
}
async function ensureDMExistsAndOpen(userId) {
const client = MatrixClientPeg.get();
const roomIds = DMRoomMap.shared().getDMRoomsForUserId(userId);
const rooms = roomIds.map(id => client.getRoom(id));
const suitableDMRooms = rooms.filter(r => {
if (r && r.getMyMembership() === "join") {
const member = r.getMember(userId);
return member && (member.membership === "invite" || member.membership === "join");
}
return false;
});
let roomId;
if (suitableDMRooms.length) {
const room = suitableDMRooms[0];
roomId = room.roomId;
} else {
roomId = await createRoom({dmUserId: userId, spinner: false, andView: false});
}
// don't use andView and spinner in createRoom, together, they cause this dialog to close and reopen,
// we causes us to loose the verifier and restart, and we end up having two verification requests
dis.dispatch({
action: 'view_room',
room_id: roomId,
should_peek: false,
});
return roomId;
}

View file

@ -128,8 +128,10 @@ class SendCustomEvent extends GenericEditor {
return <div>
<div className="mx_DevTools_content">
{ this.textInput('eventType', _t('Event Type')) }
{ this.state.isStateEvent && this.textInput('stateKey', _t('State Key')) }
<div className="mx_DevTools_eventTypeStateKeyGroup">
{ this.textInput('eventType', _t('Event Type')) }
{ this.state.isStateEvent && this.textInput('stateKey', _t('State Key')) }
</div>
<br />

View file

@ -26,11 +26,12 @@ limitations under the License.
*/
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
export default React.createClass({
export default createReactClass({
displayName: 'ErrorDialog',
propTypes: {
title: PropTypes.string,

View file

@ -34,9 +34,15 @@ export default class IncomingSasDialog extends React.Component {
constructor(props) {
super(props);
let phase = PHASE_START;
if (this.props.verifier.cancelled) {
console.log("Verifier was cancelled in the background.");
phase = PHASE_CANCELLED;
}
this._showSasEvent = null;
this.state = {
phase: PHASE_START,
phase: phase,
sasVerified: false,
opponentProfile: null,
opponentProfileError: null,

View file

@ -17,23 +17,28 @@ limitations under the License.
*/
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import classNames from "classnames";
export default React.createClass({
export default createReactClass({
displayName: 'InfoDialog',
propTypes: {
className: PropTypes.string,
title: PropTypes.string,
description: PropTypes.node,
button: PropTypes.string,
onFinished: PropTypes.func,
hasCloseButton: PropTypes.bool,
},
getDefaultProps: function() {
return {
title: '',
description: '',
hasCloseButton: false,
};
},
@ -48,9 +53,9 @@ export default React.createClass({
<BaseDialog className="mx_InfoDialog" onFinished={this.props.onFinished}
title={this.props.title}
contentId='mx_Dialog_content'
hasCancel={false}
hasCancel={this.props.hasCloseButton}
>
<div className="mx_Dialog_content" id="mx_Dialog_content">
<div className={classNames("mx_Dialog_content", this.props.className)} id="mx_Dialog_content">
{ this.props.description }
</div>
<DialogButtons primaryButton={this.props.button || _t('OK')}

View file

@ -0,0 +1,57 @@
/*
Copyright 2019 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 sdk from "../../../index";
import dis from '../../../dispatcher';
export default class IntegrationsDisabledDialog extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
};
_onAcknowledgeClick = () => {
this.props.onFinished();
};
_onOpenSettingsClick = () => {
this.props.onFinished();
dis.dispatch({action: "view_user_settings"});
};
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return (
<BaseDialog className='mx_IntegrationsDisabledDialog' hasCancel={true}
onFinished={this.props.onFinished}
title={_t("Integrations are disabled")}>
<div className='mx_IntegrationsDisabledDialog_content'>
<p>{_t("Enable 'Manage Integrations' in Settings to do this.")}</p>
</div>
<DialogButtons
primaryButton={_t("Settings")}
onPrimaryButtonClick={this._onOpenSettingsClick}
cancelButton={_t("OK")}
onCancel={this._onAcknowledgeClick}
/>
</BaseDialog>
);
}
}

View file

@ -0,0 +1,55 @@
/*
Copyright 2019 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 sdk from "../../../index";
export default class IntegrationsImpossibleDialog extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
};
_onAcknowledgeClick = () => {
this.props.onFinished();
};
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return (
<BaseDialog className='mx_IntegrationsImpossibleDialog' hasCancel={false}
onFinished={this.props.onFinished}
title={_t("Integrations not allowed")}>
<div className='mx_IntegrationsImpossibleDialog_content'>
<p>
{_t(
"Your Riot doesn't allow you to use an Integration Manager to do this. " +
"Please contact an admin.",
)}
</p>
</div>
<DialogButtons
primaryButton={_t("OK")}
onPrimaryButtonClick={this._onAcknowledgeClick}
hasCancel={false}
/>
</BaseDialog>
);
}
}

View file

@ -16,6 +16,7 @@ limitations under the License.
*/
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import sdk from '../../../index';
@ -23,7 +24,7 @@ import { _t } from '../../../languageHandler';
import AccessibleButton from '../elements/AccessibleButton';
export default React.createClass({
export default createReactClass({
displayName: 'InteractiveAuthDialog',
propTypes: {

View file

@ -16,6 +16,7 @@ limitations under the License.
import Modal from '../../../Modal';
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import sdk from '../../../index';
@ -29,7 +30,7 @@ import { _t, _td } from '../../../languageHandler';
* should not, and `undefined` if the dialog is cancelled. (In other words:
* truthy: do the key share. falsy: don't share the keys).
*/
export default React.createClass({
export default createReactClass({
propTypes: {
matrixClient: PropTypes.object.isRequired,
userId: PropTypes.string.isRequired,
@ -77,7 +78,7 @@ export default React.createClass({
true,
);
}
}).done();
});
},
componentWillUnmount: function() {

View file

@ -20,11 +20,12 @@ import sdk from '../../../index';
import dis from '../../../dispatcher';
import { _t } from '../../../languageHandler';
import MatrixClientPeg from '../../../MatrixClientPeg';
import SettingsStore from "../../../settings/SettingsStore";
export default class LogoutDialog extends React.Component {
defaultProps = {
onFinished: function() {},
}
};
constructor() {
super();
@ -34,9 +35,11 @@ export default class LogoutDialog extends React.Component {
this._onSetRecoveryMethodClick = this._onSetRecoveryMethodClick.bind(this);
this._onLogoutConfirm = this._onLogoutConfirm.bind(this);
const shouldLoadBackupStatus = !MatrixClientPeg.get().getKeyBackupEnabled();
const lowBandwidth = SettingsStore.getValue("lowBandwidth");
const shouldLoadBackupStatus = !lowBandwidth && !MatrixClientPeg.get().getKeyBackupEnabled();
this.state = {
shouldLoadBackupStatus: shouldLoadBackupStatus,
loading: shouldLoadBackupStatus,
backupInfo: null,
error: null,
@ -110,17 +113,17 @@ export default class LogoutDialog extends React.Component {
}
render() {
const description = <div>
<p>{_t(
"Encrypted messages are secured with end-to-end encryption. " +
"Only you and the recipient(s) have the keys to read these messages.",
)}</p>
<p>{_t("Back up your keys before signing out to avoid losing them.")}</p>
</div>;
if (!MatrixClientPeg.get().getKeyBackupEnabled()) {
if (this.state.shouldLoadBackupStatus) {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const description = <div>
<p>{_t(
"Encrypted messages are secured with end-to-end encryption. " +
"Only you and the recipient(s) have the keys to read these messages.",
)}</p>
<p>{_t("Back up your keys before signing out to avoid losing them.")}</p>
</div>;
let dialogContent;
if (this.state.loading) {
const Spinner = sdk.getComponent('views.elements.Spinner');

View file

@ -0,0 +1,171 @@
/*
Copyright 2019 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 MatrixClientPeg from "../../../MatrixClientPeg";
import { _t } from '../../../languageHandler';
import sdk from "../../../index";
import {wantsDateSeparator} from '../../../DateUtils';
import SettingsStore from '../../../settings/SettingsStore';
export default class MessageEditHistoryDialog extends React.PureComponent {
static propTypes = {
mxEvent: PropTypes.object.isRequired,
};
constructor(props) {
super(props);
this.state = {
originalEvent: null,
error: null,
events: [],
nextBatch: null,
isLoading: true,
isTwelveHour: SettingsStore.getValue("showTwelveHourTimestamps"),
};
}
loadMoreEdits = async (backwards) => {
if (backwards || (!this.state.nextBatch && !this.state.isLoading)) {
// bail out on backwards as we only paginate in one direction
return false;
}
const opts = {from: this.state.nextBatch};
const roomId = this.props.mxEvent.getRoomId();
const eventId = this.props.mxEvent.getId();
const client = MatrixClientPeg.get();
let result;
let resolve;
let reject;
const promise = new Promise((_resolve, _reject) => {resolve = _resolve; reject = _reject;});
try {
result = await client.relations(
roomId, eventId, "m.replace", "m.room.message", opts);
} catch (error) {
// log if the server returned an error
if (error.errcode) {
console.error("fetching /relations failed with error", error);
}
this.setState({error}, () => reject(error));
return promise;
}
const newEvents = result.events;
this._locallyRedactEventsIfNeeded(newEvents);
this.setState({
originalEvent: this.state.originalEvent || result.originalEvent,
events: this.state.events.concat(newEvents),
nextBatch: result.nextBatch,
isLoading: false,
}, () => {
const hasMoreResults = !!this.state.nextBatch;
resolve(hasMoreResults);
});
return promise;
}
_locallyRedactEventsIfNeeded(newEvents) {
const roomId = this.props.mxEvent.getRoomId();
const client = MatrixClientPeg.get();
const room = client.getRoom(roomId);
const pendingEvents = room.getPendingEvents();
for (const e of newEvents) {
const pendingRedaction = pendingEvents.find(pe => {
return pe.getType() === "m.room.redaction" && pe.getAssociatedId() === e.getId();
});
if (pendingRedaction) {
e.markLocallyRedacted(pendingRedaction);
}
}
}
componentDidMount() {
this.loadMoreEdits();
}
_renderEdits() {
const EditHistoryMessage = sdk.getComponent('messages.EditHistoryMessage');
const DateSeparator = sdk.getComponent('messages.DateSeparator');
const nodes = [];
let lastEvent;
let allEvents = this.state.events;
// append original event when we've done last pagination
if (this.state.originalEvent && !this.state.nextBatch) {
allEvents = allEvents.concat(this.state.originalEvent);
}
const baseEventId = this.props.mxEvent.getId();
allEvents.forEach((e, i) => {
if (!lastEvent || wantsDateSeparator(lastEvent.getDate(), e.getDate())) {
nodes.push(<li key={e.getTs() + "~"}><DateSeparator ts={e.getTs()} /></li>);
}
const isBaseEvent = e.getId() === baseEventId;
nodes.push((
<EditHistoryMessage
key={e.getId()}
previousEdit={!isBaseEvent ? allEvents[i + 1] : null}
isBaseEvent={isBaseEvent}
mxEvent={e}
isTwelveHour={this.state.isTwelveHour}
/>));
lastEvent = e;
});
return nodes;
}
render() {
let content;
if (this.state.error) {
const {error} = this.state;
if (error.errcode === "M_UNRECOGNIZED") {
content = (<p className="mx_MessageEditHistoryDialog_error">
{_t("Your homeserver doesn't seem to support this feature.")}
</p>);
} else if (error.errcode) {
// some kind of error from the homeserver
content = (<p className="mx_MessageEditHistoryDialog_error">
{_t("Something went wrong!")}
</p>);
} else {
content = (<p className="mx_MessageEditHistoryDialog_error">
{_t("Cannot reach homeserver")}
<br />
{_t("Ensure you have a stable internet connection, or get in touch with the server admin")}
</p>);
}
} else if (this.state.isLoading) {
const Spinner = sdk.getComponent("elements.Spinner");
content = <Spinner />;
} else {
const ScrollPanel = sdk.getComponent("structures.ScrollPanel");
content = (<ScrollPanel
className="mx_MessageEditHistoryDialog_scrollPanel"
onFillRequest={ this.loadMoreEdits }
stickyBottom={false}
startAtBottom={false}
>
<ul className="mx_MessageEditHistoryDialog_edits mx_MessagePanel_alwaysShowTimestamps">{this._renderEdits()}</ul>
</ScrollPanel>);
}
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return (
<BaseDialog className='mx_MessageEditHistoryDialog' hasCancel={true}
onFinished={this.props.onFinished} title={_t("Message edits")}>
{content}
</BaseDialog>
);
}
}

View file

@ -16,11 +16,12 @@ limitations under the License.
*/
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
export default React.createClass({
export default createReactClass({
displayName: 'QuestionDialog',
propTypes: {
title: PropTypes.string,

View file

@ -0,0 +1,137 @@
/*
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {PureComponent} from 'react';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import PropTypes from "prop-types";
import {MatrixEvent} from "matrix-js-sdk";
import MatrixClientPeg from "../../../MatrixClientPeg";
/*
* A dialog for reporting an event.
*/
export default class ReportEventDialog extends PureComponent {
static propTypes = {
mxEvent: PropTypes.instanceOf(MatrixEvent).isRequired,
onFinished: PropTypes.func.isRequired,
};
constructor(props, context) {
super(props, context);
this.state = {
reason: "",
busy: false,
err: null,
};
}
_onReasonChange = ({target: {value: reason}}) => {
this.setState({ reason });
};
_onCancel = () => {
this.props.onFinished(false);
};
_onSubmit = async () => {
if (!this.state.reason || !this.state.reason.trim()) {
this.setState({
err: _t("Please fill why you're reporting."),
});
return;
}
this.setState({
busy: true,
err: null,
});
try {
const ev = this.props.mxEvent;
await MatrixClientPeg.get().reportEvent(ev.getRoomId(), ev.getId(), -100, this.state.reason.trim());
this.props.onFinished(true);
} catch (e) {
this.setState({
busy: false,
err: e.message,
});
}
};
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const Loader = sdk.getComponent('elements.Spinner');
const Field = sdk.getComponent('elements.Field');
let error = null;
if (this.state.err) {
error = <div className="error">
{this.state.err}
</div>;
}
let progress = null;
if (this.state.busy) {
progress = (
<div className="progress">
<Loader />
</div>
);
}
return (
<BaseDialog
className="mx_BugReportDialog"
onFinished={this.props.onFinished}
title={_t('Report Content to Your Homeserver Administrator')}
contentId='mx_ReportEventDialog'
>
<div className="mx_ReportEventDialog" id="mx_ReportEventDialog">
<p>
{
_t("Reporting this message will send its unique 'event ID' to the administrator of " +
"your homeserver. If messages in this room are encrypted, your homeserver " +
"administrator will not be able to read the message text or view any files or images.")
}
</p>
<Field
id="mx_ReportEventDialog_reason"
className="mx_ReportEventDialog_reason"
element="textarea"
label={_t("Reason")}
rows={5}
onChange={this._onReasonChange}
value={this.state.reason}
disabled={this.state.busy}
/>
{progress}
{error}
</div>
<DialogButtons
primaryButton={_t("Send report")}
onPrimaryButtonClick={this._onSubmit}
focus={true}
onCancel={this._onCancel}
disabled={this.state.busy}
/>
</BaseDialog>
);
}
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -22,8 +23,10 @@ import AdvancedRoomSettingsTab from "../settings/tabs/room/AdvancedRoomSettingsT
import RolesRoomSettingsTab from "../settings/tabs/room/RolesRoomSettingsTab";
import GeneralRoomSettingsTab from "../settings/tabs/room/GeneralRoomSettingsTab";
import SecurityRoomSettingsTab from "../settings/tabs/room/SecurityRoomSettingsTab";
import NotificationSettingsTab from "../settings/tabs/room/NotificationSettingsTab";
import sdk from "../../../index";
import MatrixClientPeg from "../../../MatrixClientPeg";
import dis from "../../../dispatcher";
export default class RoomSettingsDialog extends React.Component {
static propTypes = {
@ -31,6 +34,22 @@ export default class RoomSettingsDialog extends React.Component {
onFinished: PropTypes.func.isRequired,
};
componentWillMount() {
this._dispatcherRef = dis.register(this._onAction);
}
componentWillUnmount() {
dis.unregister(this._dispatcherRef);
}
_onAction = (payload) => {
// When room changes below us, close the room settings
// whilst the modal is open this can only be triggered when someone hits Leave Room
if (payload.action === 'view_next_room') {
this.props.onFinished();
}
};
_getTabs() {
const tabs = [];
@ -49,6 +68,11 @@ export default class RoomSettingsDialog extends React.Component {
"mx_RoomSettingsDialog_rolesIcon",
<RolesRoomSettingsTab roomId={this.props.roomId} />,
));
tabs.push(new Tab(
_td("Notifications"),
"mx_RoomSettingsDialog_rolesIcon",
<NotificationSettingsTab roomId={this.props.roomId} />,
));
tabs.push(new Tab(
_td("Advanced"),
"mx_RoomSettingsDialog_warningIcon",

View file

@ -15,13 +15,14 @@ limitations under the License.
*/
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
import Modal from '../../../Modal';
import { _t } from '../../../languageHandler';
export default React.createClass({
export default createReactClass({
displayName: 'RoomUpgradeDialog',
propTypes: {
@ -92,7 +93,7 @@ export default React.createClass({
<p>
{_t(
"Upgrading this room requires closing down the current " +
"instance of the room and creating a new room it its place. " +
"instance of the room and creating a new room in its place. " +
"To give room members the best possible experience, we will:",
)}
</p>

View file

@ -16,6 +16,7 @@ limitations under the License.
*/
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import SdkConfig from '../../../SdkConfig';
@ -23,7 +24,7 @@ import Modal from '../../../Modal';
import { _t } from '../../../languageHandler';
export default React.createClass({
export default createReactClass({
displayName: 'SessionRestoreErrorDialog',
propTypes: {

View file

@ -16,6 +16,7 @@ limitations under the License.
*/
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import Email from '../../../email';
@ -29,7 +30,7 @@ import Modal from '../../../Modal';
*
* On success, `onFinished(true)` is called.
*/
export default React.createClass({
export default createReactClass({
displayName: 'SetEmailDialog',
propTypes: {
onFinished: PropTypes.func.isRequired,
@ -61,9 +62,7 @@ export default React.createClass({
return;
}
this._addThreepid = new AddThreepid();
// we always bind emails when registering, so let's do the
// same here.
this._addThreepid.addEmailAddress(emailAddress, true).done(() => {
this._addThreepid.addEmailAddress(emailAddress).then(() => {
Modal.createTrackedDialog('Verification Pending', '', QuestionDialog, {
title: _t("Verification Pending"),
description: _t(
@ -97,7 +96,7 @@ export default React.createClass({
},
verifyEmailAddress: function() {
this._addThreepid.checkEmailLinkClicked().done(() => {
this._addThreepid.checkEmailLinkClicked().then(() => {
this.props.onFinished(true);
}, (err) => {
this.setState({emailBusy: false});

View file

@ -15,8 +15,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import Promise from 'bluebird';
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
@ -34,7 +34,7 @@ const USERNAME_CHECK_DEBOUNCE_MS = 250;
*
* On success, `onFinished(true, newDisplayName)` is called.
*/
export default React.createClass({
export default createReactClass({
displayName: 'SetMxIdDialog',
propTypes: {
onFinished: PropTypes.func.isRequired,

View file

@ -1,6 +1,7 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,6 +17,8 @@ limitations under the License.
*/
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import Modal from '../../../Modal';
@ -60,10 +63,10 @@ const WarmFuzzy = function(props) {
*
* On success, `onFinished()` when finished
*/
export default React.createClass({
export default createReactClass({
displayName: 'SetPasswordDialog',
propTypes: {
onFinished: React.PropTypes.func.isRequired,
onFinished: PropTypes.func.isRequired,
},
getInitialState: function() {

View file

@ -20,7 +20,7 @@ import {Room, User, Group, RoomMember, MatrixEvent} from 'matrix-js-sdk';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import QRCode from 'qrcode-react';
import {RoomPermalinkCreator, makeGroupPermalink, makeUserPermalink} from "../../../matrix-to";
import {RoomPermalinkCreator, makeGroupPermalink, makeUserPermalink} from "../../../utils/permalinks/Permalinks";
import * as ContextualMenu from "../../structures/ContextualMenu";
const socials = [
@ -114,7 +114,8 @@ export default class ShareDialog extends React.Component {
top: y,
message: successful ? _t('Copied!') : _t('Failed to copy'),
}, false);
e.target.onmouseleave = close;
// Drop a reference to this close handler for componentWillUnmount
this.closeCopiedTooltip = e.target.onmouseleave = close;
}
onLinkSpecificEventCheckboxClick() {
@ -131,6 +132,12 @@ export default class ShareDialog extends React.Component {
}
}
componentWillUnmount() {
// if the Copied tooltip is open then get rid of it, there are ways to close the modal which wouldn't close
// the tooltip otherwise, such as pressing Escape or clicking X really quickly
if (this.closeCopiedTooltip) this.closeCopiedTooltip();
}
render() {
let title;
let matrixToUrl;
@ -146,7 +153,7 @@ export default class ShareDialog extends React.Component {
<input type="checkbox"
id="mx_ShareDialog_checkbox"
checked={this.state.linkSpecificEvent}
onClick={this.onLinkSpecificEventCheckboxClick} />
onChange={this.onLinkSpecificEventCheckboxClick} />
<label htmlFor="mx_ShareDialog_checkbox">
{ _t('Link to most recent message') }
</label>

View file

@ -0,0 +1,63 @@
/*
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import {_t} from "../../../languageHandler";
import {CommandCategories, CommandMap} from "../../../SlashCommands";
import sdk from "../../../index";
export default ({onFinished}) => {
const InfoDialog = sdk.getComponent('dialogs.InfoDialog');
const categories = {};
Object.values(CommandMap).forEach(cmd => {
if (!categories[cmd.category]) {
categories[cmd.category] = [];
}
categories[cmd.category].push(cmd);
});
const body = Object.values(CommandCategories).filter(c => categories[c]).map((category) => {
const rows = [
<tr key={"_category_" + category} className="mx_SlashCommandHelpDialog_headerRow">
<td colSpan={3}>
<h2>{_t(category)}</h2>
</td>
</tr>,
];
categories[category].forEach(cmd => {
rows.push(<tr key={cmd.command}>
<td><strong>{cmd.command}</strong></td>
<td>{cmd.args}</td>
<td>{cmd.description}</td>
</tr>);
});
return rows;
});
return <InfoDialog
className="mx_SlashCommandHelpDialog"
title={_t("Command Help")}
description={<table>
<tbody>
{body}
</tbody>
</table>}
hasCloseButton={true}
onFinished={onFinished} />;
};

View file

@ -0,0 +1,172 @@
/*
Copyright 2019 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 {IntegrationManagers} from "../../../integrations/IntegrationManagers";
import {Room} from "matrix-js-sdk";
import sdk from '../../../index';
import {dialogTermsInteractionCallback, TermsNotSignedError} from "../../../Terms";
import classNames from 'classnames';
import ScalarMessaging from "../../../ScalarMessaging";
export default class TabbedIntegrationManagerDialog extends React.Component {
static propTypes = {
/**
* Called with:
* * success {bool} True if the user accepted any douments, false if cancelled
* * agreedUrls {string[]} List of agreed URLs
*/
onFinished: PropTypes.func.isRequired,
/**
* Optional room where the integration manager should be open to
*/
room: PropTypes.instanceOf(Room),
/**
* Optional screen to open on the integration manager
*/
screen: PropTypes.string,
/**
* Optional integration ID to open in the integration manager
*/
integrationId: PropTypes.string,
};
constructor(props) {
super(props);
this.state = {
managers: IntegrationManagers.sharedInstance().getOrderedManagers(),
busy: true,
currentIndex: 0,
currentConnected: false,
currentLoading: true,
currentScalarClient: null,
};
}
componentDidMount(): void {
this.openManager(0, true);
}
openManager = async (i: number, force = false) => {
if (i === this.state.currentIndex && !force) return;
const manager = this.state.managers[i];
const client = manager.getScalarClient();
this.setState({
busy: true,
currentIndex: i,
currentLoading: true,
currentConnected: false,
currentScalarClient: client,
});
ScalarMessaging.setOpenManagerUrl(manager.uiUrl);
client.setTermsInteractionCallback((policyInfo, agreedUrls) => {
// To avoid visual glitching of two modals stacking briefly, we customise the
// terms dialog sizing when it will appear for the integration manager so that
// it gets the same basic size as the IM's own modal.
return dialogTermsInteractionCallback(
policyInfo, agreedUrls, 'mx_TermsDialog_forIntegrationManager',
);
});
try {
await client.connect();
if (!client.hasCredentials()) {
this.setState({
busy: false,
currentLoading: false,
currentConnected: false,
});
} else {
this.setState({
busy: false,
currentLoading: false,
currentConnected: true,
});
}
} catch (e) {
if (e instanceof TermsNotSignedError) {
return;
}
console.error(e);
this.setState({
busy: false,
currentLoading: false,
currentConnected: false,
});
}
};
_renderTabs() {
const AccessibleButton = sdk.getComponent("views.elements.AccessibleButton");
return this.state.managers.map((m, i) => {
const classes = classNames({
'mx_TabbedIntegrationManagerDialog_tab': true,
'mx_TabbedIntegrationManagerDialog_currentTab': this.state.currentIndex === i,
});
return (
<AccessibleButton
className={classes}
onClick={() => this.openManager(i)}
key={`tab_${i}`}
disabled={this.state.busy}
>
{m.name}
</AccessibleButton>
);
});
}
_renderTab() {
const IntegrationManager = sdk.getComponent("views.settings.IntegrationManager");
let uiUrl = null;
if (this.state.currentScalarClient) {
uiUrl = this.state.currentScalarClient.getScalarInterfaceUrlForRoom(
this.props.room,
this.props.screen,
this.props.integrationId,
);
}
return <IntegrationManager
configured={true}
loading={this.state.currentLoading}
connected={this.state.currentConnected}
url={uiUrl}
onFinished={() => {/* no-op */}}
/>;
}
render() {
return (
<div className='mx_TabbedIntegrationManagerDialog_container'>
<div className='mx_TabbedIntegrationManagerDialog_tabs'>
{this._renderTabs()}
</div>
<div className='mx_TabbedIntegrationManagerDialog_currentManager'>
{this._renderTab()}
</div>
</div>
);
}
}

View file

@ -0,0 +1,205 @@
/*
Copyright 2019 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 url from 'url';
import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import { _t, pickBestLanguage } from '../../../languageHandler';
import Matrix from 'matrix-js-sdk';
class TermsCheckbox extends React.PureComponent {
static propTypes = {
onChange: PropTypes.func.isRequired,
url: PropTypes.string.isRequired,
checked: PropTypes.bool.isRequired,
}
onChange = (ev) => {
this.props.onChange(this.props.url, ev.target.checked);
}
render() {
return <input type="checkbox"
onChange={this.onChange}
checked={this.props.checked}
/>;
}
}
export default class TermsDialog extends React.PureComponent {
static propTypes = {
/**
* Array of [Service, policies] pairs, where policies is the response from the
* /terms endpoint for that service
*/
policiesAndServicePairs: PropTypes.array.isRequired,
/**
* urls that the user has already agreed to
*/
agreedUrls: PropTypes.arrayOf(PropTypes.string),
/**
* Called with:
* * success {bool} True if the user accepted any douments, false if cancelled
* * agreedUrls {string[]} List of agreed URLs
*/
onFinished: PropTypes.func.isRequired,
}
constructor(props) {
super();
this.state = {
// url -> boolean
agreedUrls: {},
};
for (const url of props.agreedUrls) {
this.state.agreedUrls[url] = true;
}
}
_onCancelClick = () => {
this.props.onFinished(false);
}
_onNextClick = () => {
this.props.onFinished(true, Object.keys(this.state.agreedUrls).filter((url) => this.state.agreedUrls[url]));
}
_nameForServiceType(serviceType, host) {
switch (serviceType) {
case Matrix.SERVICE_TYPES.IS:
return <div>{_t("Identity Server")}<br />({host})</div>;
case Matrix.SERVICE_TYPES.IM:
return <div>{_t("Integration Manager")}<br />({host})</div>;
}
}
_summaryForServiceType(serviceType) {
switch (serviceType) {
case Matrix.SERVICE_TYPES.IS:
return <div>
{_t("Find others by phone or email")}
<br />
{_t("Be found by phone or email")}
</div>;
case Matrix.SERVICE_TYPES.IM:
return <div>
{_t("Use bots, bridges, widgets and sticker packs")}
</div>;
}
}
_onTermsCheckboxChange = (url, checked) => {
this.setState({
agreedUrls: Object.assign({}, this.state.agreedUrls, { [url]: checked }),
});
}
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const rows = [];
for (const policiesAndService of this.props.policiesAndServicePairs) {
const parsedBaseUrl = url.parse(policiesAndService.service.baseUrl);
const policyValues = Object.values(policiesAndService.policies);
for (let i = 0; i < policyValues.length; ++i) {
const termDoc = policyValues[i];
const termsLang = pickBestLanguage(Object.keys(termDoc).filter((k) => k !== 'version'));
let serviceName;
let summary;
if (i === 0) {
serviceName = this._nameForServiceType(policiesAndService.service.serviceType, parsedBaseUrl.host);
summary = this._summaryForServiceType(
policiesAndService.service.serviceType,
);
}
rows.push(<tr key={termDoc[termsLang].url}>
<td className="mx_TermsDialog_service">{serviceName}</td>
<td className="mx_TermsDialog_summary">{summary}</td>
<td>{termDoc[termsLang].name} <a rel="noopener" target="_blank" href={termDoc[termsLang].url}>
<span className="mx_TermsDialog_link" />
</a></td>
<td><TermsCheckbox
url={termDoc[termsLang].url}
onChange={this._onTermsCheckboxChange}
checked={Boolean(this.state.agreedUrls[termDoc[termsLang].url])}
/></td>
</tr>);
}
}
// if all the documents for at least one service have been checked, we can enable
// the submit button
let enableSubmit = false;
for (const policiesAndService of this.props.policiesAndServicePairs) {
let docsAgreedForService = 0;
for (const terms of Object.values(policiesAndService.policies)) {
let docAgreed = false;
for (const lang of Object.keys(terms)) {
if (lang === 'version') continue;
if (this.state.agreedUrls[terms[lang].url]) {
docAgreed = true;
break;
}
}
if (docAgreed) {
++docsAgreedForService;
}
}
if (docsAgreedForService === Object.keys(policiesAndService.policies).length) {
enableSubmit = true;
break;
}
}
return (
<BaseDialog
fixedWidth={false}
onFinished={this._onCancelClick}
title={_t("Terms of Service")}
contentId='mx_Dialog_content'
hasCancel={false}
>
<div id='mx_Dialog_content'>
<p>{_t("To continue you need to accept the terms of this service.")}</p>
<table className="mx_TermsDialog_termsTable"><tbody>
<tr className="mx_TermsDialog_termsTableHeader">
<th>{_t("Service")}</th>
<th>{_t("Summary")}</th>
<th>{_t("Document")}</th>
<th>{_t("Accept")}</th>
</tr>
{rows}
</tbody></table>
</div>
<DialogButtons primaryButton={_t('Next')}
hasCancel={true}
onCancel={this._onCancelClick}
onPrimaryButtonClick={this._onNextClick}
primaryDisabled={!enableSubmit}
/>
</BaseDialog>
);
}
}

View file

@ -15,10 +15,11 @@ limitations under the License.
*/
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import sdk from '../../../index';
export default React.createClass({
export default createReactClass({
displayName: 'TextInputDialog',
propTypes: {
title: PropTypes.string,

Some files were not shown because too many files have changed in this diff Show more