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..9252ee81 --- /dev/null +++ b/src/domain/session/RoomViewModelObservable.js @@ -0,0 +1,78 @@ +/* +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 => { + // first dispose existing VM, if any + this.get()?.dispose(); + 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) { + return await this._sessionViewModel._createArchivedRoomViewModel(this.id); + } + 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..ae697c72 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,65 @@ 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); + 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; } - 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/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/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..7e8591af 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -22,15 +22,19 @@ 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; 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"); } @@ -55,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); @@ -77,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; @@ -86,13 +93,10 @@ 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"); + if (this._room.isArchived) { + this._composerVM.emitChange(); } + this.emitChange(); } get kind() { return "room"; } @@ -129,7 +133,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 ")) { @@ -310,6 +314,10 @@ class ComposerViewModel extends ViewModel { this.emitChange("canSend"); } } + + get kind() { + return "composer"; + } } function imageToInfo(image) { @@ -326,3 +334,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/matrix/Session.js b/src/matrix/Session.js index a9076169..3cf1df65 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -16,6 +16,8 @@ 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"; import { ObservableMap } from "../observable/index.js"; @@ -38,7 +40,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"; @@ -54,8 +56,8 @@ 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._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}); @@ -70,6 +72,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(); @@ -397,8 +400,21 @@ export class Session { } /** @internal */ - addRoomAfterSync(room) { - this._rooms.add(room.id, room); + _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() { @@ -410,7 +426,6 @@ export class Session { return new Invite({ roomId, hsApi: this._hsApi, - emitCollectionRemove: this._inviteRemoveCallback, emitCollectionUpdate: this._inviteUpdateCallback, mediaRepository: this._mediaRepository, user: this._user, @@ -418,11 +433,6 @@ export class Session { }); } - /** @internal */ - addInviteAfterSync(invite) { - this._invites.add(invite.id, invite); - } - async obtainSyncLock(syncResponse) { const toDeviceEvents = syncResponse.to_device?.events; if (Array.isArray(toDeviceEvents) && toDeviceEvents.length) { @@ -502,6 +512,49 @@ export class Session { } } + applyRoomCollectionChangesAfterSync(inviteStates, roomStates, archivedRoomStates) { + // update the collections after sync + 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; @@ -585,6 +638,76 @@ 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); + 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) { + return RoomStatus.invited; + } else if (isArchived) { + return RoomStatus.archived; + } else { + return RoomStatus.none; + } + } + } + + 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; + } + + /** + 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); + 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); + await room.load(summary, txn, log); + return room; + } + }); + } } export function tests() { diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index a99c0938..62bb67bd 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, log); 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 { @@ -223,7 +226,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,15 +257,22 @@ 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 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); + } + 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 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( @@ -271,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 @@ -285,30 +306,21 @@ 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", 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); - 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); - } } - // 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); - 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); - } + log.wrap("invite", log => is.invite.afterSync(is.changes, log), log.level.Detail); } + this._session.applyRoomCollectionChangesAfterSync(inviteStates, roomStates, archivedRoomStates); } _openSyncTxn() { @@ -316,6 +328,7 @@ export class Sync { return this._storage.readWriteTxn([ storeNames.session, storeNames.roomSummary, + storeNames.archivedRoomSummary, storeNames.invites, storeNames.roomState, storeNames.roomMembers, @@ -336,8 +349,9 @@ export class Sync { ]); } - _parseRoomsResponse(roomsSection, inviteStates, isInitialSync) { + async _parseRoomsResponse(roomsSection, inviteStates, isInitialSync, log) { const roomStates = []; + const archivedRoomStates = []; if (roomsSection) { const allMemberships = ["join", "leave"]; for(const membership of allMemberships) { @@ -349,28 +363,71 @@ 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, isInitialSync); + if (roomState) { + roomStates.push(roomState); + } + const ars = await this._createArchivedRoomSyncState(roomId, roomState, roomResponse, membership, isInitialSync, log); + if (ars) { + archivedRoomStates.push(ars); } } } } } - return roomStates; + return {roomStates, archivedRoomStates}; + } + + _createRoomSyncState(roomId, invite, roomResponse, membership, isInitialSync) { + let isNewRoom = false; + let room = this._session.rooms.get(roomId); + // 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; + } + if (room) { + return new RoomSyncProcessState( + room, isNewRoom, invite, roomResponse, membership); + } + } + + async _createArchivedRoomSyncState(roomId, roomState, roomResponse, membership, isInitialSync, log) { + let archivedRoom; + 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.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 = this._session.createOrGetArchivedRoomForSync(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, log); + } + } + if (archivedRoom) { + return new ArchivedRoomSyncProcessState( + archivedRoom, roomState, roomResponse, membership); + } } _parseInvites(roomsSection) { @@ -383,8 +440,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; @@ -425,15 +481,66 @@ class RoomSyncProcessState { this.preparation = null; this.changes = null; } + + get id() { + return this.room.id; + } + + get shouldAdd() { + return this.isNewRoom && this.membership === "join"; + } + + get shouldRemove() { + return !this.isNewRoom && this.membership !== "join"; + } + + get summaryChanges() { + return this.changes?.summaryChanges; + } +} + + +class ArchivedRoomSyncProcessState { + constructor(archivedRoom, roomState, roomResponse, membership, isInitialSync) { + this.archivedRoom = archivedRoom; + this.roomState = roomState; + this.roomResponse = roomResponse; + this.membership = membership; + this.isInitialSync = isInitialSync; + this.changes = null; + } + + get id() { + return this.archivedRoom.id; + } + + get shouldAdd() { + return (this.roomState || this.isInitialSync) && 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/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 721be2d0..aba7d07d 100644 --- a/src/matrix/e2ee/RoomEncryption.js +++ b/src/matrix/e2ee/RoomEncryption.js @@ -83,8 +83,9 @@ export class RoomEncryption { } async writeMemberChanges(memberChanges, txn, log) { - let shouldFlush; + 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/room/ArchivedRoom.js b/src/matrix/room/ArchivedRoom.js new file mode 100644 index 00000000..553f5a60 --- /dev/null +++ b/src/matrix/room/ArchivedRoom.js @@ -0,0 +1,193 @@ +/* +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, EVENT_TYPE as MEMBER_EVENT_TYPE} 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. + */ + this._kickDetails = null; + 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) { + 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._kickedBy = 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 kickedBy; + if (newKickDetails) { + kickedBy = await this._getKickAuthor(newKickDetails.sender, txn); + } + const summaryData = joinedSummaryData || this._summary.data; + txn.archivedRoomSummary.set({ + summary: summaryData.serialize(), + kickDetails, + }); + return {kickDetails, kickedBy, 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, kickedBy}, log) { + log.set("id", this.id); + if (summaryData) { + this._summary.applyChanges(summaryData); + } + if (kickDetails) { + this._kickDetails = kickDetails; + } + if (kickedBy) { + this._kickedBy = kickedBy; + } + this._emitUpdate(); + } + + 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() { + + } +} + +function findKickDetails(roomResponse, ownUserId) { + const kickEvent = reduceStateEvents(roomResponse, (kickEvent, event) => { + if (event.type === MEMBER_EVENT_TYPE) { + // 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/BaseRoom.js b/src/matrix/room/BaseRoom.js new file mode 100644 index 00000000..924e5316 --- /dev/null +++ b/src/matrix/room/BaseRoom.js @@ -0,0 +1,482 @@ +/* +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. + */ + // eslint-disable-next-line no-unused-vars + _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/Invite.js b/src/matrix/room/Invite.js index d34ffa8e..aa25b0c6 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; @@ -162,16 +163,11 @@ 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 { + // 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 } } } @@ -277,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); @@ -298,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); @@ -329,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); + invite.afterSync(changes, new NullLogItem()); const joinChanges = await invite.writeSync("join", null, txn, new NullLogItem()); - assert(!removedEmitted); - invite.afterSync(joinChanges); + assert.strictEqual(changeEmitCount, 0); + invite.afterSync(joinChanges, new NullLogItem()); + assert.strictEqual(changeEmitCount, 1); assert.equal(txn.invitesMap.get(roomId), undefined); assert.equal(invite.rejected, false); assert.equal(invite.accepted, true); - assert(removedEmitted); } } } diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 6038ae19..ff13c0f4 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -14,180 +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; - this._invite = 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) { @@ -249,7 +99,13 @@ 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.isNewJoin(this._summary.data); + if (isRejoin) { + // remove all room state before calling syncWriter, + // so no old state sticks around + txn.roomState.removeAllForRoom(this.id); + txn.roomMembers.removeAllForRoom(this.id); + } 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 +132,14 @@ 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); + + // if we've have left the room, remove the summary + if (summaryChanges.membership !== "join") { + txn.roomSummary.remove(this.id); + } 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)); } @@ -345,10 +207,6 @@ 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") { - this._invite = null; - } this._summary.applyChanges(summaryChanges); if (!this._summary.data.needsHeroes) { this._heroes = null; @@ -413,31 +271,21 @@ export class Room extends EventEmitter { /** @package */ async load(summary, txn, log) { - log.set("id", this.id); try { - 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); - } - 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); } } - /** @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(); + _writeGapFill(gapChunk, txn, log) { + const removedPendingEvents = this._sendQueue.removeRemoteEchos(gapChunk, txn, log); + return removedPendingEvents; + } + + _applyGapFill(removedPendingEvents) { + this._sendQueue.emitRemovals(removedPendingEvents); } /** @public */ @@ -459,124 +307,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; } @@ -589,40 +319,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; - } - - /** - * 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? - if (this._timeline) { - this._platform.logger.run("enableSessionBackup", log => { - return this._roomEncryption.restoreMissingSessionsFromBackup(this._timeline.remoteEntries, log); - }); - } - } - get isTrackingMembers() { return this._summary.data.isTrackingMembers; } @@ -638,17 +334,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 => { @@ -682,37 +367,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._summary.data.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 */ @@ -725,75 +382,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/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/room/RoomSummary.js b/src/matrix/room/RoomSummary.js index 88b2c45b..d0c78659 100644 --- a/src/matrix/room/RoomSummary.js +++ b/src/matrix/room/RoomSummary.js @@ -27,6 +27,24 @@ 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) { if (roomResponse.summary) { @@ -39,40 +57,32 @@ function applySyncResponse(data, roomResponse, membership) { 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(processStateEvent, 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") { - return processStateEvent(data, event); - } - return data; - }, data); - } + data = reduceStateEvents(roomResponse, processStateEvent, data); 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; @@ -152,10 +162,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; } @@ -204,8 +215,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) { @@ -223,6 +238,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 { @@ -265,6 +284,14 @@ export class RoomSummary { } } + /** move summary to archived store when leaving the room */ + writeArchivedData(data, txn) { + if (data !== this._data) { + txn.archivedRoomSummary.set(data.serialize()); + return data; + } + } + async writeAndApplyData(data, storage) { if (data === this._data) { return false; @@ -297,15 +324,15 @@ export class RoomSummary { export function tests() { return { - "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"); + "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); } } } 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/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); } diff --git a/src/matrix/room/timeline/persistence/SyncWriter.js b/src/matrix/room/timeline/persistence/SyncWriter.js index dc2344e5..6a7a920d 100644 --- a/src/matrix/room/timeline/persistence/SyncWriter.js +++ b/src/matrix/room/timeline/persistence/SyncWriter.js @@ -200,13 +200,17 @@ 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), + }); } } } + if (!timeline.limited) { + log.set("force_limited_without_overlap", true); + return Object.assign({}, timeline, {limited: true}); + } return timeline; } 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/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); } diff --git a/src/matrix/storage/idb/schema.js b/src/matrix/storage/idb/schema.js index 7cf100aa..aeb85a4b 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: "summary.roomId"}); +} \ No newline at end of file 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/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); + } } diff --git a/src/matrix/storage/idb/stores/RoomStateStore.js b/src/matrix/storage/idb/stores/RoomStateStore.js index 0cec87bc..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,17 +15,19 @@ See the License for the specific language governing permissions and limitations under the License. */ +import {MAX_UNICODE} from "./common.js"; + 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 +35,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); + } } diff --git a/src/matrix/storage/idb/stores/RoomSummaryStore.js b/src/matrix/storage/idb/stores/RoomSummaryStore.js index 1264657e..426a01bb 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,17 @@ export class RoomSummaryStore { set(summary) { 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; + } + + remove(roomId) { + return this._summaryStore.delete(roomId); + } } 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}"; diff --git a/src/matrix/storage/idb/utils.js b/src/matrix/storage/idb/utils.js index 9fa8c55a..2e6c2152 100644 --- a/src/matrix/storage/idb/utils.js +++ b/src/matrix/storage/idb/utils.js @@ -81,21 +81,37 @@ 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(); }); }); } 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(); }); }); 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; } 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 => { 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), ]) ]); } 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(); + } + } +}