Merge branch 'develop' into matthew/low_bandwidth

This commit is contained in:
Travis Ralston 2019-05-30 19:42:09 -06:00
commit d81804e0fe
589 changed files with 37701 additions and 15344 deletions

View file

@ -114,10 +114,17 @@ export default class AutoHideScrollbar extends React.Component {
}
}
getScrollTop() {
return this.containerRef.scrollTop;
}
render() {
return (<div
ref={this._collectContainerRef}
style={this.props.style}
className={["mx_AutoHideScrollbar", this.props.className].join(" ")}
onScroll={this.props.onScroll}
onWheel={this.props.onWheel}
>
<div className="mx_AutoHideScrollbar_offset">
{ this.props.children }

View file

@ -1,197 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations 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 ReactDOM from 'react-dom';
import sdk from '../../index';
import dis from '../../dispatcher';
import Velocity from 'velocity-vector';
import 'velocity-vector/velocity.ui';
import SettingsStore from '../../settings/SettingsStore';
const CALLOUT_ANIM_DURATION = 1000;
module.exports = React.createClass({
displayName: 'BottomLeftMenu',
propTypes: {
collapsed: React.PropTypes.bool.isRequired,
},
getInitialState: function() {
return ({
directoryHover: false,
roomsHover: false,
homeHover: false,
peopleHover: false,
settingsHover: false,
});
},
componentWillMount: function() {
this._dispatcherRef = dis.register(this.onAction);
this._peopleButton = null;
this._directoryButton = null;
this._createRoomButton = null;
this._lastCallouts = {};
},
componentWillUnmount: function() {
dis.unregister(this._dispatcherRef);
},
// Room events
onDirectoryClick: function() {
dis.dispatch({ action: 'view_room_directory' });
},
onDirectoryMouseEnter: function() {
this.setState({ directoryHover: true });
},
onDirectoryMouseLeave: function() {
this.setState({ directoryHover: false });
},
onRoomsClick: function() {
dis.dispatch({ action: 'view_create_room' });
},
onRoomsMouseEnter: function() {
this.setState({ roomsHover: true });
},
onRoomsMouseLeave: function() {
this.setState({ roomsHover: false });
},
// Home button events
onHomeClick: function() {
dis.dispatch({ action: 'view_home_page' });
},
onHomeMouseEnter: function() {
this.setState({ homeHover: true });
},
onHomeMouseLeave: function() {
this.setState({ homeHover: false });
},
// People events
onPeopleClick: function() {
dis.dispatch({ action: 'view_create_chat' });
},
onPeopleMouseEnter: function() {
this.setState({ peopleHover: true });
},
onPeopleMouseLeave: function() {
this.setState({ peopleHover: false });
},
// Settings events
onSettingsClick: function() {
dis.dispatch({ action: 'view_user_settings' });
},
onSettingsMouseEnter: function() {
this.setState({ settingsHover: true });
},
onSettingsMouseLeave: function() {
this.setState({ settingsHover: false });
},
onAction: function(payload) {
let calloutElement;
switch (payload.action) {
// Incoming instruction: dance!
case 'callout_start_chat':
calloutElement = this._peopleButton;
break;
case 'callout_room_directory':
calloutElement = this._directoryButton;
break;
case 'callout_create_room':
calloutElement = this._createRoomButton;
break;
}
if (calloutElement) {
const lastCallout = this._lastCallouts[payload.action];
const now = Date.now();
if (lastCallout == undefined || lastCallout < now - CALLOUT_ANIM_DURATION) {
this._lastCallouts[payload.action] = now;
Velocity(ReactDOM.findDOMNode(calloutElement), "callout.bounce", CALLOUT_ANIM_DURATION);
}
}
},
// Get the label/tooltip to show
getLabel: function(label, show) {
if (show) {
const RoomTooltip = sdk.getComponent("rooms.RoomTooltip");
return <RoomTooltip className="mx_BottomLeftMenu_tooltip" label={label} />;
}
},
_collectPeopleButton: function(e) {
this._peopleButton = e;
},
_collectDirectoryButton: function(e) {
this._directoryButton = e;
},
_collectCreateRoomButton: function(e) {
this._createRoomButton = e;
},
render: function() {
const HomeButton = sdk.getComponent('elements.HomeButton');
const StartChatButton = sdk.getComponent('elements.StartChatButton');
const RoomDirectoryButton = sdk.getComponent('elements.RoomDirectoryButton');
const CreateRoomButton = sdk.getComponent('elements.CreateRoomButton');
const SettingsButton = sdk.getComponent('elements.SettingsButton');
const GroupsButton = sdk.getComponent('elements.GroupsButton');
const groupsButton = !SettingsStore.getValue("TagPanel.enableTagPanel") ?
<GroupsButton tooltip={true} /> : null;
return (
<div className="mx_BottomLeftMenu">
<div className="mx_BottomLeftMenu_options">
<HomeButton tooltip={true} />
<div ref={this._collectPeopleButton}>
<StartChatButton tooltip={true} />
</div>
<div ref={this._collectDirectoryButton}>
<RoomDirectoryButton tooltip={true} />
</div>
<div ref={this._collectCreateRoomButton}>
<CreateRoomButton tooltip={true} />
</div>
{ groupsButton }
<span className="mx_BottomLeftMenu_settings">
<SettingsButton tooltip={true} />
</span>
</div>
</div>
);
},
});

View file

@ -48,19 +48,13 @@ module.exports = React.createClass({
</p>
<p>
{ _t(
'Please install <chromeLink>Chrome</chromeLink> or <firefoxLink>Firefox</firefoxLink> ' +
'for the best experience.',
'Please install <chromeLink>Chrome</chromeLink>, <firefoxLink>Firefox</firefoxLink>, ' +
'or <safariLink>Safari</safariLink> for the best experience.',
{},
{
'chromeLink': (sub) => <a href="https://www.google.com/chrome">{sub}</a>,
'firefoxLink': (sub) => <a href="https://getfirefox.com">{sub}</a>,
},
)}
{ _t('<safariLink>Safari</safariLink> and <operaLink>Opera</operaLink> work too.',
{},
{
'safariLink': (sub) => <a href="http://apple.com/safari">{sub}</a>,
'operaLink': (sub) => <a href="http://opera.com">{sub}</a>,
'firefoxLink': (sub) => <a href="https://firefox.com">{sub}</a>,
'safariLink': (sub) => <a href="https://apple.com/safari">{sub}</a>,
},
)}
</p>

View file

@ -56,6 +56,7 @@ export default class ContextualMenu extends React.Component {
menuPaddingRight: PropTypes.number,
menuPaddingBottom: PropTypes.number,
menuPaddingLeft: PropTypes.number,
zIndex: PropTypes.number,
// If true, insert an invisible screen-sized element behind the
// menu that when clicked will close it.
@ -215,16 +216,22 @@ export default class ContextualMenu extends React.Component {
menuStyle["paddingRight"] = props.menuPaddingRight;
}
const wrapperStyle = {};
if (!isNaN(Number(props.zIndex))) {
menuStyle["zIndex"] = props.zIndex + 1;
wrapperStyle["zIndex"] = props.zIndex;
}
const ElementClass = props.elementClass;
// 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}>
return <div className={className} style={{...position, ...wrapperStyle}}>
<div className={menuClasses} style={menuStyle} ref={this.collectContextMenuRect}>
{ chevron }
<ElementClass {...props} onFinished={props.closeMenu} onResize={props.windowResize} />
</div>
{ props.hasBackground && <div className="mx_ContextualMenu_background"
{ props.hasBackground && <div className="mx_ContextualMenu_background" style={wrapperStyle}
onClick={props.closeMenu} onContextMenu={this.onContextMenu} /> }
<style>{ chevronCSS }</style>
</div>;

View file

@ -0,0 +1,125 @@
/*
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.
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 CustomRoomTagStore from '../../stores/CustomRoomTagStore';
import AutoHideScrollbar from './AutoHideScrollbar';
import sdk from '../../index';
import dis from '../../dispatcher';
import classNames from 'classnames';
import * as FormattingUtils from '../../utils/FormattingUtils';
class CustomRoomTagPanel extends React.Component {
constructor(props) {
super(props);
this.state = {
tags: CustomRoomTagStore.getSortedTags(),
};
}
componentWillMount() {
this._tagStoreToken = CustomRoomTagStore.addListener(() => {
this.setState({tags: CustomRoomTagStore.getSortedTags()});
});
}
componentWillUnmount() {
if (this._tagStoreToken) {
this._tagStoreToken.remove();
}
}
render() {
const tags = this.state.tags.map((tag) => {
return (<CustomRoomTagTile tag={tag} key={tag.name} />);
});
const classes = classNames('mx_CustomRoomTagPanel', {
mx_CustomRoomTagPanel_empty: this.state.tags.length === 0,
});
return (<div className={classes}>
<div className="mx_CustomRoomTagPanel_divider" />
<AutoHideScrollbar className="mx_CustomRoomTagPanel_scroller">
{tags}
</AutoHideScrollbar>
</div>);
}
}
class CustomRoomTagTile extends React.Component {
constructor(props) {
super(props);
this.state = {hover: false};
this.onClick = this.onClick.bind(this);
this.onMouseOut = this.onMouseOut.bind(this);
this.onMouseOver = this.onMouseOver.bind(this);
}
onMouseOver() {
this.setState({hover: true});
}
onMouseOut() {
this.setState({hover: false});
}
onClick() {
dis.dispatch({action: 'select_custom_room_tag', tag: this.props.tag.name});
}
render() {
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const Tooltip = sdk.getComponent('elements.Tooltip');
const tag = this.props.tag;
const avatarHeight = 40;
const className = classNames({
CustomRoomTagPanel_tileSelected: tag.selected,
});
const name = tag.name;
const badge = tag.badge;
let badgeElement;
if (badge) {
const badgeClasses = classNames({
"mx_TagTile_badge": true,
"mx_TagTile_badgeHighlight": badge.highlight,
});
badgeElement = (<div className={badgeClasses}>{FormattingUtils.formatCount(badge.count)}</div>);
}
const tip = (this.state.hover ?
<Tooltip className="mx_TagTile_tooltip" label={name} /> :
<div />);
return (
<AccessibleButton className={className} onClick={this.onClick}>
<div className="mx_TagTile_avatar" onMouseOver={this.onMouseOver} onMouseOut={this.onMouseOut}>
<BaseAvatar
name={tag.avatarLetter}
idName={name}
width={avatarHeight}
height={avatarHeight}
/>
{ badgeElement }
{ tip }
</div>
</AccessibleButton>
);
}
}
export default CustomRoomTagPanel;

View file

@ -1,6 +1,7 @@
/*
Copyright 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.
@ -26,22 +27,27 @@ import sdk from '../../index';
import { MatrixClient } from 'matrix-js-sdk';
import classnames from 'classnames';
class HomePage extends React.Component {
static displayName = 'HomePage';
export default class EmbeddedPage extends React.PureComponent {
static propTypes = {
// URL to use as the iFrame src. Defaults to /home.html.
homePageUrl: PropTypes.string,
// URL to request embedded page content from
url: PropTypes.string,
// Class name prefix to apply for a given instance
className: PropTypes.string,
// Whether to wrap the page in a scrollbar
scrollbar: PropTypes.bool,
};
static contextTypes = {
matrixClient: PropTypes.instanceOf(MatrixClient),
};
state = {
iframeSrc: '',
constructor(props) {
super(props);
this.state = {
page: '',
};
};
}
translate(s) {
// default implementation - skins may wish to extend this
@ -51,22 +57,24 @@ class HomePage extends React.Component {
componentWillMount() {
this._unmounted = false;
// we use request() to inline the homepage into the react component
if (!this.props.url) {
return;
}
// we use request() to inline the page into the react component
// so that it can inherit CSS and theming easily rather than mess around
// with iframes and trying to synchronise document.stylesheets.
const src = this.props.homePageUrl || 'home.html';
request(
{ method: "GET", url: src },
{ method: "GET", url: this.props.url },
(err, response, body) => {
if (this._unmounted) {
return;
}
if (err || response.status < 200 || response.status >= 300) {
console.warn(`Error loading home page: ${err}`);
this.setState({ page: _t("Couldn't load home page") });
console.warn(`Error loading page: ${err}`);
this.setState({ page: _t("Couldn't load page") });
return;
}
@ -81,28 +89,28 @@ class HomePage extends React.Component {
}
render() {
const isGuest = this.context.matrixClient.isGuest();
const client = this.context.matrixClient;
const isGuest = client ? client.isGuest() : true;
const className = this.props.className;
const classes = classnames({
mx_HomePage: true,
mx_HomePage_guest: isGuest,
[className]: true,
[`${className}_guest`]: isGuest,
});
if (this.state.iframeSrc) {
return (
<div className={classes}>
<iframe src={ this.state.iframeSrc } />
</div>
);
} else {
const content = <div className={`${className}_body`}
dangerouslySetInnerHTML={{ __html: this.state.page }}
>
</div>;
if (this.props.scrollbar) {
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
return (
<GeminiScrollbarWrapper autoshow={true} className={classes}>
<div className="mx_HomePage_body" dangerouslySetInnerHTML={{ __html: this.state.page }}>
</div>
</GeminiScrollbarWrapper>
);
return <GeminiScrollbarWrapper autoshow={true} className={classes}>
{content}
</GeminiScrollbarWrapper>;
} else {
return <div className={classes}>
{content}
</div>;
}
}
}
module.exports = HomePage;

View file

@ -123,6 +123,7 @@ const FilePanel = React.createClass({
timelineSet={this.state.timelineSet}
showUrlPreview = {false}
tileShape="file_grid"
resizeNotifier={this.props.resizeNotifier}
empty={_t('There are no visible files in this room')}
/>
);

View file

@ -0,0 +1,38 @@
/*
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.
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";
export default class GenericErrorPage extends React.PureComponent {
static propTypes = {
message: PropTypes.string.isRequired,
};
render() {
return <div className='mx_GenericErrorPage'>
<div className='mx_GenericErrorPage_box'>
<h1>{_t("Error loading Riot")}</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

@ -21,6 +21,7 @@ import Promise from 'bluebird';
import MatrixClientPeg from '../../MatrixClientPeg';
import sdk from '../../index';
import dis from '../../dispatcher';
import { getHostingLink } from '../../utils/HostingLink';
import { sanitizedHtmlNode } from '../../HtmlUtils';
import { _t, _td } from '../../languageHandler';
import AccessibleButton from '../views/elements/AccessibleButton';
@ -34,6 +35,7 @@ import GroupStore from '../../stores/GroupStore';
import FlairStore from '../../stores/FlairStore';
import { showGroupAddRoomDialog } from '../../GroupAddressPicker';
import {makeGroupPermalink, makeUserPermalink} from "../../matrix-to";
import {Group} from "matrix-js-sdk";
const LONG_DESC_PLACEHOLDER = _td(
`<h1>HTML for your community's page</h1>
@ -263,7 +265,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,
@ -569,7 +571,7 @@ export default React.createClass({
_onShareClick: function() {
const ShareDialog = sdk.getComponent("dialogs.ShareDialog");
Modal.createTrackedDialog('share community dialog', '', ShareDialog, {
target: this._matrixClient.getGroup(this.props.groupId),
target: this._matrixClient.getGroup(this.props.groupId) || new Group(this.props.groupId),
});
},
@ -815,6 +817,23 @@ export default React.createClass({
});
const header = this.state.editing ? <h2> { _t('Community Settings') } </h2> : <div />;
const hostingSignupLink = getHostingLink('community-settings');
let hostingSignup = null;
if (hostingSignupLink && this.state.isUserPrivileged) {
hostingSignup = <div className="mx_GroupView_hostingSignup">
{_t(
"Want more than a community? <a>Get your own server</a>", {},
{
a: sub => <a href={hostingSignupLink} target="_blank" rel="noopener">{sub}</a>,
},
)}
<a href={hostingSignupLink} target="_blank" rel="noopener">
<img src={require("../../../res/img/external-link.svg")} width="11" height="10" alt='' />
</a>
</div>;
}
const changeDelayWarning = this.state.editing && this.state.isUserPrivileged ?
<div className="mx_GroupView_changeDelayWarning">
{ _t(
@ -829,6 +848,7 @@ export default React.createClass({
</div> : <div />;
return <div className={groupSettingsSectionClasses}>
{ header }
{ hostingSignup }
{ changeDelayWarning }
{ this._getJoinableNode() }
{ this._getLongDescriptionNode() }
@ -1157,7 +1177,6 @@ export default React.createClass({
render: function() {
const GroupAvatar = sdk.getComponent("avatars.GroupAvatar");
const Spinner = sdk.getComponent("elements.Spinner");
const TintableSvg = sdk.getComponent("elements.TintableSvg");
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
if (this.state.summaryLoading && this.state.error === null || this.state.saving) {
@ -1248,13 +1267,17 @@ export default React.createClass({
if (this.state.editing) {
rightButtons.push(
<AccessibleButton className="mx_GroupView_textButton mx_RoomHeader_textButton"
onClick={this._onSaveClick} key="_saveButton"
key="_saveButton"
onClick={this._onSaveClick}
>
{ _t('Save') }
</AccessibleButton>,
);
rightButtons.push(
<AccessibleButton className="mx_RoomHeader_cancelButton" onClick={this._onCancelClick} key="_cancelButton">
<AccessibleButton className="mx_RoomHeader_cancelButton"
key="_cancelButton"
onClick={this._onCancelClick}
>
<img src={require("../../../res/img/cancel.svg")} className="mx_filterFlipColor"
width="18" height="18" alt={_t("Cancel")} />
</AccessibleButton>,
@ -1262,16 +1285,20 @@ export default React.createClass({
} else {
if (summary.user && summary.user.membership === 'join') {
rightButtons.push(
<AccessibleButton className="mx_GroupHeader_button"
onClick={this._onEditClick} title={_t("Community Settings")} key="_editButton"
<AccessibleButton className="mx_GroupHeader_button mx_GroupHeader_editButton"
key="_editButton"
onClick={this._onEditClick}
title={_t("Community Settings")}
>
<TintableSvg src={require("../../../res/img/icons-settings-room.svg")} width="16" height="16" />
</AccessibleButton>,
);
}
rightButtons.push(
<AccessibleButton className="mx_GroupHeader_button" onClick={this._onShareClick} title={_t('Share Community')} key="_shareButton">
<TintableSvg src={require("../../../res/img/icons-share.svg")} width="16" height="16" />
<AccessibleButton className="mx_GroupHeader_button mx_GroupHeader_shareButton"
key="_shareButton"
onClick={this._onShareClick}
title={_t('Share Community')}
>
</AccessibleButton>,
);
}

View file

@ -15,9 +15,22 @@ limitations under the License.
*/
import React from "react";
import PropTypes from "prop-types";
import AutoHideScrollbar from "./AutoHideScrollbar";
export default class IndicatorScrollbar extends React.Component {
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.
trackHorizontalOverflow: PropTypes.bool,
// If true, when the user tries to use their mouse wheel in the component it will
// scroll horizontally rather than vertically. This should only be used on components
// with no vertical scroll opportunity.
verticalScrollsHorizontally: PropTypes.bool,
};
constructor(props) {
super(props);
this._collectScroller = this._collectScroller.bind(this);
@ -25,6 +38,18 @@ export default class IndicatorScrollbar extends React.Component {
this.checkOverflow = this.checkOverflow.bind(this);
this._scrollElement = null;
this._autoHideScrollbar = null;
this.state = {
leftIndicatorOffset: 0,
rightIndicatorOffset: 0,
};
}
moveToOrigin() {
if (!this._scrollElement) return;
this._scrollElement.scrollLeft = 0;
this._scrollElement.scrollTop = 0;
}
_collectScroller(scroller) {
@ -43,6 +68,10 @@ export default class IndicatorScrollbar extends React.Component {
const hasTopOverflow = this._scrollElement.scrollTop > 0;
const hasBottomOverflow = this._scrollElement.scrollHeight >
(this._scrollElement.scrollTop + this._scrollElement.clientHeight);
const hasLeftOverflow = this._scrollElement.scrollLeft > 0;
const hasRightOverflow = this._scrollElement.scrollWidth >
(this._scrollElement.scrollLeft + this._scrollElement.clientWidth);
if (hasTopOverflow) {
this._scrollElement.classList.add("mx_IndicatorScrollbar_topOverflow");
} else {
@ -53,10 +82,34 @@ export default class IndicatorScrollbar extends React.Component {
} else {
this._scrollElement.classList.remove("mx_IndicatorScrollbar_bottomOverflow");
}
if (hasLeftOverflow) {
this._scrollElement.classList.add("mx_IndicatorScrollbar_leftOverflow");
} else {
this._scrollElement.classList.remove("mx_IndicatorScrollbar_leftOverflow");
}
if (hasRightOverflow) {
this._scrollElement.classList.add("mx_IndicatorScrollbar_rightOverflow");
} else {
this._scrollElement.classList.remove("mx_IndicatorScrollbar_rightOverflow");
}
if (this._autoHideScrollbar) {
this._autoHideScrollbar.checkOverflow();
}
if (this.props.trackHorizontalOverflow) {
this.setState({
// Offset from absolute position of the container
leftIndicatorOffset: hasLeftOverflow ? `${this._scrollElement.scrollLeft}px` : '0',
// Negative because we're coming from the right
rightIndicatorOffset: hasRightOverflow ? `-${this._scrollElement.scrollLeft}px` : '0',
});
}
}
getScrollTop() {
return this._autoHideScrollbar.getScrollTop();
}
componentWillUnmount() {
@ -65,13 +118,41 @@ export default class IndicatorScrollbar extends React.Component {
}
}
onMouseWheel = (e) => {
if (this.props.verticalScrollsHorizontally && this._scrollElement) {
// xyThreshold is the amount of horizontal motion required for the component to
// ignore the vertical delta in a scroll. Used to stop trackpads from acting in
// strange ways. Should be positive.
const xyThreshold = 0;
// yRetention is the factor multiplied by the vertical delta to try and reduce
// the harshness of the scroll behaviour. Should be a value between 0 and 1.
const yRetention = 1.0;
if (Math.abs(e.deltaX) <= xyThreshold) {
// noinspection JSSuspiciousNameCombination
this._scrollElement.scrollLeft += e.deltaY * yRetention;
}
}
};
render() {
const leftIndicatorStyle = {left: this.state.leftIndicatorOffset};
const rightIndicatorStyle = {right: this.state.rightIndicatorOffset};
const leftOverflowIndicator = this.props.trackHorizontalOverflow
? <div className="mx_IndicatorScrollbar_leftOverflowIndicator" style={leftIndicatorStyle} /> : null;
const rightOverflowIndicator = this.props.trackHorizontalOverflow
? <div className="mx_IndicatorScrollbar_rightOverflowIndicator" style={rightIndicatorStyle} /> : null;
return (<AutoHideScrollbar
ref={this._collectScrollerComponent}
wrappedRef={this._collectScroller}
onWheel={this.onMouseWheel}
{... this.props}
>
{ leftOverflowIndicator }
{ this.props.children }
{ rightOverflowIndicator }
</AutoHideScrollbar>);
}
}

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.
@ -60,7 +61,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,
@ -96,6 +97,7 @@ export default React.createClass({
sessionId: this.props.sessionId,
clientSecret: this.props.clientSecret,
emailSid: this.props.emailSid,
requestEmailToken: this.props.requestEmailToken,
});
this._authLogic.attemptAuth().then((result) => {
@ -202,7 +204,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

@ -24,8 +24,10 @@ import { KeyCode } from '../../Keyboard';
import sdk from '../../index';
import dis from '../../dispatcher';
import VectorConferenceHandler from '../../VectorConferenceHandler';
import TagPanelButtons from './TagPanelButtons';
import SettingsStore from '../../settings/SettingsStore';
import {_t} from "../../languageHandler";
import Analytics from "../../Analytics";
const LeftPanel = React.createClass({
@ -44,11 +46,23 @@ const LeftPanel = React.createClass({
getInitialState: function() {
return {
searchFilter: '',
breadcrumbs: false,
};
},
componentWillMount: function() {
this.focusedElement = null;
this._settingWatchRef = SettingsStore.watchSetting(
"feature_room_breadcrumbs", null, this._onBreadcrumbsChanged);
const useBreadcrumbs = SettingsStore.isFeatureEnabled("feature_room_breadcrumbs");
Analytics.setBreadcrumbs(useBreadcrumbs);
this.setState({breadcrumbs: useBreadcrumbs});
},
componentWillUnmount: function() {
SettingsStore.unwatchSetting(this._settingWatchRef);
},
shouldComponentUpdate: function(nextProps, nextState) {
@ -72,6 +86,22 @@ const LeftPanel = React.createClass({
return false;
},
componentDidUpdate(prevProps, prevState) {
if (prevState.breadcrumbs !== this.state.breadcrumbs) {
Analytics.setBreadcrumbs(this.state.breadcrumbs);
}
},
_onBreadcrumbsChanged: function(settingName, roomId, level, valueAtLevel, value) {
// Features are only possible at a single level, so we can get away with using valueAtLevel.
// The SettingsStore runs on the same tick as the update, so `value` will be wrong.
this.setState({breadcrumbs: valueAtLevel});
// For some reason the setState doesn't trigger a render of the component, so force one.
// Probably has to do with the change happening outside of a change detector cycle.
this.forceUpdate();
},
_onFocus: function(ev) {
this.focusedElement = ev.target;
},
@ -182,13 +212,25 @@ const LeftPanel = React.createClass({
render: function() {
const RoomList = sdk.getComponent('rooms.RoomList');
const RoomBreadcrumbs = sdk.getComponent('rooms.RoomBreadcrumbs');
const TagPanel = sdk.getComponent('structures.TagPanel');
const CustomRoomTagPanel = sdk.getComponent('structures.CustomRoomTagPanel');
const TopLeftMenuButton = sdk.getComponent('structures.TopLeftMenuButton');
const SearchBox = sdk.getComponent('structures.SearchBox');
const CallPreview = sdk.getComponent('voip.CallPreview');
const tagPanelEnabled = SettingsStore.getValue("TagPanel.enableTagPanel");
const tagPanel = tagPanelEnabled ? <TagPanel /> : <div />;
let tagPanelContainer;
const isCustomTagsEnabled = SettingsStore.isFeatureEnabled("feature_custom_tags");
if (tagPanelEnabled) {
tagPanelContainer = (<div className="mx_LeftPanel_tagPanelContainer">
<TagPanel />
{ isCustomTagsEnabled ? <CustomRoomTagPanel /> : undefined }
<TagPanelButtons />
</div>);
}
const containerClasses = classNames(
"mx_LeftPanel_container", "mx_fadable",
@ -199,27 +241,35 @@ const LeftPanel = React.createClass({
},
);
const searchBox = !this.props.collapsed ?
<SearchBox onSearch={ this.onSearch } onCleared={ this.onSearchCleared } /> :
undefined;
const searchBox = (<SearchBox
enableRoomSearchFocus={true}
placeholder={ _t('Filter room names') }
onSearch={ this.onSearch }
onCleared={ this.onSearchCleared }
collapsed={this.props.collapsed} />);
let breadcrumbs;
if (this.state.breadcrumbs) {
breadcrumbs = (<RoomBreadcrumbs collapsed={this.props.collapsed} />);
}
return (
<div className={containerClasses}>
{ tagPanel }
{ tagPanelContainer }
<aside className={"mx_LeftPanel dark-panel"} onKeyDown={ this._onKeyDown } onFocus={ this._onFocus } onBlur={ this._onBlur }>
<TopLeftMenuButton collapsed={ this.props.collapsed } />
{ breadcrumbs }
{ searchBox }
<CallPreview ConferenceHandler={VectorConferenceHandler} />
<RoomList
ref={this.collectRoomList}
toolbarShown={this.props.toolbarShown}
resizeNotifier={this.props.resizeNotifier}
collapsed={this.props.collapsed}
searchFilter={this.state.searchFilter}
ConferenceHandler={VectorConferenceHandler} />
</aside>
</div>
);
// <BottomLeftMenu collapsed={this.props.collapsed}/>
},
});

View file

@ -22,15 +22,16 @@ import PropTypes from 'prop-types';
import { DragDropContext } from 'react-beautiful-dnd';
import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard';
import Notifier from '../../Notifier';
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';
import MatrixClientPeg from '../../MatrixClientPeg';
import SettingsStore from "../../settings/SettingsStore";
import RoomListStore from "../../stores/RoomListStore";
import { getHomePageUrl } from '../../utils/pages';
import TagOrderActions from '../../actions/TagOrderActions';
import RoomListActions from '../../actions/RoomListActions';
@ -57,7 +58,6 @@ const LoggedInView = React.createClass({
matrixClient: PropTypes.instanceOf(Matrix.MatrixClient).isRequired,
page_type: PropTypes.string.isRequired,
onRoomCreated: PropTypes.func,
onUserSettingsClose: PropTypes.func,
// Called with the credentials of a registered user (if they were a ROU that
// transitioned to PWLU)
@ -119,6 +119,20 @@ 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) {
// attempt to guess when a banner was opened or closed
if (
(prevProps.showCookieBar !== this.props.showCookieBar) ||
(prevProps.hasNewVersion !== this.props.hasNewVersion) ||
(prevProps.userHasGeneratedPassword !== this.props.userHasGeneratedPassword) ||
(prevProps.showNotifierToolbar !== this.props.showNotifierToolbar)
) {
this.props.resizeNotifier.notifyBannersChanged();
}
},
componentWillUnmount: function() {
@ -173,6 +187,7 @@ const LoggedInView = React.createClass({
},
onResized: (size) => {
window.localStorage.setItem("mx_lhs_size", '' + size);
this.props.resizeNotifier.notifyLeftHandleResized();
},
};
const resizer = new Resizer(
@ -310,6 +325,18 @@ const LoggedInView = React.createClass({
handled = true;
}
break;
case KeyCode.KEY_I:
// Ideally this would be CTRL+P for "Profile", but that's
// taken by the print dialog. CTRL+I for "Information"
// will have to do.
if (ctrlCmdOnly) {
dis.dispatch({
action: 'toggle_top_left_menu',
});
handled = true;
}
break;
}
if (handled) {
@ -421,8 +448,8 @@ const LoggedInView = React.createClass({
render: function() {
const LeftPanel = sdk.getComponent('structures.LeftPanel');
const RoomView = sdk.getComponent('structures.RoomView');
const UserSettings = sdk.getComponent('structures.UserSettings');
const HomePage = sdk.getComponent('structures.HomePage');
const UserView = sdk.getComponent('structures.UserView');
const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage');
const GroupView = sdk.getComponent('structures.GroupView');
const MyGroups = sdk.getComponent('structures.MyGroups');
const MatrixToolbar = sdk.getComponent('globals.MatrixToolbar');
@ -448,16 +475,10 @@ const LoggedInView = React.createClass({
disabled={this.props.middleDisabled}
collapsedRhs={this.props.collapsedRhs}
ConferenceHandler={this.props.ConferenceHandler}
resizeNotifier={this.props.resizeNotifier}
/>;
break;
case PageTypes.UserSettings:
pageElement = <UserSettings
onClose={this.props.onCloseAllSettings}
brand={this.props.config.brand}
/>;
break;
case PageTypes.MyGroups:
pageElement = <MyGroups />;
break;
@ -468,16 +489,16 @@ const LoggedInView = React.createClass({
case PageTypes.HomePage:
{
pageElement = <HomePage
homePageUrl={this.props.config.welcomePageUrl}
const pageUrl = getHomePageUrl(this.props.config);
pageElement = <EmbeddedPage className="mx_HomePage"
url={pageUrl}
scrollbar={true}
/>;
}
break;
case PageTypes.UserView:
pageElement = null; // deliberately null for now
// TODO: fix/remove UserView
// right_panel = <RightPanel disabled={this.props.rightDisabled} />;
pageElement = <UserView userId={this.props.currentUserId} />;
break;
case PageTypes.GroupView:
pageElement = <GroupView
@ -496,7 +517,6 @@ const LoggedInView = React.createClass({
});
let topBar;
const isGuest = this.props.matrixClient.isGuest();
if (this.state.syncErrorData && this.state.syncErrorData.error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
topBar = <ServerLimitBar kind='hard'
adminContact={this.state.syncErrorData.error.data.admin_contact}
@ -520,10 +540,7 @@ const LoggedInView = React.createClass({
topBar = <UpdateCheckBar {...this.props.checkingForUpdate} />;
} else if (this.state.userHasGeneratedPassword) {
topBar = <PasswordNagBar />;
} else if (
!isGuest && Notifier.supportsDesktopNotifications() &&
!Notifier.isEnabled() && !Notifier.isToolbarHidden()
) {
} else if (this.props.showNotifierToolbar) {
topBar = <MatrixToolbar />;
}
@ -541,7 +558,7 @@ const LoggedInView = React.createClass({
<DragDropContext onDragEnd={this._onDragEnd}>
<div ref={this._setResizeContainerRef} className={bodyClasses}>
<LeftPanel
toolbarShown={!!topBar}
resizeNotifier={this.props.resizeNotifier}
collapsed={this.props.collapseLhs || false}
disabled={this.props.leftDisabled}
/>

View file

@ -27,6 +27,9 @@ export default class MainSplit extends React.Component {
_onResized(size) {
window.localStorage.setItem("mx_rhs_size", size);
if (this.props.resizeNotifier) {
this.props.resizeNotifier.notifyRightHandleResized();
}
}
_createResizer() {

View file

@ -29,6 +29,7 @@ import PlatformPeg from "../../PlatformPeg";
import SdkConfig from "../../SdkConfig";
import * as RoomListSorter from "../../RoomListSorter";
import dis from "../../dispatcher";
import Notifier from '../../Notifier';
import Modal from "../../Modal";
import Tinter from "../../Tinter";
@ -40,6 +41,7 @@ import * as Lifecycle from '../../Lifecycle';
// LifecycleStore is not used but does listen to and dispatch actions
require('../../stores/LifecycleStore');
import PageTypes from '../../PageTypes';
import { getHomePageUrl } from '../../utils/pages';
import createRoom from "../../createRoom";
import KeyRequestHandler from '../../KeyRequestHandler';
@ -47,8 +49,8 @@ import { _t, getCurrentLanguage } from '../../languageHandler';
import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
import { startAnyRegistrationFlow } from "../../Registration.js";
import { messageForSyncError } from '../../utils/ErrorUtils';
const AutoDiscovery = Matrix.AutoDiscovery;
import ResizeNotifier from "../../utils/ResizeNotifier";
import {ValidatedServerConfig} from "../../utils/AutoDiscoveryUtils";
// Disable warnings for now: we use deprecated bluebird functions
// and need to migrate, but they spam the console with warnings.
@ -60,27 +62,30 @@ const VIEWS = {
// trying to re-animate a matrix client or register as a guest.
LOADING: 0,
// we are showing the welcome view
WELCOME: 1,
// we are showing the login view
LOGIN: 1,
LOGIN: 2,
// we are showing the registration view
REGISTER: 2,
REGISTER: 3,
// completeing the registration flow
POST_REGISTRATION: 3,
POST_REGISTRATION: 4,
// showing the 'forgot password' view
FORGOT_PASSWORD: 4,
FORGOT_PASSWORD: 5,
// we have valid matrix credentials (either via an explicit login, via the
// initial re-animation/guest registration, or via a registration), and are
// now setting up a matrixclient to talk to it. This isn't an instant
// process because we need to clear out indexeddb. While it is going on we
// show a big spinner.
LOGGING_IN: 5,
LOGGING_IN: 6,
// we are logged in with an active matrix client.
LOGGED_IN: 6,
LOGGED_IN: 7,
};
// Actions that are redirected through the onboarding process prior to being
@ -103,6 +108,7 @@ export default React.createClass({
propTypes: {
config: PropTypes.object,
serverConfig: PropTypes.instanceOf(ValidatedServerConfig),
ConferenceHandler: PropTypes.any,
onNewScreen: PropTypes.func,
registrationUrl: PropTypes.string,
@ -136,10 +142,6 @@ export default React.createClass({
appConfig: PropTypes.object,
},
AuxPanel: {
RoomSettings: "room_settings",
},
getChildContext: function() {
return {
appConfig: this.props.config,
@ -179,21 +181,15 @@ export default React.createClass({
// Parameters used in the registration dance with the IS
register_client_secret: null,
register_session_id: null,
register_hs_url: null,
register_is_url: null,
register_id_sid: null,
// Parameters used for setting up the authentication views
defaultServerName: this.props.config.default_server_name,
defaultHsUrl: this.props.config.default_hs_url,
defaultIsUrl: this.props.config.default_is_url,
defaultServerDiscoveryError: null,
// When showing Modal dialogs we need to set aria-hidden on the root app element
// and disable it when there are no dialogs
hideToSRUsers: false,
syncError: null, // If the current syncing status is ERROR, the error object, otherwise null.
resizeNotifier: new ResizeNotifier(),
showNotifierToolbar: false,
};
return s;
},
@ -207,42 +203,19 @@ export default React.createClass({
};
},
getDefaultServerName: function() {
return this.state.defaultServerName;
},
getCurrentHsUrl: function() {
if (this.state.register_hs_url) {
return this.state.register_hs_url;
} else if (MatrixClientPeg.get()) {
return MatrixClientPeg.get().getHomeserverUrl();
} else {
return this.getDefaultHsUrl();
}
},
getDefaultHsUrl(defaultToMatrixDotOrg) {
defaultToMatrixDotOrg = typeof(defaultToMatrixDotOrg) !== 'boolean' ? true : defaultToMatrixDotOrg;
if (!this.state.defaultHsUrl && defaultToMatrixDotOrg) return "https://matrix.org";
return this.state.defaultHsUrl;
},
getFallbackHsUrl: function() {
return this.props.config.fallback_hs_url;
},
getCurrentIsUrl: function() {
if (this.state.register_is_url) {
return this.state.register_is_url;
} else if (MatrixClientPeg.get()) {
return MatrixClientPeg.get().getIdentityServerUrl();
if (this.props.serverConfig && this.props.serverConfig.isDefault) {
return this.props.config.fallback_hs_url;
} else {
return this.getDefaultIsUrl();
return null;
}
},
getDefaultIsUrl() {
return this.state.defaultIsUrl || "https://vector.im";
getServerProperties() {
let props = this.state.serverConfig;
if (!props) props = this.props.serverConfig; // for unit tests
if (!props) props = SdkConfig.get()["validated_server_config"];
return {serverConfig: props};
},
componentWillMount: function() {
@ -256,40 +229,6 @@ export default React.createClass({
MatrixClientPeg.opts.initialSyncLimit = this.props.config.sync_timeline_limit;
}
// Set up the default URLs (async)
if (this.getDefaultServerName() && !this.getDefaultHsUrl(false)) {
this.setState({loadingDefaultHomeserver: true});
this._tryDiscoverDefaultHomeserver(this.getDefaultServerName());
} else if (this.getDefaultServerName() && this.getDefaultHsUrl(false)) {
// Ideally we would somehow only communicate this to the server admins, but
// given this is at login time we can't really do much besides hope that people
// will check their settings.
this.setState({
defaultServerName: null, // To un-hide any secrets people might be keeping
defaultServerDiscoveryError: _t(
"Invalid configuration: Cannot supply a default homeserver URL and " +
"a default server name",
),
});
}
// Set a default HS with query param `hs_url`
const paramHs = this.props.startingFragmentQueryParams.hs_url;
if (paramHs) {
console.log('Setting register_hs_url ', paramHs);
this.setState({
register_hs_url: paramHs,
});
}
// Set a default IS with query param `is_url`
const paramIs = this.props.startingFragmentQueryParams.is_url;
if (paramIs) {
console.log('Setting register_is_url ', paramIs);
this.setState({
register_is_url: paramIs,
});
}
// a thing to call showScreen with once login completes. this is kept
// outside this.state because updating it should never trigger a
// rerender.
@ -305,6 +244,9 @@ export default React.createClass({
// N.B. we don't call the whole of setTheme() here as we may be
// racing with the theme CSS download finishing from index.js
Tinter.tint();
// For PersistentElement
this.state.resizeNotifier.on("middlePanelResized", this._dispatchTimelineResize);
},
componentDidMount: function() {
@ -346,25 +288,7 @@ export default React.createClass({
return;
}
// the extra Promise.resolve() ensures that synchronous exceptions hit the same codepath as
// asynchronous ones.
return Promise.resolve().then(() => {
return Lifecycle.loadSession({
fragmentQueryParams: this.props.startingFragmentQueryParams,
enableGuest: this.props.enableGuest,
guestHsUrl: this.getCurrentHsUrl(),
guestIsUrl: this.getCurrentIsUrl(),
defaultDeviceDisplayName: this.props.defaultDeviceDisplayName,
});
}).then((loadedSession) => {
if (!loadedSession) {
// fall back to showing the login screen
dis.dispatch({action: "start_login"});
}
});
// Note we don't catch errors from this: we catch everything within
// loadSession as there's logic there to ask the user if they want
// to try logging out.
return this._loadSession();
});
if (SettingsStore.getValue("showCookieBar")) {
@ -378,11 +302,34 @@ export default React.createClass({
}
},
_loadSession: function() {
// the extra Promise.resolve() ensures that synchronous exceptions hit the same codepath as
// asynchronous ones.
return Promise.resolve().then(() => {
return Lifecycle.loadSession({
fragmentQueryParams: this.props.startingFragmentQueryParams,
enableGuest: this.props.enableGuest,
guestHsUrl: this.getServerProperties().serverConfig.hsUrl,
guestIsUrl: this.getServerProperties().serverConfig.isUrl,
defaultDeviceDisplayName: this.props.defaultDeviceDisplayName,
});
}).then((loadedSession) => {
if (!loadedSession) {
// fall back to showing the welcome screen
dis.dispatch({action: "view_welcome_page"});
}
});
// Note we don't catch errors from this: we catch everything within
// loadSession as there's logic there to ask the user if they want
// to try logging out.
},
componentWillUnmount: function() {
Lifecycle.stopMatrixClient();
dis.unregister(this.dispatcherRef);
window.removeEventListener("focus", this.onFocus);
window.removeEventListener('resize', this.handleResize);
this.state.resizeNotifier.removeListener("middlePanelResized", this._dispatchTimelineResize);
},
componentWillUpdate: function(props, state) {
@ -541,19 +488,8 @@ export default React.createClass({
},
});
break;
case 'view_user':
// FIXME: ugly hack to expand the RightPanel and then re-dispatch.
if (this.state.collapsedRhs) {
setTimeout(()=>{
dis.dispatch({
action: 'show_right_panel',
});
dis.dispatch({
action: 'view_user',
member: payload.member,
});
}, 0);
}
case 'view_user_info':
this._viewUser(payload.userId, payload.subAction);
break;
case 'view_room':
// Takes either a room ID or room alias: if switching to a room the client is already
@ -572,40 +508,14 @@ export default React.createClass({
this._viewIndexedRoom(payload.roomIndex);
break;
case 'view_user_settings': {
if (true || SettingsStore.isFeatureEnabled("feature_tabbed_settings")) {
const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog");
Modal.createTrackedDialog('User settings', '', UserSettingsDialog, {}, 'mx_SettingsDialog');
} else {
this._setPage(PageTypes.UserSettings);
this.notifyNewScreen('settings');
}
const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog");
Modal.createTrackedDialog('User settings', '', UserSettingsDialog, {},
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
// View the welcome or home page if we need something to look at
this._viewSomethingBehindModal();
break;
}
case 'view_old_user_settings':
this._setPage(PageTypes.UserSettings);
this.notifyNewScreen('settings');
break;
case 'close_settings':
this.setState({
leftDisabled: false,
rightDisabled: false,
middleDisabled: false,
});
if (this.state.page_type === PageTypes.UserSettings) {
// We do this to get setPage and notifyNewScreen
if (this.state.currentRoomId) {
this._viewRoom({
room_id: this.state.currentRoomId,
});
} else if (this.state.currentGroupId) {
this._viewGroup({
group_id: this.state.currentGroupId,
});
} else {
this._viewHome();
}
}
break;
case 'view_create_room':
this._createRoom();
break;
@ -620,11 +530,8 @@ export default React.createClass({
config: this.props.config,
}, 'mx_RoomDirectory_dialogWrapper');
// View the home page if we need something to look at
if (!this.state.currentGroupId && !this.state.currentRoomId) {
this._setPage(PageTypes.HomePage);
this.notifyNewScreen('home');
}
// View the welcome or home page if we need something to look at
this._viewSomethingBehindModal();
}
break;
case 'view_my_groups':
@ -634,6 +541,9 @@ export default React.createClass({
case 'view_group':
this._viewGroup(payload);
break;
case 'view_welcome_page':
this._viewWelcome();
break;
case 'view_home_page':
this._viewHome();
break;
@ -649,8 +559,9 @@ export default React.createClass({
case 'view_invite':
showRoomInviteDialog(payload.roomId);
break;
case 'notifier_enabled':
this.forceUpdate();
case 'notifier_enabled': {
this.setState({showNotifierToolbar: Notifier.shouldShowToolbar()});
}
break;
case 'hide_left_panel':
this.setState({
@ -682,11 +593,9 @@ export default React.createClass({
});
break;
}
// case 'set_theme':
// disable changing the theme for now
// as other themes are not compatible with dharma
// this._onSetTheme(payload.value);
// break;
case 'set_theme':
this._onSetTheme(payload.value);
break;
case 'on_logging_in':
// We are now logging in, so set the state to reflect that
// NB. This does not touch 'ready' since if our dispatches
@ -847,6 +756,7 @@ export default React.createClass({
this.focusComposer = true;
const newState = {
view: VIEWS.LOGGED_IN,
currentRoomId: roomInfo.room_id || null,
page_type: PageTypes.RoomView,
thirdPartyInvite: roomInfo.third_party_invite,
@ -909,6 +819,23 @@ export default React.createClass({
this.notifyNewScreen('group/' + groupId);
},
_viewSomethingBehindModal() {
if (this.state.view !== VIEWS.LOGGED_IN) {
this._viewWelcome();
return;
}
if (!this.state.currentGroupId && !this.state.currentRoomId) {
this._viewHome();
}
},
_viewWelcome() {
this.setStateForNewView({
view: VIEWS.WELCOME,
});
this.notifyNewScreen('welcome');
},
_viewHome: function() {
// The home page requires the "logged in" view, so we'll set that.
this.setStateForNewView({
@ -918,6 +845,22 @@ export default React.createClass({
this.notifyNewScreen('home');
},
_viewUser: function(userId, subAction) {
// Wait for the first sync so that `getRoom` gives us a room object if it's
// in the sync response
const waitForSync = this.firstSyncPromise ?
this.firstSyncPromise.promise : Promise.resolve();
waitForSync.then(() => {
if (subAction === 'chat') {
this._chatCreateOrReuse(userId);
return;
}
this.notifyNewScreen('user/' + userId);
this.setState({currentUserId: userId});
this._setPage(PageTypes.UserView);
});
},
_setMxId: function(payload) {
const SetMxIdDialog = sdk.getComponent('views.dialogs.SetMxIdDialog');
const close = Modal.createTrackedDialog('Set MXID', '', SetMxIdDialog, {
@ -982,11 +925,11 @@ export default React.createClass({
}
dis.dispatch({
action: 'require_registration',
// If the set_mxid dialog is cancelled, view /home because if the browser
// was pointing at /user/@someone:domain?action=chat, the URL needs to be
// reset so that they can revisit /user/.. // (and trigger
// If the set_mxid dialog is cancelled, view /welcome because if the
// browser was pointing at /user/@someone:domain?action=chat, the URL
// needs to be reset so that they can revisit /user/.. // (and trigger
// `_chatCreateOrReuse` again)
go_home_on_cancel: true,
go_welcome_on_cancel: true,
});
return;
}
@ -1053,35 +996,48 @@ export default React.createClass({
button: _t("Leave"),
onFinished: (shouldLeave) => {
if (shouldLeave) {
const d = MatrixClientPeg.get().leave(roomId);
const d = MatrixClientPeg.get().leaveRoomChain(roomId);
// FIXME: controller shouldn't be loading a view :(
const Loader = sdk.getComponent("elements.Spinner");
const modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner');
d.then(() => {
d.then((errors) => {
modal.close();
for (const leftRoomId of Object.keys(errors)) {
const err = errors[leftRoomId];
if (!err) continue;
console.error("Failed to leave room " + leftRoomId + " " + err);
let title = _t("Failed to leave room");
let message = _t("Server may be unavailable, overloaded, or you hit a bug.");
if (err.errcode === 'M_CANNOT_LEAVE_SERVER_NOTICE_ROOM') {
title = _t("Can't leave Server Notices room");
message = _t(
"This room is used for important messages from the Homeserver, " +
"so you cannot leave it.",
);
} else if (err && err.message) {
message = err.message;
}
Modal.createTrackedDialog('Failed to leave room', '', ErrorDialog, {
title: title,
description: message,
});
return;
}
if (this.state.currentRoomId === roomId) {
dis.dispatch({action: 'view_next_room'});
dis.dispatch({action: 'close_room_settings'});
}
}, (err) => {
// This should only happen if something went seriously wrong with leaving the chain.
modal.close();
console.error("Failed to leave room " + roomId + " " + err);
let title = _t("Failed to leave room");
let message = _t("Server may be unavailable, overloaded, or you hit a bug.");
if (err.errcode == 'M_CANNOT_LEAVE_SERVER_NOTICE_ROOM') {
title = _t("Can't leave Server Notices room");
message = _t(
"This room is used for important messages from the Homeserver, " +
"so you cannot leave it.",
);
} else if (err && err.message) {
message = err.message;
}
Modal.createTrackedDialog('Failed to leave room', '', ErrorDialog, {
title: title,
description: message,
title: _t("Failed to leave room"),
description: _t("Unknown error"),
});
});
}
@ -1169,7 +1125,7 @@ export default React.createClass({
* Called when a new logged in session has started
*/
_onLoggedIn: async function() {
this.setStateForNewView({view: VIEWS.LOGGED_IN});
this.setStateForNewView({ view: VIEWS.LOGGED_IN });
if (this._is_registered) {
this._is_registered = false;
@ -1209,7 +1165,15 @@ export default React.createClass({
room_id: localStorage.getItem('mx_last_room_id'),
});
} else {
dis.dispatch({action: 'view_home_page'});
if (MatrixClientPeg.get().isGuest()) {
dis.dispatch({action: 'view_welcome_page'});
} else if (getHomePageUrl(this.props.config)) {
dis.dispatch({action: 'view_home_page'});
} else {
this.firstSyncPromise.promise.then(() => {
dis.dispatch({action: 'view_next_room'});
});
}
}
},
@ -1294,7 +1258,10 @@ export default React.createClass({
self.firstSyncPromise.resolve();
dis.dispatch({action: 'focus_composer'});
self.setState({ready: true});
self.setState({
ready: true,
showNotifierToolbar: Notifier.shouldShowToolbar(),
});
});
cli.on('Call.incoming', function(call) {
// we dispatch this synchronously to make sure that the event
@ -1495,6 +1462,10 @@ export default React.createClass({
dis.dispatch({
action: 'view_user_settings',
});
} else if (screen == 'welcome') {
dis.dispatch({
action: 'view_welcome_page',
});
} else if (screen == 'home') {
dis.dispatch({
action: 'view_home_page',
@ -1519,7 +1490,16 @@ export default React.createClass({
} else if (screen.indexOf('room/') == 0) {
const segments = screen.substring(5).split('/');
const roomString = segments[0];
const eventId = segments[1]; // undefined if no event id given
let eventId = segments.splice(1).join("/"); // empty string if no event id given
// Previously we pulled the eventID from the segments in such a way
// where if there was no eventId then we'd get undefined. However, we
// now do a splice and join to handle v3 event IDs which results in
// an empty string. To maintain our potential contract with the rest
// of the app, we coerce the eventId to be undefined where applicable.
if (!eventId) eventId = undefined;
// TODO: Handle encoded room/event IDs: https://github.com/vector-im/riot-web/issues/9149
// FIXME: sort_out caseConsistency
const thirdPartyInvite = {
@ -1560,31 +1540,13 @@ export default React.createClass({
payload.room_id = roomString;
}
// we can't view a room unless we're logged in
// (a guest account is fine)
if (this.state.view === VIEWS.LOGGED_IN) {
dis.dispatch(payload);
}
dis.dispatch(payload);
} else if (screen.indexOf('user/') == 0) {
const userId = screen.substring(5);
// Wait for the first sync so that `getRoom` gives us a room object if it's
// in the sync response
const waitFor = this.firstSyncPromise ?
this.firstSyncPromise.promise : Promise.resolve();
waitFor.then(() => {
if (params.action === 'chat') {
this._chatCreateOrReuse(userId);
return;
}
this._setPage(PageTypes.UserView);
this.notifyNewScreen('user/' + userId);
const member = new Matrix.RoomMember(null, userId);
dis.dispatch({
action: 'view_user',
member: member,
});
dis.dispatch({
action: 'view_user_info',
userId: userId,
subAction: params.action,
});
} else if (screen.indexOf('group/') == 0) {
const groupId = screen.substring(6);
@ -1654,9 +1616,14 @@ export default React.createClass({
dis.dispatch({ action: 'show_right_panel' });
}
this.state.resizeNotifier.notifyWindowResized();
this._windowWidth = window.innerWidth;
},
_dispatchTimelineResize() {
dis.dispatch({ action: 'timeline_resize' });
},
onRoomCreated: function(roomId) {
dis.dispatch({
action: "view_room",
@ -1676,13 +1643,47 @@ export default React.createClass({
this.showScreen("forgot_password");
},
onReturnToAppClick: function() {
// treat it the same as if the user had completed the login
this._onLoggedIn();
},
// returns a promise which resolves to the new MatrixClient
onRegistered: function(credentials) {
if (this.state.register_session_id) {
// 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 !== credentials.userId) {
console.log(
`Found a session for ${sessionOwner} but ${credentials.userId} is trying to verify their ` +
`email address. Restoring the session for ${sessionOwner} with warning.`,
);
this._loadSession();
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
// N.B. first param is passed to piwik and so doesn't want i18n
Modal.createTrackedDialog('Existing session on register', '',
QuestionDialog, {
title: _t('You are logged in to another account'),
description: _t(
"Thank you for verifying your email! The account you're logged into here " +
"(%(sessionUserId)s) appears to be different from the account you've verified an " +
"email for (%(verifiedUserId)s). If you would like to log in to %(verifiedUserId2)s, " +
"please log out first.", {
sessionUserId: sessionOwner,
verifiedUserId: credentials.userId,
// TODO: Fix translations to support reusing variables.
// https://github.com/vector-im/riot-web/issues/9086
verifiedUserId2: credentials.userId,
},
),
hasCancelButton: false,
});
return MatrixClientPeg.get();
}
}
// XXX: This should be in state or ideally store(s) because we risk not
// rendering the most up-to-date view of state otherwise.
this._is_registered = true;
@ -1722,7 +1723,7 @@ export default React.createClass({
},
_setPageSubtitle: function(subtitle='') {
document.title = `Riot ${subtitle}`;
document.title = `${SdkConfig.get().brand || 'Riot'} ${subtitle}`;
},
updateStatusIndicator: function(state, prevState) {
@ -1761,44 +1762,7 @@ export default React.createClass({
},
onServerConfigChange(config) {
const newState = {};
if (config.hsUrl) {
newState.register_hs_url = config.hsUrl;
}
if (config.isUrl) {
newState.register_is_url = config.isUrl;
}
this.setState(newState);
},
_tryDiscoverDefaultHomeserver: async function(serverName) {
try {
const discovery = await AutoDiscovery.findClientConfig(serverName);
const state = discovery["m.homeserver"].state;
if (state !== AutoDiscovery.SUCCESS) {
console.error("Failed to discover homeserver on startup:", discovery);
this.setState({
defaultServerDiscoveryError: discovery["m.homeserver"].error,
loadingDefaultHomeserver: false,
});
} else {
const hsUrl = discovery["m.homeserver"].base_url;
const isUrl = discovery["m.identity_server"].state === AutoDiscovery.SUCCESS
? discovery["m.identity_server"].base_url
: "https://vector.im";
this.setState({
defaultHsUrl: hsUrl,
defaultIsUrl: isUrl,
loadingDefaultHomeserver: false,
});
}
} catch (e) {
console.error(e);
this.setState({
defaultServerDiscoveryError: _t("Unknown error discovering homeserver"),
loadingDefaultHomeserver: false,
});
}
this.setState({serverConfig: config});
},
_makeRegistrationUrl: function(params) {
@ -1817,8 +1781,7 @@ export default React.createClass({
if (
this.state.view === VIEWS.LOADING ||
this.state.view === VIEWS.LOGGING_IN ||
this.state.loadingDefaultHomeserver
this.state.view === VIEWS.LOGGING_IN
) {
const Spinner = sdk.getComponent('elements.Spinner');
return (
@ -1883,6 +1846,11 @@ export default React.createClass({
}
}
if (this.state.view === VIEWS.WELCOME) {
const Welcome = sdk.getComponent('auth.Welcome');
return <Welcome />;
}
if (this.state.view === VIEWS.REGISTER) {
const Registration = sdk.getComponent('structures.auth.Registration');
return (
@ -1891,21 +1859,13 @@ export default React.createClass({
sessionId={this.state.register_session_id}
idSid={this.state.register_id_sid}
email={this.props.startingFragmentQueryParams.email}
referrer={this.props.startingFragmentQueryParams.referrer}
defaultServerDiscoveryError={this.state.defaultServerDiscoveryError}
defaultHsUrl={this.getDefaultHsUrl()}
defaultIsUrl={this.getDefaultIsUrl()}
brand={this.props.config.brand}
customHsUrl={this.getCurrentHsUrl()}
customIsUrl={this.getCurrentIsUrl()}
makeRegistrationUrl={this._makeRegistrationUrl}
defaultDeviceDisplayName={this.props.defaultDeviceDisplayName}
onLoggedIn={this.onRegistered}
onLoginClick={this.onLoginClick}
onRegisterClick={this.onRegisterClick}
onCancelClick={MatrixClientPeg.get() ? this.onReturnToAppClick : null}
onServerConfigChange={this.onServerConfigChange}
/>
{...this.getServerProperties()}
/>
);
}
@ -1914,14 +1874,11 @@ export default React.createClass({
const ForgotPassword = sdk.getComponent('structures.auth.ForgotPassword');
return (
<ForgotPassword
defaultServerName={this.getDefaultServerName()}
defaultServerDiscoveryError={this.state.defaultServerDiscoveryError}
defaultHsUrl={this.getDefaultHsUrl()}
defaultIsUrl={this.getDefaultIsUrl()}
customHsUrl={this.getCurrentHsUrl()}
customIsUrl={this.getCurrentIsUrl()}
onComplete={this.onLoginClick}
onLoginClick={this.onLoginClick} />
onLoginClick={this.onLoginClick}
onServerConfigChange={this.onServerConfigChange}
{...this.getServerProperties()}
/>
);
}
@ -1931,17 +1888,11 @@ export default React.createClass({
<Login
onLoggedIn={Lifecycle.setLoggedIn}
onRegisterClick={this.onRegisterClick}
defaultServerDiscoveryError={this.state.defaultServerDiscoveryError}
defaultHsUrl={this.getDefaultHsUrl()}
defaultIsUrl={this.getDefaultIsUrl()}
customHsUrl={this.getCurrentHsUrl()}
customIsUrl={this.getCurrentIsUrl()}
fallbackHsUrl={this.getFallbackHsUrl()}
defaultDeviceDisplayName={this.props.defaultDeviceDisplayName}
onForgotPasswordClick={this.onForgotPasswordClick}
enableGuest={this.props.enableGuest}
onCancelClick={MatrixClientPeg.get() ? this.onReturnToAppClick : null}
onServerConfigChange={this.onServerConfigChange}
{...this.getServerProperties()}
/>
);
}

View file

@ -21,10 +21,10 @@ import PropTypes from 'prop-types';
import classNames from 'classnames';
import shouldHideEvent from '../../shouldHideEvent';
import {wantsDateSeparator} from '../../DateUtils';
import dis from "../../dispatcher";
import sdk from '../../index';
import MatrixClientPeg from '../../MatrixClientPeg';
import SettingsStore from '../../settings/SettingsStore';
const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
const continuedTypes = ['m.sticker', 'm.room.message'];
@ -93,6 +93,12 @@ module.exports = React.createClass({
// show timestamps always
alwaysShowTimestamps: PropTypes.bool,
// helper function to access relations for an event
getRelationsForEvent: PropTypes.func,
// whether to show reactions for an event
showReactions: PropTypes.bool,
},
componentWillMount: function() {
@ -228,6 +234,13 @@ module.exports = React.createClass({
}
},
scrollToEventIfNeeded: function(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() {
@ -246,6 +259,10 @@ module.exports = React.createClass({
return false; // ignored = no show (only happens if the ignore happens after an event was received)
}
if (SettingsStore.getValue("showHiddenEventsInTimeline")) {
return true;
}
const EventTile = sdk.getComponent('rooms.EventTile');
if (!EventTile.haveTileForEvent(mxEv)) {
return false; // no tile = no show
@ -387,7 +404,7 @@ module.exports = React.createClass({
ret.push(<MemberEventListSummary key={key}
events={summarisedEvents}
onToggle={this._onWidgetLoad} // Update scroll state
onToggle={this._onHeightChanged} // Update scroll state
startExpanded={highlightInMels}
>
{ eventTiles }
@ -451,6 +468,7 @@ module.exports = React.createClass({
const DateSeparator = sdk.getComponent('messages.DateSeparator');
const ret = [];
const isEditing = this.props.editEvent && this.props.editEvent.getId() === mxEv.getId();
// is this a continuation of the previous message?
let continuation = false;
@ -512,21 +530,30 @@ module.exports = React.createClass({
readReceipts = this._getReadReceiptsForEvent(mxEv);
}
ret.push(
<li key={eventId}
ref={this._collectEventNode.bind(this, eventId)}
data-scroll-tokens={scrollToken}>
<EventTile mxEvent={mxEv} continuation={continuation}
isRedacted={mxEv.isRedacted()}
onWidgetLoad={this._onWidgetLoad}
readReceipts={readReceipts}
readReceiptMap={this._readReceiptMap}
showUrlPreview={this.props.showUrlPreview}
checkUnmounting={this._isUnmounting}
eventSendStatus={mxEv.status}
tileShape={this.props.tileShape}
isTwelveHour={this.props.isTwelveHour}
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()}
isEditing={isEditing}
onHeightChanged={this._onHeightChanged}
readReceipts={readReceipts}
readReceiptMap={this._readReceiptMap}
showUrlPreview={this.props.showUrlPreview}
checkUnmounting={this._isUnmounting}
eventSendStatus={mxEv.replacementOrOwnStatus()}
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;
@ -624,38 +651,57 @@ module.exports = React.createClass({
// once dynamic content in the events load, make the scrollPanel check the
// scroll offsets.
_onWidgetLoad: function() {
_onHeightChanged: function() {
const scrollPanel = this.refs.scrollPanel;
if (scrollPanel) {
scrollPanel.forceUpdate();
scrollPanel.checkScroll();
}
},
_onTypingVisible: function() {
_onTypingShown: function() {
const scrollPanel = this.refs.scrollPanel;
// this will make the timeline grow, so checkScroll
scrollPanel.checkScroll();
if (scrollPanel && scrollPanel.getScrollState().stuckAtBottom) {
scrollPanel.blockShrinking();
// scroll down if at bottom
scrollPanel.preventShrinking();
}
},
_onTypingHidden: function() {
const scrollPanel = this.refs.scrollPanel;
if (scrollPanel) {
// as hiding the typing notifications doesn't
// update the scrollPanel, we tell it to apply
// the shrinking prevention once the typing notifs are hidden
scrollPanel.updatePreventShrinking();
// order is important here as checkScroll will scroll down to
// reveal added padding to balance the notifs disappearing.
scrollPanel.checkScroll();
}
},
updateTimelineMinHeight: function() {
const scrollPanel = this.refs.scrollPanel;
const whoIsTyping = this.refs.whoIsTyping;
const isTypingVisible = whoIsTyping && whoIsTyping.isVisible();
if (scrollPanel) {
if (isTypingVisible) {
scrollPanel.blockShrinking();
} else {
scrollPanel.clearBlockShrinking();
const isAtBottom = scrollPanel.isAtBottom();
const whoIsTyping = this.refs.whoIsTyping;
const isTypingVisible = whoIsTyping && whoIsTyping.isVisible();
// when messages get added to the timeline,
// but somebody else is still typing,
// update the min-height, so once the last
// person stops typing, no jumping occurs
if (isAtBottom && isTypingVisible) {
scrollPanel.preventShrinking();
}
}
},
onResize: function() {
dis.dispatch({ action: 'timeline_resize' }, true);
onTimelineReset: function() {
const scrollPanel = this.refs.scrollPanel;
if (scrollPanel) {
scrollPanel.clearPreventShrinking();
}
},
render: function() {
@ -681,8 +727,13 @@ module.exports = React.createClass({
);
let whoIsTyping;
if (this.props.room) {
whoIsTyping = (<WhoIsTypingTile room={this.props.room} onVisible={this._onTypingVisible} ref="whoIsTyping" />);
if (this.props.room && !this.props.tileShape) {
whoIsTyping = (<WhoIsTypingTile
room={this.props.room}
onShown={this._onTypingShown}
onHidden={this._onTypingHidden}
ref="whoIsTyping" />
);
}
return (
@ -692,7 +743,8 @@ module.exports = React.createClass({
onFillRequest={this.props.onFillRequest}
onUnfillRequest={this.props.onUnfillRequest}
style={style}
stickyBottom={this.props.stickyBottom}>
stickyBottom={this.props.stickyBottom}
resizeNotifier={this.props.resizeNotifier}>
{ topSpinner }
{ this._getEventTiles() }
{ whoIsTyping }

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.
@ -15,12 +16,9 @@ limitations under the License.
*/
const React = require('react');
const ReactDOM = require("react-dom");
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
@ -44,7 +42,7 @@ const NotificationPanel = React.createClass({
manageReadReceipts={false}
manageReadMarkers={false}
timelineSet={timelineSet}
showUrlPreview = {false}
showUrlPreview={false}
tileShape="notif"
empty={_t('You have no visible notifications')}
/>

View file

@ -32,6 +32,7 @@ export default class RightPanel extends React.Component {
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,
};
}
@ -49,13 +50,14 @@ export default class RightPanel extends React.Component {
FilePanel: 'FilePanel',
NotificationPanel: 'NotificationPanel',
RoomMemberInfo: 'RoomMemberInfo',
Room3pidMemberInfo: 'Room3pidMemberInfo',
GroupMemberInfo: 'GroupMemberInfo',
});
constructor(props, context) {
super(props, context);
this.state = {
phase: this.props.groupId ? RightPanel.Phase.GroupMemberList : RightPanel.Phase.RoomMemberList,
phase: this._getPhaseFromProps(),
isUserPrivilegedInGroup: null,
};
this.onAction = this.onAction.bind(this);
@ -69,11 +71,24 @@ export default class RightPanel extends React.Component {
}, 500);
}
_getPhaseFromProps() {
if (this.props.groupId) {
return RightPanel.Phase.GroupMemberList;
} else if (this.props.user) {
return RightPanel.Phase.RoomMemberInfo;
} else {
return RightPanel.Phase.RoomMemberList;
}
}
componentWillMount() {
this.dispatcherRef = dis.register(this.onAction);
const cli = this.context.matrixClient;
cli.on("RoomState.members", this.onRoomStateMember);
this._initGroupStore(this.props.groupId);
if (this.props.user) {
this.setState({member: this.props.user});
}
}
componentWillUnmount() {
@ -141,6 +156,7 @@ export default class RightPanel extends React.Component {
groupRoomId: payload.groupRoomId,
groupId: payload.groupId,
member: payload.member,
event: payload.event,
});
}
}
@ -148,6 +164,7 @@ export default class RightPanel extends React.Component {
render() {
const MemberList = sdk.getComponent('rooms.MemberList');
const MemberInfo = sdk.getComponent('rooms.MemberInfo');
const ThirdPartyMemberInfo = sdk.getComponent('rooms.ThirdPartyMemberInfo');
const NotificationPanel = sdk.getComponent('structures.NotificationPanel');
const FilePanel = sdk.getComponent('structures.FilePanel');
@ -166,6 +183,8 @@ export default class RightPanel extends React.Component {
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} />;
} 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}
@ -179,7 +198,7 @@ export default class RightPanel extends React.Component {
} else if (this.state.phase === RightPanel.Phase.NotificationPanel) {
panel = <NotificationPanel />;
} else if (this.state.phase === RightPanel.Phase.FilePanel) {
panel = <FilePanel roomId={this.props.roomId} />;
panel = <FilePanel roomId={this.props.roomId} resizeNotifier={this.props.resizeNotifier} />;
}
const classes = classNames("mx_RightPanel", "mx_fadable", {

View file

@ -24,20 +24,18 @@ const Modal = require('../../Modal');
const sdk = require('../../index');
const dis = require('../../dispatcher');
const linkify = require('linkifyjs');
const linkifyString = require('linkifyjs/string');
const linkifyMatrix = require('../../linkify-matrix');
const sanitizeHtml = require('sanitize-html');
import { linkifyAndSanitizeHtml } from '../../HtmlUtils';
import Promise from 'bluebird';
import { _t } from '../../languageHandler';
import {instanceForInstanceId, protocolNameForInstanceId} from '../../utils/DirectoryUtils';
import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils';
import Analytics from '../../Analytics';
const MAX_NAME_LENGTH = 80;
const MAX_TOPIC_LENGTH = 160;
linkifyMatrix(linkify);
function track(action) {
Analytics.trackEvent('RoomDirectory', action);
}
module.exports = React.createClass({
displayName: 'RoomDirectory',
@ -58,6 +56,7 @@ module.exports = React.createClass({
publicRooms: [],
loading: true,
protocolsLoading: true,
error: null,
instanceId: null,
includeAll: false,
roomServer: null,
@ -83,6 +82,11 @@ module.exports = React.createClass({
this.protocols = null;
this.setState({protocolsLoading: true});
if (!MatrixClientPeg.get()) {
// We may not have a client yet when invoked from welcome page
this.setState({protocolsLoading: false});
return;
}
MatrixClientPeg.get().getThirdpartyProtocols().done((response) => {
this.protocols = response;
this.setState({protocolsLoading: false});
@ -95,10 +99,12 @@ module.exports = React.createClass({
// thing you see when loading the client!
return;
}
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to get protocol list from homeserver', '', ErrorDialog, {
title: _t('Failed to get protocol list from homeserver'),
description: _t('The homeserver may be too old to support third party networks'),
track('Failed to get protocol list from homeserver');
this.setState({
error: _t(
'Riot failed to get the protocol list from the homeserver. ' +
'The homeserver may be too old to support third party networks.',
),
});
});
@ -187,12 +193,14 @@ module.exports = React.createClass({
return;
}
this.setState({ loading: false });
console.error("Failed to get publicRooms: %s", JSON.stringify(err));
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to get public room list', '', ErrorDialog, {
title: _t('Failed to get public room list'),
description: ((err && err.message) ? err.message : _t('The server may be unavailable or overloaded')),
track('Failed to get public room list');
this.setState({
loading: false,
error:
`${_t('Riot failed to get the public room list.')} ` +
`${(err && err.message) ? err.message : _t('The homeserver may be unavailable or overloaded.')}`
,
});
});
},
@ -438,7 +446,7 @@ module.exports = React.createClass({
if (topic.length > MAX_TOPIC_LENGTH) {
topic = `${topic.substring(0, MAX_TOPIC_LENGTH)}...`;
}
topic = linkifyString(sanitizeHtml(topic));
topic = linkifyAndSanitizeHtml(topic);
rows.push(
<tr key={ rooms[i].room_id }
@ -511,25 +519,15 @@ module.exports = React.createClass({
},
render: function() {
const SimpleRoomHeader = sdk.getComponent('rooms.SimpleRoomHeader');
const Loader = sdk.getComponent("elements.Spinner");
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
// TODO: clean this up
if (this.state.protocolsLoading) {
return (
<div className="mx_RoomDirectory">
<Loader />
</div>
);
}
let content;
if (this.state.loading) {
content = <div className="mx_RoomDirectory">
<Loader />
</div>;
if (this.state.error) {
content = this.state.error;
} else if (this.state.protocolsLoading || this.state.loading) {
content = <Loader />;
} else {
const rows = this.getRows();
// we still show the scrollpanel, at least for now, because
@ -551,39 +549,53 @@ module.exports = React.createClass({
onFillRequest={ this.onFillRequest }
stickyBottom={false}
startAtBottom={false}
onResize={function() {}}
>
{ scrollpanel_content }
</ScrollPanel>;
}
const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId);
let instance_expected_field_type;
if (
protocolName &&
this.protocols &&
this.protocols[protocolName] &&
this.protocols[protocolName].location_fields.length > 0 &&
this.protocols[protocolName].field_types
) {
const last_field = this.protocols[protocolName].location_fields.slice(-1)[0];
instance_expected_field_type = this.protocols[protocolName].field_types[last_field];
}
let listHeader;
if (!this.state.protocolsLoading) {
const NetworkDropdown = sdk.getComponent('directory.NetworkDropdown');
const DirectorySearchBox = sdk.getComponent('elements.DirectorySearchBox');
let placeholder = _t('Search for a room');
if (!this.state.instanceId) {
placeholder = _t('Search for a room like #example') + ':' + this.state.roomServer;
} else if (instance_expected_field_type) {
placeholder = instance_expected_field_type.placeholder;
}
let showJoinButton = this._stringLooksLikeId(this.state.filterString, instance_expected_field_type);
if (protocolName) {
const instance = instanceForInstanceId(this.protocols, this.state.instanceId);
if (this._getFieldsForThirdPartyLocation(this.state.filterString, this.protocols[protocolName], instance) === null) {
showJoinButton = false;
const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId);
let instance_expected_field_type;
if (
protocolName &&
this.protocols &&
this.protocols[protocolName] &&
this.protocols[protocolName].location_fields.length > 0 &&
this.protocols[protocolName].field_types
) {
const last_field = this.protocols[protocolName].location_fields.slice(-1)[0];
instance_expected_field_type = this.protocols[protocolName].field_types[last_field];
}
let placeholder = _t('Search for a room');
if (!this.state.instanceId) {
placeholder = _t('Search for a room like #example') + ':' + this.state.roomServer;
} else if (instance_expected_field_type) {
placeholder = instance_expected_field_type.placeholder;
}
let showJoinButton = this._stringLooksLikeId(this.state.filterString, instance_expected_field_type);
if (protocolName) {
const instance = instanceForInstanceId(this.protocols, this.state.instanceId);
if (this._getFieldsForThirdPartyLocation(this.state.filterString, this.protocols[protocolName], instance) === null) {
showJoinButton = false;
}
}
listHeader = <div className="mx_RoomDirectory_listheader">
<DirectorySearchBox
className="mx_RoomDirectory_searchbox"
onChange={this.onFilterChange} onClear={this.onFilterClear} onJoinClick={this.onJoinClick}
placeholder={placeholder} showJoinButton={showJoinButton}
/>
<NetworkDropdown config={this.props.config} protocols={this.protocols} onOptionChange={this.onOptionChange} />
</div>;
}
const createRoomButton = (<AccessibleButton
@ -591,8 +603,6 @@ module.exports = React.createClass({
className="mx_RoomDirectory_createRoom"
>{_t("Create new room")}</AccessibleButton>);
const NetworkDropdown = sdk.getComponent('directory.NetworkDropdown');
const DirectorySearchBox = sdk.getComponent('elements.DirectorySearchBox');
return (
<BaseDialog
className={'mx_RoomDirectory_dialog'}
@ -603,14 +613,7 @@ module.exports = React.createClass({
>
<div className="mx_RoomDirectory">
<div className="mx_RoomDirectory_list">
<div className="mx_RoomDirectory_listheader">
<DirectorySearchBox
className="mx_RoomDirectory_searchbox"
onChange={this.onFilterChange} onClear={this.onFilterClear} onJoinClick={this.onJoinClick}
placeholder={placeholder} showJoinButton={showJoinButton}
/>
<NetworkDropdown config={this.props.config} protocols={this.protocols} onOptionChange={this.onOptionChange} />
</div>
{listHeader}
{content}
</div>
</div>

View file

@ -290,7 +290,7 @@ module.exports = React.createClass({
}
return <div className="mx_RoomStatusBar_connectionLostBar">
<img src={require("../../../res/img/feather-icons/e2e/warning.svg")} width="24" height="24" title={_t("Warning")} alt="" />
<img src={require("../../../res/img/e2e/warning.svg")} width="24" height="24" title={_t("Warning")} alt="" />
<div>
<div className="mx_RoomStatusBar_connectionLostBar_title">
{ title }
@ -304,12 +304,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/feather-icons/e2e/warning.svg")} width="24" height="24" title="/!\ " alt="/!\ " />
<img src={require("../../../res/img/e2e/warning.svg")} width="24" height="24" title="/!\ " alt="/!\ " />
<div>
<div className="mx_RoomStatusBar_connectionLostBar_title">
{ _t('Connectivity to the server has been lost.') }

View file

@ -1,7 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Copyright 2018, 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.
@ -27,7 +27,9 @@ import IndicatorScrollbar from './IndicatorScrollbar';
import { 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;
@ -41,6 +43,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,
@ -60,6 +63,9 @@ const RoomSubList = React.createClass({
getInitialState: function() {
return {
hidden: this.props.startAsHidden || false,
// some values to get LazyRenderList starting
scrollerHeight: 800,
scrollTop: 0,
};
},
@ -127,46 +133,6 @@ const RoomSubList = React.createClass({
});
},
_shouldShowNotifBadge: function(roomNotifState) {
const showBadgeInStates = [RoomNotifs.ALL_MESSAGES, RoomNotifs.ALL_MESSAGES_LOUD];
return showBadgeInStates.indexOf(roomNotifState) > -1;
},
_shouldShowMentionBadge: function(roomNotifState) {
return roomNotifState !== RoomNotifs.MUTE;
},
/**
* Total up all the notification counts from the rooms
*
* @returns {Array} The array takes the form [total, highlight] where highlight is a bool
*/
roomNotificationCount: function() {
const self = this;
if (this.props.isInvite) {
return [0, true];
}
return this.props.list.reduce(function(result, room, index) {
const roomNotifState = RoomNotifs.getRoomNotifsState(room.roomId);
const highlight = room.getUnreadNotificationCount('highlight') > 0;
const notificationCount = room.getUnreadNotificationCount();
const notifBadges = notificationCount > 0 && self._shouldShowNotifBadge(roomNotifState);
const mentionBadges = highlight && self._shouldShowMentionBadge(roomNotifState);
const badges = notifBadges || mentionBadges;
if (badges) {
result[0] += notificationCount;
if (highlight) {
result[1] = true;
}
}
return result;
}, [0, false]);
},
_updateSubListCount: function() {
// Force an update by setting the state to the current state
// Doing it this way rather than using forceUpdate(), so that the shouldComponentUpdate()
@ -174,45 +140,33 @@ const RoomSubList = React.createClass({
this.setState(this.state);
},
makeRoomTiles: function() {
const RoomTile = sdk.getComponent("rooms.RoomTile");
return this.props.list.map((room, index) => {
return <RoomTile
room={room}
roomSubList={this}
tagName={this.props.tagName}
key={room.roomId}
collapsed={this.props.collapsed || false}
unread={Unread.doesRoomHaveUnreadMessages(room)}
highlight={room.getUnreadNotificationCount('highlight') > 0 || this.props.isInvite}
isInvite={this.props.isInvite}
refreshSubList={this._updateSubListCount}
incomingCall={null}
onClick={this.onRoomTileClick}
/>;
});
makeRoomTile: function(room) {
return <RoomTile
room={room}
roomSubList={this}
tagName={this.props.tagName}
key={room.roomId}
collapsed={this.props.collapsed || false}
unread={Unread.doesRoomHaveUnreadMessages(room)}
highlight={this.props.isInvite || RoomNotifs.getUnreadNotificationCount(room, 'highlight') > 0}
notificationCount={RoomNotifs.getUnreadNotificationCount(room)}
isInvite={this.props.isInvite}
refreshSubList={this._updateSubListCount}
incomingCall={null}
onClick={this.onRoomTileClick}
/>;
},
_onNotifBadgeClick: function(e) {
// prevent the roomsublist collapsing
e.preventDefault();
e.stopPropagation();
// find first room which has notifications and switch to it
for (const room of this.props.list) {
const roomNotifState = RoomNotifs.getRoomNotifsState(room.roomId);
const highlight = room.getUnreadNotificationCount('highlight') > 0;
const notificationCount = room.getUnreadNotificationCount();
const notifBadges = notificationCount > 0 && this._shouldShowNotifBadge(roomNotifState);
const mentionBadges = highlight && this._shouldShowMentionBadge(roomNotifState);
if (notifBadges || mentionBadges) {
dis.dispatch({
action: 'view_room',
room_id: room.roomId,
});
return;
}
const room = this.props.list.find(room => RoomNotifs.getRoomHasBadge(room));
if (room) {
dis.dispatch({
action: 'view_room',
room_id: room.roomId,
});
}
},
@ -240,9 +194,11 @@ const RoomSubList = React.createClass({
_getHeaderJsx: function(isCollapsed) {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const subListNotifications = this.roomNotificationCount();
const subListNotifCount = subListNotifications[0];
const subListNotifHighlight = subListNotifications[1];
const subListNotifications = !this.props.isInvite ?
RoomNotifs.aggregateNotificationCount(this.props.list) :
{count: 0, highlight: true};
const subListNotifCount = subListNotifications.count;
const subListNotifHighlight = subListNotifications.highlight;
let badge;
if (!this.props.collapsed) {
@ -278,7 +234,11 @@ const RoomSubList = React.createClass({
let addRoomButton;
if (this.props.onAddRoom) {
addRoomButton = (
<AccessibleButton onClick={ this.props.onAddRoom } className="mx_RoomSubList_addRoom" />
<AccessibleButton
onClick={ this.props.onAddRoom }
className="mx_RoomSubList_addRoom"
title={this.props.addRoomLabel || _t("Add room")}
/>
);
}
@ -317,6 +277,21 @@ const RoomSubList = React.createClass({
if (this.refs.subList) {
this.refs.subList.style.height = `${height}px`;
}
this._updateLazyRenderHeight(height);
},
_updateLazyRenderHeight: function(height) {
this.setState({scrollerHeight: height});
},
_onScroll: function() {
this.setState({scrollTop: this.refs.scroller.getScrollTop()});
},
_canUseLazyListRendering() {
// for now disable lazy rendering as they are already rendered tiles
// not rooms like props.list we pass to LazyRenderList
return !this.props.extraTiles || !this.props.extraTiles.length;
},
render: function() {
@ -333,12 +308,24 @@ const RoomSubList = React.createClass({
return <div ref="subList" className={subListClasses}>
{this._getHeaderJsx(isCollapsed)}
</div>;
} else {
const tiles = this.makeRoomTiles();
tiles.push(...this.props.extraTiles);
} else if (this._canUseLazyListRendering()) {
return <div ref="subList" className={subListClasses}>
{this._getHeaderJsx(isCollapsed)}
<IndicatorScrollbar ref="scroller" className="mx_RoomSubList_scroll">
<IndicatorScrollbar ref="scroller" className="mx_RoomSubList_scroll" onScroll={ this._onScroll }>
<LazyRenderList
scrollTop={this.state.scrollTop }
height={ this.state.scrollerHeight }
renderItem={ this.makeRoomTile }
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 }>
{ tiles }
</IndicatorScrollbar>
</div>;

View file

@ -19,28 +19,28 @@ limitations under the License.
// TODO: This component is enormous! There's several things which could stand-alone:
// - Search results component
// - Drag and drop
// - File uploading - uploadFile()
import shouldHideEvent from "../../shouldHideEvent";
import shouldHideEvent from '../../shouldHideEvent';
const React = require("react");
const ReactDOM = require("react-dom");
import React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import Promise from 'bluebird';
import filesize from 'filesize';
const classNames = require("classnames");
import classNames from 'classnames';
import { _t } from '../../languageHandler';
import {RoomPermalinkCreator} from '../../matrix-to';
const MatrixClientPeg = require("../../MatrixClientPeg");
const ContentMessages = require("../../ContentMessages");
const Modal = require("../../Modal");
const sdk = require('../../index');
const CallHandler = require('../../CallHandler');
const dis = require("../../dispatcher");
const Tinter = require("../../Tinter");
const rate_limited_func = require('../../ratelimitedfunc');
const ObjectUtils = require('../../ObjectUtils');
const Rooms = require('../../Rooms');
import MatrixClientPeg from '../../MatrixClientPeg';
import ContentMessages from '../../ContentMessages';
import Modal from '../../Modal';
import sdk from '../../index';
import CallHandler from '../../CallHandler';
import dis from '../../dispatcher';
import Tinter from '../../Tinter';
import rate_limited_func from '../../ratelimitedfunc';
import ObjectUtils from '../../ObjectUtils';
import * as Rooms from '../../Rooms';
import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard';
@ -51,6 +51,7 @@ import RoomScrollStateStore from '../../stores/RoomScrollStateStore';
import WidgetEchoStore from '../../stores/WidgetEchoStore';
import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
import WidgetUtils from '../../utils/WidgetUtils';
import AccessibleButton from "../views/elements/AccessibleButton";
const DEBUG = false;
let debuglog = function() {};
@ -119,8 +120,6 @@ module.exports = React.createClass({
isInitialEventHighlighted: null,
forwardingEvent: null,
editingRoomSettings: false,
uploadingRoomSettings: false,
numUnreadMessages: 0,
draggingFile: false,
searching: false,
@ -145,6 +144,7 @@ module.exports = React.createClass({
// the end of the live timeline. It has the effect of hiding the
// 'scroll to bottom' knob, among a couple of other things.
atEndOfLiveTimeline: true,
atEndOfLiveTimelineInit: false, // used by componentDidUpdate to avoid unnecessary checks
showTopUnreadMessagesBar: false,
@ -169,7 +169,6 @@ module.exports = React.createClass({
MatrixClientPeg.get().on("accountData", this.onAccountData);
MatrixClientPeg.get().on("crypto.keyBackupStatus", this.onKeyBackupStatus);
MatrixClientPeg.get().on("deviceVerificationChanged", this.onDeviceVerificationChanged);
this._fetchMediaConfig();
// Start listening for RoomViewStore updates
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate);
this._onRoomViewStoreUpdate(true);
@ -177,27 +176,6 @@ module.exports = React.createClass({
WidgetEchoStore.on('update', this._onWidgetEchoStoreUpdate);
},
_fetchMediaConfig: function(invalidateCache: boolean = false) {
/// NOTE: Using global here so we don't make repeated requests for the
/// config every time we swap room.
if(global.mediaConfig !== undefined && !invalidateCache) {
this.setState({mediaConfig: global.mediaConfig});
return;
}
console.log("[Media Config] Fetching");
MatrixClientPeg.get().getMediaConfig().then((config) => {
console.log("[Media Config] Fetched config:", config);
return config;
}).catch(() => {
// Media repo can't or won't report limits, so provide an empty object (no limits).
console.log("[Media Config] Could not fetch config, so not limiting uploads.");
return {};
}).then((config) => {
global.mediaConfig = config;
this.setState({mediaConfig: config});
});
},
_onRoomViewStoreUpdate: function(initial) {
if (this.unmounted) {
return;
@ -229,11 +207,8 @@ module.exports = React.createClass({
forwardingEvent: RoomViewStore.getForwardingEvent(),
shouldPeek: RoomViewStore.shouldPeek(),
showingPinned: SettingsStore.getValue("PinnedEvents.isOpen", RoomViewStore.getRoomId()),
editingRoomSettings: RoomViewStore.isEditingSettings(),
};
if (this.state.editingRoomSettings && !newState.editingRoomSettings) dis.dispatch({action: 'focus_composer'});
// Temporary logging to diagnose https://github.com/vector-im/riot-web/issues/4307
console.log(
'RVS update:',
@ -288,6 +263,37 @@ module.exports = React.createClass({
}
},
_getRoomId() {
// According to `_onRoomViewStoreUpdate`, `state.roomId` can be null
// if we have a room alias we haven't resolved yet. To work around this,
// first we'll try the room object if it's there, and then fallback to
// the bare room ID. (We may want to update `state.roomId` after
// resolving aliases, so we could always trust it.)
return this.state.room ? this.state.room.roomId : this.state.roomId;
},
_getPermalinkCreatorForRoom: function(room) {
if (!this._permalinkCreators) this._permalinkCreators = {};
if (this._permalinkCreators[room.roomId]) return this._permalinkCreators[room.roomId];
this._permalinkCreators[room.roomId] = new RoomPermalinkCreator(room);
if (this.state.room && room.roomId === this.state.room.roomId) {
// We want to watch for changes in the creator for the primary room in the view, but
// don't need to do so for search results.
this._permalinkCreators[room.roomId].start();
} else {
this._permalinkCreators[room.roomId].load();
}
return this._permalinkCreators[room.roomId];
},
_stopAllPermalinkCreators: function() {
if (!this._permalinkCreators) return;
for (const roomId of Object.keys(this._permalinkCreators)) {
this._permalinkCreators[roomId].stop();
}
},
_onWidgetEchoStoreUpdate: function() {
this.setState({
showApps: this._shouldShowApps(this.state.room),
@ -387,7 +393,9 @@ module.exports = React.createClass({
this._updateConfCallNotification();
window.addEventListener('beforeunload', this.onPageUnload);
window.addEventListener('resize', this.onResize);
if (this.props.resizeNotifier) {
this.props.resizeNotifier.on("middlePanelResized", this.onResize);
}
this.onResize();
document.addEventListener("keydown", this.onKeyDown);
@ -423,6 +431,18 @@ module.exports = React.createClass({
roomView.addEventListener('dragend', this.onDragLeaveOrEnd);
}
}
// Note: We check the ref here with a flag because componentDidMount, despite
// documentation, does not define our messagePanel ref. It looks like our spinner
// in render() prevents the ref from being set on first mount, so we try and
// catch the messagePanel when it does mount. Because we only want the ref once,
// we use a boolean flag to avoid duplicate work.
if (this.refs.messagePanel && !this.state.atEndOfLiveTimelineInit) {
this.setState({
atEndOfLiveTimelineInit: true,
atEndOfLiveTimeline: this.refs.messagePanel.isAtEndOfLiveTimeline(),
});
}
},
componentWillUnmount: function() {
@ -437,6 +457,9 @@ module.exports = React.createClass({
RoomScrollStateStore.setScrollState(this.state.roomId, this._getScrollState());
}
// stop tracking room changes to format permalinks
this._stopAllPermalinkCreators();
if (this.refs.roomView) {
// disconnect the D&D event listeners from the room view. This
// is really just for hygiene - we're going to be
@ -462,7 +485,9 @@ module.exports = React.createClass({
}
window.removeEventListener('beforeunload', this.onPageUnload);
window.removeEventListener('resize', this.onResize);
if (this.props.resizeNotifier) {
this.props.resizeNotifier.removeListener("middlePanelResized", this.onResize);
}
document.removeEventListener("keydown", this.onKeyDown);
@ -482,7 +507,7 @@ module.exports = React.createClass({
},
onPageUnload(event) {
if (ContentMessages.getCurrentUploads().length > 0) {
if (ContentMessages.sharedInstance().getCurrentUploads().length > 0) {
return event.returnValue =
_t("You seem to be uploading files, are you sure you want to quit?");
} else if (this._getCallForRoom() && this.state.callState !== 'ended') {
@ -531,16 +556,14 @@ module.exports = React.createClass({
payload.data.description || payload.data.name);
break;
case 'picture_snapshot':
this.uploadFile(payload.file);
return ContentMessages.sharedInstance().sendContentListToRoom(
[payload.file], this.state.room.roomId, MatrixClientPeg.get(),
);
break;
case 'notifier_enabled':
case 'upload_failed':
// 413: File was too big or upset the server in some way.
if(payload.error.http_status === 413) {
this._fetchMediaConfig(true);
}
case 'upload_started':
case 'upload_finished':
case 'upload_canceled':
this.forceUpdate();
break;
case 'call_state':
@ -723,8 +746,19 @@ module.exports = React.createClass({
if (!MatrixClientPeg.get().isRoomEncrypted(room.roomId)) {
return;
}
if (!MatrixClientPeg.get().isCryptoEnabled()) {
// If crypto is not currently enabled, we aren't tracking devices at all,
// so we don't know what the answer is. Let's error on the safe side and show
// a warning for this case.
this.setState({
e2eStatus: "warning",
});
return;
}
room.hasUnverifiedDevices().then((hasUnverifiedDevices) => {
this.setState({e2eStatus: hasUnverifiedDevices ? "warning" : "verified"});
this.setState({
e2eStatus: hasUnverifiedDevices ? "warning" : "verified",
});
});
},
@ -771,7 +805,7 @@ module.exports = React.createClass({
return;
}
this._updateRoomMembers();
this._updateRoomMembers(member);
},
onMyMembership: function(room, membership, oldMembership) {
@ -783,15 +817,24 @@ module.exports = React.createClass({
// rate limited because a power level change will emit an event for every
// member in the room.
_updateRoomMembers: new rate_limited_func(function() {
_updateRoomMembers: new rate_limited_func(function(dueToMember) {
// a member state changed in this room
// refresh the conf call notification state
this._updateConfCallNotification();
this._updateDMState();
this._checkIfAlone(this.state.room);
let memberCountInfluence = 0;
if (dueToMember && dueToMember.membership === "invite" && this.state.room.getInvitedMemberCount() === 0) {
// A member got invited, but the room hasn't detected that change yet. Influence the member
// count by 1 to counteract this.
memberCountInfluence = 1;
}
this._checkIfAlone(this.state.room, memberCountInfluence);
this._updateE2EStatus(this.state.room);
}, 500),
_checkIfAlone: function(room) {
_checkIfAlone: function(room, countInfluence) {
let warnedAboutLonelyRoom = false;
if (localStorage) {
warnedAboutLonelyRoom = localStorage.getItem('mx_user_alone_warned_' + this.state.room.roomId);
@ -801,7 +844,8 @@ module.exports = React.createClass({
return;
}
const joinedOrInvitedMemberCount = room.getJoinedMemberCount() + room.getInvitedMemberCount();
let joinedOrInvitedMemberCount = room.getJoinedMemberCount() + room.getInvitedMemberCount();
if (countInfluence) joinedOrInvitedMemberCount += countInfluence;
this.setState({isAlone: joinedOrInvitedMemberCount === 1});
},
@ -840,10 +884,6 @@ module.exports = React.createClass({
}
},
onSearchResultsResize: function() {
dis.dispatch({ action: 'timeline_resize' }, true);
},
onSearchResultsFillRequest: function(backwards) {
if (!backwards) {
return Promise.resolve(false);
@ -882,13 +922,12 @@ module.exports = React.createClass({
// If the user is a ROU, allow them to transition to a PWLU
if (cli && cli.isGuest()) {
// Join this room once the user has registered and logged in
const signUrl = this.props.thirdPartyInvite ?
this.props.thirdPartyInvite.inviteSignUrl : undefined;
// (If we failed to peek, we may not have a valid room object.)
dis.dispatch({
action: 'do_after_sync_prepared',
deferred_action: {
action: 'view_room',
room_id: this.state.room.roomId,
room_id: this._getRoomId(),
},
});
@ -977,9 +1016,11 @@ module.exports = React.createClass({
onDrop: function(ev) {
ev.stopPropagation();
ev.preventDefault();
ContentMessages.sharedInstance().sendContentListToRoom(
ev.dataTransfer.files, this.state.room.roomId, MatrixClientPeg.get(),
);
this.setState({ draggingFile: false });
const files = [...ev.dataTransfer.files];
files.forEach(this.uploadFile);
dis.dispatch({action: 'focus_composer'});
},
onDragLeaveOrEnd: function(ev) {
@ -988,55 +1029,13 @@ module.exports = React.createClass({
this.setState({ draggingFile: false });
},
isFileUploadAllowed(file) {
if (this.state.mediaConfig !== undefined &&
this.state.mediaConfig["m.upload.size"] !== undefined &&
file.size > this.state.mediaConfig["m.upload.size"]) {
return _t("File is too big. Maximum file size is %(fileSize)s", {fileSize: filesize(this.state.mediaConfig["m.upload.size"])});
}
return true;
},
uploadFile: async function(file) {
dis.dispatch({action: 'focus_composer'});
if (MatrixClientPeg.get().isGuest()) {
dis.dispatch({action: 'require_registration'});
return;
}
try {
await ContentMessages.sendContentToRoom(file, this.state.room.roomId, MatrixClientPeg.get());
} catch (error) {
if (error.name === "UnknownDeviceError") {
// Let the status bar handle this
return;
}
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to upload file " + file + " " + error);
Modal.createTrackedDialog('Failed to upload file', '', ErrorDialog, {
title: _t('Failed to upload file'),
description: ((error && error.message)
? error.message : _t("Server may be unavailable, overloaded, or the file too big")),
});
// bail early to avoid calling the dispatch below
return;
}
// Send message_sent callback, for things like _checkIfAlone because after all a file is still a message.
dis.dispatch({
action: 'message_sent',
});
},
injectSticker: function(url, info, text) {
if (MatrixClientPeg.get().isGuest()) {
dis.dispatch({action: 'require_registration'});
return;
}
ContentMessages.sendStickerContentToRoom(url, this.state.room.roomId, info, text, MatrixClientPeg.get())
ContentMessages.sharedInstance().sendStickerContentToRoom(url, this.state.room.roomId, info, text, MatrixClientPeg.get())
.done(undefined, (error) => {
if (error.name === "UnknownDeviceError") {
// Let the staus bar handle this
@ -1117,7 +1116,7 @@ module.exports = React.createClass({
// favour longer (more specific) terms first
highlights = highlights.sort(function(a, b) {
return b.length - a.length;
});
});
self.setState({
searchHighlights: highlights,
@ -1171,7 +1170,7 @@ module.exports = React.createClass({
// once dynamic content in the search results load, make the scrollPanel check
// the scroll offsets.
const onWidgetLoad = () => {
const onHeightChanged = () => {
const scrollPanel = this.refs.searchResultsPanel;
if (scrollPanel) {
scrollPanel.checkScroll();
@ -1185,6 +1184,7 @@ module.exports = React.createClass({
const mxEv = result.context.getEvent();
const roomId = mxEv.getRoomId();
const room = cli.getRoom(roomId);
if (!EventTile.haveTileForEvent(mxEv)) {
// XXX: can this ever happen? It will make the result count
@ -1194,7 +1194,6 @@ module.exports = React.createClass({
if (this.state.searchScope === 'All') {
if (roomId != lastRoomId) {
const room = cli.getRoom(roomId);
// XXX: if we've left the room, we might not know about
// it. We should tell the js sdk to go and find out about
@ -1215,7 +1214,8 @@ module.exports = React.createClass({
searchResult={result}
searchHighlights={this.state.searchHighlights}
resultLink={resultLink}
onWidgetLoad={onWidgetLoad} />);
permalinkCreator={this._getPermalinkCreatorForRoom(room)}
onHeightChanged={onHeightChanged} />);
}
return ret;
},
@ -1231,50 +1231,9 @@ module.exports = React.createClass({
dis.dispatch({ action: 'open_room_settings' });
},
onSettingsSaveClick: function() {
if (!this.refs.room_settings) return;
this.setState({
uploadingRoomSettings: true,
});
const newName = this.refs.header.getEditedName();
if (newName !== undefined) {
this.refs.room_settings.setName(newName);
}
const newTopic = this.refs.header.getEditedTopic();
if (newTopic !== undefined) {
this.refs.room_settings.setTopic(newTopic);
}
this.refs.room_settings.save().then((results) => {
const fails = results.filter(function(result) { return result.state !== "fulfilled"; });
console.log("Settings saved with %s errors", fails.length);
if (fails.length) {
fails.forEach(function(result) {
console.error(result.reason);
});
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to save room settings', '', ErrorDialog, {
title: _t("Failed to save settings"),
description: fails.map(function(result) { return result.reason; }).join("\n"),
});
// still editing room settings
} else {
dis.dispatch({ action: 'close_settings' });
}
}).finally(() => {
this.setState({
uploadingRoomSettings: false,
});
dis.dispatch({ action: 'close_settings' });
}).done();
},
onCancelClick: function() {
console.log("updateTint from onCancelClick");
this.updateTint();
dis.dispatch({ action: 'close_settings' });
if (this.state.forwardingEvent) {
dis.dispatch({
action: 'forward_event',
@ -1342,7 +1301,10 @@ module.exports = React.createClass({
},
onSearchClick: function() {
this.setState({ searching: true, showingPinned: false });
this.setState({
searching: !this.state.searching,
showingPinned: false,
});
},
onCancelSearchClick: function() {
@ -1377,8 +1339,7 @@ module.exports = React.createClass({
const showBar = this.refs.messagePanel.canJumpToReadMarker();
if (this.state.showTopUnreadMessagesBar != showBar) {
this.setState({showTopUnreadMessagesBar: showBar},
this.onChildResize);
this.setState({showTopUnreadMessagesBar: showBar});
}
},
@ -1421,7 +1382,7 @@ module.exports = React.createClass({
};
},
onResize: function(e) {
onResize: function() {
// It seems flexbox doesn't give us a way to constrain the auxPanel height to have
// a minimum of the height of the video element, whilst also capping it from pushing out the page
// so we have to do it via JS instead. In this implementation we cap the height by putting
@ -1432,16 +1393,13 @@ module.exports = React.createClass({
(83 + // height of RoomHeader
36 + // height of the status area
72 + // minimum height of the message compmoser
(this.state.editingRoomSettings ? (window.innerHeight * 0.3) : 120)); // amount of desired scrollback
120); // amount of desired scrollback
// XXX: this is a bit of a hack and might possibly cause the video to push out the page anyway
// but it's better than the video going missing entirely
if (auxPanelMaxHeight < 50) auxPanelMaxHeight = 50;
this.setState({auxPanelMaxHeight: auxPanelMaxHeight});
// changing the maxHeight on the auxpanel will trigger a callback go
// onChildResize, so no need to worry about that here.
},
onFullscreenClick: function() {
@ -1471,10 +1429,6 @@ module.exports = React.createClass({
this.forceUpdate(); // TODO: just update the voip buttons
},
onChildResize: function() {
// no longer anything to do here
},
onStatusBarVisible: function() {
if (this.unmounted) return;
this.setState({
@ -1528,27 +1482,50 @@ module.exports = React.createClass({
}
},
_getOldRoom: function() {
const createEvent = this.state.room.currentState.getStateEvents("m.room.create", "");
if (!createEvent || !createEvent.getContent()['predecessor']) return null;
return MatrixClientPeg.get().getRoom(createEvent.getContent()['predecessor']['room_id']);
},
_getHiddenHighlightCount: function() {
const oldRoom = this._getOldRoom();
if (!oldRoom) return 0;
return oldRoom.getUnreadNotificationCount('highlight');
},
_onHiddenHighlightsClick: function() {
const oldRoom = this._getOldRoom();
if (!oldRoom) return;
dis.dispatch({action: "view_room", room_id: oldRoom.roomId});
},
render: function() {
const RoomHeader = sdk.getComponent('rooms.RoomHeader');
const MessageComposer = sdk.getComponent('rooms.MessageComposer');
const ForwardMessage = sdk.getComponent("rooms.ForwardMessage");
const RoomSettings = sdk.getComponent("rooms.RoomSettings");
const AuxPanel = sdk.getComponent("rooms.AuxPanel");
const SearchBar = sdk.getComponent("rooms.SearchBar");
const PinnedEventsPanel = sdk.getComponent("rooms.PinnedEventsPanel");
const ScrollPanel = sdk.getComponent("structures.ScrollPanel");
const TintableSvg = sdk.getComponent("elements.TintableSvg");
const RoomPreviewBar = sdk.getComponent("rooms.RoomPreviewBar");
const Loader = sdk.getComponent("elements.Spinner");
const TimelinePanel = sdk.getComponent("structures.TimelinePanel");
const RoomUpgradeWarningBar = sdk.getComponent("rooms.RoomUpgradeWarningBar");
const RoomRecoveryReminder = sdk.getComponent("rooms.RoomRecoveryReminder");
if (!this.state.room) {
if (this.state.roomLoading || this.state.peekLoading) {
const loading = this.state.roomLoading || this.state.peekLoading;
if (loading) {
return (
<div className="mx_RoomView">
<Loader />
<RoomPreviewBar
canPreview={false}
error={this.state.roomLoadError}
loading={loading}
joining={this.state.joining}
/>
</div>
);
} else {
@ -1566,28 +1543,16 @@ module.exports = React.createClass({
const roomAlias = this.state.roomAlias;
return (
<div className="mx_RoomView">
<RoomHeader ref="header"
<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}
oobData={this.props.oobData}
collapsedRhs={this.props.collapsedRhs}
e2eStatus={this.state.e2eStatus}
/>
<div className="mx_RoomView_body">
<div className="mx_RoomView_auxPanel">
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
onForgetClick={this.onForgetClick}
onRejectClick={this.onRejectThreepidInviteButtonClicked}
canPreview={false} error={this.state.roomLoadError}
roomAlias={roomAlias}
spinner={this.state.joining}
spinnerState="joining"
inviterName={inviterName}
invitedEmail={invitedEmail}
room={this.state.room}
/>
</div>
</div>
<div className="mx_RoomView_messagePanel"></div>
</div>
);
}
@ -1597,9 +1562,12 @@ module.exports = React.createClass({
if (myMembership == 'invite') {
if (this.state.joining || this.state.rejecting) {
return (
<div className="mx_RoomView">
<Loader />
</div>
<RoomPreviewBar
canPreview={false}
error={this.state.roomLoadError}
joining={this.state.joining}
rejecting={this.state.rejecting}
/>
);
} else {
const myUserId = MatrixClientPeg.get().credentials.userId;
@ -1614,26 +1582,14 @@ module.exports = React.createClass({
// We have a regular invite for this room.
return (
<div className="mx_RoomView">
<RoomHeader
ref="header"
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
onForgetClick={this.onForgetClick}
onRejectClick={this.onRejectButtonClicked}
inviterName={inviterName}
canPreview={false}
joining={this.state.joining}
room={this.state.room}
collapsedRhs={this.props.collapsedRhs}
e2eStatus={this.state.e2eStatus}
/>
<div className="mx_RoomView_body">
<div className="mx_RoomView_auxPanel">
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
onForgetClick={this.onForgetClick}
onRejectClick={this.onRejectButtonClicked}
inviterName={inviterName}
canPreview={false}
spinner={this.state.joining}
spinnerState="joining"
room={this.state.room}
/>
</div>
</div>
<div className="mx_RoomView_messagePanel"></div>
</div>
);
}
@ -1655,7 +1611,7 @@ module.exports = React.createClass({
let statusBar;
let isStatusAreaExpanded = true;
if (ContentMessages.getCurrentUploads().length > 0) {
if (ContentMessages.sharedInstance().getCurrentUploads().length > 0) {
const UploadBar = sdk.getComponent('structures.UploadBar');
statusBar = <UploadBar room={this.state.room} />;
} else if (!this.state.searchResults) {
@ -1668,7 +1624,6 @@ module.exports = React.createClass({
isPeeking={myMembership !== "join"}
onInviteClick={this.onInviteButtonClick}
onStopWarningClick={this.onStopAloneWarningClick}
onResize={this.onChildResize}
onVisible={this.onStatusBarVisible}
onHidden={this.onStatusBarHidden}
/>;
@ -1682,19 +1637,18 @@ module.exports = React.createClass({
);
const showRoomRecoveryReminder = (
SettingsStore.isFeatureEnabled("feature_keybackup") &&
SettingsStore.getValue("showRoomRecoveryReminder") &&
MatrixClientPeg.get().isRoomEncrypted(this.state.room.roomId) &&
!MatrixClientPeg.get().getKeyBackupEnabled()
);
const hiddenHighlightCount = this._getHiddenHighlightCount();
let aux = null;
let previewBar;
let hideCancel = false;
if (this.state.editingRoomSettings) {
aux = <RoomSettings ref="room_settings" onSaveClick={this.onSettingsSaveClick} onCancelClick={this.onCancelClick} room={this.state.room} />;
} else if (this.state.uploadingRoomSettings) {
aux = <Loader />;
} else if (this.state.forwardingEvent !== null) {
let hideRightPanel = false;
if (this.state.forwardingEvent !== null) {
aux = <ForwardMessage onCancelClick={this.onCancelClick} />;
} else if (this.state.searching) {
hideCancel = true; // has own cancel
@ -1720,31 +1674,48 @@ module.exports = React.createClass({
invitedEmail = this.props.thirdPartyInvite.invitedEmail;
}
hideCancel = true;
aux = (
previewBar = (
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
onForgetClick={this.onForgetClick}
onRejectClick={this.onRejectThreepidInviteButtonClicked}
spinner={this.state.joining}
spinnerState="joining"
joining={this.state.joining}
inviterName={inviterName}
invitedEmail={invitedEmail}
canPreview={this.state.canPeek}
room={this.state.room}
/>
);
if (!this.state.canPeek) {
return (
<div className="mx_RoomView">
{ previewBar }
</div>
);
} else {
hideRightPanel = true;
}
} else if (hiddenHighlightCount > 0) {
aux = (
<AccessibleButton element="div" className="mx_RoomView_auxPanel_hiddenHighlights"
onClick={this._onHiddenHighlightsClick}>
{_t(
"You have %(count)s unread notifications in a prior version of this room.",
{count: hiddenHighlightCount},
)}
</AccessibleButton>
);
}
const auxPanel = (
<AuxPanel ref="auxPanel" room={this.state.room}
fullHeight={this.state.editingRoomSettings}
fullHeight={false}
userId={MatrixClientPeg.get().credentials.userId}
conferenceHandler={this.props.ConferenceHandler}
draggingFile={this.state.draggingFile}
displayConfCallNotification={this.state.displayConfCallNotification}
maxHeight={this.state.auxPanelMaxHeight}
onResize={this.onChildResize}
showApps={this.state.showApps}
hideAppsDrawer={this.state.editingRoomSettings} >
hideAppsDrawer={false} >
{ aux }
</AuxPanel>
);
@ -1758,21 +1729,14 @@ module.exports = React.createClass({
messageComposer =
<MessageComposer
room={this.state.room}
onResize={this.onChildResize}
uploadFile={this.uploadFile}
callState={this.state.callState}
disabled={this.props.disabled}
showApps={this.state.showApps}
uploadAllowed={this.isFileUploadAllowed}
e2eStatus={this.state.e2eStatus}
permalinkCreator={this._getPermalinkCreatorForRoom(this.state.room)}
/>;
}
if (MatrixClientPeg.get().isGuest()) {
const AuthButtons = sdk.getComponent('views.auth.AuthButtons');
messageComposer = <AuthButtons />;
}
// TODO: Why aren't we storing the term/scope/count in this format
// in this.state if this is what RoomHeader desires?
if (this.state.searchResults) {
@ -1832,7 +1796,7 @@ module.exports = React.createClass({
<ScrollPanel ref="searchResultsPanel"
className="mx_RoomView_messagePanel mx_RoomView_searchResultsPanel"
onFillRequest={this.onSearchResultsFillRequest}
onResize={this.onSearchResultsResize}
resizeNotifier={this.props.resizeNotifier}
>
<li className={scrollheader_classes}></li>
{ this.getSearchResultTiles() }
@ -1866,6 +1830,9 @@ module.exports = React.createClass({
showUrlPreview = {this.state.showUrlPreview}
className="mx_RoomView_messagePanel"
membersLoaded={this.state.membersLoaded}
permalinkCreator={this._getPermalinkCreatorForRoom(this.state.room)}
resizeNotifier={this.props.resizeNotifier}
showReactions={true}
/>);
let topUnreadMessagesBar = null;
@ -1898,26 +1865,29 @@ module.exports = React.createClass({
},
);
const rightPanel = this.state.room ? <RightPanel roomId={this.state.room.roomId} /> : undefined;
const rightPanel = !hideRightPanel && this.state.room &&
<RightPanel roomId={this.state.room.roomId} resizeNotifier={this.props.resizeNotifier} />;
const collapsedRhs = hideRightPanel || this.props.collapsedRhs;
return (
<main className={"mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "")} ref="roomView">
<RoomHeader ref="header" room={this.state.room} searchInfo={searchInfo}
oobData={this.props.oobData}
editing={this.state.editingRoomSettings}
saving={this.state.uploadingRoomSettings}
inRoom={myMembership === 'join'}
collapsedRhs={this.props.collapsedRhs}
collapsedRhs={collapsedRhs}
onSearchClick={this.onSearchClick}
onSettingsClick={this.onSettingsClick}
onPinnedClick={this.onPinnedClick}
onSaveClick={this.onSettingsSaveClick}
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={this.props.collapsedRhs}>
<MainSplit
panel={rightPanel}
collapsedRhs={collapsedRhs}
resizeNotifier={this.props.resizeNotifier}
>
<div className={fadableSectionClasses}>
{ auxPanel }
<div className="mx_RoomView_timeline">
@ -1932,6 +1902,7 @@ module.exports = React.createClass({
{ statusBar }
</div>
</div>
{ previewBar }
{ messageComposer }
</div>
</MainSplit>

View file

@ -15,14 +15,13 @@ limitations under the License.
*/
const React = require("react");
const ReactDOM = require("react-dom");
import PropTypes from 'prop-types';
import Promise from 'bluebird';
import { KeyCode } from '../../Keyboard';
import sdk from '../../index.js';
import Timer from '../../utils/Timer';
import AutoHideScrollbar from "./AutoHideScrollbar";
const DEBUG_SCROLL = false;
// var DEBUG_SCROLL = true;
// The amount of extra scroll distance to allow prior to unfilling.
// See _getExcessHeight.
@ -30,12 +29,18 @@ const UNPAGINATION_PADDING = 6000;
// The number of milliseconds to debounce calls to onUnfillRequest, to prevent
// many scroll events causing many unfilling requests.
const UNFILL_REQUEST_DEBOUNCE_MS = 200;
// _updateHeight makes the height a ceiled multiple of this so we
// don't have to update the height too often. It also allows the user
// to scroll past the pagination spinner a bit so they don't feel blocked so
// much while the content loads.
const PAGE_SIZE = 400;
let debuglog;
if (DEBUG_SCROLL) {
// using bind means that we get to keep useful line numbers in the console
var debuglog = console.log.bind(console);
debuglog = console.log.bind(console, "ScrollPanel debuglog:");
} else {
var debuglog = function() {};
debuglog = function() {};
}
/* This component implements an intelligent scrolling list.
@ -78,6 +83,7 @@ if (DEBUG_SCROLL) {
* scroll down further. If stickyBottom is disabled, we just save the scroll
* offset as normal.
*/
module.exports = React.createClass({
displayName: 'ScrollPanel',
@ -128,11 +134,6 @@ module.exports = React.createClass({
*/
onScroll: PropTypes.func,
/* onResize: a callback which is called whenever the Gemini scroll
* panel is resized
*/
onResize: PropTypes.func,
/* className: classnames to add to the top-level div
*/
className: PropTypes.string,
@ -140,6 +141,9 @@ module.exports = React.createClass({
/* style: styles to add to the top-level div
*/
style: PropTypes.object,
/* resizeNotifier: ResizeNotifier to know when middle column has changed size
*/
resizeNotifier: PropTypes.object,
},
getDefaultProps: function() {
@ -149,12 +153,18 @@ module.exports = React.createClass({
onFillRequest: function(backwards) { return Promise.resolve(false); },
onUnfillRequest: function(backwards, scrollToken) {},
onScroll: function() {},
onResize: function() {},
};
},
componentWillMount: function() {
this._fillRequestWhileRunning = false;
this._isFilling = false;
this._pendingFillRequests = {b: null, f: null};
if (this.props.resizeNotifier) {
this.props.resizeNotifier.on("middlePanelResized", this.onResize);
}
this.resetScrollState();
},
@ -169,6 +179,7 @@ module.exports = React.createClass({
//
// This will also re-check the fill state, in case the paginate was inadequate
this.checkScroll();
this.updatePreventShrinking();
},
componentWillUnmount: function() {
@ -177,56 +188,27 @@ module.exports = React.createClass({
//
// (We could use isMounted(), but facebook have deprecated that.)
this.unmounted = true;
if (this.props.resizeNotifier) {
this.props.resizeNotifier.removeListener("middlePanelResized", this.onResize);
}
},
onScroll: function(ev) {
const sn = this._getScrollNode();
debuglog("Scroll event: offset now:", sn.scrollTop,
"_lastSetScroll:", this._lastSetScroll);
// Sometimes we see attempts to write to scrollTop essentially being
// ignored. (Or rather, it is successfully written, but on the next
// scroll event, it's been reset again).
//
// This was observed on Chrome 47, when scrolling using the trackpad in OS
// X Yosemite. Can't reproduce on El Capitan. Our theory is that this is
// due to Chrome not being able to cope with the scroll offset being reset
// while a two-finger drag is in progress.
//
// By way of a workaround, we detect this situation and just keep
// resetting scrollTop until we see the scroll node have the right
// value.
if (this._lastSetScroll !== undefined && sn.scrollTop < this._lastSetScroll-200) {
console.log("Working around vector-im/vector-web#528");
this._restoreSavedScrollState();
return;
}
// If there weren't enough children to fill the viewport, the scroll we
// got might be different to the scroll we wanted; we don't want to
// forget what we wanted, so don't overwrite the saved state unless
// this appears to be a user-initiated scroll.
if (sn.scrollTop != this._lastSetScroll) {
this._saveScrollState();
} else {
debuglog("Ignoring scroll echo");
// only ignore the echo once, otherwise we'll get confused when the
// user scrolls away from, and back to, the autoscroll point.
this._lastSetScroll = undefined;
}
debuglog("onScroll", this._getScrollNode().scrollTop);
this._scrollTimeout.restart();
this._saveScrollState();
this.updatePreventShrinking();
this.props.onScroll(ev);
this.checkFillState();
},
onResize: function() {
this.props.onResize();
// clear min-height as the height might have changed
this.clearBlockShrinking();
this.checkScroll();
if (this._gemScroll) this._gemScroll.forceUpdate();
// update preventShrinkingState if present
if (this.preventShrinkingState) {
this.preventShrinking();
}
},
// after an update to the contents of the panel, check that the scroll is
@ -239,18 +221,16 @@ module.exports = React.createClass({
// return true if the content is fully scrolled down right now; else false.
//
// note that this is independent of the 'stuckAtBottom' state - it is simply
// about whether the the content is scrolled down right now, irrespective of
// about whether the content is scrolled down right now, irrespective of
// whether it will stay that way when the children update.
isAtBottom: function() {
const sn = this._getScrollNode();
// fractional values (both too big and too small)
// for scrollTop happen on certain browsers/platforms
// when scrolled all the way down. E.g. Chrome 72 on debian.
// so check difference <= 1;
return Math.abs(sn.scrollHeight - (sn.scrollTop + sn.clientHeight)) <= 1;
// there seems to be some bug with flexbox/gemini/chrome/richvdh's
// understanding of the box model, wherein the scrollNode ends up 2
// pixels higher than the available space, even when there are less
// than a screenful of messages. + 3 is a fudge factor to pretend
// that we're at the bottom when we're still a few pixels off.
return sn.scrollHeight - Math.ceil(sn.scrollTop) <= sn.clientHeight + 3;
},
// returns the vertical height in the given direction that can be removed from
@ -286,19 +266,25 @@ module.exports = React.createClass({
// `---------' -
_getExcessHeight: function(backwards) {
const sn = this._getScrollNode();
const contentHeight = this._getMessagesHeight();
const listHeight = this._getListHeight();
const clippedHeight = contentHeight - listHeight;
const unclippedScrollTop = sn.scrollTop + clippedHeight;
if (backwards) {
return sn.scrollTop - sn.clientHeight - UNPAGINATION_PADDING;
return unclippedScrollTop - sn.clientHeight - UNPAGINATION_PADDING;
} else {
return sn.scrollHeight - (sn.scrollTop + 2*sn.clientHeight) - UNPAGINATION_PADDING;
return contentHeight - (unclippedScrollTop + 2*sn.clientHeight) - UNPAGINATION_PADDING;
}
},
// check the scroll state and send out backfill requests if necessary.
checkFillState: function() {
checkFillState: async function(depth=0) {
if (this.unmounted) {
return;
}
const isFirstCall = depth === 0;
const sn = this._getScrollNode();
// if there is less than a screenful of messages above or below the
@ -325,13 +311,53 @@ module.exports = React.createClass({
// `---------' -
//
if (sn.scrollTop < sn.clientHeight) {
// need to back-fill
this._maybeFill(true);
// as filling is async and recursive,
// don't allow more than 1 chain of calls concurrently
// do make a note when a new request comes in while already running one,
// so we can trigger a new chain of calls once done.
if (isFirstCall) {
if (this._isFilling) {
debuglog("_isFilling: not entering while request is ongoing, marking for a subsequent request");
this._fillRequestWhileRunning = true;
return;
}
debuglog("_isFilling: setting");
this._isFilling = true;
}
if (sn.scrollTop > sn.scrollHeight - sn.clientHeight * 2) {
const itemlist = this.refs.itemlist;
const firstTile = itemlist && itemlist.firstElementChild;
const contentTop = firstTile && firstTile.offsetTop;
const fillPromises = [];
// if scrollTop gets to 1 screen from the top of the first tile,
// try backward filling
if (!firstTile || (sn.scrollTop - contentTop) < sn.clientHeight) {
// need to back-fill
fillPromises.push(this._maybeFill(depth, true));
}
// if scrollTop gets to 2 screens from the end (so 1 screen below viewport),
// try forward filling
if ((sn.scrollHeight - sn.scrollTop) < sn.clientHeight * 2) {
// need to forward-fill
this._maybeFill(false);
fillPromises.push(this._maybeFill(depth, false));
}
if (fillPromises.length) {
try {
await Promise.all(fillPromises);
} catch (err) {
console.error(err);
}
}
if (isFirstCall) {
debuglog("_isFilling: clearing");
this._isFilling = false;
}
if (this._fillRequestWhileRunning) {
this._fillRequestWhileRunning = false;
this.checkFillState();
}
},
@ -341,6 +367,9 @@ module.exports = React.createClass({
if (excessHeight <= 0) {
return;
}
const origExcessHeight = excessHeight;
const tiles = this.refs.itemlist.children;
// The scroll token of the first/last tile to be unpaginated
@ -352,8 +381,9 @@ module.exports = React.createClass({
// pagination.
//
// If backwards is true, we unpaginate (remove) tiles from the back (top).
let tile;
for (let i = 0; i < tiles.length; i++) {
const tile = tiles[backwards ? i : tiles.length - 1 - i];
tile = tiles[backwards ? i : tiles.length - 1 - i];
// Subtract height of tile as if it were unpaginated
excessHeight -= tile.clientHeight;
//If removing the tile would lead to future pagination, break before setting scroll token
@ -374,28 +404,31 @@ module.exports = React.createClass({
}
this._unfillDebouncer = setTimeout(() => {
this._unfillDebouncer = null;
// if timeline shrinks, min-height should be cleared
this.clearBlockShrinking();
debuglog("unfilling now", backwards, origExcessHeight);
this.props.onUnfillRequest(backwards, markerScrollToken);
}, UNFILL_REQUEST_DEBOUNCE_MS);
}
},
// check if there is already a pending fill request. If not, set one off.
_maybeFill: function(backwards) {
_maybeFill: function(depth, backwards) {
const dir = backwards ? 'b' : 'f';
if (this._pendingFillRequests[dir]) {
debuglog("ScrollPanel: Already a "+dir+" fill in progress - not starting another");
debuglog("Already a "+dir+" fill in progress - not starting another");
return;
}
debuglog("ScrollPanel: starting "+dir+" fill");
debuglog("starting "+dir+" fill");
// onFillRequest can end up calling us recursively (via onScroll
// events) so make sure we set this before firing off the call.
this._pendingFillRequests[dir] = true;
Promise.try(() => {
// wait 1ms before paginating, because otherwise
// this will block the scroll event handler for +700ms
// if messages are already cached in memory,
// This would cause jumping to happen on Chrome/macOS.
return new Promise(resolve => setTimeout(resolve, 1)).then(() => {
return this.props.onFillRequest(backwards);
}).finally(() => {
this._pendingFillRequests[dir] = false;
@ -406,14 +439,14 @@ module.exports = React.createClass({
// Unpaginate once filling is complete
this._checkUnfillState(!backwards);
debuglog("ScrollPanel: "+dir+" fill complete; hasMoreResults:"+hasMoreResults);
debuglog(""+dir+" fill complete; hasMoreResults:"+hasMoreResults);
if (hasMoreResults) {
// further pagination requests have been disabled until now, so
// it's time to check the fill state again in case the pagination
// was insufficient.
this.checkFillState();
return this.checkFillState(depth + 1);
}
}).done();
});
},
/* get the current scroll state. This returns an object with the following
@ -426,7 +459,7 @@ module.exports = React.createClass({
* false, the first token in data-scroll-tokens of the child which we are
* tracking.
*
* number pixelOffset: undefined if stuckAtBottom is true; if it is false,
* number bottomOffset: undefined if stuckAtBottom is true; if it is false,
* the number of pixels the bottom of the tracked child is above the
* bottom of the scroll panel.
*/
@ -447,14 +480,20 @@ module.exports = React.createClass({
* child list.)
*/
resetScrollState: function() {
this.scrollState = {stuckAtBottom: this.props.startAtBottom};
this.scrollState = {
stuckAtBottom: this.props.startAtBottom,
};
this._bottomGrowth = 0;
this._pages = 0;
this._scrollTimeout = new Timer(100);
this._heightUpdateInProgress = false;
},
/**
* jump to the top of the content.
*/
scrollToTop: function() {
this._setScrollTop(0);
this._getScrollNode().scrollTop = 0;
this._saveScrollState();
},
@ -466,24 +505,26 @@ module.exports = React.createClass({
// saved is to do the scroll, then save the updated state. (Calculating
// it ourselves is hard, and we can't rely on an onScroll callback
// happening, since there may be no user-visible change here).
this._setScrollTop(Number.MAX_VALUE);
const sn = this._getScrollNode();
sn.scrollTop = sn.scrollHeight;
this._saveScrollState();
},
/**
* Page up/down.
*
* mult: -1 to page up, +1 to page down
* @param {number} mult: -1 to page up, +1 to page down
*/
scrollRelative: function(mult) {
const scrollNode = this._getScrollNode();
const delta = mult * scrollNode.clientHeight * 0.5;
this._setScrollTop(scrollNode.scrollTop + delta);
scrollNode.scrollTop = scrollNode.scrollTop + delta;
this._saveScrollState();
},
/**
* Scroll up/down in response to a scroll key
* @param {object} ev the keyboard event
*/
handleScrollKey: function(ev) {
switch (ev.keyCode) {
@ -528,135 +569,193 @@ module.exports = React.createClass({
pixelOffset = pixelOffset || 0;
offsetBase = offsetBase || 0;
// convert pixelOffset so that it is based on the bottom of the
// container.
pixelOffset += this._getScrollNode().clientHeight * (1-offsetBase);
// save the desired scroll state. It's important we do this here rather
// than as a result of the scroll event, because (a) we might not *get*
// a scroll event, and (b) it might not currently be possible to set
// the requested scroll state (eg, because we hit the end of the
// timeline and need to do more pagination); we want to save the
// *desired* scroll state rather than what we end up achieving.
// set the trackedScrollToken so we can get the node through _getTrackedNode
this.scrollState = {
stuckAtBottom: false,
trackedScrollToken: scrollToken,
pixelOffset: pixelOffset,
};
// ... then make it so.
this._restoreSavedScrollState();
},
// set the scrollTop attribute appropriately to position the given child at the
// given offset in the window. A helper for _restoreSavedScrollState.
_scrollToToken: function(scrollToken, pixelOffset) {
/* find the dom node with the right scrolltoken */
let node;
const messages = this.refs.itemlist.children;
for (let i = messages.length-1; i >= 0; --i) {
const m = messages[i];
// 'data-scroll-tokens' is a DOMString of comma-separated scroll tokens
// There might only be one scroll token
if (m.dataset.scrollTokens &&
m.dataset.scrollTokens.split(',').indexOf(scrollToken) !== -1) {
node = m;
break;
}
}
if (!node) {
debuglog("ScrollPanel: No node with scrollToken '"+scrollToken+"'");
return;
}
const trackedNode = this._getTrackedNode();
const scrollNode = this._getScrollNode();
const wrapperRect = ReactDOM.findDOMNode(this).getBoundingClientRect();
const boundingRect = node.getBoundingClientRect();
const scrollDelta = boundingRect.bottom + pixelOffset - wrapperRect.bottom;
debuglog("ScrollPanel: scrolling to token '" + scrollToken + "'+" +
pixelOffset + " (delta: "+scrollDelta+")");
if (scrollDelta != 0) {
this._setScrollTop(scrollNode.scrollTop + scrollDelta);
if (trackedNode) {
// set the scrollTop to the position we want.
// note though, that this might not succeed if the combination of offsetBase and pixelOffset
// would position the trackedNode towards the top of the viewport.
// This because when setting the scrollTop only 10 or so events might be loaded,
// not giving enough content below the trackedNode to scroll downwards
// enough so it ends up in the top of the viewport.
debuglog("scrollToken: setting scrollTop", {offsetBase, pixelOffset, offsetTop: trackedNode.offsetTop});
scrollNode.scrollTop = (trackedNode.offsetTop - (scrollNode.clientHeight * offsetBase)) + pixelOffset;
this._saveScrollState();
}
},
_saveScrollState: function() {
if (this.props.stickyBottom && this.isAtBottom()) {
this.scrollState = { stuckAtBottom: true };
debuglog("ScrollPanel: Saved scroll state", this.scrollState);
debuglog("saved stuckAtBottom state");
return;
}
const scrollNode = this._getScrollNode();
const viewportBottom = scrollNode.scrollHeight - (scrollNode.scrollTop + scrollNode.clientHeight);
const itemlist = this.refs.itemlist;
const wrapperRect = ReactDOM.findDOMNode(this).getBoundingClientRect();
const messages = itemlist.children;
let newScrollState = null;
let node = null;
// TODO: do a binary search here, as items are sorted by offsetTop
// loop backwards, from bottom-most message (as that is the most common case)
for (let i = messages.length-1; i >= 0; --i) {
const node = messages[i];
if (!node.dataset.scrollTokens) continue;
const boundingRect = node.getBoundingClientRect();
newScrollState = {
stuckAtBottom: false,
trackedScrollToken: node.dataset.scrollTokens.split(',')[0],
pixelOffset: wrapperRect.bottom - boundingRect.bottom,
};
// If the bottom of the panel intersects the ClientRect of node, use this node
// as the scrollToken.
// If this is false for the entire for-loop, we default to the last node
// (which is why newScrollState is set on every iteration).
if (boundingRect.top < wrapperRect.bottom) {
if (!messages[i].dataset.scrollTokens) {
continue;
}
node = messages[i];
// break at the first message (coming from the bottom)
// that has it's offsetTop above the bottom of the viewport.
if (this._topFromBottom(node) > viewportBottom) {
// Use this node as the scrollToken
break;
}
}
// This is only false if there were no nodes with `node.dataset.scrollTokens` set.
if (newScrollState) {
this.scrollState = newScrollState;
debuglog("ScrollPanel: saved scroll state", this.scrollState);
} else {
debuglog("ScrollPanel: unable to save scroll state: found no children in the viewport");
if (!node) {
debuglog("unable to save scroll state: found no children in the viewport");
return;
}
const scrollToken = node.dataset.scrollTokens.split(',')[0];
debuglog("saving anchored scroll state to message", node && node.innerText, scrollToken);
const bottomOffset = this._topFromBottom(node);
this.scrollState = {
stuckAtBottom: false,
trackedNode: node,
trackedScrollToken: scrollToken,
bottomOffset: bottomOffset,
pixelOffset: bottomOffset - viewportBottom, //needed for restoring the scroll position when coming back to the room
};
},
_restoreSavedScrollState: function() {
_restoreSavedScrollState: async function() {
const scrollState = this.scrollState;
const scrollNode = this._getScrollNode();
if (scrollState.stuckAtBottom) {
this._setScrollTop(Number.MAX_VALUE);
const sn = this._getScrollNode();
sn.scrollTop = sn.scrollHeight;
} else if (scrollState.trackedScrollToken) {
this._scrollToToken(scrollState.trackedScrollToken,
scrollState.pixelOffset);
const itemlist = this.refs.itemlist;
const trackedNode = this._getTrackedNode();
if (trackedNode) {
const newBottomOffset = this._topFromBottom(trackedNode);
const bottomDiff = newBottomOffset - scrollState.bottomOffset;
this._bottomGrowth += bottomDiff;
scrollState.bottomOffset = newBottomOffset;
itemlist.style.height = `${this._getListHeight()}px`;
debuglog("balancing height because messages below viewport grew by", bottomDiff);
}
}
if (!this._heightUpdateInProgress) {
this._heightUpdateInProgress = true;
try {
await this._updateHeight();
} finally {
this._heightUpdateInProgress = false;
}
} else {
debuglog("not updating height because request already in progress");
}
},
// need a better name that also indicates this will change scrollTop? Rebalance height? Reveal content?
async _updateHeight() {
// wait until user has stopped scrolling
if (this._scrollTimeout.isRunning()) {
debuglog("updateHeight waiting for scrolling to end ... ");
await this._scrollTimeout.finished();
} else {
debuglog("updateHeight getting straight to business, no scrolling going on.");
}
const sn = this._getScrollNode();
const itemlist = this.refs.itemlist;
const contentHeight = this._getMessagesHeight();
const minHeight = sn.clientHeight;
const height = Math.max(minHeight, contentHeight);
this._pages = Math.ceil(height / PAGE_SIZE);
this._bottomGrowth = 0;
const newHeight = this._getListHeight();
const scrollState = this.scrollState;
if (scrollState.stuckAtBottom) {
itemlist.style.height = `${newHeight}px`;
sn.scrollTop = sn.scrollHeight;
debuglog("updateHeight to", newHeight);
} else if (scrollState.trackedScrollToken) {
const trackedNode = this._getTrackedNode();
// if the timeline has been reloaded
// this can be called before scrollToBottom or whatever has been called
// so don't do anything if the node has disappeared from
// the currently filled piece of the timeline
if (trackedNode) {
const oldTop = trackedNode.offsetTop;
// changing the height might change the scrollTop
// if the new height is smaller than the scrollTop.
// We calculate the diff that needs to be applied
// ourselves, so be sure to measure the
// scrollTop before changing the height.
const preexistingScrollTop = sn.scrollTop;
itemlist.style.height = `${newHeight}px`;
const newTop = trackedNode.offsetTop;
const topDiff = newTop - oldTop;
sn.scrollTop = preexistingScrollTop + topDiff;
debuglog("updateHeight to", {newHeight, topDiff, preexistingScrollTop});
}
}
},
_setScrollTop: function(scrollTop) {
const scrollNode = this._getScrollNode();
_getTrackedNode() {
const scrollState = this.scrollState;
const trackedNode = scrollState.trackedNode;
const prevScroll = scrollNode.scrollTop;
if (!trackedNode || !trackedNode.parentElement) {
let node;
const messages = this.refs.itemlist.children;
const scrollToken = scrollState.trackedScrollToken;
// FF ignores attempts to set scrollTop to very large numbers
scrollNode.scrollTop = Math.min(scrollTop, scrollNode.scrollHeight);
// If this change generates a scroll event, we should not update the
// saved scroll state on it. See the comments in onScroll.
//
// If we *don't* expect a scroll event, we need to leave _lastSetScroll
// alone, otherwise we'll end up ignoring a future scroll event which is
// nothing to do with this change.
if (scrollNode.scrollTop != prevScroll) {
this._lastSetScroll = scrollNode.scrollTop;
for (let i = messages.length-1; i >= 0; --i) {
const m = messages[i];
// 'data-scroll-tokens' is a DOMString of comma-separated scroll tokens
// There might only be one scroll token
if (m.dataset.scrollTokens &&
m.dataset.scrollTokens.split(',').indexOf(scrollToken) !== -1) {
node = m;
break;
}
}
if (node) {
debuglog("had to find tracked node again for " + scrollState.trackedScrollToken);
}
scrollState.trackedNode = node;
}
debuglog("ScrollPanel: set scrollTop:", scrollNode.scrollTop,
"requested:", scrollTop,
"_lastSetScroll:", this._lastSetScroll);
if (!scrollState.trackedNode) {
debuglog("No node with ; '"+scrollState.trackedScrollToken+"'");
return;
}
return scrollState.trackedNode;
},
_getListHeight() {
return this._bottomGrowth + (this._pages * PAGE_SIZE);
},
_getMessagesHeight() {
const itemlist = this.refs.itemlist;
const lastNode = itemlist.lastElementChild;
// 18 is itemlist padding
return (lastNode.offsetTop + lastNode.clientHeight) - itemlist.firstElementChild.offsetTop + (18 * 2);
},
_topFromBottom(node) {
return this.refs.itemlist.clientHeight - node.offsetTop;
},
/* get the DOM node which has the scrollTop property we care about for our
@ -669,56 +768,112 @@ module.exports = React.createClass({
throw new Error("ScrollPanel._getScrollNode called when unmounted");
}
if (!this._gemScroll) {
if (!this._divScroll) {
// Likewise, we should have the ref by this point, but if not
// turn the NPE into something meaningful.
throw new Error("ScrollPanel._getScrollNode called before gemini ref collected");
}
return this._gemScroll.scrollbar.getViewElement();
return this._divScroll;
},
_collectGeminiScroll: function(gemScroll) {
this._gemScroll = gemScroll;
_collectScroll: function(divScroll) {
this._divScroll = divScroll;
},
/**
* Set the current height as the min height for the message list
* so the timeline cannot shrink. This is used to avoid
* jumping when the typing indicator gets replaced by a smaller message.
*/
blockShrinking: function() {
Mark the bottom offset of the last tile so we can balance it out when
anything below it changes, by calling updatePreventShrinking, to keep
the same minimum bottom offset, effectively preventing the timeline to shrink.
*/
preventShrinking: function() {
const messageList = this.refs.itemlist;
if (messageList) {
const currentHeight = messageList.clientHeight;
messageList.style.minHeight = `${currentHeight}px`;
const tiles = messageList && messageList.children;
if (!messageList) {
return;
}
let lastTileNode;
for (let i = tiles.length - 1; i >= 0; i--) {
const node = tiles[i];
if (node.dataset.scrollTokens) {
lastTileNode = node;
break;
}
}
if (!lastTileNode) {
return;
}
this.clearPreventShrinking();
const offsetFromBottom = messageList.clientHeight - (lastTileNode.offsetTop + lastTileNode.clientHeight);
this.preventShrinkingState = {
offsetFromBottom: offsetFromBottom,
offsetNode: lastTileNode,
};
debuglog("prevent shrinking, last tile ", offsetFromBottom, "px from bottom");
},
/** Clear shrinking prevention. Used internally, and when the timeline is reloaded. */
clearPreventShrinking: function() {
const messageList = this.refs.itemlist;
const balanceElement = messageList && messageList.parentElement;
if (balanceElement) balanceElement.style.paddingBottom = null;
this.preventShrinkingState = null;
debuglog("prevent shrinking cleared");
},
/**
* Clear the previously set min height
*/
clearBlockShrinking: function() {
const messageList = this.refs.itemlist;
if (messageList) {
messageList.style.minHeight = null;
update the container padding to balance
the bottom offset of the last tile since
preventShrinking was called.
Clears the prevent-shrinking state ones the offset
from the bottom of the marked tile grows larger than
what it was when marking.
*/
updatePreventShrinking: function() {
if (this.preventShrinkingState) {
const sn = this._getScrollNode();
const scrollState = this.scrollState;
const messageList = this.refs.itemlist;
const {offsetNode, offsetFromBottom} = this.preventShrinkingState;
// element used to set paddingBottom to balance the typing notifs disappearing
const balanceElement = messageList.parentElement;
// if the offsetNode got unmounted, clear
let shouldClear = !offsetNode.parentElement;
// also if 200px from bottom
if (!shouldClear && !scrollState.stuckAtBottom) {
const spaceBelowViewport = sn.scrollHeight - (sn.scrollTop + sn.clientHeight);
shouldClear = spaceBelowViewport >= 200;
}
// try updating if not clearing
if (!shouldClear) {
const currentOffset = messageList.clientHeight - (offsetNode.offsetTop + offsetNode.clientHeight);
const offsetDiff = offsetFromBottom - currentOffset;
if (offsetDiff > 0) {
balanceElement.style.paddingBottom = `${offsetDiff}px`;
debuglog("update prevent shrinking ", offsetDiff, "px from bottom");
} else if (offsetDiff < 0) {
shouldClear = true;
}
}
if (shouldClear) {
this.clearPreventShrinking();
}
}
},
render: function() {
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
// TODO: the classnames on the div and ol could do with being updated to
// reflect the fact that we don't necessarily contain a list of messages.
// it's not obvious why we have a separate div and ol anyway.
return (<GeminiScrollbarWrapper autoshow={true} wrappedRef={this._collectGeminiScroll}
onScroll={this.onScroll} onResize={this.onResize}
className={this.props.className} style={this.props.style}>
return (<AutoHideScrollbar wrappedRef={this._collectScroll}
onScroll={this.onScroll}
className={`mx_ScrollPanel ${this.props.className}`} style={this.props.style}>
<div className="mx_RoomView_messageListWrapper">
<ol ref="itemlist" className="mx_RoomView_MessageList" aria-live="polite">
{ this.props.children }
</ol>
</div>
</GeminiScrollbarWrapper>
);
</AutoHideScrollbar>
);
},
});

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,22 +15,32 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import React from 'react';
import { _t } from '../../languageHandler';
import PropTypes from 'prop-types';
import { KeyCode } from '../../Keyboard';
import sdk from '../../index';
import dis from '../../dispatcher';
import rate_limited_func from '../../ratelimitedfunc';
import { throttle } from 'lodash';
import AccessibleButton from '../../components/views/elements/AccessibleButton';
module.exports = React.createClass({
displayName: 'SearchBox',
propTypes: {
onSearch: React.PropTypes.func,
onCleared: React.PropTypes.func,
onSearch: PropTypes.func,
onCleared: PropTypes.func,
className: PropTypes.string,
placeholder: PropTypes.string.isRequired,
// If true, the search box will focus and clear itself
// on room search focus action (it would be nicer to take
// this functionality out, but not obvious how that would work)
enableRoomSearchFocus: PropTypes.bool,
},
getDefaultProps: function() {
return {
enableRoomSearchFocus: false,
};
},
getInitialState: function() {
@ -47,6 +58,8 @@ module.exports = React.createClass({
},
onAction: function(payload) {
if (!this.props.enableRoomSearchFocus) return;
switch (payload.action) {
case 'view_room':
if (this.refs.search && payload.clear_search) {
@ -67,12 +80,9 @@ module.exports = React.createClass({
this.onSearch();
},
onSearch: new rate_limited_func(
function() {
this.props.onSearch(this.refs.search.value);
},
100,
),
onSearch: throttle(function() {
this.props.onSearch(this.refs.search.value);
}, 200, {trailing: true, leading: true}),
_onKeyDown: function(ev) {
switch (ev.keyCode) {
@ -95,26 +105,32 @@ module.exports = React.createClass({
},
render: function() {
const TintableSvg = sdk.getComponent('elements.TintableSvg');
// check for collapsed here and
// not at parent so we keep
// searchTerm in our state
// when collapsing and expanding
if (this.props.collapsed) {
return null;
}
const clearButton = this.state.searchTerm.length > 0 ?
(<AccessibleButton key="button"
className="mx_SearchBox_closeButton"
onClick={ () => {this._clearSearch("button")} }>
</AccessibleButton>) : undefined;
onClick={ () => {this._clearSearch("button"); } }>
</AccessibleButton>) : undefined;
const className = this.props.className || "";
return (
<div className="mx_SearchBox mx_textinput">
<input
key="searchfield"
type="text"
ref="search"
className="mx_textinput_icon mx_textinput_search"
className={"mx_textinput_icon mx_textinput_search " + className}
value={ this.state.searchTerm }
onFocus={ this._onFocus }
onChange={ this.onChange }
onKeyDown={ this._onKeyDown }
placeholder={ _t('Filter room names') }
placeholder={ this.props.placeholder }
/>
{ clearButton }
</div>

View file

@ -74,7 +74,6 @@ export class TabbedView extends React.Component {
const idx = this.props.tabs.indexOf(tab);
if (idx === this._getActiveTabIndex()) classes += "mx_TabbedView_tabLabel_active";
if (tab.label === "Visit old settings") classes += "mx_TabbedView_tabLabel_TEMP_HACK";
let tabIcon = null;
if (tab.icon) {

View file

@ -23,7 +23,6 @@ import GroupActions from '../../actions/GroupActions';
import sdk from '../../index';
import dis from '../../dispatcher';
import Modal from '../../Modal';
import { _t } from '../../languageHandler';
import { Droppable } from 'react-beautiful-dnd';
@ -48,8 +47,6 @@ const TagPanel = React.createClass({
this.context.matrixClient.on("Group.myMembership", this._onGroupMyMembership);
this.context.matrixClient.on("sync", this._onClientSync);
this._dispatcherRef = dis.register(this._onAction);
this._tagOrderStoreToken = TagOrderStore.addListener(() => {
if (this.unmounted) {
return;
@ -70,9 +67,6 @@ const TagPanel = React.createClass({
if (this._filterStoreToken) {
this._filterStoreToken.remove();
}
if (this._dispatcherRef) {
dis.unregister(this._dispatcherRef);
}
},
_onGroupMyMembership() {
@ -106,21 +100,11 @@ const TagPanel = React.createClass({
dis.dispatch({action: 'deselect_tags'});
},
_onAction(payload) {
if (payload.action === "show_redesign_feedback_dialog") {
const RedesignFeedbackDialog =
sdk.getComponent("views.dialogs.RedesignFeedbackDialog");
Modal.createDialog(RedesignFeedbackDialog);
}
},
render() {
const GroupsButton = sdk.getComponent('elements.GroupsButton');
const DNDTagTile = sdk.getComponent('elements.DNDTagTile');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const TintableSvg = sdk.getComponent('elements.TintableSvg');
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
const ActionButton = sdk.getComponent("elements.ActionButton");
const tags = this.state.orderedTags.map((tag, index) => {
return <DNDTagTile
@ -174,13 +158,6 @@ const TagPanel = React.createClass({
) }
</Droppable>
</GeminiScrollbarWrapper>
<div className="mx_TagPanel_divider" />
<div className="mx_TagPanel_groupsButton">
<GroupsButton />
<ActionButton
className="mx_TagPanel_report" action="show_redesign_feedback_dialog"
label={_t("Report bugs & give feedback")} tooltip={true} />
</div>
</div>;
},
});

View file

@ -0,0 +1,58 @@
/*
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.
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 dis from '../../dispatcher';
import Modal from '../../Modal';
import { _t } from '../../languageHandler';
const TagPanelButtons = React.createClass({
displayName: 'TagPanelButtons',
componentWillMount: function() {
this._dispatcherRef = dis.register(this._onAction);
},
componentWillUnmount() {
if (this._dispatcherRef) {
dis.unregister(this._dispatcherRef);
this._dispatcherRef = null;
}
},
_onAction(payload) {
if (payload.action === "show_redesign_feedback_dialog") {
const RedesignFeedbackDialog =
sdk.getComponent("views.dialogs.RedesignFeedbackDialog");
Modal.createTrackedDialog('Report bugs & give feedback', '', RedesignFeedbackDialog);
}
},
render() {
const GroupsButton = sdk.getComponent('elements.GroupsButton');
const ActionButton = sdk.getComponent("elements.ActionButton");
return (<div className="mx_TagPanelButtons">
<GroupsButton />
<ActionButton
className="mx_TagPanelButtons_report" action="show_redesign_feedback_dialog"
label={_t("Report bugs & give feedback")} tooltip={true} />
</div>);
},
});
export default TagPanelButtons;

View file

@ -1,6 +1,7 @@
/*
Copyright 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.
@ -43,11 +44,10 @@ const READ_RECEIPT_INTERVAL_MS = 500;
const DEBUG = false;
let debuglog = function() {};
if (DEBUG) {
// using bind means that we get to keep useful line numbers in the console
var debuglog = console.log.bind(console);
} else {
var debuglog = function() {};
debuglog = console.log.bind(console);
}
/*
@ -55,7 +55,7 @@ if (DEBUG) {
*
* Also responsible for handling and sending read receipts.
*/
var TimelinePanel = React.createClass({
const TimelinePanel = React.createClass({
displayName: 'TimelinePanel',
propTypes: {
@ -106,6 +106,9 @@ var 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: {
@ -208,6 +211,7 @@ var TimelinePanel = React.createClass({
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);
@ -286,6 +290,7 @@ var TimelinePanel = React.createClass({
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);
}
},
@ -402,6 +407,15 @@ var TimelinePanel = React.createClass({
if (payload.action === 'ignore_state_changed') {
this.forceUpdate();
}
if (payload.action === "edit_event") {
this.setState({editEvent: payload.event}, () => {
if (payload.event && this.refs.messagePanel) {
this.refs.messagePanel.scrollToEventIfNeeded(
payload.event.getId(),
);
}
});
}
},
onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) {
@ -444,6 +458,7 @@ var TimelinePanel = React.createClass({
const updatedState = {events: events};
let callRMUpdated;
if (this.props.manageReadMarkers) {
// when a new event arrives when the user is not watching the
// window, but the window is in its auto-scroll mode, make sure the
@ -451,12 +466,12 @@ var TimelinePanel = React.createClass({
//
// We ignore events we have sent ourselves; we don't want to see the
// read-marker when a remote echo of an event we have just sent takes
// more than the timeout on userCurrentlyActive.
// more than the timeout on userActiveRecently.
//
const myUserId = MatrixClientPeg.get().credentials.userId;
const sender = ev.sender ? ev.sender.userId : null;
var callRMUpdated = false;
if (sender != myUserId && !UserActivity.userCurrentlyActive()) {
callRMUpdated = false;
if (sender != myUserId && !UserActivity.sharedInstance().userActiveRecently()) {
updatedState.readMarkerVisible = true;
} else if (lastEv && this.getReadMarkerPosition() === 0) {
// we know we're stuckAtBottom, so we can advance the RM
@ -501,6 +516,17 @@ var 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;
@ -536,6 +562,9 @@ var 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.
@ -562,10 +591,10 @@ var TimelinePanel = React.createClass({
this._readMarkerActivityTimer = new Timer(initialTimeout);
while (this._readMarkerActivityTimer) { //unset on unmount
UserActivity.timeWhileActive(this._readMarkerActivityTimer);
UserActivity.sharedInstance().timeWhileActiveRecently(this._readMarkerActivityTimer);
try {
await this._readMarkerActivityTimer.finished();
} catch(e) { continue; /* aborted */ }
} catch (e) { continue; /* aborted */ }
// outside of try/catch to not swallow errors
this.updateReadMarker();
}
@ -574,10 +603,10 @@ var TimelinePanel = React.createClass({
updateReadReceiptOnUserActivity: async function() {
this._readReceiptActivityTimer = new Timer(READ_RECEIPT_INTERVAL_MS);
while (this._readReceiptActivityTimer) { //unset on unmount
UserActivity.timeWhileActive(this._readReceiptActivityTimer);
UserActivity.sharedInstance().timeWhileActiveNow(this._readReceiptActivityTimer);
try {
await this._readReceiptActivityTimer.finished();
} catch(e) { continue; /* aborted */ }
} catch (e) { continue; /* aborted */ }
// outside of try/catch to not swallow errors
this.sendReadReceipt();
}
@ -733,7 +762,8 @@ var TimelinePanel = React.createClass({
const events = this._timelineWindow.getEvents();
// first find where the current RM is
for (var i = 0; i < events.length; i++) {
let i;
for (i = 0; i < events.length; i++) {
if (events[i].getId() == this.state.readMarkerEventId) {
break;
}
@ -745,7 +775,7 @@ var TimelinePanel = React.createClass({
// now think about advancing it
const myUserId = MatrixClientPeg.get().credentials.userId;
for (i++; i < events.length; i++) {
var ev = events[i];
const ev = events[i];
if (!ev.sender || ev.sender.userId != myUserId) {
break;
}
@ -753,7 +783,7 @@ var TimelinePanel = React.createClass({
// i is now the first unread message which we didn't send ourselves.
i--;
var ev = events[i];
const ev = events[i];
this._setReadMarker(ev.getId(), ev.getTs());
},
@ -883,7 +913,7 @@ var TimelinePanel = React.createClass({
return ret;
},
/**
/*
* called by the parent component when PageUp/Down/etc is pressed.
*
* We pass it down to the scroll panel.
@ -937,6 +967,11 @@ var TimelinePanel = React.createClass({
{windowLimit: this.props.timelineCap});
const onLoaded = () => {
// clear the timeline min-height when
// (re)loading the timeline
if (this.refs.messagePanel) {
this.refs.messagePanel.onTimelineReset();
}
this._reloadEvents();
// If we switched away from the room while there were pending
@ -971,11 +1006,10 @@ var TimelinePanel = React.createClass({
};
const onError = (error) => {
this.setState({timelineLoading: false});
this.setState({ timelineLoading: false });
console.error(
`Error loading timeline panel at ${eventId}: ${error}`,
);
const msg = error.message ? error.message : JSON.stringify(error);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
let onFinished;
@ -993,9 +1027,18 @@ var TimelinePanel = React.createClass({
});
};
}
const message = (error.errcode == 'M_FORBIDDEN')
? _t("Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.")
: _t("Tried to load a specific point in this room's timeline, but was unable to find it.");
let message;
if (error.errcode == 'M_FORBIDDEN') {
message = _t(
"Tried to load a specific point in this room's timeline, but you " +
"do not have permission to view the message in question.",
);
} else {
message = _t(
"Tried to load a specific point in this room's timeline, but was " +
"unable to find it.",
);
}
Modal.createTrackedDialog('Failed to load timeline position', '', ErrorDialog, {
title: _t("Failed to load timeline position"),
description: message,
@ -1100,12 +1143,13 @@ var TimelinePanel = React.createClass({
},
/**
* get the id of the event corresponding to our user's latest read-receipt.
* Get the id of the event corresponding to our user's latest read-receipt.
*
* @param {Boolean} ignoreSynthesized If true, return only receipts that
* have been sent by the server, not
* implicit ones generated by the JS
* SDK.
* @return {String} the event ID
*/
_getCurrentReadReceipt: function(ignoreSynthesized) {
const client = MatrixClientPeg.get();
@ -1154,6 +1198,10 @@ var 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");
@ -1179,9 +1227,9 @@ var 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>
);
}
@ -1203,26 +1251,31 @@ var TimelinePanel = React.createClass({
);
return (
<MessagePanel ref="messagePanel"
room={this.props.timelineSet.room}
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}
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}
editEvent={this.state.editEvent}
showReactions={this.props.showReactions}
/>
);
},

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,17 +74,29 @@ export default class TopLeftMenuButton extends React.Component {
}
}
render() {
const fallbackUserId = MatrixClientPeg.get().getUserId();
const profileInfo = this.state.profileInfo;
let name;
if (MatrixClientPeg.get().isGuest()) {
name = _t("Guest");
} else if (profileInfo) {
name = profileInfo.name;
} else {
name = fallbackUserId;
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");
} else if (this.state.profileInfo) {
return this.state.profileInfo.name;
} else {
return MatrixClientPeg.get().getUserId();
}
}
render() {
const name = this._getDisplayName();
let nameElement;
if (!this.props.collapsed) {
nameElement = <div className="mx_TopLeftMenuButton_name">
@ -87,17 +105,23 @@ export default class TopLeftMenuButton extends React.Component {
}
return (
<AccessibleButton className="mx_TopLeftMenuButton" onClick={this.onToggleMenu}>
<AccessibleButton
className="mx_TopLeftMenuButton"
role="button"
onClick={this.onToggleMenu}
inputRef={(r) => this._buttonRef = r}
aria-label={_t("Your profile")}
>
<BaseAvatar
idName={fallbackUserId}
idName={MatrixClientPeg.get().getUserId()}
name={name}
url={profileInfo && profileInfo.avatarUrl}
url={this.state.profileInfo && this.state.profileInfo.avatarUrl}
width={AVATAR_SIZE}
height={AVATAR_SIZE}
resizeMethod="crop"
/>
{ nameElement }
<span className="mx_TopLeftMenuButton_chevron"></span>
<span className="mx_TopLeftMenuButton_chevron" />
</AccessibleButton>
);
}
@ -106,18 +130,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

@ -16,7 +16,7 @@ limitations under the License.
const React = require('react');
import PropTypes from 'prop-types';
const ContentMessages = require('../../ContentMessages');
import ContentMessages from '../../ContentMessages';
const dis = require('../../dispatcher');
const filesize = require('filesize');
import { _t } from '../../languageHandler';
@ -40,6 +40,7 @@ module.exports = React.createClass({displayName: 'UploadBar',
switch (payload.action) {
case 'upload_progress':
case 'upload_finished':
case 'upload_canceled':
case 'upload_failed':
if (this.mounted) this.forceUpdate();
break;
@ -47,7 +48,7 @@ module.exports = React.createClass({displayName: 'UploadBar',
},
render: function() {
const uploads = ContentMessages.getCurrentUploads();
const uploads = ContentMessages.sharedInstance().getCurrentUploads();
// for testing UI... - also fix up the ContentMessages.getCurrentUploads().length
// check in RoomView
@ -93,7 +94,7 @@ module.exports = React.createClass({displayName: 'UploadBar',
</div>
<img className="mx_UploadBar_uploadIcon mx_filterFlipColor" src={require("../../../res/img/fileicon.png")} width="17" height="22" />
<img className="mx_UploadBar_uploadCancel mx_filterFlipColor" src={require("../../../res/img/cancel.svg")} width="18" height="18"
onClick={function() { ContentMessages.cancelUpload(upload.promise); }}
onClick={function() { ContentMessages.sharedInstance().cancelUpload(upload.promise); }}
/>
<div className="mx_UploadBar_uploadBytes">
{ uploadedSize } / { totalSize }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,82 @@
/*
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.
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 Matrix from "matrix-js-sdk";
import MatrixClientPeg from "../../MatrixClientPeg";
import sdk from "../../index";
import Modal from '../../Modal';
import { _t } from '../../languageHandler';
export default class UserView extends React.Component {
static get propTypes() {
return {
userId: React.PropTypes.string,
};
}
constructor(props) {
super(props);
this.state = {};
}
componentWillMount() {
if (this.props.userId) {
this._loadProfileInfo();
}
}
componentDidUpdate(prevProps) {
if (prevProps.userId !== this.props.userId) {
this._loadProfileInfo();
}
}
async _loadProfileInfo() {
const cli = MatrixClientPeg.get();
this.setState({loading: true});
let profileInfo;
try {
profileInfo = await cli.getProfileInfo(this.props.userId);
} catch (err) {
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
Modal.createTrackedDialog(_t('Could not load user profile'), '', ErrorDialog, {
title: _t('Could not load user profile'),
description: ((err && err.message) ? err.message : _t("Operation failed")),
});
this.setState({loading: false});
return;
}
const fakeEvent = new Matrix.MatrixEvent({type: "m.room.member", content: profileInfo});
const member = new Matrix.RoomMember(null, this.props.userId);
member.setMembershipEvent(fakeEvent);
this.setState({member, loading: false});
}
render() {
if (this.state.loading) {
const Spinner = sdk.getComponent("elements.Spinner");
return <Spinner />;
} else if (this.state.member) {
const RightPanel = sdk.getComponent('structures.RightPanel');
const MainSplit = sdk.getComponent('structures.MainSplit');
const panel = <RightPanel user={this.state.member} />;
return (<MainSplit panel={panel}><div style={{flex: "1"}} /></MainSplit>);
} else {
return (<div />);
}
}
}

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,11 +15,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';
import SyntaxHighlight from '../views/elements/SyntaxHighlight';
import {_t} from "../../languageHandler";
import sdk from "../../index";
module.exports = React.createClass({
@ -27,31 +28,24 @@ module.exports = React.createClass({
propTypes: {
content: PropTypes.object.isRequired,
onFinished: PropTypes.func.isRequired,
},
componentDidMount: function() {
document.addEventListener("keydown", this.onKeyDown);
},
componentWillUnmount: function() {
document.removeEventListener("keydown", this.onKeyDown);
},
onKeyDown: function(ev) {
if (ev.keyCode == 27) { // escape
ev.stopPropagation();
ev.preventDefault();
this.props.onFinished();
}
roomId: PropTypes.string.isRequired,
eventId: PropTypes.string.isRequired,
},
render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return (
<div className="mx_ViewSource">
<SyntaxHighlight className="json">
{ JSON.stringify(this.props.content, null, 2) }
</SyntaxHighlight>
</div>
<BaseDialog className="mx_ViewSource" onFinished={this.props.onFinished} title={_t('View Source')}>
<div className="mx_ViewSource_label_left">Room ID: { this.props.roomId }</div>
<div className="mx_ViewSource_label_right">Event ID: { this.props.eventId }</div>
<div className="mx_ViewSource_label_bottom" />
<div className="mx_Dialog_content">
<SyntaxHighlight className="json">
{ JSON.stringify(this.props.content, null, 2) }
</SyntaxHighlight>
</div>
</BaseDialog>
);
},
});

View file

@ -1,6 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017, 2018 New Vector Ltd
Copyright 2017, 2018, 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.
@ -20,55 +20,55 @@ import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import sdk from '../../../index';
import Modal from "../../../Modal";
import MatrixClientPeg from "../../../MatrixClientPeg";
import SdkConfig from "../../../SdkConfig";
import PasswordReset from "../../../PasswordReset";
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
// Phases
// Show controls to configure server details
const PHASE_SERVER_DETAILS = 0;
// Show the forgot password inputs
const PHASE_FORGOT = 1;
// Email is in the process of being sent
const PHASE_SENDING_EMAIL = 2;
// Email has been sent
const PHASE_EMAIL_SENT = 3;
// User has clicked the link in email and completed reset
const PHASE_DONE = 4;
module.exports = React.createClass({
displayName: 'ForgotPassword',
propTypes: {
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,
// The default server name to use when the user hasn't specified
// one. This is used when displaying the defaultHsUrl in the UI.
defaultServerName: PropTypes.string,
// An error passed along from higher up explaining that something
// went wrong when finding the defaultHsUrl.
defaultServerDiscoveryError: PropTypes.string,
},
getInitialState: function() {
return {
enteredHomeserverUrl: this.props.customHsUrl || this.props.defaultHsUrl,
enteredIdentityServerUrl: this.props.customIsUrl || this.props.defaultIsUrl,
progress: null,
password: null,
password2: null,
phase: PHASE_FORGOT,
email: "",
password: "",
password2: "",
errorText: null,
};
},
submitPasswordReset: function(hsUrl, identityUrl, email, password) {
submitPasswordReset: function(email, password) {
this.setState({
progress: "sending_email",
phase: PHASE_SENDING_EMAIL,
});
this.reset = new PasswordReset(hsUrl, identityUrl);
this.reset = new PasswordReset(this.props.serverConfig.hsUrl, this.props.serverConfig.isUrl);
this.reset.resetPassword(email, password).done(() => {
this.setState({
progress: "sent_email",
phase: PHASE_EMAIL_SENT,
});
}, (err) => {
this.showErrorDialog(_t('Failed to send email') + ": " + err.message);
this.setState({
progress: null,
phase: PHASE_FORGOT,
});
});
},
@ -80,7 +80,7 @@ module.exports = React.createClass({
return;
}
this.reset.checkEmailLinkClicked().done((res) => {
this.setState({ progress: "complete" });
this.setState({ phase: PHASE_DONE });
}, (err) => {
this.showErrorDialog(err.message);
});
@ -89,13 +89,6 @@ module.exports = React.createClass({
onSubmitForm: 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;
}
if (!this.state.email) {
this.showErrorDialog(_t('The email address linked to your account must be entered.'));
} else if (!this.state.password || !this.state.password2) {
@ -109,56 +102,40 @@ module.exports = React.createClass({
description:
<div>
{ _t(
'Resetting password will currently reset any ' +
'end-to-end encryption keys on all devices, ' +
'making encrypted chat history unreadable, ' +
'unless you first export your room keys and re-import ' +
'them afterwards. In future this will be improved.',
"Changing your password will reset any end-to-end encryption keys " +
"on all of your devices, making encrypted chat history unreadable. Set up " +
"Key Backup or export your room keys from another device before resetting your " +
"password.",
) }
</div>,
button: _t('Continue'),
extraButtons: [
<button key="export_keys" className="mx_Dialog_primary"
onClick={this._onExportE2eKeysClicked}>
{ _t('Export E2E room keys') }
</button>,
],
onFinished: (confirmed) => {
if (confirmed) {
this.submitPasswordReset(
this.state.enteredHomeserverUrl, this.state.enteredIdentityServerUrl,
this.state.email, this.state.password,
);
this.submitPasswordReset(this.state.email, this.state.password);
}
},
});
}
},
_onExportE2eKeysClicked: function() {
Modal.createTrackedDialogAsync('Export E2E Keys', 'Forgot Password',
import('../../../async-components/views/dialogs/ExportE2eKeysDialog'),
{
matrixClient: MatrixClientPeg.get(),
},
);
},
onInputChanged: function(stateKey, ev) {
this.setState({
[stateKey]: ev.target.value,
});
},
onServerConfigChange: function(config) {
const newState = {};
if (config.hsUrl !== undefined) {
newState.enteredHomeserverUrl = config.hsUrl;
}
if (config.isUrl !== undefined) {
newState.enteredIdentityServerUrl = config.isUrl;
}
this.setState(newState);
async onServerDetailsNextPhaseClick() {
this.setState({
phase: PHASE_FORGOT,
});
},
onEditServerDetailsClick(ev) {
ev.preventDefault();
ev.stopPropagation();
this.setState({
phase: PHASE_SERVER_DETAILS,
});
},
onLoginClick: function(ev) {
@ -175,95 +152,160 @@ module.exports = React.createClass({
});
},
renderServerDetails() {
const ServerConfig = sdk.getComponent("auth.ServerConfig");
if (SdkConfig.get()['disable_custom_urls']) {
return null;
}
return <ServerConfig
serverConfig={this.props.serverConfig}
onServerConfigChange={this.props.onServerConfigChange}
delayTimeMs={0}
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;
if (err) {
errorText = <div className="mx_Login_error">{ err }</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>;
},
});
}
// If custom URLs are allowed, wire up the server details edit link.
let editLink = null;
if (!SdkConfig.get()['disable_custom_urls']) {
editLink = <a className="mx_AuthBody_editServerDetails"
href="#" onClick={this.onEditServerDetailsClick}
>
{_t('Change')}
</a>;
}
return <div>
<h3>
{yourMatrixAccountText}
{editLink}
</h3>
{errorText}
<form onSubmit={this.onSubmitForm}>
<div className="mx_AuthBody_fieldRow">
<Field
id="mx_ForgotPassword_email"
name="reset_email" // define a name so browser's password autofill gets less confused
type="text"
label={_t('Email')}
value={this.state.email}
onChange={this.onInputChanged.bind(this, "email")}
autoFocus
/>
</div>
<div className="mx_AuthBody_fieldRow">
<Field
id="mx_ForgotPassword_password"
name="reset_password"
type="password"
label={_t('Password')}
value={this.state.password}
onChange={this.onInputChanged.bind(this, "password")}
/>
<Field
id="mx_ForgotPassword_passwordConfirm"
name="reset_password_confirm"
type="password"
label={_t('Confirm')}
value={this.state.password2}
onChange={this.onInputChanged.bind(this, "password2")}
/>
</div>
<span>{_t(
'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')} />
</form>
<a className="mx_AuthBody_changeFlow" onClick={this.onLoginClick} href="#">
{_t('Sign in instead')}
</a>
</div>;
},
renderSendingEmail() {
const Spinner = sdk.getComponent("elements.Spinner");
return <Spinner />;
},
renderEmailSent() {
return <div>
{_t("An email has been sent to %(emailAddress)s. Once you've followed the " +
"link it contains, click below.", { emailAddress: this.state.email })}
<br />
<input className="mx_Login_submit" type="button" onClick={this.onVerify}
value={_t('I have verified my email address')} />
</div>;
},
renderDone() {
return <div>
<p>{_t("Your password has been reset.")}</p>
<p>{_t(
"You have been logged out of all devices and will no longer receive " +
"push notifications. To re-enable notifications, sign in again on each " +
"device.",
)}</p>
<input className="mx_Login_submit" type="button" onClick={this.props.onComplete}
value={_t('Return to login screen')} />
</div>;
},
render: function() {
const AuthPage = sdk.getComponent("auth.AuthPage");
const AuthHeader = sdk.getComponent("auth.AuthHeader");
const AuthBody = sdk.getComponent("auth.AuthBody");
const ServerConfig = sdk.getComponent("auth.ServerConfig");
const Spinner = sdk.getComponent("elements.Spinner");
let resetPasswordJsx;
if (this.state.progress === "sending_email") {
resetPasswordJsx = <Spinner />;
} else if (this.state.progress === "sent_email") {
resetPasswordJsx = (
<div>
{ _t("An email has been sent to %(emailAddress)s. Once you've followed the link it contains, " +
"click below.", { emailAddress: this.state.email }) }
<br />
<input className="mx_Login_submit" type="button" onClick={this.onVerify}
value={_t('I have verified my email address')} />
</div>
);
} else if (this.state.progress === "complete") {
resetPasswordJsx = (
<div>
<p>{ _t('Your password has been reset') }.</p>
<p>{ _t('You have been logged out of all devices and will no longer receive push notifications. ' +
'To re-enable notifications, sign in again on each device') }.</p>
<input className="mx_Login_submit" type="button" onClick={this.props.onComplete}
value={_t('Return to login screen')} />
</div>
);
} else {
let serverConfigSection;
if (!SdkConfig.get()['disable_custom_urls']) {
serverConfigSection = (
<ServerConfig ref="serverConfig"
defaultHsUrl={this.props.defaultHsUrl}
defaultIsUrl={this.props.defaultIsUrl}
customHsUrl={this.props.customHsUrl}
customIsUrl={this.props.customIsUrl}
onServerConfigChange={this.onServerConfigChange}
delayTimeMs={0} />
);
}
let errorText = null;
const err = this.state.errorText || this.props.defaultServerDiscoveryError;
if (err) {
errorText = <div className="mx_Login_error">{ err }</div>;
}
resetPasswordJsx = (
<div>
<p>
{ _t('To reset your password, enter the email address linked to your account') }:
</p>
<div>
<form onSubmit={this.onSubmitForm}>
<input className="mx_Login_field" ref="user" type="text"
name="reset_email" // define a name so browser's password autofill gets less confused
value={this.state.email}
onChange={this.onInputChanged.bind(this, "email")}
placeholder={_t('Email address')} autoFocus />
<br />
<input className="mx_Login_field" ref="pass" type="password"
name="reset_password"
value={this.state.password}
onChange={this.onInputChanged.bind(this, "password")}
placeholder={_t('New password')} />
<br />
<input className="mx_Login_field" ref="pass" type="password"
name="reset_password_confirm"
value={this.state.password2}
onChange={this.onInputChanged.bind(this, "password2")}
placeholder={_t('Confirm your new password')} />
<br />
<input className="mx_Login_submit" type="submit" value={_t('Send Reset Email')} />
</form>
{ serverConfigSection }
{ errorText }
<a className="mx_AuthBody_changeFlow" onClick={this.onLoginClick} href="#">
{ _t('Sign in instead') }
</a>
</div>
</div>
);
switch (this.state.phase) {
case PHASE_SERVER_DETAILS:
resetPasswordJsx = this.renderServerDetails();
break;
case PHASE_FORGOT:
resetPasswordJsx = this.renderForgot();
break;
case PHASE_SENDING_EMAIL:
resetPasswordJsx = this.renderSendingEmail();
break;
case PHASE_EMAIL_SENT:
resetPasswordJsx = this.renderEmailSent();
break;
case PHASE_DONE:
resetPasswordJsx = this.renderDone();
break;
}
return (
<AuthPage>
<AuthHeader />

View file

@ -20,13 +20,12 @@ limitations under the License.
import React from 'react';
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 * as ServerType from '../../views/auth/ServerTypeSelector';
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
// For validating phone numbers without country codes
const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/;
@ -37,13 +36,18 @@ const PHASE_SERVER_DETAILS = 0;
// Show the appropriate login flow(s) for the server
const PHASE_LOGIN = 1;
// Disable phases for now, pending UX discussion on WK discovery
const PHASES_ENABLED = false;
// Enable phases for login
const PHASES_ENABLED = true;
// These are used in several places, and come from the js-sdk's autodiscovery
// stuff. We define them here so that they'll be picked up by i18n.
_td("Invalid homeserver discovery response");
_td("Failed to get autodiscovery configuration from server");
_td("Invalid base_url for m.homeserver");
_td("Homeserver URL does not appear to be a valid Matrix homeserver");
_td("Invalid identity server discovery response");
_td("Invalid base_url for m.identity_server");
_td("Identity server URL does not appear to be a valid identity server");
_td("General failure");
/**
@ -55,30 +59,28 @@ module.exports = React.createClass({
propTypes: {
onLoggedIn: PropTypes.func.isRequired,
enableGuest: PropTypes.bool,
// An error passed along from higher up explaining that something
// went wrong. May be replaced with a different error within the
// Login component.
errorText: 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.
fallbackHsUrl: PropTypes.string,
// An error passed along from higher up explaining that something
// went wrong when finding the defaultHsUrl.
defaultServerDiscoveryError: PropTypes.string,
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,
onCancelClick: PropTypes.func,
onServerConfigChange: PropTypes.func.isRequired,
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
},
getInitialState: function() {
@ -87,23 +89,15 @@ module.exports = React.createClass({
errorText: null,
loginIncorrect: false,
serverType: null,
enteredHsUrl: this.props.customHsUrl || this.props.defaultHsUrl,
enteredIsUrl: this.props.customIsUrl || this.props.defaultIsUrl,
// used for preserving form values when changing homeserver
username: "",
phoneCountry: null,
phoneNumber: "",
// Phase of the overall login dialog.
phase: PHASE_SERVER_DETAILS,
phase: PHASE_LOGIN,
// The current login flow, such as password, SSO, etc.
currentFlow: "m.login.password",
// .well-known discovery
discoveryError: "",
findingHomeserver: false,
};
},
@ -127,6 +121,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,
@ -134,10 +136,17 @@ module.exports = React.createClass({
});
},
isBusy: function() {
return this.state.busy || this.props.busy;
},
hasError: function() {
return this.state.errorText || this.props.errorText;
},
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;
// Prevent people from submitting their password when something isn't right.
if (this.isBusy() || this.hasError()) return;
this.setState({
busy: true,
@ -159,7 +168,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,11 +198,10 @@ module.exports = React.createClass({
<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>
);
@ -223,61 +231,30 @@ module.exports = React.createClass({
}).done();
},
_onLoginAsGuestClick: function(ev) {
ev.preventDefault();
ev.stopPropagation();
const self = this;
self.setState({
busy: true,
errorText: null,
loginIncorrect: false,
});
this._loginLogic.loginAsGuest().then(function(data) {
self.props.onLoggedIn(data);
}, function(error) {
let errorText;
if (error.httpStatus === 403) {
errorText = _t("Guest access is disabled on this homeserver.");
} else {
errorText = self._errorTextFromError(error);
}
self.setState({
errorText: errorText,
loginIncorrect: false,
});
}).finally(function() {
self.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, // unset later by the result of onServerConfigChange
errorText: null,
});
// If the free server type is selected, we don't show server details at all,
// so it doesn't make sense to try .well-known discovery.
if (this.state.serverType === ServerType.FREE) {
return;
}
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);
} 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;
}
this.setState({errorText: message, busy: false});
}
}
},
@ -305,65 +282,13 @@ module.exports = React.createClass({
}
},
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);
});
},
onServerTypeChange(type) {
this.setState({
serverType: type,
});
// When changing server types, set the HS / IS URLs to reasonable defaults for the
// the new type.
switch (type) {
case ServerType.FREE: {
const { hsUrl, isUrl } = ServerType.TYPES.FREE;
this.onServerConfigChange({
hsUrl,
isUrl,
});
// Move directly to the login phase since the server details are fixed.
this.setState({
phase: PHASE_LOGIN,
});
break;
}
case ServerType.PREMIUM:
case ServerType.ADVANCED:
this.onServerConfigChange({
hsUrl: this.props.defaultHsUrl,
isUrl: this.props.defaultIsUrl,
});
this.setState({
phase: PHASE_SERVER_DETAILS,
});
break;
}
},
onRegisterClick: function(ev) {
ev.preventDefault();
ev.stopPropagation();
this.props.onRegisterClick();
},
onServerDetailsNextPhaseClick(ev) {
ev.stopPropagation();
async onServerDetailsNextPhaseClick() {
this.setState({
phase: PHASE_LOGIN,
});
@ -377,72 +302,13 @@ module.exports = React.createClass({
});
},
_tryWellKnownDiscovery: async function(serverName) {
if (!serverName.trim()) {
// Nothing to discover
this.setState({
discoveryError: "",
findingHomeserver: false,
});
return;
}
this.setState({findingHomeserver: true});
try {
const discovery = await AutoDiscovery.findClientConfig(serverName);
// The server type may have changed while discovery began in the background.
// If it has become the free server type which doesn't show server details,
// ignore discovery results.
if (this.state.serverType === ServerType.FREE) {
this.setState({findingHomeserver: false});
return;
}
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;
hsUrl = hsUrl || this.props.serverConfig.hsUrl;
isUrl = isUrl || this.props.serverConfig.isUrl;
const fallbackHsUrl = hsUrl === this.props.defaultHsUrl ? this.props.fallbackHsUrl : null;
// TODO: TravisR - Only use this if the homeserver is the default homeserver
const fallbackHsUrl = this.props.fallbackHsUrl;
const loginLogic = new Login(hsUrl, isUrl, fallbackHsUrl, {
defaultDeviceDisplayName: this.props.defaultDeviceDisplayName,
@ -450,8 +316,6 @@ module.exports = React.createClass({
this._loginLogic = loginLogic;
this.setState({
enteredHsUrl: hsUrl,
enteredIsUrl: isUrl,
busy: true,
loginIncorrect: false,
});
@ -517,8 +381,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. " +
@ -541,9 +405,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>;
},
},
) }
@ -554,70 +418,30 @@ module.exports = React.createClass({
return errorText;
},
renderServerComponentForStep() {
const ServerTypeSelector = sdk.getComponent("auth.ServerTypeSelector");
renderServerComponent() {
const ServerConfig = sdk.getComponent("auth.ServerConfig");
const ModularServerConfig = sdk.getComponent("auth.ModularServerConfig");
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
// TODO: May need to adjust the behavior of this config option
if (SdkConfig.get()['disable_custom_urls']) {
return null;
}
// 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) {
return <div>
<ServerTypeSelector
defaultHsUrl={this.props.defaultHsUrl}
onChange={this.onServerTypeChange}
/>
</div>;
return null;
}
let serverDetails = null;
switch (this.state.serverType) {
case ServerType.FREE:
break;
case ServerType.PREMIUM:
serverDetails = <ModularServerConfig
customHsUrl={this.state.enteredHsUrl}
defaultHsUrl={this.props.defaultHsUrl}
defaultIsUrl={this.props.defaultIsUrl}
onServerConfigChange={this.onServerConfigChange}
delayTimeMs={250}
/>;
break;
case ServerType.ADVANCED:
serverDetails = <ServerConfig
customHsUrl={this.state.enteredHsUrl}
customIsUrl={this.state.enteredIsUrl}
defaultHsUrl={this.props.defaultHsUrl}
defaultIsUrl={this.props.defaultIsUrl}
onServerConfigChange={this.onServerConfigChange}
delayTimeMs={250}
/>;
break;
}
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>
<ServerTypeSelector
defaultHsUrl={this.props.defaultHsUrl}
onChange={this.onServerTypeChange}
/>
{serverDetails}
{nextButton}
</div>;
return <ServerConfig
serverConfig={this.props.serverConfig}
onServerConfigChange={this.props.onServerConfigChange}
delayTimeMs={250}
{...serverDetailsProps}
/>;
},
renderLoginComponentForStep() {
@ -642,16 +466,13 @@ module.exports = React.createClass({
_renderPasswordStep: function() {
const PasswordLogin = sdk.getComponent('auth.PasswordLogin');
let onEditServerDetailsClick = null;
// If custom URLs are allowed and we haven't selected the Free server type, wire
// up the server details edit link.
if (
PHASES_ENABLED &&
!SdkConfig.get()['disable_custom_urls'] &&
this.state.serverType !== ServerType.FREE
) {
// If custom URLs are allowed, wire up the server details edit link.
if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) {
onEditServerDetailsClick = this.onEditServerDetailsClick;
}
return (
<PasswordLogin
onSubmit={this.onPasswordLogin}
@ -667,9 +488,9 @@ module.exports = React.createClass({
onPhoneNumberBlur={this.onPhoneNumberBlur}
onForgotPasswordClick={this.props.onForgotPasswordClick}
loginIncorrect={this.state.loginIncorrect}
hsUrl={this.state.enteredHsUrl}
disableSubmit={this.state.findingHomeserver}
/>
serverConfig={this.props.serverConfig}
disableSubmit={this.isBusy()}
/>
);
},
@ -692,17 +513,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;
let loginAsGuestJsx;
if (this.props.enableGuest) {
loginAsGuestJsx =
<a className="mx_AuthBody_changeFlow" onClick={this._onLoginAsGuestClick} href="#">
{ _t('Try the app first') }
</a>;
}
const errorText = this.state.errorText || this.props.errorText;
let errorTextSection;
if (errorText) {
@ -718,16 +531,15 @@ module.exports = React.createClass({
<AuthHeader />
<AuthBody>
<h2>
{_t('Sign in to your account')}
{_t('Sign in')}
{loader}
</h2>
{ errorTextSection }
{ this.renderServerComponentForStep() }
{ this.renderServerComponent() }
{ this.renderLoginComponentForStep() }
<a className="mx_AuthBody_changeFlow" onClick={this.onRegisterClick} href="#">
{ _t('Create account') }
</a>
{ loginAsGuestJsx }
</AuthBody>
</AuthPage>
);

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,18 +18,15 @@ limitations under the License.
*/
import Matrix from 'matrix-js-sdk';
import Promise from 'bluebird';
import React from 'react';
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';
const MIN_PASSWORD_LENGTH = 6;
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
// Phases
// Show controls to configure server details
@ -48,27 +46,17 @@ module.exports = React.createClass({
sessionId: PropTypes.string,
makeRegistrationUrl: PropTypes.func.isRequired,
idSid: 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,
referrer: PropTypes.string,
// An error passed along from higher up explaining that something
// went wrong when finding the defaultHsUrl.
defaultServerDiscoveryError: PropTypes.string,
defaultDeviceDisplayName: PropTypes.string,
// registration shouldn't know or care how login is done.
onLoginClick: PropTypes.func.isRequired,
onCancelClick: PropTypes.func,
onServerConfigChange: PropTypes.func.isRequired,
},
getInitialState: function() {
const serverType = ServerType.getTypeFromServerConfig(this.props.serverConfig);
return {
busy: false,
errorText: null,
@ -87,9 +75,9 @@ module.exports = React.createClass({
// If we've been given a session ID, we're resuming
// straight back into UI auth
doingUIAuth: Boolean(this.props.sessionId),
serverType: null,
hsUrl: this.props.customHsUrl,
isUrl: this.props.customIsUrl,
serverType,
// Phase of the overall registration dialog.
phase: PHASE_REGISTRATION,
flows: null,
};
},
@ -99,18 +87,35 @@ 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;
},
getDefaultPhaseForServerType(type) {
switch (type) {
case ServerType.FREE: {
// Move directly to the registration phase since the server
// details are fixed.
return PHASE_REGISTRATION;
}
case ServerType.PREMIUM:
case ServerType.ADVANCED:
return PHASE_SERVER_DETAILS;
}
this.props.onServerConfigChange(config);
this.setState(newState, () => {
this._replaceClient();
});
},
onServerTypeChange(type) {
@ -122,37 +127,35 @@ module.exports = React.createClass({
// the new type.
switch (type) {
case ServerType.FREE: {
const { hsUrl, isUrl } = ServerType.TYPES.FREE;
this.onServerConfigChange({
hsUrl,
isUrl,
});
// Move directly to the registration phase since the server details are fixed.
this.setState({
phase: PHASE_REGISTRATION,
});
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,
});
this.setState({
phase: PHASE_SERVER_DETAILS,
});
// Use the default config from the config
this.props.onServerConfigChange(SdkConfig.get()["validated_server_config"]);
break;
}
// Reset the phase to default phase for the server type.
this.setState({
phase: this.getDefaultPhaseForServerType(type),
});
},
_replaceClient: async function() {
_replaceClient: async function(serverConfig) {
this.setState({
errorText: null,
});
if (!serverConfig) serverConfig = this.props.serverConfig;
const {hsUrl, isUrl} = serverConfig;
this._matrixClient = Matrix.createClient({
baseUrl: this.state.hsUrl,
idBaseUrl: this.state.isUrl,
baseUrl: hsUrl,
idBaseUrl: isUrl,
});
try {
await this._makeRegisterRequest({});
@ -177,12 +180,6 @@ module.exports = React.createClass({
},
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,
@ -191,11 +188,25 @@ module.exports = React.createClass({
});
},
_requestEmailToken: function(emailAddress, clientSecret, sendAttempt, sessionId) {
return this._matrixClient.requestRegisterEmailToken(
emailAddress,
clientSecret,
sendAttempt,
this.props.makeRegistrationUrl({
client_secret: clientSecret,
hs_url: this._matrixClient.getHomeserverUrl(),
is_url: this._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, {
@ -273,54 +284,24 @@ module.exports = React.createClass({
});
},
onFormValidationFailed: function(errCode) {
let errMsg;
switch (errCode) {
case "RegistrationForm.ERR_PASSWORD_MISSING":
errMsg = _t('Missing password.');
break;
case "RegistrationForm.ERR_PASSWORD_MISMATCH":
errMsg = _t('Passwords don\'t match.');
break;
case "RegistrationForm.ERR_PASSWORD_LENGTH":
errMsg = _t('Password too short (min %(MIN_PASSWORD_LENGTH)s).', {MIN_PASSWORD_LENGTH});
break;
case "RegistrationForm.ERR_EMAIL_INVALID":
errMsg = _t('This doesn\'t look like a valid email address.');
break;
case "RegistrationForm.ERR_PHONE_NUMBER_INVALID":
errMsg = _t('This doesn\'t look like a valid phone number.');
break;
case "RegistrationForm.ERR_MISSING_EMAIL":
errMsg = _t('An email address is required to register on this homeserver.');
break;
case "RegistrationForm.ERR_MISSING_PHONE_NUMBER":
errMsg = _t('A phone number is required to register on this homeserver.');
break;
case "RegistrationForm.ERR_USERNAME_INVALID":
errMsg = _t("Only use lower case letters, numbers and '=_-./'");
break;
case "RegistrationForm.ERR_USERNAME_BLANK":
errMsg = _t('You need to enter a username.');
break;
default:
console.error("Unknown error code: %s", errCode);
errMsg = _t('An unknown error occurred.');
break;
}
this.setState({
errorText: errMsg,
});
},
onLoginClick: function(ev) {
ev.preventDefault();
ev.stopPropagation();
this.props.onLoginClick();
},
onServerDetailsNextPhaseClick(ev) {
onGoToFormClicked(ev) {
ev.preventDefault();
ev.stopPropagation();
this._replaceClient();
this.setState({
busy: false,
doingUIAuth: false,
phase: PHASE_REGISTRATION,
});
},
async onServerDetailsNextPhaseClick() {
this.setState({
phase: PHASE_REGISTRATION,
});
@ -365,9 +346,7 @@ module.exports = React.createClass({
const ServerTypeSelector = sdk.getComponent("auth.ServerTypeSelector");
const ServerConfig = sdk.getComponent("auth.ServerConfig");
const ModularServerConfig = sdk.getComponent("auth.ModularServerConfig");
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
// TODO: May need to adjust the behavior of this config option
if (SdkConfig.get()['disable_custom_urls']) {
return null;
}
@ -377,53 +356,47 @@ module.exports = React.createClass({
if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS) {
return <div>
<ServerTypeSelector
defaultHsUrl={this.props.defaultHsUrl}
selected={this.state.serverType}
onChange={this.onServerTypeChange}
/>
</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}
{...serverDetailsProps}
/>;
break;
}
let nextButton = null;
if (PHASES_ENABLED) {
nextButton = <AccessibleButton className="mx_Login_submit"
onClick={this.onServerDetailsNextPhaseClick}
>
{_t("Next")}
</AccessibleButton>;
}
return <div>
<ServerTypeSelector
defaultHsUrl={this.props.defaultHsUrl}
selected={this.state.serverType}
onChange={this.onServerTypeChange}
/>
{serverDetails}
{nextButton}
</div>;
},
@ -442,14 +415,16 @@ module.exports = React.createClass({
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.busy || !this.state.flows) {
return <Spinner />;
return <div className="mx_AuthBody_spinner">
<Spinner />
</div>;
} else {
let onEditServerDetailsClick = null;
// If custom URLs are allowed and we haven't selected the Free server type, wire
@ -461,18 +436,17 @@ module.exports = React.createClass({
) {
onEditServerDetailsClick = this.onEditServerDetailsClick;
}
return <RegistrationForm
defaultUsername={this.state.formVals.username}
defaultEmail={this.state.formVals.email}
defaultPhoneCountry={this.state.formVals.phoneCountry}
defaultPhoneNumber={this.state.formVals.phoneNumber}
defaultPassword={this.state.formVals.password}
minPasswordLength={MIN_PASSWORD_LENGTH}
onError={this.onFormValidationFailed}
onRegisterClick={this.onFormSubmit}
onEditServerDetailsClick={onEditServerDetailsClick}
flows={this.state.flows}
hsUrl={this.state.hsUrl}
serverConfig={this.props.serverConfig}
/>;
}
},
@ -483,18 +457,21 @@ module.exports = React.createClass({
const AuthPage = sdk.getComponent('auth.AuthPage');
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 signIn;
if (!this.state.doingUIAuth) {
signIn = (
<a className="mx_AuthBody_changeFlow" onClick={this.onLoginClick} href="#">
{ _t('Sign in instead') }
</a>
);
const signIn = <a className="mx_AuthBody_changeFlow" onClick={this.onLoginClick} href="#">
{ _t('Sign in instead') }
</a>;
// Only show the 'go back' button if you're not looking at the form
let goBack;
if ((PHASES_ENABLED && this.state.phase !== PHASE_REGISTRATION) || this.state.doingUIAuth) {
goBack = <a className="mx_AuthBody_changeFlow" onClick={this.onGoToFormClicked} href="#">
{ _t('Go back') }
</a>;
}
return (
@ -505,6 +482,7 @@ module.exports = React.createClass({
{ errorText }
{ this.renderServerComponent() }
{ this.renderRegisterComponent() }
{ goBack }
{ signIn }
</AuthBody>
</AuthPage>

View file

@ -1,57 +0,0 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2018, 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.
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.
*/
'use strict';
const React = require('react');
import { _t } from '../../../languageHandler';
const dis = require('../../../dispatcher');
const AccessibleButton = require('../elements/AccessibleButton');
module.exports = React.createClass({
displayName: 'AuthButtons',
propTypes: {
},
onLoginClick: function() {
dis.dispatch({ action: 'start_login' });
},
onRegisterClick: function() {
dis.dispatch({ action: 'start_registration' });
},
render: function() {
const loginButton = (
<div className="mx_AuthButtons_loginButton_wrapper">
<AccessibleButton className="mx_AuthButtons_loginButton" element="button" onClick={this.onLoginClick}>
{ _t("Login") }
</AccessibleButton>
<AccessibleButton className="mx_AuthButtons_registerButton" element="button" onClick={this.onRegisterClick}>
{ _t("Register") }
</AccessibleButton>
</div>
);
return (
<div className="mx_AuthButtons">
{ loginButton }
</div>
);
},
});

View file

@ -17,7 +17,6 @@ limitations under the License.
'use strict';
import React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
@ -61,29 +60,15 @@ module.exports = React.createClass({
} else {
console.log("Loading recaptcha script...");
window.mx_on_recaptcha_loaded = () => {this._onCaptchaLoaded();};
const protocol = global.location.protocol;
let protocol = global.location.protocol;
if (protocol === "vector:") {
const warning = document.createElement('div');
// XXX: fix hardcoded app URL. Better solutions include:
// * jumping straight to a hosted captcha page (but we don't support that yet)
// * embedding the captcha in an iframe (if that works)
// * using a better captcha lib
ReactDOM.render(_t(
"Robot check is currently unavailable on desktop - please use a <a>web browser</a>",
{},
{
'a': (sub) => {
return <a target="_blank" rel="noopener" href='https://riot.im/app'>{ sub }</a>;
},
}), warning);
this.refs.recaptchaContainer.appendChild(warning);
} else {
const scriptTag = document.createElement('script');
scriptTag.setAttribute(
'src', protocol+"//www.google.com/recaptcha/api.js?onload=mx_on_recaptcha_loaded&render=explicit",
);
this.refs.recaptchaContainer.appendChild(scriptTag);
protocol = "https:";
}
const scriptTag = document.createElement('script');
scriptTag.setAttribute(
'src', `${protocol}//www.recaptcha.net/recaptcha/api.js?onload=mx_on_recaptcha_loaded&render=explicit`,
);
this.refs.recaptchaContainer.appendChild(scriptTag);
}
},
@ -141,8 +126,9 @@ module.exports = React.createClass({
return (
<div ref="recaptchaContainer">
{ _t("This homeserver would like to make sure you are not a robot.") }
<br />
<p>{_t(
"This homeserver would like to make sure you are not a robot.",
)}</p>
<div id={DIV_ID}></div>
{ error }
</div>

View file

@ -113,7 +113,7 @@ export default class CountryDropdown extends React.Component {
const options = displayedCountries.map((country) => {
return <div className="mx_CountryDropdown_option" key={country.iso2}>
{ this._flagImgForIso2(country.iso2) }
{ country.name } <span>(+{ country.prefix })</span>
{ country.name } (+{ country.prefix })
</div>;
});

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.
@ -57,7 +58,6 @@ 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.
@ -334,7 +334,7 @@ export const TermsAuthEntry = React.createClass({
let submitButton;
if (this.props.showContinue !== false) {
// XXX: button classes
submitButton = <button className="mx_InteractiveAuthEntryComponents_termsSubmit mx_UserSettings_button"
submitButton = <button className="mx_InteractiveAuthEntryComponents_termsSubmit mx_GeneralButton"
onClick={this._trySubmit} disabled={!allChecked}>{_t("Accept")}</button>;
}
@ -365,7 +365,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 +373,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");
@ -525,7 +492,7 @@ export const MsisdnAuthEntry = React.createClass({
const enableSubmit = Boolean(this.state.token);
const submitClasses = classnames({
mx_InteractiveAuthEntryComponents_msisdnSubmit: true,
mx_UserSettings_button: true, // XXX button classes
mx_GeneralButton: true,
});
let errorSection;
if (this.state.errorText) {

View file

@ -18,9 +18,15 @@ 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';
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.
*
@ -31,65 +37,107 @@ export default class ModularServerConfig extends React.PureComponent {
static propTypes = {
onServerConfigChange: PropTypes.func,
// 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,
// 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,
};
static defaultProps = {
onServerConfigChange: function() {},
customHsUrl: "",
delayTimeMs: 0,
}
};
constructor(props) {
super(props);
this.state = {
hsUrl: props.customHsUrl,
busy: false,
errorText: "",
hsUrl: props.serverConfig.hsUrl,
isUrl: props.serverConfig.isUrl,
};
}
componentWillReceiveProps(newProps) {
if (newProps.customHsUrl === this.state.hsUrl) return;
if (newProps.serverConfig.hsUrl === this.state.hsUrl &&
newProps.serverConfig.isUrl === this.state.isUrl) return;
this.validateAndApplyServer(newProps.serverConfig.hsUrl, newProps.serverConfig.isUrl);
}
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,
});
this.props.onServerConfigChange({
hsUrl: newProps.customHsUrl,
isUrl: this.props.defaultIsUrl,
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);
let message = _t("Unable to validate homeserver/identity server");
if (e.translatedMessage) {
message = e.translatedMessage;
}
this.setState({
busy: false,
errorText: message,
});
}
}
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);
}
onHomeserverBlur = (ev) => {
this._hsTimeoutId = this._waitThenInvoke(this._hsTimeoutId, () => {
this.props.onServerConfigChange({
hsUrl: this.state.hsUrl,
isUrl: this.props.defaultIsUrl,
});
this.validateServer();
});
}
};
onHomeserverChange = (ev) => {
const hsUrl = ev.target.value;
this.setState({ hsUrl });
}
};
onSubmit = async (ev) => {
ev.preventDefault();
ev.stopPropagation();
await this.validateServer();
if (this.props.onAfterSubmit) {
this.props.onAfterSubmit();
}
};
_waitThenInvoke(existingTimeoutId, fn) {
if (existingTimeoutId) {
@ -100,6 +148,16 @@ export default class ModularServerConfig extends React.PureComponent {
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 +171,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={false} 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,29 @@ 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,
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,9 +59,12 @@ class PasswordLogin extends React.Component {
initialPhoneNumber: "",
initialPassword: "",
loginIncorrect: false,
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);
@ -54,6 +76,7 @@ class PasswordLogin extends React.Component {
loginType: PasswordLogin.LOGIN_FIELD_MXID,
};
this.onForgotPasswordClick = this.onForgotPasswordClick.bind(this);
this.onSubmitForm = this.onSubmitForm.bind(this);
this.onUsernameChanged = this.onUsernameChanged.bind(this);
this.onUsernameBlur = this.onUsernameBlur.bind(this);
@ -65,9 +88,10 @@ class PasswordLogin extends React.Component {
this.isLoginEmpty = this.isLoginEmpty.bind(this);
}
componentWillMount() {
this._passwordField = null;
this._loginField = null;
onForgotPasswordClick(ev) {
ev.preventDefault();
ev.stopPropagation();
this.props.onForgotPasswordClick();
}
onSubmitForm(ev) {
@ -127,7 +151,8 @@ class PasswordLogin extends React.Component {
this.props.onUsernameBlur(ev.target.value);
}
onLoginTypeChange(loginType) {
onLoginTypeChange(ev) {
const loginType = ev.target.value;
this.props.onError(null); // send a null error to clear any error messages
this.setState({
loginType: loginType,
@ -158,67 +183,64 @@ class PasswordLogin extends React.Component {
}
renderLoginField(loginType) {
const classes = {
mx_Login_field: true,
};
const Field = sdk.getComponent('elements.Field');
const classes = {};
switch (loginType) {
case PasswordLogin.LOGIN_FIELD_EMAIL:
classes.error = this.props.loginIncorrect && !this.state.username;
return <input
className="mx_Login_field"
ref={(e) => {this._loginField = e;}}
return <Field
className={classNames(classes)}
id="mx_PasswordLogin_email"
name="username" // make it a little easier for browser's remember-password
key="email_input"
type="text"
name="username" // make it a little easier for browser's remember-password
onChange={this.onUsernameChanged}
onBlur={this.onUsernameBlur}
label={_t("Email")}
placeholder="joe@example.com"
value={this.state.username}
onChange={this.onUsernameChanged}
onBlur={this.onUsernameBlur}
autoFocus
/>;
case PasswordLogin.LOGIN_FIELD_MXID:
classes.error = this.props.loginIncorrect && !this.state.username;
return <input
return <Field
className={classNames(classes)}
ref={(e) => {this._loginField = e;}}
id="mx_PasswordLogin_username"
name="username" // make it a little easier for browser's remember-password
key="username_input"
type="text"
name="username" // make it a little easier for browser's remember-password
label={_t("Username")}
value={this.state.username}
onChange={this.onUsernameChanged}
onBlur={this.onUsernameBlur}
placeholder={SdkConfig.get().disable_custom_urls ?
_t("Username on %(hs)s", {
hs: this.props.hsUrl.replace(/^https?:\/\//, ''),
}) : _t("Username")}
value={this.state.username}
autoFocus
/>;
case PasswordLogin.LOGIN_FIELD_PHONE: {
const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown');
classes.mx_Login_field_has_prefix = true;
classes.error = this.props.loginIncorrect && !this.state.phoneNumber;
return <div className="mx_Login_phoneSection">
<CountryDropdown
className="mx_Login_phoneCountry mx_Login_field_prefix"
onOptionChange={this.onPhoneCountryChanged}
value={this.state.phoneCountry}
isSmall={true}
showPrefix={true}
/>
<input
className={classNames(classes)}
ref={(e) => {this._loginField = e;}}
key="phone_input"
type="text"
name="phoneNumber"
onChange={this.onPhoneNumberChanged}
onBlur={this.onPhoneNumberBlur}
placeholder={_t("Mobile phone number")}
value={this.state.phoneNumber}
autoFocus
/>
</div>;
const phoneCountry = <CountryDropdown
value={this.state.phoneCountry}
isSmall={true}
showPrefix={true}
onOptionChange={this.onPhoneCountryChanged}
/>;
return <Field
className={classNames(classes)}
id="mx_PasswordLogin_phoneNumber"
name="phoneNumber"
key="phone_input"
type="text"
label={_t("Phone")}
value={this.state.phoneNumber}
prefix={phoneCountry}
onChange={this.onPhoneNumberChanged}
onBlur={this.onPhoneNumberBlur}
autoFocus
/>;
}
}
}
@ -234,13 +256,15 @@ class PasswordLogin extends React.Component {
}
render() {
const Field = sdk.getComponent('elements.Field');
let forgotPasswordJsx;
if (this.props.onForgotPasswordClick) {
forgotPasswordJsx = <span>
{_t('Not sure of your password? <a>Set a new one</a>', {}, {
a: sub => <a className="mx_Login_forgot"
onClick={this.props.onForgotPasswordClick}
onClick={this.onForgotPasswordClick}
href="#"
>
{sub}
@ -249,14 +273,22 @@ class PasswordLogin extends React.Component {
</span>;
}
let yourMatrixAccountText = _t('Your account');
try {
const parsedHsUrl = new URL(this.props.hsUrl);
yourMatrixAccountText = _t('Your %(serverName)s account', {
serverName: parsedHsUrl.hostname,
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>;
},
});
} catch (e) {
// ignore
}
let editLink = null;
@ -264,17 +296,14 @@ class PasswordLogin extends React.Component {
editLink = <a className="mx_AuthBody_editServerDetails"
href="#" onClick={this.props.onEditServerDetailsClick}
>
{_t('Edit')}
{_t('Change')}
</a>;
}
const pwFieldClass = classNames({
mx_Login_field: true,
error: this.props.loginIncorrect && !this.isLoginEmpty(), // only error password if error isn't top field
});
const Dropdown = sdk.getComponent('elements.Dropdown');
const loginField = this.renderLoginField(this.state.loginType);
let loginType;
@ -282,14 +311,32 @@ class PasswordLogin extends React.Component {
loginType = (
<div className="mx_Login_type_container">
<label className="mx_Login_type_label">{ _t('Sign in with') }</label>
<Dropdown
<Field
className="mx_Login_type_dropdown"
id="mx_PasswordLogin_type"
element="select"
value={this.state.loginType}
onOptionChange={this.onLoginTypeChange}>
<span key={PasswordLogin.LOGIN_FIELD_MXID}>{ _t('Username') }</span>
<span key={PasswordLogin.LOGIN_FIELD_EMAIL}>{ _t('Email address') }</span>
<span key={PasswordLogin.LOGIN_FIELD_PHONE}>{ _t('Phone') }</span>
</Dropdown>
onChange={this.onLoginTypeChange}
>
<option
key={PasswordLogin.LOGIN_FIELD_MXID}
value={PasswordLogin.LOGIN_FIELD_MXID}
>
{_t('Username')}
</option>
<option
key={PasswordLogin.LOGIN_FIELD_EMAIL}
value={PasswordLogin.LOGIN_FIELD_EMAIL}
>
{_t('Email address')}
</option>
<option
key={PasswordLogin.LOGIN_FIELD_PHONE}
value={PasswordLogin.LOGIN_FIELD_PHONE}
>
{_t('Phone')}
</option>
</Field>
</div>
);
}
@ -297,19 +344,22 @@ class PasswordLogin extends React.Component {
return (
<div>
<h3>
{yourMatrixAccountText}
{signInToText}
{editLink}
</h3>
<form onSubmit={this.onSubmitForm}>
{ loginType }
{ loginField }
<input className={pwFieldClass} ref={(e) => {this._passwordField = e;}} type="password"
{loginType}
{loginField}
<Field
className={pwFieldClass}
id="mx_PasswordLogin_password"
type="password"
name="password"
value={this.state.password} onChange={this.onPasswordChanged}
placeholder={_t('Password')}
label={_t('Password')}
value={this.state.password}
onChange={this.onPasswordChanged}
/>
<br />
{ forgotPasswordJsx }
{forgotPasswordJsx}
<input className="mx_Login_submit"
type="submit"
value={_t('Sign in')}
@ -320,25 +370,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,
disableSubmit: PropTypes.bool,
};
module.exports = PasswordLogin;

View file

@ -1,7 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Copyright 2018, 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.
@ -25,6 +25,8 @@ import Modal from '../../../Modal';
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';
@ -32,6 +34,8 @@ const FIELD_USERNAME = 'field_username';
const FIELD_PASSWORD = 'field_password';
const FIELD_PASSWORD_CONFIRM = 'field_password_confirm';
const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from offline slow-hash scenario.
/**
* A pure UI component which displays a registration form.
*/
@ -45,77 +49,72 @@ module.exports = React.createClass({
defaultPhoneNumber: PropTypes.string,
defaultUsername: PropTypes.string,
defaultPassword: PropTypes.string,
minPasswordLength: PropTypes.number,
onError: PropTypes.func,
onRegisterClick: PropTypes.func.isRequired, // onRegisterClick(Object) => ?Promise
onEditServerDetailsClick: PropTypes.func,
flows: PropTypes.arrayOf(PropTypes.object).isRequired,
hsUrl: PropTypes.string,
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
},
getDefaultProps: function() {
return {
minPasswordLength: 6,
onError: function(e) {
console.error(e);
},
onValidationChange: console.error,
};
},
getInitialState: function() {
return {
// Field error codes by field ID
fieldValid: {},
// The ISO2 country code selected in the phone number entry
phoneCountry: this.props.defaultPhoneCountry,
username: "",
email: "",
phoneNumber: "",
password: "",
passwordConfirm: "",
passwordComplexity: null,
passwordSafe: false,
};
},
onSubmit: function(ev) {
onSubmit: async function(ev) {
ev.preventDefault();
// validate everything, in reverse order so
// the error that ends up being displayed
// is the one from the first invalid field.
// It's not super ideal that this just calls
// onError once for each invalid field.
this.validateField(FIELD_PASSWORD_CONFIRM, ev.type);
this.validateField(FIELD_PASSWORD, ev.type);
this.validateField(FIELD_USERNAME, ev.type);
this.validateField(FIELD_PHONE_NUMBER, ev.type);
this.validateField(FIELD_EMAIL, ev.type);
const allFieldsValid = await this.verifyFieldsBeforeSubmit();
if (!allFieldsValid) {
return;
}
const self = this;
if (this.allFieldsValid()) {
if (this.refs.email.value == '') {
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>,
button: _t("Continue"),
onFinished: function(confirmed) {
if (confirmed) {
self._doSubmit(ev);
}
},
});
} else {
self._doSubmit(ev);
}
if (this.state.email == '') {
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>,
button: _t("Continue"),
onFinished: function(confirmed) {
if (confirmed) {
self._doSubmit(ev);
}
},
});
} else {
self._doSubmit(ev);
}
},
_doSubmit: function(ev) {
const email = this.refs.email.value.trim();
const email = this.state.email.trim();
const promise = this.props.onRegisterClick({
username: this.refs.username.value.trim(),
password: this.refs.password.value.trim(),
username: this.state.username.trim(),
password: this.state.password.trim(),
email: email,
phoneCountry: this.state.phoneCountry,
phoneNumber: this.refs.phoneNumber ? this.refs.phoneNumber.value.trim() : '',
phoneNumber: this.state.phoneNumber,
});
if (promise) {
@ -126,135 +125,211 @@ module.exports = React.createClass({
}
},
async verifyFieldsBeforeSubmit() {
// Blur the active element if any, so we first run its blur validation,
// which is less strict than the pass we're about to do below for all fields.
const activeElement = document.activeElement;
if (activeElement) {
activeElement.blur();
}
const fieldIDsInDisplayOrder = [
FIELD_USERNAME,
FIELD_PASSWORD,
FIELD_PASSWORD_CONFIRM,
FIELD_EMAIL,
FIELD_PHONE_NUMBER,
];
// Run all fields with stricter validation that no longer allows empty
// values for required fields.
for (const fieldID of fieldIDsInDisplayOrder) {
const field = this[fieldID];
if (!field) {
continue;
}
// 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
// first. Queue a `setState` callback and wait for it to resolve.
await new Promise(resolve => this.setState({}, resolve));
if (this.allFieldsValid()) {
return true;
}
const invalidField = this.findFirstInvalidField(fieldIDsInDisplayOrder);
if (!invalidField) {
return true;
}
// Focus the first invalid field and show feedback in the stricter mode
// that no longer allows empty values for required fields.
invalidField.focus();
invalidField.validate({ allowEmpty: false, focused: true });
return false;
},
/**
* @returns {boolean} true if all fields were valid last time they were validated.
*/
allFieldsValid: function() {
const keys = Object.keys(this.state.fieldValid);
for (let i = 0; i < keys.length; ++i) {
if (this.state.fieldValid[keys[i]] == false) {
if (!this.state.fieldValid[keys[i]]) {
return false;
}
}
return true;
},
validateField: function(fieldID, eventType) {
const pwd1 = this.refs.password.value.trim();
const pwd2 = this.refs.passwordConfirm.value.trim();
const allowEmpty = eventType === "blur";
switch (fieldID) {
case FIELD_EMAIL: {
const email = this.refs.email.value;
const emailValid = email === '' || Email.looksValid(email);
if (this._authStepIsRequired('m.login.email.identity') && (!emailValid || email === '')) {
this.markFieldValid(fieldID, false, "RegistrationForm.ERR_MISSING_EMAIL");
} else this.markFieldValid(fieldID, emailValid, "RegistrationForm.ERR_EMAIL_INVALID");
break;
findFirstInvalidField(fieldIDs) {
for (const fieldID of fieldIDs) {
if (!this.state.fieldValid[fieldID] && this[fieldID]) {
return this[fieldID];
}
case FIELD_PHONE_NUMBER: {
const phoneNumber = this.refs.phoneNumber ? this.refs.phoneNumber.value : '';
const phoneNumberValid = phoneNumber === '' || phoneNumberLooksValid(phoneNumber);
if (this._authStepIsRequired('m.login.msisdn') && (!phoneNumberValid || phoneNumber === '')) {
this.markFieldValid(fieldID, false, "RegistrationForm.ERR_MISSING_PHONE_NUMBER");
} else this.markFieldValid(fieldID, phoneNumberValid, "RegistrationForm.ERR_PHONE_NUMBER_INVALID");
break;
}
case FIELD_USERNAME: {
const username = this.refs.username.value.trim();
if (allowEmpty && username === '') {
this.markFieldValid(fieldID, true);
} else if (!SAFE_LOCALPART_REGEX.test(username)) {
this.markFieldValid(
fieldID,
false,
"RegistrationForm.ERR_USERNAME_INVALID",
);
} else if (username == '') {
this.markFieldValid(
fieldID,
false,
"RegistrationForm.ERR_USERNAME_BLANK",
);
} else {
this.markFieldValid(fieldID, true);
}
break;
}
case FIELD_PASSWORD:
if (allowEmpty && pwd1 === "") {
this.markFieldValid(fieldID, true);
} else if (pwd1 == '') {
this.markFieldValid(
fieldID,
false,
"RegistrationForm.ERR_PASSWORD_MISSING",
);
} else if (pwd1.length < this.props.minPasswordLength) {
this.markFieldValid(
fieldID,
false,
"RegistrationForm.ERR_PASSWORD_LENGTH",
);
} else {
this.markFieldValid(fieldID, true);
}
break;
case FIELD_PASSWORD_CONFIRM:
this.markFieldValid(
fieldID, pwd1 == pwd2,
"RegistrationForm.ERR_PASSWORD_MISMATCH",
);
break;
}
return null;
},
markFieldValid: function(fieldID, val, errorCode) {
const fieldValid = this.state.fieldValid;
fieldValid[fieldID] = val;
this.setState({fieldValid: fieldValid});
if (!val) {
this.props.onError(errorCode);
}
markFieldValid: function(fieldID, valid) {
const { fieldValid } = this.state;
fieldValid[fieldID] = valid;
this.setState({
fieldValid,
});
},
fieldElementById(fieldID) {
switch (fieldID) {
case FIELD_EMAIL:
return this.refs.email;
case FIELD_PHONE_NUMBER:
return this.refs.phoneNumber;
case FIELD_USERNAME:
return this.refs.username;
case FIELD_PASSWORD:
return this.refs.password;
case FIELD_PASSWORD_CONFIRM:
return this.refs.passwordConfirm;
}
onEmailChange(ev) {
this.setState({
email: ev.target.value,
});
},
_classForField: function(fieldID, ...baseClasses) {
let cls = baseClasses.join(' ');
if (this.state.fieldValid[fieldID] === false) {
if (cls) cls += ' ';
cls += 'error';
}
return cls;
async onEmailValidate(fieldState) {
const result = await this.validateEmailRules(fieldState);
this.markFieldValid(FIELD_EMAIL, result.valid);
return result;
},
onEmailBlur(ev) {
this.validateField(FIELD_EMAIL, ev.type);
validateEmailRules: withValidation({
description: () => _t("Use an email address to recover your account"),
rules: [
{
key: "required",
test: function({ value, allowEmpty }) {
return allowEmpty || !this._authStepIsRequired('m.login.email.identity') || !!value;
},
invalid: () => _t("Enter email address (required on this homeserver)"),
},
{
key: "email",
test: ({ value }) => !value || Email.looksValid(value),
invalid: () => _t("Doesn't look like a valid email address"),
},
],
}),
onPasswordChange(ev) {
this.setState({
password: ev.target.value,
});
},
onPasswordBlur(ev) {
this.validateField(FIELD_PASSWORD, ev.type);
async onPasswordValidate(fieldState) {
const result = await this.validatePasswordRules(fieldState);
this.markFieldValid(FIELD_PASSWORD, result.valid);
return result;
},
onPasswordConfirmBlur(ev) {
this.validateField(FIELD_PASSWORD_CONFIRM, ev.type);
validatePasswordRules: withValidation({
description: function() {
const complexity = this.state.passwordComplexity;
const score = complexity ? complexity.score : 0;
return <progress
className="mx_AuthBody_passwordScore"
max={PASSWORD_MIN_SCORE}
value={score}
/>;
},
rules: [
{
key: "required",
test: ({ value, allowEmpty }) => allowEmpty || !!value,
invalid: () => _t("Enter password"),
},
{
key: "complexity",
test: async function({ value }) {
if (!value) {
return false;
}
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 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!");
},
invalid: function() {
const complexity = this.state.passwordComplexity;
if (!complexity) {
return null;
}
const { feedback } = complexity;
return feedback.warning || feedback.suggestions[0] || _t("Keep going...");
},
},
],
}),
onPasswordConfirmChange(ev) {
this.setState({
passwordConfirm: ev.target.value,
});
},
async onPasswordConfirmValidate(fieldState) {
const result = await this.validatePasswordConfirmRules(fieldState);
this.markFieldValid(FIELD_PASSWORD_CONFIRM, result.valid);
return result;
},
validatePasswordConfirmRules: withValidation({
rules: [
{
key: "required",
test: ({ value, allowEmpty }) => allowEmpty || !!value,
invalid: () => _t("Confirm password"),
},
{
key: "match",
test: function({ value }) {
return !value || value === this.state.password;
},
invalid: () => _t("Passwords don't match"),
},
],
}),
onPhoneCountryChange(newVal) {
this.setState({
phoneCountry: newVal.iso2,
@ -262,14 +337,64 @@ module.exports = React.createClass({
});
},
onPhoneNumberBlur(ev) {
this.validateField(FIELD_PHONE_NUMBER, ev.type);
onPhoneNumberChange(ev) {
this.setState({
phoneNumber: ev.target.value,
});
},
onUsernameBlur(ev) {
this.validateField(FIELD_USERNAME, ev.type);
async onPhoneNumberValidate(fieldState) {
const result = await this.validatePhoneNumberRules(fieldState);
this.markFieldValid(FIELD_PHONE_NUMBER, result.valid);
return result;
},
validatePhoneNumberRules: withValidation({
description: () => _t("Other users can invite you to rooms using your contact details"),
rules: [
{
key: "required",
test: function({ value, allowEmpty }) {
return allowEmpty || !this._authStepIsRequired('m.login.msisdn') || !!value;
},
invalid: () => _t("Enter phone number (required on this homeserver)"),
},
{
key: "email",
test: ({ value }) => !value || phoneNumberLooksValid(value),
invalid: () => _t("Doesn't look like a valid phone number"),
},
],
}),
onUsernameChange(ev) {
this.setState({
username: ev.target.value,
});
},
async onUsernameValidate(fieldState) {
const result = await this.validateUsernameRules(fieldState);
this.markFieldValid(FIELD_USERNAME, result.valid);
return result;
},
validateUsernameRules: withValidation({
description: () => _t("Use letters, numbers, dashes and underscores only"),
rules: [
{
key: "required",
test: ({ value, allowEmpty }) => allowEmpty || !!value,
invalid: () => _t("Enter username"),
},
{
key: "safeLocalpart",
test: ({ value }) => !value || SAFE_LOCALPART_REGEX.test(value),
invalid: () => _t("Some characters not allowed"),
},
],
}),
/**
* A step is required if all flows include that step.
*
@ -294,15 +419,115 @@ module.exports = React.createClass({
});
},
renderEmail() {
if (!this._authStepIsUsed('m.login.email.identity')) {
return null;
}
const Field = sdk.getComponent('elements.Field');
const emailPlaceholder = this._authStepIsRequired('m.login.email.identity') ?
_t("Email") :
_t("Email (optional)");
return <Field
id="mx_RegistrationForm_email"
ref={field => this[FIELD_EMAIL] = field}
type="text"
label={emailPlaceholder}
defaultValue={this.props.defaultEmail}
value={this.state.email}
onChange={this.onEmailChange}
onValidate={this.onEmailValidate}
/>;
},
renderPassword() {
const Field = sdk.getComponent('elements.Field');
return <Field
id="mx_RegistrationForm_password"
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}
/>;
},
renderPasswordConfirm() {
const Field = sdk.getComponent('elements.Field');
return <Field
id="mx_RegistrationForm_passwordConfirm"
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}
/>;
},
renderPhoneNumber() {
const threePidLogin = !SdkConfig.get().disable_3pid_login;
if (!threePidLogin || !this._authStepIsUsed('m.login.msisdn')) {
return null;
}
const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown');
const Field = sdk.getComponent('elements.Field');
const phoneLabel = this._authStepIsRequired('m.login.msisdn') ?
_t("Phone") :
_t("Phone (optional)");
const phoneCountry = <CountryDropdown
value={this.state.phoneCountry}
isSmall={true}
showPrefix={true}
onOptionChange={this.onPhoneCountryChange}
/>;
return <Field
id="mx_RegistrationForm_phoneNumber"
ref={field => this[FIELD_PHONE_NUMBER] = field}
type="text"
label={phoneLabel}
defaultValue={this.props.defaultPhoneNumber}
value={this.state.phoneNumber}
prefix={phoneCountry}
onChange={this.onPhoneNumberChange}
onValidate={this.onPhoneNumberValidate}
/>;
},
renderUsername() {
const Field = sdk.getComponent('elements.Field');
return <Field
id="mx_RegistrationForm_username"
ref={field => this[FIELD_USERNAME] = field}
type="text"
autoFocus={true}
label={_t("Username")}
defaultValue={this.props.defaultUsername}
value={this.state.username}
onChange={this.onUsernameChange}
onValidate={this.onUsernameValidate}
/>;
},
render: function() {
let yourMatrixAccountText = _t('Create your account');
try {
const parsedHsUrl = new URL(this.props.hsUrl);
yourMatrixAccountText = _t('Create your %(serverName)s account', {
serverName: parsedHsUrl.hostname,
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>;
},
});
} catch (e) {
// ignore
}
let editLink = null;
@ -310,66 +535,14 @@ module.exports = React.createClass({
editLink = <a className="mx_AuthBody_editServerDetails"
href="#" onClick={this.props.onEditServerDetailsClick}
>
{_t('Edit')}
{_t('Change')}
</a>;
}
let emailSection;
if (this._authStepIsUsed('m.login.email.identity')) {
const emailPlaceholder = this._authStepIsRequired('m.login.email.identity') ?
_t("Email") :
_t("Email (optional)");
emailSection = (
<div>
<input type="text" ref="email"
placeholder={emailPlaceholder}
defaultValue={this.props.defaultEmail}
className={this._classForField(FIELD_EMAIL, 'mx_Login_field')}
onBlur={this.onEmailBlur}
value={this.state.email} />
</div>
);
}
const threePidLogin = !SdkConfig.get().disable_3pid_login;
const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown');
let phoneSection;
if (threePidLogin && this._authStepIsUsed('m.login.msisdn')) {
const phonePlaceholder = this._authStepIsRequired('m.login.msisdn') ?
_t("Phone") :
_t("Phone (optional)");
phoneSection = (
<div className="mx_Login_phoneSection">
<CountryDropdown ref="phone_country"
className="mx_Login_phoneCountry mx_Login_field_prefix"
value={this.state.phoneCountry}
isSmall={true}
showPrefix={true}
onOptionChange={this.onPhoneCountryChange}
/>
<input type="text" ref="phoneNumber"
placeholder={phonePlaceholder}
defaultValue={this.props.defaultPhoneNumber}
className={this._classForField(
FIELD_PHONE_NUMBER,
'mx_Login_phoneNumberField',
'mx_Login_field',
'mx_Login_field_has_prefix',
)}
onBlur={this.onPhoneNumberBlur}
value={this.state.phoneNumber}
/>
</div>
);
}
const registerButton = (
<input className="mx_Login_submit" type="submit" value={_t("Register")} />
);
const placeholderUsername = _t("Username");
return (
<div>
<h3>
@ -378,31 +551,18 @@ module.exports = React.createClass({
</h3>
<form onSubmit={this.onSubmit}>
<div className="mx_AuthBody_fieldRow">
<input type="text" ref="username"
autoFocus={true}
placeholder={placeholderUsername} defaultValue={this.props.defaultUsername}
className={this._classForField(FIELD_USERNAME, 'mx_Login_field')}
onBlur={this.onUsernameBlur} />
{this.renderUsername()}
</div>
<div className="mx_AuthBody_fieldRow">
<input type="password" ref="password"
className={this._classForField(FIELD_PASSWORD, 'mx_Login_field')}
onBlur={this.onPasswordBlur}
placeholder={_t("Password")} defaultValue={this.props.defaultPassword} />
<input type="password" ref="passwordConfirm"
placeholder={_t("Confirm")}
className={this._classForField(FIELD_PASSWORD_CONFIRM, 'mx_Login_field')}
onBlur={this.onPasswordConfirmBlur}
defaultValue={this.props.defaultPassword} />
{this.renderPassword()}
{this.renderPasswordConfirm()}
</div>
<div className="mx_AuthBody_fieldRow">
{ emailSection }
{ phoneSection }
{this.renderEmail()}
{this.renderPhoneNumber()}
</div>
{_t(
"Use an email address to recover your account. Other users " +
"can invite you to rooms using your contact details.",
)}
{_t("Use an email address to recover your account.") + " "}
{_t("Other users can invite you to rooms using your contact details.")}
{ registerButton }
</form>
</div>

View file

@ -20,6 +20,9 @@ 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";
/*
* A pure UI component which displays the HS and IS to use.
@ -27,82 +30,119 @@ 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,
};
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,
};
}
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"
return this.validateAndApplyServer(this.state.hsUrl, this.state.isUrl);
}
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,
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);
let message = _t("Unable to validate homeserver/identity server");
if (e.translatedMessage) {
message = e.translatedMessage;
}
this.setState({
busy: false,
errorText: message,
});
}
}
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();
await this.validateServer();
if (this.props.onAfterSubmit) {
this.props.onAfterSubmit();
}
};
_waitThenInvoke(existingTimeoutId, fn) {
if (existingTimeoutId) {
@ -114,10 +154,24 @@ export default class ServerConfig extends React.PureComponent {
showHelpPopup = () => {
const CustomServerDialog = sdk.getComponent('auth.CustomServerDialog');
Modal.createTrackedDialog('Custom Server Dialog', '', CustomServerDialog);
}
};
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">
@ -127,22 +181,28 @@ export default class ServerConfig extends React.PureComponent {
{ 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}
<form onSubmit={this.onSubmit} autoComplete={false} action={null}>
<div className="mx_ServerConfig_fields">
<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}
/>
<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>
{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';
@ -30,40 +32,47 @@ export const TYPES = {
FREE: {
id: FREE,
label: () => _t('Free'),
logo: () => <img src={require('../../../../res/img/feather-icons/matrix-org-bw-logo.svg')} />,
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.org",
hsName: "matrix.org",
hsNameIsDifferent: false,
isUrl: "https://vector.im",
identityEnabled: true,
}),
},
PREMIUM: {
id: PREMIUM,
label: () => _t('Premium'),
logo: () => <img src={require('../../../../res/img/feather-icons/modular-bw-logo.svg')} />,
logo: () => <img src={require('../../../../res/img/modular-bw-logo.svg')} />,
description: () => _t('Premium hosting for organisations <a>Learn more</a>', {}, {
a: sub => <a href={MODULAR_URL} target="_blank" rel="noopener">
{sub}
</a>,
}),
identityServerUrl: "https://vector.im",
},
ADVANCED: {
id: ADVANCED,
label: () => _t('Advanced'),
logo: () => <div>
<img src={require('../../../../res/img/feather-icons/globe.svg')} />
<img src={require('../../../../res/img/feather-customised/globe.svg')} />
{_t('Other')}
</div>,
description: () => _t('Find other public servers or use a custom server'),
},
};
function getDefaultType(defaultHsUrl) {
if (!defaultHsUrl) {
export function getTypeFromServerConfig(config) {
const {hsUrl} = config;
if (!hsUrl) {
return null;
} else if (defaultHsUrl === TYPES.FREE.hsUrl) {
} else if (hsUrl === TYPES.FREE.serverConfig.hsUrl) {
return FREE;
} else if (new URL(defaultHsUrl).hostname.endsWith('.modular.im')) {
// TODO: Use a Riot config parameter to detect Modular-ness.
// https://github.com/vector-im/riot-web/issues/8253
} else if (new URL(hsUrl).hostname.endsWith('.modular.im')) {
// This is an unlikely case to reach, as Modular defaults to hiding the
// server type selector.
return PREMIUM;
} else {
return ADVANCED;
@ -72,26 +81,22 @@ function getDefaultType(defaultHsUrl) {
export default class ServerTypeSelector extends React.PureComponent {
static propTypes = {
// The default HS URL as another way to set the initially selected type.
defaultHsUrl: PropTypes.string,
// The default selected type.
selected: PropTypes.string,
// Handler called when the selected type changes.
onChange: PropTypes.func.isRequired,
}
};
constructor(props) {
super(props);
const {
defaultHsUrl,
onChange,
selected,
} = props;
const type = getDefaultType(defaultHsUrl);
this.state = {
selected: type,
selected,
};
if (onChange) {
onChange(type);
}
}
updateSelectedType(type) {
@ -110,7 +115,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,47 @@
/*
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.
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 SdkConfig from '../../../SdkConfig';
export default class Welcome extends React.PureComponent {
render() {
const AuthPage = sdk.getComponent("auth.AuthPage");
const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage');
const LanguageSelector = sdk.getComponent('auth.LanguageSelector');
const pagesConfig = SdkConfig.get().embeddedPages;
let pageUrl = null;
if (pagesConfig) {
pageUrl = pagesConfig.welcomeUrl;
}
if (!pageUrl) {
pageUrl = 'welcome.html';
}
return (
<AuthPage>
<div className="mx_Welcome">
<EmbeddedPage className="mx_WelcomePage"
url={pageUrl}
/>
<LanguageSelector />
</div>
</AuthPage>
);
}
}

View file

@ -138,40 +138,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 {
@ -181,20 +148,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

@ -19,7 +19,7 @@ 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({
displayName: 'RoomAvatar',
@ -89,7 +89,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 +97,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

@ -26,8 +26,8 @@ import { _t } from '../../../languageHandler';
import Modal from '../../../Modal';
import Resend from '../../../Resend';
import SettingsStore from '../../../settings/SettingsStore';
import {makeEventPermalink} from '../../../matrix-to';
import { isUrlPermitted } from '../../../HtmlUtils';
import { isContentActionable } from '../../../utils/EventUtils';
module.exports = React.createClass({
displayName: 'MessageContextMenu',
@ -90,9 +90,16 @@ module.exports = React.createClass({
this.closeMenu();
},
e2eInfoClicked: function() {
this.props.e2eInfoCallback();
this.closeMenu();
},
onViewSourceClick: function() {
const ViewSource = sdk.getComponent('structures.ViewSource');
Modal.createTrackedDialog('View Event Source', '', ViewSource, {
roomId: this.props.mxEvent.getRoomId(),
eventId: this.props.mxEvent.getId(),
content: this.props.mxEvent.event,
}, 'mx_Dialog_viewsource');
this.closeMenu();
@ -101,6 +108,8 @@ module.exports = React.createClass({
onViewClearSourceClick: function() {
const ViewSource = sdk.getComponent('structures.ViewSource');
Modal.createTrackedDialog('View Clear Event Source', '', ViewSource, {
roomId: this.props.mxEvent.getRoomId(),
eventId: this.props.mxEvent.getId(),
// FIXME: _clearEvent is private
content: this.props.mxEvent._clearEvent,
}, 'mx_Dialog_viewsource');
@ -188,14 +197,7 @@ module.exports = React.createClass({
const ShareDialog = sdk.getComponent("dialogs.ShareDialog");
Modal.createTrackedDialog('share room message dialog', '', ShareDialog, {
target: this.props.mxEvent,
});
this.closeMenu();
},
onReplyClick: function() {
dis.dispatch({
action: 'reply_to_event',
event: this.props.mxEvent,
permalinkCreator: this.props.permalinkCreator,
});
this.closeMenu();
},
@ -206,7 +208,8 @@ module.exports = React.createClass({
},
render: function() {
const eventStatus = this.props.mxEvent.status;
const mxEvent = this.props.mxEvent;
const eventStatus = mxEvent.status;
let resendButton;
let redactButton;
let cancelButton;
@ -216,7 +219,6 @@ module.exports = React.createClass({
let unhidePreviewButton;
let externalURLButton;
let quoteButton;
let replyButton;
let collapseReplyThread;
// status is SENT before remote-echo, null after
@ -246,28 +248,19 @@ module.exports = React.createClass({
);
}
if (isSent && this.props.mxEvent.getType() === 'm.room.message') {
const content = this.props.mxEvent.getContent();
if (content.msgtype && content.msgtype !== 'm.bad.encrypted' && content.hasOwnProperty('body')) {
forwardButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onForwardClick}>
{ _t('Forward Message') }
if (isContentActionable(mxEvent)) {
forwardButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onForwardClick}>
{ _t('Forward Message') }
</div>
);
if (this.state.canPin) {
pinButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onPinClick}>
{ this._isPinned() ? _t('Unpin Message') : _t('Pin Message') }
</div>
);
replyButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onReplyClick}>
{ _t('Reply') }
</div>
);
if (this.state.canPin) {
pinButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onPinClick}>
{ this._isPinned() ? _t('Unpin Message') : _t('Pin Message') }
</div>
);
}
}
}
@ -277,7 +270,7 @@ module.exports = React.createClass({
</div>
);
if (this.props.mxEvent.getType() !== this.props.mxEvent.getWireType()) {
if (mxEvent.getType() !== mxEvent.getWireType()) {
viewClearSourceButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onViewClearSourceClick}>
{ _t('View Decrypted Source') }
@ -295,11 +288,18 @@ module.exports = React.createClass({
}
}
let permalink;
if (this.props.permalinkCreator) {
permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId());
}
// 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={makeEventPermalink(this.props.mxEvent.getRoomId(), this.props.mxEvent.getId())}
target="_blank" rel="noopener" onClick={this.onPermalinkClick}>{ _t('Share Message') }</a>
<a href={permalink}
target="_blank" rel="noopener" onClick={this.onPermalinkClick}>
{ mxEvent.isRedacted() || mxEvent.getType() !== 'm.room.message'
? _t('Share Permalink') : _t('Share Message') }
</a>
</div>
);
@ -313,12 +313,12 @@ module.exports = React.createClass({
// Bridges can provide a 'external_url' to link back to the source.
if (
typeof(this.props.mxEvent.event.content.external_url) === "string" &&
isUrlPermitted(this.props.mxEvent.event.content.external_url)
typeof(mxEvent.event.content.external_url) === "string" &&
isUrlPermitted(mxEvent.event.content.external_url)
) {
externalURLButton = (
<div className="mx_MessageContextMenu_field">
<a href={this.props.mxEvent.event.content.external_url}
<a href={mxEvent.event.content.external_url}
rel="noopener" target="_blank" onClick={this.closeMenu}>{ _t('Source URL') }</a>
</div>
);
@ -332,6 +332,13 @@ module.exports = React.createClass({
);
}
let e2eInfo;
if (this.props.e2eInfoCallback) {
e2eInfo = <div className="mx_MessageContextMenu_field" onClick={this.e2eInfoClicked}>
{ _t('End-to-end encryption information') }
</div>;
}
return (
<div className="mx_MessageContextMenu">
{ resendButton }
@ -344,9 +351,9 @@ module.exports = React.createClass({
{ unhidePreviewButton }
{ permalinkButton }
{ quoteButton }
{ replyButton }
{ externalURLButton }
{ collapseReplyThread }
{ e2eInfo }
</div>
);
},

View file

@ -271,6 +271,27 @@ module.exports = React.createClass({
);
},
_onClickSettings: function() {
dis.dispatch({
action: 'open_room_settings',
room_id: this.props.room.roomId,
});
if (this.props.onFinished) {
this.props.onFinished();
}
},
_renderSettingsMenu: function() {
return (
<div>
<div 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>
</div>
);
},
_renderLeaveMenu: function(membership) {
if (!membership) {
return null;
@ -350,7 +371,11 @@ module.exports = React.createClass({
// Can't set notif level or tags on non-join rooms
if (myMembership !== 'join') {
return this._renderLeaveMenu(myMembership);
return <div>
{ this._renderLeaveMenu(myMembership) }
<hr className="mx_RoomTileContextMenu_separator" />
{ this._renderSettingsMenu() }
</div>;
}
return (
@ -360,6 +385,8 @@ module.exports = React.createClass({
{ this._renderLeaveMenu(myMembership) }
<hr className="mx_RoomTileContextMenu_separator" />
{ this._renderRoomTagMenu() }
<hr className="mx_RoomTileContextMenu_separator" />
{ this._renderSettingsMenu() }
</div>
);
},

View file

@ -25,8 +25,6 @@ export default class StatusMessageContextMenu extends React.Component {
static propTypes = {
// js-sdk User object. Not required because it might not exist.
user: PropTypes.object,
// True when waiting for status change to complete.
waiting: false,
};
constructor(props, context) {
@ -45,7 +43,7 @@ export default class StatusMessageContextMenu extends React.Component {
user.on("User._unstable_statusMessage", this._onStatusMessageCommitted);
}
componentWillUmount() {
componentWillUnmount() {
const { user } = this.props;
if (!user) {
return;

View file

@ -68,7 +68,7 @@ export default class TagTileContextMenu extends React.Component {
<hr className="mx_TagTileContextMenu_separator" />
<div className="mx_TagTileContextMenu_item" onClick={this._onRemoveClick} >
<img className="mx_TagTileContextMenu_item_icon" src={require("../../../../res/img/icon_context_delete.svg")} width="15" height="15" />
{ _t('Remove') }
{ _t('Hide') }
</div>
</div>;
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2018 New Vector 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.
@ -15,34 +16,116 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import dis from '../../../dispatcher';
import { _t } from '../../../languageHandler';
import LogoutDialog from "../dialogs/LogoutDialog";
import Modal from "../../../Modal";
import SdkConfig from '../../../SdkConfig';
import { getHostingLink } from '../../../utils/HostingLink';
import MatrixClientPeg from '../../../MatrixClientPeg';
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() {
super();
this.viewHomePage = this.viewHomePage.bind(this);
this.openSettings = this.openSettings.bind(this);
this.signIn = this.signIn.bind(this);
this.signOut = this.signOut.bind(this);
}
hasHomePage() {
const config = SdkConfig.get();
const pagesConfig = config.embeddedPages;
if (pagesConfig && pagesConfig.homeUrl) {
return true;
}
// This is a deprecated config option for the home page
// (despite the name, given we also now have a welcome
// page, which is not the same).
return !!config.welcomePageUrl;
}
render() {
return <div className="mx_TopLeftMenu">
<ul className="mx_TopLeftMenu_section">
<li className="mx_TopLeftMenu_icon_settings" onClick={this.openSettings}>{_t("Settings")}</li>
</ul>
<ul className="mx_TopLeftMenu_section">
<li className="mx_TopLeftMenu_icon_signout" onClick={this.signOut}>{_t("Sign out")}</li>
const isGuest = MatrixClientPeg.get().isGuest();
const hostingSignupLink = getHostingLink('user-context-menu');
let hostingSignup = null;
if (hostingSignupLink) {
hostingSignup = <div className="mx_TopLeftMenu_upgradeLink">
{_t(
"<a>Upgrade</a> to your own domain", {},
{
a: sub => <a href={hostingSignupLink} target="_blank" rel="noopener" tabIndex="0">{sub}</a>,
},
)}
<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 homePageItem = null;
if (this.hasHomePage()) {
homePageItem = <li className="mx_TopLeftMenu_icon_home" onClick={this.viewHomePage} tabIndex={0}>
{_t("Home")}
</li>;
}
let signInOutItem;
if (isGuest) {
signInOutItem = <li className="mx_TopLeftMenu_icon_signin" onClick={this.signIn} tabIndex={0}>
{_t("Sign in")}
</li>;
} else {
signInOutItem = <li className="mx_TopLeftMenu_icon_signout" onClick={this.signOut} tabIndex={0}>
{_t("Sign out")}
</li>;
}
const settingsItem = <li className="mx_TopLeftMenu_icon_settings" onClick={this.openSettings} tabIndex={0}>
{_t("Settings")}
</li>;
return <div className="mx_TopLeftMenu mx_HiddenFocusable" tabIndex={0} ref={this.props.containerRef}>
<div className="mx_TopLeftMenu_section_noIcon" aria-readonly={true}>
<div>{this.props.displayName}</div>
<div className="mx_TopLeftMenu_greyedText" aria-hidden={true}>{this.props.userId}</div>
{hostingSignup}
</div>
<ul className="mx_TopLeftMenu_section_withIcon">
{homePageItem}
{settingsItem}
{signInOutItem}
</ul>
</div>;
}
viewHomePage() {
dis.dispatch({action: 'view_home_page'});
this.closeMenu();
}
openSettings() {
dis.dispatch({action: 'view_user_settings'});
this.closeMenu();
}
signIn() {
dis.dispatch({action: 'start_login'});
this.closeMenu();
}
signOut() {
Modal.createTrackedDialog('Logout E2E Export', '', LogoutDialog);
this.closeMenu();

View file

@ -1,6 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017, 2018 New Vector Ltd
Copyright 2017, 2018, 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.
@ -566,7 +566,7 @@ module.exports = React.createClass({
rows="1"
id="textinput"
ref="textinput"
className="mx_ChatInviteDialog_input"
className="mx_AddressPickerDialog_input"
onChange={this.onQueryChanged}
placeholder={this.props.placeholder}
defaultValue={this.props.value}
@ -578,7 +578,7 @@ module.exports = React.createClass({
let addressSelector;
if (this.state.error) {
const validTypeDescriptions = this.props.validAddressTypes.map((t) => _t(addressTypeName[t]));
error = <div className="mx_ChatInviteDialog_error">
error = <div className="mx_AddressPickerDialog_error">
{ _t("You have entered an invalid address.") }
<br />
{ _t("Try using one of the following valid address types: %(validTypesList)s.", {
@ -586,9 +586,9 @@ module.exports = React.createClass({
}) }
</div>;
} else if (this.state.searchError) {
error = <div className="mx_ChatInviteDialog_error">{ this.state.searchError }</div>;
error = <div className="mx_AddressPickerDialog_error">{ this.state.searchError }</div>;
} else if (this.state.query.length > 0 && filteredSuggestedList.length === 0 && !this.state.busy) {
error = <div className="mx_ChatInviteDialog_error">{ _t("No results") }</div>;
error = <div className="mx_AddressPickerDialog_error">{ _t("No results") }</div>;
} else {
addressSelector = (
<AddressSelector ref={(ref) => {this.addressSelector = ref;}}
@ -601,13 +601,13 @@ module.exports = React.createClass({
}
return (
<BaseDialog className="mx_ChatInviteDialog" onKeyDown={this.onKeyDown}
<BaseDialog className="mx_AddressPickerDialog" onKeyDown={this.onKeyDown}
onFinished={this.props.onFinished} title={this.props.title}>
<div className="mx_ChatInviteDialog_label">
<div className="mx_AddressPickerDialog_label">
<label htmlFor="textinput">{ this.props.description }</label>
</div>
<div className="mx_Dialog_content">
<div className="mx_ChatInviteDialog_inputContainer">{ query }</div>
<div className="mx_AddressPickerDialog_inputContainer">{ query }</div>
{ error }
{ addressSelector }
{ this.props.extraNode }

View file

@ -1,6 +1,6 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Copyright 2018, 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.
@ -55,6 +55,11 @@ export default React.createClass({
// CSS class to apply to dialog div
className: PropTypes.string,
// if true, dialog container is 60% of the viewport width. Otherwise,
// the container will have no fixed size, allowing its contents to
// determine its size. Default: true.
fixedWidth: PropTypes.bool,
// Title for the dialog.
title: PropTypes.node.isRequired,
@ -72,6 +77,7 @@ export default React.createClass({
getDefaultProps: function() {
return {
hasCancel: true,
fixedWidth: true,
};
},
@ -113,7 +119,10 @@ export default React.createClass({
return (
<FocusTrap onKeyDown={this._onKeyDown}
className={this.props.className}
className={classNames({
[this.props.className]: true,
'mx_Dialog_fixedWidth': this.props.fixedWidth,
})}
role="dialog"
aria-labelledby='mx_BaseDialog_title'
// This should point to a node describing the dialog.
@ -131,8 +140,8 @@ export default React.createClass({
{ this.props.title }
</div>
{ this.props.headerButton }
{ cancelButton }
</div>
{ cancelButton }
{ this.props.children }
</FocusTrap>
);

View file

@ -108,6 +108,7 @@ export default class BugReportDialog extends React.Component {
const Loader = sdk.getComponent("elements.Spinner");
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const Field = sdk.getComponent('elements.Field');
let error = null;
if (this.state.err) {
@ -154,36 +155,29 @@ export default class BugReportDialog extends React.Component {
},
) }
</b></p>
<div className="mx_BugReportDialog_field_container">
<label
htmlFor="mx_BugReportDialog_issueUrl"
className="mx_BugReportDialog_field_label"
>
{ _t("What GitHub issue are these logs for?") }
</label>
<input
id="mx_BugReportDialog_issueUrl"
type="text"
className="mx_BugReportDialog_field_input"
onChange={this._onIssueUrlChange}
value={this.state.issueUrl}
placeholder="https://github.com/vector-im/riot-web/issues/..."
/>
</div>
<div className="mx_BugReportDialog_field_container">
<label
htmlFor="mx_BugReportDialog_notes_label"
className="mx_BugReportDialog_field_label"
>
{ _t("Notes:") }
</label>
<textarea
className="mx_BugReportDialog_field_input"
rows={5}
onChange={this._onTextChange}
value={this.state.text}
/>
</div>
<Field
id="mx_BugReportDialog_issueUrl"
type="text"
className="mx_BugReportDialog_field_input"
label={_t("GitHub issue")}
onChange={this._onIssueUrlChange}
value={this.state.issueUrl}
placeholder="https://github.com/vector-im/riot-web/issues/..."
/>
<Field
className="mx_BugReportDialog_field_input"
element="textarea"
label={_t("Notes")}
rows={5}
onChange={this._onTextChange}
value={this.state.text}
placeholder={_t(
"If there is additional context that would help in " +
"analysing the issue, such as what you were doing at " +
"the time, room IDs, user IDs, etc., " +
"please include those things here.",
)}
/>
{progress}
{error}
</div>

View file

@ -51,7 +51,7 @@ export default class ChangelogDialog extends React.Component {
return (
<li key={commit.sha} className="mx_ChangelogDialog_li">
<a href={commit.html_url} target="_blank" rel="noopener">
{commit.commit.message}
{commit.commit.message.split('\n')[0]}
</a>
</li>
);

View file

@ -21,7 +21,7 @@ import sdk from '../../../index';
import Analytics from '../../../Analytics';
import MatrixClientPeg from '../../../MatrixClientPeg';
import * as Lifecycle from '../../../Lifecycle';
import Velocity from 'velocity-vector';
import Velocity from 'velocity-animate';
import { _t } from '../../../languageHandler';
export default class DeactivateAccountDialog extends React.Component {

View file

@ -22,7 +22,6 @@ import MatrixClientPeg from '../../../MatrixClientPeg';
import sdk from '../../../index';
import * as FormattingUtils from '../../../utils/FormattingUtils';
import { _t } from '../../../languageHandler';
import SettingsStore from '../../../settings/SettingsStore';
import {verificationMethods} from 'matrix-js-sdk/lib/crypto';
const MODE_LEGACY = 'legacy';
@ -48,7 +47,7 @@ export default class DeviceVerifyDialog extends React.Component {
this._showSasEvent = null;
this.state = {
phase: PHASE_START,
mode: SettingsStore.isFeatureEnabled("feature_sas") ? MODE_SAS : MODE_LEGACY,
mode: MODE_SAS,
sasVerified: false,
};
}
@ -61,6 +60,11 @@ export default class DeviceVerifyDialog extends React.Component {
}
_onSwitchToLegacyClick = () => {
if (this._verifier) {
this._verifier.removeListener('show_sas', this._onVerifierShowSas);
this._verifier.cancel('User cancel');
this._verifier = null;
}
this.setState({mode: MODE_LEGACY});
}
@ -185,11 +189,21 @@ export default class DeviceVerifyDialog extends React.Component {
_renderSasVerificationPhaseWaitAccept() {
const Spinner = sdk.getComponent("views.elements.Spinner");
const AccessibleButton = sdk.getComponent('views.elements.AccessibleButton');
return (
<div>
<Spinner />
<p>{_t("Waiting for partner to accept...")}</p>
<p>{_t(
"Nothing appearing? Not all clients support interactive verification yet. " +
"<button>Use legacy verification</button>.",
{}, {button: sub => <AccessibleButton element='span' className="mx_linkButton"
onClick={this._onSwitchToLegacyClick}
>
{sub}
</AccessibleButton>},
)}</p>
</div>
);
}
@ -241,7 +255,7 @@ export default class DeviceVerifyDialog extends React.Component {
"and ask them whether the key they see in their User Settings " +
"for this device matches the key below:") }
</p>
<div className="mx_UserSettings_cryptoSection">
<div className="mx_DeviceVerifyDialog_cryptoSection">
<ul>
<li><label>{ _t("Device name") }:</label> <span>{ this.props.device.getDisplayName() }</span></li>
<li><label>{ _t("Device ID") }:</label> <span><code>{ this.props.device.deviceId }</code></span></li>

View file

@ -20,6 +20,7 @@ import sdk from '../../../index';
import SyntaxHighlight from '../elements/SyntaxHighlight';
import { _t } from '../../../languageHandler';
import MatrixClientPeg from '../../../MatrixClientPeg';
import Field from "../elements/Field";
class DevtoolsComponent extends React.Component {
static contextTypes = {
@ -56,14 +57,8 @@ class GenericEditor extends DevtoolsComponent {
}
textInput(id, label) {
return <div className="mx_DevTools_inputRow">
<div className="mx_DevTools_inputLabelCell">
<label htmlFor={id}>{ label }</label>
</div>
<div className="mx_DevTools_inputCell">
<input id={id} className="mx_TextInputDialog_input" onChange={this._onChange} value={this.state[id]} size="32" autoFocus={true} />
</div>
</div>;
return <Field id={id} label={label} size="42" autoFocus={true} type="text" autoComplete="on"
value={this.state[id]} onChange={this._onChange} />;
}
}
@ -133,17 +128,15 @@ 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 />
<div className="mx_DevTools_inputLabelCell">
<label htmlFor="evContent"> { _t('Event Content') } </label>
</div>
<div>
<textarea id="evContent" onChange={this._onChange} value={this.state.evContent} className="mx_DevTools_textarea" />
</div>
<Field id="evContent" label={_t("Event Content")} type="text" className="mx_DevTools_textarea"
autoComplete="off" value={this.state.evContent} onChange={this._onChange} element="textarea" />
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button>
@ -223,12 +216,8 @@ class SendAccountData extends GenericEditor {
{ this.textInput('eventType', _t('Event Type')) }
<br />
<div className="mx_DevTools_inputLabelCell">
<label htmlFor="evContent"> { _t('Event Content') } </label>
</div>
<div>
<textarea id="evContent" onChange={this._onChange} value={this.state.evContent} className="mx_DevTools_textarea" />
</div>
<Field id="evContent" label={_t("Event Content")} type="text" className="mx_DevTools_textarea"
autoComplete="off" value={this.state.evContent} onChange={this._onChange} element="textarea" />
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button>
@ -302,14 +291,12 @@ class FilteredList extends React.Component {
render() {
const TruncatedList = sdk.getComponent("elements.TruncatedList");
return <div>
<input size="64"
autoFocus={true}
onChange={this.onQuery}
value={this.props.query}
placeholder={_t('Filter results')}
<Field id="DevtoolsDialog_FilteredList_filter" label={_t('Filter results')} autoFocus={true} size={64}
type="text" autoComplete="off" value={this.props.query} onChange={this.onQuery}
className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query"
// force re-render so that autoFocus is applied when this component is re-used
key={this.props.children[0] ? this.props.children[0].key : ''} />
<TruncatedList getChildren={this.getChildren}
getChildCount={this.getChildCount}
truncateAt={this.state.truncateAt}
@ -566,11 +553,53 @@ class AccountDataExplorer extends DevtoolsComponent {
}
}
class ServersInRoomList extends DevtoolsComponent {
static getLabel() { return _t('View Servers in Room'); }
static propTypes = {
onBack: PropTypes.func.isRequired,
};
constructor(props, context) {
super(props, context);
const room = MatrixClientPeg.get().getRoom(this.context.roomId);
const servers = new Set();
room.currentState.getStateEvents("m.room.member").forEach(ev => servers.add(ev.getSender().split(":")[1]));
this.servers = Array.from(servers).map(s =>
<button key={s} className="mx_DevTools_ServersInRoomList_button">
{ s }
</button>);
this.state = {
query: '',
};
}
onQuery = (query) => {
this.setState({ query });
}
render() {
return <div>
<div className="mx_Dialog_content">
<FilteredList query={this.state.query} onChange={this.onQuery}>
{ this.servers }
</FilteredList>
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.props.onBack}>{ _t('Back') }</button>
</div>
</div>;
}
}
const Entries = [
SendCustomEvent,
RoomStateExplorer,
SendAccountData,
AccountDataExplorer,
ServersInRoomList,
];
export default class DevtoolsDialog extends React.Component {

View file

@ -16,6 +16,7 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import MatrixClientPeg from '../../../MatrixClientPeg';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
@ -37,9 +38,12 @@ export default class IncomingSasDialog extends React.Component {
this.state = {
phase: PHASE_START,
sasVerified: false,
opponentProfile: null,
opponentProfileError: null,
};
this.props.verifier.on('show_sas', this._onVerifierShowSas);
this.props.verifier.on('cancel', this._onVerifierCancel);
this._fetchOpponentProfile();
}
componentWillUnmount() {
@ -49,6 +53,21 @@ export default class IncomingSasDialog extends React.Component {
this.props.verifier.removeListener('show_sas', this._onVerifierShowSas);
}
async _fetchOpponentProfile() {
try {
const prof = await MatrixClientPeg.get().getProfileInfo(
this.props.verifier.userId,
);
this.setState({
opponentProfile: prof,
});
} catch (e) {
this.setState({
opponentProfileError: e,
});
}
}
_onFinished = () => {
this.props.onFinished(this.state.phase === PHASE_VERIFIED);
}
@ -93,10 +112,39 @@ export default class IncomingSasDialog extends React.Component {
_renderPhaseStart() {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const Spinner = sdk.getComponent("views.elements.Spinner");
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
let profile;
if (this.state.opponentProfile) {
profile = <div className="mx_IncomingSasDialog_opponentProfile">
<BaseAvatar name={this.state.opponentProfile.displayname}
idName={this.props.verifier.userId}
url={MatrixClientPeg.get().mxcUrlToHttp(
this.state.opponentProfile.avatar_url,
Math.floor(48 * window.devicePixelRatio),
Math.floor(48 * window.devicePixelRatio),
'crop',
)}
width={48} height={48} resizeMethod='crop'
/>
<h2>{this.state.opponentProfile.displayname}</h2>
</div>;
} else if (this.state.opponentProfileError) {
profile = <div>
<BaseAvatar name={this.props.verifier.userId.slice(1)}
idName={this.props.verifier.userId}
width={48} height={48}
/>
<h2>{this.props.verifier.userId}</h2>
</div>;
} else {
profile = <Spinner />;
}
return (
<div>
<h2>{this.props.verifier.userId}</h2>
{profile}
<p>{_t(
"Verify this user to mark them as trusted. " +
"Trusting users gives you extra peace of mind when using " +

View file

@ -0,0 +1,64 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 New Vector Ltd.
Copyright 2019 Bastian Masanek, Noxware IT <matrix@noxware.de>
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 React.createClass({
displayName: 'InfoDialog',
propTypes: {
title: PropTypes.string,
description: PropTypes.node,
button: PropTypes.string,
onFinished: PropTypes.func,
},
getDefaultProps: function() {
return {
title: '',
description: '',
};
},
onFinished: function() {
this.props.onFinished();
},
render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return (
<BaseDialog className="mx_InfoDialog" onFinished={this.props.onFinished}
title={this.props.title}
contentId='mx_Dialog_content'
hasCancel={false}
>
<div className="mx_Dialog_content" id="mx_Dialog_content">
{ this.props.description }
</div>
<DialogButtons primaryButton={this.props.button || _t('OK')}
onPrimaryButtonClick={this.onFinished}
hasCancel={false}
>
</DialogButtons>
</BaseDialog>
);
},
});

View file

@ -77,7 +77,7 @@ export default React.createClass({
<div role="alert">{ this.state.authError.message || this.state.authError.toString() }</div>
<br />
<AccessibleButton onClick={this._onDismissClick}
className="mx_UserSettings_button"
className="mx_GeneralButton"
autoFocus="true"
>
{ _t("Dismiss") }

View file

@ -20,9 +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();
this._onSettingsLinkClick = this._onSettingsLinkClick.bind(this);
@ -30,13 +33,39 @@ export default class LogoutDialog extends React.Component {
this._onFinished = this._onFinished.bind(this);
this._onSetRecoveryMethodClick = this._onSetRecoveryMethodClick.bind(this);
this._onLogoutConfirm = this._onLogoutConfirm.bind(this);
const shouldLoadBackupStatus = !MatrixClientPeg.get().getKeyBackupEnabled();
this.state = {
loading: shouldLoadBackupStatus,
backupInfo: null,
error: null,
};
if (shouldLoadBackupStatus) {
this._loadBackupStatus();
}
}
async _loadBackupStatus() {
try {
const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
this.setState({
loading: false,
backupInfo,
});
} catch (e) {
console.log("Unable to fetch key backup status", e);
this.setState({
loading: false,
error: e,
});
}
}
_onSettingsLinkClick() {
// close dialog
if (this.props.onFinished) {
this.props.onFinished();
}
this.props.onFinished();
}
_onExportE2eKeysClicked() {
@ -53,112 +82,102 @@ export default class LogoutDialog extends React.Component {
dis.dispatch({action: 'logout'});
}
// close dialog
if (this.props.onFinished) {
this.props.onFinished();
}
this.props.onFinished();
}
_onSetRecoveryMethodClick() {
Modal.createTrackedDialogAsync('Key Backup', 'Key Backup',
import('../../../async-components/views/dialogs/keybackup/CreateKeyBackupDialog'),
);
if (this.state.backupInfo) {
// A key backup exists for this account, but the creating device is not
// verified, so restore the backup which will give us the keys from it and
// allow us to trust it (ie. upload keys to it)
const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog');
Modal.createTrackedDialog('Restore Backup', '', RestoreKeyBackupDialog, {});
} else {
Modal.createTrackedDialogAsync("Key Backup", "Key Backup",
import("../../../async-components/views/dialogs/keybackup/CreateKeyBackupDialog"),
);
}
// close dialog
if (this.props.onFinished) {
this.props.onFinished();
}
this.props.onFinished();
}
_onLogoutConfirm() {
dis.dispatch({action: 'logout'});
// close dialog
if (this.props.onFinished) {
this.props.onFinished();
}
this.props.onFinished();
}
render() {
let description;
if (SettingsStore.isFeatureEnabled("feature_keybackup")) {
description = <div>
<p>{_t(
"When you log out, you'll lose your secure message history. To prevent " +
"this, set up a recovery method.",
)}</p>
<p>{_t(
"Alternatively, advanced users can also manually export encryption keys in " +
"<a>Settings</a> before logging out.", {},
{
a: sub => <a href='#/settings' onClick={this._onSettingsLinkClick}>{sub}</a>,
},
)}</p>
</div>;
} else {
description = <div>{_t(
"For security, logging out will delete any end-to-end " +
"encryption keys from this browser. If you want to be able " +
"to decrypt your conversation history from future Riot sessions, " +
"please export your room keys for safe-keeping.",
)}</div>;
}
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 (SettingsStore.isFeatureEnabled("feature_keybackup")) {
if (!MatrixClientPeg.get().getKeyBackupEnabled()) {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
if (!MatrixClientPeg.get().getKeyBackupEnabled()) {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
let dialogContent;
if (this.state.loading) {
const Spinner = sdk.getComponent('views.elements.Spinner');
dialogContent = <Spinner />;
} else {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
// Not quite a standard question dialog as the primary button cancels
// the action and does something else instead, whilst non-default button
// confirms the action.
return (<BaseDialog
title={_t("Warning!")}
contentId='mx_Dialog_content'
hasCancel={false}
onFinsihed={this._onFinished}
>
let setupButtonCaption;
if (this.state.backupInfo) {
setupButtonCaption = _t("Connect this device to Key Backup");
} else {
// if there's an error fetching the backup info, we'll just assume there's
// no backup for the purpose of the button caption
setupButtonCaption = _t("Start using Key Backup");
}
dialogContent = <div>
<div className="mx_Dialog_content" id='mx_Dialog_content'>
{ description }
</div>
<DialogButtons primaryButton={_t('Set a Recovery Method')}
<DialogButtons primaryButton={setupButtonCaption}
hasCancel={false}
onPrimaryButtonClick={this._onSetRecoveryMethodClick}
focus={true}
>
<button onClick={this._onLogoutConfirm}>
{_t("I understand, log out without")}
{_t("I don't want my encrypted messages")}
</button>
</DialogButtons>
</BaseDialog>);
} else {
const QuestionDialog = sdk.getComponent('views.dialogs.QuestionDialog');
return (<QuestionDialog
hasCancelButton={true}
title={_t("Sign out")}
// TODO: This is made up by me and would need to also mention verifying
// once you can restorew a backup by verifying a device
description={_t(
"When signing in again, you can access encrypted chat history by " +
"restoring your key backup. You'll need your recovery passphrase " +
"or, if you didn't set a recovery passphrase, your recovery key " +
"(that you downloaded).",
)}
button={_t("Sign out")}
onFinished={this._onFinished}
/>);
<details>
<summary>{_t("Advanced")}</summary>
<p><button onClick={this._onExportE2eKeysClicked}>
{_t("Manually export keys")}
</button></p>
</details>
</div>;
}
// Not quite a standard question dialog as the primary button cancels
// the action and does something else instead, whilst non-default button
// confirms the action.
return (<BaseDialog
title={_t("You'll lose access to your encrypted messages")}
contentId='mx_Dialog_content'
hasCancel={true}
onFinished={this._onFinished}
>
{dialogContent}
</BaseDialog>);
} else {
const QuestionDialog = sdk.getComponent('views.dialogs.QuestionDialog');
return (<QuestionDialog
hasCancelButton={true}
title={_t("Sign out")}
description={description}
description={_t(
"Are you sure you want to sign out?",
)}
button={_t("Sign out")}
extraButtons={[
(<button key="export" className="mx_Dialog_primary"
onClick={this._onExportE2eKeysClicked}>
{ _t("Export E2E room keys") }
</button>),
]}
onFinished={this._onFinished}
/>);
}

View file

@ -20,14 +20,12 @@ import { _t } from '../../../languageHandler';
export default (props) => {
const existingIssuesUrl = "https://github.com/vector-im/riot-web/issues" +
"?q=is%3Aopen+is%3Aissue+label%3Aredesign+sort%3Areactions-%2B1-desc";
const newIssueUrl = "https://github.com/vector-im/riot-web/issues/new" +
"?assignees=&labels=redesign&template=redesign_issue.md&title=";
"?q=is%3Aopen+is%3Aissue+sort%3Areactions-%2B1-desc";
const newIssueUrl = "https://github.com/vector-im/riot-web/issues/new";
const description1 =
_t("Thanks for testing the Riot Redesign. " +
"If you run into any bugs or visual issues, " +
"please let us know on GitHub.");
_t("If you run into any bugs or have feedback you'd like to share, " +
"please let us know on GitHub.");
const description2 = _t("To help avoid duplicate issues, " +
"please <existingIssuesLink>view existing issues</existingIssuesLink> " +
"first (and add a +1) or <newIssueLink>create a new issue</newIssueLink> " +

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.
@ -18,28 +19,13 @@ import React from 'react';
import PropTypes from 'prop-types';
import {Tab, TabbedView} from "../../structures/TabbedView";
import {_t, _td} from "../../../languageHandler";
import AdvancedRoomSettingsTab from "../settings/tabs/AdvancedRoomSettingsTab";
import dis from '../../../dispatcher';
import RolesRoomSettingsTab from "../settings/tabs/RolesRoomSettingsTab";
import GeneralRoomSettingsTab from "../settings/tabs/GeneralRoomSettingsTab";
import SecurityRoomSettingsTab from "../settings/tabs/SecurityRoomSettingsTab";
import AdvancedRoomSettingsTab from "../settings/tabs/room/AdvancedRoomSettingsTab";
import RolesRoomSettingsTab from "../settings/tabs/room/RolesRoomSettingsTab";
import GeneralRoomSettingsTab from "../settings/tabs/room/GeneralRoomSettingsTab";
import SecurityRoomSettingsTab from "../settings/tabs/room/SecurityRoomSettingsTab";
import sdk from "../../../index";
// TODO: Ditch this whole component
export class TempTab extends React.Component {
static propTypes = {
onClose: PropTypes.func.isRequired,
};
componentDidMount(): void {
dis.dispatch({action: "open_old_room_settings"});
this.props.onClose();
}
render() {
return <div>Hello World</div>;
}
}
import MatrixClientPeg from "../../../MatrixClientPeg";
import dis from "../../../dispatcher";
export default class RoomSettingsDialog extends React.Component {
static propTypes = {
@ -47,17 +33,20 @@ export default class RoomSettingsDialog extends React.Component {
onFinished: PropTypes.func.isRequired,
};
componentWillMount(): void {
this.dispatcherRef = dis.register(this._onAction);
componentWillMount() {
this._dispatcherRef = dis.register(this._onAction);
}
componentWillUnmount(): void {
dis.unregister(this.dispatcherRef);
componentWillUnmount() {
dis.unregister(this._dispatcherRef);
}
_onAction = (payload) => {
if (payload.action !== 'close_room_settings') return;
this.props.onFinished();
// 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() {
@ -81,13 +70,8 @@ export default class RoomSettingsDialog extends React.Component {
tabs.push(new Tab(
_td("Advanced"),
"mx_RoomSettingsDialog_warningIcon",
<AdvancedRoomSettingsTab roomId={this.props.roomId} />,
<AdvancedRoomSettingsTab roomId={this.props.roomId} closeSettingsFn={this.props.onFinished} />,
));
// tabs.push(new Tab(
// _td("Visit old settings"),
// "mx_RoomSettingsDialog_warningIcon",
// <TempTab onClose={this.props.onFinished} />,
// ));
return tabs;
}
@ -95,9 +79,10 @@ export default class RoomSettingsDialog extends React.Component {
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const roomName = MatrixClientPeg.get().getRoom(this.props.roomId).name;
return (
<BaseDialog className='mx_RoomSettingsDialog' hasCancel={true}
onFinished={this.props.onFinished} title={_t("Room Settings")}>
onFinished={this.props.onFinished} title={_t("Room Settings - %(roomName)s", {roomName})}>
<div className='ms_SettingsDialog_content'>
<TabbedView tabs={this._getTabs()} />
</div>

View file

@ -47,7 +47,9 @@ export default React.createClass({
_onUpgradeClick: function() {
this.setState({busy: true});
MatrixClientPeg.get().upgradeRoom(this.props.room.roomId, this._targetVersion).catch((err) => {
MatrixClientPeg.get().upgradeRoom(this.props.room.roomId, this._targetVersion).then(() => {
this.props.onFinished(true);
}).catch((err) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to upgrade room', '', ErrorDialog, {
title: _t("Failed to upgrade room"),
@ -82,10 +84,9 @@ export default React.createClass({
return (
<BaseDialog className="mx_RoomUpgradeDialog"
onFinished={this.onCancelled}
onFinished={this.props.onFinished}
title={_t("Upgrade Room Version")}
contentId='mx_Dialog_content'
onFinished={this.props.onFinished}
hasCancel={true}
>
<p>

View file

@ -41,7 +41,7 @@ export default React.createClass({
Modal.createTrackedDialog('Session Restore Confirm Logout', '', QuestionDialog, {
title: _t("Sign out"),
description:
<div>{ _t("Log out and remove encryption keys?") }</div>,
<div>{ _t("Sign out and remove encryption keys?") }</div>,
button: _t("Sign out"),
danger: true,
onFinished: this.props.onFinished,

View file

@ -115,7 +115,7 @@ export default React.createClass({
// user ID roughly looks okay from a Matrix perspective.
if (!SAFE_LOCALPART_REGEX.test(this.state.username)) {
this.setState({
usernameError: _t("Only use lower case letters, numbers and '=_-./'"),
usernameError: _t("A username can only contain lower case letters, numbers and '=_-./'"),
});
return Promise.reject();
}

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 {makeEventPermalink, makeGroupPermalink, makeRoomPermalink, makeUserPermalink} from "../../../matrix-to";
import {RoomPermalinkCreator, makeGroupPermalink, makeUserPermalink} from "../../../matrix-to";
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() {
@ -123,6 +124,20 @@ export default class ShareDialog extends React.Component {
});
}
componentWillMount() {
if (this.props.target instanceof Room) {
const permalinkCreator = new RoomPermalinkCreator(this.props.target);
permalinkCreator.load();
this.setState({permalinkCreator});
}
}
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,9 +161,9 @@ export default class ShareDialog extends React.Component {
}
if (this.state.linkSpecificEvent) {
matrixToUrl = makeEventPermalink(this.props.target.roomId, events[events.length - 1].getId());
matrixToUrl = this.state.permalinkCreator.forEvent(events[events.length - 1].getId());
} else {
matrixToUrl = makeRoomPermalink(this.props.target.roomId);
matrixToUrl = this.state.permalinkCreator.forRoom();
}
} else if (this.props.target instanceof User || this.props.target instanceof RoomMember) {
title = _t('Share User');
@ -169,9 +184,9 @@ export default class ShareDialog extends React.Component {
</div>;
if (this.state.linkSpecificEvent) {
matrixToUrl = makeEventPermalink(this.props.target.getRoomId(), this.props.target.getId());
matrixToUrl = this.props.permalinkCreator.forEvent(this.props.target.getId());
} else {
matrixToUrl = makeRoomPermalink(this.props.target.getRoomId());
matrixToUrl = this.props.permalinkCreator.forRoom();
}
}

View file

@ -0,0 +1,77 @@
/*
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.
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 SdkConfig from '../../../SdkConfig';
import Modal from '../../../Modal';
import { _t } from '../../../languageHandler';
export default class StorageEvictedDialog extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
};
_sendBugReport = ev => {
ev.preventDefault();
const BugReportDialog = sdk.getComponent("dialogs.BugReportDialog");
Modal.createTrackedDialog('Storage evicted', 'Send Bug Report Dialog', BugReportDialog, {});
};
_onSignOutClick = () => {
this.props.onFinished(true);
};
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
let logRequest;
if (SdkConfig.get().bug_report_endpoint_url) {
logRequest = _t(
"To help us prevent this in future, please <a>send us logs</a>.", {},
{
a: text => <a href="#" onClick={this._sendBugReport}>{text}</a>,
});
}
return (
<BaseDialog className="mx_ErrorDialog" onFinished={this.props.onFinished}
title={_t('Missing session data')}
contentId='mx_Dialog_content'
hasCancel={false}
>
<div className="mx_Dialog_content" id='mx_Dialog_content'>
<p>{_t(
"Some session data, including encrypted message keys, is " +
"missing. Sign out and sign in to fix this, restoring keys " +
"from backup.",
)}</p>
<p>{_t(
"Your browser likely removed this data when running low on " +
"disk space.",
)} {logRequest}</p>
</div>
<DialogButtons primaryButton={_t("Sign out")}
onPrimaryButtonClick={this._onSignOutClick}
focus={true}
hasCancel={false}
/>
</BaseDialog>
);
}
}

View file

@ -25,35 +25,12 @@ import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore";
import { markAllDevicesKnown } from '../../../cryptodevices';
function DeviceListEntry(props) {
const {userId, device} = props;
const DeviceVerifyButtons = sdk.getComponent('elements.DeviceVerifyButtons');
return (
<li>
{ device.deviceId }
<DeviceVerifyButtons device={device} userId={userId} />
<br />
{ device.getDisplayName() }
</li>
);
}
DeviceListEntry.propTypes = {
userId: PropTypes.string.isRequired,
// deviceinfo
device: PropTypes.object.isRequired,
};
function UserUnknownDeviceList(props) {
const MemberDeviceInfo = sdk.getComponent('rooms.MemberDeviceInfo');
const {userId, userDevices} = props;
const deviceListEntries = Object.keys(userDevices).map((deviceId) =>
<DeviceListEntry key={deviceId} userId={userId}
device={userDevices[deviceId]} />,
<li key={deviceId}><MemberDeviceInfo device={userDevices[deviceId]} userId={userId} showDeviceId={true} /></li>,
);
return (

View file

@ -0,0 +1,107 @@
/*
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.
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 UploadConfirmDialog extends React.Component {
static propTypes = {
file: PropTypes.object.isRequired,
currentIndex: PropTypes.number,
totalFiles: PropTypes.number,
onFinished: PropTypes.func.isRequired,
}
static defaultProps = {
totalFiles: 1,
}
constructor(props) {
super(props);
this._objectUrl = URL.createObjectURL(props.file);
}
componentWillUnmount() {
if (this._objectUrl) URL.revokeObjectURL(this._objectUrl);
}
_onCancelClick = () => {
this.props.onFinished(false);
}
_onUploadClick = () => {
this.props.onFinished(true);
}
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
let title;
if (this.props.totalFiles > 1 && this.props.currentIndex !== undefined) {
title = _t(
"Upload files (%(current)s of %(total)s)",
{
current: this.props.currentIndex + 1,
total: this.props.totalFiles,
},
);
} else {
title = _t('Upload files');
}
let preview;
if (this.props.file.type.startsWith('image/')) {
preview = <div className="mx_UploadConfirmDialog_previewOuter">
<div className="mx_UploadConfirmDialog_previewInner">
<div><img className="mx_UploadConfirmDialog_imagePreview" src={this._objectUrl} /></div>
<div>{this.props.file.name}</div>
</div>
</div>;
} else {
preview = <div>
<div>
<img className="mx_UploadConfirmDialog_fileIcon"
src={require("../../../../res/img/files.png")}
/>
{this.props.file.name}
</div>
</div>;
}
return (
<BaseDialog className='mx_UploadConfirmDialog'
fixedWidth={false}
onFinished={this._onCancelClick}
title={title}
contentId='mx_Dialog_content'
>
<div id='mx_Dialog_content'>
{preview}
</div>
<DialogButtons primaryButton={_t('Upload')}
hasCancel={false}
onPrimaryButtonClick={this._onUploadClick}
focus={true}
/>
</BaseDialog>
);
}
}

View file

@ -0,0 +1,120 @@
/*
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.
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 filesize from 'filesize';
import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import ContentMessages from '../../../ContentMessages';
/*
* Tells the user about files we know cannot be uploaded before we even try uploading
* them. This is named fairly generically but the only thing we check right now is
* the size of the file.
*/
export default class UploadFailureDialog extends React.Component {
static propTypes = {
badFiles: PropTypes.arrayOf(PropTypes.object).isRequired,
totalFiles: PropTypes.number.isRequired,
contentMessages: PropTypes.instanceOf(ContentMessages).isRequired,
onFinished: PropTypes.func.isRequired,
}
_onCancelClick = () => {
this.props.onFinished(false);
}
_onUploadClick = () => {
this.props.onFinished(true);
}
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
let message;
let preview;
let buttons;
if (this.props.totalFiles === 1 && this.props.badFiles.length === 1) {
message = _t(
"This file is <b>too large</b> to upload. " +
"The file size limit is %(limit)s but this file is %(sizeOfThisFile)s.",
{
limit: filesize(this.props.contentMessages.getUploadLimit()),
sizeOfThisFile: filesize(this.props.badFiles[0].size),
}, {
b: sub => <b>{sub}</b>,
},
);
buttons = <DialogButtons primaryButton={_t('OK')}
hasCancel={false}
onPrimaryButtonClick={this._onCancelClick}
focus={true}
/>;
} else if (this.props.totalFiles === this.props.badFiles.length) {
message = _t(
"These files are <b>too large</b> to upload. " +
"The file size limit is %(limit)s.",
{
limit: filesize(this.props.contentMessages.getUploadLimit()),
}, {
b: sub => <b>{sub}</b>,
},
);
buttons = <DialogButtons primaryButton={_t('OK')}
hasCancel={false}
onPrimaryButtonClick={this._onCancelClick}
focus={true}
/>;
} else {
message = _t(
"Some files are <b>too large</b> to be uploaded. " +
"The file size limit is %(limit)s.",
{
limit: filesize(this.props.contentMessages.getUploadLimit()),
}, {
b: sub => <b>{sub}</b>,
},
);
const howManyOthers = this.props.totalFiles - this.props.badFiles.length;
buttons = <DialogButtons
primaryButton={_t('Upload %(count)s other files', { count: howManyOthers })}
onPrimaryButtonClick={this._onUploadClick}
hasCancel={true}
cancelButton={_t("Cancel All")}
onCancel={this._onCancelClick}
focus={true}
/>;
}
return (
<BaseDialog className='mx_UploadFailureDialog'
onFinished={this._onCancelClick}
title={_t("Upload Error")}
contentId='mx_Dialog_content'
>
<div id='mx_Dialog_content'>
{message}
{preview}
</div>
{buttons}
</BaseDialog>
);
}
}

View file

@ -18,34 +18,17 @@ import React from 'react';
import PropTypes from 'prop-types';
import {Tab, TabbedView} from "../../structures/TabbedView";
import {_t, _td} from "../../../languageHandler";
import GeneralUserSettingsTab from "../settings/tabs/GeneralUserSettingsTab";
import dis from '../../../dispatcher';
import GeneralUserSettingsTab from "../settings/tabs/user/GeneralUserSettingsTab";
import SettingsStore from "../../../settings/SettingsStore";
import LabsSettingsTab from "../settings/tabs/LabsSettingsTab";
import SecuritySettingsTab from "../settings/tabs/SecuritySettingsTab";
import NotificationSettingsTab from "../settings/tabs/NotificationSettingsTab";
import PreferencesSettingsTab from "../settings/tabs/PreferencesSettingsTab";
import VoiceSettingsTab from "../settings/tabs/VoiceSettingsTab";
import HelpSettingsTab from "../settings/tabs/HelpSettingsTab";
import FlairSettingsTab from "../settings/tabs/FlairSettingsTab";
import LabsUserSettingsTab from "../settings/tabs/user/LabsUserSettingsTab";
import SecurityUserSettingsTab from "../settings/tabs/user/SecurityUserSettingsTab";
import NotificationUserSettingsTab from "../settings/tabs/user/NotificationUserSettingsTab";
import PreferencesUserSettingsTab from "../settings/tabs/user/PreferencesUserSettingsTab";
import VoiceUserSettingsTab from "../settings/tabs/user/VoiceUserSettingsTab";
import HelpUserSettingsTab from "../settings/tabs/user/HelpUserSettingsTab";
import FlairUserSettingsTab from "../settings/tabs/user/FlairUserSettingsTab";
import sdk from "../../../index";
// TODO: Ditch this whole component
export class TempTab extends React.Component {
static propTypes = {
onClose: PropTypes.func.isRequired,
};
componentDidMount(): void {
dis.dispatch({action: "view_old_user_settings"});
this.props.onClose();
}
render() {
return <div>Hello World</div>;
}
}
export default class UserSettingsDialog extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
@ -62,45 +45,40 @@ export default class UserSettingsDialog extends React.Component {
tabs.push(new Tab(
_td("Flair"),
"mx_UserSettingsDialog_flairIcon",
<FlairSettingsTab />,
<FlairUserSettingsTab />,
));
tabs.push(new Tab(
_td("Notifications"),
"mx_UserSettingsDialog_bellIcon",
<NotificationSettingsTab />,
<NotificationUserSettingsTab />,
));
tabs.push(new Tab(
_td("Preferences"),
"mx_UserSettingsDialog_preferencesIcon",
<PreferencesSettingsTab />,
<PreferencesUserSettingsTab />,
));
tabs.push(new Tab(
_td("Voice & Video"),
"mx_UserSettingsDialog_voiceIcon",
<VoiceSettingsTab />,
<VoiceUserSettingsTab />,
));
tabs.push(new Tab(
_td("Security & Privacy"),
"mx_UserSettingsDialog_securityIcon",
<SecuritySettingsTab />,
<SecurityUserSettingsTab />,
));
if (SettingsStore.getLabsFeatures().length > 0) {
tabs.push(new Tab(
_td("Labs"),
"mx_UserSettingsDialog_labsIcon",
<LabsSettingsTab />,
<LabsUserSettingsTab />,
));
}
tabs.push(new Tab(
_td("Help & About"),
"mx_UserSettingsDialog_helpIcon",
<HelpSettingsTab closeSettingsFn={this.props.onFinished} />,
<HelpUserSettingsTab closeSettingsFn={this.props.onFinished} />,
));
// tabs.push(new Tab(
// _td("Visit old settings"),
// "mx_UserSettingsDialog_helpIcon",
// <TempTab onClose={this.props.onFinished} />,
// ));
return tabs;
}

View file

@ -0,0 +1,103 @@
/*
Copyright 2019 Travis Ralston
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 SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
import sdk from "../../../index";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import WidgetUtils from "../../../utils/WidgetUtils";
export default class WidgetOpenIDPermissionsDialog extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
widgetUrl: PropTypes.string.isRequired,
widgetId: PropTypes.string.isRequired,
isUserWidget: PropTypes.bool.isRequired,
};
constructor() {
super();
this.state = {
rememberSelection: false,
};
}
_onAllow = () => {
this._onPermissionSelection(true);
};
_onDeny = () => {
this._onPermissionSelection(false);
};
_onPermissionSelection(allowed) {
if (this.state.rememberSelection) {
console.log(`Remembering ${this.props.widgetId} as allowed=${allowed} for OpenID`);
const currentValues = SettingsStore.getValue("widgetOpenIDPermissions");
if (!currentValues.allow) currentValues.allow = [];
if (!currentValues.deny) currentValues.deny = [];
const securityKey = WidgetUtils.getWidgetSecurityKey(
this.props.widgetId,
this.props.widgetUrl,
this.props.isUserWidget);
(allowed ? currentValues.allow : currentValues.deny).push(securityKey);
SettingsStore.setValue("widgetOpenIDPermissions", null, SettingLevel.DEVICE, currentValues);
}
this.props.onFinished(allowed);
}
_onRememberSelectionChange = (newVal) => {
this.setState({rememberSelection: newVal});
};
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return (
<BaseDialog className='mx_WidgetOpenIDPermissionsDialog' hasCancel={true}
onFinished={this.props.onFinished}
title={_t("A widget would like to verify your identity")}>
<div className='mx_WidgetOpenIDPermissionsDialog_content'>
<p>
{_t(
"A widget located at %(widgetUrl)s would like to verify your identity. " +
"By allowing this, the widget will be able to verify your user ID, but not " +
"perform actions as you.", {
widgetUrl: this.props.widgetUrl,
},
)}
</p>
<LabelledToggleSwitch value={this.state.rememberSelection} toggleInFront={true}
onChange={this._onRememberSelectionChange}
label={_t("Remember my selection for this widget")} />
</div>
<DialogButtons
primaryButton={_t("Allow")}
onPrimaryButtonClick={this._onAllow}
cancelButton={_t("Deny")}
onCancel={this._onDeny}
/>
</BaseDialog>
);
}
}

View file

@ -19,8 +19,13 @@ import sdk from '../../../../index';
import MatrixClientPeg from '../../../../MatrixClientPeg';
import Modal from '../../../../Modal';
import { MatrixClient } from 'matrix-js-sdk';
import { _t } from '../../../../languageHandler';
const RESTORE_TYPE_PASSPHRASE = 0;
const RESTORE_TYPE_RECOVERYKEY = 1;
/**
* Dialog for restoring e2e keys from a backup and the user's recovery key
*/
@ -36,6 +41,7 @@ export default React.createClass({
recoveryKeyValid: false,
forceRecoveryKey: false,
passPhrase: '',
restoreType: null,
};
},
@ -80,10 +86,11 @@ export default React.createClass({
this.setState({
loading: true,
restoreError: null,
restoreType: RESTORE_TYPE_PASSPHRASE,
});
try {
const recoverInfo = await MatrixClientPeg.get().restoreKeyBackupWithPassword(
this.state.passPhrase, undefined, undefined, this.state.backupInfo.version,
this.state.passPhrase, undefined, undefined, this.state.backupInfo,
);
this.setState({
loading: false,
@ -102,10 +109,11 @@ export default React.createClass({
this.setState({
loading: true,
restoreError: null,
restoreType: RESTORE_TYPE_RECOVERYKEY,
});
try {
const recoverInfo = await MatrixClientPeg.get().restoreKeyBackupWithRecoveryKey(
this.state.recoveryKey, undefined, undefined, this.state.backupInfo.version,
this.state.recoveryKey, undefined, undefined, this.state.backupInfo,
);
this.setState({
loading: false,
@ -179,19 +187,31 @@ export default React.createClass({
title = _t("Error");
content = _t("Unable to load backup status");
} else if (this.state.restoreError) {
title = _t("Error");
content = _t("Unable to restore backup");
if (this.state.restoreError.errcode === MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY) {
if (this.state.restoreType === RESTORE_TYPE_RECOVERYKEY) {
title = _t("Recovery Key Mismatch");
content = <div>
<p>{_t(
"Backup could not be decrypted with this key: " +
"please verify that you entered the correct recovery key.",
)}</p>
</div>;
} else {
title = _t("Incorrect Recovery Passphrase");
content = <div>
<p>{_t(
"Backup could not be decrypted with this passphrase: " +
"please verify that you entered the correct recovery passphrase.",
)}</p>
</div>;
}
} else {
title = _t("Error");
content = _t("Unable to restore backup");
}
} else if (this.state.backupInfo === null) {
title = _t("Error");
content = _t("No backup found!");
} else if (this.state.recoverInfo && this.state.recoverInfo.imported === 0) {
title = _t("Error Restoring Backup");
content = <div>
<p>{_t(
"Backup could not be decrypted with this key: " +
"please verify that you entered the correct recovery key.",
)}</p>
</div>;
} else if (this.state.recoverInfo) {
title = _t("Backup Restored");
let failedToDecrypt;
@ -210,10 +230,15 @@ export default React.createClass({
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
title = _t("Enter Recovery Passphrase");
content = <div>
{_t(
<p>{_t(
"<b>Warning</b>: you should only set up key backup " +
"from a trusted computer.", {},
{ b: sub => <b>{sub}</b> },
)}</p>
<p>{_t(
"Access your secure message history and set up secure " +
"messaging by entering your recovery passphrase.",
)}<br />
)}</p>
<div className="mx_RestoreKeyBackupDialog_primaryContainer">
<input type="password"
@ -268,10 +293,15 @@ export default React.createClass({
}
content = <div>
{_t(
<p>{_t(
"<b>Warning</b>: you should only set up key backup " +
"from a trusted computer.", {},
{ b: sub => <b>{sub}</b> },
)}</p>
<p>{_t(
"Access your secure message history and set up secure " +
"messaging by entering your recovery key.",
)}<br />
)}</p>
<div className="mx_RestoreKeyBackupDialog_primaryContainer">
<input className="mx_RestoreKeyBackupDialog_recoveryKeyInput"

View file

@ -41,7 +41,7 @@ export default class NetworkDropdown extends React.Component {
this.state = {
expanded: false,
selectedServer: server,
selectedInstance: null,
selectedInstanceId: null,
includeAllNetworks: false,
};
}
@ -52,7 +52,8 @@ export default class NetworkDropdown extends React.Component {
document.addEventListener('click', this.onDocumentClick, false);
// fire this now so the defaults can be set up
this.props.onOptionChange(this.state.selectedServer, this.state.selectedInstance, this.state.includeAllNetworks);
const {selectedServer, selectedInstanceId, includeAllNetworks} = this.state;
this.props.onOptionChange(selectedServer, selectedInstanceId, includeAllNetworks);
}
componentWillUnmount() {
@ -97,17 +98,18 @@ export default class NetworkDropdown extends React.Component {
expanded: false,
selectedServer: server,
selectedInstanceId: instance ? instance.instance_id : null,
includeAll: includeAll,
includeAllNetworks: includeAll,
});
this.props.onOptionChange(server, instance ? instance.instance_id : null, includeAll);
}
onInputKeyUp(e) {
if (e.key == 'Enter') {
if (e.key === 'Enter') {
this.setState({
expanded: false,
selectedServer: e.target.value,
selectedNetwork: null,
includeAllNetworks: false,
});
this.props.onOptionChange(e.target.value, null);
}
@ -129,13 +131,14 @@ export default class NetworkDropdown extends React.Component {
_getMenuOptions() {
const options = [];
const roomDirectory = this.props.config.roomDirectory || {};
let servers = [];
if (this.props.config.servers) {
servers = servers.concat(this.props.config.servers);
if (roomDirectory.servers) {
servers = servers.concat(roomDirectory.servers);
}
if (servers.indexOf(MatrixClientPeg.getHomeServerName()) == -1) {
if (!servers.includes(MatrixClientPeg.getHomeServerName())) {
servers.unshift(MatrixClientPeg.getHomeServerName());
}
@ -145,7 +148,7 @@ export default class NetworkDropdown extends React.Component {
// we can only show the default room list.
for (const server of servers) {
options.push(this._makeMenuOption(server, null, true));
if (server == MatrixClientPeg.getHomeServerName()) {
if (server === MatrixClientPeg.getHomeServerName()) {
options.push(this._makeMenuOption(server, null, false));
if (this.props.protocols) {
for (const proto of Object.keys(this.props.protocols)) {
@ -181,18 +184,15 @@ export default class NetworkDropdown extends React.Component {
let icon;
let name;
let span_class;
let key;
if (!instance && includeAll) {
key = server;
name = server;
span_class = 'mx_NetworkDropdown_menu_all';
} else if (!instance) {
key = server + '_all';
name = 'Matrix';
icon = <img src={require("../../../../res/img/network-matrix.svg")} />;
span_class = 'mx_NetworkDropdown_menu_network';
} else {
key = server + '_inst_' + instance.instance_id;
const imgUrl = instance.icon ?
@ -200,41 +200,40 @@ export default class NetworkDropdown extends React.Component {
DEFAULT_ICON_URL;
icon = <img src={imgUrl} />;
name = instance.desc;
span_class = 'mx_NetworkDropdown_menu_network';
}
const click_handler = handleClicks ? this.onMenuOptionClick.bind(this, server, instance, includeAll) : null;
const clickHandler = handleClicks ? this.onMenuOptionClick.bind(this, server, instance, includeAll) : null;
return <div key={key} className="mx_NetworkDropdown_networkoption" onClick={click_handler}>
return <div key={key} className="mx_NetworkDropdown_networkoption" onClick={clickHandler}>
{icon}
<span className="mx_NetworkDropdown_menu_network">{name}</span>
</div>;
}
render() {
let current_value;
let currentValue;
let menu;
if (this.state.expanded) {
const menu_options = this._getMenuOptions();
const menuOptions = this._getMenuOptions();
menu = <div className="mx_NetworkDropdown_menu">
{menu_options}
{menuOptions}
</div>;
current_value = <input type="text" className="mx_NetworkDropdown_networkoption"
currentValue = <input type="text" className="mx_NetworkDropdown_networkoption"
ref={this.collectInputTextBox} onKeyUp={this.onInputKeyUp}
placeholder="matrix.org" // 'matrix.org' as an example of an HS name
/>;
} else {
const instance = instanceForInstanceId(this.props.protocols, this.state.selectedInstanceId);
current_value = this._makeMenuOption(
this.state.selectedServer, instance, this.state.includeAll, false,
currentValue = this._makeMenuOption(
this.state.selectedServer, instance, this.state.includeAllNetworks, false,
);
}
return <div className="mx_NetworkDropdown" ref={this.collectRoot}>
<div className="mx_NetworkDropdown_input mx_no_textinput" onClick={this.onInputClick}>
{current_value}
<span className="mx_NetworkDropdown_arrow"></span>
{currentValue}
<span className="mx_NetworkDropdown_arrow" />
{menu}
</div>
</div>;

View file

@ -63,6 +63,10 @@ export default function AccessibleButton(props) {
};
}
// Pass through the ref - used for keyboard shortcut access to some buttons
restProps.ref = restProps.inputRef;
delete restProps.inputRef;
restProps.tabIndex = restProps.tabIndex || "0";
restProps.role = "button";
restProps.className = (restProps.className ? restProps.className + " " : "") +
@ -89,6 +93,7 @@ export default function AccessibleButton(props) {
*/
AccessibleButton.propTypes = {
children: PropTypes.node,
inputRef: PropTypes.func,
element: PropTypes.string,
onClick: PropTypes.func.isRequired,

View file

@ -69,8 +69,8 @@ export default React.createClass({
let tooltip;
if (this.state.showTooltip) {
const RoomTooltip = sdk.getComponent("rooms.RoomTooltip");
tooltip = <RoomTooltip className="mx_RoleButton_tooltip" label={this.props.label} />;
const Tooltip = sdk.getComponent("elements.Tooltip");
tooltip = <Tooltip className="mx_RoleButton_tooltip" label={this.props.label} />;
}
const icon = this.props.iconPath ?

View file

@ -47,7 +47,7 @@ export default class AppPermission extends React.Component {
return (
<div className='mx_AppPermissionWarning'>
<div className='mx_AppPermissionWarningImage'>
<img src={require("../../../../res/img/feather-icons/warning-triangle.svg")} alt={_t('Warning!')} />
<img src={require("../../../../res/img/feather-customised/warning-triangle.svg")} alt={_t('Warning!')} />
</div>
<div className='mx_AppPermissionWarningText'>
<span className='mx_AppPermissionWarningTextLabel'>{ _t('Do you want to load widget from URL:') }</span> <span className='mx_AppPermissionWarningTextURL'>{ this.state.curlBase }</span>

View file

@ -241,11 +241,18 @@ export default class AppTile extends React.Component {
this.props.onEditClick();
} else {
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
const src = this._scalarClient.getScalarInterfaceUrlForRoom(
this.props.room, 'type_' + this.props.type, this.props.id);
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
src: src,
}, "mx_IntegrationsManager");
this._scalarClient.connect().done(() => {
const src = this._scalarClient.getScalarInterfaceUrlForRoom(
this.props.room, 'type_' + this.props.type, this.props.id);
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
src: src,
}, "mx_IntegrationsManager");
}, (err) => {
this.setState({
error: err.message,
});
console.error('Error ensuring a valid scalar_token exists', err);
});
}
}
@ -329,9 +336,12 @@ export default class AppTile extends React.Component {
* Called when widget iframe has finished loading
*/
_onLoaded() {
if (!ActiveWidgetStore.getWidgetMessaging(this.props.id)) {
this._setupWidgetMessaging();
}
// Destroy the old widget messaging before starting it back up again. Some widgets
// have startup routines that run when they are loaded, so we just need to reinitialize
// the messaging for them.
ActiveWidgetStore.delWidgetMessaging(this.props.id);
this._setupWidgetMessaging();
ActiveWidgetStore.setRoomId(this.props.id, this.props.room.roomId);
this.setState({loading: false});
}
@ -339,7 +349,8 @@ export default class AppTile extends React.Component {
_setupWidgetMessaging() {
// FIXME: There's probably no reason to do this here: it should probably be done entirely
// in ActiveWidgetStore.
const widgetMessaging = new WidgetMessaging(this.props.id, this.props.url, this.refs.appFrame.contentWindow);
const widgetMessaging = new WidgetMessaging(
this.props.id, this.props.url, this.props.userWidget, this.refs.appFrame.contentWindow);
ActiveWidgetStore.setWidgetMessaging(this.props.id, widgetMessaging);
widgetMessaging.getCapabilities().then((requestedCapabilities) => {
console.log(`Widget ${this.props.id} requested capabilities: ` + requestedCapabilities);
@ -435,10 +446,14 @@ export default class AppTile extends React.Component {
}
// Toggle the view state of the apps drawer
dis.dispatch({
action: 'appsDrawer',
show: !this.props.show,
});
if (this.props.userWidget) {
this._onMinimiseClick();
} else {
dis.dispatch({
action: 'appsDrawer',
show: !this.props.show,
});
}
}
_getSafeUrl() {
@ -478,9 +493,9 @@ export default class AppTile extends React.Component {
_onPopoutWidgetClick(e) {
// Using Object.assign workaround as the following opens in a new window instead of a new tab.
// window.open(this._getSafeUrl(), '_blank', 'noopener=yes,noreferrer=yes');
// window.open(this._getSafeUrl(), '_blank', 'noopener=yes');
Object.assign(document.createElement('a'),
{ target: '_blank', href: this._getSafeUrl(), rel: 'noopener noreferrer'}).click();
{ target: '_blank', href: this._getSafeUrl(), rel: 'noopener'}).click();
}
_onReloadWidgetClick(e) {
@ -579,8 +594,8 @@ export default class AppTile extends React.Component {
// editing is done in scalar
const canUserModify = this._canUserModify();
const showEditButton = Boolean(this._scalarClient && canUserModify);
const showDeleteButton = canUserModify;
const showCancelButton = !showDeleteButton;
const showDeleteButton = (this.props.showDelete === undefined || this.props.showDelete) && canUserModify;
const showCancelButton = (this.props.showCancel === undefined || this.props.showCancel) && !showDeleteButton;
// Picture snapshot - only show button when apps are maximised.
const showPictureSnapshotButton = this._hasCapability('m.capability.screenshot') && this.props.show;
const showMinimiseButton = this.props.showMinimise && this.props.show;
@ -614,7 +629,7 @@ export default class AppTile extends React.Component {
{ /* Maximise widget */ }
{ showMaximiseButton && <AccessibleButton
className="mx_AppTileMenuBar_iconButton mx_AppTileMenuBar_iconButton_maximise"
title={_t('Minimize apps')}
title={_t('Maximize apps')}
onClick={this._onMinimiseClick}
/> }
{ /* Title */ }

View file

@ -1,5 +1,5 @@
/*
Copyright 2017 New Vector Ltd.
Copyright 2017, 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.
@ -16,142 +16,145 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import {_t} from '../../../languageHandler.js';
import Field from "./Field";
import AccessibleButton from "./AccessibleButton";
const EditableItem = React.createClass({
displayName: 'EditableItem',
propTypes: {
initialValue: PropTypes.string,
export class EditableItem extends React.Component {
static propTypes = {
index: PropTypes.number,
placeholder: PropTypes.string,
onChange: PropTypes.func,
value: PropTypes.string,
onRemove: PropTypes.func,
onAdd: PropTypes.func,
};
addOnChange: PropTypes.bool,
},
constructor() {
super();
onChange: function(value) {
this.setState({ value });
if (this.props.onChange) this.props.onChange(value, this.props.index);
if (this.props.addOnChange && this.props.onAdd) this.props.onAdd(value);
},
this.state = {
verifyRemove: false,
};
}
_onRemove = (e) => {
e.stopPropagation();
e.preventDefault();
this.setState({verifyRemove: true});
};
_onDontRemove = (e) => {
e.stopPropagation();
e.preventDefault();
this.setState({verifyRemove: false});
};
_onActuallyRemove = (e) => {
e.stopPropagation();
e.preventDefault();
onRemove: function() {
if (this.props.onRemove) this.props.onRemove(this.props.index);
},
this.setState({verifyRemove: false});
};
onAdd: function() {
if (this.props.onAdd) this.props.onAdd(this.state.value);
},
render: function() {
const EditableText = sdk.getComponent('elements.EditableText');
return <div className="mx_EditableItem">
<EditableText
className="mx_EditableItem_editable"
placeholderClassName="mx_EditableItem_editablePlaceholder"
placeholder={this.props.placeholder}
blurToCancel={false}
editable={true}
initialValue={this.props.initialValue}
onValueChanged={this.onChange} />
{ this.props.onAdd ?
<div className="mx_EditableItem_addButton">
<img className="mx_filterFlipColor"
src={require("../../../../res/img/plus.svg")} width="14" height="14"
alt={_t("Add")} onClick={this.onAdd} />
render() {
if (this.state.verifyRemove) {
return (
<div className="mx_EditableItem">
<span className="mx_EditableItem_promptText">
{_t("Are you sure?")}
</span>
<AccessibleButton onClick={this._onActuallyRemove} kind="primary_sm"
className="mx_EditableItem_confirmBtn">
{_t("Yes")}
</AccessibleButton>
<AccessibleButton onClick={this._onDontRemove} kind="danger_sm"
className="mx_EditableItem_confirmBtn">
{_t("No")}
</AccessibleButton>
</div>
:
<div className="mx_EditableItem_removeButton">
<img className="mx_filterFlipColor"
src={require("../../../../res/img/cancel-small.svg")} width="14" height="14"
alt={_t("Delete")} onClick={this.onRemove} />
</div>
}
</div>;
},
});
);
}
// TODO: Make this use the new Field element
module.exports = React.createClass({
displayName: 'EditableItemList',
return (
<div className="mx_EditableItem">
<img src={require("../../../../res/img/feather-customised/cancel.svg")} width={14} height={14}
onClick={this._onRemove} className="mx_EditableItem_delete" alt={_t("Remove")} />
<span className="mx_EditableItem_item">{this.props.value}</span>
</div>
);
}
}
propTypes: {
export default class EditableItemList extends React.Component {
static propTypes = {
items: PropTypes.arrayOf(PropTypes.string).isRequired,
onNewItemChanged: PropTypes.func,
itemsLabel: PropTypes.string,
noItemsLabel: PropTypes.string,
placeholder: PropTypes.string,
newItem: PropTypes.string,
onItemAdded: PropTypes.func,
onItemEdited: PropTypes.func,
onItemRemoved: PropTypes.func,
onNewItemChanged: PropTypes.func,
canEdit: PropTypes.bool,
},
canRemove: PropTypes.bool,
};
getDefaultProps: function() {
return {
onItemAdded: () => {},
onItemEdited: () => {},
onItemRemoved: () => {},
onNewItemChanged: () => {},
};
},
_onItemAdded = (e) => {
e.stopPropagation();
e.preventDefault();
onItemAdded: function(value) {
this.props.onItemAdded(value);
},
if (this.props.onItemAdded) this.props.onItemAdded(this.props.newItem);
};
onItemEdited: function(value, index) {
if (value.length === 0) {
this.onItemRemoved(index);
} else {
this.props.onItemEdited(value, index);
}
},
_onItemRemoved = (index) => {
if (this.props.onItemRemoved) this.props.onItemRemoved(index);
};
onItemRemoved: function(index) {
this.props.onItemRemoved(index);
},
_onNewItemChanged = (e) => {
if (this.props.onNewItemChanged) this.props.onNewItemChanged(e.target.value);
};
onNewItemChanged: function(value) {
this.props.onNewItemChanged(value);
},
_renderNewItemField() {
return (
<form onSubmit={this._onItemAdded} autoComplete={false}
noValidate={true} className="mx_EditableItemList_newItem">
<Field id="newEmailAddress" label={this.props.placeholder}
type="text" autoComplete="off" value={this.props.newItem}
onChange={this._onNewItemChanged}
/>
<AccessibleButton onClick={this._onItemAdded} kind="primary">
{_t("Add")}
</AccessibleButton>
</form>
);
}
render: function() {
render() {
const editableItems = this.props.items.map((item, index) => {
if (!this.props.canRemove) {
return <li>{item}</li>;
}
return <EditableItem
key={index}
index={index}
initialValue={item}
onChange={this.onItemEdited}
onRemove={this.onItemRemoved}
placeholder={this.props.placeholder}
value={item}
onRemove={this._onItemRemoved}
/>;
});
const label = this.props.items.length > 0 ?
this.props.itemsLabel : this.props.noItemsLabel;
const editableItemsSection = this.props.canRemove ? editableItems : <ul>{editableItems}</ul>;
const label = this.props.items.length > 0 ? this.props.itemsLabel : this.props.noItemsLabel;
return (<div className="mx_EditableItemList">
<div className="mx_EditableItemList_label">
{ label }
</div>
{ editableItems }
{ this.props.canEdit ?
// This is slightly evil; we want a new instance of
// EditableItem when the list grows. To make sure it's
// reset to its initial state.
<EditableItem
key={editableItems.length}
initialValue={this.props.newItem}
onAdd={this.onItemAdded}
onChange={this.onNewItemChanged}
addOnChange={true}
placeholder={this.props.placeholder}
/> : <div />
}
{ editableItemsSection }
{ this.props.canEdit ? this._renderNewItemField() : <div /> }
</div>);
},
});
}
}

View file

@ -1,42 +0,0 @@
/*
Copyright 2016 Aviral Dasgupta
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import {emojifyText, containsEmoji} from '../../../HtmlUtils';
export default function EmojiText(props) {
const {element, children, ...restProps} = props;
// fast path: simple regex to detect strings that don't contain
// emoji and just return them
if (containsEmoji(children)) {
restProps.dangerouslySetInnerHTML = emojifyText(children);
return React.createElement(element, restProps);
} else {
return React.createElement(element, restProps, children);
}
}
EmojiText.propTypes = {
element: PropTypes.string,
children: PropTypes.string.isRequired,
};
EmojiText.defaultProps = {
element: 'span',
};

View file

@ -16,53 +16,160 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import sdk from '../../../index';
import { throttle } from 'lodash';
// Invoke validation from user input (when typing, etc.) at most once every N ms.
const VALIDATION_THROTTLE_MS = 200;
export default class Field extends React.PureComponent {
static propTypes = {
// The field's ID, which binds the input and label together.
id: PropTypes.string.isRequired,
// The field's <input> type. Defaults to "text".
// The element to create. Defaults to "input".
// To define options for a select, use <Field><option ... /></Field>
element: PropTypes.oneOf(["input", "select", "textarea"]),
// The field's type (when used as an <input>). Defaults to "text".
type: PropTypes.string,
// The field's label string.
label: PropTypes.string,
// The field's placeholder string. Defaults to the label.
placeholder: PropTypes.string,
// The type of field to create. Defaults to "input". Should be "input" or "select".
// To define options for a select, use <Field><option ... /></Field>
element: PropTypes.string,
// The field's value.
// This is a controlled component, so the value is required.
value: PropTypes.string.isRequired,
// Optional component to include inside the field before the input.
prefix: PropTypes.node,
// The callback called whenever the contents of the field
// changes. Returns an object with `valid` boolean field
// and a `feedback` react component field to provide feedback
// to the user.
onValidate: PropTypes.func,
// All other props pass through to the <input>.
};
get value() {
if (!this.refs.fieldInput) return null;
return this.refs.fieldInput.value;
constructor() {
super();
this.state = {
valid: undefined,
feedback: undefined,
};
}
set value(newValue) {
if (!this.refs.fieldInput) {
throw new Error("No field input reference");
onFocus = (ev) => {
this.validate({
focused: true,
});
// Parent component may have supplied its own `onFocus` as well
if (this.props.onFocus) {
this.props.onFocus(ev);
}
this.refs.fieldInput.value = newValue;
};
onChange = (ev) => {
this.validateOnChange();
// Parent component may have supplied its own `onChange` as well
if (this.props.onChange) {
this.props.onChange(ev);
}
};
onBlur = (ev) => {
this.validate({
focused: false,
});
// Parent component may have supplied its own `onBlur` as well
if (this.props.onBlur) {
this.props.onBlur(ev);
}
};
focus() {
this.input.focus();
}
async validate({ focused, allowEmpty = true }) {
if (!this.props.onValidate) {
return;
}
const value = this.input ? this.input.value : null;
const { valid, feedback } = await this.props.onValidate({
value,
focused,
allowEmpty,
});
if (feedback) {
this.setState({
valid,
feedback,
feedbackVisible: true,
});
} else {
// When we receive null `feedback`, we want to hide the tooltip.
// We leave the previous `feedback` content in state without updating it,
// so that we can hide the tooltip containing the most recent feedback
// via CSS animation.
this.setState({
valid,
feedbackVisible: false,
});
}
}
validateOnChange = throttle(() => {
this.validate({
focused: true,
});
}, VALIDATION_THROTTLE_MS);
render() {
const extraProps = Object.assign({}, this.props);
const { element, prefix, onValidate, children, ...inputProps } = this.props;
// Remove explicit properties that shouldn't be copied
delete extraProps.element;
delete extraProps.children;
const inputElement = element || "input";
// Set some defaults for the element
extraProps.type = extraProps.type || "text";
extraProps.ref = "fieldInput";
extraProps.placeholder = extraProps.placeholder || extraProps.label;
// Set some defaults for the <input> element
inputProps.type = inputProps.type || "text";
inputProps.ref = input => this.input = input;
inputProps.placeholder = inputProps.placeholder || inputProps.label;
const element = this.props.element || "input";
const fieldInput = React.createElement(element, extraProps, this.props.children);
inputProps.onFocus = this.onFocus;
inputProps.onChange = this.onChange;
inputProps.onBlur = this.onBlur;
return <div className={`mx_Field mx_Field_${element}`}>
const fieldInput = React.createElement(inputElement, inputProps, children);
let prefixContainer = null;
if (prefix) {
prefixContainer = <span className="mx_Field_prefix">{prefix}</span>;
}
const fieldClasses = classNames("mx_Field", `mx_Field_${inputElement}`, {
// If we have a prefix element, leave the label always at the top left and
// don't animate it, as it looks a bit clunky and would add complexity to do
// properly.
mx_Field_labelAlwaysTopLeft: prefix,
mx_Field_valid: onValidate && this.state.valid === true,
mx_Field_invalid: onValidate && this.state.valid === false,
});
// Handle displaying feedback on validity
const Tooltip = sdk.getComponent("elements.Tooltip");
let tooltip;
if (this.state.feedback) {
tooltip = <Tooltip
tooltipClassName="mx_Field_tooltip"
visible={this.state.feedbackVisible}
label={this.state.feedback}
/>;
}
return <div className={fieldClasses}>
{prefixContainer}
{fieldInput}
<label htmlFor={this.props.id}>{this.props.label}</label>
{tooltip}
</div>;
}
}

View file

@ -117,7 +117,7 @@ export default class Flair extends React.Component {
render() {
if (this.state.profiles.length === 0) {
return <div />;
return <span className="mx_Flair" />;
}
const avatars = this.state.profiles.map((profile, index) => {
return <FlairAvatar key={index} groupProfile={profile} />;

View file

@ -1,103 +0,0 @@
/*
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.
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 classnames from 'classnames';
import sdk from '../../../index';
class HexVerifyPair extends React.Component {
static propTypes = {
text: PropTypes.string.isRequired,
index: PropTypes.number,
verified: PropTypes.bool,
onChange: PropTypes.func.isRequired,
}
_onClick = () => {
this.setState({verified: !this.props.verified});
this.props.onChange(this.props.index, !this.props.verified);
}
render() {
const classNames = {
mx_HexVerify_pair: true,
mx_HexVerify_pair_verified: this.props.verified,
};
const AccessibleButton = sdk.getComponent('views.elements.AccessibleButton');
return <AccessibleButton className={classnames(classNames)}
onClick={this._onClick}
>
{this.props.text}
</AccessibleButton>;
}
}
/*
* Helps a user verify a hexadecimal code matches one displayed
* elsewhere (eg. on a different device)
*/
export default class HexVerify extends React.Component {
static propTypes = {
text: PropTypes.string.isRequired,
onVerifiedStateChange: PropTypes.func,
}
static defaultProps = {
onVerifiedStateChange: function() {},
}
constructor(props) {
super(props);
this.state = {
pairsVerified: [],
};
for (let i = 0; i < props.text.length; i += 2) {
this.state.pairsVerified.push(false);
}
}
_onPairChange = (index, newVal) => {
const oldVerified = this.state.pairsVerified.reduce((acc, val) => {
return acc && val;
}, true);
const newPairsVerified = this.state.pairsVerified.slice(0);
newPairsVerified[index] = newVal;
const newVerified = newPairsVerified.reduce((acc, val) => {
return acc && val;
}, true);
this.setState({pairsVerified: newPairsVerified});
if (oldVerified !== newVerified) {
this.props.onVerifiedStateChange(newVerified);
}
}
render() {
const pairs = [];
for (let i = 0; i < this.props.text.length / 2; ++i) {
pairs.push(<HexVerifyPair key={i} index={i}
text={this.props.text.substr(i * 2, 2)}
verified={this.state.pairsVerified[i]}
onChange={this._onPairChange}
/>);
}
return <div className="mx_HexVerify">
{pairs}
</div>;
}
}

View file

@ -1,39 +0,0 @@
/*
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import sdk from '../../../index';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
const HomeButton = function(props) {
const ActionButton = sdk.getComponent('elements.ActionButton');
return (
<ActionButton action="view_home_page"
label={_t("Home")}
iconPath={require("../../../../res/img/icons-home.svg")}
size={props.size}
tooltip={props.tooltip}
/>
);
};
HomeButton.propTypes = {
size: PropTypes.string,
tooltip: PropTypes.bool,
};
export default HomeButton;

View file

@ -27,10 +27,8 @@ const Modal = require('../../../Modal');
const sdk = require('../../../index');
import { _t } from '../../../languageHandler';
module.exports = React.createClass({
displayName: 'ImageView',
propTypes: {
export default class ImageView extends React.Component {
static propTypes = {
src: React.PropTypes.string.isRequired, // the source of the image being displayed
name: React.PropTypes.string, // the main title ('name') for the image
link: React.PropTypes.string, // the link (if any) applied to the name of the image
@ -44,27 +42,32 @@ module.exports = React.createClass({
// properties above, which let us use lightboxes to display images which aren't associated
// with events.
mxEvent: React.PropTypes.object,
},
};
constructor(props) {
super(props);
this.state = { rotationDegrees: 0 };
}
// XXX: keyboard shortcuts for managing dialogs should be done by the modal
// dialog base class somehow, surely...
componentDidMount: function() {
componentDidMount() {
document.addEventListener("keydown", this.onKeyDown);
},
}
componentWillUnmount: function() {
componentWillUnmount() {
document.removeEventListener("keydown", this.onKeyDown);
},
}
onKeyDown: function(ev) {
onKeyDown = (ev) => {
if (ev.keyCode == 27) { // escape
ev.stopPropagation();
ev.preventDefault();
this.props.onFinished();
}
},
};
onRedactClick: function() {
onRedactClick = () => {
const ConfirmRedactDialog = sdk.getComponent("dialogs.ConfirmRedactDialog");
Modal.createTrackedDialog('Confirm Redact Dialog', 'Image View', ConfirmRedactDialog, {
onFinished: (proceed) => {
@ -83,17 +86,29 @@ module.exports = React.createClass({
}).done();
},
});
},
};
getName: function() {
getName() {
let name = this.props.name;
if (name && this.props.link) {
name = <a href={ this.props.link } target="_blank" rel="noopener">{ name }</a>;
}
return name;
},
}
render: function() {
rotateCounterClockwise = () => {
const cur = this.state.rotationDegrees;
const rotationDegrees = (cur - 90) % 360;
this.setState({ rotationDegrees });
};
rotateClockwise = () => {
const cur = this.state.rotationDegrees;
const rotationDegrees = (cur + 90) % 360;
this.setState({ rotationDegrees });
};
render() {
/*
// In theory max-width: 80%, max-height: 80% on the CSS should work
// but in practice, it doesn't, so do it manually:
@ -122,7 +137,8 @@ module.exports = React.createClass({
height: displayHeight
};
*/
let style; let res;
let style = {};
let res;
if (this.props.width && this.props.height) {
style = {
@ -168,15 +184,26 @@ module.exports = React.createClass({
</div>);
}
const rotationDegrees = this.state.rotationDegrees;
const effectiveStyle = {transform: `rotate(${rotationDegrees}deg)`, ...style};
return (
<div className="mx_ImageView">
<div className="mx_ImageView_lhs">
</div>
<div className="mx_ImageView_content">
<img src={this.props.src} style={style} />
<img src={this.props.src} title={this.props.name} style={effectiveStyle} className="mainImage" />
<div className="mx_ImageView_labelWrapper">
<div className="mx_ImageView_label">
<AccessibleButton className="mx_ImageView_cancel" onClick={ this.props.onFinished }><img src={require("../../../../res/img/cancel-white.svg")} width="18" height="18" alt={ _t('Close') } /></AccessibleButton>
<AccessibleButton className="mx_ImageView_rotateCounterClockwise" title={_t("Rotate Left")} onClick={ this.rotateCounterClockwise }>
<img src={require("../../../../res/img/rotate-ccw.svg")} alt={ _t('Rotate counter-clockwise') } width="18" height="18" />
</AccessibleButton>
<AccessibleButton className="mx_ImageView_rotateClockwise" title={_t("Rotate Right")} onClick={ this.rotateClockwise }>
<img src={require("../../../../res/img/rotate-cw.svg")} alt={ _t('Rotate clockwise') } width="18" height="18" />
</AccessibleButton>
<AccessibleButton className="mx_ImageView_cancel" title={_t("Close")} onClick={ this.props.onFinished }>
<img src={require("../../../../res/img/cancel-white.svg")} width="18" height="18" alt={ _t('Close') } />
</AccessibleButton>
<div className="mx_ImageView_shim">
</div>
<div className="mx_ImageView_name">
@ -199,5 +226,5 @@ module.exports = React.createClass({
</div>
</div>
);
},
});
}
}

View file

@ -31,15 +31,29 @@ export default class LabelledToggleSwitch extends React.Component {
// Whether or not to disable the toggle switch
disabled: PropTypes.bool,
// True to put the toggle in front of the label
// Default false.
toggleInFront: PropTypes.bool,
};
render() {
// This is a minimal version of a SettingsFlag
let firstPart = <span className="mx_SettingsFlag_label">{this.props.label}</span>;
let secondPart = <ToggleSwitch checked={this.props.value} disabled={this.props.disabled}
onChange={this.props.onChange} />;
if (this.props.toggleInFront) {
const temp = firstPart;
firstPart = secondPart;
secondPart = temp;
}
return (
<div className="mx_SettingsFlag">
<span className="mx_SettingsFlag_label">{this.props.label}</span>
<ToggleSwitch checked={this.props.value} disabled={this.props.disabled}
onChange={this.props.onChange} />
{firstPart}
{secondPart}
</div>
);
}

View file

@ -0,0 +1,91 @@
/*
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.
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";
const OVERFLOW_ITEMS = 20;
const OVERFLOW_MARGIN = 5;
class ItemRange {
constructor(topCount, renderCount, bottomCount) {
this.topCount = topCount;
this.renderCount = renderCount;
this.bottomCount = bottomCount;
}
contains(range) {
return range.topCount >= this.topCount &&
(range.topCount + range.renderCount) <= (this.topCount + this.renderCount);
}
expand(amount) {
const topGrow = Math.min(amount, this.topCount);
const bottomGrow = Math.min(amount, this.bottomCount);
return new ItemRange(
this.topCount - topGrow,
this.renderCount + topGrow + bottomGrow,
this.bottomCount - bottomGrow,
);
}
}
export default class LazyRenderList extends React.Component {
constructor(props) {
super(props);
const renderRange = LazyRenderList.getVisibleRangeFromProps(props).expand(OVERFLOW_ITEMS);
this.state = {renderRange};
}
static getVisibleRangeFromProps(props) {
const {items, itemHeight, scrollTop, height} = props;
const length = items ? items.length : 0;
const topCount = Math.max(0, Math.floor(scrollTop / itemHeight));
const itemsAfterTop = length - topCount;
const renderCount = Math.min(Math.ceil(height / itemHeight), itemsAfterTop);
const bottomCount = itemsAfterTop - renderCount;
return new ItemRange(topCount, renderCount, bottomCount);
}
componentWillReceiveProps(props) {
const state = this.state;
const range = LazyRenderList.getVisibleRangeFromProps(props);
const intersectRange = range.expand(OVERFLOW_MARGIN);
const prevSize = this.props.items ? this.props.items.length : 0;
const listHasChangedSize = props.items.length !== prevSize;
// only update renderRange if the list has shrunk/grown and we need to adjust padding or
// if the new range isn't contained by the old anymore
if (listHasChangedSize || !state.renderRange || !state.renderRange.contains(intersectRange)) {
this.setState({renderRange: range.expand(OVERFLOW_ITEMS)});
}
}
render() {
const {itemHeight, items, renderItem} = this.props;
const {renderRange} = this.state;
const paddingTop = renderRange.topCount * itemHeight;
const paddingBottom = renderRange.bottomCount * itemHeight;
const renderedItems = (items || []).slice(
renderRange.topCount,
renderRange.topCount + renderRange.renderCount,
);
return (<div style={{paddingTop: `${paddingTop}px`, paddingBottom: `${paddingBottom}px`}}>
{ renderedItems.map(renderItem) }
</div>);
}
}

View file

@ -24,7 +24,6 @@ import ScalarMessaging from '../../../ScalarMessaging';
import Modal from "../../../Modal";
import { _t } from '../../../languageHandler';
import AccessibleButton from './AccessibleButton';
import TintableSvg from './TintableSvg';
export default class ManageIntegsButton extends React.Component {
constructor(props) {
@ -46,7 +45,7 @@ export default class ManageIntegsButton extends React.Component {
this.scalarClient.connect().done(() => {
this.forceUpdate();
}, (err) => {
this.setState({ scalarError: err});
this.setState({scalarError: err});
console.error('Error whilst initialising scalarClient for ManageIntegsButton', err);
});
}
@ -62,11 +61,16 @@ export default class ManageIntegsButton extends React.Component {
return;
}
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
Modal.createDialog(IntegrationsManager, {
src: (this.scalarClient !== null && this.scalarClient.hasCredentials()) ?
this.scalarClient.getScalarInterfaceUrlForRoom(this.props.room) :
null,
}, "mx_IntegrationsManager");
this.scalarClient.connect().done(() => {
Modal.createDialog(IntegrationsManager, {
src: (this.scalarClient !== null && this.scalarClient.hasCredentials()) ?
this.scalarClient.getScalarInterfaceUrlForRoom(this.props.room) :
null,
}, "mx_IntegrationsManager");
}, (err) => {
this.setState({scalarError: err});
console.error('Error ensuring a valid scalar_token exists', err);
});
}
render() {
@ -76,7 +80,8 @@ export default class ManageIntegsButton extends React.Component {
if (this.scalarClient !== null) {
const integrationsButtonClasses = classNames({
mx_RoomHeader_button: true,
mx_RoomSettings_integrationsButton_error: !!this.state.scalarError,
mx_RoomHeader_manageIntegsButton: true,
mx_ManageIntegsButton_error: !!this.state.scalarError,
});
if (this.state.scalarError && !this.scalarClient.hasCredentials()) {
@ -87,15 +92,17 @@ export default class ManageIntegsButton extends React.Component {
/>;
// Popup shown when hovering over integrationsButton_error (via CSS)
integrationsErrorPopup = (
<span className="mx_RoomSettings_integrationsButton_errorPopup">
<span className="mx_ManageIntegsButton_errorPopup">
{ _t('Could not connect to the integration server') }
</span>
);
}
integrationsButton = (
<AccessibleButton className={integrationsButtonClasses} onClick={this.onManageIntegrations} title={_t('Manage Integrations')}>
<TintableSvg src={require("../../../../res/img/feather-icons/grid.svg")} width="20" height="20" />
<AccessibleButton className={integrationsButtonClasses}
onClick={this.onManageIntegrations}
title={_t('Manage Integrations')}
>
{ integrationsWarningTriangle }
{ integrationsErrorPopup }
</AccessibleButton>

View file

@ -1,5 +1,6 @@
/*
Copyright 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.
@ -13,11 +14,13 @@ 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';
const MemberAvatar = require('../avatars/MemberAvatar.js');
import MemberAvatar from '../avatars/MemberAvatar';
import { _t } from '../../../languageHandler';
import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
module.exports = React.createClass({
displayName: 'MemberEventListSummary',
@ -105,7 +108,7 @@ module.exports = React.createClass({
);
});
const desc = this._renderCommaSeparatedList(descs);
const desc = formatCommaSeparatedList(descs);
return _t('%(nameList)s %(transitionList)s', { nameList: nameList, transitionList: desc });
});
@ -114,13 +117,9 @@ module.exports = React.createClass({
return null;
}
const EmojiText = sdk.getComponent('elements.EmojiText');
return (
<span className="mx_TextualEvent mx_MemberEventListSummary_summary">
<EmojiText>
{ summaries.join(", ") }
</EmojiText>
{ summaries.join(", ") }
</span>
);
},
@ -132,7 +131,7 @@ module.exports = React.createClass({
* included before "and [n] others".
*/
_renderNameList: function(users) {
return this._renderCommaSeparatedList(users, this.props.summaryLength);
return formatCommaSeparatedList(users, this.props.summaryLength);
},
/**
@ -283,35 +282,6 @@ module.exports = React.createClass({
return res;
},
/**
* Constructs a written English string representing `items`, with an optional limit on
* the number of items included in the result. If specified and if the length of
*`items` is greater than the limit, the string "and n others" will be appended onto
* the result.
* If `items` is empty, returns the empty string. If there is only one item, return
* it.
* @param {string[]} items the items to construct a string from.
* @param {number?} itemLimit the number by which to limit the list.
* @returns {string} a string constructed by joining `items` with a comma between each
* item, but with the last item appended as " and [lastItem]".
*/
_renderCommaSeparatedList(items, itemLimit) {
const remaining = itemLimit === undefined ? 0 : Math.max(
items.length - itemLimit, 0,
);
if (items.length === 0) {
return "";
} else if (items.length === 1) {
return items[0];
} else if (remaining > 0) {
items = items.slice(0, itemLimit);
return _t("%(items)s and %(count)s others", { items: items.join(', '), count: remaining } );
} else {
const lastItem = items.pop();
return _t("%(items)s and %(lastItem)s", { items: items.join(', '), lastItem: lastItem });
}
},
_renderAvatars: function(roomMembers) {
const avatars = roomMembers.slice(0, this.props.avatarsMaxLength).map((m) => {
return (

View file

@ -0,0 +1,240 @@
/*
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.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import sdk from '../../../index';
import {_t} from '../../../languageHandler';
import PropTypes from 'prop-types';
import dis from '../../../dispatcher';
import EditorModel from '../../../editor/model';
import {setCaretPosition} from '../../../editor/caret';
import {getCaretOffsetAndText} from '../../../editor/dom';
import {htmlSerializeIfNeeded, textSerialize} from '../../../editor/serialize';
import {findEditableEvent} from '../../../utils/EventUtils';
import {parseEvent} from '../../../editor/deserialize';
import Autocomplete from '../rooms/Autocomplete';
import {PartCreator} from '../../../editor/parts';
import {renderModel} from '../../../editor/render';
import {MatrixEvent, MatrixClient} from 'matrix-js-sdk';
import classNames from 'classnames';
export default class MessageEditor extends React.Component {
static propTypes = {
// the message event being edited
event: PropTypes.instanceOf(MatrixEvent).isRequired,
};
static contextTypes = {
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
};
constructor(props, context) {
super(props, context);
const room = this._getRoom();
const partCreator = new PartCreator(
() => this._autocompleteRef,
query => this.setState({query}),
room,
);
this.model = new EditorModel(
parseEvent(this.props.event, room),
partCreator,
this._updateEditorState,
);
this.state = {
autoComplete: null,
room,
};
this._editorRef = null;
this._autocompleteRef = null;
this._hasModifications = false;
}
_getRoom() {
return this.context.matrixClient.getRoom(this.props.event.getRoomId());
}
_updateEditorState = (caret) => {
renderModel(this._editorRef, this.model);
if (caret) {
try {
setCaretPosition(this._editorRef, this.model, caret);
} catch (err) {
console.error(err);
}
}
this.setState({autoComplete: this.model.autoComplete});
}
_onInput = (event) => {
this._hasModifications = true;
const sel = document.getSelection();
const {caret, text} = getCaretOffsetAndText(this._editorRef, sel);
this.model.update(text, event.inputType, caret);
}
_isCaretAtStart() {
const {caret} = getCaretOffsetAndText(this._editorRef, document.getSelection());
return caret.offset === 0;
}
_isCaretAtEnd() {
const {caret, text} = getCaretOffsetAndText(this._editorRef, document.getSelection());
return caret.offset === text.length;
}
_onKeyDown = (event) => {
// insert newline on Shift+Enter
if (event.shiftKey && event.key === "Enter") {
event.preventDefault(); // just in case the browser does support this
document.execCommand("insertHTML", undefined, "\n");
return;
}
// autocomplete or enter to send below shouldn't have any modifier keys pressed.
if (event.metaKey || event.altKey || event.shiftKey) {
return;
}
if (this.model.autoComplete) {
const autoComplete = this.model.autoComplete;
switch (event.key) {
case "Enter":
autoComplete.onEnter(event); break;
case "ArrowUp":
autoComplete.onUpArrow(event); break;
case "ArrowDown":
autoComplete.onDownArrow(event); break;
case "Tab":
autoComplete.onTab(event); break;
case "Escape":
autoComplete.onEscape(event); break;
default:
return; // don't preventDefault on anything else
}
event.preventDefault();
} else if (event.key === "Enter") {
this._sendEdit();
event.preventDefault();
} else if (event.key === "Escape") {
this._cancelEdit();
} else if (event.key === "ArrowUp") {
if (this._hasModifications || !this._isCaretAtStart()) {
return;
}
const previousEvent = findEditableEvent(this._getRoom(), false, this.props.event.getId());
if (previousEvent) {
dis.dispatch({action: 'edit_event', event: previousEvent});
event.preventDefault();
}
} else if (event.key === "ArrowDown") {
if (this._hasModifications || !this._isCaretAtEnd()) {
return;
}
const nextEvent = findEditableEvent(this._getRoom(), true, this.props.event.getId());
if (nextEvent) {
dis.dispatch({action: 'edit_event', event: nextEvent});
} else {
dis.dispatch({action: 'edit_event', event: null});
dis.dispatch({action: 'focus_composer'});
}
event.preventDefault();
}
}
_cancelEdit = () => {
dis.dispatch({action: "edit_event", event: null});
dis.dispatch({action: 'focus_composer'});
}
_sendEdit = () => {
const newContent = {
"msgtype": "m.text",
"body": textSerialize(this.model),
};
const contentBody = {
msgtype: newContent.msgtype,
body: ` * ${newContent.body}`,
};
const formattedBody = htmlSerializeIfNeeded(this.model);
if (formattedBody) {
newContent.format = "org.matrix.custom.html";
newContent.formatted_body = formattedBody;
contentBody.format = newContent.format;
contentBody.formatted_body = ` * ${newContent.formatted_body}`;
}
const content = Object.assign({
"m.new_content": newContent,
"m.relates_to": {
"rel_type": "m.replace",
"event_id": this.props.event.getId(),
},
}, contentBody);
const roomId = this.props.event.getRoomId();
this.context.matrixClient.sendMessage(roomId, content);
dis.dispatch({action: "edit_event", event: null});
dis.dispatch({action: 'focus_composer'});
}
_onAutoCompleteConfirm = (completion) => {
this.model.autoComplete.onComponentConfirm(completion);
}
_onAutoCompleteSelectionChange = (completion) => {
this.model.autoComplete.onComponentSelectionChange(completion);
}
componentDidMount() {
this._updateEditorState();
setCaretPosition(this._editorRef, this.model, this.model.getPositionAtEnd());
this._editorRef.focus();
}
render() {
let autoComplete;
if (this.state.autoComplete) {
const query = this.state.query;
const queryLen = query.length;
autoComplete = <div className="mx_MessageEditor_AutoCompleteWrapper">
<Autocomplete
ref={ref => this._autocompleteRef = ref}
query={query}
onConfirm={this._onAutoCompleteConfirm}
onSelectionChange={this._onAutoCompleteSelectionChange}
selection={{beginning: true, end: queryLen, start: queryLen}}
room={this.state.room}
/>
</div>;
}
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return <div className={classNames("mx_MessageEditor", this.props.className)}>
{ autoComplete }
<div
className="mx_MessageEditor_editor"
contentEditable="true"
tabIndex="1"
onInput={this._onInput}
onKeyDown={this._onKeyDown}
ref={ref => this._editorRef = ref}
aria-label={_t("Edit message")}
></div>
<div className="mx_MessageEditor_buttons">
<AccessibleButton kind="secondary" onClick={this._cancelEdit}>{_t("Cancel")}</AccessibleButton>
<AccessibleButton kind="primary" onClick={this._sendEdit}>{_t("Save")}</AccessibleButton>
</div>
</div>;
}
}

View file

@ -20,6 +20,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import * as Roles from '../../../Roles';
import { _t } from '../../../languageHandler';
import Field from "./Field";
module.exports = React.createClass({
displayName: 'PowerSelector',
@ -32,19 +33,15 @@ module.exports = React.createClass({
// Default user power level for the room
usersDefault: PropTypes.number.isRequired,
// if true, the <select/> should be a 'controlled' form element and updated by React
// to reflect the current value, rather than left freeform.
// MemberInfo uses controlled; RoomSettings uses non-controlled.
//
// ignored if disabled is truthy. false by default.
controlled: PropTypes.bool,
// should the user be able to change the value? false by default.
disabled: PropTypes.bool,
onChange: PropTypes.func,
// Optional key to pass as the second argument to `onChange`
powerLevelKey: PropTypes.string,
// The name to annotate the selector with
label: PropTypes.string,
},
getInitialState: function() {
@ -52,6 +49,9 @@ module.exports = React.createClass({
levelRoleMap: {},
// List of power levels to show in the drop-down
options: [],
customValue: this.props.value,
selectValue: 0,
};
},
@ -73,65 +73,69 @@ module.exports = React.createClass({
_initStateFromProps: function(newProps) {
// This needs to be done now because levelRoleMap has translated strings
const levelRoleMap = Roles.levelRoleMap(newProps.usersDefault);
const options = Object.keys(levelRoleMap).filter((l) => {
return l === undefined || l <= newProps.maxValue;
const options = Object.keys(levelRoleMap).filter(level => {
return (
level === undefined ||
level <= newProps.maxValue ||
level == newProps.value
);
});
const isCustom = levelRoleMap[newProps.value] === undefined;
this.setState({
levelRoleMap,
options,
custom: levelRoleMap[newProps.value] === undefined,
custom: isCustom,
customLevel: newProps.value,
selectValue: isCustom ? "SELECT_VALUE_CUSTOM" : newProps.value,
});
},
onSelectChange: function(event) {
this.setState({ custom: event.target.value === "SELECT_VALUE_CUSTOM" });
if (event.target.value !== "SELECT_VALUE_CUSTOM") {
const isCustom = event.target.value === "SELECT_VALUE_CUSTOM";
if (isCustom) {
this.setState({custom: true});
} else {
this.props.onChange(event.target.value, this.props.powerLevelKey);
this.setState({selectValue: event.target.value});
}
},
onCustomBlur: function(event) {
this.props.onChange(parseInt(this.refs.custom.value), this.props.powerLevelKey);
onCustomChange: function(event) {
this.setState({customValue: event.target.value});
},
onCustomKeyDown: function(event) {
if (event.key == "Enter") {
this.props.onChange(parseInt(this.refs.custom.value), this.props.powerLevelKey);
onCustomBlur: function(event) {
event.preventDefault();
event.stopPropagation();
this.props.onChange(parseInt(this.state.customValue), this.props.powerLevelKey);
},
onCustomKeyPress: function(event) {
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
// Do not call the onChange handler directly here - it can cause an infinite loop.
// Long story short, a user hits Enter to submit the value which onChange handles as
// raising a dialog which causes a blur which causes a dialog which causes a blur and
// so on. By not causing the onChange to be called here, we avoid the loop because we
// handle the onBlur safely.
event.target.blur();
}
},
render: function() {
let customPicker;
let picker;
if (this.state.custom) {
if (this.props.disabled) {
customPicker = <span>{ _t(
"Custom of %(powerLevel)s",
{ powerLevel: this.props.value },
) }</span>;
} else {
customPicker = <span> = <input
ref="custom"
type="text"
size="3"
defaultValue={this.props.value}
onBlur={this.onCustomBlur}
onKeyDown={this.onCustomKeyDown}
/>
</span>;
}
}
let selectValue;
if (this.state.custom) {
selectValue = "SELECT_VALUE_CUSTOM";
} else {
selectValue = this.state.levelRoleMap[this.props.value] ?
this.props.value : "SELECT_VALUE_CUSTOM";
}
let select;
if (this.props.disabled) {
select = <span>{ this.state.levelRoleMap[selectValue] }</span>;
picker = (
<Field id={`powerSelector_custom_${this.props.powerLevelKey}`} type="number"
label={this.props.label || _t("Power level")} max={this.props.maxValue}
onBlur={this.onCustomBlur} onKeyPress={this.onCustomKeyPress} onChange={this.onCustomChange}
value={String(this.state.customValue)} disabled={this.props.disabled} />
);
} else {
// Each level must have a definition in this.state.levelRoleMap
let options = this.state.options.map((level) => {
@ -145,20 +149,19 @@ module.exports = React.createClass({
return <option value={op.value} key={op.value}>{ op.text }</option>;
});
select =
<select ref="select"
value={this.props.controlled ? selectValue : undefined}
defaultValue={!this.props.controlled ? selectValue : undefined}
onChange={this.onSelectChange}>
{ options }
</select>;
picker = (
<Field id={`powerSelector_notCustom_${this.props.powerLevelKey}`} element="select"
label={this.props.label || _t("Power level")} onChange={this.onSelectChange}
value={String(this.state.selectValue)} disabled={this.props.disabled}>
{options}
</Field>
);
}
return (
<span className="mx_PowerSelector">
{ select }
{ customPicker }
</span>
<div className="mx_PowerSelector">
{ picker }
</div>
);
},
});

View file

@ -20,7 +20,7 @@ import PropTypes from 'prop-types';
import dis from '../../../dispatcher';
import {wantsDateSeparator} from '../../../DateUtils';
import {MatrixEvent, MatrixClient} from 'matrix-js-sdk';
import {makeEventPermalink, makeUserPermalink} from "../../../matrix-to";
import {makeUserPermalink, RoomPermalinkCreator} from "../../../matrix-to";
import SettingsStore from "../../../settings/SettingsStore";
// This component does no cycle detection, simply because the only way to make such a cycle would be to
@ -31,7 +31,8 @@ export default class ReplyThread extends React.Component {
// the latest event in this chain of replies
parentEv: PropTypes.instanceOf(MatrixEvent),
// called when the ReplyThread contents has changed, including EventTiles thereof
onWidgetLoad: PropTypes.func.isRequired,
onHeightChanged: PropTypes.func.isRequired,
permalinkCreator: PropTypes.instanceOf(RoomPermalinkCreator).isRequired,
};
static contextTypes = {
@ -62,6 +63,11 @@ export default class ReplyThread extends React.Component {
static getParentEventId(ev) {
if (!ev || ev.isRedacted()) return;
// XXX: For newer relations (annotations, replacements, etc.), we now
// have a `getRelation` helper on the event, and you might assume it
// could be used here for replies as well... However, the helper
// currently assumes the relation has a `rel_type`, which older replies
// do not, so this block is left as-is for now.
const mRelatesTo = ev.getWireContent()['m.relates_to'];
if (mRelatesTo && mRelatesTo['m.in_reply_to']) {
const mInReplyTo = mRelatesTo['m.in_reply_to'];
@ -85,7 +91,7 @@ export default class ReplyThread extends React.Component {
}
// Part of Replies fallback support
static getNestedReplyText(ev) {
static getNestedReplyText(ev, permalinkCreator) {
if (!ev) return null;
let {body, formatted_body: html} = ev.getContent();
@ -94,7 +100,7 @@ export default class ReplyThread extends React.Component {
if (html) html = this.stripHTMLReply(html);
}
const evLink = makeEventPermalink(ev.getRoomId(), ev.getId());
const evLink = permalinkCreator.forEvent(ev.getId());
const userLink = makeUserPermalink(ev.getSender());
const mxid = ev.getSender();
@ -159,11 +165,12 @@ export default class ReplyThread extends React.Component {
};
}
static makeThread(parentEv, onWidgetLoad, ref) {
static makeThread(parentEv, onHeightChanged, permalinkCreator, ref) {
if (!ReplyThread.getParentEventId(parentEv)) {
return <div />;
}
return <ReplyThread parentEv={parentEv} onWidgetLoad={onWidgetLoad} ref={ref} />;
return <ReplyThread parentEv={parentEv} onHeightChanged={onHeightChanged}
ref={ref} permalinkCreator={permalinkCreator} />;
}
componentWillMount() {
@ -173,7 +180,7 @@ export default class ReplyThread extends React.Component {
}
componentDidUpdate() {
this.props.onWidgetLoad();
this.props.onHeightChanged();
}
componentWillUnmount() {
@ -293,7 +300,8 @@ export default class ReplyThread extends React.Component {
{ dateSep }
<EventTile mxEvent={ev}
tileShape="reply"
onWidgetLoad={this.props.onWidgetLoad}
onHeightChanged={this.props.onHeightChanged}
permalinkCreator={this.props.permalinkCreator}
isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")} />
</blockquote>;
});

View file

@ -1,39 +0,0 @@
/*
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import sdk from '../../../index';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
const SettingsButton = function(props) {
const ActionButton = sdk.getComponent('elements.ActionButton');
return (
<ActionButton action="view_user_settings"
label={_t("Settings")}
iconPath={require("../../../../res/img/icons-settings.svg")}
size={props.size}
tooltip={props.tooltip}
/>
);
};
SettingsButton.propTypes = {
size: PropTypes.string,
tooltip: PropTypes.bool,
};
export default SettingsButton;

View file

@ -23,9 +23,11 @@ import sdk from '../../../index';
import dis from '../../../dispatcher';
import { isOnlyCtrlOrCmdIgnoreShiftKeyEvent } from '../../../Keyboard';
import * as ContextualMenu from '../../structures/ContextualMenu';
import * as FormattingUtils from '../../../utils/FormattingUtils';
import FlairStore from '../../../stores/FlairStore';
import GroupStore from '../../../stores/GroupStore';
import TagOrderStore from '../../../stores/TagOrderStore';
// A class for a child of TagPanel (possibly wrapped in a DNDTagTile) that represents
// a thing to click on for the user to filter the visible rooms in the RoomList to:
@ -154,7 +156,7 @@ export default React.createClass({
render: function() {
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const RoomTooltip = sdk.getComponent('rooms.RoomTooltip');
const Tooltip = sdk.getComponent('elements.Tooltip');
const profile = this.state.profile || {};
const name = profile.name || this.props.tag;
const avatarHeight = 40;
@ -168,8 +170,18 @@ export default React.createClass({
mx_TagTile_selected: this.props.selected,
});
const badge = TagOrderStore.getGroupBadge(this.props.tag);
let badgeElement;
if (badge && !this.state.hover) {
const badgeClasses = classNames({
"mx_TagTile_badge": true,
"mx_TagTile_badgeHighlight": badge.highlight,
});
badgeElement = (<div className={badgeClasses}>{FormattingUtils.formatCount(badge.count)}</div>);
}
const tip = this.state.hover ?
<RoomTooltip className="mx_TagTile_tooltip" label={name} /> :
<Tooltip className="mx_TagTile_tooltip" label={name} /> :
<div />;
const contextButton = this.state.hover || this.state.menuDisplayed ?
<div className="mx_TagTile_context_button" onClick={this.onContextButtonClick}>
@ -186,6 +198,7 @@ export default React.createClass({
/>
{ tip }
{ contextButton }
{ badgeElement }
</div>
</AccessibleButton>;
},

View file

@ -0,0 +1,56 @@
/*
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.
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';
export default class TextWithTooltip extends React.Component {
static propTypes = {
class: PropTypes.string,
tooltip: PropTypes.string.isRequired,
};
constructor() {
super();
this.state = {
hover: false,
};
}
onMouseOver = () => {
this.setState({hover: true});
};
onMouseOut = () => {
this.setState({hover: false});
};
render() {
const Tooltip = sdk.getComponent("elements.Tooltip");
return (
<span onMouseOver={this.onMouseOver} onMouseOut={this.onMouseOut} className={this.props.class}>
{this.props.children}
<Tooltip
label={this.props.tooltip}
visible={this.state.hover}
className={"mx_TextWithTooltip_tooltip"} />
</span>
);
}
}

View file

@ -38,6 +38,12 @@ export default class ToggleSwitch extends React.Component {
};
}
componentWillReceiveProps(nextProps) {
if (nextProps.checked !== this.state.checked) {
this.setState({checked: nextProps.checked});
}
}
_onClick = (e) => {
e.stopPropagation();
e.preventDefault();

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