diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index 64b0a8e875..dcebe38fa4 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -281,7 +281,6 @@ module.exports = React.createClass({ var isMembershipChange = (e) => e.getType() === 'm.room.member' - && ['join', 'leave'].indexOf(e.getContent().membership) !== -1 && (!e.getPrevContent() || e.getContent().membership !== e.getPrevContent().membership); for (i = 0; i < this.props.events.length; i++) { 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/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([ +
Expanded membership
, + ]); + }); + + it('renders expanded events if there are less than props.threshold', function() { + const events = generateEvents([ + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + ]); + 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([ +
Expanded membership
, +
Expanded membership
, + ]); + }); + + it('renders collapsed events if events.length = props.threshold', function() { + const events = generateEvents([ + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 1, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe("user_1 joined and left and joined"); + }); + + it('truncates long join,leave repetitions', function() { + const events = generateEvents([ + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 1, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe("user_1 joined and left 7 times"); + }); + + it('truncates long join,leave repetitions between other events', function() { + const events = generateEvents([ + { + userId: "@user_1:some.domain", + prevMembership: "ban", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + { + userId: "@user_1:some.domain", + prevMembership: "leave", + membership: "invite", + senderId: "@some_other_user:some.domain", + }, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 1, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe( + "user_1 was unbanned, joined and left 7 times and was invited" + ); + }); + + it('truncates multiple sequences of repetitions with other events between', + function() { + const events = generateEvents([ + { + userId: "@user_1:some.domain", + prevMembership: "ban", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + { + userId: "@user_1:some.domain", + prevMembership: "leave", + membership: "ban", + senderId: "@some_other_user:some.domain", + }, + {userId: "@user_1:some.domain", prevMembership: "ban", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + { + userId: "@user_1:some.domain", + prevMembership: "leave", + membership: "invite", + senderId: "@some_other_user:some.domain", + }, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 1, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe( + "user_1 was unbanned, joined and left 2 times, was banned, " + + "joined and left 3 times and was invited" + ); + }); + + it('handles multiple users following the same sequence of memberships', function() { + const events = generateEvents([ + // user_1 + { + userId: "@user_1:some.domain", + prevMembership: "ban", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + { + userId: "@user_1:some.domain", + prevMembership: "leave", + membership: "ban", + senderId: "@some_other_user:some.domain", + }, + // user_2 + { + userId: "@user_2:some.domain", + prevMembership: "ban", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + {userId: "@user_2:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_2:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_2:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_2:some.domain", prevMembership: "join", membership: "leave"}, + { + userId: "@user_2:some.domain", + prevMembership: "leave", + membership: "ban", + senderId: "@some_other_user:some.domain", + }, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 1, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe( + "user_1 and 1 other were unbanned, joined and left 2 times and were banned" + ); + }); + + it('handles many users following the same sequence of memberships', function() { + const events = generateEventsForUsers("@user_$:some.domain", 20, [ + { + prevMembership: "ban", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + {prevMembership: "leave", membership: "join"}, + {prevMembership: "join", membership: "leave"}, + {prevMembership: "leave", membership: "join"}, + {prevMembership: "join", membership: "leave"}, + { + prevMembership: "leave", + membership: "ban", + senderId: "@some_other_user:some.domain", + }, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 1, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe( + "user_0 and 19 others were unbanned, joined and left 2 times and were banned" + ); + }); + + it('correctly orders sequences of transitions by the order of their first event', + function() { + const events = generateEvents([ + { + userId: "@user_2:some.domain", + prevMembership: "ban", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + { + userId: "@user_1:some.domain", + prevMembership: "ban", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + { + userId: "@user_1:some.domain", + prevMembership: "leave", + membership: "ban", + senderId: "@some_other_user:some.domain", + }, + {userId: "@user_2:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_2:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_2:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_2:some.domain", prevMembership: "join", membership: "leave"}, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 1, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe( + "user_2 was unbanned and joined and left 2 times, user_1 was unbanned, " + + "joined and left 2 times and was banned" + ); + }); + + it('correctly identifies transitions', function() { + const events = generateEvents([ + // invited + {userId: "@user_1:some.domain", membership: "invite"}, + // banned + {userId: "@user_1:some.domain", membership: "ban"}, + // joined + {userId: "@user_1:some.domain", membership: "join"}, + // invite_reject + { + userId: "@user_1:some.domain", + prevMembership: "invite", + membership: "leave", + }, + // left + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + // invite_withdrawal + { + userId: "@user_1:some.domain", + prevMembership: "invite", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + // unbanned + { + userId: "@user_1:some.domain", + prevMembership: "ban", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + // kicked + { + userId: "@user_1:some.domain", + prevMembership: "join", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + // default = left + { + userId: "@user_1:some.domain", + prevMembership: "????", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 1, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe( + "user_1 was invited, was banned, joined, rejected their invitation, left, " + + "had their invitation withdrawn, was unbanned, was kicked and left" + ); + }); + + it('handles invitation plurals correctly when there are multiple users', function() { + const events = generateEvents([ + { + userId: "@user_1:some.domain", + prevMembership: "invite", + membership: "leave", + }, + { + userId: "@user_1:some.domain", + prevMembership: "invite", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + { + userId: "@user_2:some.domain", + prevMembership: "invite", + membership: "leave", + }, + { + userId: "@user_2:some.domain", + prevMembership: "invite", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 1, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe( + "user_1 and 1 other rejected their invitations and " + + "had their invitations withdrawn" + ); + }); + + it('handles invitation plurals correctly when there are multiple invites', + function() { + const events = generateEvents([ + { + userId: "@user_1:some.domain", + prevMembership: "invite", + membership: "leave", + }, + { + userId: "@user_1:some.domain", + prevMembership: "invite", + membership: "leave", + }, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 1, + avatarsMaxLength: 5, + threshold: 1, // threshold = 1 to force collapse + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe( + "user_1 rejected their invitations 2 times" + ); + }); + + it('handles a summary length = 2, with no "others"', function() { + const events = generateEvents([ + {userId: "@user_1:some.domain", membership: "join"}, + {userId: "@user_1:some.domain", membership: "join"}, + {userId: "@user_2:some.domain", membership: "join"}, + {userId: "@user_2:some.domain", membership: "join"}, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 2, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe( + "user_1 and user_2 joined 2 times" + ); + }); + + it('handles a summary length = 2, with 1 "other"', function() { + const events = generateEvents([ + {userId: "@user_1:some.domain", membership: "join"}, + {userId: "@user_2:some.domain", membership: "join"}, + {userId: "@user_3:some.domain", membership: "join"}, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 2, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe( + "user_1, user_2 and 1 other joined" + ); + }); + + it('handles a summary length = 2, with many "others"', function() { + const events = generateEventsForUsers("@user_$:some.domain", 20, [ + {membership: "join"}, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 2, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe( + "user_0, user_1 and 18 others joined" + ); + }); +}); diff --git a/test/test-utils.js b/test/test-utils.js index db405c2e1a..cdfae4421c 100644 --- a/test/test-utils.js +++ b/test/test-utils.js @@ -108,6 +108,7 @@ export function mkEvent(opts) { room_id: opts.room, sender: opts.user, content: opts.content, + prev_content: opts.prev_content, event_id: "$" + Math.random() + "-" + Math.random(), origin_server_ts: opts.ts, }; @@ -150,7 +151,9 @@ export function mkPresence(opts) { * @param {Object} opts Values for the membership. * @param {string} opts.room The room ID for the event. * @param {string} opts.mship The content.membership for the event. + * @param {string} opts.prevMship The prev_content.membership for the event. * @param {string} opts.user The user ID for the event. + * @param {RoomMember} opts.target The target of the event. * @param {string} opts.skey The other user ID for the event if applicable * e.g. for invites/bans. * @param {string} opts.name The content.displayname for the event. @@ -169,9 +172,16 @@ export function mkMembership(opts) { opts.content = { membership: opts.mship }; + if (opts.prevMship) { + opts.prev_content = { membership: opts.prevMship }; + } if (opts.name) { opts.content.displayname = opts.name; } if (opts.url) { opts.content.avatar_url = opts.url; } - return mkEvent(opts); + let e = mkEvent(opts); + if (opts.target) { + e.target = opts.target; + } + return e; }; /**