diff --git a/src/component-index.js b/src/component-index.js
index 50a02e0862..bc3d698cac 100644
--- a/src/component-index.js
+++ b/src/component-index.js
@@ -105,6 +105,8 @@ import views$elements$EditableTextContainer from './components/views/elements/Ed
views$elements$EditableTextContainer && (module.exports.components['views.elements.EditableTextContainer'] = views$elements$EditableTextContainer);
import views$elements$EmojiText from './components/views/elements/EmojiText';
views$elements$EmojiText && (module.exports.components['views.elements.EmojiText'] = views$elements$EmojiText);
+import views$elements$MemberEventListSummary from './components/views/elements/MemberEventListSummary';
+views$elements$MemberEventListSummary && (module.exports.components['views.elements.MemberEventListSummary'] = views$elements$MemberEventListSummary);
import views$elements$PowerSelector from './components/views/elements/PowerSelector';
views$elements$PowerSelector && (module.exports.components['views.elements.PowerSelector'] = views$elements$PowerSelector);
import views$elements$ProgressBar from './components/views/elements/ProgressBar';
diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js
index 31ef15b6dc..298372a4d8 100644
--- a/src/components/structures/MessagePanel.js
+++ b/src/components/structures/MessagePanel.js
@@ -229,6 +229,7 @@ module.exports = React.createClass({
_getEventTiles: function() {
var EventTile = sdk.getComponent('rooms.EventTile');
+ const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary');
this.eventNodes = {};
@@ -275,6 +276,11 @@ module.exports = React.createClass({
this.currentGhostEventId = null;
}
+ var isMembershipChange = (e) =>
+ e.getType() === 'm.room.member'
+ && ['join', 'leave'].indexOf(e.event.content.membership) !== -1
+ && (!e.event.prev_content || e.event.content.membership !== e.event.prev_content.membership);
+
for (i = 0; i < this.props.events.length; i++) {
var mxEv = this.props.events[i];
var wantTile = true;
@@ -286,6 +292,42 @@ module.exports = React.createClass({
var last = (i == lastShownEventIndex);
+ // Wrap consecutive member events in a ListSummary
+ if (isMembershipChange(mxEv)) {
+ let summarisedEvents = [mxEv];
+ i++;
+ for (;i < this.props.events.length; i++) {
+ let collapsedMxEv = this.props.events[i];
+
+ if (!isMembershipChange(collapsedMxEv)) {
+ i--;
+ break;
+ }
+ summarisedEvents.push(collapsedMxEv);
+ }
+ // At this point, i = this.props.events.length OR i = the index of the last
+ // MembershipChange in a sequence of MembershipChanges
+
+ let eventTiles = summarisedEvents.map(
+ (e) => {
+ let ret = this._getTilesForEvent(prevEvent, e);
+ prevEvent = e;
+ return ret;
+ }
+ ).reduce((a,b) => a.concat(b));
+
+ if (eventTiles.length === 0) {
+ eventTiles = null;
+ }
+
+ ret.push(
+
+ {eventTiles}
+
+ );
+ continue;
+ }
+
if (wantTile) {
// make sure we unpack the array returned by _getTilesForEvent,
// otherwise react will auto-generate keys and we will end up
diff --git a/src/components/views/avatars/MemberAvatar.js b/src/components/views/avatars/MemberAvatar.js
index 6e1670604e..7f9630d416 100644
--- a/src/components/views/avatars/MemberAvatar.js
+++ b/src/components/views/avatars/MemberAvatar.js
@@ -19,6 +19,7 @@ limitations under the License.
var React = require('react');
var Avatar = require('../../../Avatar');
var sdk = require("../../../index");
+const dispatcher = require("../../../dispatcher");
module.exports = React.createClass({
displayName: 'MemberAvatar',
@@ -27,14 +28,19 @@ module.exports = React.createClass({
member: React.PropTypes.object.isRequired,
width: React.PropTypes.number,
height: React.PropTypes.number,
- resizeMethod: React.PropTypes.string
+ resizeMethod: React.PropTypes.string,
+ // The onClick to give the avatar
+ onClick: React.PropTypes.function,
+ // Whether the onClick of the avatar should be overriden to dispatch 'view_user'
+ viewUserOnClick: React.PropTypes.boolean,
},
getDefaultProps: function() {
return {
width: 40,
height: 40,
- resizeMethod: 'crop'
+ resizeMethod: 'crop',
+ viewUserOnClick: false,
}
},
@@ -63,11 +69,20 @@ module.exports = React.createClass({
render: function() {
var BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
- var {member, ...otherProps} = this.props;
+ var {member, onClick, ...otherProps} = this.props;
+
+ if (this.props.viewUserOnClick) {
+ onClick = () => {
+ dispatcher.dispatch({
+ action: 'view_user',
+ member: this.props.member,
+ });
+ }
+ }
return (
+ idName={member.userId} url={this.state.imageUrl} onClick={onClick}/>
);
}
});
diff --git a/src/components/views/elements/MemberEventListSummary.js b/src/components/views/elements/MemberEventListSummary.js
new file mode 100644
index 0000000000..f50f56ffb4
--- /dev/null
+++ b/src/components/views/elements/MemberEventListSummary.js
@@ -0,0 +1,212 @@
+/*
+Copyright 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.
+*/
+import React from 'react';
+const MemberAvatar = require('../avatars/MemberAvatar.js');
+
+module.exports = React.createClass({
+ displayName: 'MemberEventListSummary',
+
+ propTypes: {
+ // An array of member events to summarise
+ 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
+ summaryLength: React.PropTypes.number,
+ // The maximum number of avatars to display in the summary
+ avatarsMaxLength: React.PropTypes.number,
+ // The minimum number of events needed to trigger summarisation
+ threshold: React.PropTypes.number,
+ },
+
+ getInitialState: function() {
+ return {
+ expanded: false,
+ };
+ },
+
+ getDefaultProps: function() {
+ return {
+ summaryLength: 3,
+ 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
+
+ );
+ }
+ return (
+
+ {joinSummary}{joinSummary && leaveSummary?'; ':''}
+ {leaveSummary}
+
+ );
+ },
+
+ _renderAvatars: function(events) {
+ let avatars = events.slice(0, this.props.avatarsMaxLength).map((e) => {
+ return (
+
+ );
+ });
+
+ return (
+
+ {avatars}
+
+ );
+ },
+
+ render: function() {
+ let summary = null;
+
+ // Reorder events so that joins come before leaves
+ let eventsToRender = this.props.events;
+
+ // Filter out those who joined, then left
+ let filteredEvents = eventsToRender.filter(
+ (e) => {
+ return eventsToRender.filter(
+ (e2) => {
+ return e.getSender() === e2.getSender()
+ && e.event.content.membership !== e2.event.content.membership;
+ }
+ ).length === 0;
+ }
+ );
+
+ let joinAndLeft = (eventsToRender.length - filteredEvents.length) / 2;
+ if (joinAndLeft <= 0 || joinAndLeft % 1 !== 0) {
+ joinAndLeft = null;
+ }
+
+ let joinEvents = filteredEvents.filter((ev) => {
+ return ev.event.content.membership === 'join';
+ });
+
+ let leaveEvents = filteredEvents.filter((ev) => {
+ return ev.event.content.membership === 'leave';
+ });
+
+ let fewEvents = eventsToRender.length < this.props.threshold;
+ let expanded = this.state.expanded || fewEvents;
+ let expandedEvents = null;
+
+ if (expanded) {
+ expandedEvents = this.props.children;
+ }
+
+ let avatars = this._renderAvatars(joinEvents.concat(leaveEvents));
+
+ let toggleButton = null;
+ let summaryContainer = null;
+ if (!fewEvents) {
+ summary = this._renderSummary(joinEvents, leaveEvents);
+ toggleButton = (
+
+ {expanded ? 'collapse' : 'expand'}
+
+ );
+ let plural = (joinEvents.length + leaveEvents.length > 0) ? 'others' : 'users';
+ let noun = (joinAndLeft === 1 ? 'user' : plural);
+
+ summaryContainer = (
+
+
+
+ {avatars}
+
+
+ {summary}{joinAndLeft? '. ' + joinAndLeft + ' ' + noun + ' joined and left' : ''}
+
+ {toggleButton}
+
+
+ );
+ }
+
+ return (
+
+ {summaryContainer}
+ {expandedEvents}
+
+ );
+ },
+});
diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js
index fb97d863cd..f4167b32f6 100644
--- a/src/components/views/rooms/EventTile.js
+++ b/src/components/views/rooms/EventTile.js
@@ -348,13 +348,6 @@ module.exports = React.createClass({
;
},
- onMemberAvatarClick: function(event) {
- dispatcher.dispatch({
- action: 'view_user',
- member: this.props.mxEvent.sender,
- });
- },
-
onSenderProfileClick: function(event) {
var mxEvent = this.props.mxEvent;
dispatcher.dispatch({
@@ -443,7 +436,7 @@ module.exports = React.createClass({
);