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

This commit is contained in:
Weblate 2019-02-27 17:33:37 +00:00
commit 2194eebfd0
255 changed files with 3715 additions and 3159 deletions

View file

@ -113,4 +113,29 @@ export default class BasePlatform {
reload() {
throw new Error("reload not implemented!");
}
supportsAutoLaunch(): boolean {
return false;
}
// XXX: Surely this should be a setting like any other?
async getAutoLaunchEnabled(): boolean {
return false;
}
async setAutoLaunchEnabled(enabled: boolean): void {
throw new Error("Unimplemented");
}
supportsMinimizeToTray(): boolean {
return false;
}
async getMinimizeToTrayEnabled(): boolean {
return false;
}
async setMinimizeToTrayEnabled(enabled: boolean): void {
throw new Error("Unimplemented");
}
}

View file

@ -30,6 +30,7 @@ import MatrixActionCreators from './actions/MatrixActionCreators';
import {phasedRollOutExpiredForUser} from "./PhasedRollOut";
import Modal from './Modal';
import {verificationMethods} from 'matrix-js-sdk/lib/crypto';
import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler";
interface MatrixClientCreds {
homeserverUrl: string,
@ -137,8 +138,9 @@ class MatrixClientPeg {
opts.pendingEventOrdering = "detached";
opts.lazyLoadMembers = true;
// Connect the matrix client to the dispatcher
// Connect the matrix client to the dispatcher and setting handlers
MatrixActionCreators.start(this.matrixClient);
MatrixClientBackedSettingsHandler.matrixClient = this.matrixClient;
console.log(`MatrixClientPeg: really starting MatrixClient`);
await this.get().startClient(opts);

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
const DEFAULTS = {
export const DEFAULTS = {
// URL to a page we show in an iframe to configure integrations
integrations_ui_url: "https://scalar.vector.im/",
// Base URL to the REST interface of the integrations server

View file

@ -110,6 +110,24 @@ export const CommandMap = {
},
}),
roomnick: new Command({
name: 'roomnick',
args: '<display_name>',
description: _td('Changes your display nickname in the current room only'),
runFn: function(roomId, args) {
if (args) {
const cli = MatrixClientPeg.get();
const ev = cli.getRoom(roomId).currentState.getStateEvents('m.room.member', cli.getUserId());
const content = {
...ev ? ev.getContent() : { membership: 'join' },
displayname: args,
};
return success(cli.sendStateEvent(roomId, 'm.room.member', content, cli.getUserId()));
}
return reject(this.getUsage());
},
}),
tint: new Command({
name: 'tint',
args: '<color1> [<color2>]',

View file

@ -166,6 +166,36 @@ function textForGuestAccessEvent(ev) {
}
}
function textForRelatedGroupsEvent(ev) {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
const groups = ev.getContent().groups || [];
const prevGroups = ev.getPrevContent().groups || [];
const added = groups.filter((g) => !prevGroups.includes(g));
const removed = prevGroups.filter((g) => !groups.includes(g));
if (added.length && !removed.length) {
return _t('%(senderDisplayName)s enabled flair for %(groups)s in this room.', {
senderDisplayName,
groups: added.join(', '),
});
} else if (!added.length && removed.length) {
return _t('%(senderDisplayName)s disabled flair for %(groups)s in this room.', {
senderDisplayName,
groups: removed.join(', '),
});
} else if (added.length && removed.length) {
return _t('%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for ' +
'%(oldGroups)s in this room.', {
senderDisplayName,
newGroups: added.join(', '),
oldGroups: removed.join(', '),
});
} else {
// Don't bother rendering this change (because there were no changes)
return '';
}
}
function textForServerACLEvent(ev) {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
const prevContent = ev.getPrevContent();
@ -473,6 +503,7 @@ const stateHandlers = {
'm.room.tombstone': textForTombstoneEvent,
'm.room.join_rules': textForJoinRulesEvent,
'm.room.guest_access': textForGuestAccessEvent,
'm.room.related_groups': textForRelatedGroupsEvent,
'im.vector.modular.widgets': textForWidgetEvent,
};

View file

@ -131,6 +131,24 @@ function createRoomTagsAction(matrixClient, roomTagsEvent, room) {
return { action: 'MatrixActions.Room.tags', room };
}
/**
* Create a MatrixActions.Room.receipt action that represents a MatrixClient
* `Room.receipt` event, each parameter mapping to a key-value in the action.
*
* @param {MatrixClient} matrixClient the matrix client
* @param {MatrixEvent} event the receipt event.
* @param {Room} room the room the receipt happened in.
* @returns {Object} an action of type MatrixActions.Room.receipt.
*/
function createRoomReceiptAction(matrixClient, event, room) {
return {
action: 'MatrixActions.Room.receipt',
event,
room,
matrixClient,
};
}
/**
* @typedef RoomTimelineAction
* @type {Object}
@ -233,6 +251,7 @@ export default {
this._addMatrixClientListener(matrixClient, 'Room.accountData', createRoomAccountDataAction);
this._addMatrixClientListener(matrixClient, 'Room', createRoomAction);
this._addMatrixClientListener(matrixClient, 'Room.tags', createRoomTagsAction);
this._addMatrixClientListener(matrixClient, 'Room.receipt', createRoomReceiptAction);
this._addMatrixClientListener(matrixClient, 'Room.timeline', createRoomTimelineAction);
this._addMatrixClientListener(matrixClient, 'Room.myMembership', createSelfMembershipAction);
this._addMatrixClientListener(matrixClient, 'Event.decrypted', createEventDecryptedAction);

View file

@ -34,6 +34,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>
@ -569,7 +570,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),
});
},

View file

@ -26,6 +26,7 @@ import dis from '../../dispatcher';
import VectorConferenceHandler from '../../VectorConferenceHandler';
import TagPanelButtons from './TagPanelButtons';
import SettingsStore from '../../settings/SettingsStore';
import {_t} from "../../languageHandler";
const LeftPanel = React.createClass({
@ -212,6 +213,7 @@ const LeftPanel = React.createClass({
);
const searchBox = (<SearchBox
placeholder={ _t('Filter room names') }
onSearch={ this.onSearch }
onCleared={ this.onSearchCleared }
collapsed={this.props.collapsed} />);

View file

@ -421,6 +421,7 @@ const LoggedInView = React.createClass({
render: function() {
const LeftPanel = sdk.getComponent('structures.LeftPanel');
const RoomView = sdk.getComponent('structures.RoomView');
const UserView = sdk.getComponent('structures.UserView');
const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage');
const GroupView = sdk.getComponent('structures.GroupView');
const MyGroups = sdk.getComponent('structures.MyGroups');
@ -469,9 +470,7 @@ const LoggedInView = React.createClass({
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

View file

@ -245,6 +245,17 @@ export default React.createClass({
return this.state.defaultIsUrl || "https://vector.im";
},
/**
* Whether to skip the server details phase of registration and start at the
* actual form.
* @return {boolean}
* If there was a configured default HS or default server name, skip the
* the server details.
*/
skipServerDetailsForRegistration() {
return !!this.state.defaultHsUrl;
},
componentWillMount: function() {
SdkConfig.put(this.props.config);
@ -1573,14 +1584,9 @@ export default React.createClass({
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,
});
this.setState({currentUserId: userId});
this._setPage(PageTypes.UserView);
});
} else if (screen.indexOf('group/') == 0) {
const groupId = screen.substring(6);
@ -1887,9 +1893,11 @@ export default React.createClass({
sessionId={this.state.register_session_id}
idSid={this.state.register_id_sid}
email={this.props.startingFragmentQueryParams.email}
defaultServerName={this.getDefaultServerName()}
defaultServerDiscoveryError={this.state.defaultServerDiscoveryError}
defaultHsUrl={this.getDefaultHsUrl()}
defaultIsUrl={this.getDefaultIsUrl()}
skipServerDetails={this.skipServerDetailsForRegistration()}
brand={this.props.config.brand}
customHsUrl={this.getCurrentHsUrl()}
customIsUrl={this.getCurrentIsUrl()}
@ -1923,6 +1931,7 @@ export default React.createClass({
<Login
onLoggedIn={Lifecycle.setLoggedIn}
onRegisterClick={this.onRegisterClick}
defaultServerName={this.getDefaultServerName()}
defaultServerDiscoveryError={this.state.defaultServerDiscoveryError}
defaultHsUrl={this.getDefaultHsUrl()}
defaultIsUrl={this.getDefaultIsUrl()}

View file

@ -525,6 +525,7 @@ module.exports = React.createClass({
eventSendStatus={mxEv.status}
tileShape={this.props.tileShape}
isTwelveHour={this.props.isTwelveHour}
permalinkCreator={this.props.permalinkCreator}
last={last} isSelectedEvent={highlight} />
</li>,
);

View file

@ -32,6 +32,7 @@ export default class RightPanel extends React.Component {
return {
roomId: React.PropTypes.string, // if showing panels for a given room, this is set
groupId: React.PropTypes.string, // if showing panels for a given group, this is set
user: React.PropTypes.object,
};
}
@ -55,7 +56,7 @@ export default class RightPanel extends React.Component {
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 +70,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() {

View file

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

View file

@ -282,18 +282,10 @@ const RoomSubList = React.createClass({
this.setState({scrollTop: this.refs.scroller.getScrollTop()});
},
_getRenderItems: function() {
// try our best to not create a new array
// because LazyRenderList rerender when the items prop
// is not the same object as the previous value
const {list, extraTiles} = this.props;
if (!extraTiles || !extraTiles.length) {
return list;
}
if (!list || list.length) {
return extraTiles;
}
return list.concat(extraTiles);
_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() {
@ -310,7 +302,7 @@ const RoomSubList = React.createClass({
return <div ref="subList" className={subListClasses}>
{this._getHeaderJsx(isCollapsed)}
</div>;
} else {
} else if (this._canUseLazyListRendering()) {
return <div ref="subList" className={subListClasses}>
{this._getHeaderJsx(isCollapsed)}
<IndicatorScrollbar ref="scroller" className="mx_RoomSubList_scroll" onScroll={ this._onScroll }>
@ -319,7 +311,16 @@ const RoomSubList = React.createClass({
height={ this.state.scrollerHeight }
renderItem={ this.makeRoomTile }
itemHeight={34}
items={this._getRenderItems()} />
items={ this.props.list } />
</IndicatorScrollbar>
</div>;
} else {
const roomTiles = this.props.list.map(r => this.makeRoomTile(r));
const tiles = roomTiles.concat(this.props.extraTiles);
return <div ref="subList" className={subListClasses}>
{this._getHeaderJsx(isCollapsed)}
<IndicatorScrollbar ref="scroller" className="mx_RoomSubList_scroll" onScroll={ this._onScroll }>
{ tiles }
</IndicatorScrollbar>
</div>;
}

View file

@ -30,6 +30,7 @@ import Promise from 'bluebird';
import filesize from 'filesize';
const classNames = require("classnames");
import { _t } from '../../languageHandler';
import {RoomPermalinkCreator} from "../../matrix-to";
const MatrixClientPeg = require("../../MatrixClientPeg");
const ContentMessages = require("../../ContentMessages");
@ -441,6 +442,11 @@ module.exports = React.createClass({
RoomScrollStateStore.setScrollState(this.state.roomId, this._getScrollState());
}
// stop tracking room changes to format permalinks
if (this.state.permalinkCreator) {
this.state.permalinkCreator.stop();
}
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
@ -537,12 +543,12 @@ module.exports = React.createClass({
case 'picture_snapshot':
this.uploadFile(payload.file);
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) {
if (payload.error && payload.error.http_status === 413) {
this._fetchMediaConfig(true);
}
case 'notifier_enabled':
case 'upload_started':
case 'upload_finished':
this.forceUpdate();
@ -652,6 +658,11 @@ module.exports = React.createClass({
this._loadMembersIfJoined(room);
this._calculateRecommendedVersion(room);
this._updateE2EStatus(room);
if (!this.state.permalinkCreator) {
const permalinkCreator = new RoomPermalinkCreator(room);
permalinkCreator.start();
this.setState({permalinkCreator});
}
},
_calculateRecommendedVersion: async function(room) {
@ -1219,6 +1230,7 @@ module.exports = React.createClass({
searchResult={result}
searchHighlights={this.state.searchHighlights}
resultLink={resultLink}
permalinkCreator={this.state.permalinkCreator}
onWidgetLoad={onWidgetLoad} />);
}
return ret;
@ -1305,7 +1317,10 @@ module.exports = React.createClass({
},
onSearchClick: function() {
this.setState({ searching: true, showingPinned: false });
this.setState({
searching: !this.state.searching,
showingPinned: false,
});
},
onCancelSearchClick: function() {
@ -1722,6 +1737,7 @@ module.exports = React.createClass({
showApps={this.state.showApps}
uploadAllowed={this.isFileUploadAllowed}
e2eStatus={this.state.e2eStatus}
permalinkCreator={this.state.permalinkCreator}
/>;
}
@ -1823,6 +1839,7 @@ module.exports = React.createClass({
showUrlPreview = {this.state.showUrlPreview}
className="mx_RoomView_messagePanel"
membersLoaded={this.state.membersLoaded}
permalinkCreator={this.state.permalinkCreator}
/>);
let topUnreadMessagesBar = null;

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,12 +15,9 @@ 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 { throttle } from 'lodash';
import AccessibleButton from '../../components/views/elements/AccessibleButton';
@ -28,8 +26,10 @@ 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,
},
getInitialState: function() {
@ -102,21 +102,22 @@ module.exports = React.createClass({
const clearButton = this.state.searchTerm.length > 0 ?
(<AccessibleButton key="button"
className="mx_SearchBox_closeButton"
onClick={ () => {this._clearSearch("button")} }>
</AccessibleButton>) : undefined;
onClick={ () => {this._clearSearch("button"); } }>
</AccessibleButton>) : undefined;
const className = this.props.className || "";
return (
<div className="mx_SearchBox mx_textinput">
<input
key="searchfield"
type="text"
ref="search"
className="mx_textinput_icon mx_textinput_search"
className={"mx_textinput_icon mx_textinput_search " + className}
value={ this.state.searchTerm }
onFocus={ this._onFocus }
onChange={ this.onChange }
onKeyDown={ this._onKeyDown }
placeholder={ _t('Filter room names') }
placeholder={ this.props.placeholder }
/>
{ clearButton }
</div>

View file

@ -1202,6 +1202,7 @@ var TimelinePanel = React.createClass({
return (
<MessagePanel ref="messagePanel"
room={this.props.timelineSet.room}
permalinkCreator={this.props.permalinkCreator}
hidden={this.props.hidden}
backPaginating={this.state.backPaginating}
forwardPaginating={forwardPaginating}

View file

@ -0,0 +1,82 @@
/*
Copyright 2019 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import Matrix from "matrix-js-sdk";
import MatrixClientPeg from "../../MatrixClientPeg";
import sdk from "../../index";
import Modal from '../../Modal';
import { _t } from '../../languageHandler';
export default class UserView extends React.Component {
static get propTypes() {
return {
userId: React.PropTypes.string,
};
}
constructor(props) {
super(props);
this.state = {};
}
componentWillMount() {
if (this.props.userId) {
this._loadProfileInfo();
}
}
componentDidUpdate(prevProps) {
if (prevProps.userId !== this.props.userId) {
this._loadProfileInfo();
}
}
async _loadProfileInfo() {
const cli = MatrixClientPeg.get();
this.setState({loading: true});
let profileInfo;
try {
profileInfo = await cli.getProfileInfo(this.props.userId);
} catch (err) {
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
Modal.createTrackedDialog(_t('Could not load user profile'), '', ErrorDialog, {
title: _t('Could not load user profile'),
description: ((err && err.message) ? err.message : _t("Operation failed")),
});
this.setState({loading: false});
return;
}
const fakeEvent = new Matrix.MatrixEvent({type: "m.room.member", content: profileInfo});
const member = new Matrix.RoomMember(null, this.props.userId);
member.setMembershipEvent(fakeEvent);
this.setState({member, loading: false});
}
render() {
if (this.state.loading) {
const Spinner = sdk.getComponent("elements.Spinner");
return <Spinner />;
} else if (this.state.member) {
const RightPanel = sdk.getComponent('structures.RightPanel');
const MainSplit = sdk.getComponent('structures.MainSplit');
const panel = <RightPanel user={this.state.member} />;
return (<MainSplit panel={panel}><div style={{flex: "1"}} /></MainSplit>);
} else {
return (<div />);
}
}
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,11 +15,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import React from 'react';
import PropTypes from 'prop-types';
import SyntaxHighlight from '../views/elements/SyntaxHighlight';
import {_t} from "../../languageHandler";
import sdk from "../../index";
module.exports = React.createClass({
@ -27,31 +28,24 @@ module.exports = React.createClass({
propTypes: {
content: PropTypes.object.isRequired,
onFinished: PropTypes.func.isRequired,
},
componentDidMount: function() {
document.addEventListener("keydown", this.onKeyDown);
},
componentWillUnmount: function() {
document.removeEventListener("keydown", this.onKeyDown);
},
onKeyDown: function(ev) {
if (ev.keyCode == 27) { // escape
ev.stopPropagation();
ev.preventDefault();
this.props.onFinished();
}
roomId: PropTypes.string.isRequired,
eventId: PropTypes.string.isRequired,
},
render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return (
<div className="mx_ViewSource">
<SyntaxHighlight className="json">
{ JSON.stringify(this.props.content, null, 2) }
</SyntaxHighlight>
</div>
<BaseDialog className="mx_ViewSource" onFinished={this.props.onFinished} title={_t('View Source')}>
<div className="mx_ViewSource_label_left">Room ID: { this.props.roomId }</div>
<div className="mx_ViewSource_label_right">Event ID: { this.props.eventId }</div>
<div className="mx_ViewSource_label_bottom" />
<div className="mx_Dialog_content">
<SyntaxHighlight className="json">
{ JSON.stringify(this.props.content, null, 2) }
</SyntaxHighlight>
</div>
</BaseDialog>
);
},
});

View file

@ -41,20 +41,22 @@ module.exports = React.createClass({
displayName: 'ForgotPassword',
propTypes: {
// The default server name to use when the user hasn't specified
// one. If set, `defaultHsUrl` and `defaultHsUrl` were derived for this
// via `.well-known` discovery. The server name is used instead of the
// HS URL when talking about "your account".
defaultServerName: PropTypes.string,
// An error passed along from higher up explaining that something
// went wrong when finding the defaultHsUrl.
defaultServerDiscoveryError: PropTypes.string,
defaultHsUrl: PropTypes.string,
defaultIsUrl: PropTypes.string,
customHsUrl: PropTypes.string,
customIsUrl: PropTypes.string,
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() {
@ -234,19 +236,25 @@ module.exports = React.createClass({
errorText = <div className="mx_Login_error">{ err }</div>;
}
let yourMatrixAccountText = _t('Your account');
try {
const parsedHsUrl = new URL(this.state.enteredHsUrl);
yourMatrixAccountText = _t('Your account on %(serverName)s', {
serverName: parsedHsUrl.hostname,
let yourMatrixAccountText = _t('Your Matrix account');
if (this.state.enteredHsUrl === this.props.defaultHsUrl) {
yourMatrixAccountText = _t('Your Matrix account on %(serverName)s', {
serverName: this.props.defaultServerName,
});
} catch (e) {
errorText = <div className="mx_Login_error">{_t(
"The homeserver URL %(hsUrl)s doesn't seem to be valid URL. Please " +
"enter a valid URL including the protocol prefix.",
{
hsUrl: this.state.enteredHsUrl,
})}</div>;
} else {
try {
const parsedHsUrl = new URL(this.state.enteredHsUrl);
yourMatrixAccountText = _t('Your Matrix account on %(serverName)s', {
serverName: parsedHsUrl.hostname,
});
} catch (e) {
errorText = <div className="mx_Login_error">{_t(
"The homeserver URL %(hsUrl)s doesn't seem to be valid URL. Please " +
"enter a valid URL including the protocol prefix.",
{
hsUrl: this.state.enteredHsUrl,
})}</div>;
}
}
// If custom URLs are allowed, wire up the server details edit link.

View file

@ -56,6 +56,15 @@ module.exports = React.createClass({
enableGuest: PropTypes.bool,
// The default server name to use when the user hasn't specified
// one. If set, `defaultHsUrl` and `defaultHsUrl` were derived for this
// via `.well-known` discovery. The server name is used instead of the
// HS URL when talking about where to "sign in to".
defaultServerName: PropTypes.string,
// An error passed along from higher up explaining that something
// went wrong when finding the defaultHsUrl.
defaultServerDiscoveryError: PropTypes.string,
customHsUrl: PropTypes.string,
customIsUrl: PropTypes.string,
defaultHsUrl: PropTypes.string,
@ -65,10 +74,6 @@ module.exports = React.createClass({
// 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.
@ -563,11 +568,20 @@ module.exports = React.createClass({
_renderPasswordStep: function() {
const PasswordLogin = sdk.getComponent('auth.PasswordLogin');
let onEditServerDetailsClick = null;
// If custom URLs are allowed, wire up the server details edit link.
if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) {
onEditServerDetailsClick = this.onEditServerDetailsClick;
}
// If the current HS URL is the default HS URL, then we can label it
// with the default HS name (if it exists).
let hsName;
if (this.state.enteredHsUrl === this.props.defaultHsUrl) {
hsName = this.props.defaultServerName;
}
return (
<PasswordLogin
onSubmit={this.onPasswordLogin}
@ -583,6 +597,7 @@ module.exports = React.createClass({
onPhoneNumberBlur={this.onPhoneNumberBlur}
onForgotPasswordClick={this.props.onForgotPasswordClick}
loginIncorrect={this.state.loginIncorrect}
hsName={hsName}
hsUrl={this.state.enteredHsUrl}
disableSubmit={this.state.findingHomeserver}
/>

View file

@ -48,31 +48,46 @@ module.exports = React.createClass({
sessionId: PropTypes.string,
makeRegistrationUrl: PropTypes.func.isRequired,
idSid: PropTypes.string,
// The default server name to use when the user hasn't specified
// one. If set, `defaultHsUrl` and `defaultHsUrl` were derived for this
// via `.well-known` discovery. The server name is used instead of the
// HS URL when talking about "your account".
defaultServerName: PropTypes.string,
// An error passed along from higher up explaining that something
// went wrong when finding the defaultHsUrl.
defaultServerDiscoveryError: PropTypes.string,
customHsUrl: PropTypes.string,
customIsUrl: PropTypes.string,
defaultHsUrl: PropTypes.string,
defaultIsUrl: PropTypes.string,
skipServerDetails: PropTypes.bool,
brand: PropTypes.string,
email: PropTypes.string,
// An error passed along from higher up explaining that something
// went wrong when finding the defaultHsUrl.
defaultServerDiscoveryError: PropTypes.string,
// registration shouldn't know or care how login is done.
onLoginClick: PropTypes.func.isRequired,
onServerConfigChange: PropTypes.func.isRequired,
},
getInitialState: function() {
const serverType = ServerType.getTypeFromHsUrl(this.props.customHsUrl);
const customURLsAllowed = !SdkConfig.get()['disable_custom_urls'];
let initialPhase = PHASE_SERVER_DETAILS;
let initialPhase = this.getDefaultPhaseForServerType(serverType);
if (
// if we have these two, skip to the good bit
// (they could come in from the URL params in a
// registration email link)
(this.props.clientSecret && this.props.sessionId) ||
// or if custom URLs aren't allowed, skip them
!customURLsAllowed
// if custom URLs aren't allowed, skip to form
!customURLsAllowed ||
// if other logic says to, skip to form
this.props.skipServerDetails
) {
// TODO: It would seem we've now added enough conditions here that the initial
// phase will _always_ be the form. It's tempting to remove the complexity and
// just do that, but we keep tweaking and changing auth, so let's wait until
// things settle a bit.
// Filed https://github.com/vector-im/riot-web/issues/8886 to track this.
initialPhase = PHASE_REGISTRATION;
}
@ -94,7 +109,7 @@ 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,
serverType,
hsUrl: this.props.customHsUrl,
isUrl: this.props.customIsUrl,
// Phase of the overall registration dialog.
@ -122,7 +137,20 @@ module.exports = React.createClass({
});
},
onServerTypeChange(type, initial) {
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;
}
},
onServerTypeChange(type) {
this.setState({
serverType: type,
});
@ -136,10 +164,6 @@ module.exports = React.createClass({
hsUrl,
isUrl,
});
// Move directly to the registration phase since the server details are fixed.
this.setState({
phase: PHASE_REGISTRATION,
});
break;
}
case ServerType.PREMIUM:
@ -148,17 +172,13 @@ module.exports = React.createClass({
hsUrl: this.props.defaultHsUrl,
isUrl: this.props.defaultIsUrl,
});
// if this is the initial value from the control and we're
// already in the registration phase, don't go back to the
// server details phase (but do if it's actually a change resulting
// from user interaction).
if (!initial || !this.state.phase === PHASE_REGISTRATION) {
this.setState({
phase: PHASE_SERVER_DETAILS,
});
}
break;
}
// Reset the phase to default phase for the server type.
this.setState({
phase: this.getDefaultPhaseForServerType(type),
});
},
_replaceClient: async function() {
@ -288,7 +308,19 @@ module.exports = React.createClass({
});
},
onFormValidationFailed: function(errCode) {
onFormValidationChange: function(fieldErrors) {
// `fieldErrors` is an object mapping field IDs to error codes when there is an
// error or `null` for no error, so the values array will be something like:
// `[ null, "RegistrationForm.ERR_PASSWORD_MISSING", null]`
// Find the first non-null error code and show that.
const errCode = Object.values(fieldErrors).find(value => !!value);
if (!errCode) {
this.setState({
errorText: null,
});
return;
}
let errMsg;
switch (errCode) {
case "RegistrationForm.ERR_PASSWORD_MISSING":
@ -313,7 +345,7 @@ module.exports = React.createClass({
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 '=_-./'");
errMsg = _t("A username can only contain lower case letters, numbers and '=_-./'");
break;
case "RegistrationForm.ERR_USERNAME_BLANK":
errMsg = _t('You need to enter a username.');
@ -389,12 +421,9 @@ module.exports = React.createClass({
// If we're on a different phase, we only show the server type selector,
// which is always shown if we allow custom URLs at all.
if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS) {
// if we've been given a custom HS URL we should actually pass that, so
// that the appropriate section is selected at the start to match the
// homeserver URL we're using
return <div>
<ServerTypeSelector
defaultHsUrl={this.props.customHsUrl || this.props.defaultHsUrl}
selected={this.state.serverType}
onChange={this.onServerTypeChange}
/>
</div>;
@ -436,7 +465,7 @@ module.exports = React.createClass({
return <div>
<ServerTypeSelector
defaultHsUrl={this.props.defaultHsUrl}
selected={this.state.serverType}
onChange={this.onServerTypeChange}
/>
{serverDetails}
@ -466,7 +495,9 @@ module.exports = React.createClass({
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
@ -478,6 +509,14 @@ module.exports = React.createClass({
) {
onEditServerDetailsClick = this.onEditServerDetailsClick;
}
// If the current HS URL is the default HS URL, then we can label it
// with the default HS name (if it exists).
let hsName;
if (this.state.hsUrl === this.props.defaultHsUrl) {
hsName = this.props.defaultServerName;
}
return <RegistrationForm
defaultUsername={this.state.formVals.username}
defaultEmail={this.state.formVals.email}
@ -485,10 +524,11 @@ module.exports = React.createClass({
defaultPhoneNumber={this.state.formVals.phoneNumber}
defaultPassword={this.state.formVals.password}
minPasswordLength={MIN_PASSWORD_LENGTH}
onError={this.onFormValidationFailed}
onValidationChange={this.onFormValidationChange}
onRegisterClick={this.onFormSubmit}
onEditServerDetailsClick={onEditServerDetailsClick}
flows={this.state.flows}
hsName={hsName}
hsUrl={this.state.hsUrl}
/>;
}
@ -505,14 +545,9 @@ module.exports = React.createClass({
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>;
return (
<AuthPage>

View file

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

View file

@ -40,6 +40,10 @@ class PasswordLogin extends React.Component {
initialPhoneNumber: "",
initialPassword: "",
loginIncorrect: false,
// This is optional and only set if we used a server name to determine
// the HS URL via `.well-known` discovery. The server name is used
// instead of the HS URL when talking about where to "sign in to".
hsName: null,
hsUrl: "",
disableSubmit: false,
}
@ -54,6 +58,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);
@ -70,6 +75,12 @@ class PasswordLogin extends React.Component {
this._loginField = null;
}
onForgotPasswordClick(ev) {
ev.preventDefault();
ev.stopPropagation();
this.props.onForgotPasswordClick();
}
onSubmitForm(ev) {
ev.preventDefault();
@ -240,7 +251,7 @@ class PasswordLogin extends React.Component {
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 +260,20 @@ class PasswordLogin extends React.Component {
</span>;
}
let signInToText = _t('Sign in');
try {
const parsedHsUrl = new URL(this.props.hsUrl);
signInToText = _t('Sign in to %(serverName)s', {
serverName: parsedHsUrl.hostname,
let signInToText = _t('Sign in to your Matrix account');
if (this.props.hsName) {
signInToText = _t('Sign in to your Matrix account on %(serverName)s', {
serverName: this.props.hsName,
});
} catch (e) {
// ignore
} else {
try {
const parsedHsUrl = new URL(this.props.hsUrl);
signInToText = _t('Sign in to your Matrix account on %(serverName)s', {
serverName: parsedHsUrl.hostname,
});
} catch (e) {
// ignore
}
}
let editLink = null;
@ -338,6 +355,8 @@ PasswordLogin.propTypes = {
onPhoneNumberChanged: PropTypes.func,
onPasswordChanged: PropTypes.func,
loginIncorrect: PropTypes.bool,
hsName: PropTypes.string,
hsUrl: PropTypes.string,
disableSubmit: PropTypes.bool,
};

View file

@ -46,25 +46,28 @@ module.exports = React.createClass({
defaultUsername: PropTypes.string,
defaultPassword: PropTypes.string,
minPasswordLength: PropTypes.number,
onError: PropTypes.func,
onValidationChange: PropTypes.func,
onRegisterClick: PropTypes.func.isRequired, // onRegisterClick(Object) => ?Promise
onEditServerDetailsClick: PropTypes.func,
flows: PropTypes.arrayOf(PropTypes.object).isRequired,
// This is optional and only set if we used a server name to determine
// the HS URL via `.well-known` discovery. The server name is used
// instead of the HS URL when talking about "your account".
hsName: PropTypes.string,
hsUrl: PropTypes.string,
},
getDefaultProps: function() {
return {
minPasswordLength: 6,
onError: function(e) {
console.error(e);
},
onValidationChange: console.error,
};
},
getInitialState: function() {
return {
fieldValid: {},
// Field error codes by field ID
fieldErrors: {},
// The ISO2 country code selected in the phone number entry
phoneCountry: this.props.defaultPhoneCountry,
};
@ -77,12 +80,12 @@ module.exports = React.createClass({
// 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.
// onValidationChange once for each invalid field.
this.validateField(FIELD_PHONE_NUMBER, ev.type);
this.validateField(FIELD_EMAIL, ev.type);
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 self = this;
if (this.allFieldsValid()) {
@ -130,9 +133,9 @@ module.exports = React.createClass({
* @returns {boolean} true if all fields were valid last time they were validated.
*/
allFieldsValid: function() {
const keys = Object.keys(this.state.fieldValid);
const keys = Object.keys(this.state.fieldErrors);
for (let i = 0; i < keys.length; ++i) {
if (this.state.fieldValid[keys[i]] == false) {
if (this.state.fieldErrors[keys[i]]) {
return false;
}
}
@ -202,21 +205,29 @@ module.exports = React.createClass({
}
break;
case FIELD_PASSWORD_CONFIRM:
this.markFieldValid(
fieldID, pwd1 == pwd2,
"RegistrationForm.ERR_PASSWORD_MISMATCH",
);
if (allowEmpty && pwd2 === "") {
this.markFieldValid(fieldID, true);
} else {
this.markFieldValid(
fieldID, pwd1 == pwd2,
"RegistrationForm.ERR_PASSWORD_MISMATCH",
);
}
break;
}
},
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, errorCode) {
const { fieldErrors } = this.state;
if (valid) {
fieldErrors[fieldID] = null;
} else {
fieldErrors[fieldID] = errorCode;
}
this.setState({
fieldErrors,
});
this.props.onValidationChange(fieldErrors);
},
fieldElementById(fieldID) {
@ -236,7 +247,7 @@ module.exports = React.createClass({
_classForField: function(fieldID, ...baseClasses) {
let cls = baseClasses.join(' ');
if (this.state.fieldValid[fieldID] === false) {
if (this.state.fieldErrors[fieldID]) {
if (cls) cls += ' ';
cls += 'error';
}
@ -295,14 +306,20 @@ module.exports = React.createClass({
},
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');
if (this.props.hsName) {
yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', {
serverName: this.props.hsName,
});
} catch (e) {
// ignore
} else {
try {
const parsedHsUrl = new URL(this.props.hsUrl);
yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', {
serverName: parsedHsUrl.hostname,
});
} catch (e) {
// ignore
}
}
let editLink = null;
@ -310,7 +327,7 @@ module.exports = React.createClass({
editLink = <a className="mx_AuthBody_editServerDetails"
href="#" onClick={this.props.onEditServerDetailsClick}
>
{_t('Edit')}
{_t('Change')}
</a>;
}

View file

@ -30,7 +30,7 @@ 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',
@ -38,7 +38,7 @@ export const TYPES = {
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}
@ -49,21 +49,21 @@ export const TYPES = {
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 getTypeFromHsUrl(hsUrl) {
if (!hsUrl) {
return null;
} else if (defaultHsUrl === TYPES.FREE.hsUrl) {
} else if (hsUrl === TYPES.FREE.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,8 +72,8 @@ 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,
}
@ -82,20 +82,12 @@ export default class ServerTypeSelector extends React.PureComponent {
super(props);
const {
defaultHsUrl,
onChange,
selected,
} = props;
const type = getDefaultType(defaultHsUrl);
this.state = {
selected: type,
selected,
};
if (onChange) {
// FIXME: Supply a second 'initial' param here to flag that this is
// initialising the value rather than from user interaction
// (which sometimes we'll want to ignore). Must be a better way
// to do this.
onChange(type, true);
}
}
updateSelectedType(type) {

View file

@ -26,7 +26,6 @@ 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';
module.exports = React.createClass({
@ -98,6 +97,8 @@ module.exports = React.createClass({
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();
@ -106,6 +107,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');
@ -193,6 +196,7 @@ module.exports = React.createClass({
const ShareDialog = sdk.getComponent("dialogs.ShareDialog");
Modal.createTrackedDialog('share room message dialog', '', ShareDialog, {
target: this.props.mxEvent,
permalinkCreator: this.props.permalinkCreator,
});
this.closeMenu();
},
@ -211,7 +215,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;
@ -251,8 +256,8 @@ module.exports = React.createClass({
);
}
if (isSent && this.props.mxEvent.getType() === 'm.room.message') {
const content = this.props.mxEvent.getContent();
if (isSent && mxEvent.getType() === 'm.room.message') {
const content = mxEvent.getContent();
if (content.msgtype && content.msgtype !== 'm.bad.encrypted' && content.hasOwnProperty('body')) {
forwardButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onForwardClick}>
@ -282,7 +287,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') }
@ -300,11 +305,21 @@ module.exports = React.createClass({
}
}
let permalink;
if (this.props.permalinkCreator) {
permalink = this.props.permalinkCreator.forEvent(
this.props.mxEvent.getRoomId(),
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>
);
@ -318,12 +333,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>
);

View file

@ -271,6 +271,27 @@ module.exports = React.createClass({
);
},
_onClickSettings: function() {
dis.dispatch({
action: 'open_room_settings',
room_id: this.props.room.roomId,
});
if (this.props.onFinished) {
this.props.onFinished();
}
},
_renderSettingsMenu: function() {
return (
<div>
<div className="mx_RoomTileContextMenu_tag_field" onClick={this._onClickSettings} >
<img className="mx_RoomTileContextMenu_tag_icon" src={require("../../../../res/img/icons-settings-room.svg")} width="15" height="15" />
{ _t('Settings') }
</div>
</div>
);
},
_renderLeaveMenu: function(membership) {
if (!membership) {
return null;
@ -350,7 +371,11 @@ module.exports = React.createClass({
// Can't set notif level or tags on non-join rooms
if (myMembership !== 'join') {
return this._renderLeaveMenu(myMembership);
return <div>
{ this._renderLeaveMenu(myMembership) }
<hr className="mx_RoomTileContextMenu_separator" />
{ this._renderSettingsMenu() }
</div>;
}
return (
@ -360,6 +385,8 @@ module.exports = React.createClass({
{ this._renderLeaveMenu(myMembership) }
<hr className="mx_RoomTileContextMenu_separator" />
{ this._renderRoomTagMenu() }
<hr className="mx_RoomTileContextMenu_separator" />
{ this._renderSettingsMenu() }
</div>
);
},

View file

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

View file

@ -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} />;
}
}
@ -138,12 +133,8 @@ class SendCustomEvent extends GenericEditor {
<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 +214,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 +289,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}

View file

@ -34,13 +34,15 @@ export default class LogoutDialog extends React.Component {
this._onSetRecoveryMethodClick = this._onSetRecoveryMethodClick.bind(this);
this._onLogoutConfirm = this._onLogoutConfirm.bind(this);
const shouldLoadBackupStatus = !MatrixClientPeg.get().getKeyBackupEnabled();
this.state = {
loading: false,
loading: shouldLoadBackupStatus,
backupInfo: null,
error: null,
};
if (!MatrixClientPeg.get().getKeyBackupEnabled()) {
if (shouldLoadBackupStatus) {
this._loadBackupStatus();
}
}
@ -84,9 +86,17 @@ export default class LogoutDialog extends React.Component {
}
_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
this.props.onFinished();

View file

@ -18,11 +18,12 @@ 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 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";
import MatrixClientPeg from "../../../MatrixClientPeg";
export default class RoomSettingsDialog extends React.Component {
static propTypes = {
@ -60,9 +61,10 @@ export default class RoomSettingsDialog extends React.Component {
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const roomName = MatrixClientPeg.get().getRoom(this.props.roomId).name;
return (
<BaseDialog className='mx_RoomSettingsDialog' hasCancel={true}
onFinished={this.props.onFinished} title={_t("Room Settings")}>
onFinished={this.props.onFinished} title={_t("Room Settings - %(roomName)s", {roomName})}>
<div className='ms_SettingsDialog_content'>
<TabbedView tabs={this._getTabs()} />
</div>

View file

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

View file

@ -20,7 +20,7 @@ import {Room, User, Group, RoomMember, MatrixEvent} from 'matrix-js-sdk';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import QRCode from 'qrcode-react';
import {makeEventPermalink, makeGroupPermalink, makeRoomPermalink, makeUserPermalink} from "../../../matrix-to";
import {RoomPermalinkCreator, makeGroupPermalink, makeUserPermalink} from "../../../matrix-to";
import * as ContextualMenu from "../../structures/ContextualMenu";
const socials = [
@ -123,6 +123,14 @@ 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});
}
}
render() {
let title;
let matrixToUrl;
@ -146,9 +154,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 +177,9 @@ export default class ShareDialog extends React.Component {
</div>;
if (this.state.linkSpecificEvent) {
matrixToUrl = makeEventPermalink(this.props.target.getRoomId(), this.props.target.getId());
matrixToUrl = this.props.permalinkCreator.forEvent(this.props.target.getId());
} else {
matrixToUrl = makeRoomPermalink(this.props.target.getRoomId());
matrixToUrl = this.props.permalinkCreator.forRoom();
}
}

View file

@ -18,15 +18,15 @@ 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 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";
export default class UserSettingsDialog extends React.Component {
@ -45,39 +45,39 @@ 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} />,
));
return tabs;

View file

@ -131,8 +131,8 @@ export default class NetworkDropdown extends React.Component {
const options = [];
let servers = [];
if (this.props.config.servers) {
servers = servers.concat(this.props.config.servers);
if (this.props.config.roomDirectory.servers) {
servers = servers.concat(this.props.config.roomDirectory.servers);
}
if (servers.indexOf(MatrixClientPeg.getHomeServerName()) == -1) {

View file

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

View file

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

View file

@ -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} 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
@ -32,6 +32,7 @@ export default class ReplyThread extends React.Component {
parentEv: PropTypes.instanceOf(MatrixEvent),
// called when the ReplyThread contents has changed, including EventTiles thereof
onWidgetLoad: PropTypes.func.isRequired,
permalinkCreator: PropTypes.object.isRequired,
};
static contextTypes = {
@ -85,7 +86,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 +95,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 +160,12 @@ export default class ReplyThread extends React.Component {
};
}
static makeThread(parentEv, onWidgetLoad, ref) {
static makeThread(parentEv, onWidgetLoad, permalinkCreator, ref) {
if (!ReplyThread.getParentEventId(parentEv)) {
return <div />;
}
return <ReplyThread parentEv={parentEv} onWidgetLoad={onWidgetLoad} ref={ref} />;
return <ReplyThread parentEv={parentEv} onWidgetLoad={onWidgetLoad}
ref={ref} permalinkCreator={permalinkCreator} />;
}
componentWillMount() {
@ -294,6 +296,7 @@ export default class ReplyThread extends React.Component {
<EventTile mxEvent={ev}
tileShape="reply"
onWidgetLoad={this.props.onWidgetLoad}
permalinkCreator={this.props.permalinkCreator}
isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")} />
</blockquote>;
});

View file

@ -150,6 +150,7 @@ export default React.createClass({
const classes = classNames('mx_RoomTile mx_RoomTile_highlight', {
'mx_RoomTile_menuDisplayed': this.state.menuDisplayed,
'mx_RoomTile_selected': this.state.selected,
'mx_GroupInviteTile': true,
});
return (

View file

@ -68,7 +68,9 @@ export default React.createClass({
render() {
const GroupTile = sdk.getComponent('groups.GroupTile');
return <div className="mx_GroupPublicity_toggle">
<GroupTile groupId={this.props.groupId} showDescription={false} avatarHeight={40} />
<GroupTile groupId={this.props.groupId} showDescription={false}
avatarHeight={40} draggable={false}
/>
<ToggleSwitch checked={this.state.isGroupPublicised}
disabled={!this.state.ready || this.state.busy}
onChange={this._onPublicityToggle} />

View file

@ -33,6 +33,7 @@ const GroupTile = React.createClass({
showDescription: PropTypes.bool,
// Height of the group avatar in pixels
avatarHeight: PropTypes.number,
draggable: PropTypes.bool,
},
contextTypes: {
@ -49,6 +50,7 @@ const GroupTile = React.createClass({
return {
showDescription: true,
avatarHeight: 50,
draggable: true,
};
},
@ -78,54 +80,54 @@ const GroupTile = React.createClass({
<div className="mx_GroupTile_desc">{ profile.shortDescription }</div> :
<div />;
const httpUrl = profile.avatarUrl ? this.context.matrixClient.mxcUrlToHttp(
profile.avatarUrl, avatarHeight, avatarHeight, "crop",
) : null;
profile.avatarUrl, avatarHeight, avatarHeight, "crop") : null;
let avatarElement = (
<div className="mx_GroupTile_avatar">
<BaseAvatar
name={name}
idName={this.props.groupId}
url={httpUrl}
width={avatarHeight}
height={avatarHeight} />
</div>
);
if (this.props.draggable) {
const avatarClone = avatarElement;
avatarElement = (
<Droppable droppableId="my-groups-droppable" type="draggable-TagTile">
{ (droppableProvided, droppableSnapshot) => (
<div ref={droppableProvided.innerRef}>
<Draggable
key={"GroupTile " + this.props.groupId}
draggableId={"GroupTile " + this.props.groupId}
index={this.props.groupId}
type="draggable-TagTile"
>
{ (provided, snapshot) => (
<div>
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
{avatarClone}
</div>
{ /* Instead of a blank placeholder, use a copy of the avatar itself. */ }
{ provided.placeholder ? avatarClone : <div /> }
</div>
) }
</Draggable>
</div>
) }
</Droppable>
);
}
// XXX: Use onMouseDown as a workaround for https://github.com/atlassian/react-beautiful-dnd/issues/273
// instead of onClick. Otherwise we experience https://github.com/vector-im/riot-web/issues/6156
return <AccessibleButton className="mx_GroupTile" onMouseDown={this.onMouseDown} onClick={nop}>
<Droppable droppableId="my-groups-droppable" type="draggable-TagTile">
{ (droppableProvided, droppableSnapshot) => (
<div ref={droppableProvided.innerRef}>
<Draggable
key={"GroupTile " + this.props.groupId}
draggableId={"GroupTile " + this.props.groupId}
index={this.props.groupId}
type="draggable-TagTile"
>
{ (provided, snapshot) => (
<div>
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<div className="mx_GroupTile_avatar">
<BaseAvatar
name={name}
idName={this.props.groupId}
url={httpUrl}
width={avatarHeight}
height={avatarHeight} />
</div>
</div>
{ /* Instead of a blank placeholder, use a copy of the avatar itself. */ }
{ provided.placeholder ?
<div className="mx_GroupTile_avatar">
<BaseAvatar
name={name}
idName={this.props.groupId}
url={httpUrl}
width={avatarHeight}
height={avatarHeight} />
</div> :
<div />
}
</div>
) }
</Draggable>
</div>
) }
</Droppable>
{ avatarElement }
<div className="mx_GroupTile_profile">
<div className="mx_GroupTile_name">{ name }</div>
{ descElement }

View file

@ -18,8 +18,9 @@ import React from 'react';
import PropTypes from 'prop-types';
import dis from '../../../dispatcher';
import { makeEventPermalink } from '../../../matrix-to';
import { RoomPermalinkCreator } from '../../../matrix-to';
import { _t } from '../../../languageHandler';
import MatrixClientPeg from '../../../MatrixClientPeg';
module.exports = React.createClass({
displayName: 'RoomCreate',
@ -47,13 +48,17 @@ module.exports = React.createClass({
if (predecessor === undefined) {
return <div />; // We should never have been instaniated in this case
}
const prevRoom = MatrixClientPeg.get().getRoom(predecessor['room_id']);
const permalinkCreator = new RoomPermalinkCreator(prevRoom);
permalinkCreator.load();
const predecessorPermalink = permalinkCreator.forEvent(predecessor['event_id']);
return <div className="mx_CreateEvent">
<img className="mx_CreateEvent_image" src={require("../../../../res/img/room-continuation.svg")} />
<div className="mx_CreateEvent_header">
{_t("This room is a continuation of another conversation.")}
</div>
<a className="mx_CreateEvent_link"
href={makeEventPermalink(predecessor['room_id'], predecessor['event_id'])}
href={predecessorPermalink}
onClick={this._onLinkClicked}
>
{_t("Click here to see older messages.")}

View file

@ -97,7 +97,7 @@ export default React.createClass({
render() {
const EmojiText = sdk.getComponent('elements.EmojiText');
const {mxEvent} = this.props;
const colorNumber = hashCode(mxEvent.getSender()) % 8;
const colorNumber = (hashCode(mxEvent.getSender()) % 8) + 1;
const name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender();
const {msgtype} = mxEvent.getContent();

View file

@ -1,6 +1,6 @@
/*
Copyright 2016 OpenMarket 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.
@ -15,112 +15,55 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import Promise from 'bluebird';
const React = require('react');
import PropTypes from 'prop-types';
const ObjectUtils = require("../../../ObjectUtils");
const MatrixClientPeg = require('../../../MatrixClientPeg');
const sdk = require("../../../index");
import { _t } from '../../../languageHandler';
import Field from "../elements/Field";
import ErrorDialog from "../dialogs/ErrorDialog";
const Modal = require("../../../Modal");
module.exports = React.createClass({
displayName: 'AliasSettings',
propTypes: {
export default class AliasSettings extends React.Component {
static propTypes = {
roomId: PropTypes.string.isRequired,
canSetCanonicalAlias: PropTypes.bool.isRequired,
canSetAliases: PropTypes.bool.isRequired,
aliasEvents: PropTypes.array, // [MatrixEvent]
canonicalAliasEvent: PropTypes.object, // MatrixEvent
},
};
getDefaultProps: function() {
return {
canSetAliases: false,
canSetCanonicalAlias: false,
aliasEvents: [],
};
},
static defaultProps = {
canSetAliases: false,
canSetCanonicalAlias: false,
aliasEvents: [],
};
getInitialState: function() {
return this.recalculateState(this.props.aliasEvents, this.props.canonicalAliasEvent);
},
recalculateState: function(aliasEvents, canonicalAliasEvent) {
aliasEvents = aliasEvents || [];
constructor(props) {
super(props);
const state = {
domainToAliases: {}, // { domain.com => [#alias1:domain.com, #alias2:domain.com] }
remoteDomains: [], // [ domain.com, foobar.com ]
canonicalAlias: null, // #canonical:domain.com
updatingCanonicalAlias: false,
newItem: "",
};
const localDomain = MatrixClientPeg.get().getDomain();
state.domainToAliases = this.aliasEventsToDictionary(aliasEvents);
state.domainToAliases = this.aliasEventsToDictionary(props.aliasEvents || []);
state.remoteDomains = Object.keys(state.domainToAliases).filter((domain) => {
return domain !== localDomain && state.domainToAliases[domain].length > 0;
});
if (canonicalAliasEvent) {
state.canonicalAlias = canonicalAliasEvent.getContent().alias;
if (props.canonicalAliasEvent) {
state.canonicalAlias = props.canonicalAliasEvent.getContent().alias;
}
return state;
},
this.state = state;
}
saveSettings: function() {
let promises = [];
// save new aliases for m.room.aliases
const aliasOperations = this.getAliasOperations();
for (let i = 0; i < aliasOperations.length; i++) {
const alias_operation = aliasOperations[i];
console.log("alias %s %s", alias_operation.place, alias_operation.val);
switch (alias_operation.place) {
case 'add':
promises.push(
MatrixClientPeg.get().createAlias(
alias_operation.val, this.props.roomId,
),
);
break;
case 'del':
promises.push(
MatrixClientPeg.get().deleteAlias(
alias_operation.val,
),
);
break;
default:
console.log("Unknown alias operation, ignoring: " + alias_operation.place);
}
}
let oldCanonicalAlias = null;
if (this.props.canonicalAliasEvent) {
oldCanonicalAlias = this.props.canonicalAliasEvent.getContent().alias;
}
const newCanonicalAlias = this.state.canonicalAlias;
if (this.props.canSetCanonicalAlias && oldCanonicalAlias !== newCanonicalAlias) {
console.log("AliasSettings: Updating canonical alias");
promises = [Promise.all(promises).then(
MatrixClientPeg.get().sendStateEvent(
this.props.roomId, "m.room.canonical_alias", {
alias: newCanonicalAlias,
}, "",
),
)];
}
return promises;
},
aliasEventsToDictionary: function(aliasEvents) { // m.room.alias events
aliasEventsToDictionary(aliasEvents) { // m.room.alias events
const dict = {};
aliasEvents.forEach((event) => {
dict[event.getStateKey()] = (
@ -128,35 +71,72 @@ module.exports = React.createClass({
);
});
return dict;
},
}
isAliasValid: function(alias) {
isAliasValid(alias) {
// XXX: FIXME https://github.com/matrix-org/matrix-doc/issues/668
return (alias.match(/^#([^\/:,]+?):(.+)$/) && encodeURI(alias) === alias);
},
return (alias.match(/^#([^/:,]+?):(.+)$/) && encodeURI(alias) === alias);
}
getAliasOperations: function() {
const oldAliases = this.aliasEventsToDictionary(this.props.aliasEvents);
return ObjectUtils.getKeyValueArrayDiffs(oldAliases, this.state.domainToAliases);
},
changeCanonicalAlias(alias) {
if (!this.props.canSetCanonicalAlias) return;
onNewAliasChanged: function(value) {
this.setState({
canonicalAlias: alias,
updatingCanonicalAlias: true,
});
const eventContent = {};
if (alias) eventContent["alias"] = alias;
MatrixClientPeg.get().sendStateEvent(this.props.roomId, "m.room.canonical_alias",
eventContent, "").catch((err) => {
console.error(err);
Modal.createTrackedDialog('Error updating main address', '', ErrorDialog, {
title: _t("Error updating main address"),
description: _t(
"There was an error updating the room's main address. It may not be allowed by the server " +
"or a temporary failure occurred.",
),
});
}).finally(() => {
this.setState({updatingCanonicalAlias: false});
});
}
onNewAliasChanged = (value) => {
this.setState({newAlias: value});
},
};
onLocalAliasAdded: function(alias) {
onLocalAliasAdded = (alias) => {
if (!alias || alias.length === 0) return; // ignore attempts to create blank aliases
const localDomain = MatrixClientPeg.get().getDomain();
if (!alias.includes(':')) alias += ':' + localDomain;
if (this.isAliasValid(alias) && alias.endsWith(localDomain)) {
this.state.domainToAliases[localDomain] = this.state.domainToAliases[localDomain] || [];
this.state.domainToAliases[localDomain].push(alias);
MatrixClientPeg.get().createAlias(alias, this.props.roomId).then(() => {
const localAliases = this.state.domainToAliases[localDomain] || [];
const domainAliases = Object.assign({}, this.state.domainToAliases);
domainAliases[localDomain] = [...localAliases, alias];
this.setState({
domainToAliases: this.state.domainToAliases,
// Reset the add field
newAlias: "",
this.setState({
domainToAliases: domainAliases,
// Reset the add field
newAlias: "",
});
if (!this.state.canonicalAlias) {
this.changeCanonicalAlias(alias);
}
}).catch((err) => {
console.error(err);
Modal.createTrackedDialog('Error creating alias', '', ErrorDialog, {
title: _t("Error creating alias"),
description: _t(
"There was an error creating that alias. It may not be allowed by the server " +
"or a temporary failure occurred.",
),
});
});
} else {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
@ -165,130 +145,102 @@ module.exports = React.createClass({
description: _t('\'%(alias)s\' is not a valid format for an alias', { alias: alias }),
});
}
};
if (!this.props.canonicalAlias) {
this.setState({
canonicalAlias: alias,
});
}
},
onLocalAliasChanged: function(alias, index) {
if (alias === "") return; // hit the delete button to delete please
onLocalAliasDeleted = (index) => {
const localDomain = MatrixClientPeg.get().getDomain();
if (!alias.includes(':')) alias += ':' + localDomain;
if (this.isAliasValid(alias) && alias.endsWith(localDomain)) {
this.state.domainToAliases[localDomain][index] = alias;
} else {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Invalid address format', '', ErrorDialog, {
title: _t('Invalid address format'),
description: _t('\'%(alias)s\' is not a valid format for an address', { alias: alias }),
const alias = this.state.domainToAliases[localDomain][index];
// TODO: In future, we should probably be making sure that the alias actually belongs
// to this room. See https://github.com/vector-im/riot-web/issues/7353
MatrixClientPeg.get().deleteAlias(alias).then(() => {
const localAliases = this.state.domainToAliases[localDomain].filter((a) => a !== alias);
const domainAliases = Object.assign({}, this.state.domainToAliases);
domainAliases[localDomain] = localAliases;
this.setState({domainToAliases: domainAliases});
if (this.state.canonicalAlias === alias) {
this.changeCanonicalAlias(null);
}
}).catch((err) => {
console.error(err);
Modal.createTrackedDialog('Error removing alias', '', ErrorDialog, {
title: _t("Error removing alias"),
description: _t(
"There was an error removing that alias. It may no longer exist or a temporary " +
"error occurred.",
),
});
}
},
onLocalAliasDeleted: function(index) {
const localDomain = MatrixClientPeg.get().getDomain();
// It's a bit naughty to directly manipulate this.state, and React would
// normally whine at you, but it can't see us doing the splice. Given we
// promptly setState anyway, it's just about acceptable. The alternative
// would be to arbitrarily deepcopy to a temp variable and then setState
// that, but why bother when we can cut this corner.
const alias = this.state.domainToAliases[localDomain].splice(index, 1);
this.setState({
domainToAliases: this.state.domainToAliases,
});
if (this.props.canonicalAlias === alias) {
this.setState({
canonicalAlias: null,
});
}
},
};
onCanonicalAliasChange: function(event) {
this.setState({
canonicalAlias: event.target.value,
});
},
onCanonicalAliasChange = (event) => {
this.changeCanonicalAlias(event.target.value);
};
render: function() {
const self = this;
const EditableText = sdk.getComponent("elements.EditableText");
render() {
const EditableItemList = sdk.getComponent("elements.EditableItemList");
const localDomain = MatrixClientPeg.get().getDomain();
let canonical_alias_section;
if (this.props.canSetCanonicalAlias) {
let found = false;
const canonicalValue = this.state.canonicalAlias || "";
canonical_alias_section = (
<Field onChange={this.onCanonicalAliasChange} value={canonicalValue}
element='select' id='canonicalAlias' label={_t('Main address')}>
<option value="" key="unset">{ _t('not specified') }</option>
{
Object.keys(self.state.domainToAliases).map((domain, i) => {
return self.state.domainToAliases[domain].map((alias, j) => {
if (alias === this.state.canonicalAlias) found = true;
return (
<option value={alias} key={i + "_" + j}>
{ alias }
</option>
);
});
})
}
{
found || !this.stateCanonicalAlias ? '' :
<option value={ this.state.canonicalAlias } key='arbitrary'>
{ this.state.canonicalAlias }
</option>
}
</Field>
);
} else {
canonical_alias_section = (
<b>{ this.state.canonicalAlias || _t('not set') }</b>
);
}
let found = false;
const canonicalValue = this.state.canonicalAlias || "";
const canonicalAliasSection = (
<Field onChange={this.onCanonicalAliasChange} value={canonicalValue}
disabled={this.state.updatingCanonicalAlias || !this.props.canSetCanonicalAlias}
element='select' id='canonicalAlias' label={_t('Main address')}>
<option value="" key="unset">{ _t('not specified') }</option>
{
Object.keys(this.state.domainToAliases).map((domain, i) => {
return this.state.domainToAliases[domain].map((alias, j) => {
if (alias === this.state.canonicalAlias) found = true;
return (
<option value={alias} key={i + "_" + j}>
{ alias }
</option>
);
});
})
}
{
found || !this.state.canonicalAlias ? '' :
<option value={ this.state.canonicalAlias } key='arbitrary'>
{ this.state.canonicalAlias }
</option>
}
</Field>
);
let remote_aliases_section;
let remoteAliasesSection;
if (this.state.remoteDomains.length) {
remote_aliases_section = (
remoteAliasesSection = (
<div>
<div>
{ _t("Remote addresses for this room:") }
</div>
<div>
<ul>
{ this.state.remoteDomains.map((domain, i) => {
return this.state.domainToAliases[domain].map(function(alias, j) {
return (
<div key={i + "_" + j}>
<EditableText
className="mx_AliasSettings_alias mx_AliasSettings_editable"
blurToCancel={false}
editable={false}
initialValue={alias} />
</div>
);
return this.state.domainToAliases[domain].map((alias, j) => {
return <li key={i + "_" + j}>{alias}</li>;
});
}) }
</div>
</ul>
</div>
);
}
return (
<div className='mx_AliasSettings'>
{canonical_alias_section}
{canonicalAliasSection}
<EditableItemList
className={"mx_RoomSettings_localAliases"}
items={this.state.domainToAliases[localDomain] || []}
newItem={this.state.newAlias}
onNewItemChanged={this.onNewAliasChanged}
canRemove={this.props.canSetAliases}
canEdit={this.props.canSetAliases}
onItemAdded={this.onLocalAliasAdded}
onItemEdited={this.onLocalAliasChanged}
onItemRemoved={this.onLocalAliasDeleted}
itemsLabel={_t('Local addresses for this room:')}
noItemsLabel={_t('This room has no local addresses')}
@ -296,10 +248,8 @@ module.exports = React.createClass({
'New address (e.g. #foo:%(localDomain)s)', {localDomain: localDomain},
)}
/>
{ remote_aliases_section }
{remoteAliasesSection}
</div>
);
},
});
}
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2017 New Vector Ltd.
Copyright 2017, 2019 New Vector Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -20,61 +20,50 @@ import {MatrixEvent, MatrixClient} from 'matrix-js-sdk';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import Modal from '../../../Modal';
import isEqual from 'lodash/isEqual';
import ErrorDialog from "../dialogs/ErrorDialog";
const GROUP_ID_REGEX = /\+\S+:\S+/;
module.exports = React.createClass({
displayName: 'RelatedGroupSettings',
propTypes: {
export default class RelatedGroupSettings extends React.Component {
static propTypes = {
roomId: PropTypes.string.isRequired,
canSetRelatedGroups: PropTypes.bool.isRequired,
relatedGroupsEvent: PropTypes.instanceOf(MatrixEvent),
},
};
contextTypes: {
static contextTypes = {
matrixClient: PropTypes.instanceOf(MatrixClient),
},
};
getDefaultProps: function() {
return {
canSetRelatedGroups: false,
static defaultProps = {
canSetRelatedGroups: false,
};
constructor(props) {
super(props);
this.state = {
newGroupId: "",
newGroupsList: props.relatedGroupsEvent ? (props.relatedGroupsEvent.getContent().groups || []) : [],
};
},
}
getInitialState: function() {
return {
newGroupsList: this.getInitialGroupList(),
newGroupId: null,
};
},
updateGroups(newGroupsList) {
this.context.matrixClient.sendStateEvent(this.props.roomId, 'm.room.related_groups', {
groups: newGroupsList,
}, '').catch((err) => {
console.error(err);
Modal.createTrackedDialog('Error updating flair', '', ErrorDialog, {
title: _t("Error updating flair"),
description: _t(
"There was an error updating the flair for this room. The server may not allow it or " +
"a temporary error occurred.",
),
});
});
}
getInitialGroupList: function() {
return this.props.relatedGroupsEvent ? (this.props.relatedGroupsEvent.getContent().groups || []) : [];
},
needsSaving: function() {
const cli = this.context.matrixClient;
const room = cli.getRoom(this.props.roomId);
if (!room.currentState.maySendStateEvent('m.room.related_groups', cli.getUserId())) return false;
return !isEqual(this.getInitialGroupList(), this.state.newGroupsList);
},
saveSettings: function() {
if (!this.needsSaving()) return Promise.resolve();
return this.context.matrixClient.sendStateEvent(
this.props.roomId,
'm.room.related_groups',
{
groups: this.state.newGroupsList,
},
'',
);
},
validateGroupId: function(groupId) {
validateGroupId(groupId) {
if (!GROUP_ID_REGEX.test(groupId)) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Invalid related community ID', '', ErrorDialog, {
@ -84,38 +73,32 @@ module.exports = React.createClass({
return false;
}
return true;
},
}
onNewGroupChanged: function(newGroupId) {
onNewGroupChanged = (newGroupId) => {
this.setState({ newGroupId });
},
};
onGroupAdded: function(groupId) {
onGroupAdded = (groupId) => {
if (groupId.length === 0 || !this.validateGroupId(groupId)) {
return;
}
const newGroupsList = [...this.state.newGroupsList, groupId];
this.setState({
newGroupsList: this.state.newGroupsList.concat([groupId]),
newGroupsList: newGroupsList,
newGroupId: '',
});
},
this.updateGroups(newGroupsList);
};
onGroupEdited: function(groupId, index) {
if (groupId.length === 0 || !this.validateGroupId(groupId)) {
return;
}
this.setState({
newGroupsList: Object.assign(this.state.newGroupsList, {[index]: groupId}),
});
},
onGroupDeleted: function(index) {
const newGroupsList = this.state.newGroupsList.slice();
newGroupsList.splice(index, 1);
onGroupDeleted = (index) => {
const group = this.state.newGroupsList[index];
const newGroupsList = this.state.newGroupsList.filter((g) => g !== group);
this.setState({ newGroupsList });
},
this.updateGroups(newGroupsList);
};
render: function() {
render() {
const localDomain = this.context.matrixClient.getDomain();
const EditableItemList = sdk.getComponent('elements.EditableItemList');
return <div>
@ -123,10 +106,10 @@ module.exports = React.createClass({
items={this.state.newGroupsList}
className={"mx_RelatedGroupSettings"}
newItem={this.state.newGroupId}
canRemove={this.props.canSetRelatedGroups}
canEdit={this.props.canSetRelatedGroups}
onNewItemChanged={this.onNewGroupChanged}
onItemAdded={this.onGroupAdded}
onItemEdited={this.onGroupEdited}
onItemRemoved={this.onGroupDeleted}
itemsLabel={_t('Showing flair for these communities:')}
noItemsLabel={_t('This room is not showing flair for any communities')}
@ -135,5 +118,5 @@ module.exports = React.createClass({
)}
/>
</div>;
},
});
}
}

View file

@ -32,7 +32,6 @@ import withMatrixClient from '../../../wrappers/withMatrixClient';
const ContextualMenu = require('../../structures/ContextualMenu');
import dis from '../../../dispatcher';
import {makeEventPermalink} from "../../../matrix-to";
import SettingsStore from "../../../settings/SettingsStore";
import {EventStatus} from 'matrix-js-sdk';
@ -65,6 +64,7 @@ const stateEventTileTypes = {
'm.room.tombstone': 'messages.TextualEvent',
'm.room.join_rules': 'messages.TextualEvent',
'm.room.guest_access': 'messages.TextualEvent',
'm.room.related_groups': 'messages.TextualEvent',
};
function getHandlerTile(ev) {
@ -320,14 +320,18 @@ module.exports = withMatrixClient(React.createClass({
const {tile, replyThread} = this.refs;
let e2eInfoCallback = null;
if (this.props.mxEvent.isEncrypted()) e2eInfoCallback = () => this.onCryptoClicked();
ContextualMenu.createMenu(MessageContextMenu, {
chevronOffset: 10,
mxEvent: this.props.mxEvent,
left: x,
top: y,
permalinkCreator: this.props.permalinkCreator,
eventTileOps: tile && tile.getEventTileOps ? tile.getEventTileOps() : undefined,
collapseReplyThread: replyThread && replyThread.canCollapse() ? replyThread.collapse : undefined,
e2eInfoCallback: () => this.onCryptoClicked(),
e2eInfoCallback: e2eInfoCallback,
onFinished: function() {
self.setState({menu: false});
},
@ -540,7 +544,10 @@ module.exports = withMatrixClient(React.createClass({
mx_EventTile_redacted: isRedacted,
});
const permalink = makeEventPermalink(this.props.mxEvent.getRoomId(), this.props.mxEvent.getId());
let permalink = "#";
if (this.props.permalinkCreator) {
permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId());
}
const readAvatars = this.getReadAvatars();
@ -693,6 +700,15 @@ module.exports = withMatrixClient(React.createClass({
case 'reply':
case 'reply_preview': {
let thread;
if (this.props.tileShape === 'reply_preview') {
thread = ReplyThread.makeThread(
this.props.mxEvent,
this.props.onWidgetLoad,
this.props.permalinkCreator,
'replyThread',
);
}
return (
<div className={classes}>
{ avatar }
@ -702,10 +718,7 @@ module.exports = withMatrixClient(React.createClass({
{ timestamp }
</a>
{ this._renderE2EPadlock() }
{
this.props.tileShape === 'reply_preview'
&& ReplyThread.makeThread(this.props.mxEvent, this.props.onWidgetLoad, 'replyThread')
}
{ thread }
<EventTileType ref="tile"
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
@ -717,6 +730,12 @@ module.exports = withMatrixClient(React.createClass({
);
}
default: {
const thread = ReplyThread.makeThread(
this.props.mxEvent,
this.props.onWidgetLoad,
this.props.permalinkCreator,
'replyThread',
);
return (
<div className={classes}>
<div className="mx_EventTile_msgOption">
@ -728,7 +747,7 @@ module.exports = withMatrixClient(React.createClass({
{ timestamp }
</a>
{ this._renderE2EPadlock() }
{ ReplyThread.makeThread(this.props.mxEvent, this.props.onWidgetLoad, 'replyThread') }
{ thread }
<EventTileType ref="tile"
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}

View file

@ -980,12 +980,18 @@ module.exports = withMatrixClient(React.createClass({
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
const EmojiText = sdk.getComponent('elements.EmojiText');
let backButton;
if (this.props.member.roomId) {
backButton = (<AccessibleButton className="mx_MemberInfo_cancel"
onClick={this.onCancel}
title={_t('Close')}
/>);
}
return (
<div className="mx_MemberInfo">
<div className="mx_MemberInfo_name">
<AccessibleButton className="mx_MemberInfo_cancel" onClick={this.onCancel}>
<img src={require("../../../../res/img/minimise.svg")} width="10" height="16" className="mx_filterFlipColor" alt={_t('Close')} />
</AccessibleButton>
{ backButton }
{ e2eIconElement }
<EmojiText element="h2">{ memberName }</EmojiText>
</div>

View file

@ -339,12 +339,11 @@ module.exports = React.createClass({
return nameA.localeCompare(nameB);
},
onSearchQueryChanged: function(ev) {
const q = ev.target.value;
onSearchQueryChanged: function(searchQuery) {
this.setState({
searchQuery: q,
filteredJoinedMembers: this._filterMembers(this.state.members, 'join', q),
filteredInvitedMembers: this._filterMembers(this.state.members, 'invite', q),
searchQuery,
filteredJoinedMembers: this._filterMembers(this.state.members, 'join', searchQuery),
filteredInvitedMembers: this._filterMembers(this.state.members, 'invite', searchQuery),
});
},
@ -438,6 +437,7 @@ module.exports = React.createClass({
return <div className="mx_MemberList"><Spinner /></div>;
}
const SearchBox = sdk.getComponent('structures.SearchBox');
const TruncatedList = sdk.getComponent("elements.TruncatedList");
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
@ -445,7 +445,6 @@ module.exports = React.createClass({
const room = cli.getRoom(this.props.roomId);
let inviteButton;
if (room && room.getMyMembership() === 'join') {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
inviteButton =
<AccessibleButton className="mx_MemberList_invite" onClick={this.onInviteButtonClick}>
@ -477,9 +476,10 @@ module.exports = React.createClass({
{ invitedSection }
</div>
</GeminiScrollbarWrapper>
<input className="mx_MemberList_query mx_textinput_icon mx_textinput_search" id="mx_MemberList_query" type="text"
onChange={this.onSearchQueryChanged} value={this.state.searchQuery}
placeholder={_t('Filter room members')} />
<SearchBox className="mx_MemberList_query mx_textinput_icon mx_textinput_search"
placeholder={ _t('Filter room members') }
onSearch={ this.onSearchQueryChanged } />
</div>
);
},

View file

@ -68,6 +68,7 @@ export default class MessageComposer extends React.Component {
showFormatting: SettingsStore.getValue('MessageComposer.showFormatting'),
isQuoting: Boolean(RoomViewStore.getQuotingEvent()),
tombstone: this._getRoomTombstone(),
canSendMessages: this.props.room.maySendMessage(),
};
}
@ -120,6 +121,9 @@ export default class MessageComposer extends React.Component {
if (ev.getType() === 'm.room.tombstone') {
this.setState({tombstone: this._getRoomTombstone()});
}
if (ev.getType() === 'm.room.power_levels') {
this.setState({canSendMessages: this.props.room.maySendMessage()});
}
}
_getRoomTombstone() {
@ -257,6 +261,8 @@ export default class MessageComposer extends React.Component {
}
onInputStateChanged(inputState) {
// Merge the new input state with old to support partial updates
inputState = Object.assign({}, this.state.inputState, inputState);
this.setState({inputState});
}
@ -357,38 +363,7 @@ export default class MessageComposer extends React.Component {
</AccessibleButton>;
}
const canSendMessages = !this.state.tombstone &&
this.props.room.maySendMessage();
// TODO: Remove temporary logging for riot-web#7838
// Note: we rip apart the power level event ourselves because we don't want to
// log too much data about it - just the bits we care about. Many of the variables
// logged here are to help figure out where in the stack the 'cannot post in room'
// warning is coming from. This means logging various numbers from the PL event to
// verify RoomState._maySendEventOfType is doing the right thing.
const room = this.props.room;
const plEvent = room.currentState.getStateEvents('m.room.power_levels', '');
let plEventString = "<no power level event>";
if (plEvent) {
const content = plEvent.getContent();
if (!content) {
plEventString = "<no event content>";
} else {
const stringifyFalsey = (v) => v === null ? '<null>' : (v === undefined ? '<undefined>' : v);
const actualUserPl = stringifyFalsey(content.users ? content.users[room.myUserId] : "<no users in content>");
const usersPl = stringifyFalsey(content.users_default);
const actualEventPl = stringifyFalsey(content.events ? content.events['m.room.message'] : "<no events in content>");
const eventPl = stringifyFalsey(content.events_default);
plEventString = `actualUserPl=${actualUserPl} defaultUserPl=${usersPl} actualEventPl=${actualEventPl} defaultEventPl=${eventPl}`;
}
}
console.log(
`[riot-web#7838] renderComposer() hasTombstone=${!!this.state.tombstone} maySendMessage=${room.maySendMessage()}` +
` myMembership=${room.getMyMembership()} maySendEvent=${room.currentState.maySendEvent('m.room.message', room.myUserId)}` +
` myUserId=${room.myUserId} roomId=${room.roomId} hasPlEvent=${!!plEvent} powerLevels='${plEventString}'`
);
if (canSendMessages) {
if (!this.state.tombstone && this.state.canSendMessages) {
// This also currently includes the call buttons. Really we should
// check separately for whether we can call, but this is slightly
// complex because of conference calls.
@ -441,7 +416,8 @@ export default class MessageComposer extends React.Component {
room={this.props.room}
placeholder={placeholderText}
onFilesPasted={this.uploadFiles}
onInputStateChanged={this.onInputStateChanged} />,
onInputStateChanged={this.onInputStateChanged}
permalinkCreator={this.props.permalinkCreator} />,
formattingButton,
stickerpickerButton,
uploadButton,
@ -467,8 +443,6 @@ export default class MessageComposer extends React.Component {
</div>
</div>);
} else {
// TODO: Remove temporary logging for riot-web#7838
console.log("[riot-web#7838] Falling back to showing cannot post in room error");
controls.push(
<div key="controls_error" className="mx_MessageComposer_noperm_error">
{ _t('You do not have permission to post to this room') }
@ -499,15 +473,16 @@ export default class MessageComposer extends React.Component {
<div className="mx_MessageComposer_formatbar_wrapper">
<div className="mx_MessageComposer_formatbar">
{ formatButtons }
<div style={{flex: 1}}></div>
<img title={this.state.inputState.isRichTextEnabled ? _t("Turn Markdown on") : _t("Turn Markdown off")}
onMouseDown={this.onToggleMarkdownClicked}
className="mx_MessageComposer_formatbar_markdown mx_filterFlipColor"
src={require(`../../../../res/img/button-md-${!this.state.inputState.isRichTextEnabled}.png`)} />
<div style={{ flex: 1 }}></div>
<AccessibleButton className="mx_MessageComposer_formatbar_markdown mx_MessageComposer_markdownDisabled"
onClick={this.onToggleMarkdownClicked}
title={_t("Markdown is disabled")}
/>
<AccessibleButton element="img" title={_t("Hide Text Formatting Toolbar")}
onClick={this.onToggleFormattingClicked}
className="mx_MessageComposer_formatbar_cancel mx_filterFlipColor"
src={require("../../../../res/img/icon-text-cancel.svg")} />
onClick={this.onToggleFormattingClicked}
className="mx_MessageComposer_formatbar_cancel mx_filterFlipColor"
src={require("../../../../res/img/icon-text-cancel.svg")}
/>
</div>
</div>;
}

View file

@ -60,6 +60,7 @@ import ReplyPreview from "./ReplyPreview";
import RoomViewStore from '../../../stores/RoomViewStore';
import ReplyThread from "../elements/ReplyThread";
import {ContentHelpers} from 'matrix-js-sdk';
import AccessibleButton from '../elements/AccessibleButton';
const EMOJI_UNICODE_TO_SHORTNAME = mapUnicodeToShort();
const REGEX_EMOJI_WHITESPACE = new RegExp('(?:^|\\s)(' + asciiRegexp + ')\\s$');
@ -627,7 +628,6 @@ export default class MessageComposerInput extends React.Component {
}
const inputState = {
marks: editorState.activeMarks,
isRichTextEnabled: this.state.isRichTextEnabled,
blockType,
};
this.props.onInputStateChanged(inputState);
@ -685,20 +685,22 @@ export default class MessageComposerInput extends React.Component {
enableRichtext(enabled: boolean) {
if (enabled === this.state.isRichTextEnabled) return;
let editorState = null;
if (enabled) {
editorState = this.mdToRichEditorState(this.state.editorState);
} else {
editorState = this.richToMdEditorState(this.state.editorState);
}
Analytics.setRichtextMode(enabled);
this.setState({
editorState: this.createEditorState(enabled, editorState),
editorState: this.createEditorState(
enabled,
this.state.editorState,
this.state.isRichTextEnabled,
),
isRichTextEnabled: enabled,
}, ()=>{
}, () => {
this._editor.focus();
if (this.props.onInputStateChanged) {
this.props.onInputStateChanged({
isRichTextEnabled: enabled,
});
}
});
SettingsStore.setValue("MessageComposerInput.isRichTextEnabled", null, SettingLevel.ACCOUNT, enabled);
@ -1193,7 +1195,7 @@ export default class MessageComposerInput extends React.Component {
// Part of Replies fallback support - prepend the text we're sending
// with the text we're replying to
const nestedReply = ReplyThread.getNestedReplyText(replyingToEv);
const nestedReply = ReplyThread.getNestedReplyText(replyingToEv, this.props.permalinkCreator);
if (nestedReply) {
if (content.formatted_body) {
content.formatted_body = nestedReply.html + content.formatted_body;
@ -1582,6 +1584,11 @@ export default class MessageComposerInput extends React.Component {
placeholder = undefined;
}
const markdownClasses = classNames({
mx_MessageComposer_input_markdownIndicator: true,
mx_MessageComposer_markdownDisabled: this.state.isRichTextEnabled,
});
return (
<div className="mx_MessageComposer_input_wrapper" onClick={this.focusComposer}>
<div className="mx_MessageComposer_autocomplete_wrapper">
@ -1596,10 +1603,10 @@ export default class MessageComposerInput extends React.Component {
/>
</div>
<div className={className}>
<img className="mx_MessageComposer_input_markdownIndicator mx_filterFlipColor"
onMouseDown={this.onMarkdownToggleClicked}
title={this.state.isRichTextEnabled ? _t("Markdown is disabled") : _t("Markdown is enabled")}
src={require(`../../../../res/img/button-md-${!this.state.isRichTextEnabled}.png`)} />
<AccessibleButton className={markdownClasses}
onClick={this.onMarkdownToggleClicked}
title={this.state.isRichTextEnabled ? _t("Markdown is disabled") : _t("Markdown is enabled")}
/>
<Editor ref={this._collectEditor}
dir="auto"
className="mx_MessageComposer_editor"

View file

@ -56,6 +56,7 @@ module.exports = React.createClass({
}
if (EventTile.haveTileForEvent(ev)) {
ret.push(<EventTile key={eventId+"+"+j} mxEvent={ev} contextual={contextual} highlights={highlights}
permalinkCreator={this.props.permalinkCreator}
highlightLink={this.props.resultLink}
onWidgetLoad={this.props.onWidgetLoad} />);
}

View file

@ -170,6 +170,7 @@ module.exports = React.createClass({
width={24}
height={24}
resizeMethod="crop"
viewUserOnClick={true}
/>
);
});

View file

@ -102,7 +102,7 @@ export class ExistingEmailAddress extends React.Component {
return (
<div className="mx_ExistingEmailAddress">
<img src={require("../../../../res/img/feather-icons/cancel.svg")} width={14} height={14}
<img src={require("../../../../res/img/feather-customised/cancel.svg")} width={14} height={14}
onClick={this._onRemove} className="mx_ExistingEmailAddress_delete" alt={_t("Remove")} />
<span className="mx_ExistingEmailAddress_email">{this.props.email.address}</span>
</div>

View file

@ -28,7 +28,7 @@ import {
PushRuleVectorState,
ContentRules,
} from '../../../notifications';
import * as SdkConfig from "../../../SdkConfig";
import SdkConfig from "../../../SdkConfig";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
// TODO: this "view" component still has far too much application logic in it,
@ -132,9 +132,9 @@ module.exports = React.createClass({
});
},
onEnableEmailNotificationsChange: function(address, event) {
onEnableEmailNotificationsChange: function(address, checked) {
let emailPusherPromise;
if (event.target.checked) {
if (checked) {
const data = {};
data['brand'] = SdkConfig.get().brand || 'Riot';
emailPusherPromise = UserSettingsStore.addEmailPusher(address, data);

View file

@ -97,7 +97,7 @@ export class ExistingPhoneNumber extends React.Component {
return (
<div className="mx_ExistingPhoneNumber">
<img src={require("../../../../res/img/feather-icons/cancel.svg")} width={14} height={14}
<img src={require("../../../../res/img/feather-customised/cancel.svg")} width={14} height={14}
onClick={this._onRemove} className="mx_ExistingPhoneNumber_delete" alt={_t("Remove")} />
<span className="mx_ExistingPhoneNumber_address">+{this.props.msisdn.address}</span>
</div>

View file

@ -16,11 +16,11 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import {_t} from "../../../../languageHandler";
import MatrixClientPeg from "../../../../MatrixClientPeg";
import sdk from "../../../../index";
import AccessibleButton from "../../elements/AccessibleButton";
import Modal from "../../../../Modal";
import {_t} from "../../../../../languageHandler";
import MatrixClientPeg from "../../../../../MatrixClientPeg";
import sdk from "../../../../..";
import AccessibleButton from "../../../elements/AccessibleButton";
import Modal from "../../../../../Modal";
export default class AdvancedRoomSettingsTab extends React.Component {
static propTypes = {

View file

@ -16,14 +16,14 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import {_t} from "../../../../languageHandler";
import RoomProfileSettings from "../../room_settings/RoomProfileSettings";
import MatrixClientPeg from "../../../../MatrixClientPeg";
import sdk from "../../../../index";
import AccessibleButton from "../../elements/AccessibleButton";
import {_t} from "../../../../../languageHandler";
import RoomProfileSettings from "../../../room_settings/RoomProfileSettings";
import MatrixClientPeg from "../../../../../MatrixClientPeg";
import sdk from "../../../../..";
import AccessibleButton from "../../../elements/AccessibleButton";
import {MatrixClient} from "matrix-js-sdk";
import dis from "../../../../dispatcher";
import LabelledToggleSwitch from "../../elements/LabelledToggleSwitch";
import dis from "../../../../../dispatcher";
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
export default class GeneralRoomSettingsTab extends React.Component {
static childContextTypes = {
@ -68,18 +68,6 @@ export default class GeneralRoomSettingsTab extends React.Component {
});
};
_saveAliases = (e) => {
// TODO: Live modification?
if (!this.refs.aliasSettings) return;
this.refs.aliasSettings.saveSettings();
};
_saveGroups = (e) => {
// TODO: Live modification?
if (!this.refs.flairSettings) return;
this.refs.flairSettings.saveSettings();
};
_onLeaveClick = () => {
dis.dispatch({
action: 'leave_room',
@ -113,12 +101,9 @@ export default class GeneralRoomSettingsTab extends React.Component {
<span className='mx_SettingsTab_subheading'>{_t("Room Addresses")}</span>
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
<AliasSettings ref="aliasSettings" roomId={this.props.roomId}
<AliasSettings roomId={this.props.roomId}
canSetCanonicalAlias={canSetCanonical} canSetAliases={canSetAliases}
canonicalAliasEvent={canonicalAliasEv} aliasEvents={aliasEvents} />
<AccessibleButton onClick={this._saveAliases} kind='primary'>
{_t("Save")}
</AccessibleButton>
</div>
<div className='mx_SettingsTab_section'>
<LabelledToggleSwitch value={this.state.isRoomPublished}
@ -131,12 +116,9 @@ export default class GeneralRoomSettingsTab extends React.Component {
<span className='mx_SettingsTab_subheading'>{_t("Flair")}</span>
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
<RelatedGroupSettings ref="flairSettings" roomId={room.roomId}
<RelatedGroupSettings roomId={room.roomId}
canSetRelatedGroups={canChangeGroups}
relatedGroupsEvent={groupsEvent} />
<AccessibleButton onClick={this._saveGroups} kind='primary'>
{_t("Save")}
</AccessibleButton>
</div>
<span className='mx_SettingsTab_subheading'>{_t("URL Previews")}</span>

View file

@ -16,11 +16,11 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import {_t, _td} from "../../../../languageHandler";
import MatrixClientPeg from "../../../../MatrixClientPeg";
import sdk from "../../../../index";
import AccessibleButton from "../../elements/AccessibleButton";
import Modal from "../../../../Modal";
import {_t, _td} from "../../../../../languageHandler";
import MatrixClientPeg from "../../../../../MatrixClientPeg";
import sdk from "../../../../..";
import AccessibleButton from "../../../elements/AccessibleButton";
import Modal from "../../../../../Modal";
const plEventsToLabels = {
// These will be translated for us later.
@ -116,7 +116,8 @@ export default class RolesRoomSettingsTab extends React.Component {
_onPowerLevelsChanged = (value, powerLevelKey) => {
const client = MatrixClientPeg.get();
const room = client.getRoom(this.props.roomId);
let plContent = room.currentState.getStateEvents('m.room.power_levels', '').getContent() || {};
const plEvent = room.currentState.getStateEvents('m.room.power_levels', '');
let plContent = plEvent ? (plEvent.getContent() || {}) : {};
// Clone the power levels just in case
plContent = Object.assign({}, plContent);
@ -151,7 +152,8 @@ export default class RolesRoomSettingsTab extends React.Component {
const client = MatrixClientPeg.get();
const room = client.getRoom(this.props.roomId);
const plContent = room.currentState.getStateEvents('m.room.power_levels', '').getContent() || {};
const plEvent = room.currentState.getStateEvents('m.room.power_levels', '');
const plContent = plEvent ? (plEvent.getContent() || {}) : {};
const canChangeLevels = room.currentState.mayClientSendStateEvent('m.room.power_levels', client);
const powerLevelDescriptors = {

View file

@ -16,11 +16,11 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import {_t} from "../../../../languageHandler";
import MatrixClientPeg from "../../../../MatrixClientPeg";
import sdk from "../../../../index";
import LabelledToggleSwitch from "../../elements/LabelledToggleSwitch";
import {SettingLevel} from "../../../../settings/SettingsStore";
import {_t} from "../../../../../languageHandler";
import MatrixClientPeg from "../../../../../MatrixClientPeg";
import sdk from "../../../../..";
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
import {SettingLevel} from "../../../../../settings/SettingsStore";
export default class SecurityRoomSettingsTab extends React.Component {
static propTypes = {
@ -43,13 +43,31 @@ export default class SecurityRoomSettingsTab extends React.Component {
const room = MatrixClientPeg.get().getRoom(this.props.roomId);
const state = room.currentState;
const joinRule = state.getStateEvents("m.room.join_rules", "").getContent()['join_rule'];
const guestAccess = state.getStateEvents("m.room.guest_access", "").getContent()['guest_access'];
const history = state.getStateEvents("m.room.history_visibility", "").getContent()['history_visibility'];
const joinRule = this._pullContentPropertyFromEvent(
state.getStateEvents("m.room.join_rules", ""),
'join_rule',
'invite',
);
const guestAccess = this._pullContentPropertyFromEvent(
state.getStateEvents("m.room.guest_access", ""),
'guest_access',
'forbidden',
);
const history = this._pullContentPropertyFromEvent(
state.getStateEvents("m.room.history_visibility", ""),
'history_visibility',
'shared',
);
const encrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.roomId);
this.setState({joinRule, guestAccess, history, encrypted});
}
_pullContentPropertyFromEvent(event, key, defaultValue) {
if (!event || !event.getContent()) return defaultValue;
return event.getContent()[key] || defaultValue;
}
componentWillUnmount(): void {
MatrixClientPeg.get().removeListener("RoomState.events", this._onStateEvent);
}
@ -170,7 +188,7 @@ export default class SecurityRoomSettingsTab extends React.Component {
if (joinRule !== 'public' && guestAccess === 'forbidden') {
guestWarning = (
<div className='mx_SecurityRoomSettingsTab_warning'>
<img src={require("../../../../../res/img/warning.svg")} width={15} height={15} />
<img src={require("../../../../../../res/img/warning.svg")} width={15} height={15} />
<span>
{_t("Guests cannot join this room even if explicitly invited.")}&nbsp;
<a href="" onClick={this._fixGuestAccess}>{_t("Click here to fix")}</a>
@ -183,7 +201,7 @@ export default class SecurityRoomSettingsTab extends React.Component {
if (joinRule === 'public' && !hasAliases) {
aliasWarning = (
<div className='mx_SecurityRoomSettingsTab_warning'>
<img src={require("../../../../../res/img/warning.svg")} width={15} height={15} />
<img src={require("../../../../../../res/img/warning.svg")} width={15} height={15} />
<span>
{_t("To link to this room, please add an alias.")}
</span>

View file

@ -15,14 +15,13 @@ limitations under the License.
*/
import React from 'react';
import {_t} from "../../../../languageHandler";
import {DragDropContext} from "react-beautiful-dnd";
import GroupUserSettings from "../../groups/GroupUserSettings";
import MatrixClientPeg from "../../../../MatrixClientPeg";
import {_t} from "../../../../../languageHandler";
import GroupUserSettings from "../../../groups/GroupUserSettings";
import MatrixClientPeg from "../../../../../MatrixClientPeg";
import PropTypes from "prop-types";
import {MatrixClient} from "matrix-js-sdk";
export default class FlairSettingsTab extends React.Component {
export default class FlairUserSettingsTab extends React.Component {
static childContextTypes = {
matrixClient: PropTypes.instanceOf(MatrixClient),
};
@ -42,9 +41,7 @@ export default class FlairSettingsTab extends React.Component {
<div className="mx_SettingsTab">
<span className="mx_SettingsTab_heading">{_t("Flair")}</span>
<div className="mx_SettingsTab_section">
<DragDropContext>
<GroupUserSettings />
</DragDropContext>
<GroupUserSettings />
</div>
</div>
);

View file

@ -15,21 +15,21 @@ limitations under the License.
*/
import React from 'react';
import {_t} from "../../../../languageHandler";
import ProfileSettings from "../ProfileSettings";
import EmailAddresses from "../EmailAddresses";
import PhoneNumbers from "../PhoneNumbers";
import Field from "../../elements/Field";
import * as languageHandler from "../../../../languageHandler";
import {SettingLevel} from "../../../../settings/SettingsStore";
import SettingsStore from "../../../../settings/SettingsStore";
import LanguageDropdown from "../../elements/LanguageDropdown";
import AccessibleButton from "../../elements/AccessibleButton";
import DeactivateAccountDialog from "../../dialogs/DeactivateAccountDialog";
const PlatformPeg = require("../../../../PlatformPeg");
const sdk = require('../../../../index');
const Modal = require("../../../../Modal");
const dis = require("../../../../dispatcher");
import {_t} from "../../../../../languageHandler";
import ProfileSettings from "../../ProfileSettings";
import EmailAddresses from "../../EmailAddresses";
import PhoneNumbers from "../../PhoneNumbers";
import Field from "../../../elements/Field";
import * as languageHandler from "../../../../../languageHandler";
import {SettingLevel} from "../../../../../settings/SettingsStore";
import SettingsStore from "../../../../../settings/SettingsStore";
import LanguageDropdown from "../../../elements/LanguageDropdown";
import AccessibleButton from "../../../elements/AccessibleButton";
import DeactivateAccountDialog from "../../../dialogs/DeactivateAccountDialog";
const PlatformPeg = require("../../../../../PlatformPeg");
const sdk = require('../../../../..');
const Modal = require("../../../../../Modal");
const dis = require("../../../../../dispatcher");
export default class GeneralUserSettingsTab extends React.Component {
constructor() {
@ -145,7 +145,7 @@ export default class GeneralUserSettingsTab extends React.Component {
<span className="mx_SettingsTab_subheading">{_t("Theme")}</span>
<Field id="theme" label={_t("Theme")} element="select"
value={this.state.theme} onChange={this._onThemeChange}>
<option value="light">{_t("Default theme")}</option>
<option value="light">{_t("Light theme")}</option>
<option value="dark">{_t("Dark theme")}</option>
</Field>
<SettingsFlag name="useCompactLayout" level={SettingLevel.ACCOUNT} />

View file

@ -16,15 +16,15 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import {_t, getCurrentLanguage} from "../../../../languageHandler";
import MatrixClientPeg from "../../../../MatrixClientPeg";
import AccessibleButton from "../../elements/AccessibleButton";
import SdkConfig from "../../../../SdkConfig";
import createRoom from "../../../../createRoom";
const packageJson = require('../../../../../package.json');
const Modal = require("../../../../Modal");
const sdk = require("../../../../index");
const PlatformPeg = require("../../../../PlatformPeg");
import {_t, getCurrentLanguage} from "../../../../../languageHandler";
import MatrixClientPeg from "../../../../../MatrixClientPeg";
import AccessibleButton from "../../../elements/AccessibleButton";
import SdkConfig from "../../../../../SdkConfig";
import createRoom from "../../../../../createRoom";
const packageJson = require('../../../../../../package.json');
const Modal = require("../../../../../Modal");
const sdk = require("../../../../..");
const PlatformPeg = require("../../../../../PlatformPeg");
// if this looks like a release, use the 'version' from package.json; else use
// the git sha. Prepend version with v, to look like riot-web version
@ -45,7 +45,7 @@ const ghVersionLabel = function(repo, token='') {
return <a target="_blank" rel="noopener" href={url}>{ token }</a>;
};
export default class HelpSettingsTab extends React.Component {
export default class HelpUserSettingsTab extends React.Component {
static propTypes = {
closeSettingsFn: PropTypes.func.isRequired,
};
@ -117,7 +117,7 @@ export default class HelpSettingsTab extends React.Component {
}
return (
<div className='mx_SettingsTab_section mx_HelpSettingsTab_versions'>
<div className='mx_SettingsTab_section mx_HelpUserSettingsTab_versions'>
<span className='mx_SettingsTab_subheading'>{_t("Legal")}</span>
<div className='mx_SettingsTab_subsectionText'>
{legalLinks}
@ -190,7 +190,7 @@ export default class HelpSettingsTab extends React.Component {
}
return (
<div className="mx_SettingsTab mx_HelpSettingsTab">
<div className="mx_SettingsTab mx_HelpUserSettingsTab">
<div className="mx_SettingsTab_heading">{_t("Help & About")}</div>
<div className="mx_SettingsTab_section">
<span className='mx_SettingsTab_subheading'>{_t('Bug reporting')}</span>
@ -203,12 +203,12 @@ export default class HelpSettingsTab extends React.Component {
"other users. They do not contain messages.",
)
}
<div className='mx_HelpSettingsTab_debugButton'>
<div className='mx_HelpUserSettingsTab_debugButton'>
<AccessibleButton onClick={this._onBugReport} kind='primary'>
{_t("Submit debug logs")}
</AccessibleButton>
</div>
<div className='mx_HelpSettingsTab_debugButton'>
<div className='mx_HelpUserSettingsTab_debugButton'>
<AccessibleButton onClick={this._onClearCacheAndReload} kind='danger'>
{_t("Clear Cache and Reload")}
</AccessibleButton>
@ -221,7 +221,7 @@ export default class HelpSettingsTab extends React.Component {
{faqText}
</div>
</div>
<div className='mx_SettingsTab_section mx_HelpSettingsTab_versions'>
<div className='mx_SettingsTab_section mx_HelpUserSettingsTab_versions'>
<span className='mx_SettingsTab_subheading'>{_t("Versions")}</span>
<div className='mx_SettingsTab_subsectionText'>
{_t("matrix-react-sdk version:")} {reactSdkVersion}<br />
@ -232,7 +232,7 @@ export default class HelpSettingsTab extends React.Component {
</div>
{this._renderLegal()}
{this._renderCredits()}
<div className='mx_SettingsTab_section mx_HelpSettingsTab_versions'>
<div className='mx_SettingsTab_section mx_HelpUserSettingsTab_versions'>
<span className='mx_SettingsTab_subheading'>{_t("Advanced")}</span>
<div className='mx_SettingsTab_subsectionText'>
{_t("Homeserver is")} {MatrixClientPeg.get().getHomeserverUrl()}<br />

View file

@ -15,11 +15,11 @@ limitations under the License.
*/
import React from 'react';
import {_t} from "../../../../languageHandler";
import {_t} from "../../../../../languageHandler";
import PropTypes from "prop-types";
import SettingsStore, {SettingLevel} from "../../../../settings/SettingsStore";
import LabelledToggleSwitch from "../../elements/LabelledToggleSwitch";
const sdk = require("../../../../index");
import SettingsStore, {SettingLevel} from "../../../../../settings/SettingsStore";
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
const sdk = require("../../../../..");
export class LabsSettingToggle extends React.Component {
static propTypes = {
@ -38,7 +38,7 @@ export class LabsSettingToggle extends React.Component {
}
}
export default class LabsSettingsTab extends React.Component {
export default class LabsUserSettingsTab extends React.Component {
constructor() {
super();
}

View file

@ -15,10 +15,10 @@ limitations under the License.
*/
import React from 'react';
import {_t} from "../../../../languageHandler";
const sdk = require("../../../../index");
import {_t} from "../../../../../languageHandler";
const sdk = require("../../../../..");
export default class NotificationSettingsTab extends React.Component {
export default class NotificationUserSettingsTab extends React.Component {
constructor() {
super();
}
@ -26,7 +26,7 @@ export default class NotificationSettingsTab extends React.Component {
render() {
const Notifications = sdk.getComponent("views.settings.Notifications");
return (
<div className="mx_SettingsTab mx_NotificationSettingsTab">
<div className="mx_SettingsTab mx_NotificationUserSettingsTab">
<div className="mx_SettingsTab_heading">{_t("Notifications")}</div>
<div className="mx_SettingsTab_section mx_SettingsTab_subsectionText">
<Notifications />

View file

@ -15,26 +15,21 @@ limitations under the License.
*/
import React from 'react';
import {_t} from "../../../../languageHandler";
import {SettingLevel} from "../../../../settings/SettingsStore";
import LabelledToggleSwitch from "../../elements/LabelledToggleSwitch";
import SettingsStore from "../../../../settings/SettingsStore";
import Field from "../../elements/Field";
const sdk = require("../../../../index");
const PlatformPeg = require("../../../../PlatformPeg");
import {_t} from "../../../../../languageHandler";
import {SettingLevel} from "../../../../../settings/SettingsStore";
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
import SettingsStore from "../../../../../settings/SettingsStore";
import Field from "../../../elements/Field";
const sdk = require("../../../../..");
const PlatformPeg = require("../../../../../PlatformPeg");
export default class PreferencesSettingsTab extends React.Component {
export default class PreferencesUserSettingsTab extends React.Component {
static COMPOSER_SETTINGS = [
'MessageComposerInput.autoReplaceEmoji',
'MessageComposerInput.suggestEmoji',
'sendTypingNotifications',
];
static ROOM_LIST_SETTINGS = [
'pinUnreadRooms',
'pinMentionedRooms',
];
static TIMELINE_SETTINGS = [
'autoplayGifsAndVideos',
'urlPreviewsEnabled',
@ -49,6 +44,10 @@ export default class PreferencesSettingsTab extends React.Component {
'showDisplaynameChanges',
];
static ROOM_LIST_SETTINGS = [
'RoomList.orderByImportance',
];
static ADVANCED_SETTINGS = [
'alwaysShowEncryptionIcons',
'Pill.shouldShowPillAvatar',
@ -64,24 +63,39 @@ export default class PreferencesSettingsTab extends React.Component {
this.state = {
autoLaunch: false,
autoLaunchSupported: false,
minimizeToTray: true,
minimizeToTraySupported: false,
};
}
async componentWillMount(): void {
const autoLaunchSupported = await PlatformPeg.get().supportsAutoLaunch();
const platform = PlatformPeg.get();
const autoLaunchSupported = await platform.supportsAutoLaunch();
let autoLaunch = false;
if (autoLaunchSupported) {
autoLaunch = await PlatformPeg.get().getAutoLaunchEnabled();
autoLaunch = await platform.getAutoLaunchEnabled();
}
this.setState({autoLaunch, autoLaunchSupported});
const minimizeToTraySupported = await platform.supportsMinimizeToTray();
let minimizeToTray = true;
if (minimizeToTraySupported) {
minimizeToTray = await platform.getMinimizeToTrayEnabled();
}
this.setState({autoLaunch, autoLaunchSupported, minimizeToTraySupported, minimizeToTray});
}
_onAutoLaunchChange = (checked) => {
PlatformPeg.get().setAutoLaunchEnabled(checked).then(() => this.setState({autoLaunch: checked}));
};
_onMinimizeToTrayChange = (checked) => {
PlatformPeg.get().setMinimizeToTrayEnabled(checked).then(() => this.setState({minimizeToTray: checked}));
};
_onAutocompleteDelayChange = (e) => {
SettingsStore.setValue("autocompleteDelay", null, SettingLevel.DEVICE, e.target.value);
};
@ -99,21 +113,29 @@ export default class PreferencesSettingsTab extends React.Component {
label={_t('Start automatically after system login')} />;
}
let minimizeToTrayOption = null;
if (this.state.minimizeToTraySupported) {
minimizeToTrayOption = <LabelledToggleSwitch value={this.state.minimizeToTray}
onChange={this._onMinimizeToTrayChange}
label={_t('Close button should minimize window to tray')} />;
}
return (
<div className="mx_SettingsTab mx_PreferencesSettingsTab">
<div className="mx_SettingsTab mx_PreferencesUserSettingsTab">
<div className="mx_SettingsTab_heading">{_t("Preferences")}</div>
<div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{_t("Composer")}</span>
{this._renderGroup(PreferencesSettingsTab.COMPOSER_SETTINGS)}
<span className="mx_SettingsTab_subheading">{_t("Room list")}</span>
{this._renderGroup(PreferencesSettingsTab.ROOM_LIST_SETTINGS)}
{this._renderGroup(PreferencesUserSettingsTab.COMPOSER_SETTINGS)}
<span className="mx_SettingsTab_subheading">{_t("Timeline")}</span>
{this._renderGroup(PreferencesSettingsTab.TIMELINE_SETTINGS)}
{this._renderGroup(PreferencesUserSettingsTab.TIMELINE_SETTINGS)}
<span className="mx_SettingsTab_subheading">{_t("Room list")}</span>
{this._renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS)}
<span className="mx_SettingsTab_subheading">{_t("Advanced")}</span>
{this._renderGroup(PreferencesSettingsTab.ADVANCED_SETTINGS)}
{this._renderGroup(PreferencesUserSettingsTab.ADVANCED_SETTINGS)}
{minimizeToTrayOption}
{autoLaunchOption}
<Field id={"autocompleteDelay"} label={_t('Autocomplete delay (ms)')} type='number'
value={SettingsStore.getValueAt(SettingLevel.DEVICE, 'autocompleteDelay')}

View file

@ -16,15 +16,15 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import {_t} from "../../../../languageHandler";
import {SettingLevel} from "../../../../settings/SettingsStore";
import MatrixClientPeg from "../../../../MatrixClientPeg";
import * as FormattingUtils from "../../../../utils/FormattingUtils";
import AccessibleButton from "../../elements/AccessibleButton";
import Analytics from "../../../../Analytics";
import {_t} from "../../../../../languageHandler";
import {SettingLevel} from "../../../../../settings/SettingsStore";
import MatrixClientPeg from "../../../../../MatrixClientPeg";
import * as FormattingUtils from "../../../../../utils/FormattingUtils";
import AccessibleButton from "../../../elements/AccessibleButton";
import Analytics from "../../../../../Analytics";
import Promise from "bluebird";
import Modal from "../../../../Modal";
import sdk from "../../../../index";
import Modal from "../../../../../Modal";
import sdk from "../../../../..";
export class IgnoredUser extends React.Component {
static propTypes = {
@ -38,7 +38,7 @@ export class IgnoredUser extends React.Component {
render() {
return (
<div className='mx_SecuritySettingsTab_ignoredUser'>
<div className='mx_SecurityUserSettingsTab_ignoredUser'>
<AccessibleButton onClick={this._onUnignoreClicked} kind='primary_sm'>
{_t('Unignore')}
</AccessibleButton>
@ -48,7 +48,7 @@ export class IgnoredUser extends React.Component {
}
}
export default class SecuritySettingsTab extends React.Component {
export default class SecurityUserSettingsTab extends React.Component {
constructor() {
super();
@ -68,14 +68,14 @@ export default class SecuritySettingsTab extends React.Component {
_onExportE2eKeysClicked = () => {
Modal.createTrackedDialogAsync('Export E2E Keys', '',
import('../../../../async-components/views/dialogs/ExportE2eKeysDialog'),
import('../../../../../async-components/views/dialogs/ExportE2eKeysDialog'),
{matrixClient: MatrixClientPeg.get()},
);
};
_onImportE2eKeysClicked = () => {
Modal.createTrackedDialogAsync('Import E2E Keys', '',
import('../../../../async-components/views/dialogs/ImportE2eKeysDialog'),
import('../../../../../async-components/views/dialogs/ImportE2eKeysDialog'),
{matrixClient: MatrixClientPeg.get()},
);
};
@ -126,7 +126,7 @@ export default class SecuritySettingsTab extends React.Component {
let importExportButtons = null;
if (client.isCryptoEnabled()) {
importExportButtons = (
<div className='mx_SecuritySettingsTab_importExportButtons'>
<div className='mx_SecurityUserSettingsTab_importExportButtons'>
<AccessibleButton kind='primary' onClick={this._onExportE2eKeysClicked}>
{_t("Export E2E room keys")}
</AccessibleButton>
@ -140,7 +140,7 @@ export default class SecuritySettingsTab extends React.Component {
return (
<div className='mx_SettingsTab_section'>
<span className='mx_SettingsTab_subheading'>{_t("Cryptography")}</span>
<ul className='mx_SettingsTab_subsectionText mx_SecuritySettingsTab_deviceInfo'>
<ul className='mx_SettingsTab_subsectionText mx_SecurityUserSettingsTab_deviceInfo'>
<li>
<label>{_t("Device ID:")}</label>
<span><code>{deviceId}</code></span>
@ -207,7 +207,7 @@ export default class SecuritySettingsTab extends React.Component {
);
return (
<div className="mx_SettingsTab mx_SecuritySettingsTab">
<div className="mx_SettingsTab mx_SecurityUserSettingsTab">
<div className="mx_SettingsTab_heading">{_t("Security & Privacy")}</div>
<div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{_t("Devices")}</span>

View file

@ -15,16 +15,16 @@ limitations under the License.
*/
import React from 'react';
import {_t} from "../../../../languageHandler";
import CallMediaHandler from "../../../../CallMediaHandler";
import Field from "../../elements/Field";
import AccessibleButton from "../../elements/AccessibleButton";
import {SettingLevel} from "../../../../settings/SettingsStore";
const Modal = require("../../../../Modal");
const sdk = require("../../../../index");
const MatrixClientPeg = require("../../../../MatrixClientPeg");
import {_t} from "../../../../../languageHandler";
import CallMediaHandler from "../../../../../CallMediaHandler";
import Field from "../../../elements/Field";
import AccessibleButton from "../../../elements/AccessibleButton";
import {SettingLevel} from "../../../../../settings/SettingsStore";
const Modal = require("../../../../../Modal");
const sdk = require("../../../../..");
const MatrixClientPeg = require("../../../../../MatrixClientPeg");
export default class VoiceSettingsTab extends React.Component {
export default class VoiceUserSettingsTab extends React.Component {
constructor() {
super();
@ -103,7 +103,7 @@ export default class VoiceSettingsTab extends React.Component {
let webcamDropdown = null;
if (this.state.mediaDevices === false) {
requestButton = (
<div className='mx_VoiceSettingsTab_missingMediaPermissions'>
<div className='mx_VoiceUserSettingsTab_missingMediaPermissions'>
<p>{_t("Missing media permissions, click the button below to request.")}</p>
<AccessibleButton onClick={this._requestMediaPermissions} kind="primary">
{_t("Request media permissions")}
@ -166,7 +166,7 @@ export default class VoiceSettingsTab extends React.Component {
}
return (
<div className="mx_SettingsTab mx_VoiceSettingsTab">
<div className="mx_SettingsTab mx_VoiceUserSettingsTab">
<div className="mx_SettingsTab_heading">{_t("Voice & Video")}</div>
<div className="mx_SettingsTab_section">
{requestButton}

View file

@ -140,7 +140,7 @@ _td("Light bulb");
_td("Book");
_td("Pencil");
_td("Paperclip");
_td("Scisors");
_td("Scissors");
_td("Padlock");
_td("Key");
_td("Hammer");

View file

@ -132,6 +132,7 @@
"To use it, just wait for autocomplete results to load and tab through them.": "To use it, just wait for autocomplete results to load and tab through them.",
"Upgrades a room to a new version": "Upgrades a room to a new version",
"Changes your display nickname": "Changes your display nickname",
"Changes your display nickname in the current room only": "Changes your display nickname in the current room only",
"Changes colour scheme of current room": "Changes colour scheme of current room",
"Gets or sets the room topic": "Gets or sets the room topic",
"This room has no topic.": "This room has no topic.",
@ -192,6 +193,9 @@
"%(senderDisplayName)s has allowed guests to join the room.": "%(senderDisplayName)s has allowed guests to join the room.",
"%(senderDisplayName)s has prevented guests from joining the room.": "%(senderDisplayName)s has prevented guests from joining the room.",
"%(senderDisplayName)s changed guest access to %(rule)s": "%(senderDisplayName)s changed guest access to %(rule)s",
"%(senderDisplayName)s enabled flair for %(groups)s in this room.": "%(senderDisplayName)s enabled flair for %(groups)s in this room.",
"%(senderDisplayName)s disabled flair for %(groups)s in this room.": "%(senderDisplayName)s disabled flair for %(groups)s in this room.",
"%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for %(oldGroups)s in this room.": "%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for %(oldGroups)s in this room.",
"%(senderDisplayName)s sent an image.": "%(senderDisplayName)s sent an image.",
"%(senderName)s added %(count)s %(addedAddresses)s as addresses for this room.|other": "%(senderName)s added %(addedAddresses)s as addresses for this room.",
"%(senderName)s added %(count)s %(addedAddresses)s as addresses for this room.|one": "%(senderName)s added %(addedAddresses)s as an address for this room.",
@ -279,7 +283,7 @@
"Show join/leave messages (invites/kicks/bans unaffected)": "Show join/leave messages (invites/kicks/bans unaffected)",
"Show avatar changes": "Show avatar changes",
"Show display name changes": "Show display name changes",
"Show read receipts": "Show read receipts",
"Show read receipts sent by other users": "Show read receipts sent by other users",
"Show timestamps in 12 hour format (e.g. 2:30pm)": "Show timestamps in 12 hour format (e.g. 2:30pm)",
"Always show message timestamps": "Always show message timestamps",
"Autoplay GIFs and videos": "Autoplay GIFs and videos",
@ -300,11 +304,10 @@
"Enable URL previews for this room (only affects you)": "Enable URL previews for this room (only affects you)",
"Enable URL previews by default for participants in this room": "Enable URL previews by default for participants in this room",
"Room Colour": "Room Colour",
"Pin rooms I'm mentioned in to the top of the room list": "Pin rooms I'm mentioned in to the top of the room list",
"Pin unread rooms to the top of the room list": "Pin unread rooms to the top of the room list",
"Enable widget screenshots on supported widgets": "Enable widget screenshots on supported widgets",
"Prompt before sending invites to potentially invalid matrix IDs": "Prompt before sending invites to potentially invalid matrix IDs",
"Show developer tools": "Show developer tools",
"Order rooms in the room list by most important first instead of most recent": "Order rooms in the room list by most important first instead of most recent",
"Collecting app version information": "Collecting app version information",
"Collecting logs": "Collecting logs",
"Uploading report": "Uploading report",
@ -382,7 +385,7 @@
"Book": "Book",
"Pencil": "Pencil",
"Paperclip": "Paperclip",
"Scisors": "Scisors",
"Scissors": "Scissors",
"Padlock": "Padlock",
"Key": "Key",
"Hammer": "Hammer",
@ -499,19 +502,7 @@
"Upload profile picture": "Upload profile picture",
"Display Name": "Display Name",
"Save": "Save",
"This room is not accessible by remote Matrix servers": "This room is not accessible by remote Matrix servers",
"Upgrade room to version %(ver)s": "Upgrade room to version %(ver)s",
"Room information": "Room information",
"Internal room ID:": "Internal room ID:",
"Room version": "Room version",
"Room version:": "Room version:",
"Developer options": "Developer options",
"Open Devtools": "Open Devtools",
"Flair": "Flair",
"General": "General",
"Room Addresses": "Room Addresses",
"Publish this room to the public in %(domain)s's room directory?": "Publish this room to the public in %(domain)s's room directory?",
"URL Previews": "URL Previews",
"Failed to change password. Is your password correct?": "Failed to change password. Is your password correct?",
"Success": "Success",
"Your password was successfully changed. You will not receive push notifications on other devices until you log back in to them": "Your password was successfully changed. You will not receive push notifications on other devices until you log back in to them",
@ -522,11 +513,12 @@
"Phone numbers": "Phone numbers",
"Language and region": "Language and region",
"Theme": "Theme",
"Default theme": "Default theme",
"Light theme": "Light theme",
"Dark theme": "Dark theme",
"Account management": "Account management",
"Deactivating your account is a permanent action - be careful!": "Deactivating your account is a permanent action - be careful!",
"Deactivate Account": "Deactivate Account",
"General": "General",
"Legal": "Legal",
"Credits": "Credits",
"For help with using Riot, click <a>here</a>.": "For help with using Riot, click <a>here</a>.",
@ -550,11 +542,50 @@
"Labs": "Labs",
"Notifications": "Notifications",
"Start automatically after system login": "Start automatically after system login",
"Close button should minimize window to tray": "Close button should minimize window to tray",
"Preferences": "Preferences",
"Composer": "Composer",
"Room list": "Room list",
"Timeline": "Timeline",
"Room list": "Room list",
"Autocomplete delay (ms)": "Autocomplete delay (ms)",
"Unignore": "Unignore",
"<not supported>": "<not supported>",
"Import E2E room keys": "Import E2E room keys",
"Cryptography": "Cryptography",
"Device ID:": "Device ID:",
"Device key:": "Device key:",
"Ignored users": "Ignored users",
"Bulk options": "Bulk options",
"Reject all %(invitedRooms)s invites": "Reject all %(invitedRooms)s invites",
"Key backup": "Key backup",
"Security & Privacy": "Security & Privacy",
"Devices": "Devices",
"Riot collects anonymous analytics to allow us to improve the application.": "Riot collects anonymous analytics to allow us to improve the application.",
"Privacy is important to us, so we don't collect any personal or identifiable data for our analytics.": "Privacy is important to us, so we don't collect any personal or identifiable data for our analytics.",
"Learn more about how we use analytics.": "Learn more about how we use analytics.",
"No media permissions": "No media permissions",
"You may need to manually permit Riot to access your microphone/webcam": "You may need to manually permit Riot to access your microphone/webcam",
"Missing media permissions, click the button below to request.": "Missing media permissions, click the button below to request.",
"Request media permissions": "Request media permissions",
"No Audio Outputs detected": "No Audio Outputs detected",
"No Microphones detected": "No Microphones detected",
"No Webcams detected": "No Webcams detected",
"Default Device": "Default Device",
"Audio Output": "Audio Output",
"Microphone": "Microphone",
"Camera": "Camera",
"Voice & Video": "Voice & Video",
"This room is not accessible by remote Matrix servers": "This room is not accessible by remote Matrix servers",
"Upgrade room to version %(ver)s": "Upgrade room to version %(ver)s",
"Room information": "Room information",
"Internal room ID:": "Internal room ID:",
"Room version": "Room version",
"Room version:": "Room version:",
"Developer options": "Developer options",
"Open Devtools": "Open Devtools",
"Room Addresses": "Room Addresses",
"Publish this room to the public in %(domain)s's room directory?": "Publish this room to the public in %(domain)s's room directory?",
"URL Previews": "URL Previews",
"To change the room's avatar, you must be a": "To change the room's avatar, you must be a",
"To change the room's name, you must be a": "To change the room's name, you must be a",
"To change the room's main address, you must be a": "To change the room's main address, you must be a",
@ -592,38 +623,11 @@
"Members only (since the point in time of selecting this option)": "Members only (since the point in time of selecting this option)",
"Members only (since they were invited)": "Members only (since they were invited)",
"Members only (since they joined)": "Members only (since they joined)",
"Security & Privacy": "Security & Privacy",
"Encryption": "Encryption",
"Once enabled, encryption cannot be disabled.": "Once enabled, encryption cannot be disabled.",
"Encrypted": "Encrypted",
"Who can access this room?": "Who can access this room?",
"Who can read history?": "Who can read history?",
"Unignore": "Unignore",
"<not supported>": "<not supported>",
"Import E2E room keys": "Import E2E room keys",
"Cryptography": "Cryptography",
"Device ID:": "Device ID:",
"Device key:": "Device key:",
"Ignored users": "Ignored users",
"Bulk options": "Bulk options",
"Reject all %(invitedRooms)s invites": "Reject all %(invitedRooms)s invites",
"Key backup": "Key backup",
"Devices": "Devices",
"Riot collects anonymous analytics to allow us to improve the application.": "Riot collects anonymous analytics to allow us to improve the application.",
"Privacy is important to us, so we don't collect any personal or identifiable data for our analytics.": "Privacy is important to us, so we don't collect any personal or identifiable data for our analytics.",
"Learn more about how we use analytics.": "Learn more about how we use analytics.",
"No media permissions": "No media permissions",
"You may need to manually permit Riot to access your microphone/webcam": "You may need to manually permit Riot to access your microphone/webcam",
"Missing media permissions, click the button below to request.": "Missing media permissions, click the button below to request.",
"Request media permissions": "Request media permissions",
"No Audio Outputs detected": "No Audio Outputs detected",
"No Microphones detected": "No Microphones detected",
"No Webcams detected": "No Webcams detected",
"Default Device": "Default Device",
"Audio Output": "Audio Output",
"Microphone": "Microphone",
"Camera": "Camera",
"Voice & Video": "Voice & Video",
"Cannot add any more widgets": "Cannot add any more widgets",
"The maximum permitted number of widgets have already been added to this room.": "The maximum permitted number of widgets have already been added to this room.",
"Add a widget": "Add a widget",
@ -714,15 +718,13 @@
"This room has been replaced and is no longer active.": "This room has been replaced and is no longer active.",
"The conversation continues here.": "The conversation continues here.",
"You do not have permission to post to this room": "You do not have permission to post to this room",
"Turn Markdown on": "Turn Markdown on",
"Turn Markdown off": "Turn Markdown off",
"Markdown is disabled": "Markdown is disabled",
"Hide Text Formatting Toolbar": "Hide Text Formatting Toolbar",
"Server error": "Server error",
"Server unavailable, overloaded, or something else went wrong.": "Server unavailable, overloaded, or something else went wrong.",
"Command error": "Command error",
"Unable to reply": "Unable to reply",
"At this time it is not possible to reply with an emote.": "At this time it is not possible to reply with an emote.",
"Markdown is disabled": "Markdown is disabled",
"Markdown is enabled": "Markdown is enabled",
"No pinned messages.": "No pinned messages.",
"Loading...": "Loading...",
@ -809,17 +811,22 @@
"Hide Stickers": "Hide Stickers",
"Show Stickers": "Show Stickers",
"Jump to first unread message.": "Jump to first unread message.",
"Error updating main address": "Error updating main address",
"There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.",
"Error creating alias": "Error creating alias",
"There was an error creating that alias. It may not be allowed by the server or a temporary failure occurred.": "There was an error creating that alias. It may not be allowed by the server or a temporary failure occurred.",
"Invalid alias format": "Invalid alias format",
"'%(alias)s' is not a valid format for an alias": "'%(alias)s' is not a valid format for an alias",
"Invalid address format": "Invalid address format",
"'%(alias)s' is not a valid format for an address": "'%(alias)s' is not a valid format for an address",
"Error removing alias": "Error removing alias",
"There was an error removing that alias. It may no longer exist or a temporary error occurred.": "There was an error removing that alias. It may no longer exist or a temporary error occurred.",
"Main address": "Main address",
"not specified": "not specified",
"not set": "not set",
"Remote addresses for this room:": "Remote addresses for this room:",
"Local addresses for this room:": "Local addresses for this room:",
"This room has no local addresses": "This room has no local addresses",
"New address (e.g. #foo:%(localDomain)s)": "New address (e.g. #foo:%(localDomain)s)",
"Error updating flair": "Error updating flair",
"There was an error updating the flair for this room. The server may not allow it or a temporary error occurred.": "There was an error updating the flair for this room. The server may not allow it or a temporary error occurred.",
"Invalid community ID": "Invalid community ID",
"'%(groupId)s' is not a valid community ID": "'%(groupId)s' is not a valid community ID",
"Showing flair for these communities:": "Showing flair for these communities:",
@ -930,7 +937,6 @@
"Verify...": "Verify...",
"Join": "Join",
"No results": "No results",
"Delete": "Delete",
"Communities": "Communities",
"Home": "Home",
"You cannot delete this image. (%(code)s)": "You cannot delete this image. (%(code)s)",
@ -1111,7 +1117,7 @@
"To help avoid duplicate issues, please <existingIssuesLink>view existing issues</existingIssuesLink> first (and add a +1) or <newIssueLink>create a new issue</newIssueLink> if you can't find it.": "To help avoid duplicate issues, please <existingIssuesLink>view existing issues</existingIssuesLink> first (and add a +1) or <newIssueLink>create a new issue</newIssueLink> if you can't find it.",
"Report bugs & give feedback": "Report bugs & give feedback",
"Go back": "Go back",
"Room Settings": "Room Settings",
"Room Settings - %(roomName)s": "Room Settings - %(roomName)s",
"Failed to upgrade room": "Failed to upgrade room",
"The room upgrade could not be completed": "The room upgrade could not be completed",
"Upgrade this room to version %(version)s": "Upgrade this room to version %(version)s",
@ -1134,7 +1140,7 @@
"Email address": "Email address",
"This will allow you to reset your password and receive notifications.": "This will allow you to reset your password and receive notifications.",
"Skip": "Skip",
"Only use lower case letters, numbers and '=_-./'": "Only use lower case letters, numbers and '=_-./'",
"A username can only contain lower case letters, numbers and '=_-./'": "A username can only contain lower case letters, numbers and '=_-./'",
"Username not available": "Username not available",
"Username invalid: %(errMessage)s": "Username invalid: %(errMessage)s",
"An error occurred: %(error_string)s": "An error occurred: %(error_string)s",
@ -1199,6 +1205,7 @@
"View Source": "View Source",
"View Decrypted Source": "View Decrypted Source",
"Unhide Preview": "Unhide Preview",
"Share Permalink": "Share Permalink",
"Share Message": "Share Message",
"Quote": "Quote",
"Source URL": "Source URL",
@ -1224,7 +1231,6 @@
"Sign in": "Sign in",
"Login": "Login",
"powered by Matrix": "powered by Matrix",
"Robot check is currently unavailable on desktop - please use a <a>web browser</a>": "Robot check is currently unavailable on desktop - please use a <a>web browser</a>",
"This homeserver would like to make sure you are not a robot.": "This homeserver would like to make sure you are not a robot.",
"Custom Server Options": "Custom Server Options",
"You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use this app with an existing Matrix account on a different homeserver.": "You can use the custom server options to sign into other Matrix servers by specifying a different homeserver URL. This allows you to use this app with an existing Matrix account on a different homeserver.",
@ -1252,13 +1258,14 @@
"Username": "Username",
"Mobile phone number": "Mobile phone number",
"Not sure of your password? <a>Set a new one</a>": "Not sure of your password? <a>Set a new one</a>",
"Sign in to %(serverName)s": "Sign in to %(serverName)s",
"Sign in to your Matrix account": "Sign in to your Matrix account",
"Sign in to your Matrix account on %(serverName)s": "Sign in to your Matrix account on %(serverName)s",
"Change": "Change",
"Sign in with": "Sign in with",
"Phone": "Phone",
"If you don't specify an email address, you won't be able to reset your password. Are you sure?": "If you don't specify an email address, you won't be able to reset your password. Are you sure?",
"Create your account": "Create your account",
"Create your %(serverName)s account": "Create your %(serverName)s account",
"Create your Matrix account": "Create your Matrix account",
"Create your Matrix account on %(serverName)s": "Create your Matrix account on %(serverName)s",
"Email": "Email",
"Email (optional)": "Email (optional)",
"Phone (optional)": "Phone (optional)",
@ -1325,6 +1332,7 @@
"Community %(groupId)s not found": "Community %(groupId)s not found",
"This homeserver does not support communities": "This homeserver does not support communities",
"Failed to load %(groupId)s": "Failed to load %(groupId)s",
"Filter room names": "Filter room names",
"Invalid configuration: Cannot supply a default homeserver URL and a default server name": "Invalid configuration: Cannot supply a default homeserver URL and a default server name",
"Failed to reject invitation": "Failed to reject invitation",
"This room is not public. You will not be able to rejoin without an invite.": "This room is not public. You will not be able to rejoin without an invite.",
@ -1394,7 +1402,6 @@
"Click to mute video": "Click to mute video",
"Click to unmute audio": "Click to unmute audio",
"Click to mute audio": "Click to mute audio",
"Filter room names": "Filter room names",
"Clear filter": "Clear filter",
"Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.",
"Tried to load a specific point in this room's timeline, but was unable to find it.": "Tried to load a specific point in this room's timeline, but was unable to find it.",
@ -1403,13 +1410,14 @@
"Uploading %(filename)s and %(count)s others|other": "Uploading %(filename)s and %(count)s others",
"Uploading %(filename)s and %(count)s others|zero": "Uploading %(filename)s",
"Uploading %(filename)s and %(count)s others|one": "Uploading %(filename)s and %(count)s other",
"Could not load user profile": "Could not load user profile",
"Failed to send email": "Failed to send email",
"The email address linked to your account must be entered.": "The email address linked to your account must be entered.",
"A new password must be entered.": "A new password must be entered.",
"New passwords must match each other.": "New passwords must match each other.",
"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.": "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.",
"Your account": "Your account",
"Your account on %(serverName)s": "Your account on %(serverName)s",
"Your Matrix account": "Your Matrix account",
"Your Matrix account on %(serverName)s": "Your Matrix account on %(serverName)s",
"The homeserver URL %(hsUrl)s doesn't seem to be valid URL. Please enter a valid URL including the protocol prefix.": "The homeserver URL %(hsUrl)s doesn't seem to be valid URL. Please enter a valid URL including the protocol prefix.",
"A verification email will be sent to your inbox to confirm setting your new password.": "A verification email will be sent to your inbox to confirm setting your new password.",
"Send Reset Email": "Send Reset Email",
@ -1453,6 +1461,7 @@
"A phone number is required to register on this homeserver.": "A phone number is required to register on this homeserver.",
"You need to enter a username.": "You need to enter a username.",
"An unknown error occurred.": "An unknown error occurred.",
"Create your account": "Create your account",
"Commands": "Commands",
"Results from DuckDuckGo": "Results from DuckDuckGo",
"Emoji": "Emoji",

View file

@ -338,8 +338,10 @@ export function getCurrentLanguage() {
function getLangsJson() {
return new Promise((resolve, reject) => {
// LANGUAGES_FILE is a webpack compile-time define, see webpack config
const url = (typeof LANGUAGES_FILE === "string") ? require(LANGUAGES_FILE) : (i18nFolder + 'languages.json');
request(
{ method: "GET", url: i18nFolder + 'languages.json' },
{ method: "GET", url },
(err, response, body) => {
if (err || response.status < 200 || response.status >= 300) {
reject({err: err, response: response});

View file

@ -1,5 +1,5 @@
/*
Copyright 2017 New Vector 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.
@ -25,17 +25,213 @@ export const baseUrl = `https://${host}`;
// to add to permalinks. The servers are appended as ?via=example.org
const MAX_SERVER_CANDIDATES = 3;
export function makeEventPermalink(roomId, eventId) {
const permalinkBase = `${baseUrl}/#/${roomId}/${eventId}`;
// If the roomId isn't actually a room ID, don't try to list the servers.
// Aliases are already routable, and don't need extra information.
if (roomId[0] !== '!') return permalinkBase;
// Permalinks can have servers appended to them so that the user
// receiving them can have a fighting chance at joining the room.
// These servers are called "candidates" at this point because
// it is unclear whether they are going to be useful to actually
// join in the future.
//
// We pick 3 servers based on the following criteria:
//
// Server 1: The highest power level user in the room, provided
// they are at least PL 50. We don't calculate "what is a moderator"
// here because it is less relevant for the vast majority of rooms.
// We also want to ensure that we get an admin or high-ranking mod
// as they are less likely to leave the room. If no user happens
// to meet this criteria, we'll pick the most popular server in the
// room.
//
// Server 2: The next most popular server in the room (in user
// distribution). This cannot be the same as Server 1. If no other
// servers are available then we'll only return Server 1.
//
// Server 3: The next most popular server by user distribution. This
// has the same rules as Server 2, with the added exception that it
// must be unique from Server 1 and 2.
const serverCandidates = pickServerCandidates(roomId);
return `${permalinkBase}${encodeServerCandidates(serverCandidates)}`;
// Rationale for popular servers: It's hard to get rid of people when
// they keep flocking in from a particular server. Sure, the server could
// be ACL'd in the future or for some reason be evicted from the room
// however an event like that is unlikely the larger the room gets. If
// the server is ACL'd at the time of generating the link however, we
// shouldn't pick them. We also don't pick IP addresses.
// Note: we don't pick the server the room was created on because the
// homeserver should already be using that server as a last ditch attempt
// and there's less of a guarantee that the server is a resident server.
// Instead, we actively figure out which servers are likely to be residents
// in the future and try to use those.
// Note: Users receiving permalinks that happen to have all 3 potential
// servers fail them (in terms of joining) are somewhat expected to hunt
// down the person who gave them the link to ask for a participating server.
// The receiving user can then manually append the known-good server to
// the list and magically have the link work.
export class RoomPermalinkCreator {
constructor(room) {
this._room = room;
this._highestPlUserId = null;
this._populationMap = null;
this._bannedHostsRegexps = null;
this._allowedHostsRegexps = null;
this._serverCandidates = null;
this.onMembership = this.onMembership.bind(this);
this.onRoomState = this.onRoomState.bind(this);
}
load() {
this._updateAllowedServers();
this._updateHighestPlUser();
this._updatePopulationMap();
this._updateServerCandidates();
}
start() {
this.load();
this._room.on("RoomMember.membership", this.onMembership);
this._room.on("RoomState.events", this.onRoomState);
}
stop() {
this._room.removeListener("RoomMember.membership", this.onMembership);
this._room.removeListener("RoomState.events", this.onRoomState);
}
forEvent(eventId) {
const roomId = this._room.roomId;
const permalinkBase = `${baseUrl}/#/${roomId}/${eventId}`;
return `${permalinkBase}${encodeServerCandidates(this._serverCandidates)}`;
}
forRoom() {
const roomId = this._room.roomId;
const permalinkBase = `${baseUrl}/#/${roomId}`;
return `${permalinkBase}${encodeServerCandidates(this._serverCandidates)}`;
}
onRoomState(event) {
switch (event.getType()) {
case "m.room.server_acl":
this._updateAllowedServers();
this._updateHighestPlUser();
this._updatePopulationMap();
this._updateServerCandidates();
return;
case "m.room.power_levels":
this._updateHighestPlUser();
this._updateServerCandidates();
return;
}
}
onMembership(evt, member, oldMembership) {
const userId = member.userId;
const membership = member.membership;
const serverName = getServerName(userId);
const hasJoined = oldMembership !== "join" && membership === "join";
const hasLeft = oldMembership === "join" && membership !== "join";
if (hasLeft) {
this._populationMap[serverName]--;
} else if (hasJoined) {
this._populationMap[serverName]++;
}
this._updateHighestPlUser();
this._updateServerCandidates();
}
_updateHighestPlUser() {
const plEvent = this._room.currentState.getStateEvents("m.room.power_levels", "");
if (plEvent) {
const content = plEvent.getContent();
if (content) {
const users = content.users;
if (users) {
const entries = Object.entries(users);
const allowedEntries = entries.filter(([userId]) => {
const member = this._room.getMember(userId);
if (!member || member.membership !== "join") {
return false;
}
const serverName = getServerName(userId);
return !isHostnameIpAddress(serverName) &&
!isHostInRegex(serverName, this._bannedHostsRegexps) &&
isHostInRegex(serverName, this._allowedHostsRegexps);
});
const maxEntry = allowedEntries.reduce((max, entry) => {
return (entry[1] > max[1]) ? entry : max;
}, [null, 0]);
const [userId, powerLevel] = maxEntry;
// object wasn't empty, and max entry wasn't a demotion from the default
if (userId !== null && powerLevel >= 50) {
this._highestPlUserId = userId;
return;
}
}
}
}
this._highestPlUserId = null;
}
_updateAllowedServers() {
const bannedHostsRegexps = [];
let allowedHostsRegexps = [new RegExp(".*")]; // default allow everyone
if (this._room.currentState) {
const aclEvent = this._room.currentState.getStateEvents("m.room.server_acl", "");
if (aclEvent && aclEvent.getContent()) {
const getRegex = (hostname) => new RegExp("^" + utils.globToRegexp(hostname, false) + "$");
const denied = aclEvent.getContent().deny || [];
denied.forEach(h => bannedHostsRegexps.push(getRegex(h)));
const allowed = aclEvent.getContent().allow || [];
allowedHostsRegexps = []; // we don't want to use the default rule here
allowed.forEach(h => allowedHostsRegexps.push(getRegex(h)));
}
}
this._bannedHostsRegexps = bannedHostsRegexps;
this._allowedHostsRegexps = allowedHostsRegexps;
}
_updatePopulationMap() {
const populationMap: {[server:string]:number} = {};
for (const member of this._room.getJoinedMembers()) {
const serverName = getServerName(member.userId);
if (!populationMap[serverName]) {
populationMap[serverName] = 0;
}
populationMap[serverName]++;
}
this._populationMap = populationMap;
}
_updateServerCandidates() {
let candidates = [];
if (this._highestPlUserId) {
candidates.push(getServerName(this._highestPlUserId));
}
const serversByPopulation = Object.keys(this._populationMap)
.sort((a, b) => this._populationMap[b] - this._populationMap[a])
.filter(a => {
return !candidates.includes(a) &&
!isHostnameIpAddress(a) &&
!isHostInRegex(a, this._bannedHostsRegexps) &&
isHostInRegex(a, this._allowedHostsRegexps);
});
const remainingServers = serversByPopulation.slice(0, MAX_SERVER_CANDIDATES - candidates.length);
candidates = candidates.concat(remainingServers);
this._serverCandidates = candidates;
}
}
export function makeUserPermalink(userId) {
return `${baseUrl}/#/${userId}`;
}
@ -47,8 +243,14 @@ export function makeRoomPermalink(roomId) {
// Aliases are already routable, and don't need extra information.
if (roomId[0] !== '!') return permalinkBase;
const serverCandidates = pickServerCandidates(roomId);
return `${permalinkBase}${encodeServerCandidates(serverCandidates)}`;
const client = MatrixClientPeg.get();
const room = client.getRoom(roomId);
if (!room) {
return permalinkBase;
}
const permalinkCreator = new RoomPermalinkCreator(room);
permalinkCreator.load();
return permalinkCreator.forRoom();
}
export function makeGroupPermalink(groupId) {
@ -60,111 +262,13 @@ export function encodeServerCandidates(candidates) {
return `?via=${candidates.map(c => encodeURIComponent(c)).join("&via=")}`;
}
export function pickServerCandidates(roomId) {
const client = MatrixClientPeg.get();
const room = client.getRoom(roomId);
if (!room) return [];
// Permalinks can have servers appended to them so that the user
// receiving them can have a fighting chance at joining the room.
// These servers are called "candidates" at this point because
// it is unclear whether they are going to be useful to actually
// join in the future.
//
// We pick 3 servers based on the following criteria:
//
// Server 1: The highest power level user in the room, provided
// they are at least PL 50. We don't calculate "what is a moderator"
// here because it is less relevant for the vast majority of rooms.
// We also want to ensure that we get an admin or high-ranking mod
// as they are less likely to leave the room. If no user happens
// to meet this criteria, we'll pick the most popular server in the
// room.
//
// Server 2: The next most popular server in the room (in user
// distribution). This cannot be the same as Server 1. If no other
// servers are available then we'll only return Server 1.
//
// Server 3: The next most popular server by user distribution. This
// has the same rules as Server 2, with the added exception that it
// must be unique from Server 1 and 2.
// Rationale for popular servers: It's hard to get rid of people when
// they keep flocking in from a particular server. Sure, the server could
// be ACL'd in the future or for some reason be evicted from the room
// however an event like that is unlikely the larger the room gets. If
// the server is ACL'd at the time of generating the link however, we
// shouldn't pick them. We also don't pick IP addresses.
// Note: we don't pick the server the room was created on because the
// homeserver should already be using that server as a last ditch attempt
// and there's less of a guarantee that the server is a resident server.
// Instead, we actively figure out which servers are likely to be residents
// in the future and try to use those.
// Note: Users receiving permalinks that happen to have all 3 potential
// servers fail them (in terms of joining) are somewhat expected to hunt
// down the person who gave them the link to ask for a participating server.
// The receiving user can then manually append the known-good server to
// the list and magically have the link work.
const bannedHostsRegexps = [];
let allowedHostsRegexps = [new RegExp(".*")]; // default allow everyone
if (room.currentState) {
const aclEvent = room.currentState.getStateEvents("m.room.server_acl", "");
if (aclEvent && aclEvent.getContent()) {
const getRegex = (hostname) => new RegExp("^" + utils.globToRegexp(hostname, false) + "$");
const denied = aclEvent.getContent().deny || [];
denied.forEach(h => bannedHostsRegexps.push(getRegex(h)));
const allowed = aclEvent.getContent().allow || [];
allowedHostsRegexps = []; // we don't want to use the default rule here
allowed.forEach(h => allowedHostsRegexps.push(getRegex(h)));
}
}
const populationMap: {[server:string]:number} = {};
const highestPlUser = {userId: null, powerLevel: 0, serverName: null};
for (const member of room.getJoinedMembers()) {
const serverName = member.userId.split(":").splice(1).join(":");
if (member.powerLevel > highestPlUser.powerLevel && !isHostnameIpAddress(serverName)
&& !isHostInRegex(serverName, bannedHostsRegexps) && isHostInRegex(serverName, allowedHostsRegexps)) {
highestPlUser.userId = member.userId;
highestPlUser.powerLevel = member.powerLevel;
highestPlUser.serverName = serverName;
}
if (!populationMap[serverName]) populationMap[serverName] = 0;
populationMap[serverName]++;
}
const candidates = [];
if (highestPlUser.powerLevel >= 50) candidates.push(highestPlUser.serverName);
const beforePopulation = candidates.length;
const serversByPopulation = Object.keys(populationMap)
.sort((a, b) => populationMap[b] - populationMap[a])
.filter(a => !candidates.includes(a) && !isHostnameIpAddress(a)
&& !isHostInRegex(a, bannedHostsRegexps) && isHostInRegex(a, allowedHostsRegexps));
for (let i = beforePopulation; i < MAX_SERVER_CANDIDATES; i++) {
const idx = i - beforePopulation;
if (idx >= serversByPopulation.length) break;
candidates.push(serversByPopulation[idx]);
}
return candidates;
function getServerName(userId) {
return userId.split(":").splice(1).join(":");
}
function getHostnameFromMatrixDomain(domain) {
if (!domain) return null;
// The hostname might have a port, so we convert it to a URL and
// split out the real hostname.
const parser = document.createElement('a');
parser.href = "https://" + domain;
return parser.hostname;
return new URL(`https://${domain}`).hostname;
}
function isHostInRegex(hostname, regexps) {

View file

@ -1,6 +1,6 @@
/*
Copyright 2017 Travis Ralston
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.
@ -155,7 +155,7 @@ export const SETTINGS = {
},
"showReadReceipts": {
supportedLevels: LEVELS_ROOM_SETTINGS,
displayName: _td('Show read receipts'),
displayName: _td('Show read receipts sent by other users'),
default: true,
invertedSettingName: 'hideReadReceipts',
},
@ -321,16 +321,6 @@ export const SETTINGS = {
default: true,
controller: new AudioNotificationsEnabledController(),
},
"pinMentionedRooms": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td("Pin rooms I'm mentioned in to the top of the room list"),
default: true,
},
"pinUnreadRooms": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td("Pin unread rooms to the top of the room list"),
default: true,
},
"enableWidgetScreenshots": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td('Enable widget screenshots on supported widgets'),
@ -350,4 +340,9 @@ export const SETTINGS = {
displayName: _td('Show developer tools'),
default: false,
},
"RoomList.orderByImportance": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td('Order rooms in the room list by most important first instead of most recent'),
default: true,
},
};

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 Travis Ralston
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.
@ -23,8 +24,10 @@ import RoomSettingsHandler from "./handlers/RoomSettingsHandler";
import ConfigSettingsHandler from "./handlers/ConfigSettingsHandler";
import {_t} from '../languageHandler';
import SdkConfig from "../SdkConfig";
import dis from '../dispatcher';
import {SETTINGS} from "./Settings";
import LocalEchoWrapper from "./handlers/LocalEchoWrapper";
import {WatchManager} from "./WatchManager";
/**
* Represents the various setting levels supported by the SettingsStore.
@ -41,6 +44,8 @@ export const SettingLevel = {
DEFAULT: "default",
};
const defaultWatchManager = new WatchManager();
// Convert the settings to easier to manage objects for the handlers
const defaultSettings = {};
const invertedDefaultSettings = {};
@ -56,11 +61,11 @@ for (const key of Object.keys(SETTINGS)) {
}
const LEVEL_HANDLERS = {
"device": new DeviceSettingsHandler(featureNames),
"room-device": new RoomDeviceSettingsHandler(),
"room-account": new RoomAccountSettingsHandler(),
"account": new AccountSettingsHandler(),
"room": new RoomSettingsHandler(),
"device": new DeviceSettingsHandler(featureNames, defaultWatchManager),
"room-device": new RoomDeviceSettingsHandler(defaultWatchManager),
"room-account": new RoomAccountSettingsHandler(defaultWatchManager),
"account": new AccountSettingsHandler(defaultWatchManager),
"room": new RoomSettingsHandler(defaultWatchManager),
"config": new ConfigSettingsHandler(),
"default": new DefaultSettingsHandler(defaultSettings, invertedDefaultSettings),
};
@ -98,6 +103,109 @@ const LEVEL_ORDER = [
* be enabled).
*/
export default class SettingsStore {
// We support watching settings for changes, and do this by tracking which callbacks have
// been given to us. We end up returning the callbackRef to the caller so they can unsubscribe
// at a later point.
//
// We also maintain a list of monitors which are special watchers: they cause dispatches
// when the setting changes. We track which rooms we're monitoring though to ensure we
// don't duplicate updates on the bus.
static _watchers = {}; // { callbackRef => { callbackFn } }
static _monitors = {}; // { settingName => { roomId => callbackRef } }
/**
* Watches for changes in a particular setting. This is done without any local echo
* wrapping and fires whenever a change is detected in a setting's value, at any level.
* Watching is intended to be used in scenarios where the app needs to react to changes
* made by other devices. It is otherwise expected that callers will be able to use the
* Controller system or track their own changes to settings. Callers should retain the
* returned reference to later unsubscribe from updates.
* @param {string} settingName The setting name to watch
* @param {String} roomId The room ID to watch for changes in. May be null for 'all'.
* @param {function} callbackFn A function to be called when a setting change is
* detected. Five arguments can be expected: the setting name, the room ID (may be null),
* the level the change happened at, the new value at the given level, and finally the new
* value for the setting regardless of level. The callback is responsible for determining
* if the change in value is worthwhile enough to react upon.
* @returns {string} A reference to the watcher that was employed.
*/
static watchSetting(settingName, roomId, callbackFn) {
const setting = SETTINGS[settingName];
const originalSettingName = settingName;
if (!setting) throw new Error(`${settingName} is not a setting`);
if (setting.invertedSettingName) {
settingName = setting.invertedSettingName;
}
const watcherId = `${new Date().getTime()}_${settingName}_${roomId}`;
const localizedCallback = (changedInRoomId, atLevel, newValAtLevel) => {
const newValue = SettingsStore.getValue(originalSettingName);
callbackFn(originalSettingName, changedInRoomId, atLevel, newValAtLevel, newValue);
};
console.log(`Starting watcher for ${settingName}@${roomId || '<null room>'}`);
SettingsStore._watchers[watcherId] = localizedCallback;
defaultWatchManager.watchSetting(settingName, roomId, localizedCallback);
return watcherId;
}
/**
* Stops the SettingsStore from watching a setting. This is a no-op if the watcher
* provided is not found.
* @param {string} watcherReference The watcher reference (received from #watchSetting)
* to cancel.
*/
static unwatchSetting(watcherReference) {
if (!SettingsStore._watchers[watcherReference]) return;
defaultWatchManager.unwatchSetting(SettingsStore._watchers[watcherReference]);
delete SettingsStore._watchers[watcherReference];
}
/**
* Sets up a monitor for a setting. This behaves similar to #watchSetting except instead
* of making a call to a callback, it forwards all changes to the dispatcher. Callers can
* expect to listen for the 'setting_updated' action with an object containing settingName,
* roomId, level, newValueAtLevel, and newValue.
* @param {string} settingName The setting name to monitor.
* @param {String} roomId The room ID to monitor for changes in. Use null for all rooms.
*/
static monitorSetting(settingName, roomId) {
if (!this._monitors[settingName]) this._monitors[settingName] = {};
const registerWatcher = () => {
this._monitors[settingName][roomId] = SettingsStore.watchSetting(
settingName, roomId, (settingName, inRoomId, level, newValueAtLevel, newValue) => {
dis.dispatch({
action: 'setting_updated',
settingName,
roomId: inRoomId,
level,
newValueAtLevel,
newValue,
});
},
);
};
const hasRoom = Object.keys(this._monitors[settingName]).find((r) => r === roomId || r === null);
if (!hasRoom) {
registerWatcher();
} else {
if (roomId === null) {
// Unregister all existing watchers and register the new one
for (const roomId of Object.keys(this._monitors[settingName])) {
SettingsStore.unwatchSetting(this._monitors[settingName][roomId]);
}
this._monitors[settingName] = {};
registerWatcher();
} // else a watcher is already registered for the room, so don't bother registering it again
}
}
/**
* Gets the translated display name for a given setting
* @param {string} settingName The setting to look up.

View file

@ -0,0 +1,61 @@
/*
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.
*/
/**
* Generalized management class for dealing with watchers on a per-handler (per-level)
* basis without duplicating code. Handlers are expected to push updates through this
* class, which are then proxied outwards to any applicable watchers.
*/
export class WatchManager {
_watchers = {}; // { settingName: { roomId: callbackFns[] } }
// Proxy for handlers to delegate changes to this manager
watchSetting(settingName, roomId, cb) {
if (!this._watchers[settingName]) this._watchers[settingName] = {};
if (!this._watchers[settingName][roomId]) this._watchers[settingName][roomId] = [];
this._watchers[settingName][roomId].push(cb);
}
// Proxy for handlers to delegate changes to this manager
unwatchSetting(cb) {
for (const settingName of Object.keys(this._watchers)) {
for (const roomId of Object.keys(this._watchers[settingName])) {
let idx;
while ((idx = this._watchers[settingName][roomId].indexOf(cb)) !== -1) {
this._watchers[settingName][roomId].splice(idx, 1);
}
}
}
}
notifyUpdate(settingName, inRoomId, atLevel, newValueAtLevel) {
// Dev note: We could avoid raising changes for ultimately inconsequential changes, but
// we also don't have a reliable way to get the old value of a setting. Instead, we'll just
// let it fall through regardless and let the receiver dedupe if they want to.
if (!this._watchers[settingName]) return;
const roomWatchers = this._watchers[settingName];
const callbacks = [];
if (inRoomId !== null && roomWatchers[inRoomId]) callbacks.push(...roomWatchers[inRoomId]);
if (roomWatchers[null]) callbacks.push(...roomWatchers[null]);
for (const callback of callbacks) {
callback(inRoomId, atLevel, newValueAtLevel);
}
}
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 Travis Ralston
Copyright 2019 New Vector Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,14 +15,49 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import SettingsHandler from "./SettingsHandler";
import MatrixClientPeg from '../../MatrixClientPeg';
import MatrixClientBackedSettingsHandler from "./MatrixClientBackedSettingsHandler";
import {SettingLevel} from "../SettingsStore";
/**
* Gets and sets settings at the "account" level for the current user.
* This handler does not make use of the roomId parameter.
*/
export default class AccountSettingHandler extends SettingsHandler {
export default class AccountSettingsHandler extends MatrixClientBackedSettingsHandler {
constructor(watchManager) {
super();
this._watchers = watchManager;
this._onAccountData = this._onAccountData.bind(this);
}
initMatrixClient(oldClient, newClient) {
if (oldClient) {
oldClient.removeListener("accountData", this._onAccountData);
}
newClient.on("accountData", this._onAccountData);
}
_onAccountData(event) {
if (event.getType() === "org.matrix.preview_urls") {
let val = event.getContent()['disable'];
if (typeof(val) !== "boolean") {
val = null;
} else {
val = !val;
}
this._watchers.notifyUpdate("urlPreviewsEnabled", null, SettingLevel.ACCOUNT, val);
} else if (event.getType() === "im.vector.web.settings") {
// We can't really discern what changed, so trigger updates for everything
for (const settingName of Object.keys(event.getContent())) {
const val = event.getContent()[settingName];
this._watchers.notifyUpdate(settingName, null, SettingLevel.ACCOUNT, val);
}
}
}
getValue(settingName, roomId) {
// Special case URL previews
if (settingName === "urlPreviewsEnabled") {

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 Travis Ralston
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.

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 Travis Ralston
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.
@ -17,6 +18,7 @@ limitations under the License.
import Promise from 'bluebird';
import SettingsHandler from "./SettingsHandler";
import MatrixClientPeg from "../../MatrixClientPeg";
import {SettingLevel} from "../SettingsStore";
/**
* Gets and sets settings at the "device" level for the current device.
@ -27,10 +29,12 @@ export default class DeviceSettingsHandler extends SettingsHandler {
/**
* Creates a new device settings handler
* @param {string[]} featureNames The names of known features.
* @param {WatchManager} watchManager The watch manager to notify updates to
*/
constructor(featureNames) {
constructor(featureNames, watchManager) {
super();
this._featureNames = featureNames;
this._watchers = watchManager;
}
getValue(settingName, roomId) {
@ -66,18 +70,22 @@ export default class DeviceSettingsHandler extends SettingsHandler {
// Special case notifications
if (settingName === "notificationsEnabled") {
localStorage.setItem("notifications_enabled", newValue);
this._watchers.notifyUpdate(settingName, null, SettingLevel.DEVICE, newValue);
return Promise.resolve();
} else if (settingName === "notificationBodyEnabled") {
localStorage.setItem("notifications_body_enabled", newValue);
this._watchers.notifyUpdate(settingName, null, SettingLevel.DEVICE, newValue);
return Promise.resolve();
} else if (settingName === "audioNotificationsEnabled") {
localStorage.setItem("audio_notifications_enabled", newValue);
this._watchers.notifyUpdate(settingName, null, SettingLevel.DEVICE, newValue);
return Promise.resolve();
}
const settings = this._getSettings() || {};
settings[settingName] = newValue;
localStorage.setItem("mx_local_settings", JSON.stringify(settings));
this._watchers.notifyUpdate(settingName, null, SettingLevel.DEVICE, newValue);
return Promise.resolve();
}
@ -90,6 +98,14 @@ export default class DeviceSettingsHandler extends SettingsHandler {
return localStorage !== undefined && localStorage !== null;
}
watchSetting(settingName, roomId, cb) {
this._watchers.watchSetting(settingName, roomId, cb);
}
unwatchSetting(cb) {
this._watchers.unwatchSetting(cb);
}
_getSettings() {
const value = localStorage.getItem("mx_local_settings");
if (!value) return null;
@ -111,5 +127,6 @@ export default class DeviceSettingsHandler extends SettingsHandler {
_writeFeature(featureName, enabled) {
localStorage.setItem("mx_labs_feature_" + featureName, enabled);
this._watchers.notifyUpdate(featureName, null, enabled);
}
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 Travis Ralston
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.

View file

@ -0,0 +1,48 @@
/*
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 SettingsHandler from "./SettingsHandler";
// Dev note: This whole class exists in the event someone logs out and back in - we want
// to make sure the right MatrixClient is listening for changes.
/**
* Represents the base class for settings handlers which need access to a MatrixClient.
* This class performs no logic and should be overridden.
*/
export default class MatrixClientBackedSettingsHandler extends SettingsHandler {
static _matrixClient;
static _instances = [];
static set matrixClient(client) {
const oldClient = MatrixClientBackedSettingsHandler._matrixClient;
MatrixClientBackedSettingsHandler._matrixClient = client;
for (const instance of MatrixClientBackedSettingsHandler._instances) {
instance.initMatrixClient(oldClient, client);
}
}
constructor() {
super();
MatrixClientBackedSettingsHandler._instances.push(this);
}
initMatrixClient() {
console.warn("initMatrixClient not overridden");
}
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 Travis Ralston
Copyright 2019 New Vector Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,13 +15,52 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import SettingsHandler from "./SettingsHandler";
import MatrixClientPeg from '../../MatrixClientPeg';
import MatrixClientBackedSettingsHandler from "./MatrixClientBackedSettingsHandler";
import {SettingLevel} from "../SettingsStore";
/**
* Gets and sets settings at the "room-account" level for the current user.
*/
export default class RoomAccountSettingsHandler extends SettingsHandler {
export default class RoomAccountSettingsHandler extends MatrixClientBackedSettingsHandler {
constructor(watchManager) {
super();
this._watchers = watchManager;
this._onAccountData = this._onAccountData.bind(this);
}
initMatrixClient(oldClient, newClient) {
if (oldClient) {
oldClient.removeListener("Room.accountData", this._onAccountData);
}
newClient.on("Room.accountData", this._onAccountData);
}
_onAccountData(event, room) {
const roomId = room.roomId;
if (event.getType() === "org.matrix.room.preview_urls") {
let val = event.getContent()['disable'];
if (typeof (val) !== "boolean") {
val = null;
} else {
val = !val;
}
this._watchers.notifyUpdate("urlPreviewsEnabled", roomId, SettingLevel.ROOM_ACCOUNT, val);
} else if (event.getType() === "org.matrix.room.color_scheme") {
this._watchers.notifyUpdate("roomColor", roomId, SettingLevel.ROOM_ACCOUNT, event.getContent());
} else if (event.getType() === "im.vector.web.settings") {
// We can't really discern what changed, so trigger updates for everything
for (const settingName of Object.keys(event.getContent())) {
const val = event.getContent()[settingName];
this._watchers.notifyUpdate(settingName, roomId, SettingLevel.ROOM_ACCOUNT, val);
}
}
}
getValue(settingName, roomId) {
// Special case URL previews
if (settingName === "urlPreviewsEnabled") {

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 Travis Ralston
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.
@ -16,12 +17,19 @@ limitations under the License.
import Promise from 'bluebird';
import SettingsHandler from "./SettingsHandler";
import {SettingLevel} from "../SettingsStore";
/**
* Gets and sets settings at the "room-device" level for the current device in a particular
* room.
*/
export default class RoomDeviceSettingsHandler extends SettingsHandler {
constructor(watchManager) {
super();
this._watchers = watchManager;
}
getValue(settingName, roomId) {
// Special case blacklist setting to use legacy values
if (settingName === "blacklistUnverifiedDevices") {
@ -44,6 +52,7 @@ export default class RoomDeviceSettingsHandler extends SettingsHandler {
if (!value["blacklistUnverifiedDevicesPerRoom"]) value["blacklistUnverifiedDevicesPerRoom"] = {};
value["blacklistUnverifiedDevicesPerRoom"][roomId] = newValue;
localStorage.setItem("mx_local_settings", JSON.stringify(value));
this._watchers.notifyUpdate(settingName, roomId, SettingLevel.ROOM_DEVICE, newValue);
return Promise.resolve();
}
@ -54,6 +63,7 @@ export default class RoomDeviceSettingsHandler extends SettingsHandler {
localStorage.setItem(this._getKey(settingName, roomId), newValue);
}
this._watchers.notifyUpdate(settingName, roomId, SettingLevel.ROOM_DEVICE, newValue);
return Promise.resolve();
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 Travis Ralston
Copyright 2019 New Vector Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,13 +15,49 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import SettingsHandler from "./SettingsHandler";
import MatrixClientPeg from '../../MatrixClientPeg';
import MatrixClientBackedSettingsHandler from "./MatrixClientBackedSettingsHandler";
import {SettingLevel} from "../SettingsStore";
/**
* Gets and sets settings at the "room" level.
*/
export default class RoomSettingsHandler extends SettingsHandler {
export default class RoomSettingsHandler extends MatrixClientBackedSettingsHandler {
constructor(watchManager) {
super();
this._watchers = watchManager;
this._onEvent = this._onEvent.bind(this);
}
initMatrixClient(oldClient, newClient) {
if (oldClient) {
oldClient.removeListener("RoomState.events", this._onEvent);
}
newClient.on("RoomState.events", this._onEvent);
}
_onEvent(event) {
const roomId = event.getRoomId();
if (event.getType() === "org.matrix.room.preview_urls") {
let val = event.getContent()['disable'];
if (typeof (val) !== "boolean") {
val = null;
} else {
val = !val;
}
this._watchers.notifyUpdate("urlPreviewsEnabled", roomId, SettingLevel.ROOM, val);
} else if (event.getType() === "im.vector.web.settings") {
// We can't really discern what changed, so trigger updates for everything
for (const settingName of Object.keys(event.getContent())) {
this._watchers.notifyUpdate(settingName, roomId, SettingLevel.ROOM, event.getContent()[settingName]);
}
}
}
getValue(settingName, roomId) {
// Special case URL previews
if (settingName === "urlPreviewsEnabled") {

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 Travis Ralston
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.

View file

@ -1,5 +1,5 @@
/*
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.
@ -19,20 +19,52 @@ import DMRoomMap from '../utils/DMRoomMap';
import Unread from '../Unread';
import SettingsStore from "../settings/SettingsStore";
/*
Room sorting algorithm:
* Always prefer to have red > grey > bold > idle
* The room being viewed should be sticky (not jump down to the idle list)
* When switching to a new room, sort the last sticky room to the top of the idle list.
The approach taken by the store is to generate an initial representation of all the
tagged lists (accepting that it'll take a little bit longer to calculate) and make
small changes to that over time. This results in quick changes to the room list while
also having update operations feel more like popping/pushing to a stack.
*/
const CATEGORY_RED = "red"; // Mentions in the room
const CATEGORY_GREY = "grey"; // Unread notified messages (not mentions)
const CATEGORY_BOLD = "bold"; // Unread messages (not notified, 'Mentions Only' rooms)
const CATEGORY_IDLE = "idle"; // Nothing of interest
const CATEGORY_ORDER = [CATEGORY_RED, CATEGORY_GREY, CATEGORY_BOLD, CATEGORY_IDLE];
const LIST_ORDERS = {
"m.favourite": "manual",
"im.vector.fake.invite": "recent",
"im.vector.fake.recent": "recent",
"im.vector.fake.direct": "recent",
"m.lowpriority": "recent",
"im.vector.fake.archived": "recent",
};
/**
* Identifier for the "breadcrumb" (or "sort by most important room first") algorithm.
* Includes a provision for keeping the currently open room from flying down the room
* list.
* @type {string}
*/
const ALGO_IMPORTANCE = "importance";
/**
* Identifier for classic sorting behaviour: sort by the most recent message first.
* @type {string}
*/
const ALGO_RECENT = "recent";
/**
* A class for storing application state for categorising rooms in
* the RoomList.
*/
class RoomListStore extends Store {
static _listOrders = {
"m.favourite": "manual",
"im.vector.fake.invite": "recent",
"im.vector.fake.recent": "recent",
"im.vector.fake.direct": "recent",
"m.lowpriority": "recent",
"im.vector.fake.archived": "recent",
};
constructor() {
super(dis);
@ -41,78 +73,127 @@ class RoomListStore extends Store {
this._recentsComparator = this._recentsComparator.bind(this);
}
/**
* Changes the sorting algorithm used by the RoomListStore.
* @param {string} algorithm The new algorithm to use. Should be one of the ALGO_* constants.
*/
updateSortingAlgorithm(algorithm) {
// Dev note: We only have two algorithms at the moment, but it isn't impossible that we want
// multiple in the future. Also constants make things slightly clearer.
const byImportance = algorithm === ALGO_IMPORTANCE;
console.log("Updating room sorting algorithm: sortByImportance=" + byImportance);
this._setState({orderRoomsByImportance: byImportance});
// Trigger a resort of the entire list to reflect the change in algorithm
this._generateInitialRoomLists();
}
_init() {
// Initialise state
this._state = {
lists: {
"m.server_notice": [],
"im.vector.fake.invite": [],
"m.favourite": [],
"im.vector.fake.recent": [],
"im.vector.fake.direct": [],
"m.lowpriority": [],
"im.vector.fake.archived": [],
},
ready: false,
// The room cache stores a mapping of roomId to cache record.
// Each cache record is a key/value pair for various bits of
// data used to sort the room list. Currently this stores the
// following bits of informations:
// "timestamp": number, The timestamp of the last relevant
// event in the room.
// "notifications": boolean, Whether or not the user has been
// highlighted on any unread events.
// "unread": boolean, Whether or not the user has any
// unread events.
//
// All of the cached values are lazily loaded on read in the
// recents comparator. When an event is received for a particular
// room, all the cached values are invalidated - forcing the
// next read to set new values. The entries do not expire on
// their own.
roomCache: {},
const defaultLists = {
"m.server_notice": [/* { room: js-sdk room, category: string } */],
"im.vector.fake.invite": [],
"m.favourite": [],
"im.vector.fake.recent": [],
"im.vector.fake.direct": [],
"m.lowpriority": [],
"im.vector.fake.archived": [],
};
this._state = {
// The rooms in these arrays are ordered according to either the
// 'recents' behaviour or 'manual' behaviour.
lists: defaultLists,
presentationLists: defaultLists, // like `lists`, but with arrays of rooms instead
ready: false,
stickyRoomId: null,
orderRoomsByImportance: true,
};
SettingsStore.monitorSetting('RoomList.orderByImportance', null);
SettingsStore.monitorSetting('feature_custom_tags', null);
}
_setState(newState) {
// If we're changing the lists, transparently change the presentation lists (which
// is given to requesting components). This dramatically simplifies our code elsewhere
// while also ensuring we don't need to update all the calling components to support
// categories.
if (newState['lists']) {
const presentationLists = {};
for (const key of Object.keys(newState['lists'])) {
presentationLists[key] = newState['lists'][key].map((e) => e.room);
}
newState['presentationLists'] = presentationLists;
}
this._state = Object.assign(this._state, newState);
this.__emitChange();
}
__onDispatch(payload) {
const logicallyReady = this._matrixClient && this._state.ready;
switch (payload.action) {
case 'setting_updated': {
if (payload.settingName === 'RoomList.orderByImportance') {
this.updateSortingAlgorithm(payload.newValue === true ? ALGO_IMPORTANCE : ALGO_RECENT);
} else if (payload.settingName === 'feature_custom_tags') {
this._setState({tagsEnabled: payload.newValue});
this._generateInitialRoomLists(); // Tags means we have to start from scratch
}
}
break;
// Initialise state after initial sync
case 'MatrixActions.sync': {
if (!(payload.prevState !== 'PREPARED' && payload.state === 'PREPARED')) {
break;
}
this._setState({tagsEnabled: SettingsStore.isFeatureEnabled("feature_custom_tags")});
this._matrixClient = payload.matrixClient;
this._generateRoomLists();
const algorithm = SettingsStore.getValue("RoomList.orderByImportance")
? ALGO_IMPORTANCE : ALGO_RECENT;
this.updateSortingAlgorithm(algorithm);
}
break;
case 'MatrixActions.Room.receipt': {
if (!logicallyReady) break;
// First see if the receipt event is for our own user. If it was, trigger
// a room update (we probably read the room on a different device).
const myUserId = this._matrixClient.getUserId();
for (const eventId of Object.keys(payload.event.getContent())) {
const receiptUsers = Object.keys(payload.event.getContent()[eventId]['m.read'] || {});
if (receiptUsers.includes(myUserId)) {
this._roomUpdateTriggered(payload.room.roomId);
return;
}
}
}
break;
case 'MatrixActions.Room.tags': {
if (!this._state.ready) break;
this._generateRoomLists();
if (!logicallyReady) break;
// TODO: Figure out which rooms changed in the tag and only change those.
// This is very blunt and wipes out the sticky room stuff
this._generateInitialRoomLists();
}
break;
case 'MatrixActions.Room.timeline': {
if (!this._state.ready ||
if (!logicallyReady ||
!payload.isLiveEvent ||
!payload.isLiveUnfilteredRoomTimelineEvent ||
!this._eventTriggersRecentReorder(payload.event)
) break;
) {
break;
}
this._clearCachedRoomState(payload.event.getRoomId());
this._generateRoomLists();
this._roomUpdateTriggered(payload.event.getRoomId());
}
break;
// When an event is decrypted, it could mean we need to reorder the room
// list because we now know the type of the event.
case 'MatrixActions.Event.decrypted': {
// We may not have synced or done an initial generation of the lists
if (!this._matrixClient || !this._state.ready) break;
if (!logicallyReady) break;
const roomId = payload.event.getRoomId();
@ -129,52 +210,51 @@ class RoomListStore extends Store {
// Either this event was not added to the live timeline (e.g. pagination)
// or it doesn't affect the ordering of the room list.
if (liveTimeline !== eventTimeline ||
!this._eventTriggersRecentReorder(payload.event)
) break;
if (liveTimeline !== eventTimeline || !this._eventTriggersRecentReorder(payload.event)) {
break;
}
this._clearCachedRoomState(payload.event.getRoomId());
this._generateRoomLists();
this._roomUpdateTriggered(roomId);
}
break;
case 'MatrixActions.accountData': {
if (!logicallyReady) break;
if (payload.event_type !== 'm.direct') break;
this._generateRoomLists();
}
break;
case 'MatrixActions.Room.accountData': {
if (payload.event_type === 'm.fully_read') {
this._clearCachedRoomState(payload.room.roomId);
this._generateRoomLists();
}
// TODO: Figure out which rooms changed in the direct chat and only change those.
// This is very blunt and wipes out the sticky room stuff
this._generateInitialRoomLists();
}
break;
case 'MatrixActions.Room.myMembership': {
this._generateRoomLists();
if (!logicallyReady) break;
this._roomUpdateTriggered(payload.room.roomId, true);
}
break;
// This could be a new room that we've been invited to, joined or created
// we won't get a RoomMember.membership for these cases if we're not already
// a member.
case 'MatrixActions.Room': {
if (!this._state.ready || !this._matrixClient.credentials.userId) break;
this._generateRoomLists();
}
break;
case 'RoomListActions.tagRoom.pending': {
// XXX: we only show one optimistic update at any one time.
// Ideally we should be making a list of in-flight requests
// that are backed by transaction IDs. Until the js-sdk
// supports this, we're stuck with only being able to use
// the most recent optimistic update.
this._generateRoomLists(payload.request);
}
break;
case 'RoomListActions.tagRoom.failure': {
// Reset state according to js-sdk
this._generateRoomLists();
if (!logicallyReady) break;
this._roomUpdateTriggered(payload.room.roomId, true);
}
break;
// TODO: Re-enable optimistic updates when we support dragging again
// case 'RoomListActions.tagRoom.pending': {
// if (!logicallyReady) break;
// // XXX: we only show one optimistic update at any one time.
// // Ideally we should be making a list of in-flight requests
// // that are backed by transaction IDs. Until the js-sdk
// // supports this, we're stuck with only being able to use
// // the most recent optimistic update.
// console.log("!! Optimistic tag: ", payload);
// }
// break;
// case 'RoomListActions.tagRoom.failure': {
// if (!logicallyReady) break;
// // Reset state according to js-sdk
// console.log("!! Optimistic tag failure: ", payload);
// }
// break;
case 'on_logged_out': {
// Reset state without pushing an update to the view, which generally assumes that
// the matrix client isn't `null` and so causing a re-render will cause NPEs.
@ -182,10 +262,212 @@ class RoomListStore extends Store {
this._matrixClient = null;
}
break;
case 'view_room': {
if (!logicallyReady) break;
// Note: it is important that we set a new stickyRoomId before setting the old room
// to IDLE. If we don't, the wrong room gets counted as sticky.
const currentStickyId = this._state.stickyRoomId;
this._setState({stickyRoomId: payload.room_id});
if (currentStickyId) {
this._setRoomCategory(this._matrixClient.getRoom(currentStickyId), CATEGORY_IDLE);
}
}
break;
}
}
_generateRoomLists(optimisticRequest) {
_roomUpdateTriggered(roomId, ignoreSticky) {
// We don't calculate categories for sticky rooms because we have a moderate
// interest in trying to maintain the category that they were last in before
// being artificially flagged as IDLE. Also, this reduces the amount of time
// we spend in _setRoomCategory ever so slightly.
if (this._state.stickyRoomId !== roomId || ignoreSticky) {
// Micro optimization: Only look up the room if we're confident we'll need it.
const room = this._matrixClient.getRoom(roomId);
if (!room) return;
const category = this._calculateCategory(room);
this._setRoomCategory(room, category);
}
}
_filterTags(tags) {
tags = tags ? Object.keys(tags) : [];
if (this._state.tagsEnabled) return tags;
return tags.filter((t) => !!LIST_ORDERS[t]);
}
_getRecommendedTagsForRoom(room) {
const tags = [];
const myMembership = room.getMyMembership();
if (myMembership === 'join' || myMembership === 'invite') {
// Stack the user's tags on top
tags.push(...this._filterTags(room.tags));
// Order matters here: The DMRoomMap updates before invites
// are accepted, so we check to see if the room is an invite
// first, then if it is a direct chat, and finally default
// to the "recents" list.
const dmRoomMap = DMRoomMap.shared();
if (myMembership === 'invite') {
tags.push("im.vector.fake.invite");
} else if (dmRoomMap.getUserIdForRoomId(room.roomId)) {
tags.push("im.vector.fake.direct");
} else if (tags.length === 0) {
tags.push("im.vector.fake.recent");
}
} else {
tags.push("im.vector.fake.archived");
}
return tags;
}
_setRoomCategory(room, category) {
if (!room) return; // This should only happen in tests
const listsClone = {};
const targetCategoryIndex = CATEGORY_ORDER.indexOf(category);
// Micro optimization: Support lazily loading the last timestamp in a room
let _targetTimestamp = null;
const targetTimestamp = () => {
if (_targetTimestamp === null) {
_targetTimestamp = this._tsOfNewestEvent(room);
}
return _targetTimestamp;
};
const targetTags = this._getRecommendedTagsForRoom(room);
const insertedIntoTags = [];
// We need to make sure all the tags (lists) are updated with the room's new position. We
// generally only get called here when there's a new room to insert or a room has potentially
// changed positions within the list.
//
// We do all our checks by iterating over the rooms in the existing lists, trying to insert
// our room where we can. As a guiding principle, we should be removing the room from all
// tags, and insert the room into targetTags. We should perform the deletion before the addition
// where possible to keep a consistent state. By the end of this, targetTags should be the
// same as insertedIntoTags.
for (const key of Object.keys(this._state.lists)) {
const shouldHaveRoom = targetTags.includes(key);
// Speed optimization: Don't do complicated math if we don't have to.
if (!shouldHaveRoom) {
listsClone[key] = this._state.lists[key].filter((e) => e.room.roomId !== room.roomId);
} else if (LIST_ORDERS[key] !== 'recent') {
// Manually ordered tags are sorted later, so for now we'll just clone the tag
// and add our room if needed
listsClone[key] = this._state.lists[key].filter((e) => e.room.roomId !== room.roomId);
listsClone[key].push({room, category});
insertedIntoTags.push(key);
} else {
listsClone[key] = [];
// We track where the boundary within listsClone[key] is just in case our timestamp
// ordering fails. If we can't stick the room in at the correct place in the category
// grouping based on timestamp, we'll stick it at the top of the group which will be
// the index we track here.
let desiredCategoryBoundaryIndex = 0;
let foundBoundary = false;
let pushedEntry = false;
for (const entry of this._state.lists[key]) {
// We insert our own record as needed, so don't let the old one through.
if (entry.room.roomId === room.roomId) {
continue;
}
// if the list is a recent list, and the room appears in this list, and we're
// not looking at a sticky room (sticky rooms have unreliable categories), try
// to slot the new room in
if (entry.room.roomId !== this._state.stickyRoomId) {
if (!pushedEntry && shouldHaveRoom) {
// Micro optimization: Support lazily loading the last timestamp in a room
let _entryTimestamp = null;
const entryTimestamp = () => {
if (_entryTimestamp === null) {
_entryTimestamp = this._tsOfNewestEvent(entry.room);
}
return _entryTimestamp;
};
const entryCategoryIndex = CATEGORY_ORDER.indexOf(entry.category);
// As per above, check if we're meeting that boundary we wanted to locate.
if (entryCategoryIndex >= targetCategoryIndex && !foundBoundary) {
desiredCategoryBoundaryIndex = listsClone[key].length - 1;
foundBoundary = true;
}
// If we've hit the top of a boundary beyond our target category, insert at the top of
// the grouping to ensure the room isn't slotted incorrectly. Otherwise, try to insert
// based on most recent timestamp.
const changedBoundary = entryCategoryIndex > targetCategoryIndex;
const currentCategory = entryCategoryIndex === targetCategoryIndex;
if (changedBoundary || (currentCategory && targetTimestamp() >= entryTimestamp())) {
if (changedBoundary) {
// If we changed a boundary, then we've gone too far - go to the top of the last
// section instead.
listsClone[key].splice(desiredCategoryBoundaryIndex, 0, {room, category});
} else {
// If we're ordering by timestamp, just insert normally
listsClone[key].push({room, category});
}
pushedEntry = true;
insertedIntoTags.push(key);
}
}
}
// Fall through and clone the list.
listsClone[key].push(entry);
}
if (!pushedEntry) {
if (listsClone[key].length === 0) {
listsClone[key].push({room, category});
insertedIntoTags.push(key);
} else {
// In theory, this should never happen
console.warn(`!! Room ${room.roomId} lost: No position available`);
}
}
}
}
// Double check that we inserted the room in the right places
for (const targetTag of targetTags) {
let count = 0;
for (const insertedTag of insertedIntoTags) {
if (insertedTag === targetTag) count++;
}
if (count !== 1) {
console.warn(`!! Room ${room.roomId} inserted ${count} times`);
}
}
// Sort the favourites before we set the clone
for (const tag of Object.keys(listsClone)) {
if (LIST_ORDERS[tag] === 'recent') continue; // skip recents (pre-sorted)
listsClone[tag].sort(this._getManualComparator(tag));
}
this._setState({lists: listsClone});
}
_generateInitialRoomLists() {
// Log something to show that we're throwing away the old results. This is for the inevitable
// question of "why is 100% of my CPU going towards Riot?" - a quick look at the logs would reveal
// that something is wrong with the RoomListStore.
console.log("Generating initial room lists");
const lists = {
"m.server_notice": [],
"im.vector.fake.invite": [],
@ -196,74 +478,84 @@ class RoomListStore extends Store {
"im.vector.fake.archived": [],
};
const dmRoomMap = DMRoomMap.shared();
// If somehow we dispatched a RoomListActions.tagRoom.failure before a MatrixActions.sync
if (!this._matrixClient) return;
// Speed optimization: Hitting the SettingsStore is expensive, so avoid that at all costs.
let _isCustomTagsEnabled = null;
const isCustomTagsEnabled = () => {
if (_isCustomTagsEnabled === null) {
_isCustomTagsEnabled = SettingsStore.isFeatureEnabled("feature_custom_tags");
}
return _isCustomTagsEnabled;
};
const isCustomTagsEnabled = SettingsStore.isFeatureEnabled("feature_custom_tags");
this._matrixClient.getRooms().forEach((room, index) => {
this._matrixClient.getRooms().forEach((room) => {
const myUserId = this._matrixClient.getUserId();
const membership = room.getMyMembership();
const me = room.getMember(myUserId);
if (membership == "invite") {
lists["im.vector.fake.invite"].push(room);
} else if (membership == "join" || membership === "ban" || (me && me.isKicked())) {
if (membership === "invite") {
lists["im.vector.fake.invite"].push({room, category: CATEGORY_RED});
} else if (membership === "join" || membership === "ban" || (me && me.isKicked())) {
// Used to split rooms via tags
let tagNames = Object.keys(room.tags);
if (optimisticRequest && optimisticRequest.room === room) {
// Remove old tag
tagNames = tagNames.filter((tagName) => tagName !== optimisticRequest.oldTag);
// Add new tag
if (optimisticRequest.newTag &&
!tagNames.includes(optimisticRequest.newTag)
) {
tagNames.push(optimisticRequest.newTag);
}
}
// ignore any m. tag names we don't know about
tagNames = tagNames.filter((t) => {
return (isCustomTagsEnabled && !t.startsWith('m.')) || lists[t] !== undefined;
// Speed optimization: Avoid hitting the SettingsStore at all costs by making it the
// last condition possible.
return lists[t] !== undefined || (!t.startsWith('m.') && isCustomTagsEnabled());
});
if (tagNames.length) {
for (let i = 0; i < tagNames.length; i++) {
const tagName = tagNames[i];
lists[tagName] = lists[tagName] || [];
lists[tagName].push(room);
// Default to an arbitrary category for tags which aren't ordered by recents
let category = CATEGORY_IDLE;
if (LIST_ORDERS[tagName] === 'recent') category = this._calculateCategory(room);
lists[tagName].push({room, category: category});
}
} else if (dmRoomMap.getUserIdForRoomId(room.roomId)) {
// "Direct Message" rooms (that we're still in and that aren't otherwise tagged)
lists["im.vector.fake.direct"].push(room);
lists["im.vector.fake.direct"].push({room, category: this._calculateCategory(room)});
} else {
lists["im.vector.fake.recent"].push(room);
lists["im.vector.fake.recent"].push({room, category: this._calculateCategory(room)});
}
} else if (membership === "leave") {
lists["im.vector.fake.archived"].push(room);
// The category of these rooms is not super important, so deprioritize it to the lowest
// possible value.
lists["im.vector.fake.archived"].push({room, category: CATEGORY_IDLE});
}
});
// Note: we check the settings up here instead of in the forEach or
// in the _recentsComparator to avoid hitting the SettingsStore a few
// thousand times.
const pinUnread = SettingsStore.getValue("pinUnreadRooms");
const pinMentioned = SettingsStore.getValue("pinMentionedRooms");
// We use this cache in the recents comparator because _tsOfNewestEvent can take a while. This
// cache only needs to survive the sort operation below and should not be implemented outside
// of this function, otherwise the room lists will almost certainly be out of date and wrong.
const latestEventTsCache = {}; // roomId => timestamp
Object.keys(lists).forEach((listKey) => {
let comparator;
switch (RoomListStore._listOrders[listKey]) {
switch (LIST_ORDERS[listKey]) {
case "recent":
comparator = (roomA, roomB) => {
return this._recentsComparator(roomA, roomB, pinUnread, pinMentioned);
comparator = (entryA, entryB) => {
return this._recentsComparator(entryA, entryB, (room) => {
if (!room) return Number.MAX_SAFE_INTEGER; // Should only happen in tests
if (latestEventTsCache[room.roomId]) {
return latestEventTsCache[room.roomId];
}
const ts = this._tsOfNewestEvent(room);
latestEventTsCache[room.roomId] = ts;
return ts;
});
};
break;
case "manual":
default:
comparator = this._getManualComparator(listKey, optimisticRequest);
comparator = this._getManualComparator(listKey);
break;
}
lists[listKey].sort(comparator);
@ -271,52 +563,10 @@ class RoomListStore extends Store {
this._setState({
lists,
ready: true, // Ready to receive updates via Room.tags events
ready: true, // Ready to receive updates to ordering
});
}
_updateCachedRoomState(roomId, type, value) {
const roomCache = this._state.roomCache;
if (!roomCache[roomId]) roomCache[roomId] = {};
if (typeof value !== "undefined") roomCache[roomId][type] = value;
else delete roomCache[roomId][type];
this._setState({roomCache});
}
_clearCachedRoomState(roomId) {
const roomCache = this._state.roomCache;
delete roomCache[roomId];
this._setState({roomCache});
}
_getRoomState(room, type) {
const roomId = room.roomId;
const roomCache = this._state.roomCache;
if (roomCache[roomId] && typeof roomCache[roomId][type] !== 'undefined') {
return roomCache[roomId][type];
}
if (type === "timestamp") {
const ts = this._tsOfNewestEvent(room);
this._updateCachedRoomState(roomId, "timestamp", ts);
return ts;
} else if (type === "unread-muted") {
const unread = Unread.doesRoomHaveUnreadMessages(room);
this._updateCachedRoomState(roomId, "unread-muted", unread);
return unread;
} else if (type === "unread") {
const unread = room.getUnreadNotificationCount() > 0;
this._updateCachedRoomState(roomId, "unread", unread);
return unread;
} else if (type === "notifications") {
const notifs = room.getUnreadNotificationCount("highlight") > 0;
this._updateCachedRoomState(roomId, "notifications", notifs);
return notifs;
} else throw new Error("Unrecognized room cache type: " + type);
}
_eventTriggersRecentReorder(ev) {
return ev.getTs() && (
Unread.eventTriggersUnreadCount(ev) ||
@ -325,6 +575,10 @@ class RoomListStore extends Store {
}
_tsOfNewestEvent(room) {
// Apparently we can have rooms without timelines, at least under testing
// environments. Just return MAX_INT when this happens.
if (!room || !room.timeline) return Number.MAX_SAFE_INTEGER;
for (let i = room.timeline.length - 1; i >= 0; --i) {
const ev = room.timeline[i];
if (this._eventTriggersRecentReorder(ev)) {
@ -342,53 +596,44 @@ class RoomListStore extends Store {
}
}
_recentsComparator(roomA, roomB, pinUnread, pinMentioned) {
// We try and set the ordering to be Mentioned > Unread > Recent
// assuming the user has the right settings, of course.
const timestampA = this._getRoomState(roomA, "timestamp");
const timestampB = this._getRoomState(roomB, "timestamp");
const timestampDiff = timestampB - timestampA;
if (pinMentioned) {
const mentionsA = this._getRoomState(roomA, "notifications");
const mentionsB = this._getRoomState(roomB, "notifications");
if (mentionsA && !mentionsB) return -1;
if (!mentionsA && mentionsB) return 1;
// If they both have notifications, sort by timestamp.
// If neither have notifications (the fourth check not shown
// here), then try and sort by unread messages and finally by
// timestamp.
if (mentionsA && mentionsB) return timestampDiff;
_calculateCategory(room) {
if (!this._state.orderRoomsByImportance) {
// Effectively disable the categorization of rooms if we're supposed to
// be sorting by more recent messages first. This triggers the timestamp
// comparison bit of _setRoomCategory and _recentsComparator instead of
// the category ordering.
return CATEGORY_IDLE;
}
if (pinUnread) {
let unreadA = this._getRoomState(roomA, "unread");
let unreadB = this._getRoomState(roomB, "unread");
if (unreadA && !unreadB) return -1;
if (!unreadA && unreadB) return 1;
const mentions = room.getUnreadNotificationCount("highlight") > 0;
if (mentions) return CATEGORY_RED;
// If they both have unread messages, sort by timestamp
// If nether have unread message (the fourth check not shown
// here), then just sort by timestamp anyways.
if (unreadA && unreadB) return timestampDiff;
let unread = room.getUnreadNotificationCount() > 0;
if (unread) return CATEGORY_GREY;
// Unread can also mean "unread without badge", which is
// different from what the above checks for. We're also
// going to sort those here.
unreadA = this._getRoomState(roomA, "unread-muted");
unreadB = this._getRoomState(roomB, "unread-muted");
if (unreadA && !unreadB) return -1;
if (!unreadA && unreadB) return 1;
unread = Unread.doesRoomHaveUnreadMessages(room);
if (unread) return CATEGORY_BOLD;
// If they both have unread messages, sort by timestamp
// If nether have unread message (the fourth check not shown
// here), then just sort by timestamp anyways.
if (unreadA && unreadB) return timestampDiff;
return CATEGORY_IDLE;
}
_recentsComparator(entryA, entryB, tsOfNewestEventFn) {
const roomA = entryA.room;
const roomB = entryB.room;
const categoryA = entryA.category;
const categoryB = entryB.category;
if (categoryA !== categoryB) {
const idxA = CATEGORY_ORDER.indexOf(categoryA);
const idxB = CATEGORY_ORDER.indexOf(categoryB);
if (idxA > idxB) return 1;
if (idxA < idxB) return -1;
return 0; // Technically not possible
}
return timestampDiff;
const timestampA = tsOfNewestEventFn(roomA);
const timestampB = tsOfNewestEventFn(roomB);
return timestampB - timestampA;
}
_lexicographicalComparator(roomA, roomB) {
@ -396,7 +641,10 @@ class RoomListStore extends Store {
}
_getManualComparator(tagName, optimisticRequest) {
return (roomA, roomB) => {
return (entryA, entryB) => {
const roomA = entryA.room;
const roomB = entryB.room;
let metaA = roomA.tags[tagName];
let metaB = roomB.tags[tagName];
@ -404,8 +652,8 @@ class RoomListStore extends Store {
if (optimisticRequest && roomB === optimisticRequest.room) metaB = optimisticRequest.metaData;
// Make sure the room tag has an order element, if not set it to be the bottom
const a = metaA ? metaA.order : undefined;
const b = metaB ? metaB.order : undefined;
const a = metaA ? Number(metaA.order) : undefined;
const b = metaB ? Number(metaB.order) : undefined;
// Order undefined room tag orders to the bottom
if (a === undefined && b !== undefined) {
@ -414,12 +662,12 @@ class RoomListStore extends Store {
return -1;
}
return a == b ? this._lexicographicalComparator(roomA, roomB) : ( a > b ? 1 : -1);
return a === b ? this._lexicographicalComparator(roomA, roomB) : (a > b ? 1 : -1);
};
}
getRoomLists() {
return this._state.lists;
return this._state.presentationLists;
}
}

View file

@ -119,7 +119,7 @@ class RoomViewStore extends Store {
case 'open_room_settings': {
const RoomSettingsDialog = sdk.getComponent("dialogs.RoomSettingsDialog");
Modal.createTrackedDialog('Room settings', '', RoomSettingsDialog, {
roomId: this._state.roomId,
roomId: payload.room_id || this._state.roomId,
}, 'mx_SettingsDialog');
break;
}