Merge branch 'develop' into matthew/low_bandwidth
This commit is contained in:
commit
d81804e0fe
589 changed files with 37701 additions and 15344 deletions
|
@ -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 }
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
});
|
|
@ -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>
|
||||
|
|
|
@ -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>;
|
||||
|
|
125
src/components/structures/CustomRoomTagPanel.js
Normal file
125
src/components/structures/CustomRoomTagPanel.js
Normal 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;
|
|
@ -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;
|
|
@ -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')}
|
||||
/>
|
||||
);
|
||||
|
|
38
src/components/structures/GenericErrorPage.js
Normal file
38
src/components/structures/GenericErrorPage.js
Normal 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>;
|
||||
}
|
||||
}
|
|
@ -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>,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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}/>
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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')}
|
||||
/>
|
||||
|
|
|
@ -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", {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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.') }
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>;
|
||||
},
|
||||
});
|
||||
|
|
58
src/components/structures/TagPanelButtons.js
Normal file
58
src/components/structures/TagPanelButtons.js
Normal 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;
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
82
src/components/structures/UserView.js
Normal file
82
src/components/structures/UserView.js
Normal 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 />);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
});
|
|
@ -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>
|
||||
|
|
|
@ -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>;
|
||||
});
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
|
|
47
src/components/views/auth/Welcome.js
Normal file
47
src/components/views/auth/Welcome.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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 it’s 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 (
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 " +
|
||||
|
|
64
src/components/views/dialogs/InfoDialog.js
Normal file
64
src/components/views/dialogs/InfoDialog.js
Normal 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>
|
||||
);
|
||||
},
|
||||
});
|
|
@ -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") }
|
||||
|
|
|
@ -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}
|
||||
/>);
|
||||
}
|
||||
|
|
|
@ -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> " +
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
77
src/components/views/dialogs/StorageEvictedDialog.js
Normal file
77
src/components/views/dialogs/StorageEvictedDialog.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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 (
|
||||
|
|
107
src/components/views/dialogs/UploadConfirmDialog.js
Normal file
107
src/components/views/dialogs/UploadConfirmDialog.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
120
src/components/views/dialogs/UploadFailureDialog.js
Normal file
120
src/components/views/dialogs/UploadFailureDialog.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
103
src/components/views/dialogs/WidgetOpenIDPermissionsDialog.js
Normal file
103
src/components/views/dialogs/WidgetOpenIDPermissionsDialog.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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,
|
||||
|
||||
|
|
|
@ -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 ?
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 */ }
|
||||
|
|
|
@ -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>);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
};
|
|
@ -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>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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} />;
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
91
src/components/views/elements/LazyRenderList.js
Normal file
91
src/components/views/elements/LazyRenderList.js
Normal 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>);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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 (
|
||||
|
|
240
src/components/views/elements/MessageEditor.js
Normal file
240
src/components/views/elements/MessageEditor.js
Normal 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>;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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>;
|
||||
});
|
||||
|
|
|
@ -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;
|
|
@ -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>;
|
||||
},
|
||||
|
|
56
src/components/views/elements/TextWithTooltip.js
Normal file
56
src/components/views/elements/TextWithTooltip.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue