diff --git a/src/matrix/room/RoomSummary.js b/src/matrix/room/RoomSummary.js index fe3e2a92..13d06ee5 100644 --- a/src/matrix/room/RoomSummary.js +++ b/src/matrix/room/RoomSummary.js @@ -28,13 +28,15 @@ function applyTimelineEntries(data, timelineEntries, isInitialSync, canMarkUnrea } -function applySyncResponse(data, roomResponse, membership) { +function applySyncResponse(data, roomResponse, membership, ownUserId) { if (roomResponse.summary) { data = updateSummary(data, roomResponse.summary); } + let needKickDetails = false; if (membership !== data.membership) { data = data.cloneIfNeeded(); data.membership = membership; + needKickDetails = membership === "leave" || membership === "ban"; } if (roomResponse.account_data) { data = roomResponse.account_data.events.reduce(processRoomAccountData, data); @@ -42,7 +44,12 @@ function applySyncResponse(data, roomResponse, membership) { const stateEvents = roomResponse?.state?.events; // state comes before timeline if (Array.isArray(stateEvents)) { - data = stateEvents.reduce(processStateEvent, data); + data = stateEvents.reduce((data, event) => { + if (needKickDetails) { + data = findKickDetails(data, event, ownUserId); + } + return processStateEvent(data, event, ownUserId, needKickDetails); + }, data); } const timelineEvents = roomResponse?.timeline?.events; // process state events in timeline @@ -51,6 +58,9 @@ function applySyncResponse(data, roomResponse, membership) { if (Array.isArray(timelineEvents)) { data = timelineEvents.reduce((data, event) => { if (typeof event.state_key === "string") { + if (needKickDetails) { + data = findKickDetails(data, event, ownUserId); + } return processStateEvent(data, event); } return data; @@ -112,6 +122,22 @@ export function processStateEvent(data, event) { return data; } +function findKickDetails(data, event, ownUserId) { + if (event.type === "m.room.member") { + // did we get kicked? + if (event.state_key === ownUserId && event.sender !== event.state_key) { + data = data.cloneIfNeeded(); + data.kickDetails = { + // this is different from the room membership in the sync section, which can only be leave + membership: event.content?.membership, // could be leave or ban + reason: event.content?.reason, + sender: event.sender, + } + } + } + return data; +} + function processTimelineEvent(data, eventEntry, isInitialSync, canMarkUnread, ownUserId) { if (eventEntry.eventType === "m.room.message") { if (!data.lastMessageTimestamp || eventEntry.timestamp > data.lastMessageTimestamp) { @@ -180,6 +206,7 @@ export class SummaryData { this.tags = copy ? copy.tags : null; this.isDirectMessage = copy ? copy.isDirectMessage : false; this.dmUserId = copy ? copy.dmUserId : null; + this.kickDetails = copy ? copy.kickDetails : null; this.cloned = copy ? true : false; } @@ -217,8 +244,8 @@ export class SummaryData { return applyTimelineEntries(this, timelineEntries, isInitialSync, canMarkUnread, ownUserId); } - applySyncResponse(roomResponse, membership) { - return applySyncResponse(this, roomResponse, membership); + applySyncResponse(roomResponse, membership, ownUserId) { + return applySyncResponse(this, roomResponse, membership, ownUserId); } applyInvite(invite) { @@ -301,6 +328,17 @@ export class RoomSummary { } export function tests() { + function createMemberEvent(sender, target, membership, reason) { + return { + sender, + state_key: target, + type: "m.room.member", + content: { reason, membership } + }; + } + const bob = "@bob:hs.tld"; + const alice = "@alice:hs.tld"; + return { "serialize doesn't include null fields or cloned": assert => { const roomId = "!123:hs.tld"; @@ -312,6 +350,37 @@ export function tests() { const nullCount = Object.values(serialized).reduce((count, value) => count + value === null ? 1 : 0, 0); assert.strictEqual(nullCount, 0); }, + "ban/kick sets kickDetails from state event": assert => { + const reason = "Bye!"; + const leaveEvent = createMemberEvent(alice, bob, "ban", reason); + const data = new SummaryData(null, "!123:hs.tld"); + const newData = data.applySyncResponse({state: {events: [leaveEvent]}}, "leave", bob); + assert.equal(newData.membership, "leave"); + assert.equal(newData.kickDetails.membership, "ban"); + assert.equal(newData.kickDetails.reason, reason); + assert.equal(newData.kickDetails.sender, alice); + }, + "ban/kick sets kickDetails from timeline state event, taking precedence over state": assert => { + const reason = "Bye!"; + const inviteEvent = createMemberEvent(alice, bob, "invite"); + const leaveEvent = createMemberEvent(alice, bob, "ban", reason); + const data = new SummaryData(null, "!123:hs.tld"); + const newData = data.applySyncResponse({ + state: { events: [inviteEvent] }, + timeline: {events: [leaveEvent] } + }, "leave", bob); + assert.equal(newData.membership, "leave"); + assert.equal(newData.kickDetails.membership, "ban"); + assert.equal(newData.kickDetails.reason, reason); + assert.equal(newData.kickDetails.sender, alice); + }, + "leaving without being kicked doesn't produce kickDetails": assert => { + const leaveEvent = createMemberEvent(bob, bob, "leave"); + const data = new SummaryData(null, "!123:hs.tld"); + const newData = data.applySyncResponse({state: {events: [leaveEvent]}}, "leave", bob); + assert.equal(newData.membership, "leave"); + assert.equal(newData.kickDetails, null); + }, "membership trigger change": function(assert) { const summary = new RoomSummary("id"); let written = false;