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({
);