diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js
index 821e370628..b8b11fbb31 100644
--- a/src/components/structures/MessagePanel.js
+++ b/src/components/structures/MessagePanel.js
@@ -413,10 +413,6 @@ export default class MessagePanel extends React.Component {
};
_getEventTiles() {
- const DateSeparator = sdk.getComponent('messages.DateSeparator');
- const EventListSummary = sdk.getComponent('views.elements.EventListSummary');
- const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary');
-
this.eventNodes = {};
let i;
@@ -458,199 +454,48 @@ export default class MessagePanel extends React.Component {
this._readReceiptsByEvent = this._getReadReceiptsByShownEvent();
}
+ let grouper = null;
+
for (i = 0; i < this.props.events.length; i++) {
const mxEv = this.props.events[i];
const eventId = mxEv.getId();
const last = (mxEv === lastShownEvent);
- // Wrap initial room creation events into an EventListSummary
- // Grouping only events sent by the same user that sent the `m.room.create` and only until
- // the first non-state event or membership event which is not regarding the sender of the `m.room.create` event
- const shouldGroup = (ev) => {
- if (ev.getType() === "m.room.member"
- && (ev.getStateKey() !== mxEv.getSender() || ev.getContent()["membership"] !== "join")) {
- return false;
- }
- if (ev.isState() && ev.getSender() === mxEv.getSender()) {
- return true;
- }
- return false;
- };
- // events that we include in the group but then eject out and place
- // above the group.
- const shouldEject = (ev) => {
- if (ev.getType() === "m.room.encryption") return true;
- return false;
- };
- if (mxEv.getType() === "m.room.create") {
- let summaryReadMarker = null;
- const ts1 = mxEv.getTs();
-
- if (this._wantsDateSeparator(prevEvent, mxEv.getDate())) {
- const dateSeparator =
;
- ret.push(dateSeparator);
+ if (grouper) {
+ if (grouper.shouldGroup(mxEv)) {
+ grouper.add(mxEv);
+ continue;
+ } else {
+ // not part of group, so get the group tiles, close the
+ // group, and continue like a normal event
+ ret.push(...grouper.getTiles());
+ prevEvent = grouper.getNewPrevEvent();
+ grouper = null;
}
-
- // If RM event is the first in the summary, append the RM after the summary
- summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(mxEv.getId());
-
- // If this m.room.create event should be shown (room upgrade) then show it before the summary
- if (this._shouldShowEvent(mxEv)) {
- // pass in the mxEv as prevEvent as well so no extra DateSeparator is rendered
- ret.push(...this._getTilesForEvent(mxEv, mxEv, false));
- }
-
- const summarisedEvents = []; // Don't add m.room.create here as we don't want it inside the summary
- const ejectedEvents = [];
- for (;i + 1 < this.props.events.length; i++) {
- const collapsedMxEv = this.props.events[i + 1];
-
- // Ignore redacted/hidden member events
- if (!this._shouldShowEvent(collapsedMxEv)) {
- // If this hidden event is the RM and in or at end of a summary put RM after the summary.
- summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(collapsedMxEv.getId());
- continue;
- }
-
- if (!shouldGroup(collapsedMxEv) || this._wantsDateSeparator(mxEv, collapsedMxEv.getDate())) {
- break;
- }
-
- // If RM event is in the summary, mark it as such and the RM will be appended after the summary.
- summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(collapsedMxEv.getId());
-
- if (shouldEject(collapsedMxEv)) {
- ejectedEvents.push(collapsedMxEv);
- } else {
- summarisedEvents.push(collapsedMxEv);
- }
- }
-
- // At this point, i = the index of the last event in the summary sequence
- const eventTiles = summarisedEvents.map((e) => {
- // In order to prevent DateSeparators from appearing in the expanded form
- // of EventListSummary, render each member event as if the previous
- // one was itself. This way, the timestamp of the previous event === the
- // timestamp of the current event, and no DateSeparator is inserted.
- return this._getTilesForEvent(e, e, e === lastShownEvent);
- }).reduce((a, b) => a.concat(b), []);
-
- for (const ejected of ejectedEvents) {
- ret.push(...this._getTilesForEvent(mxEv, ejected, last));
- }
-
- // Get sender profile from the latest event in the summary as the m.room.create doesn't contain one
- const ev = this.props.events[i];
- ret.push(
- { eventTiles }
- );
-
- if (summaryReadMarker) {
- ret.push(summaryReadMarker);
- }
-
- prevEvent = mxEv;
- continue;
}
- const wantTile = this._shouldShowEvent(mxEv);
-
- // Wrap consecutive member events in a ListSummary, ignore if redacted
- if (isMembershipChange(mxEv) && wantTile) {
- let summaryReadMarker = null;
- const ts1 = mxEv.getTs();
- // Ensure that the key of the MemberEventListSummary does not change with new
- // member events. This will prevent it from being re-created unnecessarily, and
- // instead will allow new props to be provided. In turn, the shouldComponentUpdate
- // method on MELS can be used to prevent unnecessary renderings.
- //
- // Whilst back-paginating with a MELS at the top of the panel, prevEvent will be null,
- // so use the key "membereventlistsummary-initial". Otherwise, use the ID of the first
- // membership event, which will not change during forward pagination.
- const key = "membereventlistsummary-" + (prevEvent ? mxEv.getId() : "initial");
-
- if (this._wantsDateSeparator(prevEvent, mxEv.getDate())) {
- const dateSeparator = ;
- ret.push(dateSeparator);
+ for (const Grouper of groupers) {
+ if (Grouper.canStartGroup(this, mxEv)) {
+ grouper = new Grouper(this, mxEv, prevEvent, lastShownEvent);
}
-
- // If RM event is the first in the MELS, append the RM after MELS
- summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(mxEv.getId());
-
- const summarisedEvents = [mxEv];
- for (;i + 1 < this.props.events.length; i++) {
- const collapsedMxEv = this.props.events[i + 1];
-
- // Ignore redacted/hidden member events
- if (!this._shouldShowEvent(collapsedMxEv)) {
- // If this hidden event is the RM and in or at end of a MELS put RM after MELS.
- summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(collapsedMxEv.getId());
- continue;
- }
-
- if (!isMembershipChange(collapsedMxEv) ||
- this._wantsDateSeparator(mxEv, collapsedMxEv.getDate())) {
- break;
- }
-
- // If RM event is in MELS mark it as such and the RM will be appended after MELS.
- summaryReadMarker = summaryReadMarker || this._readMarkerForEvent(collapsedMxEv.getId());
-
- summarisedEvents.push(collapsedMxEv);
- }
-
- let highlightInMels = false;
-
- // At this point, i = the index of the last event in the summary sequence
- let eventTiles = summarisedEvents.map((e) => {
- if (e.getId() === this.props.highlightedEventId) {
- highlightInMels = true;
- }
- // In order to prevent DateSeparators from appearing in the expanded form
- // of MemberEventListSummary, render each member event as if the previous
- // one was itself. This way, the timestamp of the previous event === the
- // timestamp of the current event, and no DateSeparator is inserted.
- return this._getTilesForEvent(e, e, e === lastShownEvent);
- }).reduce((a, b) => a.concat(b), []);
-
- if (eventTiles.length === 0) {
- eventTiles = null;
- }
-
- ret.push(
- { eventTiles }
- );
-
- if (summaryReadMarker) {
- ret.push(summaryReadMarker);
- }
-
- prevEvent = mxEv;
- continue;
}
+ if (!grouper) {
+ const wantTile = this._shouldShowEvent(mxEv);
+ if (wantTile) {
+ // make sure we unpack the array returned by _getTilesForEvent,
+ // otherwise react will auto-generate keys and we will end up
+ // replacing all of the DOM elements every time we paginate.
+ ret.push(...this._getTilesForEvent(prevEvent, mxEv, last));
+ prevEvent = mxEv;
+ }
- if (wantTile) {
- // make sure we unpack the array returned by _getTilesForEvent,
- // otherwise react will auto-generate keys and we will end up
- // replacing all of the DOM elements every time we paginate.
- ret.push(...this._getTilesForEvent(prevEvent, mxEv, last));
- prevEvent = mxEv;
+ const readMarker = this._readMarkerForEvent(eventId, i >= lastShownNonLocalEchoIndex);
+ if (readMarker) ret.push(readMarker);
}
+ }
- const readMarker = this._readMarkerForEvent(eventId, i >= lastShownNonLocalEchoIndex);
- if (readMarker) ret.push(readMarker);
+ if (grouper) {
+ ret.push(...grouper.getTiles());
}
return ret;
@@ -961,3 +806,222 @@ export default class MessagePanel extends React.Component {
);
}
}
+
+/* Grouper classes determine when events can be grouped together in a summary.
+ * Groupers should have the following methods:
+ * - canStartGroup (static): determines if a new group should be started with the
+ * given event
+ * - shouldGroup: determines if the given event should be added to an existing group
+ * - add: adds an event to an existing group (should only be called if shouldGroup
+ * return true)
+ * - getTiles: returns the tiles that represent the group
+ * - getNewPrevEvent: returns the event that should be used as the new prevEvent
+ * when determining things such as whether a date separator is necessary
+ */
+
+// Wrap initial room creation events into an EventListSummary
+// Grouping only events sent by the same user that sent the `m.room.create` and only until
+// the first non-state event or membership event which is not regarding the sender of the `m.room.create` event
+class CreationGrouper {
+ static canStartGroup = function(panel, ev) {
+ return ev.getType() === "m.room.create";
+ };
+
+ constructor(panel, createEvent, prevEvent, lastShownEvent) {
+ this.panel = panel;
+ this.createEvent = createEvent;
+ this.prevEvent = prevEvent;
+ this.lastShownEvent = lastShownEvent;
+ this.events = [];
+ // events that we include in the group but then eject out and place
+ // above the group.
+ this.ejectedEvents = [];
+ this.readMarker = panel._readMarkerForEvent(createEvent.getId());
+ }
+
+ shouldGroup(ev) {
+ const panel = this.panel;
+ const createEvent = this.createEvent;
+ if (!panel._shouldShowEvent(ev)) {
+ this.readMarker = this.readMarker || panel._readMarkerForEvent(ev.getId());
+ return true;
+ }
+ if (panel._wantsDateSeparator(this.createEvent, ev.getDate())) {
+ return false;
+ }
+ if (ev.getType() === "m.room.member"
+ && (ev.getStateKey() !== createEvent.getSender() || ev.getContent()["membership"] !== "join")) {
+ return false;
+ }
+ if (ev.isState() && ev.getSender() === createEvent.getSender()) {
+ return true;
+ }
+ return false;
+ }
+
+ add(ev) {
+ const panel = this.panel;
+ this.readMarker = this.readMarker || panel._readMarkerForEvent(ev.getId());
+ if (!panel._shouldShowEvent(ev)) {
+ return;
+ }
+ if (ev.getType() === "m.room.encryption") {
+ this.ejectedEvents.push(ev);
+ } else {
+ this.events.push(ev);
+ }
+ }
+
+ getTiles() {
+ const DateSeparator = sdk.getComponent('messages.DateSeparator');
+ const EventListSummary = sdk.getComponent('views.elements.EventListSummary');
+
+ const panel = this.panel;
+ const ret = [];
+ const createEvent = this.createEvent;
+ const lastShownEvent = this.lastShownEvent;
+
+ if (panel._wantsDateSeparator(this.prevEvent, createEvent.getDate())) {
+ const ts = createEvent.getTs();
+ ret.push(
+ ,
+ );
+ }
+
+ // If this m.room.create event should be shown (room upgrade) then show it before the summary
+ if (panel._shouldShowEvent(createEvent)) {
+ // pass in the createEvent as prevEvent as well so no extra DateSeparator is rendered
+ ret.push(...panel._getTilesForEvent(createEvent, createEvent, false));
+ }
+
+ for (const ejected of this.ejectedEvents) {
+ ret.push(...panel._getTilesForEvent(
+ createEvent, ejected, createEvent === lastShownEvent,
+ ));
+ }
+
+ const eventTiles = this.events.map((e) => {
+ // In order to prevent DateSeparators from appearing in the expanded form
+ // of EventListSummary, render each member event as if the previous
+ // one was itself. This way, the timestamp of the previous event === the
+ // timestamp of the current event, and no DateSeparator is inserted.
+ return panel._getTilesForEvent(e, e, e === lastShownEvent);
+ }).reduce((a, b) => a.concat(b), []);
+ // Get sender profile from the latest event in the summary as the m.room.create doesn't contain one
+ const ev = this.events[this.events.length - 1];
+ ret.push(
+
+ { eventTiles }
+ ,
+ );
+
+ if (this.readMarker) {
+ ret.push(this.readMarker);
+ }
+
+ return ret;
+ }
+
+ getNewPrevEvent() {
+ return this.createEvent;
+ }
+}
+
+// Wrap consecutive member events in a ListSummary, ignore if redacted
+class MemberGrouper {
+ static canStartGroup = function(panel, ev) {
+ return panel._shouldShowEvent(ev) && isMembershipChange(ev);
+ }
+
+ constructor(panel, ev, prevEvent, lastShownEvent) {
+ this.panel = panel;
+ this.readMarker = panel._readMarkerForEvent(ev.getId());
+ this.events = [ev];
+ this.prevEvent = prevEvent;
+ this.lastShownEvent = lastShownEvent;
+ }
+
+ shouldGroup(ev) {
+ return isMembershipChange(ev);
+ }
+
+ add(ev) {
+ this.readMarker = this.readMarker || this.panel._readMarkerForEvent(ev.getId());
+ this.events.push(ev);
+ }
+
+ getTiles() {
+ const DateSeparator = sdk.getComponent('messages.DateSeparator');
+ const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary');
+
+ const panel = this.panel;
+ const lastShownEvent = this.lastShownEvent;
+ const ret = [];
+
+ if (panel._wantsDateSeparator(this.prevEvent, this.events[0].getDate())) {
+ const ts = this.events[0].getTs();
+ ret.push(
+ ,
+ );
+ }
+
+ // Ensure that the key of the MemberEventListSummary does not change with new
+ // member events. This will prevent it from being re-created unnecessarily, and
+ // instead will allow new props to be provided. In turn, the shouldComponentUpdate
+ // method on MELS can be used to prevent unnecessary renderings.
+ //
+ // Whilst back-paginating with a MELS at the top of the panel, prevEvent will be null,
+ // so use the key "membereventlistsummary-initial". Otherwise, use the ID of the first
+ // membership event, which will not change during forward pagination.
+ const key = "membereventlistsummary-" + (
+ this.prevEvent ? this.events[0].getId() : "initial"
+ );
+
+ let highlightInMels;
+ let eventTiles = this.events.map((e) => {
+ if (e.getId() === panel.props.highlightedEventId) {
+ highlightInMels = true;
+ }
+ // In order to prevent DateSeparators from appearing in the expanded form
+ // of MemberEventListSummary, render each member event as if the previous
+ // one was itself. This way, the timestamp of the previous event === the
+ // timestamp of the current event, and no DateSeparator is inserted.
+ return panel._getTilesForEvent(e, e, e === lastShownEvent);
+ }).reduce((a, b) => a.concat(b), []);
+
+ if (eventTiles.length === 0) {
+ eventTiles = null;
+ }
+
+ ret.push(
+
+ { eventTiles }
+ ,
+ );
+
+ if (this.readMarker) {
+ ret.push(this.readMarker);
+ }
+
+ return ret;
+ }
+
+ getNewPrevEvent() {
+ return this.events[0];
+ }
+}
+
+// all the grouper classes that we use
+const groupers = [CreationGrouper, MemberGrouper];
diff --git a/test/components/structures/MessagePanel-test.js b/test/components/structures/MessagePanel-test.js
index 59917057a5..e6332cf7f8 100644
--- a/test/components/structures/MessagePanel-test.js
+++ b/test/components/structures/MessagePanel-test.js
@@ -34,10 +34,15 @@ import Matrix from 'matrix-js-sdk';
const test_utils = require('../../test-utils');
const mockclock = require('../../mock-clock');
+import Adapter from "enzyme-adapter-react-16";
+import { configure, mount } from "enzyme";
+
import Velocity from 'velocity-animate';
import MatrixClientContext from "../../../src/contexts/MatrixClientContext";
import RoomContext from "../../../src/contexts/RoomContext";
+configure({ adapter: new Adapter() });
+
let client;
const room = new Matrix.Room();
@@ -251,4 +256,111 @@ describe('MessagePanel', function() {
}, 100);
}, 100);
});
+
+ it('should collapse creation events', function() {
+ const mkEvent = test_utils.mkEvent;
+ const mkMembership = test_utils.mkMembership;
+ const roomId = "!someroom";
+ const alice = "@alice:example.org";
+ const ts0 = Date.now();
+ const events = [
+ mkEvent({
+ event: true,
+ type: "m.room.create",
+ room: roomId,
+ user: alice,
+ content: {
+ creator: alice,
+ room_version: "5",
+ predecessor: {
+ room_id: "!prevroom",
+ event_id: "$someevent",
+ },
+ },
+ ts: ts0,
+ }),
+ mkMembership({
+ event: true,
+ room: roomId,
+ user: alice,
+ target: {
+ userId: alice,
+ name: "Alice",
+ getAvatarUrl: () => {
+ return "avatar.jpeg";
+ },
+ },
+ ts: ts0 + 1,
+ mship: 'join',
+ name: 'Alice',
+ }),
+ mkEvent({
+ event: true,
+ type: "m.room.join_rules",
+ room: roomId,
+ user: alice,
+ content: {
+ "join_rule": "invite"
+ },
+ ts: ts0 + 2,
+ }),
+ mkEvent({
+ event: true,
+ type: "m.room.history_visibility",
+ room: roomId,
+ user: alice,
+ content: {
+ "history_visibility": "invited",
+ },
+ ts: ts0 + 3,
+ }),
+ mkEvent({
+ event: true,
+ type: "m.room.encryption",
+ room: roomId,
+ user: alice,
+ content: {
+ "algorithm": "m.megolm.v1.aes-sha2",
+ },
+ ts: ts0 + 4,
+ }),
+ mkMembership({
+ event: true,
+ room: roomId,
+ user: alice,
+ skey: "@bob:example.org",
+ target: {
+ userId: "@bob:example.org",
+ name: "Bob",
+ getAvatarUrl: () => {
+ return "avatar.jpeg";
+ },
+ },
+ ts: ts0 + 5,
+ mship: 'invite',
+ name: 'Bob',
+ }),
+ ];
+ const res = mount(
+ ,
+ );
+
+ // we expect that
+ // - the room creation event, the room encryption event, and Alice inviting Bob,
+ // should be outside of the room creation summary
+ // - all other events should be inside the room creation summary
+
+ const tiles = res.find(sdk.getComponent('views.rooms.EventTile'));
+
+ expect(tiles.at(0).props().mxEvent.getType()).toEqual("m.room.create");
+ expect(tiles.at(1).props().mxEvent.getType()).toEqual("m.room.encryption");
+
+ const summaryTiles = res.find(sdk.getComponent('views.elements.EventListSummary'));
+ const summaryTile = summaryTiles.at(0);
+
+ const summaryEventTiles = summaryTile.find(sdk.getComponent('views.rooms.EventTile'));
+ // every event except for the room creation, room encryption, and Bob's
+ // invite event should be in the event summary
+ expect(summaryEventTiles.length).toEqual(tiles.length - 3);
+ });
});
diff --git a/test/test-utils.js b/test/test-utils.js
index fdd50a8792..d7aa9d5de9 100644
--- a/test/test-utils.js
+++ b/test/test-utils.js
@@ -51,7 +51,7 @@ export function createTestClient() {
getUserId: jest.fn().mockReturnValue("@userId:matrix.rog"),
getPushActionsForEvent: jest.fn(),
- getRoom: jest.fn().mockReturnValue(mkStubRoom()),
+ getRoom: jest.fn().mockImplementation(mkStubRoom),
getRooms: jest.fn().mockReturnValue([]),
getVisibleRooms: jest.fn().mockReturnValue([]),
getGroups: jest.fn().mockReturnValue([]),
@@ -111,7 +111,7 @@ export function mkEvent(opts) {
if (opts.skey) {
event.state_key = opts.skey;
} else if (["m.room.name", "m.room.topic", "m.room.create", "m.room.join_rules",
- "m.room.power_levels", "m.room.topic",
+ "m.room.power_levels", "m.room.topic", "m.room.history_visibility", "m.room.encryption",
"com.example.state"].indexOf(opts.type) !== -1) {
event.state_key = "";
}