Merge pull request #342 from vector-im/bwindels/archive-room-on-leave

Archive room on leave
This commit is contained in:
Bruno Windels 2021-05-12 10:34:38 +00:00 committed by GitHub
commit d7e8529a6e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 1605 additions and 748 deletions

View File

@ -15,7 +15,6 @@ limitations under the License.
*/ */
import {ViewModel} from "../ViewModel.js"; import {ViewModel} from "../ViewModel.js";
import {removeRoomFromPath} from "../navigation/index.js";
function dedupeSparse(roomIds) { function dedupeSparse(roomIds) {
return roomIds.map((id, idx) => { return roomIds.map((id, idx) => {
@ -33,10 +32,9 @@ export class RoomGridViewModel extends ViewModel {
this._width = options.width; this._width = options.width;
this._height = options.height; this._height = options.height;
this._createRoomViewModel = options.createRoomViewModel; this._createRoomViewModelObservable = options.createRoomViewModelObservable;
this._selectedIndex = 0; this._selectedIndex = 0;
this._viewModels = []; this._viewModelsObservables = [];
this._refreshRoomViewModel = this._refreshRoomViewModel.bind(this);
this._setupNavigation(); this._setupNavigation();
} }
@ -55,38 +53,17 @@ export class RoomGridViewModel extends ViewModel {
this.track(focusedRoom.subscribe(roomId => { this.track(focusedRoom.subscribe(roomId => {
if (roomId) { if (roomId) {
// as the room will be in the "rooms" observable // 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 // 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); this._setFocusRoom(roomId);
} }
})); }));
// initial focus for a room is set by initializeRoomIdsAndTransferVM // 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) { roomViewModelAt(i) {
return this._viewModels[i]; return this._viewModelsObservables[i]?.get();
} }
get focusIndex() { get focusIndex() {
@ -105,9 +82,9 @@ export class RoomGridViewModel extends ViewModel {
if (index === this._selectedIndex) { if (index === this._selectedIndex) {
return; return;
} }
const vm = this._viewModels[index]; const vmo = this._viewModelsObservables[index];
if (vm) { if (vmo) {
this.navigation.push("room", vm.id); this.navigation.push("room", vmo.id);
} else { } else {
this.navigation.push("empty-grid-tile", index); this.navigation.push("empty-grid-tile", index);
} }
@ -120,7 +97,8 @@ export class RoomGridViewModel extends ViewModel {
if (existingRoomVM) { if (existingRoomVM) {
const index = roomIds.indexOf(existingRoomVM.id); const index = roomIds.indexOf(existingRoomVM.id);
if (index !== -1) { if (index !== -1) {
this._viewModels[index] = this.track(existingRoomVM); this._viewModelsObservables[index] = this.track(existingRoomVM);
existingRoomVM.subscribe(viewModel => this._refreshRoomViewModel(viewModel));
transfered = true; transfered = true;
} }
} }
@ -128,7 +106,7 @@ export class RoomGridViewModel extends ViewModel {
// now all view models exist, set the focus to the selected room // now all view models exist, set the focus to the selected room
const focusedRoom = this.navigation.path.get("room"); const focusedRoom = this.navigation.path.get("room");
if (focusedRoom) { 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) { if (index !== -1) {
this._selectedIndex = index; this._selectedIndex = index;
} }
@ -143,17 +121,17 @@ export class RoomGridViewModel extends ViewModel {
const len = this._height * this._width; const len = this._height * this._width;
for (let i = 0; i < len; i += 1) { for (let i = 0; i < len; i += 1) {
const newId = roomIds[i]; const newId = roomIds[i];
const vm = this._viewModels[i]; const vmo = this._viewModelsObservables[i];
// did anything change? // did anything change?
if ((!vm && newId) || (vm && vm.id !== newId)) { if ((!vmo && newId) || (vmo && vmo.id !== newId)) {
if (vm) { if (vmo) {
this._viewModels[i] = this.disposeTracked(vm); this._viewModelsObservables[i] = this.disposeTracked(vmo);
} }
if (newId) { if (newId) {
const newVM = this._createRoomViewModel(newId, this._refreshRoomViewModel); const vmo = this._createRoomViewModelObservable(newId);
if (newVM) { this._viewModelsObservables[i] = this.track(vmo);
this._viewModels[i] = this.track(newVM); vmo.subscribe(viewModel => this._refreshRoomViewModel(viewModel));
} vmo.initialize();
} }
changed = true; changed = true;
} }
@ -164,14 +142,20 @@ export class RoomGridViewModel extends ViewModel {
return changed; return changed;
} }
_refreshRoomViewModel(viewModel) {
this.emitChange();
viewModel?.focus();
}
/** called from SessionViewModel */ /** called from SessionViewModel */
releaseRoomViewModel(roomId) { 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) { if (index !== -1) {
const vm = this._viewModels[index]; const vmo = this._viewModelsObservables[index];
this.untrack(vm); this.untrack(vmo);
this._viewModels[index] = null; vmo.unsubscribeAll();
return vm; this._viewModelsObservables[index] = null;
return vmo;
} }
} }
@ -180,13 +164,13 @@ export class RoomGridViewModel extends ViewModel {
return; return;
} }
this._selectedIndex = idx; this._selectedIndex = idx;
const vm = this._viewModels[this._selectedIndex]; const vmo = this._viewModelsObservables[this._selectedIndex];
vm?.focus(); vmo?.get()?.focus();
this.emitChange("focusIndex"); this.emitChange("focusIndex");
} }
_setFocusRoom(roomId) { _setFocusRoom(roomId) {
const index = this._viewModels.findIndex(vm => vm?.id === roomId); const index = this._viewModelsObservables.findIndex(vmo => vmo?.id === roomId);
if (index >= 0) { if (index >= 0) {
this._setFocusIndex(index); this._setFocusIndex(index);
} }
@ -194,6 +178,8 @@ export class RoomGridViewModel extends ViewModel {
} }
import {createNavigation} from "../navigation/index.js"; import {createNavigation} from "../navigation/index.js";
import {ObservableValue} from "../../observable/ObservableValue.js";
export function tests() { export function tests() {
class RoomVMMock { class RoomVMMock {
constructor(id) { 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) { function createNavigationForRoom(rooms, room) {
const navigation = createNavigation(); const navigation = createNavigation();
navigation.applyPath(navigation.pathFrom([ navigation.applyPath(navigation.pathFrom([
@ -233,7 +225,7 @@ export function tests() {
"initialize with duplicate set of rooms": assert => { "initialize with duplicate set of rooms": assert => {
const navigation = createNavigationForRoom(["c", "a", "b", undefined, "a"], "a"); const navigation = createNavigationForRoom(["c", "a", "b", undefined, "a"], "a");
const gridVM = new RoomGridViewModel({ const gridVM = new RoomGridViewModel({
createRoomViewModel: id => new RoomVMMock(id), createRoomViewModelObservable: id => new RoomViewModelObservableMock(new RoomVMMock(id)),
navigation, navigation,
width: 3, width: 3,
height: 2, height: 2,
@ -250,12 +242,12 @@ export function tests() {
"transfer room view model": assert => { "transfer room view model": assert => {
const navigation = createNavigationForRoom(["a"], "a"); const navigation = createNavigationForRoom(["a"], "a");
const gridVM = new RoomGridViewModel({ const gridVM = new RoomGridViewModel({
createRoomViewModel: () => assert.fail("no vms should be created"), createRoomViewModelObservable: () => assert.fail("no vms should be created"),
navigation, navigation,
width: 3, width: 3,
height: 2, height: 2,
}); });
const existingRoomVM = new RoomVMMock("a"); const existingRoomVM = new RoomViewModelObservableMock(new RoomVMMock("a"));
const transfered = gridVM.initializeRoomIdsAndTransferVM(navigation.path.get("rooms").value, existingRoomVM); const transfered = gridVM.initializeRoomIdsAndTransferVM(navigation.path.get("rooms").value, existingRoomVM);
assert.equal(transfered, true); assert.equal(transfered, true);
assert.equal(gridVM.focusIndex, 0); assert.equal(gridVM.focusIndex, 0);
@ -264,12 +256,12 @@ export function tests() {
"reject transfer for non-matching room view model": assert => { "reject transfer for non-matching room view model": assert => {
const navigation = createNavigationForRoom(["a"], "a"); const navigation = createNavigationForRoom(["a"], "a");
const gridVM = new RoomGridViewModel({ const gridVM = new RoomGridViewModel({
createRoomViewModel: id => new RoomVMMock(id), createRoomViewModelObservable: id => new RoomViewModelObservableMock(new RoomVMMock(id)),
navigation, navigation,
width: 3, width: 3,
height: 2, height: 2,
}); });
const existingRoomVM = new RoomVMMock("f"); const existingRoomVM = new RoomViewModelObservableMock(new RoomVMMock("f"));
const transfered = gridVM.initializeRoomIdsAndTransferVM(navigation.path.get("rooms").value, existingRoomVM); const transfered = gridVM.initializeRoomIdsAndTransferVM(navigation.path.get("rooms").value, existingRoomVM);
assert.equal(transfered, false); assert.equal(transfered, false);
assert.equal(gridVM.focusIndex, 0); assert.equal(gridVM.focusIndex, 0);
@ -278,7 +270,7 @@ export function tests() {
"created & released room view model is not disposed": assert => { "created & released room view model is not disposed": assert => {
const navigation = createNavigationForRoom(["a"], "a"); const navigation = createNavigationForRoom(["a"], "a");
const gridVM = new RoomGridViewModel({ const gridVM = new RoomGridViewModel({
createRoomViewModel: id => new RoomVMMock(id), createRoomViewModelObservable: id => new RoomViewModelObservableMock(new RoomVMMock(id)),
navigation, navigation,
width: 3, width: 3,
height: 2, height: 2,
@ -287,27 +279,27 @@ export function tests() {
assert.equal(transfered, false); assert.equal(transfered, false);
const releasedVM = gridVM.releaseRoomViewModel("a"); const releasedVM = gridVM.releaseRoomViewModel("a");
gridVM.dispose(); gridVM.dispose();
assert.equal(releasedVM.disposed, false); assert.equal(releasedVM.get().disposed, false);
}, },
"transfered & released room view model is not disposed": assert => { "transfered & released room view model is not disposed": assert => {
const navigation = createNavigationForRoom([undefined, "a"], "a"); const navigation = createNavigationForRoom([undefined, "a"], "a");
const gridVM = new RoomGridViewModel({ const gridVM = new RoomGridViewModel({
createRoomViewModel: () => assert.fail("no vms should be created"), createRoomViewModelObservable: () => assert.fail("no vms should be created"),
navigation, navigation,
width: 3, width: 3,
height: 2, height: 2,
}); });
const existingRoomVM = new RoomVMMock("a"); const existingRoomVM = new RoomViewModelObservableMock(new RoomVMMock("a"));
const transfered = gridVM.initializeRoomIdsAndTransferVM(navigation.path.get("rooms").value, existingRoomVM); const transfered = gridVM.initializeRoomIdsAndTransferVM(navigation.path.get("rooms").value, existingRoomVM);
assert.equal(transfered, true); assert.equal(transfered, true);
const releasedVM = gridVM.releaseRoomViewModel("a"); const releasedVM = gridVM.releaseRoomViewModel("a");
gridVM.dispose(); gridVM.dispose();
assert.equal(releasedVM.disposed, false); assert.equal(releasedVM.get().disposed, false);
}, },
"try release non-existing room view model is": assert => { "try release non-existing room view model is": assert => {
const navigation = createNavigationForEmptyTile([undefined, "b"], 3); const navigation = createNavigationForEmptyTile([undefined, "b"], 3);
const gridVM = new RoomGridViewModel({ const gridVM = new RoomGridViewModel({
createRoomViewModel: id => new RoomVMMock(id), createRoomViewModelObservable: id => new RoomViewModelObservableMock(new RoomVMMock(id)),
navigation, navigation,
width: 3, width: 3,
height: 2, height: 2,
@ -319,7 +311,7 @@ export function tests() {
"initial focus is set to empty tile": assert => { "initial focus is set to empty tile": assert => {
const navigation = createNavigationForEmptyTile(["a"], 1); const navigation = createNavigationForEmptyTile(["a"], 1);
const gridVM = new RoomGridViewModel({ const gridVM = new RoomGridViewModel({
createRoomViewModel: id => new RoomVMMock(id), createRoomViewModelObservable: id => new RoomViewModelObservableMock(new RoomVMMock(id)),
navigation, navigation,
width: 3, width: 3,
height: 2, height: 2,
@ -331,7 +323,7 @@ export function tests() {
"change room ids after creation": assert => { "change room ids after creation": assert => {
const navigation = createNavigationForRoom(["a", "b"], "a"); const navigation = createNavigationForRoom(["a", "b"], "a");
const gridVM = new RoomGridViewModel({ const gridVM = new RoomGridViewModel({
createRoomViewModel: id => new RoomVMMock(id), createRoomViewModelObservable: id => new RoomViewModelObservableMock(new RoomVMMock(id)),
navigation, navigation,
width: 3, width: 3,
height: 2, height: 2,

View File

@ -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();
}
}

View File

@ -15,7 +15,6 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {removeRoomFromPath} from "../navigation/index.js";
import {LeftPanelViewModel} from "./leftpanel/LeftPanelViewModel.js"; import {LeftPanelViewModel} from "./leftpanel/LeftPanelViewModel.js";
import {RoomViewModel} from "./room/RoomViewModel.js"; import {RoomViewModel} from "./room/RoomViewModel.js";
import {InviteViewModel} from "./room/InviteViewModel.js"; import {InviteViewModel} from "./room/InviteViewModel.js";
@ -24,6 +23,7 @@ import {SessionStatusViewModel} from "./SessionStatusViewModel.js";
import {RoomGridViewModel} from "./RoomGridViewModel.js"; import {RoomGridViewModel} from "./RoomGridViewModel.js";
import {SettingsViewModel} from "./settings/SettingsViewModel.js"; import {SettingsViewModel} from "./settings/SettingsViewModel.js";
import {ViewModel} from "../ViewModel.js"; import {ViewModel} from "../ViewModel.js";
import {RoomViewModelObservable} from "./RoomViewModelObservable.js";
export class SessionViewModel extends ViewModel { export class SessionViewModel extends ViewModel {
constructor(options) { constructor(options) {
@ -40,10 +40,8 @@ export class SessionViewModel extends ViewModel {
rooms: this._sessionContainer.session.rooms rooms: this._sessionContainer.session.rooms
}))); })));
this._settingsViewModel = null; this._settingsViewModel = null;
this._currentRoomViewModel = null; this._roomViewModelObservable = null;
this._gridViewModel = null; this._gridViewModel = null;
this._refreshRoomViewModel = this._refreshRoomViewModel.bind(this);
this._createRoomViewModel = this._createRoomViewModel.bind(this);
this._setupNavigation(); this._setupNavigation();
} }
@ -90,7 +88,7 @@ export class SessionViewModel extends ViewModel {
} }
get activeMiddleViewModel() { get activeMiddleViewModel() {
return this._currentRoomViewModel || this._gridViewModel || this._settingsViewModel; return this._roomViewModelObservable?.get() || this._gridViewModel || this._settingsViewModel;
} }
get roomGridViewModel() { get roomGridViewModel() {
@ -110,7 +108,7 @@ export class SessionViewModel extends ViewModel {
} }
get currentRoomViewModel() { get currentRoomViewModel() {
return this._currentRoomViewModel; return this._roomViewModelObservable?.get();
} }
_updateGrid(roomIds) { _updateGrid(roomIds) {
@ -121,12 +119,14 @@ export class SessionViewModel extends ViewModel {
this._gridViewModel = this.track(new RoomGridViewModel(this.childOptions({ this._gridViewModel = this.track(new RoomGridViewModel(this.childOptions({
width: 3, width: 3,
height: 2, height: 2,
createRoomViewModel: this._createRoomViewModel, createRoomViewModelObservable: roomId => new RoomViewModelObservable(this, roomId),
}))); })));
if (this._gridViewModel.initializeRoomIdsAndTransferVM(roomIds, this._currentRoomViewModel)) { // try to transfer the current room view model, so we don't have to reload the timeline
this._currentRoomViewModel = this.untrack(this._currentRoomViewModel); this._roomViewModelObservable?.unsubscribeAll();
} else if (this._currentRoomViewModel) { if (this._gridViewModel.initializeRoomIdsAndTransferVM(roomIds, this._roomViewModelObservable)) {
this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); this._roomViewModelObservable = this.untrack(this._roomViewModelObservable);
} else if (this._roomViewModelObservable) {
this._roomViewModelObservable = this.disposeTracked(this._roomViewModelObservable);
} }
} else { } else {
this._gridViewModel.setRoomIds(roomIds); this._gridViewModel.setRoomIds(roomIds);
@ -134,14 +134,12 @@ export class SessionViewModel extends ViewModel {
} else if (this._gridViewModel && !roomIds) { } else if (this._gridViewModel && !roomIds) {
// closing grid, try to show focused room in grid // closing grid, try to show focused room in grid
if (currentRoomId) { if (currentRoomId) {
const vm = this._gridViewModel.releaseRoomViewModel(currentRoomId.value); const vmo = this._gridViewModel.releaseRoomViewModel(currentRoomId.value);
if (vm) { if (vmo) {
this._currentRoomViewModel = this.track(vm); this._roomViewModelObservable = this.track(vmo);
} else { this._roomViewModelObservable.subscribe(() => {
const newVM = this._createRoomViewModel(currentRoomId.value, this._refreshRoomViewModel); this.emitChange("activeMiddleViewModel");
if (newVM) { });
this._currentRoomViewModel = this.track(newVM);
}
} }
} }
this._gridViewModel = this.disposeTracked(this._gridViewModel); this._gridViewModel = this.disposeTracked(this._gridViewModel);
@ -151,63 +149,65 @@ export class SessionViewModel extends ViewModel {
} }
} }
/** _createRoomViewModel(roomId) {
* @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) {
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); const room = this._sessionContainer.session.rooms.get(roomId);
if (room) { if (room) {
const roomVM = new RoomViewModel(this.childOptions({ const roomVM = new RoomViewModel(this.childOptions({
room, room,
ownUserId: this._sessionContainer.session.user.id, ownUserId: this._sessionContainer.session.user.id,
refreshRoomViewModel
})); }));
roomVM.load(); roomVM.load();
return roomVM; 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; return null;
} }
/** refresh the room view model after an internal change that needs _createInviteViewModel(roomId) {
to change between invite, room or none state */ const invite = this._sessionContainer.session.invites.get(roomId);
_refreshRoomViewModel(roomId) { if (invite) {
this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); return new InviteViewModel(this.childOptions({
const roomVM = this._createRoomViewModel(roomId, this._refreshRoomViewModel); invite,
if (roomVM) { mediaRepository: this._sessionContainer.session.mediaRepository,
this._currentRoomViewModel = this.track(roomVM); }));
} else {
// close room id
this.navigation.applyPath(removeRoomFromPath(this.navigation.path, roomId));
} }
this.emitChange("activeMiddleViewModel"); return null;
} }
_updateRoom(roomId) { _updateRoom(roomId) {
// opening a room and already open? // opening a room and already open?
if (this._currentRoomViewModel?.id === roomId) { if (this._roomViewModelObservable?.id === roomId) {
return; return;
} }
// close if needed // close if needed
if (this._currentRoomViewModel) { if (this._roomViewModelObservable) {
this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); 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"); this.emitChange("activeMiddleViewModel");
return;
}
const vmo = new RoomViewModelObservable(this, roomId);
this._roomViewModelObservable = this.track(vmo);
// subscription is unsubscribed in RoomViewModelObservable.dispose, and thus handled by track
this._roomViewModelObservable.subscribe(() => {
this.emitChange("activeMiddleViewModel");
});
vmo.initialize();
} }
_updateSettings(settingsOpen) { _updateSettings(settingsOpen) {

View File

@ -35,9 +35,8 @@ export class LeftPanelViewModel extends ViewModel {
} }
_mapTileViewModels(rooms, invites) { _mapTileViewModels(rooms, invites) {
const joinedRooms = rooms.filterValues(room => room.membership === "join");
// join is not commutative, invites will take precedence over rooms // 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; const isOpen = this.navigation.path.get("room")?.value === roomOrInvite.id;
let vm; let vm;
if (roomOrInvite.isInvite) { if (roomOrInvite.isInvite) {

View File

@ -21,10 +21,9 @@ import {ViewModel} from "../../ViewModel.js";
export class InviteViewModel extends ViewModel { export class InviteViewModel extends ViewModel {
constructor(options) { constructor(options) {
super(options); super(options);
const {invite, mediaRepository, refreshRoomViewModel} = options; const {invite, mediaRepository} = options;
this._invite = invite; this._invite = invite;
this._mediaRepository = mediaRepository; this._mediaRepository = mediaRepository;
this._refreshRoomViewModel = refreshRoomViewModel;
this._onInviteChange = this._onInviteChange.bind(this); this._onInviteChange = this._onInviteChange.bind(this);
this._error = null; this._error = null;
this._closeUrl = this.urlCreator.urlUntilSegment("session"); this._closeUrl = this.urlCreator.urlUntilSegment("session");
@ -107,18 +106,8 @@ export class InviteViewModel extends ViewModel {
} }
_onInviteChange() { _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() { dispose() {
super.dispose(); super.dispose();

View File

@ -22,15 +22,19 @@ import {ViewModel} from "../../ViewModel.js";
export class RoomViewModel extends ViewModel { export class RoomViewModel extends ViewModel {
constructor(options) { constructor(options) {
super(options); super(options);
const {room, ownUserId, refreshRoomViewModel} = options; const {room, ownUserId} = options;
this._room = room; this._room = room;
this._ownUserId = ownUserId; this._ownUserId = ownUserId;
this._refreshRoomViewModel = refreshRoomViewModel;
this._timelineVM = null; this._timelineVM = null;
this._onRoomChange = this._onRoomChange.bind(this); this._onRoomChange = this._onRoomChange.bind(this);
this._timelineError = null; this._timelineError = null;
this._sendError = null; this._sendError = null;
this._composerVM = null;
if (room.isArchived) {
this._composerVM = new ArchivedViewModel(this.childOptions({archivedRoom: room}));
} else {
this._composerVM = new ComposerViewModel(this); this._composerVM = new ComposerViewModel(this);
}
this._clearUnreadTimout = null; this._clearUnreadTimout = null;
this._closeUrl = this.urlCreator.urlUntilSegment("session"); this._closeUrl = this.urlCreator.urlUntilSegment("session");
} }
@ -55,7 +59,7 @@ export class RoomViewModel extends ViewModel {
} }
async _clearUnreadAfterDelay() { async _clearUnreadAfterDelay() {
if (this._clearUnreadTimout) { if (this._room.isArchived || this._clearUnreadTimout) {
return; return;
} }
this._clearUnreadTimout = this.clock.createTimeout(2000); this._clearUnreadTimout = this.clock.createTimeout(2000);
@ -77,6 +81,9 @@ export class RoomViewModel extends ViewModel {
dispose() { dispose() {
super.dispose(); super.dispose();
this._room.off("change", this._onRoomChange); this._room.off("change", this._onRoomChange);
if (this._room.isArchived) {
this._room.release();
}
if (this._clearUnreadTimout) { if (this._clearUnreadTimout) {
this._clearUnreadTimout.abort(); this._clearUnreadTimout.abort();
this._clearUnreadTimout = null; this._clearUnreadTimout = null;
@ -86,13 +93,10 @@ export class RoomViewModel extends ViewModel {
// room doesn't tell us yet which fields changed, // room doesn't tell us yet which fields changed,
// so emit all fields originating from summary // so emit all fields originating from summary
_onRoomChange() { _onRoomChange() {
// if there is now an invite on this (left) room, if (this._room.isArchived) {
// show the invite view by refreshing the view model this._composerVM.emitChange();
if (this._room.invite) {
this._refreshRoomViewModel(this.id);
} else {
this.emitChange("name");
} }
this.emitChange();
} }
get kind() { return "room"; } get kind() { return "room"; }
@ -129,7 +133,7 @@ export class RoomViewModel extends ViewModel {
} }
async _sendMessage(message) { async _sendMessage(message) {
if (message) { if (!this._room.isArchived && message) {
try { try {
let msgtype = "m.text"; let msgtype = "m.text";
if (message.startsWith("/me ")) { if (message.startsWith("/me ")) {
@ -310,6 +314,10 @@ class ComposerViewModel extends ViewModel {
this.emitChange("canSend"); this.emitChange("canSend");
} }
} }
get kind() {
return "composer";
}
} }
function imageToInfo(image) { function imageToInfo(image) {
@ -326,3 +334,32 @@ function videoToInfo(video) {
info.duration = video.duration; info.duration = video.duration;
return info; 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";
}
}

View File

@ -16,6 +16,8 @@ limitations under the License.
*/ */
import {Room} from "./room/Room.js"; 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 {Invite} from "./room/Invite.js";
import {Pusher} from "./push/Pusher.js"; import {Pusher} from "./push/Pusher.js";
import { ObservableMap } from "../observable/index.js"; import { ObservableMap } from "../observable/index.js";
@ -38,7 +40,7 @@ import {
writeKey as ssssWriteKey, writeKey as ssssWriteKey,
} from "./ssss/index.js"; } from "./ssss/index.js";
import {SecretStorage} from "./ssss/SecretStorage.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 PICKLE_KEY = "DEFAULT_KEY";
const PUSHER_KEY = "pusher"; const PUSHER_KEY = "pusher";
@ -54,8 +56,8 @@ export class Session {
this._sessionInfo = sessionInfo; this._sessionInfo = sessionInfo;
this._rooms = new ObservableMap(); this._rooms = new ObservableMap();
this._roomUpdateCallback = (room, params) => this._rooms.update(room.id, params); this._roomUpdateCallback = (room, params) => this._rooms.update(room.id, params);
this._activeArchivedRooms = new Map();
this._invites = new ObservableMap(); this._invites = new ObservableMap();
this._inviteRemoveCallback = invite => this._invites.remove(invite.id);
this._inviteUpdateCallback = (invite, params) => this._invites.update(invite.id, params); this._inviteUpdateCallback = (invite, params) => this._invites.update(invite.id, params);
this._user = new User(sessionInfo.userId); this._user = new User(sessionInfo.userId);
this._deviceMessageHandler = new DeviceMessageHandler({storage}); this._deviceMessageHandler = new DeviceMessageHandler({storage});
@ -70,6 +72,7 @@ export class Session {
this._olmWorker = olmWorker; this._olmWorker = olmWorker;
this._sessionBackup = null; this._sessionBackup = null;
this._hasSecretStorageKey = new ObservableValue(null); this._hasSecretStorageKey = new ObservableValue(null);
this._observedRoomStatus = new Map();
if (olm) { if (olm) {
this._olmUtil = new olm.Utility(); this._olmUtil = new olm.Utility();
@ -397,8 +400,21 @@ export class Session {
} }
/** @internal */ /** @internal */
addRoomAfterSync(room) { _createArchivedRoom(roomId) {
this._rooms.add(room.id, room); 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() { get invites() {
@ -410,7 +426,6 @@ export class Session {
return new Invite({ return new Invite({
roomId, roomId,
hsApi: this._hsApi, hsApi: this._hsApi,
emitCollectionRemove: this._inviteRemoveCallback,
emitCollectionUpdate: this._inviteUpdateCallback, emitCollectionUpdate: this._inviteUpdateCallback,
mediaRepository: this._mediaRepository, mediaRepository: this._mediaRepository,
user: this._user, user: this._user,
@ -418,11 +433,6 @@ export class Session {
}); });
} }
/** @internal */
addInviteAfterSync(invite) {
this._invites.add(invite.id, invite);
}
async obtainSyncLock(syncResponse) { async obtainSyncLock(syncResponse) {
const toDeviceEvents = syncResponse.to_device?.events; const toDeviceEvents = syncResponse.to_device?.events;
if (Array.isArray(toDeviceEvents) && toDeviceEvents.length) { 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 */ /** @internal */
get syncToken() { get syncToken() {
return this._syncInfo?.token; return this._syncInfo?.token;
@ -585,6 +638,76 @@ export class Session {
const serverPushers = (serverPushersData?.pushers || []).map(data => new Pusher(data)); const serverPushers = (serverPushersData?.pushers || []).map(data => new Pusher(data));
return serverPushers.some(p => p.equals(myPusher)); 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() { export function tests() {

View File

@ -192,7 +192,8 @@ export class Sync {
const isInitialSync = !syncToken; const isInitialSync = !syncToken;
const sessionState = new SessionSyncProcessState(); const sessionState = new SessionSyncProcessState();
const inviteStates = this._parseInvites(response.rooms); 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 { try {
// take a lock on olm sessions used in this sync so sending a message doesn't change them while syncing // 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); return rs.room.afterPrepareSync(rs.preparation, log);
}))); })));
await log.wrap("write", async log => this._writeSync( await log.wrap("write", async log => this._writeSync(
sessionState, inviteStates, roomStates, response, syncFilterId, isInitialSync, log)); sessionState, inviteStates, roomStates, archivedRoomStates,
response, syncFilterId, isInitialSync, log));
} finally { } finally {
sessionState.dispose(); sessionState.dispose();
} }
// sync txn comitted, emit updates and apply changes to in-memory state // 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; const toDeviceEvents = response.to_device?.events;
return { return {
@ -223,7 +226,11 @@ export class Sync {
return this._storage.readTxn([ return this._storage.readTxn([
storeNames.olmSessions, storeNames.olmSessions,
storeNames.inboundGroupSessions, 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 => { await Promise.all(roomStates.map(async rs => {
const newKeys = newKeysByRoom?.get(rs.room.id); const newKeys = newKeysByRoom?.get(rs.room.id);
rs.preparation = await log.wrap("room", log => rs.room.prepareSync( rs.preparation = await log.wrap("room", async log => {
rs.roomResponse, rs.membership, rs.invite, newKeys, prepareTxn, log), log.level.Detail); // 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 // This is needed for safari to not throw TransactionInactiveErrors on the syncTxn. See docs/INDEXEDDB.md
await prepareTxn.complete(); 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(); const syncTxn = await this._openSyncTxn();
try { try {
sessionState.changes = await log.wrap("session", log => this._session.writeSync( 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.changes = await log.wrap("room", log => rs.room.writeSync(
rs.roomResponse, isInitialSync, rs.preparation, syncTxn, log)); 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) { } catch(err) {
// avoid corrupting state by only // avoid corrupting state by only
// storing the sync up till the point // storing the sync up till the point
@ -285,30 +306,21 @@ export class Sync {
await syncTxn.complete(); 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); 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) { for(let rs of roomStates) {
log.wrap("room", log => rs.room.afterSync(rs.changes, log), log.level.Detail); 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) { for(let is of inviteStates) {
log.wrap("invite", () => is.invite.afterSync(is.changes), log.level.Detail); log.wrap("invite", log => is.invite.afterSync(is.changes, log), log.level.Detail);
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);
}
} }
this._session.applyRoomCollectionChangesAfterSync(inviteStates, roomStates, archivedRoomStates);
} }
_openSyncTxn() { _openSyncTxn() {
@ -316,6 +328,7 @@ export class Sync {
return this._storage.readWriteTxn([ return this._storage.readWriteTxn([
storeNames.session, storeNames.session,
storeNames.roomSummary, storeNames.roomSummary,
storeNames.archivedRoomSummary,
storeNames.invites, storeNames.invites,
storeNames.roomState, storeNames.roomState,
storeNames.roomMembers, storeNames.roomMembers,
@ -336,8 +349,9 @@ export class Sync {
]); ]);
} }
_parseRoomsResponse(roomsSection, inviteStates, isInitialSync) { async _parseRoomsResponse(roomsSection, inviteStates, isInitialSync, log) {
const roomStates = []; const roomStates = [];
const archivedRoomStates = [];
if (roomsSection) { if (roomsSection) {
const allMemberships = ["join", "leave"]; const allMemberships = ["join", "leave"];
for(const membership of allMemberships) { for(const membership of allMemberships) {
@ -349,28 +363,71 @@ export class Sync {
if (isInitialSync && timelineIsEmpty(roomResponse)) { if (isInitialSync && timelineIsEmpty(roomResponse)) {
continue; 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); const invite = this._session.invites.get(roomId);
// if there is an existing invite, add a process state for it // if there is an existing invite, add a process state for it
// so its writeSync and afterSync will run and remove the invite // so its writeSync and afterSync will run and remove the invite
if (invite) { if (invite) {
inviteStates.push(new InviteSyncProcessState(invite, false, null, membership, null)); inviteStates.push(new InviteSyncProcessState(invite, false, null, 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, 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) { if (room) {
roomStates.push(new RoomSyncProcessState( return new RoomSyncProcessState(
room, isNewRoom, invite, roomResponse, membership)); 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);
} }
return roomStates;
} }
_parseInvites(roomsSection) { _parseInvites(roomsSection) {
@ -383,8 +440,7 @@ export class Sync {
invite = this._session.createInvite(roomId); invite = this._session.createInvite(roomId);
isNewInvite = true; isNewInvite = true;
} }
const room = this._session.rooms.get(roomId); inviteStates.push(new InviteSyncProcessState(invite, isNewInvite, roomResponse, "invite"));
inviteStates.push(new InviteSyncProcessState(invite, isNewInvite, room, "invite", roomResponse));
} }
} }
return inviteStates; return inviteStates;
@ -425,15 +481,66 @@ class RoomSyncProcessState {
this.preparation = null; this.preparation = null;
this.changes = 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 { class InviteSyncProcessState {
constructor(invite, isNewInvite, room, membership, roomResponse) { constructor(invite, isNewInvite, roomResponse, membership) {
this.invite = invite; this.invite = invite;
this.isNewInvite = isNewInvite; this.isNewInvite = isNewInvite;
this.room = room;
this.membership = membership; this.membership = membership;
this.roomResponse = roomResponse; this.roomResponse = roomResponse;
this.changes = null; this.changes = null;
} }
get id() {
return this.invite.id;
}
get shouldAdd() {
return this.isNewInvite;
}
get shouldRemove() {
return this.membership !== "invite";
}
} }

View File

@ -121,26 +121,40 @@ export class DeviceTracker {
} }
} }
async _applyMemberChange(memberChange, txn) { async _removeRoomFromUserIdentity(roomId, userId, txn) {
// TODO: depends whether we encrypt for invited users?? const {userIdentities, deviceIdentities} = txn;
// add room const identity = await userIdentities.get(userId);
if (memberChange.previousMembership !== "join" && memberChange.membership === "join") {
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) { if (identity) {
identity.roomIds = identity.roomIds.filter(roomId => roomId !== memberChange.roomId); identity.roomIds = identity.roomIds.filter(id => id !== roomId);
// no more encrypted rooms with this user, remove // no more encrypted rooms with this user, remove
if (identity.roomIds.length === 0) { if (identity.roomIds.length === 0) {
userIdentities.remove(identity.userId); userIdentities.remove(userId);
deviceIdentities.removeAllForUser(userId);
} else { } else {
userIdentities.set(identity); userIdentities.set(identity);
} }
} }
} }
async _applyMemberChange(memberChange, txn) {
// TODO: depends whether we encrypt for invited users??
// add room
if (memberChange.hasJoined) {
await this._writeMember(memberChange.member, txn);
}
// remove room
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);
}
}
} }
async _queryKeys(userIds, hsApi, log) { async _queryKeys(userIds, hsApi, log) {

View File

@ -83,8 +83,9 @@ export class RoomEncryption {
} }
async writeMemberChanges(memberChanges, txn, log) { async writeMemberChanges(memberChanges, txn, log) {
let shouldFlush; let shouldFlush = false;
const memberChangesArray = Array.from(memberChanges.values()); const memberChangesArray = Array.from(memberChanges.values());
// this also clears our session if we leave the room ourselves
if (memberChangesArray.some(m => m.hasLeft)) { if (memberChangesArray.some(m => m.hasLeft)) {
log.log({ log.log({
l: "discardOutboundSession", l: "discardOutboundSession",

View File

@ -0,0 +1,193 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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);
}
}
}

482
src/matrix/room/BaseRoom.js Normal file
View File

@ -0,0 +1,482 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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<string>} 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();
}
}
}

View File

@ -152,7 +152,8 @@ export class Invite extends EventEmitter {
} }
} }
afterSync(changes) { afterSync(changes, log) {
log.set("id", this.id);
if (changes) { if (changes) {
if (changes.removed) { if (changes.removed) {
this._accepting = false; this._accepting = false;
@ -162,16 +163,11 @@ export class Invite extends EventEmitter {
} else { } else {
this._rejected = true; 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"); this.emit("change");
} else { } else {
// no emit change, adding to the collection is done by sync
this._inviteData = changes.inviteData; this._inviteData = changes.inviteData;
this._inviter = changes.inviter; 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 txn = createStorage();
const changes = await invite.writeSync("invite", roomInviteFixture, txn, new NullLogItem()); const changes = await invite.writeSync("invite", roomInviteFixture, txn, new NullLogItem());
assert.equal(txn.invitesMap.get(roomId).roomId, roomId); 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.name, "Invite example");
assert.equal(invite.avatarUrl, roomAvatarUrl); assert.equal(invite.avatarUrl, roomAvatarUrl);
assert.equal(invite.isPublic, false); assert.equal(invite.isPublic, false);
@ -298,7 +294,7 @@ export function tests() {
const txn = createStorage(); const txn = createStorage();
const changes = await invite.writeSync("invite", dmInviteFixture, txn, new NullLogItem()); const changes = await invite.writeSync("invite", dmInviteFixture, txn, new NullLogItem());
assert.equal(txn.invitesMap.get(roomId).roomId, roomId); assert.equal(txn.invitesMap.get(roomId).roomId, roomId);
invite.afterSync(changes); invite.afterSync(changes, new NullLogItem());
assert.equal(invite.name, "Alice"); assert.equal(invite.name, "Alice");
assert.equal(invite.avatarUrl, aliceAvatarUrl); assert.equal(invite.avatarUrl, aliceAvatarUrl);
assert.equal(invite.timestamp, 1003); assert.equal(invite.timestamp, 1003);
@ -329,28 +325,25 @@ export function tests() {
assert.equal(invite.inviter.displayName, "Alice"); assert.equal(invite.inviter.displayName, "Alice");
assert.equal(invite.inviter.avatarUrl, aliceAvatarUrl); assert.equal(invite.inviter.avatarUrl, aliceAvatarUrl);
}, },
"syncing with membership from invite removes the invite": async assert => { "syncing join sets accepted": async assert => {
let removedEmitted = false; let changeEmitCount = 0;
const invite = new Invite({ const invite = new Invite({
roomId, roomId,
platform: {clock: new MockClock(1003)}, platform: {clock: new MockClock(1003)},
user: {id: "@bob:hs.tld"}, user: {id: "@bob:hs.tld"},
emitCollectionRemove: emittingInvite => {
assert.equal(emittingInvite, invite);
removedEmitted = true;
}
}); });
invite.on("change", () => { changeEmitCount += 1; });
const txn = createStorage(); const txn = createStorage();
const changes = await invite.writeSync("invite", dmInviteFixture, txn, new NullLogItem()); const changes = await invite.writeSync("invite", dmInviteFixture, txn, new NullLogItem());
assert.equal(txn.invitesMap.get(roomId).roomId, roomId); 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()); const joinChanges = await invite.writeSync("join", null, txn, new NullLogItem());
assert(!removedEmitted); assert.strictEqual(changeEmitCount, 0);
invite.afterSync(joinChanges); invite.afterSync(joinChanges, new NullLogItem());
assert.strictEqual(changeEmitCount, 1);
assert.equal(txn.invitesMap.get(roomId), undefined); assert.equal(txn.invitesMap.get(roomId), undefined);
assert.equal(invite.rejected, false); assert.equal(invite.rejected, false);
assert.equal(invite.accepted, true); assert.equal(invite.accepted, true);
assert(removedEmitted);
} }
} }
} }

View File

@ -14,180 +14,30 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {EventEmitter} from "../../utils/EventEmitter.js"; import {BaseRoom} from "./BaseRoom.js";
import {RoomSummary} from "./RoomSummary.js";
import {SyncWriter} from "./timeline/persistence/SyncWriter.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 {SendQueue} from "./sending/SendQueue.js";
import {WrappedError} from "../error.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 {Heroes} from "./members/Heroes.js";
import {EventEntry} from "./timeline/entries/EventEntry.js";
import {ObservedEventMap} from "./ObservedEventMap.js";
import {AttachmentUpload} from "./AttachmentUpload.js"; import {AttachmentUpload} from "./AttachmentUpload.js";
import {DecryptionSource} from "../e2ee/common.js"; import {DecryptionSource} from "../e2ee/common.js";
import {ensureLogItem} from "../../logging/utils.js";
const EVENT_ENCRYPTED_TYPE = "m.room.encrypted"; const EVENT_ENCRYPTED_TYPE = "m.room.encrypted";
export class Room extends EventEmitter { export class Room extends BaseRoom {
constructor({roomId, storage, hsApi, mediaRepository, emitCollectionChange, pendingEvents, user, createRoomEncryption, getSyncToken, platform}) { constructor(options) {
super(); super(options);
this._roomId = roomId; const {pendingEvents} = options;
this._storage = storage; this._syncWriter = new SyncWriter({roomId: this.id, fragmentIdComparer: this._fragmentIdComparer});
this._hsApi = hsApi; this._sendQueue = new SendQueue({roomId: this.id, storage: this._storage, hsApi: this._hsApi, pendingEvents});
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<string>} 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) { _setEncryption(roomEncryption) {
if (roomEncryption && !this._roomEncryption) { if (super._setEncryption(roomEncryption)) {
this._roomEncryption = roomEncryption;
this._sendQueue.enableEncryption(this._roomEncryption); this._sendQueue.enableEncryption(this._roomEncryption);
if (this._timeline) { return true;
this._timeline.enableEncryption(this._decryptEntries.bind(this, DecryptionSource.Timeline));
} }
} 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;
} }
async prepareSync(roomResponse, membership, invite, newKeys, txn, log) { async prepareSync(roomResponse, membership, invite, newKeys, txn, log) {
@ -249,7 +99,13 @@ export class Room extends EventEmitter {
/** @package */ /** @package */
async writeSync(roomResponse, isInitialSync, {summaryChanges, decryptChanges, roomEncryption, retryEntries}, txn, log) { async writeSync(roomResponse, isInitialSync, {summaryChanges, decryptChanges, roomEncryption, retryEntries}, txn, log) {
log.set("id", this.id); 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} = const {entries: newEntries, newLiveKey, memberChanges} =
await log.wrap("syncWriter", log => this._syncWriter.writeSync(roomResponse, isRejoin, txn, log), log.level.Detail); await log.wrap("syncWriter", log => this._syncWriter.writeSync(roomResponse, isRejoin, txn, log), log.level.Detail);
let allEntries = newEntries; let allEntries = newEntries;
@ -276,8 +132,14 @@ export class Room extends EventEmitter {
// also apply (decrypted) timeline entries to the summary changes // also apply (decrypted) timeline entries to the summary changes
summaryChanges = summaryChanges.applyTimelineEntries( summaryChanges = summaryChanges.applyTimelineEntries(
allEntries, isInitialSync, !this._isTimelineOpen, this._user.id); allEntries, isInitialSync, !this._isTimelineOpen, this._user.id);
// 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 // write summary changes, and unset if nothing was actually changed
summaryChanges = this._summary.writeData(summaryChanges, txn); summaryChanges = this._summary.writeData(summaryChanges, txn);
}
if (summaryChanges) { if (summaryChanges) {
log.set("summaryChanges", summaryChanges.diff(this._summary.data)); log.set("summaryChanges", summaryChanges.diff(this._summary.data));
} }
@ -345,10 +207,6 @@ export class Room extends EventEmitter {
} }
let emitChange = false; let emitChange = false;
if (summaryChanges) { 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); this._summary.applyChanges(summaryChanges);
if (!this._summary.data.needsHeroes) { if (!this._summary.data.needsHeroes) {
this._heroes = null; this._heroes = null;
@ -413,31 +271,21 @@ export class Room extends EventEmitter {
/** @package */ /** @package */
async load(summary, txn, log) { async load(summary, txn, log) {
log.set("id", this.id);
try { try {
this._summary.load(summary); super.load(summary, txn, log);
if (this._summary.data.encryption) { this._syncWriter.load(txn, log);
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);
} catch (err) { } catch (err) {
throw new WrappedError(`Could not load room ${this._roomId}`, err); throw new WrappedError(`Could not load room ${this._roomId}`, err);
} }
} }
/** @internal */ _writeGapFill(gapChunk, txn, log) {
setInvite(invite) { const removedPendingEvents = this._sendQueue.removeRemoteEchos(gapChunk, txn, log);
// called when an invite comes in for this room return removedPendingEvents;
// (e.g. when we're in membership leave and haven't been archived or forgotten yet) }
this._invite = invite;
this._emitUpdate(); _applyGapFill(removedPendingEvents) {
this._sendQueue.emitRemovals(removedPendingEvents);
} }
/** @public */ /** @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() { get isUnread() {
return this._summary.data.isUnread; return this._summary.data.isUnread;
} }
@ -589,40 +319,6 @@ export class Room extends EventEmitter {
return this._summary.data.highlightCount; 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() { get isTrackingMembers() {
return this._summary.data.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) { async clearUnread(log = null) {
if (this.isUnread || this.notificationCount) { if (this.isUnread || this.notificationCount) {
return await this._platform.logger.wrapOrRun(log, "clearUnread", async log => { return await this._platform.logger.wrapOrRun(log, "clearUnread", async log => {
@ -682,37 +367,9 @@ export class Room extends EventEmitter {
} }
} }
/** @public */ /* called by BaseRoom to pass pendingEvents when opening the timeline */
openTimeline(log = null) { _getPendingEvents() {
return this._platform.logger.wrapOrRun(log, "open timeline", async log => { return this._sendQueue.pendingEvents;
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;
} }
/** @package */ /** @package */
@ -725,75 +382,12 @@ export class Room extends EventEmitter {
this._summary.applyChanges(changes); 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) { createAttachment(blob, filename) {
return new AttachmentUpload({blob, filename, platform: this._platform}); return new AttachmentUpload({blob, filename, platform: this._platform});
} }
dispose() { dispose() {
this._roomEncryption?.dispose(); super.dispose();
this._timeline?.dispose();
this._sendQueue.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();
}
}
}

View File

@ -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);

View File

@ -27,6 +27,24 @@ function applyTimelineEntries(data, timelineEntries, isInitialSync, canMarkUnrea
return data; 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) { function applySyncResponse(data, roomResponse, membership) {
if (roomResponse.summary) { if (roomResponse.summary) {
@ -39,25 +57,19 @@ function applySyncResponse(data, roomResponse, membership) {
if (roomResponse.account_data) { if (roomResponse.account_data) {
data = roomResponse.account_data.events.reduce(processRoomAccountData, data); data = roomResponse.account_data.events.reduce(processRoomAccountData, data);
} }
const stateEvents = roomResponse?.state?.events; // process state events in state and in timeline.
// state comes before timeline
if (Array.isArray(stateEvents)) {
data = stateEvents.reduce(processStateEvent, data);
}
const timelineEvents = roomResponse?.timeline?.events;
// process state events in timeline
// non-state events are handled by applyTimelineEntries // non-state events are handled by applyTimelineEntries
// so decryption is handled properly // so decryption is handled properly
if (Array.isArray(timelineEvents)) { data = reduceStateEvents(roomResponse, processStateEvent, data);
data = timelineEvents.reduce((data, event) => {
if (typeof event.state_key === "string") {
return processStateEvent(data, event);
}
return data;
}, data);
}
const unreadNotifications = roomResponse.unread_notifications; const unreadNotifications = roomResponse.unread_notifications;
if (unreadNotifications) { if (unreadNotifications) {
data = processNotificationCounts(data, unreadNotifications);
}
return data;
}
function processNotificationCounts(data, unreadNotifications) {
const highlightCount = unreadNotifications.highlight_count || 0; const highlightCount = unreadNotifications.highlight_count || 0;
if (highlightCount !== data.highlightCount) { if (highlightCount !== data.highlightCount) {
data = data.cloneIfNeeded(); data = data.cloneIfNeeded();
@ -68,8 +80,6 @@ function applySyncResponse(data, roomResponse, membership) {
data = data.cloneIfNeeded(); data = data.cloneIfNeeded();
data.notificationCount = notificationCount; data.notificationCount = notificationCount;
} }
}
return data; return data;
} }
@ -152,10 +162,11 @@ function applyInvite(data, invite) {
if (data.isDirectMessage !== invite.isDirectMessage) { if (data.isDirectMessage !== invite.isDirectMessage) {
data = data.cloneIfNeeded(); data = data.cloneIfNeeded();
data.isDirectMessage = invite.isDirectMessage; data.isDirectMessage = invite.isDirectMessage;
} if (invite.isDirectMessage) {
if (data.dmUserId !== invite.inviter?.userId) {
data = data.cloneIfNeeded();
data.dmUserId = invite.inviter?.userId; data.dmUserId = invite.inviter?.userId;
} else {
data.dmUserId = null;
}
} }
return data; return data;
} }
@ -204,8 +215,12 @@ export class SummaryData {
} }
serialize() { serialize() {
const {cloned, ...serializedProps} = this; return Object.entries(this).reduce((obj, [key, value]) => {
return serializedProps; if (key !== "cloned" && value !== null) {
obj[key] = value;
}
return obj;
}, {});
} }
applyTimelineEntries(timelineEntries, isInitialSync, canMarkUnread, ownUserId) { applyTimelineEntries(timelineEntries, isInitialSync, canMarkUnread, ownUserId) {
@ -223,6 +238,10 @@ export class SummaryData {
get needsHeroes() { get needsHeroes() {
return !this.name && !this.canonicalAlias && this.heroes && this.heroes.length > 0; return !this.name && !this.canonicalAlias && this.heroes && this.heroes.length > 0;
} }
isNewJoin(oldData) {
return this.membership === "join" && oldData.membership !== "join";
}
} }
export class RoomSummary { 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) { async writeAndApplyData(data, storage) {
if (data === this._data) { if (data === this._data) {
return false; return false;
@ -297,15 +324,15 @@ export class RoomSummary {
export function tests() { export function tests() {
return { return {
"membership trigger change": function(assert) { "serialize doesn't include null fields or cloned": assert => {
const summary = new RoomSummary("id"); const roomId = "!123:hs.tld";
let written = false; const data = new SummaryData(null, roomId);
let changes = summary.data.applySyncResponse({}, "join"); const clone = data.cloneIfNeeded();
const txn = {roomSummary: {set: () => { written = true; }}}; const serialized = clone.serialize();
changes = summary.writeData(changes, txn); assert.strictEqual(serialized.cloned, undefined);
assert(changes); assert.equal(serialized.roomId, roomId);
assert(written); const nullCount = Object.values(serialized).reduce((count, value) => count + value === null ? 1 : 0, 0);
assert.equal(changes.membership, "join"); assert.strictEqual(nullCount, 0);
} }
} }
} }

View File

@ -15,15 +15,15 @@ limitations under the License.
*/ */
import {ObservableMap} from "../../../observable/map/ObservableMap.js"; import {ObservableMap} from "../../../observable/map/ObservableMap.js";
import {RetainedValue} from "../../../utils/RetainedValue.js";
export class MemberList { export class MemberList extends RetainedValue {
constructor({members, closeCallback}) { constructor({members, closeCallback}) {
super(closeCallback);
this._members = new ObservableMap(); this._members = new ObservableMap();
for (const member of members) { for (const member of members) {
this._members.add(member.userId, member); this._members.add(member.userId, member);
} }
this._closeCallback = closeCallback;
this._retentionCount = 1;
} }
afterSync(memberChanges) { afterSync(memberChanges) {
@ -35,15 +35,4 @@ export class MemberList {
get members() { get members() {
return this._members; return this._members;
} }
retain() {
this._retentionCount += 1;
}
release() {
this._retentionCount -= 1;
if (this._retentionCount === 0) {
this._closeCallback();
}
}
} }

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. 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 {Disposables} from "../../../utils/Disposables.js";
import {Direction} from "./Direction.js"; import {Direction} from "./Direction.js";
import {TimelineReader} from "./persistence/TimelineReader.js"; import {TimelineReader} from "./persistence/TimelineReader.js";
@ -36,11 +36,16 @@ export class Timeline {
fragmentIdComparer: this._fragmentIdComparer fragmentIdComparer: this._fragmentIdComparer
}); });
this._readerRequest = null; this._readerRequest = null;
const localEntries = new MappedList(pendingEvents, pe => { let localEntries;
if (pendingEvents) {
localEntries = new MappedList(pendingEvents, pe => {
return new PendingEventEntry({pendingEvent: pe, member: this._ownMember, clock}); return new PendingEventEntry({pendingEvent: pe, member: this._ownMember, clock});
}, (pee, params) => { }, (pee, params) => {
pee.notifyUpdate(params); pee.notifyUpdate(params);
}); });
} else {
localEntries = new ObservableArray();
}
this._allEntries = new ConcatList(this._remoteEntries, localEntries); this._allEntries = new ConcatList(this._remoteEntries, localEntries);
} }

View File

@ -200,13 +200,17 @@ export class SyncWriter {
const index = events.findIndex(event => event.event_id === lastEventId); const index = events.findIndex(event => event.event_id === lastEventId);
if (index !== -1) { if (index !== -1) {
log.set("overlap_event_id", lastEventId); log.set("overlap_event_id", lastEventId);
return { return Object.assign({}, timeline, {
limited: false, 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; return timeline;
} }

View File

@ -18,6 +18,7 @@ export const STORE_NAMES = Object.freeze([
"session", "session",
"roomState", "roomState",
"roomSummary", "roomSummary",
"archivedRoomSummary",
"invites", "invites",
"roomMembers", "roomMembers",
"timelineEvents", "timelineEvents",

View File

@ -65,6 +65,10 @@ export class Transaction {
return this._store("roomSummary", idbStore => new RoomSummaryStore(idbStore)); return this._store("roomSummary", idbStore => new RoomSummaryStore(idbStore));
} }
get archivedRoomSummary() {
return this._store("archivedRoomSummary", idbStore => new RoomSummaryStore(idbStore));
}
get invites() { get invites() {
return this._store("invites", idbStore => new InviteStore(idbStore)); return this._store("invites", idbStore => new InviteStore(idbStore));
} }

View File

@ -42,7 +42,7 @@ export class IDBError extends StorageError {
export class IDBRequestError extends IDBError { export class IDBRequestError extends IDBError {
constructor(request, message = "IDBRequest failed") { constructor(request, message = "IDBRequest failed") {
const source = request?.source; const source = request.source;
const cause = request.error; const cause = request.error;
super(message, source, cause); super(message, source, cause);
} }

View File

@ -12,7 +12,8 @@ export const schema = [
createE2EEStores, createE2EEStores,
migrateEncryptionFlag, migrateEncryptionFlag,
createAccountDataStore, createAccountDataStore,
createInviteStore createInviteStore,
createArchivedRoomSummaryStore,
]; ];
// TODO: how to deal with git merge conflicts of this array? // TODO: how to deal with git merge conflicts of this array?
@ -109,3 +110,8 @@ function createAccountDataStore(db) {
function createInviteStore(db) { function createInviteStore(db) {
db.createObjectStore("invites", {keyPath: "roomId"}); db.createObjectStore("invites", {keyPath: "roomId"});
} }
// v8
function createArchivedRoomSummaryStore(db) {
db.createObjectStore("archivedRoomSummary", {keyPath: "summary.roomId"});
}

View File

@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {MAX_UNICODE, MIN_UNICODE} from "./common.js";
function encodeKey(userId, deviceId) { function encodeKey(userId, deviceId) {
return `${userId}|${deviceId}`; return `${userId}|${deviceId}`;
} }
@ -66,4 +68,11 @@ export class DeviceIdentityStore {
remove(userId, deviceId) { remove(userId, deviceId) {
this._store.delete(encodeKey(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);
}
} }

View File

@ -1,6 +1,6 @@
/* /*
Copyright 2020 Bruno Windels <bruno@windels.cloud> Copyright 2020 Bruno Windels <bruno@windels.cloud>
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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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. limitations under the License.
*/ */
import {MAX_UNICODE} from "./common.js";
function encodeKey(roomId, userId) { function encodeKey(roomId, userId) {
return `${roomId}|${userId}`; return `${roomId}|${userId}`;
} }
@ -60,4 +62,11 @@ export class RoomMemberStore {
}); });
return userIds; 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);
}
} }

View File

@ -1,5 +1,6 @@
/* /*
Copyright 2020 Bruno Windels <bruno@windels.cloud> Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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. limitations under the License.
*/ */
import {MAX_UNICODE} from "./common.js";
export class RoomStateStore { export class RoomStateStore {
constructor(idbStore) { constructor(idbStore) {
this._roomStateStore = idbStore; this._roomStateStore = idbStore;
} }
async getAllForType(type) { async getAllForType(type) {
throw new Error("unimplemented");
} }
async get(type, stateKey) { async get(type, stateKey) {
throw new Error("unimplemented");
} }
async set(roomId, event) { async set(roomId, event) {
@ -32,4 +35,11 @@ export class RoomStateStore {
const entry = {roomId, event, key}; const entry = {roomId, event, key};
return this._roomStateStore.put(entry); 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);
}
} }

View File

@ -27,6 +27,8 @@ store contains:
inviteCount inviteCount
joinCount joinCount
*/ */
/** Used for both roomSummary and archivedRoomSummary stores */
export class RoomSummaryStore { export class RoomSummaryStore {
constructor(summaryStore) { constructor(summaryStore) {
this._summaryStore = summaryStore; this._summaryStore = summaryStore;
@ -39,4 +41,17 @@ export class RoomSummaryStore {
set(summary) { set(summary) {
return this._summaryStore.put(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);
}
} }

View File

@ -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}";

View File

@ -81,21 +81,37 @@ export function reqAsPromise(req) {
resolve(event.target.result); resolve(event.target.result);
needsSyncPromise && Promise._flush && Promise._flush(); needsSyncPromise && Promise._flush && Promise._flush();
}); });
req.addEventListener("error", () => { req.addEventListener("error", event => {
reject(new IDBRequestError(req)); const error = new IDBRequestError(event.target);
reject(error);
needsSyncPromise && Promise._flush && Promise._flush(); needsSyncPromise && Promise._flush && Promise._flush();
}); });
}); });
} }
export function txnAsPromise(txn) { export function txnAsPromise(txn) {
let error;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
txn.addEventListener("complete", () => { txn.addEventListener("complete", () => {
resolve(); resolve();
needsSyncPromise && Promise._flush && Promise._flush(); needsSyncPromise && Promise._flush && Promise._flush();
}); });
txn.addEventListener("abort", () => { txn.addEventListener("error", event => {
reject(new IDBRequestError(txn)); 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(); needsSyncPromise && Promise._flush && Promise._flush();
}); });
}); });

View File

@ -48,6 +48,13 @@ export class BaseObservable {
return null; return null;
} }
unsubscribeAll() {
if (this._handlers.size !== 0) {
this._handlers.clear();
this.onUnsubscribeLast();
}
}
get hasSubscriptions() { get hasSubscriptions() {
return this._handlers.size !== 0; return this._handlers.size !== 0;
} }

View File

@ -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() { export function tests() {
return { return {
"set emits an update": assert => { "set emits an update": assert => {

View File

@ -897,3 +897,12 @@ button.link {
display: block; display: block;
width: 100%; width: 100%;
} }
.RoomArchivedView {
padding: 12px;
background-color: rgba(245, 245, 245, 0.90);
}
.RoomArchivedView h3 {
margin: 0;
}

View File

@ -0,0 +1,23 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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));
}
}

View File

@ -19,10 +19,17 @@ import {TemplateView} from "../../general/TemplateView.js";
import {TimelineList} from "./TimelineList.js"; import {TimelineList} from "./TimelineList.js";
import {TimelineLoadingView} from "./TimelineLoadingView.js"; import {TimelineLoadingView} from "./TimelineLoadingView.js";
import {MessageComposer} from "./MessageComposer.js"; import {MessageComposer} from "./MessageComposer.js";
import {RoomArchivedView} from "./RoomArchivedView.js";
import {AvatarView} from "../../avatar.js"; import {AvatarView} from "../../avatar.js";
export class RoomView extends TemplateView { export class RoomView extends TemplateView {
render(t, vm) { 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"}, [ return t.main({className: "RoomView middle"}, [
t.div({className: "RoomHeader middle-header"}, [ t.div({className: "RoomHeader middle-header"}, [
t.a({className: "button-utility close-middle", href: vm.closeUrl, title: vm.i18n`Close room`}), 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 TimelineList(timelineViewModel) :
new TimelineLoadingView(vm); // vm is just needed for i18n new TimelineLoadingView(vm); // vm is just needed for i18n
}), }),
t.view(new MessageComposer(vm.composerViewModel)), t.view(bottomView),
]) ])
]); ]);
} }

View File

@ -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();
}
}
}