From 85385295a6179af1d3b6acc5112c7594d39b9a90 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 4 May 2021 13:31:04 +0200 Subject: [PATCH 01/51] don't serialize null values in room summary they only take space in the storage otherwise as we add more fields --- src/matrix/room/RoomSummary.js | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/matrix/room/RoomSummary.js b/src/matrix/room/RoomSummary.js index 88b2c45b..fe3e2a92 100644 --- a/src/matrix/room/RoomSummary.js +++ b/src/matrix/room/RoomSummary.js @@ -205,7 +205,12 @@ export class SummaryData { serialize() { const {cloned, ...serializedProps} = this; - return serializedProps; + return Object.entries(this).reduce((obj, [key, value]) => { + if (key !== "cloned" && value !== null) { + obj[key] = value; + } + return obj; + }, {}); } applyTimelineEntries(timelineEntries, isInitialSync, canMarkUnread, ownUserId) { @@ -297,6 +302,16 @@ export class RoomSummary { export function tests() { return { + "serialize doesn't include null fields or cloned": assert => { + const roomId = "!123:hs.tld"; + const data = new SummaryData(null, roomId); + const clone = data.cloneIfNeeded(); + const serialized = clone.serialize(); + assert.strictEqual(serialized.cloned, undefined); + assert.equal(serialized.roomId, roomId); + const nullCount = Object.values(serialized).reduce((count, value) => count + value === null ? 1 : 0, 0); + assert.strictEqual(nullCount, 0); + }, "membership trigger change": function(assert) { const summary = new RoomSummary("id"); let written = false; From b13bfee3d882858d0729ec3e971112f51fddb3fd Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 4 May 2021 13:33:12 +0200 Subject: [PATCH 02/51] support setting kickDetails in room summary --- src/matrix/room/RoomSummary.js | 77 ++++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 4 deletions(-) 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; From 2cfe7034e8d2f873cffb74e96dc745de8ce42536 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 4 May 2021 13:33:30 +0200 Subject: [PATCH 03/51] extract fn --- src/matrix/room/RoomSummary.js | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/matrix/room/RoomSummary.js b/src/matrix/room/RoomSummary.js index 13d06ee5..5cdc24a6 100644 --- a/src/matrix/room/RoomSummary.js +++ b/src/matrix/room/RoomSummary.js @@ -68,21 +68,26 @@ function applySyncResponse(data, roomResponse, membership, ownUserId) { } const unreadNotifications = roomResponse.unread_notifications; if (unreadNotifications) { - const highlightCount = unreadNotifications.highlight_count || 0; - if (highlightCount !== data.highlightCount) { - data = data.cloneIfNeeded(); - data.highlightCount = highlightCount; - } - const notificationCount = unreadNotifications.notification_count; - if (notificationCount !== data.notificationCount) { - data = data.cloneIfNeeded(); - data.notificationCount = notificationCount; - } + data = processNotificationCounts(data, unreadNotifications); } return data; } +function processNotificationCounts(data, unreadNotifications) { + const highlightCount = unreadNotifications.highlight_count || 0; + if (highlightCount !== data.highlightCount) { + data = data.cloneIfNeeded(); + data.highlightCount = highlightCount; + } + const notificationCount = unreadNotifications.notification_count; + if (notificationCount !== data.notificationCount) { + data = data.cloneIfNeeded(); + data.notificationCount = notificationCount; + } + return data; +} + function processRoomAccountData(data, event) { if (event?.type === "m.tag") { let tags = event?.content?.tags; From c2716a061b934073ec222ab57773fe098e0a569e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 4 May 2021 13:33:44 +0200 Subject: [PATCH 04/51] pass in userId for kickDetails --- src/matrix/room/Room.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 6038ae19..d7a2c47b 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -195,7 +195,7 @@ export class Room extends EventEmitter { if (newKeys) { log.set("newKeys", newKeys.length); } - let summaryChanges = this._summary.data.applySyncResponse(roomResponse, membership); + let summaryChanges = this._summary.data.applySyncResponse(roomResponse, membership, this._user.id); if (membership === "join" && invite) { summaryChanges = summaryChanges.applyInvite(invite); } From d4d7adc7fc1ab5328c4ef918afa359daadd47ca5 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 4 May 2021 13:34:42 +0200 Subject: [PATCH 05/51] add archivedRoomSummary store --- src/matrix/Sync.js | 1 + src/matrix/storage/common.js | 1 + src/matrix/storage/idb/Transaction.js | 4 ++++ src/matrix/storage/idb/schema.js | 8 +++++++- src/matrix/storage/idb/stores/RoomSummaryStore.js | 6 ++++++ 5 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index a99c0938..de1099e4 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -316,6 +316,7 @@ export class Sync { return this._storage.readWriteTxn([ storeNames.session, storeNames.roomSummary, + storeNames.archivedRoomSummary, storeNames.invites, storeNames.roomState, storeNames.roomMembers, diff --git a/src/matrix/storage/common.js b/src/matrix/storage/common.js index 438cf6b3..bd477cbd 100644 --- a/src/matrix/storage/common.js +++ b/src/matrix/storage/common.js @@ -18,6 +18,7 @@ export const STORE_NAMES = Object.freeze([ "session", "roomState", "roomSummary", + "archivedRoomSummary", "invites", "roomMembers", "timelineEvents", diff --git a/src/matrix/storage/idb/Transaction.js b/src/matrix/storage/idb/Transaction.js index 162f821f..d497077b 100644 --- a/src/matrix/storage/idb/Transaction.js +++ b/src/matrix/storage/idb/Transaction.js @@ -64,6 +64,10 @@ export class Transaction { get roomSummary() { return this._store("roomSummary", idbStore => new RoomSummaryStore(idbStore)); } + + get archivedRoomSummary() { + return this._store("archivedRoomSummary", idbStore => new RoomSummaryStore(idbStore)); + } get invites() { return this._store("invites", idbStore => new InviteStore(idbStore)); diff --git a/src/matrix/storage/idb/schema.js b/src/matrix/storage/idb/schema.js index 7cf100aa..f0c052ea 100644 --- a/src/matrix/storage/idb/schema.js +++ b/src/matrix/storage/idb/schema.js @@ -12,7 +12,8 @@ export const schema = [ createE2EEStores, migrateEncryptionFlag, createAccountDataStore, - createInviteStore + createInviteStore, + createArchivedRoomSummaryStore, ]; // TODO: how to deal with git merge conflicts of this array? @@ -109,3 +110,8 @@ function createAccountDataStore(db) { function createInviteStore(db) { db.createObjectStore("invites", {keyPath: "roomId"}); } + +// v8 +function createArchivedRoomSummaryStore(db) { + db.createObjectStore("archivedRoomSummary", {keyPath: "roomId"}); +} \ No newline at end of file diff --git a/src/matrix/storage/idb/stores/RoomSummaryStore.js b/src/matrix/storage/idb/stores/RoomSummaryStore.js index 1264657e..a445cd99 100644 --- a/src/matrix/storage/idb/stores/RoomSummaryStore.js +++ b/src/matrix/storage/idb/stores/RoomSummaryStore.js @@ -27,6 +27,8 @@ store contains: inviteCount joinCount */ + +/** Used for both roomSummary and archivedRoomSummary stores */ export class RoomSummaryStore { constructor(summaryStore) { this._summaryStore = summaryStore; @@ -39,4 +41,8 @@ export class RoomSummaryStore { set(summary) { return this._summaryStore.put(summary); } + + remove(roomId) { + return this._summaryStore.delete(roomId); + } } From 184480ad36977dbe19efbba9a4b6e89e2593b976 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 5 May 2021 14:36:43 +0200 Subject: [PATCH 06/51] no need to capture req here --- src/matrix/storage/idb/utils.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/matrix/storage/idb/utils.js b/src/matrix/storage/idb/utils.js index 9fa8c55a..4ecb04d6 100644 --- a/src/matrix/storage/idb/utils.js +++ b/src/matrix/storage/idb/utils.js @@ -81,8 +81,9 @@ export function reqAsPromise(req) { resolve(event.target.result); needsSyncPromise && Promise._flush && Promise._flush(); }); - req.addEventListener("error", () => { - reject(new IDBRequestError(req)); + req.addEventListener("error", event => { + const error = new IDBRequestError(event.target); + reject(error); needsSyncPromise && Promise._flush && Promise._flush(); }); }); From bcfc4d1fd7b4b2933b0034b12d7e6215cb3de5d4 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 5 May 2021 16:02:39 +0200 Subject: [PATCH 07/51] have better transaction errors --- src/matrix/storage/idb/utils.js | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/matrix/storage/idb/utils.js b/src/matrix/storage/idb/utils.js index 4ecb04d6..2e6c2152 100644 --- a/src/matrix/storage/idb/utils.js +++ b/src/matrix/storage/idb/utils.js @@ -90,13 +90,28 @@ export function reqAsPromise(req) { } export function txnAsPromise(txn) { + let error; return new Promise((resolve, reject) => { txn.addEventListener("complete", () => { resolve(); needsSyncPromise && Promise._flush && Promise._flush(); }); - txn.addEventListener("abort", () => { - reject(new IDBRequestError(txn)); + txn.addEventListener("error", event => { + const request = event.target; + // catch first error here, but don't reject yet, + // as we don't have access to the failed request in the abort event handler + if (!error && request) { + error = new IDBRequestError(request); + } + }); + txn.addEventListener("abort", event => { + if (!error) { + const txn = event.target; + const dbName = txn.db.name; + const storeNames = Array.from(txn.objectStoreNames).join(", ") + error = new StorageError(`Transaction on ${dbName} with stores ${storeNames} was aborted.`); + } + reject(error); needsSyncPromise && Promise._flush && Promise._flush(); }); }); From 12da71f731c1865d682b8f96399faf9218abca8a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 5 May 2021 16:03:17 +0200 Subject: [PATCH 08/51] unneeded ? --- src/matrix/storage/idb/error.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/storage/idb/error.js b/src/matrix/storage/idb/error.js index 05390bea..2ba6289f 100644 --- a/src/matrix/storage/idb/error.js +++ b/src/matrix/storage/idb/error.js @@ -42,7 +42,7 @@ export class IDBError extends StorageError { export class IDBRequestError extends IDBError { constructor(request, message = "IDBRequest failed") { - const source = request?.source; + const source = request.source; const cause = request.error; super(message, source, cause); } From 89461bf69a1d3f298089b472a95c115569558490 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 5 May 2021 16:50:45 +0200 Subject: [PATCH 09/51] do all collection removal from sync rather than hand callbacks to invite --- src/matrix/Session.js | 7 +++++-- src/matrix/Sync.js | 10 +++++++++- src/matrix/room/Invite.js | 4 ---- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index a9076169..db44a463 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -55,7 +55,6 @@ export class Session { this._rooms = new ObservableMap(); this._roomUpdateCallback = (room, params) => this._rooms.update(room.id, params); this._invites = new ObservableMap(); - this._inviteRemoveCallback = invite => this._invites.remove(invite.id); this._inviteUpdateCallback = (invite, params) => this._invites.update(invite.id, params); this._user = new User(sessionInfo.userId); this._deviceMessageHandler = new DeviceMessageHandler({storage}); @@ -410,7 +409,6 @@ export class Session { return new Invite({ roomId, hsApi: this._hsApi, - emitCollectionRemove: this._inviteRemoveCallback, emitCollectionUpdate: this._inviteUpdateCallback, mediaRepository: this._mediaRepository, user: this._user, @@ -423,6 +421,11 @@ export class Session { this._invites.add(invite.id, invite); } + /** @internal */ + removeInviteAfterSync(invite) { + this._invites.remove(invite.id); + } + async obtainSyncLock(syncResponse) { const toDeviceEvents = syncResponse.to_device?.events; if (Array.isArray(toDeviceEvents) && toDeviceEvents.length) { diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index de1099e4..e888ab01 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -299,7 +299,15 @@ export class Sync { } // emit invite related events after txn has been closed for(let is of inviteStates) { - log.wrap("invite", () => is.invite.afterSync(is.changes), log.level.Detail); + log.wrap("invite", () => { + // important to remove before emitting change in afterSync + // so code checking session.invites.get(id) won't + // find the invite anymore on update + if (is.membership !== "invite") { + this._session.removeInviteAfterSync(is.invite); + } + is.invite.afterSync(is.changes); + }, log.level.Detail); if (is.isNewInvite) { this._session.addInviteAfterSync(is.invite); } diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index d34ffa8e..0c8601b4 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -162,10 +162,6 @@ export class Invite extends EventEmitter { } else { this._rejected = true; } - // important to remove before emitting change - // so code checking session.invites.get(id) won't - // find the invite anymore on update - this._emitCollectionRemove(this); this.emit("change"); } else { this._inviteData = changes.inviteData; From 644698aed76791c68dc377df1ac8deb5b4de58a4 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 5 May 2021 16:54:38 +0200 Subject: [PATCH 10/51] remove room from room list when leaving --- src/domain/session/leftpanel/LeftPanelViewModel.js | 3 +-- src/matrix/Session.js | 9 +++++++++ src/matrix/Sync.js | 2 ++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/domain/session/leftpanel/LeftPanelViewModel.js b/src/domain/session/leftpanel/LeftPanelViewModel.js index dd9c89ac..a1a577a9 100644 --- a/src/domain/session/leftpanel/LeftPanelViewModel.js +++ b/src/domain/session/leftpanel/LeftPanelViewModel.js @@ -35,9 +35,8 @@ export class LeftPanelViewModel extends ViewModel { } _mapTileViewModels(rooms, invites) { - const joinedRooms = rooms.filterValues(room => room.membership === "join"); // join is not commutative, invites will take precedence over rooms - return invites.join(joinedRooms).mapValues((roomOrInvite, emitChange) => { + return invites.join(rooms).mapValues((roomOrInvite, emitChange) => { const isOpen = this.navigation.path.get("room")?.value === roomOrInvite.id; let vm; if (roomOrInvite.isInvite) { diff --git a/src/matrix/Session.js b/src/matrix/Session.js index db44a463..93088df3 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -54,6 +54,7 @@ export class Session { this._sessionInfo = sessionInfo; this._rooms = new ObservableMap(); this._roomUpdateCallback = (room, params) => this._rooms.update(room.id, params); + this._archivedRooms = null; this._invites = new ObservableMap(); this._inviteUpdateCallback = (invite, params) => this._invites.update(invite.id, params); this._user = new User(sessionInfo.userId); @@ -426,6 +427,14 @@ export class Session { this._invites.remove(invite.id); } + /** @internal */ + archiveRoomAfterSync(room) { + this._rooms.remove(room.id); + if (this._archivedRooms) { + this._archivedRooms.add(room.id, room); + } + } + async obtainSyncLock(syncResponse) { const toDeviceEvents = syncResponse.to_device?.events; if (Array.isArray(toDeviceEvents) && toDeviceEvents.length) { diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index e888ab01..f459297d 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -295,6 +295,8 @@ export class Sync { // so the room will be found if looking for it when the invite // is removed this._session.addRoomAfterSync(rs.room); + } else if (rs.membership === "leave") { + this._session.archiveRoomAfterSync(rs.room); } } // emit invite related events after txn has been closed From f6957278c37a64c6d9b097acbf8bff90240d21a2 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 5 May 2021 17:03:22 +0200 Subject: [PATCH 11/51] write and remove archived summary when leaving/rejoining --- src/matrix/room/Room.js | 12 ++++++++++-- src/matrix/room/RoomSummary.js | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index d7a2c47b..a4dcff0a 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -250,6 +250,9 @@ export class Room extends EventEmitter { async writeSync(roomResponse, isInitialSync, {summaryChanges, decryptChanges, roomEncryption, retryEntries}, txn, log) { log.set("id", this.id); const isRejoin = summaryChanges.membership === "join" && this._summary.data.membership === "leave"; + if (isRejoin) { + this._summary.tryRemoveArchive(txn); + } const {entries: newEntries, newLiveKey, memberChanges} = await log.wrap("syncWriter", log => this._syncWriter.writeSync(roomResponse, isRejoin, txn, log), log.level.Detail); let allEntries = newEntries; @@ -276,8 +279,13 @@ export class Room extends EventEmitter { // also apply (decrypted) timeline entries to the summary changes summaryChanges = summaryChanges.applyTimelineEntries( allEntries, isInitialSync, !this._isTimelineOpen, this._user.id); - // write summary changes, and unset if nothing was actually changed - summaryChanges = this._summary.writeData(summaryChanges, txn); + // only archive a room if we had previously joined it + if (summaryChanges.membership === "leave" && this.membership === "join") { + summaryChanges = this._summary.removeAndWriteArchive(summaryChanges, txn); + } else { + // write summary changes, and unset if nothing was actually changed + summaryChanges = this._summary.writeData(summaryChanges, txn); + } if (summaryChanges) { log.set("summaryChanges", summaryChanges.diff(this._summary.data)); } diff --git a/src/matrix/room/RoomSummary.js b/src/matrix/room/RoomSummary.js index 5cdc24a6..86927e03 100644 --- a/src/matrix/room/RoomSummary.js +++ b/src/matrix/room/RoomSummary.js @@ -302,6 +302,20 @@ export class RoomSummary { } } + /** move summary to archived store when leaving the room */ + removeAndWriteArchive(data, txn) { + txn.roomSummary.remove(data.roomId); + if (data !== this._data) { + txn.archivedRoomSummary.set(data.serialize()); + return data; + } + } + + /** delete archived summary when rejoining the room */ + tryRemoveArchive(txn) { + txn.archivedRoomSummary.remove(this._data.roomId); + } + async writeAndApplyData(data, storage) { if (data === this._data) { return false; From 1258aaee7c70c4ae870e71a69ccc4d25e79ec861 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 5 May 2021 17:03:52 +0200 Subject: [PATCH 12/51] brevity --- src/matrix/room/Room.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index a4dcff0a..c9d08e05 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -354,7 +354,7 @@ export class Room extends EventEmitter { let emitChange = false; if (summaryChanges) { // if we joined the room, we can't have an invite anymore - if (summaryChanges.membership === "join" && this._summary.data.membership !== "join") { + if (summaryChanges.membership === "join" && this.membership !== "join") { this._invite = null; } this._summary.applyChanges(summaryChanges); From 07535eedca9aa11015d13dbd0b0b527c97f2803c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 5 May 2021 17:04:39 +0200 Subject: [PATCH 13/51] when rejoining, room will be archived so consider any non-join a rejoin --- src/matrix/room/Room.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index c9d08e05..213575e9 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -249,7 +249,7 @@ export class Room extends EventEmitter { /** @package */ async writeSync(roomResponse, isInitialSync, {summaryChanges, decryptChanges, roomEncryption, retryEntries}, txn, log) { log.set("id", this.id); - const isRejoin = summaryChanges.membership === "join" && this._summary.data.membership === "leave"; + const isRejoin = summaryChanges.membership === "join" && this.membership !== "join"; if (isRejoin) { this._summary.tryRemoveArchive(txn); } From 9546b13821a8fa2e773a8df772d5c6b2b0173d86 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 5 May 2021 17:07:26 +0200 Subject: [PATCH 14/51] attempt to load sync writer position when joining a room during sync since fragments and events are not archived, just the summary, attempt to load the room and sync writer during sync, so we write the timeline correctly and don't cause ConstraintErrors because unaware of fragments and events already there. --- src/matrix/Sync.js | 15 ++++++++++++--- src/matrix/room/Room.js | 7 +++++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index f459297d..0fc10b84 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -223,7 +223,11 @@ export class Sync { return this._storage.readTxn([ storeNames.olmSessions, storeNames.inboundGroupSessions, - storeNames.timelineEvents // to read events that can now be decrypted + // to read fragments when loading sync writer when rejoining archived room + storeNames.timelineFragments, + // to read fragments when loading sync writer when rejoining archived room + // to read events that can now be decrypted + storeNames.timelineEvents, ]); } @@ -250,8 +254,13 @@ export class Sync { await Promise.all(roomStates.map(async rs => { const newKeys = newKeysByRoom?.get(rs.room.id); - rs.preparation = await log.wrap("room", log => rs.room.prepareSync( - rs.roomResponse, rs.membership, rs.invite, newKeys, prepareTxn, log), log.level.Detail); + rs.preparation = await log.wrap("room", async log => { + if (rs.isNewRoom) { + await rs.room.load(null, prepareTxn, log); + } + return rs.room.prepareSync( + rs.roomResponse, rs.membership, rs.invite, newKeys, prepareTxn, log) + }, log.level.Detail); })); // This is needed for safari to not throw TransactionInactiveErrors on the syncTxn. See docs/INDEXEDDB.md diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 213575e9..61e3ff2e 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -423,7 +423,10 @@ export class Room extends EventEmitter { async load(summary, txn, log) { log.set("id", this.id); try { - this._summary.load(summary); + // if called from sync, there is no summary yet + if (summary) { + this._summary.load(summary); + } if (this._summary.data.encryption) { const roomEncryption = this._createRoomEncryption(this, this._summary.data.encryption); this._setEncryption(roomEncryption); @@ -714,7 +717,7 @@ export class Room extends EventEmitter { if (this._roomEncryption) { this._timeline.enableEncryption(this._decryptEntries.bind(this, DecryptionSource.Timeline)); } - await this._timeline.load(this._user, this._summary.data.membership, log); + await this._timeline.load(this._user, this.membership, log); return this._timeline; }); } From 08ba4577f68e99b04629078a36e84269713c6aea Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 5 May 2021 17:10:15 +0200 Subject: [PATCH 15/51] rejoin logic was throwing away the prev_batch token --- src/matrix/room/timeline/persistence/SyncWriter.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/matrix/room/timeline/persistence/SyncWriter.js b/src/matrix/room/timeline/persistence/SyncWriter.js index dc2344e5..8e3d2527 100644 --- a/src/matrix/room/timeline/persistence/SyncWriter.js +++ b/src/matrix/room/timeline/persistence/SyncWriter.js @@ -200,10 +200,10 @@ export class SyncWriter { const index = events.findIndex(event => event.event_id === lastEventId); if (index !== -1) { log.set("overlap_event_id", lastEventId); - return { + return Object.assign({}, timeline, { limited: false, - events: events.slice(index + 1) - }; + events: events.slice(index + 1), + }); } } } From 7defd4a02bed16a9c48e50f79c9a979050ee0b6c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 5 May 2021 17:10:31 +0200 Subject: [PATCH 16/51] ensure the sync is limited when rejoining without overlap otherwise gap would be lost. The server should do this already, but we're just ensuring it is, to be more robust. --- src/matrix/room/timeline/persistence/SyncWriter.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/matrix/room/timeline/persistence/SyncWriter.js b/src/matrix/room/timeline/persistence/SyncWriter.js index 8e3d2527..6a7a920d 100644 --- a/src/matrix/room/timeline/persistence/SyncWriter.js +++ b/src/matrix/room/timeline/persistence/SyncWriter.js @@ -207,6 +207,10 @@ export class SyncWriter { } } } + if (!timeline.limited) { + log.set("force_limited_without_overlap", true); + return Object.assign({}, timeline, {limited: true}); + } return timeline; } From 45837f73770b78a550301abd8c22b70f5f18d164 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 5 May 2021 17:11:53 +0200 Subject: [PATCH 17/51] don't set dmUserId when not a DM --- src/matrix/room/RoomSummary.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/matrix/room/RoomSummary.js b/src/matrix/room/RoomSummary.js index 86927e03..6d7d0cac 100644 --- a/src/matrix/room/RoomSummary.js +++ b/src/matrix/room/RoomSummary.js @@ -183,10 +183,11 @@ function applyInvite(data, invite) { if (data.isDirectMessage !== invite.isDirectMessage) { data = data.cloneIfNeeded(); data.isDirectMessage = invite.isDirectMessage; - } - if (data.dmUserId !== invite.inviter?.userId) { - data = data.cloneIfNeeded(); - data.dmUserId = invite.inviter?.userId; + if (invite.isDirectMessage) { + data.dmUserId = invite.inviter?.userId; + } else { + data.dmUserId = null; + } } return data; } From 15080edfa7b00b899e63dc8fc6f491453efb1055 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 5 May 2021 17:47:46 +0200 Subject: [PATCH 18/51] fix failing test now we don't remove invite from collection anymore here --- src/matrix/room/Invite.js | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index 0c8601b4..ec173f50 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -325,28 +325,25 @@ export function tests() { assert.equal(invite.inviter.displayName, "Alice"); assert.equal(invite.inviter.avatarUrl, aliceAvatarUrl); }, - "syncing with membership from invite removes the invite": async assert => { - let removedEmitted = false; + "syncing join sets accepted": async assert => { + let changeEmitCount = 0; const invite = new Invite({ roomId, platform: {clock: new MockClock(1003)}, user: {id: "@bob:hs.tld"}, - emitCollectionRemove: emittingInvite => { - assert.equal(emittingInvite, invite); - removedEmitted = true; - } }); + invite.on("change", () => { changeEmitCount += 1; }); const txn = createStorage(); const changes = await invite.writeSync("invite", dmInviteFixture, txn, new NullLogItem()); assert.equal(txn.invitesMap.get(roomId).roomId, roomId); invite.afterSync(changes); const joinChanges = await invite.writeSync("join", null, txn, new NullLogItem()); - assert(!removedEmitted); + assert.strictEqual(changeEmitCount, 0); invite.afterSync(joinChanges); + assert.strictEqual(changeEmitCount, 1); assert.equal(txn.invitesMap.get(roomId), undefined); assert.equal(invite.rejected, false); assert.equal(invite.accepted, true); - assert(removedEmitted); } } } From 00d8f81bddcfa301521a0ee7311c56da287ddab3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 5 May 2021 19:04:26 +0200 Subject: [PATCH 19/51] clear all room state before rejoining room --- src/matrix/room/Room.js | 3 +++ src/matrix/storage/idb/stores/RoomStateStore.js | 13 +++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 61e3ff2e..00a97bcf 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -251,6 +251,9 @@ export class Room extends EventEmitter { log.set("id", this.id); const isRejoin = summaryChanges.membership === "join" && this.membership !== "join"; if (isRejoin) { + // remove all room state before calling syncWriter, + // so no old state sticks around + txn.roomState.removeAllForRoom(this.id); this._summary.tryRemoveArchive(txn); } const {entries: newEntries, newLiveKey, memberChanges} = diff --git a/src/matrix/storage/idb/stores/RoomStateStore.js b/src/matrix/storage/idb/stores/RoomStateStore.js index 0cec87bc..4b5ea118 100644 --- a/src/matrix/storage/idb/stores/RoomStateStore.js +++ b/src/matrix/storage/idb/stores/RoomStateStore.js @@ -14,17 +14,19 @@ See the License for the specific language governing permissions and limitations under the License. */ +const MAX_UNICODE = "\u{10FFFF}"; + export class RoomStateStore { constructor(idbStore) { this._roomStateStore = idbStore; } async getAllForType(type) { - + throw new Error("unimplemented"); } async get(type, stateKey) { - + throw new Error("unimplemented"); } async set(roomId, event) { @@ -32,4 +34,11 @@ export class RoomStateStore { const entry = {roomId, event, key}; return this._roomStateStore.put(entry); } + + removeAllForRoom(roomId) { + // exclude both keys as they are theoretical min and max, + // but we should't have a match for just the room id, or room id with max + const range = IDBKeyRange.bound(roomId, `${roomId}|${MAX_UNICODE}`, true, true); + this._roomStateStore.delete(range); + } } From a12f10dc3c5b2a62cfe4099e1403de7c5c55e433 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 6 May 2021 15:23:33 +0200 Subject: [PATCH 20/51] make type explicit --- src/matrix/e2ee/RoomEncryption.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/e2ee/RoomEncryption.js b/src/matrix/e2ee/RoomEncryption.js index 721be2d0..60e03193 100644 --- a/src/matrix/e2ee/RoomEncryption.js +++ b/src/matrix/e2ee/RoomEncryption.js @@ -83,7 +83,7 @@ export class RoomEncryption { } async writeMemberChanges(memberChanges, txn, log) { - let shouldFlush; + let shouldFlush = false; const memberChangesArray = Array.from(memberChanges.values()); if (memberChangesArray.some(m => m.hasLeft)) { log.log({ From f16c08f13e90c6149d88fcbd0b679748983010a1 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 6 May 2021 15:23:58 +0200 Subject: [PATCH 21/51] remove room from all user identities when leaving and delete identity as well as all device identities if no rooms left --- src/matrix/e2ee/DeviceTracker.js | 38 +++++++++++++------ src/matrix/e2ee/RoomEncryption.js | 1 + .../storage/idb/stores/DeviceIdentityStore.js | 9 +++++ .../storage/idb/stores/RoomStateStore.js | 3 +- src/matrix/storage/idb/stores/common.js | 18 +++++++++ 5 files changed, 56 insertions(+), 13 deletions(-) create mode 100644 src/matrix/storage/idb/stores/common.js diff --git a/src/matrix/e2ee/DeviceTracker.js b/src/matrix/e2ee/DeviceTracker.js index ed55b79a..a14b42f3 100644 --- a/src/matrix/e2ee/DeviceTracker.js +++ b/src/matrix/e2ee/DeviceTracker.js @@ -121,24 +121,38 @@ export class DeviceTracker { } } + async _removeRoomFromUserIdentity(roomId, userId, txn) { + const {userIdentities, deviceIdentities} = txn; + const identity = await userIdentities.get(userId); + if (identity) { + identity.roomIds = identity.roomIds.filter(id => id !== roomId); + // no more encrypted rooms with this user, remove + if (identity.roomIds.length === 0) { + userIdentities.remove(userId); + deviceIdentities.removeAllForUser(userId); + } else { + userIdentities.set(identity); + } + } + } + async _applyMemberChange(memberChange, txn) { // TODO: depends whether we encrypt for invited users?? // add room - if (memberChange.previousMembership !== "join" && memberChange.membership === "join") { + if (memberChange.hasJoined) { await this._writeMember(memberChange.member, txn); } // remove room - else if (memberChange.previousMembership === "join" && memberChange.membership !== "join") { - const {userIdentities} = txn; - const identity = await userIdentities.get(memberChange.userId); - if (identity) { - identity.roomIds = identity.roomIds.filter(roomId => roomId !== memberChange.roomId); - // no more encrypted rooms with this user, remove - if (identity.roomIds.length === 0) { - userIdentities.remove(identity.userId); - } else { - userIdentities.set(identity); - } + else if (memberChange.hasLeft) { + const {roomId} = memberChange; + // if we left the room, remove room from all user identities in the room + if (memberChange.userId === this._ownUserId) { + const userIds = await txn.roomMembers.getAllUserIds(roomId); + await Promise.all(userIds.map(userId => { + return this._removeRoomFromUserIdentity(roomId, userId, txn); + })); + } else { + await this._removeRoomFromUserIdentity(roomId, memberChange.userId, txn); } } } diff --git a/src/matrix/e2ee/RoomEncryption.js b/src/matrix/e2ee/RoomEncryption.js index 60e03193..aba7d07d 100644 --- a/src/matrix/e2ee/RoomEncryption.js +++ b/src/matrix/e2ee/RoomEncryption.js @@ -85,6 +85,7 @@ export class RoomEncryption { async writeMemberChanges(memberChanges, txn, log) { let shouldFlush = false; const memberChangesArray = Array.from(memberChanges.values()); + // this also clears our session if we leave the room ourselves if (memberChangesArray.some(m => m.hasLeft)) { log.log({ l: "discardOutboundSession", diff --git a/src/matrix/storage/idb/stores/DeviceIdentityStore.js b/src/matrix/storage/idb/stores/DeviceIdentityStore.js index 4d209532..fed8878b 100644 --- a/src/matrix/storage/idb/stores/DeviceIdentityStore.js +++ b/src/matrix/storage/idb/stores/DeviceIdentityStore.js @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import {MAX_UNICODE, MIN_UNICODE} from "./common.js"; + function encodeKey(userId, deviceId) { return `${userId}|${deviceId}`; } @@ -66,4 +68,11 @@ export class DeviceIdentityStore { remove(userId, deviceId) { this._store.delete(encodeKey(userId, deviceId)); } + + removeAllForUser(userId) { + // exclude both keys as they are theoretical min and max, + // but we should't have a match for just the room id, or room id with max + const range = IDBKeyRange.bound(encodeKey(userId, MIN_UNICODE), encodeKey(userId, MAX_UNICODE), true, true); + this._store.delete(range); + } } diff --git a/src/matrix/storage/idb/stores/RoomStateStore.js b/src/matrix/storage/idb/stores/RoomStateStore.js index 4b5ea118..20ef6942 100644 --- a/src/matrix/storage/idb/stores/RoomStateStore.js +++ b/src/matrix/storage/idb/stores/RoomStateStore.js @@ -1,5 +1,6 @@ /* Copyright 2020 Bruno Windels +Copyright 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -const MAX_UNICODE = "\u{10FFFF}"; +import {MAX_UNICODE} from "./common.js"; export class RoomStateStore { constructor(idbStore) { diff --git a/src/matrix/storage/idb/stores/common.js b/src/matrix/storage/idb/stores/common.js new file mode 100644 index 00000000..e05fe486 --- /dev/null +++ b/src/matrix/storage/idb/stores/common.js @@ -0,0 +1,18 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +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. +*/ + +export const MIN_UNICODE = "\u{0}"; +export const MAX_UNICODE = "\u{10FFFF}"; From 8c2ae863fdbf3dd687875535af2dd70405b7965f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 6 May 2021 15:26:48 +0200 Subject: [PATCH 22/51] clean up rejoin storage logic somewhat --- src/matrix/room/Room.js | 11 ++++++----- src/matrix/room/RoomSummary.js | 12 +++++------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 00a97bcf..50ffd7a0 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -249,12 +249,12 @@ export class Room extends EventEmitter { /** @package */ async writeSync(roomResponse, isInitialSync, {summaryChanges, decryptChanges, roomEncryption, retryEntries}, txn, log) { log.set("id", this.id); - const isRejoin = summaryChanges.membership === "join" && this.membership !== "join"; + const isRejoin = summaryChanges.isNewJoin(this._summary.data); if (isRejoin) { // remove all room state before calling syncWriter, // so no old state sticks around txn.roomState.removeAllForRoom(this.id); - this._summary.tryRemoveArchive(txn); + txn.archivedRoomSummary.remove(this.id); } const {entries: newEntries, newLiveKey, memberChanges} = await log.wrap("syncWriter", log => this._syncWriter.writeSync(roomResponse, isRejoin, txn, log), log.level.Detail); @@ -282,9 +282,11 @@ export class Room extends EventEmitter { // also apply (decrypted) timeline entries to the summary changes summaryChanges = summaryChanges.applyTimelineEntries( allEntries, isInitialSync, !this._isTimelineOpen, this._user.id); + // only archive a room if we had previously joined it if (summaryChanges.membership === "leave" && this.membership === "join") { - summaryChanges = this._summary.removeAndWriteArchive(summaryChanges, txn); + txn.roomSummary.remove(this.id); + summaryChanges = this._summary.writeArchivedData(summaryChanges, txn); } else { // write summary changes, and unset if nothing was actually changed summaryChanges = this._summary.writeData(summaryChanges, txn); @@ -356,8 +358,7 @@ export class Room extends EventEmitter { } let emitChange = false; if (summaryChanges) { - // if we joined the room, we can't have an invite anymore - if (summaryChanges.membership === "join" && this.membership !== "join") { + if (summaryChanges.isNewJoin(this._summary.data)) { this._invite = null; } this._summary.applyChanges(summaryChanges); diff --git a/src/matrix/room/RoomSummary.js b/src/matrix/room/RoomSummary.js index 6d7d0cac..8e78a3ee 100644 --- a/src/matrix/room/RoomSummary.js +++ b/src/matrix/room/RoomSummary.js @@ -261,6 +261,10 @@ export class SummaryData { get needsHeroes() { return !this.name && !this.canonicalAlias && this.heroes && this.heroes.length > 0; } + + isNewJoin(oldData) { + return this.membership === "join" && oldData.membership !== "join"; + } } export class RoomSummary { @@ -304,19 +308,13 @@ export class RoomSummary { } /** move summary to archived store when leaving the room */ - removeAndWriteArchive(data, txn) { - txn.roomSummary.remove(data.roomId); + writeArchivedData(data, txn) { if (data !== this._data) { txn.archivedRoomSummary.set(data.serialize()); return data; } } - /** delete archived summary when rejoining the room */ - tryRemoveArchive(txn) { - txn.archivedRoomSummary.remove(this._data.roomId); - } - async writeAndApplyData(data, storage) { if (data === this._data) { return false; From 7e450071b18733499b76ba7ded95879f70f77fad Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 6 May 2021 15:27:10 +0200 Subject: [PATCH 23/51] clear all room state when rejoining room --- src/matrix/room/Room.js | 1 + src/matrix/storage/idb/stores/RoomMemberStore.js | 11 ++++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 50ffd7a0..375f98c0 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -254,6 +254,7 @@ export class Room extends EventEmitter { // remove all room state before calling syncWriter, // so no old state sticks around txn.roomState.removeAllForRoom(this.id); + txn.roomMembers.removeAllForRoom(this.id); txn.archivedRoomSummary.remove(this.id); } const {entries: newEntries, newLiveKey, memberChanges} = diff --git a/src/matrix/storage/idb/stores/RoomMemberStore.js b/src/matrix/storage/idb/stores/RoomMemberStore.js index be2b16ec..340e48a1 100644 --- a/src/matrix/storage/idb/stores/RoomMemberStore.js +++ b/src/matrix/storage/idb/stores/RoomMemberStore.js @@ -1,6 +1,6 @@ /* Copyright 2020 Bruno Windels -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020, 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,6 +15,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import {MAX_UNICODE} from "./common.js"; + function encodeKey(roomId, userId) { return `${roomId}|${userId}`; } @@ -60,4 +62,11 @@ export class RoomMemberStore { }); return userIds; } + + removeAllForRoom(roomId) { + // exclude both keys as they are theoretical min and max, + // but we should't have a match for just the room id, or room id with max + const range = IDBKeyRange.bound(roomId, `${roomId}|${MAX_UNICODE}`, true, true); + this._roomMembersStore.delete(range); + } } From 030b6837efe41768f2f6166950ad2ab96896872a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 6 May 2021 15:27:32 +0200 Subject: [PATCH 24/51] rename --- src/matrix/room/RoomSummary.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/matrix/room/RoomSummary.js b/src/matrix/room/RoomSummary.js index 8e78a3ee..94404d61 100644 --- a/src/matrix/room/RoomSummary.js +++ b/src/matrix/room/RoomSummary.js @@ -32,11 +32,11 @@ function applySyncResponse(data, roomResponse, membership, ownUserId) { if (roomResponse.summary) { data = updateSummary(data, roomResponse.summary); } - let needKickDetails = false; + let hasLeft = false; if (membership !== data.membership) { data = data.cloneIfNeeded(); data.membership = membership; - needKickDetails = membership === "leave" || membership === "ban"; + hasLeft = membership === "leave" || membership === "ban"; } if (roomResponse.account_data) { data = roomResponse.account_data.events.reduce(processRoomAccountData, data); @@ -45,10 +45,10 @@ function applySyncResponse(data, roomResponse, membership, ownUserId) { // state comes before timeline if (Array.isArray(stateEvents)) { data = stateEvents.reduce((data, event) => { - if (needKickDetails) { + if (hasLeft) { data = findKickDetails(data, event, ownUserId); } - return processStateEvent(data, event, ownUserId, needKickDetails); + return processStateEvent(data, event, ownUserId, hasLeft); }, data); } const timelineEvents = roomResponse?.timeline?.events; @@ -58,7 +58,7 @@ function applySyncResponse(data, roomResponse, membership, ownUserId) { if (Array.isArray(timelineEvents)) { data = timelineEvents.reduce((data, event) => { if (typeof event.state_key === "string") { - if (needKickDetails) { + if (hasLeft) { data = findKickDetails(data, event, ownUserId); } return processStateEvent(data, event); From 36f54420cf773ff0ff193659755232ba81ce5e67 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 7 May 2021 13:06:00 +0200 Subject: [PATCH 25/51] extract RetainedValue from MemberList --- src/matrix/room/members/MemberList.js | 17 +++----------- src/utils/RetainedValue.js | 33 +++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 14 deletions(-) create mode 100644 src/utils/RetainedValue.js diff --git a/src/matrix/room/members/MemberList.js b/src/matrix/room/members/MemberList.js index 734887fd..05e6ea9a 100644 --- a/src/matrix/room/members/MemberList.js +++ b/src/matrix/room/members/MemberList.js @@ -15,15 +15,15 @@ limitations under the License. */ import {ObservableMap} from "../../../observable/map/ObservableMap.js"; +import {RetainedValue} from "../../../utils/RetainedValue.js"; -export class MemberList { +export class MemberList extends RetainedValue { constructor({members, closeCallback}) { + super(closeCallback); this._members = new ObservableMap(); for (const member of members) { this._members.add(member.userId, member); } - this._closeCallback = closeCallback; - this._retentionCount = 1; } afterSync(memberChanges) { @@ -35,15 +35,4 @@ export class MemberList { get members() { return this._members; } - - retain() { - this._retentionCount += 1; - } - - release() { - this._retentionCount -= 1; - if (this._retentionCount === 0) { - this._closeCallback(); - } - } } diff --git a/src/utils/RetainedValue.js b/src/utils/RetainedValue.js new file mode 100644 index 00000000..b3ed7a91 --- /dev/null +++ b/src/utils/RetainedValue.js @@ -0,0 +1,33 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +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. +*/ + +export class RetainedValue { + constructor(freeCallback) { + this._freeCallback = freeCallback; + this._retentionCount = 1; + } + + retain() { + this._retentionCount += 1; + } + + release() { + this._retentionCount -= 1; + if (this._retentionCount === 0) { + this._freeCallback(); + } + } +} From 3143f2a791e5ebbe785785436a1eaf8b9413653e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 7 May 2021 13:06:20 +0200 Subject: [PATCH 26/51] also make an observable version of a retained value --- src/observable/ObservableValue.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/observable/ObservableValue.js b/src/observable/ObservableValue.js index 3fbfe463..8a433444 100644 --- a/src/observable/ObservableValue.js +++ b/src/observable/ObservableValue.js @@ -94,6 +94,18 @@ export class ObservableValue extends BaseObservableValue { } } +export class RetainedObservableValue extends ObservableValue { + constructor(initialValue, freeCallback) { + super(initialValue); + this._freeCallback = freeCallback; + } + + onUnsubscribeLast() { + super.onUnsubscribeLast(); + this._freeCallback(); + } +} + export function tests() { return { "set emits an update": assert => { From 243d105aadb85b903b9b9e3abebd7d583d5ff0ac Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 7 May 2021 13:08:39 +0200 Subject: [PATCH 27/51] support getting the room status for a room: invited, joined or archived --- src/matrix/Session.js | 26 ++++++++++ src/matrix/room/RoomStatus.js | 51 +++++++++++++++++++ .../storage/idb/stores/RoomSummaryStore.js | 5 ++ 3 files changed, 82 insertions(+) create mode 100644 src/matrix/room/RoomStatus.js diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 93088df3..fdf6b487 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -16,6 +16,7 @@ limitations under the License. */ import {Room} from "./room/Room.js"; +import {RoomStatus} from "./room/RoomStatus.js"; import {Invite} from "./room/Invite.js"; import {Pusher} from "./push/Pusher.js"; import { ObservableMap } from "../observable/index.js"; @@ -597,6 +598,31 @@ export class Session { const serverPushers = (serverPushersData?.pushers || []).map(data => new Pusher(data)); return serverPushers.some(p => p.equals(myPusher)); } + + async getRoomStatus(roomId) { + const isJoined = !!this._rooms.get(roomId); + if (isJoined) { + return RoomStatus.joined; + } else { + const isInvited = !!this._invites.get(roomId); + let isArchived; + if (this._archivedRooms) { + isArchived = !!this._archivedRooms.get(roomId); + } else { + const txn = await this._storage.readTxn([this._storage.storeNames.archivedRoomSummary]); + isArchived = await txn.archivedRoomSummary.has(roomId); + } + if (isInvited && isArchived) { + return RoomStatus.invitedAndArchived; + } else if (isInvited) { + return RoomStatus.invited; + } else if (isArchived) { + return RoomStatus.archived; + } else { + return RoomStatus.none; + } + } + } } export function tests() { diff --git a/src/matrix/room/RoomStatus.js b/src/matrix/room/RoomStatus.js new file mode 100644 index 00000000..b03b3177 --- /dev/null +++ b/src/matrix/room/RoomStatus.js @@ -0,0 +1,51 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +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. +*/ + +export class RoomStatus { + constructor(joined, invited, archived) { + this.joined = joined; + this.invited = invited; + this.archived = archived; + } + + withInvited() { + if (this.invited) { + return this; + } else if (this.archived) { + return RoomStatus.invitedAndArchived; + } else { + return RoomStatus.invited; + } + } + + withoutInvited() { + if (!this.invited) { + return this; + } else if (this.joined) { + return RoomStatus.joined; + } else if (this.archived) { + return RoomStatus.archived; + } else { + return RoomStatus.none; + } + } +} + +RoomStatus.joined = new RoomStatus(true, false, false); +RoomStatus.archived = new RoomStatus(false, false, true); +RoomStatus.invited = new RoomStatus(false, true, false); +RoomStatus.invitedAndArchived = new RoomStatus(false, true, true); +RoomStatus.none = new RoomStatus(false, false, false); diff --git a/src/matrix/storage/idb/stores/RoomSummaryStore.js b/src/matrix/storage/idb/stores/RoomSummaryStore.js index a445cd99..f4ae4431 100644 --- a/src/matrix/storage/idb/stores/RoomSummaryStore.js +++ b/src/matrix/storage/idb/stores/RoomSummaryStore.js @@ -42,6 +42,11 @@ export class RoomSummaryStore { return this._summaryStore.put(summary); } + async has(roomId) { + const fetchedKey = await this._summaryStore.getKey(roomId); + return roomId === fetchedKey; + } + remove(roomId) { return this._summaryStore.delete(roomId); } From 1b83ae7d8a6a9ed444cceacb85cdc2d2c080f9ed Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 7 May 2021 13:09:38 +0200 Subject: [PATCH 28/51] allow observing the room status --- src/matrix/Session.js | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index fdf6b487..138fb6e5 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -39,7 +39,7 @@ import { writeKey as ssssWriteKey, } from "./ssss/index.js"; import {SecretStorage} from "./ssss/SecretStorage.js"; -import {ObservableValue} from "../observable/ObservableValue.js"; +import {ObservableValue, RetainedObservableValue} from "../observable/ObservableValue.js"; const PICKLE_KEY = "DEFAULT_KEY"; const PUSHER_KEY = "pusher"; @@ -71,6 +71,7 @@ export class Session { this._olmWorker = olmWorker; this._sessionBackup = null; this._hasSecretStorageKey = new ObservableValue(null); + this._observedRoomStatus = new Map(); if (olm) { this._olmUtil = new olm.Utility(); @@ -400,6 +401,8 @@ export class Session { /** @internal */ addRoomAfterSync(room) { this._rooms.add(room.id, room); + const statusObservable = this._observedRoomStatus.get(room.id); + statusObservable?.set(RoomStatus.joined); } get invites() { @@ -421,16 +424,26 @@ export class Session { /** @internal */ addInviteAfterSync(invite) { this._invites.add(invite.id, invite); + const statusObservable = this._observedRoomStatus.get(invite.id); + if (statusObservable) { + statusObservable.set(statusObservable.get().withInvited()); + } } /** @internal */ removeInviteAfterSync(invite) { this._invites.remove(invite.id); + const statusObservable = this._observedRoomStatus.get(invite.id); + if (statusObservable) { + statusObservable.set(statusObservable.get().withoutInvited()); + } } /** @internal */ archiveRoomAfterSync(room) { this._rooms.remove(room.id); + const statusObservable = this._observedRoomStatus.get(room.id); + statusObservable?.set(RoomStatus.archived); if (this._archivedRooms) { this._archivedRooms.add(room.id, room); } @@ -623,6 +636,18 @@ export class Session { } } } + + async observeRoomStatus(roomId) { + let observable = this._observedRoomStatus.get(roomId); + if (!observable) { + const status = await this.getRoomStatus(roomId); + observable = new RetainedObservableValue(status, () => { + this._observedRoomStatus.delete(roomId); + }); + this._observedRoomStatus.set(roomId, observable); + } + return observable; + } } export function tests() { From 6bb8e2fa438d79e3f1b590d2ca69722d927a4546 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 7 May 2021 13:10:10 +0200 Subject: [PATCH 29/51] allow loading an archived room --- src/matrix/Session.js | 19 +++++++++++++++++++ src/matrix/room/Room.js | 5 ++++- .../storage/idb/stores/RoomSummaryStore.js | 4 ++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 138fb6e5..c2b85215 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -648,6 +648,25 @@ export class Session { } return observable; } + + loadArchivedRoom(roomId, log = null) { + return this._platform.logger.wrapOrRun(log, "loadArchivedRoom", async log => { + log.set("id", roomId); + const txn = await this._storage.readTxn([ + this._storage.storeNames.archivedRoomSummary, + this._storage.storeNames.roomMembers, + ]); + const summary = await txn.archivedRoomSummary.get(roomId); + if (summary) { + // TODO: should we really be using a Room here? + // Or rather an ArchivedRoom that shares a common base class with Room? + // That will make the Room code harder to read though ... + const room = this.createRoom(roomId); + await room.load(summary, txn, log); + return room; + } + }); + } } export function tests() { diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 375f98c0..62431ef6 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -442,7 +442,10 @@ export class Room extends EventEmitter { const changes = await this._heroes.calculateChanges(this._summary.data.heroes, [], txn); this._heroes.applyChanges(changes, this._summary.data); } - return this._syncWriter.load(txn, log); + // don't load sync writer for archived room + if (this.membership !== "leave") { + return this._syncWriter.load(txn, log); + } } catch (err) { throw new WrappedError(`Could not load room ${this._roomId}`, err); } diff --git a/src/matrix/storage/idb/stores/RoomSummaryStore.js b/src/matrix/storage/idb/stores/RoomSummaryStore.js index f4ae4431..426a01bb 100644 --- a/src/matrix/storage/idb/stores/RoomSummaryStore.js +++ b/src/matrix/storage/idb/stores/RoomSummaryStore.js @@ -42,6 +42,10 @@ export class RoomSummaryStore { return this._summaryStore.put(summary); } + get(roomId) { + return this._summaryStore.get(roomId); + } + async has(roomId) { const fetchedKey = await this._summaryStore.getKey(roomId); return roomId === fetchedKey; From 6c58c61da9a0a8ae0e7a7ceac9e85d016c95bc13 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 7 May 2021 13:10:35 +0200 Subject: [PATCH 30/51] move switching room view models to a dedicated observable based on the observing the room status --- src/domain/session/RoomGridViewModel.js | 116 ++++++++---------- src/domain/session/RoomViewModelObservable.js | 81 ++++++++++++ src/domain/session/SessionViewModel.js | 114 ++++++++--------- src/observable/BaseObservable.js | 7 ++ 4 files changed, 196 insertions(+), 122 deletions(-) create mode 100644 src/domain/session/RoomViewModelObservable.js diff --git a/src/domain/session/RoomGridViewModel.js b/src/domain/session/RoomGridViewModel.js index ce31e22c..aee80b6a 100644 --- a/src/domain/session/RoomGridViewModel.js +++ b/src/domain/session/RoomGridViewModel.js @@ -15,7 +15,6 @@ limitations under the License. */ import {ViewModel} from "../ViewModel.js"; -import {removeRoomFromPath} from "../navigation/index.js"; function dedupeSparse(roomIds) { return roomIds.map((id, idx) => { @@ -33,10 +32,9 @@ export class RoomGridViewModel extends ViewModel { this._width = options.width; this._height = options.height; - this._createRoomViewModel = options.createRoomViewModel; + this._createRoomViewModelObservable = options.createRoomViewModelObservable; this._selectedIndex = 0; - this._viewModels = []; - this._refreshRoomViewModel = this._refreshRoomViewModel.bind(this); + this._viewModelsObservables = []; this._setupNavigation(); } @@ -55,38 +53,17 @@ export class RoomGridViewModel extends ViewModel { this.track(focusedRoom.subscribe(roomId => { if (roomId) { // as the room will be in the "rooms" observable - // (monitored by the parent vm) as well, + // (monitored by the parent vmo) as well, // we only change the focus here and trust - // setRoomIds to have created the vm already + // setRoomIds to have created the vmo already this._setFocusRoom(roomId); } })); // initial focus for a room is set by initializeRoomIdsAndTransferVM } - _refreshRoomViewModel(roomId) { - const index = this._viewModels.findIndex(vm => vm?.id === roomId); - if (index === -1) { - return; - } - this._viewModels[index] = this.disposeTracked(this._viewModels[index]); - // this will create a RoomViewModel because the invite is already - // removed from the collection (see Invite.afterSync) - const roomVM = this._createRoomViewModel(roomId, this._refreshRoomViewModel); - if (roomVM) { - this._viewModels[index] = this.track(roomVM); - if (this.focusIndex === index) { - roomVM.focus(); - } - } else { - // close room id - this.navigation.applyPath(removeRoomFromPath(this.navigation.path, roomId)); - } - this.emitChange(); - } - roomViewModelAt(i) { - return this._viewModels[i]; + return this._viewModelsObservables[i]?.get(); } get focusIndex() { @@ -105,9 +82,9 @@ export class RoomGridViewModel extends ViewModel { if (index === this._selectedIndex) { return; } - const vm = this._viewModels[index]; - if (vm) { - this.navigation.push("room", vm.id); + const vmo = this._viewModelsObservables[index]; + if (vmo) { + this.navigation.push("room", vmo.id); } else { this.navigation.push("empty-grid-tile", index); } @@ -120,7 +97,8 @@ export class RoomGridViewModel extends ViewModel { if (existingRoomVM) { const index = roomIds.indexOf(existingRoomVM.id); if (index !== -1) { - this._viewModels[index] = this.track(existingRoomVM); + this._viewModelsObservables[index] = this.track(existingRoomVM); + existingRoomVM.subscribe(viewModel => this._refreshRoomViewModel(viewModel)); transfered = true; } } @@ -128,7 +106,7 @@ export class RoomGridViewModel extends ViewModel { // now all view models exist, set the focus to the selected room const focusedRoom = this.navigation.path.get("room"); if (focusedRoom) { - const index = this._viewModels.findIndex(vm => vm && vm.id === focusedRoom.value); + const index = this._viewModelsObservables.findIndex(vmo => vmo && vmo.id === focusedRoom.value); if (index !== -1) { this._selectedIndex = index; } @@ -143,17 +121,17 @@ export class RoomGridViewModel extends ViewModel { const len = this._height * this._width; for (let i = 0; i < len; i += 1) { const newId = roomIds[i]; - const vm = this._viewModels[i]; + const vmo = this._viewModelsObservables[i]; // did anything change? - if ((!vm && newId) || (vm && vm.id !== newId)) { - if (vm) { - this._viewModels[i] = this.disposeTracked(vm); + if ((!vmo && newId) || (vmo && vmo.id !== newId)) { + if (vmo) { + this._viewModelsObservables[i] = this.disposeTracked(vmo); } if (newId) { - const newVM = this._createRoomViewModel(newId, this._refreshRoomViewModel); - if (newVM) { - this._viewModels[i] = this.track(newVM); - } + const vmo = this._createRoomViewModelObservable(newId); + this._viewModelsObservables[i] = this.track(vmo); + vmo.subscribe(viewModel => this._refreshRoomViewModel(viewModel)); + vmo.initialize(); } changed = true; } @@ -163,15 +141,21 @@ export class RoomGridViewModel extends ViewModel { } return changed; } + + _refreshRoomViewModel(viewModel) { + this.emitChange(); + viewModel?.focus(); + } /** called from SessionViewModel */ releaseRoomViewModel(roomId) { - const index = this._viewModels.findIndex(vm => vm && vm.id === roomId); + const index = this._viewModelsObservables.findIndex(vmo => vmo && vmo.id === roomId); if (index !== -1) { - const vm = this._viewModels[index]; - this.untrack(vm); - this._viewModels[index] = null; - return vm; + const vmo = this._viewModelsObservables[index]; + this.untrack(vmo); + vmo.unsubscribeAll(); + this._viewModelsObservables[index] = null; + return vmo; } } @@ -180,13 +164,13 @@ export class RoomGridViewModel extends ViewModel { return; } this._selectedIndex = idx; - const vm = this._viewModels[this._selectedIndex]; - vm?.focus(); + const vmo = this._viewModelsObservables[this._selectedIndex]; + vmo?.get()?.focus(); this.emitChange("focusIndex"); } _setFocusRoom(roomId) { - const index = this._viewModels.findIndex(vm => vm?.id === roomId); + const index = this._viewModelsObservables.findIndex(vmo => vmo?.id === roomId); if (index >= 0) { this._setFocusIndex(index); } @@ -194,6 +178,8 @@ export class RoomGridViewModel extends ViewModel { } import {createNavigation} from "../navigation/index.js"; +import {ObservableValue} from "../../observable/ObservableValue.js"; + export function tests() { class RoomVMMock { constructor(id) { @@ -209,6 +195,12 @@ export function tests() { } } + class RoomViewModelObservableMock extends ObservableValue { + async initialize() {} + dispose() { this.get()?.dispose(); } + get id() { return this.get()?.id; } + } + function createNavigationForRoom(rooms, room) { const navigation = createNavigation(); navigation.applyPath(navigation.pathFrom([ @@ -233,7 +225,7 @@ export function tests() { "initialize with duplicate set of rooms": assert => { const navigation = createNavigationForRoom(["c", "a", "b", undefined, "a"], "a"); const gridVM = new RoomGridViewModel({ - createRoomViewModel: id => new RoomVMMock(id), + createRoomViewModelObservable: id => new RoomViewModelObservableMock(new RoomVMMock(id)), navigation, width: 3, height: 2, @@ -250,12 +242,12 @@ export function tests() { "transfer room view model": assert => { const navigation = createNavigationForRoom(["a"], "a"); const gridVM = new RoomGridViewModel({ - createRoomViewModel: () => assert.fail("no vms should be created"), + createRoomViewModelObservable: () => assert.fail("no vms should be created"), navigation, width: 3, height: 2, }); - const existingRoomVM = new RoomVMMock("a"); + const existingRoomVM = new RoomViewModelObservableMock(new RoomVMMock("a")); const transfered = gridVM.initializeRoomIdsAndTransferVM(navigation.path.get("rooms").value, existingRoomVM); assert.equal(transfered, true); assert.equal(gridVM.focusIndex, 0); @@ -264,12 +256,12 @@ export function tests() { "reject transfer for non-matching room view model": assert => { const navigation = createNavigationForRoom(["a"], "a"); const gridVM = new RoomGridViewModel({ - createRoomViewModel: id => new RoomVMMock(id), + createRoomViewModelObservable: id => new RoomViewModelObservableMock(new RoomVMMock(id)), navigation, width: 3, height: 2, }); - const existingRoomVM = new RoomVMMock("f"); + const existingRoomVM = new RoomViewModelObservableMock(new RoomVMMock("f")); const transfered = gridVM.initializeRoomIdsAndTransferVM(navigation.path.get("rooms").value, existingRoomVM); assert.equal(transfered, false); assert.equal(gridVM.focusIndex, 0); @@ -278,7 +270,7 @@ export function tests() { "created & released room view model is not disposed": assert => { const navigation = createNavigationForRoom(["a"], "a"); const gridVM = new RoomGridViewModel({ - createRoomViewModel: id => new RoomVMMock(id), + createRoomViewModelObservable: id => new RoomViewModelObservableMock(new RoomVMMock(id)), navigation, width: 3, height: 2, @@ -287,27 +279,27 @@ export function tests() { assert.equal(transfered, false); const releasedVM = gridVM.releaseRoomViewModel("a"); gridVM.dispose(); - assert.equal(releasedVM.disposed, false); + assert.equal(releasedVM.get().disposed, false); }, "transfered & released room view model is not disposed": assert => { const navigation = createNavigationForRoom([undefined, "a"], "a"); const gridVM = new RoomGridViewModel({ - createRoomViewModel: () => assert.fail("no vms should be created"), + createRoomViewModelObservable: () => assert.fail("no vms should be created"), navigation, width: 3, height: 2, }); - const existingRoomVM = new RoomVMMock("a"); + const existingRoomVM = new RoomViewModelObservableMock(new RoomVMMock("a")); const transfered = gridVM.initializeRoomIdsAndTransferVM(navigation.path.get("rooms").value, existingRoomVM); assert.equal(transfered, true); const releasedVM = gridVM.releaseRoomViewModel("a"); gridVM.dispose(); - assert.equal(releasedVM.disposed, false); + assert.equal(releasedVM.get().disposed, false); }, "try release non-existing room view model is": assert => { const navigation = createNavigationForEmptyTile([undefined, "b"], 3); const gridVM = new RoomGridViewModel({ - createRoomViewModel: id => new RoomVMMock(id), + createRoomViewModelObservable: id => new RoomViewModelObservableMock(new RoomVMMock(id)), navigation, width: 3, height: 2, @@ -319,7 +311,7 @@ export function tests() { "initial focus is set to empty tile": assert => { const navigation = createNavigationForEmptyTile(["a"], 1); const gridVM = new RoomGridViewModel({ - createRoomViewModel: id => new RoomVMMock(id), + createRoomViewModelObservable: id => new RoomViewModelObservableMock(new RoomVMMock(id)), navigation, width: 3, height: 2, @@ -331,7 +323,7 @@ export function tests() { "change room ids after creation": assert => { const navigation = createNavigationForRoom(["a", "b"], "a"); const gridVM = new RoomGridViewModel({ - createRoomViewModel: id => new RoomVMMock(id), + createRoomViewModelObservable: id => new RoomViewModelObservableMock(new RoomVMMock(id)), navigation, width: 3, height: 2, diff --git a/src/domain/session/RoomViewModelObservable.js b/src/domain/session/RoomViewModelObservable.js new file mode 100644 index 00000000..030ba5e4 --- /dev/null +++ b/src/domain/session/RoomViewModelObservable.js @@ -0,0 +1,81 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +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 {ObservableValue} from "../../observable/ObservableValue.js"; + +/** +Depending on the status of a room (invited, joined, archived, or none), +we want to show a different view with a different view model +when showing a room. Furthermore, this logic is needed both in the +single room view and in the grid view. So this logic is extracted here, +and this observable updates with the right view model as the status for +a room changes. + +To not have to track the subscription manually in the SessionViewModel and +the RoomGridViewModel, all subscriptions are removed in the dispose method. +Only when transferring a RoomViewModelObservable between the SessionViewModel +and RoomGridViewModel, unsubscribeAll should be called prior to doing +the transfer, so either parent view model don't keep getting updates for +the now transferred child view model. + +This is also why there is an explicit initialize method, see comment there. +*/ +export class RoomViewModelObservable extends ObservableValue { + constructor(sessionViewModel, roomId) { + super(null); + this._sessionViewModel = sessionViewModel; + this.id = roomId; + } + + /** + Separate initialize method rather than doing this onSubscribeFirst because + we don't want to run this again when transferring this value between + SessionViewModel and RoomGridViewModel, as onUnsubscribeLast and onSubscribeFirst + are called in that case. + */ + async initialize() { + const {session} = this._sessionViewModel._sessionContainer; + this._statusObservable = await session.observeRoomStatus(this.id); + this.set(await this._statusToViewModel(this._statusObservable.get())); + this._statusObservable.subscribe(async status => { + this.set(await this._statusToViewModel(status)); + }); + } + + async _statusToViewModel(status) { + if (status.invited) { + return this._sessionViewModel._createInviteViewModel(this.id); + } else if (status.joined) { + return this._sessionViewModel._createRoomViewModel(this.id); + } else if (status.archived) { + if (!this.get() || this.get().kind !== "room") { + return await this._sessionViewModel._createArchivedRoomViewModel(this.id); + } else { + // reuse existing Room + return this.get(); + } + } + return null; + } + + dispose() { + if (this._statusSubscription) { + this._statusSubscription = this._statusSubscription(); + } + this.unsubscribeAll(); + this.get()?.dispose(); + } +} \ No newline at end of file diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 1e59a9d5..c847ce79 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -15,7 +15,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {removeRoomFromPath} from "../navigation/index.js"; import {LeftPanelViewModel} from "./leftpanel/LeftPanelViewModel.js"; import {RoomViewModel} from "./room/RoomViewModel.js"; import {InviteViewModel} from "./room/InviteViewModel.js"; @@ -24,6 +23,7 @@ import {SessionStatusViewModel} from "./SessionStatusViewModel.js"; import {RoomGridViewModel} from "./RoomGridViewModel.js"; import {SettingsViewModel} from "./settings/SettingsViewModel.js"; import {ViewModel} from "../ViewModel.js"; +import {RoomViewModelObservable} from "./RoomViewModelObservable.js"; export class SessionViewModel extends ViewModel { constructor(options) { @@ -40,10 +40,8 @@ export class SessionViewModel extends ViewModel { rooms: this._sessionContainer.session.rooms }))); this._settingsViewModel = null; - this._currentRoomViewModel = null; + this._roomViewModelObservable = null; this._gridViewModel = null; - this._refreshRoomViewModel = this._refreshRoomViewModel.bind(this); - this._createRoomViewModel = this._createRoomViewModel.bind(this); this._setupNavigation(); } @@ -90,7 +88,7 @@ export class SessionViewModel extends ViewModel { } get activeMiddleViewModel() { - return this._currentRoomViewModel || this._gridViewModel || this._settingsViewModel; + return this._roomViewModelObservable?.get() || this._gridViewModel || this._settingsViewModel; } get roomGridViewModel() { @@ -110,7 +108,7 @@ export class SessionViewModel extends ViewModel { } get currentRoomViewModel() { - return this._currentRoomViewModel; + return this._roomViewModelObservable?.get(); } _updateGrid(roomIds) { @@ -121,12 +119,14 @@ export class SessionViewModel extends ViewModel { this._gridViewModel = this.track(new RoomGridViewModel(this.childOptions({ width: 3, height: 2, - createRoomViewModel: this._createRoomViewModel, + createRoomViewModelObservable: roomId => new RoomViewModelObservable(this, roomId), }))); - if (this._gridViewModel.initializeRoomIdsAndTransferVM(roomIds, this._currentRoomViewModel)) { - this._currentRoomViewModel = this.untrack(this._currentRoomViewModel); - } else if (this._currentRoomViewModel) { - this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); + // try to transfer the current room view model, so we don't have to reload the timeline + this._roomViewModelObservable?.unsubscribeAll(); + if (this._gridViewModel.initializeRoomIdsAndTransferVM(roomIds, this._roomViewModelObservable)) { + this._roomViewModelObservable = this.untrack(this._roomViewModelObservable); + } else if (this._roomViewModelObservable) { + this._roomViewModelObservable = this.disposeTracked(this._roomViewModelObservable); } } else { this._gridViewModel.setRoomIds(roomIds); @@ -134,14 +134,12 @@ export class SessionViewModel extends ViewModel { } else if (this._gridViewModel && !roomIds) { // closing grid, try to show focused room in grid if (currentRoomId) { - const vm = this._gridViewModel.releaseRoomViewModel(currentRoomId.value); - if (vm) { - this._currentRoomViewModel = this.track(vm); - } else { - const newVM = this._createRoomViewModel(currentRoomId.value, this._refreshRoomViewModel); - if (newVM) { - this._currentRoomViewModel = this.track(newVM); - } + const vmo = this._gridViewModel.releaseRoomViewModel(currentRoomId.value); + if (vmo) { + this._roomViewModelObservable = this.track(vmo); + this._roomViewModelObservable.subscribe(() => { + this.emitChange("activeMiddleViewModel"); + }); } } this._gridViewModel = this.disposeTracked(this._gridViewModel); @@ -151,63 +149,59 @@ export class SessionViewModel extends ViewModel { } } - /** - * @param {string} roomId - * @param {function} refreshRoomViewModel passed in as an argument, because the grid needs a different impl of this - * @return {RoomViewModel | InviteViewModel} - */ - _createRoomViewModel(roomId, refreshRoomViewModel) { + _createRoomViewModel(roomId) { + const room = this._sessionContainer.session.rooms.get(roomId); + if (room) { + const roomVM = new RoomViewModel(this.childOptions({ + room, + ownUserId: this._sessionContainer.session.user.id, + })); + roomVM.load(); + return roomVM; + } + return null; + } + + async _createArchivedRoomViewModel(roomId) { + const room = await this._sessionContainer.session.loadArchivedRoom(roomId); + if (room) { + const roomVM = new RoomViewModel(this.childOptions({ + room, + ownUserId: this._sessionContainer.session.user.id, + })); + roomVM.load(); + return roomVM; + } + return null; + } + + _createInviteViewModel(roomId) { const invite = this._sessionContainer.session.invites.get(roomId); if (invite) { return new InviteViewModel(this.childOptions({ invite, mediaRepository: this._sessionContainer.session.mediaRepository, - refreshRoomViewModel, })); - } else { - const room = this._sessionContainer.session.rooms.get(roomId); - if (room) { - const roomVM = new RoomViewModel(this.childOptions({ - room, - ownUserId: this._sessionContainer.session.user.id, - refreshRoomViewModel - })); - roomVM.load(); - return roomVM; - } } return null; } - /** refresh the room view model after an internal change that needs - to change between invite, room or none state */ - _refreshRoomViewModel(roomId) { - this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); - const roomVM = this._createRoomViewModel(roomId, this._refreshRoomViewModel); - if (roomVM) { - this._currentRoomViewModel = this.track(roomVM); - } else { - // close room id - this.navigation.applyPath(removeRoomFromPath(this.navigation.path, roomId)); - } - this.emitChange("activeMiddleViewModel"); - } - _updateRoom(roomId) { // opening a room and already open? - if (this._currentRoomViewModel?.id === roomId) { + if (this._roomViewModelObservable?.id === roomId) { return; } // close if needed - if (this._currentRoomViewModel) { - this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); + if (this._roomViewModelObservable) { + this._roomViewModelObservable = this.disposeTracked(this._roomViewModelObservable); } - // and try opening again - const roomVM = this._createRoomViewModel(roomId, this._refreshRoomViewModel); - if (roomVM) { - this._currentRoomViewModel = this.track(roomVM); - } - this.emitChange("activeMiddleViewModel"); + const vmo = new RoomViewModelObservable(this, roomId); + this._roomViewModelObservable = this.track(vmo); + // subscription is unsubscribed in RoomViewModelObservable.dispose, and thus handled by track + this._roomViewModelObservable.subscribe(() => { + this.emitChange("activeMiddleViewModel"); + }); + vmo.initialize(); } _updateSettings(settingsOpen) { diff --git a/src/observable/BaseObservable.js b/src/observable/BaseObservable.js index 29387020..0f9934c3 100644 --- a/src/observable/BaseObservable.js +++ b/src/observable/BaseObservable.js @@ -48,6 +48,13 @@ export class BaseObservable { return null; } + unsubscribeAll() { + if (this._handlers.size !== 0) { + this._handlers.clear(); + this.onUnsubscribeLast(); + } + } + get hasSubscriptions() { return this._handlers.size !== 0; } From a8d4ee0dd6881310e6835c8b3928925dd85adc06 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 7 May 2021 13:11:17 +0200 Subject: [PATCH 31/51] different room view models don't need to initiate switching now as this is triggered by observing the room status --- src/domain/session/room/InviteViewModel.js | 15 ++------------- src/domain/session/room/RoomViewModel.js | 11 ++--------- 2 files changed, 4 insertions(+), 22 deletions(-) diff --git a/src/domain/session/room/InviteViewModel.js b/src/domain/session/room/InviteViewModel.js index 15fcb5a5..a9cc917f 100644 --- a/src/domain/session/room/InviteViewModel.js +++ b/src/domain/session/room/InviteViewModel.js @@ -21,10 +21,9 @@ import {ViewModel} from "../../ViewModel.js"; export class InviteViewModel extends ViewModel { constructor(options) { super(options); - const {invite, mediaRepository, refreshRoomViewModel} = options; + const {invite, mediaRepository} = options; this._invite = invite; this._mediaRepository = mediaRepository; - this._refreshRoomViewModel = refreshRoomViewModel; this._onInviteChange = this._onInviteChange.bind(this); this._error = null; this._closeUrl = this.urlCreator.urlUntilSegment("session"); @@ -107,17 +106,7 @@ export class InviteViewModel extends ViewModel { } _onInviteChange() { - if (this._invite.accepted || this._invite.rejected) { - // close invite if rejected, or open room if accepted. - // Done with a callback rather than manipulating the nav, - // as closing the invite changes the nav path depending whether - // we're in a grid view, and opening the room doesn't change - // the nav path because the url is the same for an - // invite and the room. - this._refreshRoomViewModel(this.id); - } else { - this.emitChange(); - } + this.emitChange(); } dispose() { diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 63aa6811..df631b87 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -22,10 +22,9 @@ import {ViewModel} from "../../ViewModel.js"; export class RoomViewModel extends ViewModel { constructor(options) { super(options); - const {room, ownUserId, refreshRoomViewModel} = options; + const {room, ownUserId} = options; this._room = room; this._ownUserId = ownUserId; - this._refreshRoomViewModel = refreshRoomViewModel; this._timelineVM = null; this._onRoomChange = this._onRoomChange.bind(this); this._timelineError = null; @@ -86,13 +85,7 @@ export class RoomViewModel extends ViewModel { // room doesn't tell us yet which fields changed, // so emit all fields originating from summary _onRoomChange() { - // if there is now an invite on this (left) room, - // show the invite view by refreshing the view model - if (this._room.invite) { - this._refreshRoomViewModel(this.id); - } else { - this.emitChange("name"); - } + this.emitChange(); } get kind() { return "room"; } From 06868abdb2a41b0465aef359c0b09a4e3c4a6720 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 7 May 2021 14:42:29 +0200 Subject: [PATCH 32/51] with room status being a thing, we don't need the invite on the room --- src/matrix/Sync.js | 5 ----- src/matrix/room/Room.js | 23 ----------------------- 2 files changed, 28 deletions(-) diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index 0fc10b84..35703589 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -322,11 +322,6 @@ export class Sync { if (is.isNewInvite) { this._session.addInviteAfterSync(is.invite); } - // if we haven't archived or forgotten the (left) room yet, - // notify there is an invite now, so we can update the UI - if (is.room) { - is.room.setInvite(is.invite); - } } } diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 62431ef6..e234e0e4 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -54,7 +54,6 @@ export class Room extends EventEmitter { this._getSyncToken = getSyncToken; this._platform = platform; this._observedEvents = null; - this._invite = null; } async _eventIdsToEntries(eventIds, txn) { @@ -359,9 +358,6 @@ export class Room extends EventEmitter { } let emitChange = false; if (summaryChanges) { - if (summaryChanges.isNewJoin(this._summary.data)) { - this._invite = null; - } this._summary.applyChanges(summaryChanges); if (!this._summary.data.needsHeroes) { this._heroes = null; @@ -451,14 +447,6 @@ export class Room extends EventEmitter { } } - /** @internal */ - setInvite(invite) { - // called when an invite comes in for this room - // (e.g. when we're in membership leave and haven't been archived or forgotten yet) - this._invite = invite; - this._emitUpdate(); - } - /** @public */ sendEvent(eventType, content, attachments, log = null) { this._platform.logger.wrapOrRun(log, "send", log => { @@ -621,17 +609,6 @@ export class Room extends EventEmitter { return this._summary.data.membership; } - /** - * The invite for this room, if any. - * This will only be set if you've left a room, and - * don't archive or forget it, and then receive an invite - * for it again - * @return {Invite?} - */ - get invite() { - return this._invite; - } - enableSessionBackup(sessionBackup) { this._roomEncryption?.enableSessionBackup(sessionBackup); // TODO: do we really want to do this every time you open the app? From 1216378783cb386a8c068fea4b179cb1d118992a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 7 May 2021 16:13:49 +0200 Subject: [PATCH 33/51] Extract BaseRoom from Room with summary and timeline, not sync or send Which we can then reuse to create a dedicated ArchivedRoom class which will: - have only relevant methods and properties (e.g. no sendEvent) - turns out that you can still receive a leave room in the sync (e.g. when banned after kick) so we'll make the sync for an archived room separate from room to not overcomplicate the sync there, much like we did for Invite already. --- src/matrix/room/BaseRoom.js | 481 +++++++++++++++++++++++++++ src/matrix/room/Room.js | 451 ++----------------------- src/matrix/room/timeline/Timeline.js | 17 +- 3 files changed, 518 insertions(+), 431 deletions(-) create mode 100644 src/matrix/room/BaseRoom.js diff --git a/src/matrix/room/BaseRoom.js b/src/matrix/room/BaseRoom.js new file mode 100644 index 00000000..d01cfaf1 --- /dev/null +++ b/src/matrix/room/BaseRoom.js @@ -0,0 +1,481 @@ +/* +Copyright 2020 Bruno Windels + +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 {EventEmitter} from "../../utils/EventEmitter.js"; +import {RoomSummary} from "./RoomSummary.js"; +import {GapWriter} from "./timeline/persistence/GapWriter.js"; +import {Timeline} from "./timeline/Timeline.js"; +import {FragmentIdComparer} from "./timeline/FragmentIdComparer.js"; +import {WrappedError} from "../error.js" +import {fetchOrLoadMembers} from "./members/load.js"; +import {MemberList} from "./members/MemberList.js"; +import {Heroes} from "./members/Heroes.js"; +import {EventEntry} from "./timeline/entries/EventEntry.js"; +import {ObservedEventMap} from "./ObservedEventMap.js"; +import {DecryptionSource} from "../e2ee/common.js"; +import {ensureLogItem} from "../../logging/utils.js"; + +const EVENT_ENCRYPTED_TYPE = "m.room.encrypted"; + +export class BaseRoom extends EventEmitter { + constructor({roomId, storage, hsApi, mediaRepository, emitCollectionChange, user, createRoomEncryption, getSyncToken, platform}) { + super(); + this._roomId = roomId; + this._storage = storage; + this._hsApi = hsApi; + this._mediaRepository = mediaRepository; + this._summary = new RoomSummary(roomId); + this._fragmentIdComparer = new FragmentIdComparer([]); + this._emitCollectionChange = emitCollectionChange; + this._timeline = null; + this._user = user; + this._changedMembersDuringSync = null; + this._memberList = null; + this._createRoomEncryption = createRoomEncryption; + this._roomEncryption = null; + this._getSyncToken = getSyncToken; + this._platform = platform; + this._observedEvents = null; + } + + async _eventIdsToEntries(eventIds, txn) { + const retryEntries = []; + await Promise.all(eventIds.map(async eventId => { + const storageEntry = await txn.timelineEvents.getByEventId(this._roomId, eventId); + if (storageEntry) { + retryEntries.push(new EventEntry(storageEntry, this._fragmentIdComparer)); + } + })); + return retryEntries; + } + + _getAdditionalTimelineRetryEntries(otherRetryEntries, roomKeys) { + let retryTimelineEntries = this._roomEncryption.filterUndecryptedEventEntriesForKeys(this._timeline.remoteEntries, roomKeys); + // filter out any entries already in retryEntries so we don't decrypt them twice + const existingIds = otherRetryEntries.reduce((ids, e) => {ids.add(e.id); return ids;}, new Set()); + retryTimelineEntries = retryTimelineEntries.filter(e => !existingIds.has(e.id)); + return retryTimelineEntries; + } + + /** + * Used for retrying decryption from other sources than sync, like key backup. + * @internal + * @param {RoomKey} roomKey + * @param {Array} eventIds any event ids that should be retried. There might be more in the timeline though for this key. + * @return {Promise} + */ + async notifyRoomKey(roomKey, eventIds, log) { + if (!this._roomEncryption) { + return; + } + const txn = await this._storage.readTxn([ + this._storage.storeNames.timelineEvents, + this._storage.storeNames.inboundGroupSessions, + ]); + let retryEntries = await this._eventIdsToEntries(eventIds, txn); + if (this._timeline) { + const retryTimelineEntries = this._getAdditionalTimelineRetryEntries(retryEntries, [roomKey]); + retryEntries = retryEntries.concat(retryTimelineEntries); + } + if (retryEntries.length) { + const decryptRequest = this._decryptEntries(DecryptionSource.Retry, retryEntries, txn, log); + // this will close txn while awaiting decryption + await decryptRequest.complete(); + + this._timeline?.replaceEntries(retryEntries); + // we would ideally write the room summary in the same txn as the groupSessionDecryptions in the + // _decryptEntries entries and could even know which events have been decrypted for the first + // time from DecryptionChanges.write and only pass those to the summary. As timeline changes + // are not essential to the room summary, it's fine to write this in a separate txn for now. + const changes = this._summary.data.applyTimelineEntries(retryEntries, false, false); + if (await this._summary.writeAndApplyData(changes, this._storage)) { + this._emitUpdate(); + } + } + } + + _setEncryption(roomEncryption) { + if (roomEncryption && !this._roomEncryption) { + this._roomEncryption = roomEncryption; + if (this._timeline) { + this._timeline.enableEncryption(this._decryptEntries.bind(this, DecryptionSource.Timeline)); + } + return true; + } + return false; + } + + /** + * Used for decrypting when loading/filling the timeline, and retrying decryption, + * not during sync, where it is split up during the multiple phases. + */ + _decryptEntries(source, entries, inboundSessionTxn, log = null) { + const request = new DecryptionRequest(async (r, log) => { + if (!inboundSessionTxn) { + inboundSessionTxn = await this._storage.readTxn([this._storage.storeNames.inboundGroupSessions]); + } + if (r.cancelled) return; + const events = entries.filter(entry => { + return entry.eventType === EVENT_ENCRYPTED_TYPE; + }).map(entry => entry.event); + r.preparation = await this._roomEncryption.prepareDecryptAll(events, null, source, inboundSessionTxn); + if (r.cancelled) return; + const changes = await r.preparation.decrypt(); + r.preparation = null; + if (r.cancelled) return; + const stores = [this._storage.storeNames.groupSessionDecryptions]; + const isTimelineOpen = this._isTimelineOpen; + if (isTimelineOpen) { + // read to fetch devices if timeline is open + stores.push(this._storage.storeNames.deviceIdentities); + } + const writeTxn = await this._storage.readWriteTxn(stores); + let decryption; + try { + decryption = await changes.write(writeTxn, log); + if (isTimelineOpen) { + await decryption.verifySenders(writeTxn); + } + } catch (err) { + writeTxn.abort(); + throw err; + } + await writeTxn.complete(); + // TODO: log decryption errors here + decryption.applyToEntries(entries); + if (this._observedEvents) { + this._observedEvents.updateEvents(entries); + } + }, ensureLogItem(log)); + return request; + } + + async _getSyncRetryDecryptEntries(newKeys, roomEncryption, txn) { + const entriesPerKey = await Promise.all(newKeys.map(async key => { + const retryEventIds = await roomEncryption.getEventIdsForMissingKey(key, txn); + if (retryEventIds) { + return this._eventIdsToEntries(retryEventIds, txn); + } + })); + let retryEntries = entriesPerKey.reduce((allEntries, entries) => entries ? allEntries.concat(entries) : allEntries, []); + // If we have the timeline open, see if there are more entries for the new keys + // as we only store missing session information for synced events, not backfilled. + // We want to decrypt all events we can though if the user is looking + // at them when the timeline is open + if (this._timeline) { + const retryTimelineEntries = this._getAdditionalTimelineRetryEntries(retryEntries, newKeys); + // make copies so we don't modify the original entry in writeSync, before the afterSync stage + const retryTimelineEntriesCopies = retryTimelineEntries.map(e => e.clone()); + // add to other retry entries + retryEntries = retryEntries.concat(retryTimelineEntriesCopies); + } + return retryEntries; + } + + /** @package */ + async load(summary, txn, log) { + log.set("id", this.id); + try { + // if called from sync, there is no summary yet + if (summary) { + this._summary.load(summary); + } + if (this._summary.data.encryption) { + const roomEncryption = this._createRoomEncryption(this, this._summary.data.encryption); + this._setEncryption(roomEncryption); + } + // need to load members for name? + if (this._summary.data.needsHeroes) { + this._heroes = new Heroes(this._roomId); + const changes = await this._heroes.calculateChanges(this._summary.data.heroes, [], txn); + this._heroes.applyChanges(changes, this._summary.data); + } + } catch (err) { + throw new WrappedError(`Could not load room ${this._roomId}`, err); + } + } + + /** @public */ + async loadMemberList(log = null) { + if (this._memberList) { + // TODO: also await fetchOrLoadMembers promise here + this._memberList.retain(); + return this._memberList; + } else { + const members = await fetchOrLoadMembers({ + summary: this._summary, + roomId: this._roomId, + hsApi: this._hsApi, + storage: this._storage, + syncToken: this._getSyncToken(), + // to handle race between /members and /sync + setChangedMembersMap: map => this._changedMembersDuringSync = map, + log, + }, this._platform.logger); + this._memberList = new MemberList({ + members, + closeCallback: () => { this._memberList = null; } + }); + return this._memberList; + } + } + + /** @public */ + fillGap(fragmentEntry, amount, log = null) { + // TODO move some/all of this out of BaseRoom + return this._platform.logger.wrapOrRun(log, "fillGap", async log => { + log.set("id", this.id); + log.set("fragment", fragmentEntry.fragmentId); + log.set("dir", fragmentEntry.direction.asApiString()); + if (fragmentEntry.edgeReached) { + log.set("edgeReached", true); + return; + } + const response = await this._hsApi.messages(this._roomId, { + from: fragmentEntry.token, + dir: fragmentEntry.direction.asApiString(), + limit: amount, + filter: { + lazy_load_members: true, + include_redundant_members: true, + } + }, {log}).response(); + + const txn = await this._storage.readWriteTxn([ + this._storage.storeNames.pendingEvents, + this._storage.storeNames.timelineEvents, + this._storage.storeNames.timelineFragments, + ]); + let extraGapFillChanges; + let gapResult; + try { + // detect remote echos of pending messages in the gap + extraGapFillChanges = this._writeGapFill(response.chunk, txn, log); + // write new events into gap + const gapWriter = new GapWriter({ + roomId: this._roomId, + storage: this._storage, + fragmentIdComparer: this._fragmentIdComparer, + }); + gapResult = await gapWriter.writeFragmentFill(fragmentEntry, response, txn, log); + } catch (err) { + txn.abort(); + throw err; + } + await txn.complete(); + if (this._roomEncryption) { + const decryptRequest = this._decryptEntries(DecryptionSource.Timeline, gapResult.entries, null, log); + await decryptRequest.complete(); + } + // once txn is committed, update in-memory state & emit events + for (const fragment of gapResult.fragments) { + this._fragmentIdComparer.add(fragment); + } + if (extraGapFillChanges) { + this._applyGapFill(extraGapFillChanges); + } + if (this._timeline) { + this._timeline.addOrReplaceEntries(gapResult.entries); + } + }); + } + + /** + allow sub classes to integrate in the gap fill lifecycle. + JoinedRoom uses this update remote echos. + */ + _writeGapFill(chunk, txn, log) {} + _applyGapFill() {} + + /** @public */ + get name() { + if (this._heroes) { + return this._heroes.roomName; + } + const summaryData = this._summary.data; + if (summaryData.name) { + return summaryData.name; + } + if (summaryData.canonicalAlias) { + return summaryData.canonicalAlias; + } + return null; + } + + /** @public */ + get id() { + return this._roomId; + } + + get avatarUrl() { + if (this._summary.data.avatarUrl) { + return this._summary.data.avatarUrl; + } else if (this._heroes) { + return this._heroes.roomAvatarUrl; + } + return null; + } + + get lastMessageTimestamp() { + return this._summary.data.lastMessageTimestamp; + } + + get isLowPriority() { + const tags = this._summary.data.tags; + return !!(tags && tags['m.lowpriority']); + } + + get isEncrypted() { + return !!this._summary.data.encryption; + } + + get isJoined() { + return this.membership === "join"; + } + + get isLeft() { + return this.membership === "leave"; + } + + get mediaRepository() { + return this._mediaRepository; + } + + get membership() { + return this._summary.data.membership; + } + + enableSessionBackup(sessionBackup) { + this._roomEncryption?.enableSessionBackup(sessionBackup); + // TODO: do we really want to do this every time you open the app? + if (this._timeline) { + this._platform.logger.run("enableSessionBackup", log => { + return this._roomEncryption.restoreMissingSessionsFromBackup(this._timeline.remoteEntries, log); + }); + } + } + + get _isTimelineOpen() { + return !!this._timeline; + } + + _emitUpdate() { + // once for event emitter listeners + this.emit("change"); + // and once for collection listeners + this._emitCollectionChange(this); + } + + /** @public */ + openTimeline(log = null) { + return this._platform.logger.wrapOrRun(log, "open timeline", async log => { + log.set("id", this.id); + if (this._timeline) { + throw new Error("not dealing with load race here for now"); + } + this._timeline = new Timeline({ + roomId: this.id, + storage: this._storage, + fragmentIdComparer: this._fragmentIdComparer, + pendingEvents: this._getPendingEvents(), + closeCallback: () => { + this._timeline = null; + if (this._roomEncryption) { + this._roomEncryption.notifyTimelineClosed(); + } + }, + clock: this._platform.clock, + logger: this._platform.logger, + }); + if (this._roomEncryption) { + this._timeline.enableEncryption(this._decryptEntries.bind(this, DecryptionSource.Timeline)); + } + await this._timeline.load(this._user, this.membership, log); + return this._timeline; + }); + } + + /* allow subclasses to provide an observable list with pending events when opening the timeline */ + _getPendingEvents() { return null; } + + observeEvent(eventId) { + if (!this._observedEvents) { + this._observedEvents = new ObservedEventMap(() => { + this._observedEvents = null; + }); + } + let entry = null; + if (this._timeline) { + entry = this._timeline.getByEventId(eventId); + } + const observable = this._observedEvents.observe(eventId, entry); + if (!entry) { + // update in the background + this._readEventById(eventId).then(entry => { + observable.update(entry); + }).catch(err => { + console.warn(`could not load event ${eventId} from storage`, err); + }); + } + return observable; + } + + async _readEventById(eventId) { + let stores = [this._storage.storeNames.timelineEvents]; + if (this.isEncrypted) { + stores.push(this._storage.storeNames.inboundGroupSessions); + } + const txn = await this._storage.readTxn(stores); + const storageEntry = await txn.timelineEvents.getByEventId(this._roomId, eventId); + if (storageEntry) { + const entry = new EventEntry(storageEntry, this._fragmentIdComparer); + if (entry.eventType === EVENT_ENCRYPTED_TYPE) { + const request = this._decryptEntries(DecryptionSource.Timeline, [entry], txn); + await request.complete(); + } + return entry; + } + } + + + dispose() { + this._roomEncryption?.dispose(); + this._timeline?.dispose(); + } +} + +class DecryptionRequest { + constructor(decryptFn, log) { + this._cancelled = false; + this.preparation = null; + this._promise = log.wrap("decryptEntries", log => decryptFn(this, log)); + } + + complete() { + return this._promise; + } + + get cancelled() { + return this._cancelled; + } + + dispose() { + this._cancelled = true; + if (this.preparation) { + this.preparation.dispose(); + } + } +} diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index e234e0e4..7e45a8da 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -14,179 +14,30 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {EventEmitter} from "../../utils/EventEmitter.js"; -import {RoomSummary} from "./RoomSummary.js"; +import {BaseRoom} from "./BaseRoom.js"; import {SyncWriter} from "./timeline/persistence/SyncWriter.js"; -import {GapWriter} from "./timeline/persistence/GapWriter.js"; -import {Timeline} from "./timeline/Timeline.js"; -import {FragmentIdComparer} from "./timeline/FragmentIdComparer.js"; import {SendQueue} from "./sending/SendQueue.js"; import {WrappedError} from "../error.js" -import {fetchOrLoadMembers} from "./members/load.js"; -import {MemberList} from "./members/MemberList.js"; import {Heroes} from "./members/Heroes.js"; -import {EventEntry} from "./timeline/entries/EventEntry.js"; -import {ObservedEventMap} from "./ObservedEventMap.js"; import {AttachmentUpload} from "./AttachmentUpload.js"; import {DecryptionSource} from "../e2ee/common.js"; -import {ensureLogItem} from "../../logging/utils.js"; const EVENT_ENCRYPTED_TYPE = "m.room.encrypted"; -export class Room extends EventEmitter { - constructor({roomId, storage, hsApi, mediaRepository, emitCollectionChange, pendingEvents, user, createRoomEncryption, getSyncToken, platform}) { - super(); - this._roomId = roomId; - this._storage = storage; - this._hsApi = hsApi; - this._mediaRepository = mediaRepository; - this._summary = new RoomSummary(roomId); - this._fragmentIdComparer = new FragmentIdComparer([]); - this._syncWriter = new SyncWriter({roomId, fragmentIdComparer: this._fragmentIdComparer}); - this._emitCollectionChange = emitCollectionChange; - this._sendQueue = new SendQueue({roomId, storage, hsApi, pendingEvents}); - this._timeline = null; - this._user = user; - this._changedMembersDuringSync = null; - this._memberList = null; - this._createRoomEncryption = createRoomEncryption; - this._roomEncryption = null; - this._getSyncToken = getSyncToken; - this._platform = platform; - this._observedEvents = null; - } - - async _eventIdsToEntries(eventIds, txn) { - const retryEntries = []; - await Promise.all(eventIds.map(async eventId => { - const storageEntry = await txn.timelineEvents.getByEventId(this._roomId, eventId); - if (storageEntry) { - retryEntries.push(new EventEntry(storageEntry, this._fragmentIdComparer)); - } - })); - return retryEntries; - } - - _getAdditionalTimelineRetryEntries(otherRetryEntries, roomKeys) { - let retryTimelineEntries = this._roomEncryption.filterUndecryptedEventEntriesForKeys(this._timeline.remoteEntries, roomKeys); - // filter out any entries already in retryEntries so we don't decrypt them twice - const existingIds = otherRetryEntries.reduce((ids, e) => {ids.add(e.id); return ids;}, new Set()); - retryTimelineEntries = retryTimelineEntries.filter(e => !existingIds.has(e.id)); - return retryTimelineEntries; - } - - /** - * Used for retrying decryption from other sources than sync, like key backup. - * @internal - * @param {RoomKey} roomKey - * @param {Array} eventIds any event ids that should be retried. There might be more in the timeline though for this key. - * @return {Promise} - */ - async notifyRoomKey(roomKey, eventIds, log) { - if (!this._roomEncryption) { - return; - } - const txn = await this._storage.readTxn([ - this._storage.storeNames.timelineEvents, - this._storage.storeNames.inboundGroupSessions, - ]); - let retryEntries = await this._eventIdsToEntries(eventIds, txn); - if (this._timeline) { - const retryTimelineEntries = this._getAdditionalTimelineRetryEntries(retryEntries, [roomKey]); - retryEntries = retryEntries.concat(retryTimelineEntries); - } - if (retryEntries.length) { - const decryptRequest = this._decryptEntries(DecryptionSource.Retry, retryEntries, txn, log); - // this will close txn while awaiting decryption - await decryptRequest.complete(); - - this._timeline?.replaceEntries(retryEntries); - // we would ideally write the room summary in the same txn as the groupSessionDecryptions in the - // _decryptEntries entries and could even know which events have been decrypted for the first - // time from DecryptionChanges.write and only pass those to the summary. As timeline changes - // are not essential to the room summary, it's fine to write this in a separate txn for now. - const changes = this._summary.data.applyTimelineEntries(retryEntries, false, false); - if (await this._summary.writeAndApplyData(changes, this._storage)) { - this._emitUpdate(); - } - } +export class Room extends BaseRoom { + constructor(options) { + super(options); + const {pendingEvents} = options; + this._syncWriter = new SyncWriter({roomId: this.id, fragmentIdComparer: this._fragmentIdComparer}); + this._sendQueue = new SendQueue({roomId: this.id, storage: this._storage, hsApi: this._hsApi, pendingEvents}); } _setEncryption(roomEncryption) { - if (roomEncryption && !this._roomEncryption) { - this._roomEncryption = roomEncryption; + if (super._setEncryption(roomEncryption)) { this._sendQueue.enableEncryption(this._roomEncryption); - if (this._timeline) { - this._timeline.enableEncryption(this._decryptEntries.bind(this, DecryptionSource.Timeline)); - } + return true; } - } - - /** - * Used for decrypting when loading/filling the timeline, and retrying decryption, - * not during sync, where it is split up during the multiple phases. - */ - _decryptEntries(source, entries, inboundSessionTxn, log = null) { - const request = new DecryptionRequest(async (r, log) => { - if (!inboundSessionTxn) { - inboundSessionTxn = await this._storage.readTxn([this._storage.storeNames.inboundGroupSessions]); - } - if (r.cancelled) return; - const events = entries.filter(entry => { - return entry.eventType === EVENT_ENCRYPTED_TYPE; - }).map(entry => entry.event); - r.preparation = await this._roomEncryption.prepareDecryptAll(events, null, source, inboundSessionTxn); - if (r.cancelled) return; - const changes = await r.preparation.decrypt(); - r.preparation = null; - if (r.cancelled) return; - const stores = [this._storage.storeNames.groupSessionDecryptions]; - const isTimelineOpen = this._isTimelineOpen; - if (isTimelineOpen) { - // read to fetch devices if timeline is open - stores.push(this._storage.storeNames.deviceIdentities); - } - const writeTxn = await this._storage.readWriteTxn(stores); - let decryption; - try { - decryption = await changes.write(writeTxn, log); - if (isTimelineOpen) { - await decryption.verifySenders(writeTxn); - } - } catch (err) { - writeTxn.abort(); - throw err; - } - await writeTxn.complete(); - // TODO: log decryption errors here - decryption.applyToEntries(entries); - if (this._observedEvents) { - this._observedEvents.updateEvents(entries); - } - }, ensureLogItem(log)); - return request; - } - - async _getSyncRetryDecryptEntries(newKeys, roomEncryption, txn) { - const entriesPerKey = await Promise.all(newKeys.map(async key => { - const retryEventIds = await roomEncryption.getEventIdsForMissingKey(key, txn); - if (retryEventIds) { - return this._eventIdsToEntries(retryEventIds, txn); - } - })); - let retryEntries = entriesPerKey.reduce((allEntries, entries) => entries ? allEntries.concat(entries) : allEntries, []); - // If we have the timeline open, see if there are more entries for the new keys - // as we only store missing session information for synced events, not backfilled. - // We want to decrypt all events we can though if the user is looking - // at them when the timeline is open - if (this._timeline) { - const retryTimelineEntries = this._getAdditionalTimelineRetryEntries(retryEntries, newKeys); - // make copies so we don't modify the original entry in writeSync, before the afterSync stage - const retryTimelineEntriesCopies = retryTimelineEntries.map(e => e.clone()); - // add to other retry entries - retryEntries = retryEntries.concat(retryTimelineEntriesCopies); - } - return retryEntries; + return false; } async prepareSync(roomResponse, membership, invite, newKeys, txn, log) { @@ -254,6 +105,7 @@ export class Room extends EventEmitter { // so no old state sticks around txn.roomState.removeAllForRoom(this.id); txn.roomMembers.removeAllForRoom(this.id); + // TODO: this should be done in ArchivedRoom txn.archivedRoomSummary.remove(this.id); } const {entries: newEntries, newLiveKey, memberChanges} = @@ -422,31 +274,23 @@ export class Room extends EventEmitter { /** @package */ async load(summary, txn, log) { - log.set("id", this.id); try { - // if called from sync, there is no summary yet - if (summary) { - this._summary.load(summary); - } - if (this._summary.data.encryption) { - const roomEncryption = this._createRoomEncryption(this, this._summary.data.encryption); - this._setEncryption(roomEncryption); - } - // need to load members for name? - if (this._summary.data.needsHeroes) { - this._heroes = new Heroes(this._roomId); - const changes = await this._heroes.calculateChanges(this._summary.data.heroes, [], txn); - this._heroes.applyChanges(changes, this._summary.data); - } - // don't load sync writer for archived room - if (this.membership !== "leave") { - return this._syncWriter.load(txn, log); - } + super.load(summary, txn, log); + this._syncWriter.load(txn, log); } catch (err) { throw new WrappedError(`Could not load room ${this._roomId}`, err); } } + _writeGapFill(gapChunk, txn, log) { + const removedPendingEvents = this._sendQueue.removeRemoteEchos(gapChunk, txn, log); + return removedPendingEvents; + } + + _applyGapFill(removedPendingEvents) { + this._sendQueue.emitRemovals(removedPendingEvents); + } + /** @public */ sendEvent(eventType, content, attachments, log = null) { this._platform.logger.wrapOrRun(log, "send", log => { @@ -466,124 +310,6 @@ export class Room extends EventEmitter { }); } - /** @public */ - async loadMemberList(log = null) { - if (this._memberList) { - // TODO: also await fetchOrLoadMembers promise here - this._memberList.retain(); - return this._memberList; - } else { - const members = await fetchOrLoadMembers({ - summary: this._summary, - roomId: this._roomId, - hsApi: this._hsApi, - storage: this._storage, - syncToken: this._getSyncToken(), - // to handle race between /members and /sync - setChangedMembersMap: map => this._changedMembersDuringSync = map, - log, - }, this._platform.logger); - this._memberList = new MemberList({ - members, - closeCallback: () => { this._memberList = null; } - }); - return this._memberList; - } - } - - /** @public */ - fillGap(fragmentEntry, amount, log = null) { - // TODO move some/all of this out of Room - return this._platform.logger.wrapOrRun(log, "fillGap", async log => { - log.set("id", this.id); - log.set("fragment", fragmentEntry.fragmentId); - log.set("dir", fragmentEntry.direction.asApiString()); - if (fragmentEntry.edgeReached) { - log.set("edgeReached", true); - return; - } - const response = await this._hsApi.messages(this._roomId, { - from: fragmentEntry.token, - dir: fragmentEntry.direction.asApiString(), - limit: amount, - filter: { - lazy_load_members: true, - include_redundant_members: true, - } - }, {log}).response(); - - const txn = await this._storage.readWriteTxn([ - this._storage.storeNames.pendingEvents, - this._storage.storeNames.timelineEvents, - this._storage.storeNames.timelineFragments, - ]); - let removedPendingEvents; - let gapResult; - try { - // detect remote echos of pending messages in the gap - removedPendingEvents = this._sendQueue.removeRemoteEchos(response.chunk, txn, log); - // write new events into gap - const gapWriter = new GapWriter({ - roomId: this._roomId, - storage: this._storage, - fragmentIdComparer: this._fragmentIdComparer, - }); - gapResult = await gapWriter.writeFragmentFill(fragmentEntry, response, txn, log); - } catch (err) { - txn.abort(); - throw err; - } - await txn.complete(); - if (this._roomEncryption) { - const decryptRequest = this._decryptEntries(DecryptionSource.Timeline, gapResult.entries, null, log); - await decryptRequest.complete(); - } - // once txn is committed, update in-memory state & emit events - for (const fragment of gapResult.fragments) { - this._fragmentIdComparer.add(fragment); - } - if (removedPendingEvents) { - this._sendQueue.emitRemovals(removedPendingEvents); - } - if (this._timeline) { - this._timeline.addOrReplaceEntries(gapResult.entries); - } - }); - } - - /** @public */ - get name() { - if (this._heroes) { - return this._heroes.roomName; - } - const summaryData = this._summary.data; - if (summaryData.name) { - return summaryData.name; - } - if (summaryData.canonicalAlias) { - return summaryData.canonicalAlias; - } - return null; - } - - /** @public */ - get id() { - return this._roomId; - } - - get avatarUrl() { - if (this._summary.data.avatarUrl) { - return this._summary.data.avatarUrl; - } else if (this._heroes) { - return this._heroes.roomAvatarUrl; - } - return null; - } - - get lastMessageTimestamp() { - return this._summary.data.lastMessageTimestamp; - } - get isUnread() { return this._summary.data.isUnread; } @@ -596,29 +322,6 @@ export class Room extends EventEmitter { return this._summary.data.highlightCount; } - get isLowPriority() { - const tags = this._summary.data.tags; - return !!(tags && tags['m.lowpriority']); - } - - get isEncrypted() { - return !!this._summary.data.encryption; - } - - get membership() { - return this._summary.data.membership; - } - - enableSessionBackup(sessionBackup) { - this._roomEncryption?.enableSessionBackup(sessionBackup); - // TODO: do we really want to do this every time you open the app? - if (this._timeline) { - this._platform.logger.run("enableSessionBackup", log => { - return this._roomEncryption.restoreMissingSessionsFromBackup(this._timeline.remoteEntries, log); - }); - } - } - get isTrackingMembers() { return this._summary.data.isTrackingMembers; } @@ -634,17 +337,6 @@ export class Room extends EventEmitter { } } - get _isTimelineOpen() { - return !!this._timeline; - } - - _emitUpdate() { - // once for event emitter listeners - this.emit("change"); - // and once for collection listeners - this._emitCollectionChange(this); - } - async clearUnread(log = null) { if (this.isUnread || this.notificationCount) { return await this._platform.logger.wrapOrRun(log, "clearUnread", async log => { @@ -678,37 +370,9 @@ export class Room extends EventEmitter { } } - /** @public */ - openTimeline(log = null) { - return this._platform.logger.wrapOrRun(log, "open timeline", async log => { - log.set("id", this.id); - if (this._timeline) { - throw new Error("not dealing with load race here for now"); - } - this._timeline = new Timeline({ - roomId: this.id, - storage: this._storage, - fragmentIdComparer: this._fragmentIdComparer, - pendingEvents: this._sendQueue.pendingEvents, - closeCallback: () => { - this._timeline = null; - if (this._roomEncryption) { - this._roomEncryption.notifyTimelineClosed(); - } - }, - clock: this._platform.clock, - logger: this._platform.logger, - }); - if (this._roomEncryption) { - this._timeline.enableEncryption(this._decryptEntries.bind(this, DecryptionSource.Timeline)); - } - await this._timeline.load(this._user, this.membership, log); - return this._timeline; - }); - } - - get mediaRepository() { - return this._mediaRepository; + /* called by BaseRoom to pass pendingEvents when opening the timeline */ + _getPendingEvents() { + return this._sendQueue.pendingEvents; } /** @package */ @@ -721,75 +385,12 @@ export class Room extends EventEmitter { this._summary.applyChanges(changes); } - observeEvent(eventId) { - if (!this._observedEvents) { - this._observedEvents = new ObservedEventMap(() => { - this._observedEvents = null; - }); - } - let entry = null; - if (this._timeline) { - entry = this._timeline.getByEventId(eventId); - } - const observable = this._observedEvents.observe(eventId, entry); - if (!entry) { - // update in the background - this._readEventById(eventId).then(entry => { - observable.update(entry); - }).catch(err => { - console.warn(`could not load event ${eventId} from storage`, err); - }); - } - return observable; - } - - async _readEventById(eventId) { - let stores = [this._storage.storeNames.timelineEvents]; - if (this.isEncrypted) { - stores.push(this._storage.storeNames.inboundGroupSessions); - } - const txn = await this._storage.readTxn(stores); - const storageEntry = await txn.timelineEvents.getByEventId(this._roomId, eventId); - if (storageEntry) { - const entry = new EventEntry(storageEntry, this._fragmentIdComparer); - if (entry.eventType === EVENT_ENCRYPTED_TYPE) { - const request = this._decryptEntries(DecryptionSource.Timeline, [entry], txn); - await request.complete(); - } - return entry; - } - } - createAttachment(blob, filename) { return new AttachmentUpload({blob, filename, platform: this._platform}); } dispose() { - this._roomEncryption?.dispose(); - this._timeline?.dispose(); + super.dispose(); this._sendQueue.dispose(); } } - -class DecryptionRequest { - constructor(decryptFn, log) { - this._cancelled = false; - this.preparation = null; - this._promise = log.wrap("decryptEntries", log => decryptFn(this, log)); - } - - complete() { - return this._promise; - } - - get cancelled() { - return this._cancelled; - } - - dispose() { - this._cancelled = true; - if (this.preparation) { - this.preparation.dispose(); - } - } -} diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index 5c9091df..74040586 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {SortedArray, MappedList, ConcatList} from "../../../observable/index.js"; +import {SortedArray, MappedList, ConcatList, ObservableArray} from "../../../observable/index.js"; import {Disposables} from "../../../utils/Disposables.js"; import {Direction} from "./Direction.js"; import {TimelineReader} from "./persistence/TimelineReader.js"; @@ -36,11 +36,16 @@ export class Timeline { fragmentIdComparer: this._fragmentIdComparer }); this._readerRequest = null; - const localEntries = new MappedList(pendingEvents, pe => { - return new PendingEventEntry({pendingEvent: pe, member: this._ownMember, clock}); - }, (pee, params) => { - pee.notifyUpdate(params); - }); + let localEntries; + if (pendingEvents) { + localEntries = new MappedList(pendingEvents, pe => { + return new PendingEventEntry({pendingEvent: pe, member: this._ownMember, clock}); + }, (pee, params) => { + pee.notifyUpdate(params); + }); + } else { + localEntries = new ObservableArray(); + } this._allEntries = new ConcatList(this._remoteEntries, localEntries); } From 79d97737bc23dd012992f3fe33507c2d854bacaf Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 10 May 2021 18:41:43 +0200 Subject: [PATCH 34/51] calculate leave details in archived room --- src/matrix/room/ArchivedRoom.js | 164 ++++++++++++++++++++++++++++++++ src/matrix/room/RoomSummary.js | 121 +++++------------------ 2 files changed, 187 insertions(+), 98 deletions(-) create mode 100644 src/matrix/room/ArchivedRoom.js diff --git a/src/matrix/room/ArchivedRoom.js b/src/matrix/room/ArchivedRoom.js new file mode 100644 index 00000000..0253d366 --- /dev/null +++ b/src/matrix/room/ArchivedRoom.js @@ -0,0 +1,164 @@ +/* +Copyright 2020 Bruno Windels + +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 {reduceStateEvents} from "./RoomSummary.js"; +import {BaseRoom} from "./BaseRoom.js"; +import {RoomMember} from "./members/RoomMember.js"; + +export class ArchivedRoom extends BaseRoom { + constructor(options) { + super(options); + this._kickDetails = null; + this._kickAuthor = null; + } + + async _getKickAuthor(sender, txn) { + const senderMember = await txn.roomMembers.get(this.id, sender); + if (senderMember) { + return new RoomMember(senderMember); + } else { + return RoomMember.fromUserId(this.id, sender, "join"); + } + } + + async load(archivedRoomSummary, txn, log) { + const {summary, kickDetails} = archivedRoomSummary; + this._kickDetails = kickDetails; + if (this._kickDetails) { + this._kickAuthor = await this._getKickAuthor(this._kickDetails.sender, txn); + } + return super.load(summary, txn, log); + } + + /** @package */ + async writeSync(joinedSummaryData, roomResponse, membership, txn, log) { + log.set("id", this.id); + if (membership === "leave") { + const newKickDetails = findKickDetails(roomResponse, this._user.id); + if (newKickDetails || joinedSummaryData) { + const kickDetails = newKickDetails || this._kickDetails; + let kickAuthor; + if (newKickDetails) { + kickAuthor = await this._getKickAuthor(newKickDetails.sender, txn); + } + const summaryData = joinedSummaryData || this._summary.data; + txn.archivedRoomSummary.set({ + summary: summaryData.serialize(), + kickDetails, + }); + return {kickDetails, kickAuthor, summaryData}; + } + } else if (membership === "join") { + txn.archivedRoomSummary.remove(this.id); + } + // always return object + return {}; + } + + /** + * @package + * Called with the changes returned from `writeSync` to apply them and emit changes. + * No storage or network operations should be done here. + */ + afterSync({summaryData, kickDetails, kickAuthor}) { + if (summaryData) { + this._summary.applyChanges(summaryData); + } + if (kickDetails) { + this._kickDetails = kickDetails; + } + if (kickAuthor) { + this._kickAuthor = kickAuthor; + } + this._emitUpdate(); + } + + getLeaveDetails() { + if (this.membership === "leave") { + return { + isKicked: this._kickDetails?.membership === "leave", + isBanned: this._kickDetails?.membership === "ban", + reason: this._kickDetails?.reason, + sender: this._kickAuthor, + }; + } + } + + forget() { + + } +} + +function findKickDetails(roomResponse, ownUserId) { + const kickEvent = reduceStateEvents(roomResponse, (kickEvent, event) => { + if (event.type === "m.room.member") { + // did we get kicked? + if (event.state_key === ownUserId && event.sender !== event.state_key) { + kickEvent = event; + } + } + return kickEvent; + }, null); + if (kickEvent) { + return { + // this is different from the room membership in the sync section, which can only be leave + membership: kickEvent.content?.membership, // could be leave or ban + reason: kickEvent.content?.reason, + sender: kickEvent.sender, + }; + } +} + +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 { + "ban/kick sets kickDetails from state event": assert => { + const reason = "Bye!"; + const leaveEvent = createMemberEvent(alice, bob, "ban", reason); + const kickDetails = findKickDetails({state: {events: [leaveEvent]}}, bob); + assert.equal(kickDetails.membership, "ban"); + assert.equal(kickDetails.reason, reason); + assert.equal(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 kickDetails = findKickDetails({ + state: { events: [inviteEvent] }, + timeline: {events: [leaveEvent] } + }, bob); + assert.equal(kickDetails.membership, "ban"); + assert.equal(kickDetails.reason, reason); + assert.equal(kickDetails.sender, alice); + }, + "leaving without being kicked doesn't produce kickDetails": assert => { + const leaveEvent = createMemberEvent(bob, bob, "leave"); + const kickDetails = findKickDetails({state: {events: [leaveEvent]}}, bob); + assert.equal(kickDetails, null); + } + } +} diff --git a/src/matrix/room/RoomSummary.js b/src/matrix/room/RoomSummary.js index 94404d61..d0c78659 100644 --- a/src/matrix/room/RoomSummary.js +++ b/src/matrix/room/RoomSummary.js @@ -27,45 +27,40 @@ function applyTimelineEntries(data, timelineEntries, isInitialSync, canMarkUnrea return data; } +export function reduceStateEvents(roomResponse, callback, value) { + const stateEvents = roomResponse?.state?.events; + // state comes before timeline + if (Array.isArray(stateEvents)) { + value = stateEvents.reduce(callback, value); + } + const timelineEvents = roomResponse?.timeline?.events; + // and after that state events in the timeline + if (Array.isArray(timelineEvents)) { + value = timelineEvents.reduce((data, event) => { + if (typeof event.state_key === "string") { + value = callback(value, event); + } + return value; + }, value); + } + return value; +} -function applySyncResponse(data, roomResponse, membership, ownUserId) { +function applySyncResponse(data, roomResponse, membership) { if (roomResponse.summary) { data = updateSummary(data, roomResponse.summary); } - let hasLeft = false; if (membership !== data.membership) { data = data.cloneIfNeeded(); data.membership = membership; - hasLeft = membership === "leave" || membership === "ban"; } if (roomResponse.account_data) { data = roomResponse.account_data.events.reduce(processRoomAccountData, data); } - const stateEvents = roomResponse?.state?.events; - // state comes before timeline - if (Array.isArray(stateEvents)) { - data = stateEvents.reduce((data, event) => { - if (hasLeft) { - data = findKickDetails(data, event, ownUserId); - } - return processStateEvent(data, event, ownUserId, hasLeft); - }, data); - } - const timelineEvents = roomResponse?.timeline?.events; - // process state events in timeline + // process state events in state and in timeline. // non-state events are handled by applyTimelineEntries // so decryption is handled properly - if (Array.isArray(timelineEvents)) { - data = timelineEvents.reduce((data, event) => { - if (typeof event.state_key === "string") { - if (hasLeft) { - data = findKickDetails(data, event, ownUserId); - } - return processStateEvent(data, event); - } - return data; - }, data); - } + data = reduceStateEvents(roomResponse, processStateEvent, data); const unreadNotifications = roomResponse.unread_notifications; if (unreadNotifications) { data = processNotificationCounts(data, unreadNotifications); @@ -127,22 +122,6 @@ 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) { @@ -212,7 +191,6 @@ 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; } @@ -237,7 +215,6 @@ export class SummaryData { } serialize() { - const {cloned, ...serializedProps} = this; return Object.entries(this).reduce((obj, [key, value]) => { if (key !== "cloned" && value !== null) { obj[key] = value; @@ -250,8 +227,8 @@ export class SummaryData { return applyTimelineEntries(this, timelineEntries, isInitialSync, canMarkUnread, ownUserId); } - applySyncResponse(roomResponse, membership, ownUserId) { - return applySyncResponse(this, roomResponse, membership, ownUserId); + applySyncResponse(roomResponse, membership) { + return applySyncResponse(this, roomResponse, membership); } applyInvite(invite) { @@ -346,17 +323,6 @@ 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"; @@ -367,47 +333,6 @@ export function tests() { assert.equal(serialized.roomId, roomId); 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; - let changes = summary.data.applySyncResponse({}, "join"); - const txn = {roomSummary: {set: () => { written = true; }}}; - changes = summary.writeData(changes, txn); - assert(changes); - assert(written); - assert.equal(changes.membership, "join"); } } } From 99d5467ad1f7674b0768a76daadc0cb759693041 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 10 May 2021 18:42:30 +0200 Subject: [PATCH 35/51] make archived room part of sync lifecycle (draft) --- src/matrix/Session.js | 102 +++++++++++++++--------- src/matrix/Sync.js | 169 +++++++++++++++++++++++++++++----------- src/matrix/room/Room.js | 9 +-- 3 files changed, 194 insertions(+), 86 deletions(-) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index c2b85215..961adf11 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -16,6 +16,7 @@ limitations under the License. */ import {Room} from "./room/Room.js"; +import {ArchivedRoom} from "./room/ArchivedRoom.js"; import {RoomStatus} from "./room/RoomStatus.js"; import {Invite} from "./room/Invite.js"; import {Pusher} from "./push/Pusher.js"; @@ -399,10 +400,18 @@ export class Session { } /** @internal */ - addRoomAfterSync(room) { - this._rooms.add(room.id, room); - const statusObservable = this._observedRoomStatus.get(room.id); - statusObservable?.set(RoomStatus.joined); + createArchivedRoom(roomId) { + return new ArchivedRoom({ + roomId, + getSyncToken: this._getSyncToken, + storage: this._storage, + emitCollectionChange: () => {}, + hsApi: this._hsApi, + mediaRepository: this._mediaRepository, + user: this._user, + createRoomEncryption: this._createRoomEncryption, + platform: this._platform + }); } get invites() { @@ -421,34 +430,6 @@ export class Session { }); } - /** @internal */ - addInviteAfterSync(invite) { - this._invites.add(invite.id, invite); - const statusObservable = this._observedRoomStatus.get(invite.id); - if (statusObservable) { - statusObservable.set(statusObservable.get().withInvited()); - } - } - - /** @internal */ - removeInviteAfterSync(invite) { - this._invites.remove(invite.id); - const statusObservable = this._observedRoomStatus.get(invite.id); - if (statusObservable) { - statusObservable.set(statusObservable.get().withoutInvited()); - } - } - - /** @internal */ - archiveRoomAfterSync(room) { - this._rooms.remove(room.id); - const statusObservable = this._observedRoomStatus.get(room.id); - statusObservable?.set(RoomStatus.archived); - if (this._archivedRooms) { - this._archivedRooms.add(room.id, room); - } - } - async obtainSyncLock(syncResponse) { const toDeviceEvents = syncResponse.to_device?.events; if (Array.isArray(toDeviceEvents) && toDeviceEvents.length) { @@ -528,6 +509,58 @@ export class Session { } } + applyRoomCollectionChangesAfterSync(inviteStates, roomStates, archivedRoomStates) { + // update the collections after sync + if (this._archivedRooms) { + for (const ars of archivedRoomStates) { + if (ars.shouldAdd) { + this._archivedRooms.add(ars.id, ars.archivedRoom); + } else if (ars.shouldRemove) { + this._archivedRooms.remove(ars.id); + } + } + } + for (const rs of roomStates) { + if (rs.shouldAdd) { + this._rooms.add(rs.id, rs.room); + } else if (rs.shouldRemove) { + this._rooms.remove(rs.id); + } + } + for (const is of inviteStates) { + if (is.shouldAdd) { + this._invites.add(is.id, is.invite); + } else if (is.shouldRemove) { + this._invites.remove(is.id); + } + } + // now all the collections are updated, update the room status + // so any listeners to the status will find the collections + // completely up to date + if (this._observedRoomStatus.size !== 0) { + for (const ars of archivedRoomStates) { + if (ars.shouldAdd) { + this._observedRoomStatus.get(ars.id)?.set(RoomStatus.archived); + } + } + for (const rs of roomStates) { + if (rs.shouldAdd) { + this._observedRoomStatus.get(rs.id)?.set(RoomStatus.joined); + } + } + for (const is of inviteStates) { + const statusObservable = this._observedRoomStatus.get(is.id); + if (statusObservable) { + if (is.shouldAdd) { + statusObservable.set(statusObservable.get().withInvited()); + } else if (is.shouldRemove) { + statusObservable.set(statusObservable.get().withoutInvited()); + } + } + } + } + } + /** @internal */ get syncToken() { return this._syncInfo?.token; @@ -658,10 +691,7 @@ export class Session { ]); const summary = await txn.archivedRoomSummary.get(roomId); if (summary) { - // TODO: should we really be using a Room here? - // Or rather an ArchivedRoom that shares a common base class with Room? - // That will make the Room code harder to read though ... - const room = this.createRoom(roomId); + const room = this.createArchivedRoom(roomId); await room.load(summary, txn, log); return room; } diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index 35703589..dc613d00 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -192,7 +192,8 @@ export class Sync { const isInitialSync = !syncToken; const sessionState = new SessionSyncProcessState(); const inviteStates = this._parseInvites(response.rooms); - const roomStates = this._parseRoomsResponse(response.rooms, inviteStates, isInitialSync); + const {roomStates, archivedRoomStates} = await this._parseRoomsResponse( + response.rooms, inviteStates, isInitialSync); try { // take a lock on olm sessions used in this sync so sending a message doesn't change them while syncing @@ -202,12 +203,14 @@ export class Sync { return rs.room.afterPrepareSync(rs.preparation, log); }))); await log.wrap("write", async log => this._writeSync( - sessionState, inviteStates, roomStates, response, syncFilterId, isInitialSync, log)); + sessionState, inviteStates, roomStates, archivedRoomStates, + response, syncFilterId, isInitialSync, log)); } finally { sessionState.dispose(); } // sync txn comitted, emit updates and apply changes to in-memory state - log.wrap("after", log => this._afterSync(sessionState, inviteStates, roomStates, log)); + log.wrap("after", log => this._afterSync( + sessionState, inviteStates, roomStates, archivedRoomStates, log)); const toDeviceEvents = response.to_device?.events; return { @@ -255,6 +258,8 @@ export class Sync { await Promise.all(roomStates.map(async rs => { const newKeys = newKeysByRoom?.get(rs.room.id); rs.preparation = await log.wrap("room", async log => { + // if previously joined and we still have the timeline for it, + // this loads the syncWriter at the correct position to continue writing the timeline if (rs.isNewRoom) { await rs.room.load(null, prepareTxn, log); } @@ -267,7 +272,7 @@ export class Sync { await prepareTxn.complete(); } - async _writeSync(sessionState, inviteStates, roomStates, response, syncFilterId, isInitialSync, log) { + async _writeSync(sessionState, inviteStates, roomStates, archivedRoomStates, response, syncFilterId, isInitialSync, log) { const syncTxn = await this._openSyncTxn(); try { sessionState.changes = await log.wrap("session", log => this._session.writeSync( @@ -280,6 +285,13 @@ export class Sync { rs.changes = await log.wrap("room", log => rs.room.writeSync( rs.roomResponse, isInitialSync, rs.preparation, syncTxn, log)); })); + // important to do this after roomStates, + // as we're referring to the roomState to get the summaryChanges + await Promise.all(archivedRoomStates.map(async ars => { + const summaryChanges = ars.roomState?.summaryChanges; + ars.changes = await log.wrap("archivedRoom", log => ars.archivedRoom.writeSync( + summaryChanges, ars.roomResponse, ars.membership, syncTxn, log)); + })); } catch(err) { // avoid corrupting state by only // storing the sync up till the point @@ -294,35 +306,18 @@ export class Sync { await syncTxn.complete(); } - _afterSync(sessionState, inviteStates, roomStates, log) { + _afterSync(sessionState, inviteStates, roomStates, archivedRoomStates, log) { log.wrap("session", log => this._session.afterSync(sessionState.changes, log), log.level.Detail); - // emit room related events after txn has been closed + for(let ars of archivedRoomStates) { + log.wrap("archivedRoom", () => ars.archivedRoom.afterSync(ars.changes), log.level.Detail); + } for(let rs of roomStates) { log.wrap("room", log => rs.room.afterSync(rs.changes, log), log.level.Detail); - if (rs.isNewRoom) { - // important to add the room before removing the invite, - // so the room will be found if looking for it when the invite - // is removed - this._session.addRoomAfterSync(rs.room); - } else if (rs.membership === "leave") { - this._session.archiveRoomAfterSync(rs.room); - } } - // emit invite related events after txn has been closed for(let is of inviteStates) { - log.wrap("invite", () => { - // important to remove before emitting change in afterSync - // so code checking session.invites.get(id) won't - // find the invite anymore on update - if (is.membership !== "invite") { - this._session.removeInviteAfterSync(is.invite); - } - is.invite.afterSync(is.changes); - }, log.level.Detail); - if (is.isNewInvite) { - this._session.addInviteAfterSync(is.invite); - } + log.wrap("invite", () => is.invite.afterSync(is.changes), log.level.Detail); } + this._session.applyRoomCollectionChangesAfterSync(inviteStates, roomStates, archivedRoomStates); } _openSyncTxn() { @@ -351,8 +346,9 @@ export class Sync { ]); } - _parseRoomsResponse(roomsSection, inviteStates, isInitialSync) { + async _parseRoomsResponse(roomsSection, inviteStates, isInitialSync) { const roomStates = []; + const archivedRoomStates = []; if (roomsSection) { const allMemberships = ["join", "leave"]; for(const membership of allMemberships) { @@ -364,28 +360,64 @@ export class Sync { if (isInitialSync && timelineIsEmpty(roomResponse)) { continue; } - let isNewRoom = false; - let room = this._session.rooms.get(roomId); - // don't create a room for a rejected invite - if (!room && membership === "join") { - room = this._session.createRoom(roomId); - isNewRoom = true; - } const invite = this._session.invites.get(roomId); // if there is an existing invite, add a process state for it // so its writeSync and afterSync will run and remove the invite if (invite) { - inviteStates.push(new InviteSyncProcessState(invite, false, null, membership, null)); + inviteStates.push(new InviteSyncProcessState(invite, false, null, membership)); } - if (room) { - roomStates.push(new RoomSyncProcessState( - room, isNewRoom, invite, roomResponse, membership)); + const roomState = this._createRoomSyncState(roomId, invite, roomResponse, membership); + if (roomState) { + roomStates.push(roomState); + } + const ars = await this._createArchivedRoomSyncState(roomId, roomState, roomResponse, membership); + if (ars) { + archivedRoomStates.push(ars); } } } } } - return roomStates; + return {roomStates, archivedRoomStates}; + } + + _createRoomSyncState(roomId, invite, roomResponse, membership) { + let isNewRoom = false; + let room = this._session.rooms.get(roomId); + // create room only on new join, + // don't create a room for a rejected invite + if (!room && membership === "join") { + room = this._session.createRoom(roomId); + isNewRoom = true; + } + if (room) { + return new RoomSyncProcessState( + room, isNewRoom, invite, roomResponse, membership); + } + } + + async _createArchivedRoomSyncState(roomId, roomState, roomResponse, membership) { + let archivedRoom; + if (membership === "join") { + // when joining, always create the archived room to write the removal + archivedRoom = this._session.createArchivedRoom(roomId); + } else if (membership === "leave") { + if (roomState) { + // we still have a roomState, so we just left it + // in this case, create a new archivedRoom + archivedRoom = this._session.createArchivedRoom(roomId); + } else { + // this is an update of an already left room, restore + // it from storage first, so we can increment it. + // this happens for example when our membership changes + // after leaving (e.g. being (un)banned, possibly after being kicked), etc + archivedRoom = await this._session.loadArchivedRoom(roomId); + } + } + if (archivedRoom) { + return new ArchivedRoomSyncProcessState( + archivedRoom, roomState, roomResponse, membership); + } } _parseInvites(roomsSection) { @@ -398,8 +430,7 @@ export class Sync { invite = this._session.createInvite(roomId); isNewInvite = true; } - const room = this._session.rooms.get(roomId); - inviteStates.push(new InviteSyncProcessState(invite, isNewInvite, room, "invite", roomResponse)); + inviteStates.push(new InviteSyncProcessState(invite, isNewInvite, roomResponse, "invite")); } } return inviteStates; @@ -440,15 +471,65 @@ class RoomSyncProcessState { this.preparation = null; this.changes = null; } + + get id() { + return this.room.id; + } + + get shouldAdd() { + return this.isNewRoom; + } + + get shouldRemove() { + return this.membership !== "join"; + } + + get summaryChanges() { + return this.changes?.summaryChanges; + } +} + + +class ArchivedRoomSyncProcessState { + constructor(archivedRoom, roomState, roomResponse, membership) { + this.archivedRoom = archivedRoom; + this.roomState = roomState; + this.roomResponse = roomResponse; + this.membership = membership; + this.changes = null; + } + + get id() { + return this.archivedRoom.id; + } + + get shouldAdd() { + return this.roomState && this.membership === "leave"; + } + + get shouldRemove() { + return this.membership === "join"; + } } class InviteSyncProcessState { - constructor(invite, isNewInvite, room, membership, roomResponse) { + constructor(invite, isNewInvite, roomResponse, membership) { this.invite = invite; this.isNewInvite = isNewInvite; - this.room = room; this.membership = membership; this.roomResponse = roomResponse; this.changes = null; } + + get id() { + return this.invite.id; + } + + get shouldAdd() { + return this.isNewInvite; + } + + get shouldRemove() { + return this.membership !== "invite"; + } } diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 7e45a8da..ff13c0f4 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -45,7 +45,7 @@ export class Room extends BaseRoom { if (newKeys) { log.set("newKeys", newKeys.length); } - let summaryChanges = this._summary.data.applySyncResponse(roomResponse, membership, this._user.id); + let summaryChanges = this._summary.data.applySyncResponse(roomResponse, membership); if (membership === "join" && invite) { summaryChanges = summaryChanges.applyInvite(invite); } @@ -105,8 +105,6 @@ export class Room extends BaseRoom { // so no old state sticks around txn.roomState.removeAllForRoom(this.id); txn.roomMembers.removeAllForRoom(this.id); - // TODO: this should be done in ArchivedRoom - txn.archivedRoomSummary.remove(this.id); } const {entries: newEntries, newLiveKey, memberChanges} = await log.wrap("syncWriter", log => this._syncWriter.writeSync(roomResponse, isRejoin, txn, log), log.level.Detail); @@ -135,10 +133,9 @@ export class Room extends BaseRoom { summaryChanges = summaryChanges.applyTimelineEntries( allEntries, isInitialSync, !this._isTimelineOpen, this._user.id); - // only archive a room if we had previously joined it - if (summaryChanges.membership === "leave" && this.membership === "join") { + // if we've have left the room, remove the summary + if (summaryChanges.membership !== "join") { txn.roomSummary.remove(this.id); - summaryChanges = this._summary.writeArchivedData(summaryChanges, txn); } else { // write summary changes, and unset if nothing was actually changed summaryChanges = this._summary.writeData(summaryChanges, txn); From 2087059c0ba59c88695e54e529c923bcb887d0eb Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 11 May 2021 13:01:19 +0200 Subject: [PATCH 36/51] fix archived room summary key path now the summary is nested --- src/matrix/storage/idb/schema.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/storage/idb/schema.js b/src/matrix/storage/idb/schema.js index f0c052ea..aeb85a4b 100644 --- a/src/matrix/storage/idb/schema.js +++ b/src/matrix/storage/idb/schema.js @@ -113,5 +113,5 @@ function createInviteStore(db) { // v8 function createArchivedRoomSummaryStore(db) { - db.createObjectStore("archivedRoomSummary", {keyPath: "roomId"}); + db.createObjectStore("archivedRoomSummary", {keyPath: "summary.roomId"}); } \ No newline at end of file From e775ed12b421e4ff455ae3627f7e77769fecc923 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 11 May 2021 13:02:43 +0200 Subject: [PATCH 37/51] sync arch. rooms with Room during init. sync to create summary, timeline when receiving archived rooms during initial sync, sync them with Room (e.g. as a joined room) first so the members, timeline get written and the summary gets created which is then adopted by the ArchivedRoom. --- src/matrix/Sync.js | 42 +++++++++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index dc613d00..ce297cc7 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -193,7 +193,7 @@ export class Sync { const sessionState = new SessionSyncProcessState(); const inviteStates = this._parseInvites(response.rooms); const {roomStates, archivedRoomStates} = await this._parseRoomsResponse( - response.rooms, inviteStates, isInitialSync); + response.rooms, inviteStates, isInitialSync, log); try { // take a lock on olm sessions used in this sync so sending a message doesn't change them while syncing @@ -346,7 +346,7 @@ export class Sync { ]); } - async _parseRoomsResponse(roomsSection, inviteStates, isInitialSync) { + async _parseRoomsResponse(roomsSection, inviteStates, isInitialSync, log) { const roomStates = []; const archivedRoomStates = []; if (roomsSection) { @@ -366,11 +366,11 @@ export class Sync { if (invite) { inviteStates.push(new InviteSyncProcessState(invite, false, null, membership)); } - const roomState = this._createRoomSyncState(roomId, invite, roomResponse, membership); + const roomState = this._createRoomSyncState(roomId, invite, roomResponse, membership, isInitialSync); if (roomState) { roomStates.push(roomState); } - const ars = await this._createArchivedRoomSyncState(roomId, roomState, roomResponse, membership); + const ars = await this._createArchivedRoomSyncState(roomId, roomState, roomResponse, membership, isInitialSync, log); if (ars) { archivedRoomStates.push(ars); } @@ -381,12 +381,17 @@ export class Sync { return {roomStates, archivedRoomStates}; } - _createRoomSyncState(roomId, invite, roomResponse, membership) { + _createRoomSyncState(roomId, invite, roomResponse, membership, isInitialSync) { let isNewRoom = false; let room = this._session.rooms.get(roomId); - // create room only on new join, - // don't create a room for a rejected invite - if (!room && membership === "join") { + // create room only either on new join, + // or for an archived room during initial sync, + // where we create the summaryChanges with a joined + // room to then adopt by the archived room. + // This way the limited timeline, members, ... + // we receive also gets written. + // In any case, don't create a room for a rejected invite + if (!room && (membership === "join" || (isInitialSync && membership === "leave"))) { room = this._session.createRoom(roomId); isNewRoom = true; } @@ -396,10 +401,12 @@ export class Sync { } } - async _createArchivedRoomSyncState(roomId, roomState, roomResponse, membership) { + async _createArchivedRoomSyncState(roomId, roomState, roomResponse, membership, isInitialSync, log) { let archivedRoom; - if (membership === "join") { - // when joining, always create the archived room to write the removal + if (roomState?.shouldAdd && !isInitialSync) { + // when adding a joined room during incremental sync, + // always create the archived room to write the removal + // of the archived summary archivedRoom = this._session.createArchivedRoom(roomId); } else if (membership === "leave") { if (roomState) { @@ -411,12 +418,12 @@ export class Sync { // it from storage first, so we can increment it. // this happens for example when our membership changes // after leaving (e.g. being (un)banned, possibly after being kicked), etc - archivedRoom = await this._session.loadArchivedRoom(roomId); + archivedRoom = await this._session.loadArchivedRoom(roomId, log); } } if (archivedRoom) { return new ArchivedRoomSyncProcessState( - archivedRoom, roomState, roomResponse, membership); + archivedRoom, roomState, roomResponse, membership); } } @@ -477,11 +484,11 @@ class RoomSyncProcessState { } get shouldAdd() { - return this.isNewRoom; + return this.isNewRoom && this.membership === "join"; } get shouldRemove() { - return this.membership !== "join"; + return !this.isNewRoom && this.membership !== "join"; } get summaryChanges() { @@ -491,11 +498,12 @@ class RoomSyncProcessState { class ArchivedRoomSyncProcessState { - constructor(archivedRoom, roomState, roomResponse, membership) { + constructor(archivedRoom, roomState, roomResponse, membership, isInitialSync) { this.archivedRoom = archivedRoom; this.roomState = roomState; this.roomResponse = roomResponse; this.membership = membership; + this.isInitialSync = isInitialSync; this.changes = null; } @@ -504,7 +512,7 @@ class ArchivedRoomSyncProcessState { } get shouldAdd() { - return this.roomState && this.membership === "leave"; + return (this.roomState || this.isInitialSync) && this.membership === "leave"; } get shouldRemove() { From 24731687dcb993f00e38c74294929ca0ed3fb980 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 11 May 2021 13:05:02 +0200 Subject: [PATCH 38/51] log room id in invite and archivedroom afterSync --- src/matrix/Sync.js | 4 ++-- src/matrix/room/ArchivedRoom.js | 3 ++- src/matrix/room/Invite.js | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index ce297cc7..cde134e4 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -309,13 +309,13 @@ export class Sync { _afterSync(sessionState, inviteStates, roomStates, archivedRoomStates, log) { log.wrap("session", log => this._session.afterSync(sessionState.changes, log), log.level.Detail); for(let ars of archivedRoomStates) { - log.wrap("archivedRoom", () => ars.archivedRoom.afterSync(ars.changes), log.level.Detail); + log.wrap("archivedRoom", log => ars.archivedRoom.afterSync(ars.changes, log), log.level.Detail); } for(let rs of roomStates) { log.wrap("room", log => rs.room.afterSync(rs.changes, log), log.level.Detail); } for(let is of inviteStates) { - log.wrap("invite", () => is.invite.afterSync(is.changes), log.level.Detail); + log.wrap("invite", log => is.invite.afterSync(is.changes, log), log.level.Detail); } this._session.applyRoomCollectionChangesAfterSync(inviteStates, roomStates, archivedRoomStates); } diff --git a/src/matrix/room/ArchivedRoom.js b/src/matrix/room/ArchivedRoom.js index 0253d366..f3bd09a0 100644 --- a/src/matrix/room/ArchivedRoom.js +++ b/src/matrix/room/ArchivedRoom.js @@ -73,7 +73,8 @@ export class ArchivedRoom extends BaseRoom { * Called with the changes returned from `writeSync` to apply them and emit changes. * No storage or network operations should be done here. */ - afterSync({summaryData, kickDetails, kickAuthor}) { + afterSync({summaryData, kickDetails, kickAuthor}, log) { + log.set("id", this.id); if (summaryData) { this._summary.applyChanges(summaryData); } diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index ec173f50..d0476cb3 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -152,7 +152,8 @@ export class Invite extends EventEmitter { } } - afterSync(changes) { + afterSync(changes, log) { + log.set("id", this.id); if (changes) { if (changes.removed) { this._accepting = false; From 00d4dc95182e2f63eb6f6ef25babda3830b368b0 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 11 May 2021 13:07:11 +0200 Subject: [PATCH 39/51] rename kickAuthor to kickedBy --- src/matrix/room/ArchivedRoom.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/matrix/room/ArchivedRoom.js b/src/matrix/room/ArchivedRoom.js index f3bd09a0..3873eab5 100644 --- a/src/matrix/room/ArchivedRoom.js +++ b/src/matrix/room/ArchivedRoom.js @@ -22,7 +22,7 @@ export class ArchivedRoom extends BaseRoom { constructor(options) { super(options); this._kickDetails = null; - this._kickAuthor = null; + this._kickedBy = null; } async _getKickAuthor(sender, txn) { @@ -38,7 +38,7 @@ export class ArchivedRoom extends BaseRoom { const {summary, kickDetails} = archivedRoomSummary; this._kickDetails = kickDetails; if (this._kickDetails) { - this._kickAuthor = await this._getKickAuthor(this._kickDetails.sender, txn); + this._kickedBy = await this._getKickAuthor(this._kickDetails.sender, txn); } return super.load(summary, txn, log); } @@ -50,16 +50,16 @@ export class ArchivedRoom extends BaseRoom { const newKickDetails = findKickDetails(roomResponse, this._user.id); if (newKickDetails || joinedSummaryData) { const kickDetails = newKickDetails || this._kickDetails; - let kickAuthor; + let kickedBy; if (newKickDetails) { - kickAuthor = await this._getKickAuthor(newKickDetails.sender, txn); + kickedBy = await this._getKickAuthor(newKickDetails.sender, txn); } const summaryData = joinedSummaryData || this._summary.data; txn.archivedRoomSummary.set({ summary: summaryData.serialize(), kickDetails, }); - return {kickDetails, kickAuthor, summaryData}; + return {kickDetails, kickedBy, summaryData}; } } else if (membership === "join") { txn.archivedRoomSummary.remove(this.id); @@ -73,7 +73,7 @@ export class ArchivedRoom extends BaseRoom { * Called with the changes returned from `writeSync` to apply them and emit changes. * No storage or network operations should be done here. */ - afterSync({summaryData, kickDetails, kickAuthor}, log) { + afterSync({summaryData, kickDetails, kickedBy}, log) { log.set("id", this.id); if (summaryData) { this._summary.applyChanges(summaryData); @@ -81,8 +81,8 @@ export class ArchivedRoom extends BaseRoom { if (kickDetails) { this._kickDetails = kickDetails; } - if (kickAuthor) { - this._kickAuthor = kickAuthor; + if (kickedBy) { + this._kickedBy = kickedBy; } this._emitUpdate(); } From 6bb9140720737f9480dc8858fcf4a6ff3988d968 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 11 May 2021 13:07:31 +0200 Subject: [PATCH 40/51] have individual getters for kickDetails --- src/matrix/room/ArchivedRoom.js | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/src/matrix/room/ArchivedRoom.js b/src/matrix/room/ArchivedRoom.js index 3873eab5..a7f6525e 100644 --- a/src/matrix/room/ArchivedRoom.js +++ b/src/matrix/room/ArchivedRoom.js @@ -21,6 +21,10 @@ import {RoomMember} from "./members/RoomMember.js"; export class ArchivedRoom extends BaseRoom { constructor(options) { super(options); + /** + Some details from our own member event when being kicked or banned. + We can't get this from the member store, because we don't store the reason field there. + */ this._kickDetails = null; this._kickedBy = null; } @@ -87,15 +91,24 @@ export class ArchivedRoom extends BaseRoom { this._emitUpdate(); } - getLeaveDetails() { - if (this.membership === "leave") { - return { - isKicked: this._kickDetails?.membership === "leave", - isBanned: this._kickDetails?.membership === "ban", - reason: this._kickDetails?.reason, - sender: this._kickAuthor, - }; - } + get isKicked() { + return this._kickDetails?.membership === "leave"; + } + + get isBanned() { + return this._kickDetails?.membership === "ban"; + } + + get kickedBy() { + return this._kickedBy; + } + + get kickReason() { + return this._kickDetails?.reason; + } + + isArchived() { + return true; } forget() { From 51d13fd8d262f8d915e05ae55741290557dfda60 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 11 May 2021 13:07:57 +0200 Subject: [PATCH 41/51] update comments for Invite updates --- src/matrix/room/Invite.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index d0476cb3..55dc9742 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -165,10 +165,9 @@ export class Invite extends EventEmitter { } this.emit("change"); } else { + // no emit change, adding to the collection is done by sync this._inviteData = changes.inviteData; this._inviter = changes.inviter; - // sync will add the invite to the collection by - // calling session.addInviteAfterSync } } } From be7934057e982346cdd7fc00036297bdc775c0c7 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 11 May 2021 13:08:13 +0200 Subject: [PATCH 42/51] lint --- src/matrix/room/BaseRoom.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/matrix/room/BaseRoom.js b/src/matrix/room/BaseRoom.js index d01cfaf1..924e5316 100644 --- a/src/matrix/room/BaseRoom.js +++ b/src/matrix/room/BaseRoom.js @@ -297,6 +297,7 @@ export class BaseRoom extends EventEmitter { allow sub classes to integrate in the gap fill lifecycle. JoinedRoom uses this update remote echos. */ + // eslint-disable-next-line no-unused-vars _writeGapFill(chunk, txn, log) {} _applyGapFill() {} From 9ea0138ffdc3e85be707e2ec46be61f13ae85259 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 11 May 2021 13:10:21 +0200 Subject: [PATCH 43/51] don't open room view when clearing room id --- src/domain/session/SessionViewModel.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index c847ce79..ae697c72 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -195,6 +195,12 @@ export class SessionViewModel extends ViewModel { if (this._roomViewModelObservable) { this._roomViewModelObservable = this.disposeTracked(this._roomViewModelObservable); } + if (!roomId) { + // if clearing the activeMiddleViewModel rather than changing to a different one, + // emit so the view picks it up and show the placeholder + this.emitChange("activeMiddleViewModel"); + return; + } const vmo = new RoomViewModelObservable(this, roomId); this._roomViewModelObservable = this.track(vmo); // subscription is unsubscribed in RoomViewModelObservable.dispose, and thus handled by track From e3c1644d093d24f2c4084e8e4a955851f095f4b7 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 11 May 2021 13:11:11 +0200 Subject: [PATCH 44/51] show leave reason instead of composer for archived room --- src/domain/session/room/RoomViewModel.js | 47 +++++++++++++++++-- .../web/ui/css/themes/element/theme.css | 9 ++++ .../web/ui/session/room/RoomArchivedView.js | 23 +++++++++ src/platform/web/ui/session/room/RoomView.js | 9 +++- 4 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 src/platform/web/ui/session/room/RoomArchivedView.js diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index df631b87..9c0dfd65 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -29,7 +29,12 @@ export class RoomViewModel extends ViewModel { this._onRoomChange = this._onRoomChange.bind(this); this._timelineError = null; this._sendError = null; - this._composerVM = new ComposerViewModel(this); + this._composerVM = null; + if (room.isArchived) { + this._composerVM = new ArchivedViewModel(this.childOptions({archivedRoom: room})); + } else { + this._composerVM = new ComposerViewModel(this); + } this._clearUnreadTimout = null; this._closeUrl = this.urlCreator.urlUntilSegment("session"); } @@ -54,7 +59,7 @@ export class RoomViewModel extends ViewModel { } async _clearUnreadAfterDelay() { - if (this._clearUnreadTimout) { + if (this._room.isArchived || this._clearUnreadTimout) { return; } this._clearUnreadTimout = this.clock.createTimeout(2000); @@ -85,6 +90,9 @@ export class RoomViewModel extends ViewModel { // room doesn't tell us yet which fields changed, // so emit all fields originating from summary _onRoomChange() { + if (this._room.isArchived) { + this._composerVM.emitChange(); + } this.emitChange(); } @@ -122,7 +130,7 @@ export class RoomViewModel extends ViewModel { } async _sendMessage(message) { - if (message) { + if (!this._room.isArchived && message) { try { let msgtype = "m.text"; if (message.startsWith("/me ")) { @@ -303,6 +311,10 @@ class ComposerViewModel extends ViewModel { this.emitChange("canSend"); } } + + get kind() { + return "composer"; + } } function imageToInfo(image) { @@ -319,3 +331,32 @@ function videoToInfo(video) { info.duration = video.duration; return info; } + +class ArchivedViewModel extends ViewModel { + constructor(options) { + super(options); + this._archivedRoom = options.archivedRoom; + } + + get description() { + if (this._archivedRoom.isKicked) { + if (this._archivedRoom.kickReason) { + return this.i18n`You were kicked from the room by ${this._archivedRoom.kickedBy.name} because: ${this._archivedRoom.kickReason}`; + } else { + return this.i18n`You were kicked from the room by ${this._archivedRoom.kickedBy.name}.`; + } + } else if (this._archivedRoom.isBanned) { + if (this._archivedRoom.kickReason) { + return this.i18n`You were banned from the room by ${this._archivedRoom.kickedBy.name} because: ${this._archivedRoom.kickReason}`; + } else { + return this.i18n`You were banned from the room by ${this._archivedRoom.kickedBy.name}.`; + } + } else { + return this.i18n`You left this room`; + } + } + + get kind() { + return "archived"; + } +} \ No newline at end of file diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index f38ba82b..6f253329 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -897,3 +897,12 @@ button.link { display: block; width: 100%; } + +.RoomArchivedView { + padding: 12px; + background-color: rgba(245, 245, 245, 0.90); +} + +.RoomArchivedView h3 { + margin: 0; +} \ No newline at end of file diff --git a/src/platform/web/ui/session/room/RoomArchivedView.js b/src/platform/web/ui/session/room/RoomArchivedView.js new file mode 100644 index 00000000..e5e489ed --- /dev/null +++ b/src/platform/web/ui/session/room/RoomArchivedView.js @@ -0,0 +1,23 @@ +/* +Copyright 2020 Bruno Windels + +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 {TemplateView} from "../../general/TemplateView.js"; + +export class RoomArchivedView extends TemplateView { + render(t, vm) { + return t.div({className: "RoomArchivedView"}, t.h3(vm => vm.description)); + } +} \ No newline at end of file diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index 327af046..db3f68da 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -19,10 +19,17 @@ import {TemplateView} from "../../general/TemplateView.js"; import {TimelineList} from "./TimelineList.js"; import {TimelineLoadingView} from "./TimelineLoadingView.js"; import {MessageComposer} from "./MessageComposer.js"; +import {RoomArchivedView} from "./RoomArchivedView.js"; import {AvatarView} from "../../avatar.js"; export class RoomView extends TemplateView { render(t, vm) { + let bottomView; + if (vm.composerViewModel.kind === "composer") { + bottomView = new MessageComposer(vm.composerViewModel); + } else if (vm.composerViewModel.kind === "archived") { + bottomView = new RoomArchivedView(vm.composerViewModel); + } return t.main({className: "RoomView middle"}, [ t.div({className: "RoomHeader middle-header"}, [ t.a({className: "button-utility close-middle", href: vm.closeUrl, title: vm.i18n`Close room`}), @@ -38,7 +45,7 @@ export class RoomView extends TemplateView { new TimelineList(timelineViewModel) : new TimelineLoadingView(vm); // vm is just needed for i18n }), - t.view(new MessageComposer(vm.composerViewModel)), + t.view(bottomView), ]) ]); } From 82a1b37363a09e33aa2eb80a8b0a9babae673b93 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 11 May 2021 13:11:38 +0200 Subject: [PATCH 45/51] refresh room view model when going from joined -> archived given we have a dedicated class (ArchivedRoom) for it now --- src/domain/session/RoomViewModelObservable.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/domain/session/RoomViewModelObservable.js b/src/domain/session/RoomViewModelObservable.js index 030ba5e4..02b63d3d 100644 --- a/src/domain/session/RoomViewModelObservable.js +++ b/src/domain/session/RoomViewModelObservable.js @@ -61,12 +61,7 @@ export class RoomViewModelObservable extends ObservableValue { } else if (status.joined) { return this._sessionViewModel._createRoomViewModel(this.id); } else if (status.archived) { - if (!this.get() || this.get().kind !== "room") { - return await this._sessionViewModel._createArchivedRoomViewModel(this.id); - } else { - // reuse existing Room - return this.get(); - } + return await this._sessionViewModel._createArchivedRoomViewModel(this.id); } return null; } From 965700272b62a03f43685027b0bb947c953e2fbf Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 11 May 2021 16:09:01 +0200 Subject: [PATCH 46/51] remove archivedRoom map, it's unused and just complicating for now --- src/matrix/Session.js | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 961adf11..20f303fb 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -56,7 +56,6 @@ export class Session { this._sessionInfo = sessionInfo; this._rooms = new ObservableMap(); this._roomUpdateCallback = (room, params) => this._rooms.update(room.id, params); - this._archivedRooms = null; this._invites = new ObservableMap(); this._inviteUpdateCallback = (invite, params) => this._invites.update(invite.id, params); this._user = new User(sessionInfo.userId); @@ -511,15 +510,6 @@ export class Session { applyRoomCollectionChangesAfterSync(inviteStates, roomStates, archivedRoomStates) { // update the collections after sync - if (this._archivedRooms) { - for (const ars of archivedRoomStates) { - if (ars.shouldAdd) { - this._archivedRooms.add(ars.id, ars.archivedRoom); - } else if (ars.shouldRemove) { - this._archivedRooms.remove(ars.id); - } - } - } for (const rs of roomStates) { if (rs.shouldAdd) { this._rooms.add(rs.id, rs.room); @@ -651,13 +641,6 @@ export class Session { return RoomStatus.joined; } else { const isInvited = !!this._invites.get(roomId); - let isArchived; - if (this._archivedRooms) { - isArchived = !!this._archivedRooms.get(roomId); - } else { - const txn = await this._storage.readTxn([this._storage.storeNames.archivedRoomSummary]); - isArchived = await txn.archivedRoomSummary.has(roomId); - } if (isInvited && isArchived) { return RoomStatus.invitedAndArchived; } else if (isInvited) { From 8b8214cd1b7c67f534641b2d78953f280462e6a2 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 11 May 2021 16:09:58 +0200 Subject: [PATCH 47/51] reference count archived rooms and keep track of active ones so we don't create two instances for the same id, one for sync, and one for displaying, and hence updates from sync being pushed on a different instance than the one displaying, and not updating the view. --- src/domain/session/room/RoomViewModel.js | 3 +++ src/matrix/Session.js | 17 ++++++++++++++--- src/matrix/Sync.js | 9 ++++++--- src/matrix/room/ArchivedRoom.js | 15 +++++++++++++++ 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 9c0dfd65..7e8591af 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -81,6 +81,9 @@ export class RoomViewModel extends ViewModel { dispose() { super.dispose(); this._room.off("change", this._onRoomChange); + if (this._room.isArchived) { + this._room.release(); + } if (this._clearUnreadTimout) { this._clearUnreadTimout.abort(); this._clearUnreadTimout = null; diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 20f303fb..527b7e6c 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -56,6 +56,7 @@ export class Session { this._sessionInfo = sessionInfo; this._rooms = new ObservableMap(); this._roomUpdateCallback = (room, params) => this._rooms.update(room.id, params); + this._activeArchivedRooms = new Map(); this._invites = new ObservableMap(); this._inviteUpdateCallback = (invite, params) => this._invites.update(invite.id, params); this._user = new User(sessionInfo.userId); @@ -399,18 +400,21 @@ export class Session { } /** @internal */ - createArchivedRoom(roomId) { - return new ArchivedRoom({ + _createArchivedRoom(roomId) { + const room = new ArchivedRoom({ roomId, getSyncToken: this._getSyncToken, storage: this._storage, emitCollectionChange: () => {}, + releaseCallback: () => this._activeArchivedRooms.delete(roomId), hsApi: this._hsApi, mediaRepository: this._mediaRepository, user: this._user, createRoomEncryption: this._createRoomEncryption, platform: this._platform }); + this._activeArchivedRooms.set(roomId, room); + return room; } get invites() { @@ -641,6 +645,8 @@ export class Session { return RoomStatus.joined; } else { const isInvited = !!this._invites.get(roomId); + const txn = await this._storage.readTxn([this._storage.storeNames.archivedRoomSummary]); + const isArchived = await txn.archivedRoomSummary.has(roomId); if (isInvited && isArchived) { return RoomStatus.invitedAndArchived; } else if (isInvited) { @@ -668,13 +674,18 @@ export class Session { loadArchivedRoom(roomId, log = null) { return this._platform.logger.wrapOrRun(log, "loadArchivedRoom", async log => { log.set("id", roomId); + const activeArchivedRoom = this._activeArchivedRooms.get(roomId); + if (activeArchivedRoom) { + activeArchivedRoom.retain(); + return activeArchivedRoom; + } const txn = await this._storage.readTxn([ this._storage.storeNames.archivedRoomSummary, this._storage.storeNames.roomMembers, ]); const summary = await txn.archivedRoomSummary.get(roomId); if (summary) { - const room = this.createArchivedRoom(roomId); + const room = this._createArchivedRoom(roomId); await room.load(summary, txn, log); return room; } diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index cde134e4..ac0da4b2 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -309,7 +309,10 @@ export class Sync { _afterSync(sessionState, inviteStates, roomStates, archivedRoomStates, log) { log.wrap("session", log => this._session.afterSync(sessionState.changes, log), log.level.Detail); for(let ars of archivedRoomStates) { - log.wrap("archivedRoom", log => ars.archivedRoom.afterSync(ars.changes, log), log.level.Detail); + log.wrap("archivedRoom", log => { + ars.archivedRoom.afterSync(ars.changes, log); + ars.archivedRoom.release(); + }, log.level.Detail); } for(let rs of roomStates) { log.wrap("room", log => rs.room.afterSync(rs.changes, log), log.level.Detail); @@ -407,12 +410,12 @@ export class Sync { // when adding a joined room during incremental sync, // always create the archived room to write the removal // of the archived summary - archivedRoom = this._session.createArchivedRoom(roomId); + archivedRoom = await this._session.loadArchivedRoom(roomId, log); } else if (membership === "leave") { if (roomState) { // we still have a roomState, so we just left it // in this case, create a new archivedRoom - archivedRoom = this._session.createArchivedRoom(roomId); + archivedRoom = await this._session.loadArchivedRoom(roomId, log); } else { // this is an update of an already left room, restore // it from storage first, so we can increment it. diff --git a/src/matrix/room/ArchivedRoom.js b/src/matrix/room/ArchivedRoom.js index a7f6525e..fa95270b 100644 --- a/src/matrix/room/ArchivedRoom.js +++ b/src/matrix/room/ArchivedRoom.js @@ -21,6 +21,10 @@ import {RoomMember} from "./members/RoomMember.js"; export class ArchivedRoom extends BaseRoom { constructor(options) { super(options); + // archived rooms are reference counted, + // as they are not kept in memory when not needed + this._releaseCallback = options.releaseCallback; + this._retentionCount = 1; /** Some details from our own member event when being kicked or banned. We can't get this from the member store, because we don't store the reason field there. @@ -29,6 +33,17 @@ export class ArchivedRoom extends BaseRoom { this._kickedBy = null; } + retain() { + this._retentionCount += 1; + } + + release() { + this._retentionCount -= 1; + if (this._retentionCount === 0) { + this._releaseCallback(); + } + } + async _getKickAuthor(sender, txn) { const senderMember = await txn.roomMembers.get(this.id, sender); if (senderMember) { From a0e3848cf6381a211dd89410a571cf9eb098cc92 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 11 May 2021 16:39:33 +0200 Subject: [PATCH 48/51] dispose existing view model when changing status, otherwise we leak! --- src/domain/session/RoomViewModelObservable.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/domain/session/RoomViewModelObservable.js b/src/domain/session/RoomViewModelObservable.js index 02b63d3d..9252ee81 100644 --- a/src/domain/session/RoomViewModelObservable.js +++ b/src/domain/session/RoomViewModelObservable.js @@ -51,6 +51,8 @@ export class RoomViewModelObservable extends ObservableValue { this._statusObservable = await session.observeRoomStatus(this.id); this.set(await this._statusToViewModel(this._statusObservable.get())); this._statusObservable.subscribe(async status => { + // first dispose existing VM, if any + this.get()?.dispose(); this.set(await this._statusToViewModel(status)); }); } From 1738a0ea3c9dc4aabdb66446e0213ac89513dbeb Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 11 May 2021 16:58:16 +0200 Subject: [PATCH 49/51] need to create archived room when leaving, otherwise it isn't stored --- src/matrix/Session.js | 17 +++++++++++++++++ src/matrix/Sync.js | 4 ++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 527b7e6c..3cf1df65 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -671,6 +671,23 @@ export class Session { return observable; } + /** + Creates an empty (summary isn't loaded) the archived room if it isn't + loaded already, assuming sync will either remove it (when rejoining) or + write a full summary adopting it from the joined room when leaving + + @internal + */ + createOrGetArchivedRoomForSync(roomId) { + let archivedRoom = this._activeArchivedRooms.get(roomId); + if (archivedRoom) { + archivedRoom.retain(); + } else { + archivedRoom = this._createArchivedRoom(roomId); + } + return archivedRoom; + } + loadArchivedRoom(roomId, log = null) { return this._platform.logger.wrapOrRun(log, "loadArchivedRoom", async log => { log.set("id", roomId); diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index ac0da4b2..62bb67bd 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -410,12 +410,12 @@ export class Sync { // when adding a joined room during incremental sync, // always create the archived room to write the removal // of the archived summary - archivedRoom = await this._session.loadArchivedRoom(roomId, log); + archivedRoom = this._session.createOrGetArchivedRoomForSync(roomId); } else if (membership === "leave") { if (roomState) { // we still have a roomState, so we just left it // in this case, create a new archivedRoom - archivedRoom = await this._session.loadArchivedRoom(roomId, log); + archivedRoom = this._session.createOrGetArchivedRoomForSync(roomId); } else { // this is an update of an already left room, restore // it from storage first, so we can increment it. From ca84f485abc7dffeb06b50c5aec50d6566a6a71b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 11 May 2021 17:04:36 +0200 Subject: [PATCH 50/51] fix tests --- src/matrix/room/Invite.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index 55dc9742..aa25b0c6 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -273,7 +273,7 @@ export function tests() { const txn = createStorage(); const changes = await invite.writeSync("invite", roomInviteFixture, txn, new NullLogItem()); assert.equal(txn.invitesMap.get(roomId).roomId, roomId); - invite.afterSync(changes); + invite.afterSync(changes, new NullLogItem()); assert.equal(invite.name, "Invite example"); assert.equal(invite.avatarUrl, roomAvatarUrl); assert.equal(invite.isPublic, false); @@ -294,7 +294,7 @@ export function tests() { const txn = createStorage(); const changes = await invite.writeSync("invite", dmInviteFixture, txn, new NullLogItem()); assert.equal(txn.invitesMap.get(roomId).roomId, roomId); - invite.afterSync(changes); + invite.afterSync(changes, new NullLogItem()); assert.equal(invite.name, "Alice"); assert.equal(invite.avatarUrl, aliceAvatarUrl); assert.equal(invite.timestamp, 1003); @@ -336,10 +336,10 @@ export function tests() { const txn = createStorage(); const changes = await invite.writeSync("invite", dmInviteFixture, txn, new NullLogItem()); assert.equal(txn.invitesMap.get(roomId).roomId, roomId); - invite.afterSync(changes); + invite.afterSync(changes, new NullLogItem()); const joinChanges = await invite.writeSync("join", null, txn, new NullLogItem()); assert.strictEqual(changeEmitCount, 0); - invite.afterSync(joinChanges); + invite.afterSync(joinChanges, new NullLogItem()); assert.strictEqual(changeEmitCount, 1); assert.equal(txn.invitesMap.get(roomId), undefined); assert.equal(invite.rejected, false); From afd33f4b77354e4f261cf5b7fcde2b7ca814a365 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 12 May 2021 12:19:05 +0200 Subject: [PATCH 51/51] use constant for member event type --- src/matrix/room/ArchivedRoom.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/matrix/room/ArchivedRoom.js b/src/matrix/room/ArchivedRoom.js index fa95270b..553f5a60 100644 --- a/src/matrix/room/ArchivedRoom.js +++ b/src/matrix/room/ArchivedRoom.js @@ -16,7 +16,7 @@ limitations under the License. import {reduceStateEvents} from "./RoomSummary.js"; import {BaseRoom} from "./BaseRoom.js"; -import {RoomMember} from "./members/RoomMember.js"; +import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "./members/RoomMember.js"; export class ArchivedRoom extends BaseRoom { constructor(options) { @@ -133,7 +133,7 @@ export class ArchivedRoom extends BaseRoom { function findKickDetails(roomResponse, ownUserId) { const kickEvent = reduceStateEvents(roomResponse, (kickEvent, event) => { - if (event.type === "m.room.member") { + if (event.type === MEMBER_EVENT_TYPE) { // did we get kicked? if (event.state_key === ownUserId && event.sender !== event.state_key) { kickEvent = event;