- This process will allow you to export the keys for messages
- you have received in encrypted rooms to a local file. You
- will then be able to import the file into another Matrix
- client in the future, so that client will also be able to
- decrypt these messages.
-
-
- The exported file will allow anyone who can read it to decrypt
- any encrypted messages that you can see, so you should be
- careful to keep it secure. To help with this, you should enter
- a passphrase below, which will be used to encrypt the exported
- data. It will only be possible to import the data by using the
- same passphrase.
-
);
}
diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js
index 8753540e48..24c8ff53c0 100644
--- a/src/components/structures/RoomView.js
+++ b/src/components/structures/RoomView.js
@@ -722,15 +722,11 @@ module.exports = React.createClass({
if (!result.displayname) {
var SetDisplayNameDialog = sdk.getComponent('views.dialogs.SetDisplayNameDialog');
var dialog_defer = q.defer();
- var dialog_ref;
Modal.createDialog(SetDisplayNameDialog, {
currentDisplayName: result.displayname,
- ref: (r) => {
- dialog_ref = r;
- },
- onFinished: (submitted) => {
+ onFinished: (submitted, newDisplayName) => {
if (submitted) {
- cli.setDisplayName(dialog_ref.getValue()).done(() => {
+ cli.setDisplayName(newDisplayName).done(() => {
dialog_defer.resolve();
});
}
@@ -1531,6 +1527,7 @@ module.exports = React.createClass({
onResize={this.onChildResize}
onVisible={this.onStatusBarVisible}
onHidden={this.onStatusBarHidden}
+ whoIsTypingLimit={2}
/>;
}
diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js
index 65e41abb24..99f3d9cd3c 100644
--- a/src/components/structures/UserSettings.js
+++ b/src/components/structures/UserSettings.js
@@ -27,6 +27,7 @@ var GeminiScrollbar = require('react-gemini-scrollbar');
var Email = require('../../email');
var AddThreepid = require('../../AddThreepid');
var SdkConfig = require('../../SdkConfig');
+import AccessibleButton from '../views/elements/AccessibleButton';
// if this looks like a release, use the 'version' from package.json; else use
// the git sha.
@@ -229,8 +230,26 @@ module.exports = React.createClass({
},
onLogoutClicked: function(ev) {
- var LogoutPrompt = sdk.getComponent('dialogs.LogoutPrompt');
- this.logoutModal = Modal.createDialog(LogoutPrompt);
+ var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
+ Modal.createDialog(QuestionDialog, {
+ title: "Sign out?",
+ description:
+
+ For security, logging out will delete any end-to-end encryption keys from this browser,
+ making previous encrypted chat history unreadable if you log back in.
+ In future this will be improved,
+ but for now be warned.
+
;
},
@@ -576,10 +638,10 @@ module.exports = React.createClass({
// bind() the invited rooms so any new invites that may come in as this button is clicked
// don't inadvertently get rejected as well.
reject = (
-
+
);
}
@@ -747,9 +809,9 @@ module.exports = React.createClass({
+ Resetting password will currently reset any end-to-end encryption keys on all devices,
+ making encrypted chat history unreadable.
+ In future this may be improved,
+ but for now be warned.
+
,
+ button: "Continue",
+ onFinished: (confirmed) => {
+ if (confirmed) {
+ this.submitPasswordReset(
+ this.state.enteredHomeserverUrl, this.state.enteredIdentityServerUrl,
+ this.state.email, this.state.password
+ );
+ }
+ },
+ });
}
},
diff --git a/src/components/views/avatars/BaseAvatar.js b/src/components/views/avatars/BaseAvatar.js
index 363f340fad..c9c84aa1bf 100644
--- a/src/components/views/avatars/BaseAvatar.js
+++ b/src/components/views/avatars/BaseAvatar.js
@@ -19,6 +19,7 @@ limitations under the License.
var React = require('react');
var AvatarLogic = require("../../../Avatar");
import sdk from '../../../index';
+import AccessibleButton from '../elements/AccessibleButton';
module.exports = React.createClass({
displayName: 'BaseAvatar',
@@ -138,7 +139,7 @@ module.exports = React.createClass({
const {
name, idName, title, url, urls, width, height, resizeMethod,
- defaultToInitialLetter,
+ defaultToInitialLetter, onClick,
...otherProps
} = this.props;
@@ -156,12 +157,24 @@ module.exports = React.createClass({
);
}
- return (
-
- );
+ if (onClick != null) {
+ return (
+
+
+
+ );
+ } else {
+ return (
+
+ );
+ }
}
});
diff --git a/src/components/views/dialogs/BaseDialog.js b/src/components/views/dialogs/BaseDialog.js
new file mode 100644
index 0000000000..2b3980c536
--- /dev/null
+++ b/src/components/views/dialogs/BaseDialog.js
@@ -0,0 +1,72 @@
+/*
+Copyright 2017 Vector Creations Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from 'react';
+
+import * as KeyCode from '../../../KeyCode';
+
+/**
+ * Basic container for modal dialogs.
+ *
+ * Includes a div for the title, and a keypress handler which cancels the
+ * dialog on escape.
+ */
+export default React.createClass({
+ displayName: 'BaseDialog',
+
+ propTypes: {
+ // onFinished callback to call when Escape is pressed
+ onFinished: React.PropTypes.func.isRequired,
+
+ // callback to call when Enter is pressed
+ onEnterPressed: React.PropTypes.func,
+
+ // CSS class to apply to dialog div
+ className: React.PropTypes.string,
+
+ // Title for the dialog.
+ // (could probably actually be something more complicated than a string if desired)
+ title: React.PropTypes.string.isRequired,
+
+ // children should be the content of the dialog
+ children: React.PropTypes.node,
+ },
+
+ _onKeyDown: function(e) {
+ if (e.keyCode === KeyCode.ESCAPE) {
+ e.stopPropagation();
+ e.preventDefault();
+ this.props.onFinished();
+ } else if (e.keyCode === KeyCode.ENTER) {
+ if (this.props.onEnterPressed) {
+ e.stopPropagation();
+ e.preventDefault();
+ this.props.onEnterPressed(e);
+ }
+ }
+ },
+
+ render: function() {
+ return (
+
+
+ { this.props.title }
+
+ { this.props.children }
+
+ );
+ },
+});
diff --git a/src/components/views/dialogs/ChatInviteDialog.js b/src/components/views/dialogs/ChatInviteDialog.js
index f3bfb37c02..2f17445263 100644
--- a/src/components/views/dialogs/ChatInviteDialog.js
+++ b/src/components/views/dialogs/ChatInviteDialog.js
@@ -24,6 +24,7 @@ var DMRoomMap = require('../../../utils/DMRoomMap');
var rate_limited_func = require("../../../ratelimitedfunc");
var dis = require("../../../dispatcher");
var Modal = require('../../../Modal');
+import AccessibleButton from '../elements/AccessibleButton';
const TRUNCATE_QUERY_LIST = 40;
@@ -436,9 +437,10 @@ module.exports = React.createClass({
+
);
},
});
diff --git a/src/components/views/dialogs/LogoutPrompt.js b/src/components/views/dialogs/LogoutPrompt.js
deleted file mode 100644
index c4bd7a0474..0000000000
--- a/src/components/views/dialogs/LogoutPrompt.js
+++ /dev/null
@@ -1,61 +0,0 @@
-/*
-Copyright 2015, 2016 OpenMarket 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.
-*/
-var React = require('react');
-var dis = require("../../../dispatcher");
-
-module.exports = React.createClass({
- displayName: 'LogoutPrompt',
-
- propTypes: {
- onFinished: React.PropTypes.func,
- },
-
- logOut: function() {
- dis.dispatch({action: 'logout'});
- if (this.props.onFinished) {
- this.props.onFinished();
- }
- },
-
- cancelPrompt: function() {
- if (this.props.onFinished) {
- this.props.onFinished();
- }
- },
-
- onKeyDown: function(e) {
- if (e.keyCode === 27) { // escape
- e.stopPropagation();
- e.preventDefault();
- this.cancelPrompt();
- }
- },
-
- render: function() {
- return (
-
-
+
);
- }
+ },
});
diff --git a/src/components/views/dialogs/SetDisplayNameDialog.js b/src/components/views/dialogs/SetDisplayNameDialog.js
index c1041cc218..1047e05c26 100644
--- a/src/components/views/dialogs/SetDisplayNameDialog.js
+++ b/src/components/views/dialogs/SetDisplayNameDialog.js
@@ -14,11 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-var React = require("react");
-var sdk = require("../../../index.js");
-var MatrixClientPeg = require("../../../MatrixClientPeg");
+import React from 'react';
+import sdk from '../../../index';
+import MatrixClientPeg from '../../../MatrixClientPeg';
-module.exports = React.createClass({
+/**
+ * Prompt the user to set a display name.
+ *
+ * On success, `onFinished(true, newDisplayName)` is called.
+ */
+export default React.createClass({
displayName: 'SetDisplayNameDialog',
propTypes: {
onFinished: React.PropTypes.func.isRequired,
@@ -42,10 +47,6 @@ module.exports = React.createClass({
this.refs.input_value.select();
},
- getValue: function() {
- return this.state.value;
- },
-
onValueChange: function(ev) {
this.setState({
value: ev.target.value
@@ -54,16 +55,17 @@ module.exports = React.createClass({
onFormSubmit: function(ev) {
ev.preventDefault();
- this.props.onFinished(true);
+ this.props.onFinished(true, this.state.value);
return false;
},
render: function() {
+ const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return (
-
-
- Set a Display Name
-
+
Your display name is how you'll appear to others when you speak in rooms.
What would you like it to be?
@@ -79,7 +81,7 @@ module.exports = React.createClass({
+
);
- }
+ },
});
diff --git a/src/components/views/elements/AccessibleButton.js b/src/components/views/elements/AccessibleButton.js
new file mode 100644
index 0000000000..ffea8e1ba7
--- /dev/null
+++ b/src/components/views/elements/AccessibleButton.js
@@ -0,0 +1,54 @@
+/*
+ Copyright 2016 Jani Mustonen
+
+ 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';
+
+/**
+ * AccessibleButton is a generic wrapper for any element that should be treated
+ * as a button. Identifies the element as a button, setting proper tab
+ * indexing and keyboard activation behavior.
+ *
+ * @param {Object} props react element properties
+ * @returns {Object} rendered react
+ */
+export default function AccessibleButton(props) {
+ const {element, onClick, children, ...restProps} = props;
+ restProps.onClick = onClick;
+ restProps.onKeyDown = function(e) {
+ if (e.keyCode == 13 || e.keyCode == 32) return onClick();
+ };
+ restProps.tabIndex = restProps.tabIndex || "0";
+ restProps.role = "button";
+ return React.createElement(element, restProps, children);
+}
+
+/**
+ * children: React's magic prop. Represents all children given to the element.
+ * element: (optional) The base element type. "div" by default.
+ * onClick: (required) Event handler for button activation. Should be
+ * implemented exactly like a normal onClick handler.
+ */
+AccessibleButton.propTypes = {
+ children: React.PropTypes.node,
+ element: React.PropTypes.string,
+ onClick: React.PropTypes.func.isRequired,
+};
+
+AccessibleButton.defaultProps = {
+ element: 'div',
+};
+
+AccessibleButton.displayName = "AccessibleButton";
diff --git a/src/components/views/elements/MemberEventListSummary.js b/src/components/views/elements/MemberEventListSummary.js
index 518439b1c7..61fa0e076f 100644
--- a/src/components/views/elements/MemberEventListSummary.js
+++ b/src/components/views/elements/MemberEventListSummary.js
@@ -24,7 +24,7 @@ module.exports = React.createClass({
events: React.PropTypes.array.isRequired,
// An array of EventTiles to render when expanded
children: React.PropTypes.array.isRequired,
- // The maximum number of names to show in either the join or leave summaries
+ // The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left"
summaryLength: React.PropTypes.number,
// The maximum number of avatars to display in the summary
avatarsMaxLength: React.PropTypes.number,
@@ -40,110 +40,12 @@ module.exports = React.createClass({
getDefaultProps: function() {
return {
- summaryLength: 3,
+ summaryLength: 1,
threshold: 3,
avatarsMaxLength: 5,
};
},
- _toggleSummary: function() {
- this.setState({
- expanded: !this.state.expanded,
- });
- },
-
- _getEventSenderName: function(ev) {
- if (!ev) {
- return 'undefined';
- }
- return ev.sender.name || ev.event.content.displayname || ev.getSender();
- },
-
- _renderNameList: function(events) {
- if (events.length === 0) {
- return null;
- }
- let originalNumber = events.length;
- events = events.slice(0, this.props.summaryLength);
- let lastEvent = events.pop();
-
- let names = events.map((ev) => {
- return this._getEventSenderName(ev);
- }).join(', ');
-
- let lastName = this._getEventSenderName(lastEvent);
- if (names.length === 0) {
- // special-case for a single event
- return lastName;
- }
-
- let remaining = originalNumber - this.props.summaryLength;
- if (remaining > 0) {
- // name1, name2, name3, and 100 others
- return names + ', ' + lastName + ', and ' + remaining + ' others';
- } else {
- // name1, name2 and name3
- return names + ' and ' + lastName;
- }
- },
-
- _renderSummary: function(joinEvents, leaveEvents) {
- let joiners = this._renderNameList(joinEvents);
- let leavers = this._renderNameList(leaveEvents);
-
- let joinSummary = null;
- if (joiners) {
- joinSummary = (
-
- {joiners} joined the room
-
- );
- }
- let leaveSummary = null;
- if (leavers) {
- leaveSummary = (
-
- {leavers} left the room
-
- );
- }
-
- // The joinEvents and leaveEvents are representative of the net movement
- // per-user, and so it is possible that the total net movement is nil,
- // whilst there are some events in the expanded list. If the total net
- // movement is nil, then neither joinSummary nor leaveSummary will be
- // truthy, so return null.
- if (!joinSummary && !leaveSummary) {
- return null;
- }
-
- return (
-
- {joinSummary}{joinSummary && leaveSummary?'; ':''}
- {leaveSummary}.
-
- );
- },
-
- _renderAvatars: function(events) {
- let avatars = events.slice(0, this.props.avatarsMaxLength).map((e) => {
- return (
-
- );
- });
-
- return (
-
- {avatars}
-
- );
- },
-
shouldComponentUpdate: function(nextProps, nextState) {
// Update if
// - The number of summarised events has changed
@@ -157,10 +59,296 @@ module.exports = React.createClass({
);
},
+ _toggleSummary: function() {
+ this.setState({
+ expanded: !this.state.expanded,
+ });
+ },
+
+ /**
+ * Render the JSX for users aggregated by their transition sequences (`eventAggregates`) where
+ * the sequences are ordered by `orderedTransitionSequences`.
+ * @param {object[]} eventAggregates a map of transition sequence to array of user display names
+ * or user IDs.
+ * @param {string[]} orderedTransitionSequences an array which is some ordering of
+ * `Object.keys(eventAggregates)`.
+ * @returns {ReactElement} a single containing the textual summary of the aggregated
+ * events that occurred.
+ */
+ _renderSummary: function(eventAggregates, orderedTransitionSequences) {
+ const summaries = orderedTransitionSequences.map((transitions) => {
+ const userNames = eventAggregates[transitions];
+ const nameList = this._renderNameList(userNames);
+ const plural = userNames.length > 1;
+
+ const splitTransitions = transitions.split(',');
+
+ // Some neighbouring transitions are common, so canonicalise some into "pair"
+ // transitions
+ const canonicalTransitions = this._getCanonicalTransitions(splitTransitions);
+ // Transform into consecutive repetitions of the same transition (like 5
+ // consecutive 'joined_and_left's)
+ const coalescedTransitions = this._coalesceRepeatedTransitions(
+ canonicalTransitions
+ );
+
+ const descs = coalescedTransitions.map((t) => {
+ return this._getDescriptionForTransition(
+ t.transitionType, plural, t.repeats
+ );
+ });
+
+ const desc = this._renderCommaSeparatedList(descs);
+
+ return nameList + " " + desc;
+ });
+
+ if (!summaries) {
+ return null;
+ }
+
+ return (
+
+ {summaries.join(", ")}
+
+ );
+ },
+
+ /**
+ * @param {string[]} users an array of user display names or user IDs.
+ * @returns {string} a comma-separated list that ends with "and [n] others" if there are
+ * more items in `users` than `this.props.summaryLength`, which is the number of names
+ * included before "and [n] others".
+ */
+ _renderNameList: function(users) {
+ return this._renderCommaSeparatedList(users, this.props.summaryLength);
+ },
+
+ /**
+ * Canonicalise an array of transitions such that some pairs of transitions become
+ * single transitions. For example an input ['joined','left'] would result in an output
+ * ['joined_and_left'].
+ * @param {string[]} transitions an array of transitions.
+ * @returns {string[]} an array of transitions.
+ */
+ _getCanonicalTransitions: function(transitions) {
+ const modMap = {
+ 'joined': {
+ 'after': 'left',
+ 'newTransition': 'joined_and_left',
+ },
+ 'left': {
+ 'after': 'joined',
+ 'newTransition': 'left_and_joined',
+ },
+ // $currentTransition : {
+ // 'after' : $nextTransition,
+ // 'newTransition' : 'new_transition_type',
+ // },
+ };
+ const res = [];
+
+ for (let i = 0; i < transitions.length; i++) {
+ const t = transitions[i];
+ const t2 = transitions[i + 1];
+
+ let transition = t;
+
+ if (i < transitions.length - 1 && modMap[t] && modMap[t].after === t2) {
+ transition = modMap[t].newTransition;
+ i++;
+ }
+
+ res.push(transition);
+ }
+ return res;
+ },
+
+ /**
+ * Transform an array of transitions into an array of transitions and how many times
+ * they are repeated consecutively.
+ *
+ * An array of 123 "joined_and_left" transitions, would result in:
+ * ```
+ * [{
+ * transitionType: "joined_and_left"
+ * repeats: 123
+ * }]
+ * ```
+ * @param {string[]} transitions the array of transitions to transform.
+ * @returns {object[]} an array of coalesced transitions.
+ */
+ _coalesceRepeatedTransitions: function(transitions) {
+ const res = [];
+ for (let i = 0; i < transitions.length; i++) {
+ if (res.length > 0 && res[res.length - 1].transitionType === transitions[i]) {
+ res[res.length - 1].repeats += 1;
+ } else {
+ res.push({
+ transitionType: transitions[i],
+ repeats: 1,
+ });
+ }
+ }
+ return res;
+ },
+
+ /**
+ * For a certain transition, t, describe what happened to the users that
+ * underwent the transition.
+ * @param {string} t the transition type.
+ * @param {boolean} plural whether there were multiple users undergoing the same
+ * transition.
+ * @param {number} repeats the number of times the transition was repeated in a row.
+ * @returns {string} the written English equivalent of the transition.
+ */
+ _getDescriptionForTransition(t, plural, repeats) {
+ const beConjugated = plural ? "were" : "was";
+ const invitation = "their invitation" + (plural || (repeats > 1) ? "s" : "");
+
+ let res = null;
+ const map = {
+ "joined": "joined",
+ "left": "left",
+ "joined_and_left": "joined and left",
+ "left_and_joined": "left and rejoined",
+ "invite_reject": "rejected " + invitation,
+ "invite_withdrawal": "had " + invitation + " withdrawn",
+ "invited": beConjugated + " invited",
+ "banned": beConjugated + " banned",
+ "unbanned": beConjugated + " unbanned",
+ "kicked": beConjugated + " kicked",
+ };
+
+ if (Object.keys(map).includes(t)) {
+ res = map[t] + (repeats > 1 ? " " + repeats + " times" : "" );
+ }
+
+ return res;
+ },
+
+ /**
+ * Constructs a written English string representing `items`, with an optional limit on
+ * the number of items included in the result. If specified and if the length of
+ *`items` is greater than the limit, the string "and n others" will be appended onto
+ * the result.
+ * If `items` is empty, returns the empty string. If there is only one item, return
+ * it.
+ * @param {string[]} items the items to construct a string from.
+ * @param {number?} itemLimit the number by which to limit the list.
+ * @returns {string} a string constructed by joining `items` with a comma between each
+ * item, but with the last item appended as " and [lastItem]".
+ */
+ _renderCommaSeparatedList(items, itemLimit) {
+ const remaining = itemLimit === undefined ? 0 : Math.max(
+ items.length - itemLimit, 0
+ );
+ if (items.length === 0) {
+ return "";
+ } else if (items.length === 1) {
+ return items[0];
+ } else if (remaining) {
+ items = items.slice(0, itemLimit);
+ const other = " other" + (remaining > 1 ? "s" : "");
+ return items.join(', ') + ' and ' + remaining + other;
+ } else {
+ const lastItem = items.pop();
+ return items.join(', ') + ' and ' + lastItem;
+ }
+ },
+
+ _renderAvatars: function(roomMembers) {
+ const avatars = roomMembers.slice(0, this.props.avatarsMaxLength).map((m) => {
+ return (
+
+ );
+ });
+ return (
+
+ {avatars}
+
+ );
+ },
+
+ _getTransitionSequence: function(events) {
+ return events.map(this._getTransition);
+ },
+
+ /**
+ * Label a given membership event, `e`, where `getContent().membership` has
+ * changed for each transition allowed by the Matrix protocol. This attempts to
+ * label the membership changes that occur in `../../../TextForEvent.js`.
+ * @param {MatrixEvent} e the membership change event to label.
+ * @returns {string?} the transition type given to this event. This defaults to `null`
+ * if a transition is not recognised.
+ */
+ _getTransition: function(e) {
+ switch (e.mxEvent.getContent().membership) {
+ case 'invite': return 'invited';
+ case 'ban': return 'banned';
+ case 'join': return 'joined';
+ case 'leave':
+ if (e.mxEvent.getSender() === e.mxEvent.getStateKey()) {
+ switch (e.mxEvent.getPrevContent().membership) {
+ case 'invite': return 'invite_reject';
+ default: return 'left';
+ }
+ }
+ switch (e.mxEvent.getPrevContent().membership) {
+ case 'invite': return 'invite_withdrawal';
+ case 'ban': return 'unbanned';
+ case 'join': return 'kicked';
+ default: return 'left';
+ }
+ default: return null;
+ }
+ },
+
+ _getAggregate: function(userEvents) {
+ // A map of aggregate type to arrays of display names. Each aggregate type
+ // is a comma-delimited string of transitions, e.g. "joined,left,kicked".
+ // The array of display names is the array of users who went through that
+ // sequence during eventsToRender.
+ const aggregate = {
+ // $aggregateType : []:string
+ };
+ // A map of aggregate types to the indices that order them (the index of
+ // the first event for a given transition sequence)
+ const aggregateIndices = {
+ // $aggregateType : int
+ };
+
+ const users = Object.keys(userEvents);
+ users.forEach(
+ (userId) => {
+ const firstEvent = userEvents[userId][0];
+ const displayName = firstEvent.displayName;
+
+ const seq = this._getTransitionSequence(userEvents[userId]);
+ if (!aggregate[seq]) {
+ aggregate[seq] = [];
+ aggregateIndices[seq] = -1;
+ }
+
+ aggregate[seq].push(displayName);
+
+ if (aggregateIndices[seq] === -1 ||
+ firstEvent.index < aggregateIndices[seq]) {
+ aggregateIndices[seq] = firstEvent.index;
+ }
+ }
+ );
+
+ return {
+ names: aggregate,
+ indices: aggregateIndices,
+ };
+ },
+
render: function() {
- let eventsToRender = this.props.events;
- let fewEvents = eventsToRender.length < this.props.threshold;
- let expanded = this.state.expanded || fewEvents;
+ const eventsToRender = this.props.events;
+ const fewEvents = eventsToRender.length < this.props.threshold;
+ const expanded = this.state.expanded || fewEvents;
let expandedEvents = null;
if (expanded) {
@@ -175,70 +363,56 @@ module.exports = React.createClass({
);
}
- // Map user IDs to the first and last member events in eventsToRender for each user
- let userEvents = {
- // $userId : {first : e0, last : e1}
+ // Map user IDs to an array of objects:
+ const userEvents = {
+ // $userId : [{
+ // // The original event
+ // mxEvent: e,
+ // // The display name of the user (if not, then user ID)
+ // displayName: e.target.name || userId,
+ // // The original index of the event in this.props.events
+ // index: index,
+ // }]
};
- eventsToRender.forEach((e) => {
+ const avatarMembers = [];
+ eventsToRender.forEach((e, index) => {
const userId = e.getStateKey();
// Initialise a user's events
if (!userEvents[userId]) {
- userEvents[userId] = {first: null, last: null};
+ userEvents[userId] = [];
+ avatarMembers.push(e.target);
}
- if (!userEvents[userId].first) {
- userEvents[userId].first = e;
- }
- userEvents[userId].last = e;
+ userEvents[userId].push({
+ mxEvent: e,
+ displayName: e.target.name || userId,
+ index: index,
+ });
});
- // Populate the join/leave event arrays with events that represent what happened
- // overall to a user's membership. If no events are added to either array for a
- // particular user, they will be considered a user that "joined and left".
- let joinEvents = [];
- let leaveEvents = [];
- let joinedAndLeft = 0;
- let senders = Object.keys(userEvents);
- senders.forEach(
- (userId) => {
- let firstEvent = userEvents[userId].first;
- let lastEvent = userEvents[userId].last;
+ const aggregate = this._getAggregate(userEvents);
- // Membership BEFORE eventsToRender
- let previousMembership = firstEvent.getPrevContent().membership || "leave";
-
- // If the last membership event differs from previousMembership, use that.
- if (previousMembership !== lastEvent.getContent().membership) {
- if (lastEvent.event.content.membership === 'join') {
- joinEvents.push(lastEvent);
- } else if (lastEvent.event.content.membership === 'leave') {
- leaveEvents.push(lastEvent);
- }
- } else {
- // Increment the number of users whose membership change was nil overall
- joinedAndLeft++;
- }
- }
+ // Sort types by order of lowest event index within sequence
+ const orderedTransitionSequences = Object.keys(aggregate.names).sort(
+ (seq1, seq2) => aggregate.indices[seq1] > aggregate.indices[seq2]
);
- let avatars = this._renderAvatars(joinEvents.concat(leaveEvents));
- let summary = this._renderSummary(joinEvents, leaveEvents);
- let toggleButton = (
+ const avatars = this._renderAvatars(avatarMembers);
+ const summary = this._renderSummary(aggregate.names, orderedTransitionSequences);
+ const toggleButton = (
{expanded ? 'collapse' : 'expand'}
);
- let plural = (joinEvents.length + leaveEvents.length > 0) ? 'others' : 'users';
- let noun = (joinedAndLeft === 1 ? 'user' : plural);
- let summaryContainer = (
+ const summaryContainer = (
{avatars}
- {summary}{joinedAndLeft ? joinedAndLeft + ' ' + noun + ' joined and left' : ''}
+ {summary}
{toggleButton}
diff --git a/src/components/views/elements/TintableSvg.js b/src/components/views/elements/TintableSvg.js
index 0157131506..401a11c1cb 100644
--- a/src/components/views/elements/TintableSvg.js
+++ b/src/components/views/elements/TintableSvg.js
@@ -69,6 +69,7 @@ var TintableSvg = React.createClass({
width={ this.props.width }
height={ this.props.height }
onLoad={ this.onLoad }
+ tabIndex="-1"
/>
);
}
diff --git a/src/components/views/rooms/EntityTile.js b/src/components/views/rooms/EntityTile.js
index d29137ffc2..71e8fb0be7 100644
--- a/src/components/views/rooms/EntityTile.js
+++ b/src/components/views/rooms/EntityTile.js
@@ -20,6 +20,7 @@ var React = require('react');
var MatrixClientPeg = require('../../../MatrixClientPeg');
var sdk = require('../../../index');
+import AccessibleButton from '../elements/AccessibleButton';
var PRESENCE_CLASS = {
@@ -152,7 +153,7 @@ module.exports = React.createClass({
var av = this.props.avatarJsx || ;
return (
-
;
+ ;
}
// TODO: we should have an invite button if this MemberInfo is showing a user who isn't actually in the current room yet
@@ -682,7 +694,7 @@ module.exports = WithMatrixClient(React.createClass({
const EmojiText = sdk.getComponent('elements.EmojiText');
return (
);
diff --git a/src/components/views/rooms/SimpleRoomHeader.js b/src/components/views/rooms/SimpleRoomHeader.js
index 4c63be5b99..bc2f4bca69 100644
--- a/src/components/views/rooms/SimpleRoomHeader.js
+++ b/src/components/views/rooms/SimpleRoomHeader.js
@@ -19,6 +19,7 @@ limitations under the License.
var React = require('react');
var sdk = require('../../../index');
var dis = require("../../../dispatcher");
+import AccessibleButton from '../elements/AccessibleButton';
/*
* A stripped-down room header used for things like the user settings
@@ -44,7 +45,7 @@ module.exports = React.createClass({
var cancelButton;
if (this.props.onCancelClick) {
- cancelButton =
;
+ cancelButton = ;
}
var showRhsButton;
@@ -70,4 +71,3 @@ module.exports = React.createClass({
);
},
});
-
diff --git a/src/components/views/settings/ChangePassword.js b/src/components/views/settings/ChangePassword.js
index a011d5262e..8b53a0e779 100644
--- a/src/components/views/settings/ChangePassword.js
+++ b/src/components/views/settings/ChangePassword.js
@@ -18,7 +18,9 @@ limitations under the License.
var React = require('react');
var MatrixClientPeg = require("../../../MatrixClientPeg");
+var Modal = require("../../../Modal");
var sdk = require("../../../index");
+import AccessibleButton from '../elements/AccessibleButton';
module.exports = React.createClass({
displayName: 'ChangePassword',
@@ -65,26 +67,42 @@ module.exports = React.createClass({
changePassword: function(old_password, new_password) {
var cli = MatrixClientPeg.get();
- var authDict = {
- type: 'm.login.password',
- user: cli.credentials.userId,
- password: old_password
- };
+ var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
+ Modal.createDialog(QuestionDialog, {
+ title: "Warning",
+ description:
+
+ Changing password will currently reset any end-to-end encryption keys on all devices,
+ making encrypted chat history unreadable.
+ This will be improved shortly,
+ but for now be warned.
+
);
case this.Phases.Uploading:
diff --git a/src/dispatcher.js b/src/dispatcher.js
index ed0350fe54..9864cb3807 100644
--- a/src/dispatcher.js
+++ b/src/dispatcher.js
@@ -28,7 +28,6 @@ class MatrixDispatcher extends flux.Dispatcher {
* for.
*/
dispatch(payload, sync) {
- console.log("Dispatch: "+payload.action);
if (sync) {
super.dispatch(payload);
} else {
@@ -42,6 +41,9 @@ class MatrixDispatcher extends flux.Dispatcher {
}
}
+// XXX this is a big anti-pattern, and makes testing hard. Because dispatches
+// happen asynchronously, it is possible for actions dispatched in one thread
+// to arrive in another, with *hilarious* consequences.
if (global.mxDispatcher === undefined) {
global.mxDispatcher = new MatrixDispatcher();
}
diff --git a/src/index.js b/src/index.js
index 5d4145a39b..b6d8c0b5f4 100644
--- a/src/index.js
+++ b/src/index.js
@@ -27,28 +27,3 @@ module.exports.resetSkin = function() {
module.exports.getComponent = function(componentName) {
return Skinner.getComponent(componentName);
};
-
-
-/* hacky functions for megolm import/export until we give it a UI */
-import * as MegolmExportEncryption from './utils/MegolmExportEncryption';
-import MatrixClientPeg from './MatrixClientPeg';
-
-window.exportKeys = function(password) {
- return MatrixClientPeg.get().exportRoomKeys().then((k) => {
- return MegolmExportEncryption.encryptMegolmKeyFile(
- JSON.stringify(k), password
- );
- }).then((f) => {
- console.log(new TextDecoder().decode(new Uint8Array(f)));
- }).done();
-};
-
-window.importKeys = function(password, data) {
- const arrayBuffer = new TextEncoder().encode(data).buffer;
- return MegolmExportEncryption.decryptMegolmKeyFile(
- arrayBuffer, password
- ).then((j) => {
- const k = JSON.parse(j);
- return MatrixClientPeg.get().importRoomKeys(k);
- });
-};
diff --git a/test/components/views/elements/MemberEventListSummary-test.js b/test/components/views/elements/MemberEventListSummary-test.js
new file mode 100644
index 0000000000..d01d705040
--- /dev/null
+++ b/test/components/views/elements/MemberEventListSummary-test.js
@@ -0,0 +1,681 @@
+const expect = require('expect');
+const React = require('react');
+const ReactDOM = require("react-dom");
+const ReactTestUtils = require('react-addons-test-utils');
+const sdk = require('matrix-react-sdk');
+const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary');
+
+const testUtils = require('../../../test-utils');
+describe('MemberEventListSummary', function() {
+ let sandbox;
+
+ // Generate dummy event tiles for use in simulating an expanded MELS
+ const generateTiles = (events) => {
+ return events.map((e) => {
+ return (
+
+ Expanded membership
+
+ );
+ });
+ };
+
+ /**
+ * Generates a membership event with the target of the event set as a mocked
+ * RoomMember based on `parameters.userId`.
+ * @param {string} eventId the ID of the event.
+ * @param {object} parameters the parameters to use to create the event.
+ * @param {string} parameters.membership the membership to assign to
+ * `content.membership`
+ * @param {string} parameters.userId the state key and target userId of the event. If
+ * `parameters.senderId` is not specified, this is also used as the event sender.
+ * @param {string} parameters.prevMembership the membership to assign to
+ * `prev_content.membership`.
+ * @param {string} parameters.senderId the user ID of the sender of the event.
+ * Optional. Defaults to `parameters.userId`.
+ * @returns {MatrixEvent} the event created.
+ */
+ const generateMembershipEvent = (eventId, parameters) => {
+ const e = testUtils.mkMembership({
+ event: true,
+ user: parameters.senderId || parameters.userId,
+ skey: parameters.userId,
+ mship: parameters.membership,
+ prevMship: parameters.prevMembership,
+ target: {
+ // Use localpart as display name
+ name: parameters.userId.match(/@([^:]*):/)[1],
+ userId: parameters.userId,
+ getAvatarUrl: () => {
+ return "avatar.jpeg";
+ },
+ },
+ });
+ // Override random event ID to allow for equality tests against tiles from
+ // generateTiles
+ e.event.event_id = eventId;
+ return e;
+ };
+
+ // Generate mock MatrixEvents from the array of parameters
+ const generateEvents = (parameters) => {
+ const res = [];
+ for (let i = 0; i < parameters.length; i++) {
+ res.push(generateMembershipEvent(`event${i}`, parameters[i]));
+ }
+ return res;
+ };
+
+ // Generate the same sequence of `events` for `n` users, where each user ID
+ // is created by replacing the first "$" in userIdTemplate with `i` for
+ // `i = 0 .. n`.
+ const generateEventsForUsers = (userIdTemplate, n, events) => {
+ let eventsForUsers = [];
+ let userId = "";
+ for (let i = 0; i < n; i++) {
+ userId = userIdTemplate.replace('$', i);
+ events.forEach((e) => {
+ e.userId = userId;
+ });
+ eventsForUsers = eventsForUsers.concat(generateEvents(events));
+ }
+ return eventsForUsers;
+ };
+
+ beforeEach(function() {
+ testUtils.beforeEach(this);
+ sandbox = testUtils.stubClient();
+ });
+
+ afterEach(function() {
+ sandbox.restore();
+ });
+
+ it('renders expanded events if there are less than props.threshold', function() {
+ const events = generateEvents([
+ {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"},
+ ]);
+ const props = {
+ events: events,
+ children: generateTiles(events),
+ summaryLength: 1,
+ avatarsMaxLength: 5,
+ threshold: 3,
+ };
+
+ const renderer = ReactTestUtils.createRenderer();
+ renderer.render();
+ const result = renderer.getRenderOutput();
+
+ expect(result.type).toBe('div');
+ expect(result.props.children).toEqual([
+