forked from mystiq/hydrogen-web
Merge pull request #342 from vector-im/bwindels/archive-room-on-leave
Archive room on leave
This commit is contained in:
commit
d7e8529a6e
35 changed files with 1605 additions and 748 deletions
|
@ -15,7 +15,6 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import {ViewModel} from "../ViewModel.js";
|
||||
import {removeRoomFromPath} from "../navigation/index.js";
|
||||
|
||||
function dedupeSparse(roomIds) {
|
||||
return roomIds.map((id, idx) => {
|
||||
|
@ -33,10 +32,9 @@ export class RoomGridViewModel extends ViewModel {
|
|||
|
||||
this._width = options.width;
|
||||
this._height = options.height;
|
||||
this._createRoomViewModel = options.createRoomViewModel;
|
||||
this._createRoomViewModelObservable = options.createRoomViewModelObservable;
|
||||
this._selectedIndex = 0;
|
||||
this._viewModels = [];
|
||||
this._refreshRoomViewModel = this._refreshRoomViewModel.bind(this);
|
||||
this._viewModelsObservables = [];
|
||||
this._setupNavigation();
|
||||
}
|
||||
|
||||
|
@ -55,38 +53,17 @@ export class RoomGridViewModel extends ViewModel {
|
|||
this.track(focusedRoom.subscribe(roomId => {
|
||||
if (roomId) {
|
||||
// as the room will be in the "rooms" observable
|
||||
// (monitored by the parent vm) as well,
|
||||
// (monitored by the parent vmo) as well,
|
||||
// we only change the focus here and trust
|
||||
// setRoomIds to have created the vm already
|
||||
// setRoomIds to have created the vmo already
|
||||
this._setFocusRoom(roomId);
|
||||
}
|
||||
}));
|
||||
// initial focus for a room is set by initializeRoomIdsAndTransferVM
|
||||
}
|
||||
|
||||
_refreshRoomViewModel(roomId) {
|
||||
const index = this._viewModels.findIndex(vm => vm?.id === roomId);
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
this._viewModels[index] = this.disposeTracked(this._viewModels[index]);
|
||||
// this will create a RoomViewModel because the invite is already
|
||||
// removed from the collection (see Invite.afterSync)
|
||||
const roomVM = this._createRoomViewModel(roomId, this._refreshRoomViewModel);
|
||||
if (roomVM) {
|
||||
this._viewModels[index] = this.track(roomVM);
|
||||
if (this.focusIndex === index) {
|
||||
roomVM.focus();
|
||||
}
|
||||
} else {
|
||||
// close room id
|
||||
this.navigation.applyPath(removeRoomFromPath(this.navigation.path, roomId));
|
||||
}
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
roomViewModelAt(i) {
|
||||
return this._viewModels[i];
|
||||
return this._viewModelsObservables[i]?.get();
|
||||
}
|
||||
|
||||
get focusIndex() {
|
||||
|
@ -105,9 +82,9 @@ export class RoomGridViewModel extends ViewModel {
|
|||
if (index === this._selectedIndex) {
|
||||
return;
|
||||
}
|
||||
const vm = this._viewModels[index];
|
||||
if (vm) {
|
||||
this.navigation.push("room", vm.id);
|
||||
const vmo = this._viewModelsObservables[index];
|
||||
if (vmo) {
|
||||
this.navigation.push("room", vmo.id);
|
||||
} else {
|
||||
this.navigation.push("empty-grid-tile", index);
|
||||
}
|
||||
|
@ -120,7 +97,8 @@ export class RoomGridViewModel extends ViewModel {
|
|||
if (existingRoomVM) {
|
||||
const index = roomIds.indexOf(existingRoomVM.id);
|
||||
if (index !== -1) {
|
||||
this._viewModels[index] = this.track(existingRoomVM);
|
||||
this._viewModelsObservables[index] = this.track(existingRoomVM);
|
||||
existingRoomVM.subscribe(viewModel => this._refreshRoomViewModel(viewModel));
|
||||
transfered = true;
|
||||
}
|
||||
}
|
||||
|
@ -128,7 +106,7 @@ export class RoomGridViewModel extends ViewModel {
|
|||
// now all view models exist, set the focus to the selected room
|
||||
const focusedRoom = this.navigation.path.get("room");
|
||||
if (focusedRoom) {
|
||||
const index = this._viewModels.findIndex(vm => vm && vm.id === focusedRoom.value);
|
||||
const index = this._viewModelsObservables.findIndex(vmo => vmo && vmo.id === focusedRoom.value);
|
||||
if (index !== -1) {
|
||||
this._selectedIndex = index;
|
||||
}
|
||||
|
@ -143,17 +121,17 @@ export class RoomGridViewModel extends ViewModel {
|
|||
const len = this._height * this._width;
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
const newId = roomIds[i];
|
||||
const vm = this._viewModels[i];
|
||||
const vmo = this._viewModelsObservables[i];
|
||||
// did anything change?
|
||||
if ((!vm && newId) || (vm && vm.id !== newId)) {
|
||||
if (vm) {
|
||||
this._viewModels[i] = this.disposeTracked(vm);
|
||||
if ((!vmo && newId) || (vmo && vmo.id !== newId)) {
|
||||
if (vmo) {
|
||||
this._viewModelsObservables[i] = this.disposeTracked(vmo);
|
||||
}
|
||||
if (newId) {
|
||||
const newVM = this._createRoomViewModel(newId, this._refreshRoomViewModel);
|
||||
if (newVM) {
|
||||
this._viewModels[i] = this.track(newVM);
|
||||
}
|
||||
const vmo = this._createRoomViewModelObservable(newId);
|
||||
this._viewModelsObservables[i] = this.track(vmo);
|
||||
vmo.subscribe(viewModel => this._refreshRoomViewModel(viewModel));
|
||||
vmo.initialize();
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
@ -164,14 +142,20 @@ export class RoomGridViewModel extends ViewModel {
|
|||
return changed;
|
||||
}
|
||||
|
||||
_refreshRoomViewModel(viewModel) {
|
||||
this.emitChange();
|
||||
viewModel?.focus();
|
||||
}
|
||||
|
||||
/** called from SessionViewModel */
|
||||
releaseRoomViewModel(roomId) {
|
||||
const index = this._viewModels.findIndex(vm => vm && vm.id === roomId);
|
||||
const index = this._viewModelsObservables.findIndex(vmo => vmo && vmo.id === roomId);
|
||||
if (index !== -1) {
|
||||
const vm = this._viewModels[index];
|
||||
this.untrack(vm);
|
||||
this._viewModels[index] = null;
|
||||
return vm;
|
||||
const vmo = this._viewModelsObservables[index];
|
||||
this.untrack(vmo);
|
||||
vmo.unsubscribeAll();
|
||||
this._viewModelsObservables[index] = null;
|
||||
return vmo;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -180,13 +164,13 @@ export class RoomGridViewModel extends ViewModel {
|
|||
return;
|
||||
}
|
||||
this._selectedIndex = idx;
|
||||
const vm = this._viewModels[this._selectedIndex];
|
||||
vm?.focus();
|
||||
const vmo = this._viewModelsObservables[this._selectedIndex];
|
||||
vmo?.get()?.focus();
|
||||
this.emitChange("focusIndex");
|
||||
}
|
||||
|
||||
_setFocusRoom(roomId) {
|
||||
const index = this._viewModels.findIndex(vm => vm?.id === roomId);
|
||||
const index = this._viewModelsObservables.findIndex(vmo => vmo?.id === roomId);
|
||||
if (index >= 0) {
|
||||
this._setFocusIndex(index);
|
||||
}
|
||||
|
@ -194,6 +178,8 @@ export class RoomGridViewModel extends ViewModel {
|
|||
}
|
||||
|
||||
import {createNavigation} from "../navigation/index.js";
|
||||
import {ObservableValue} from "../../observable/ObservableValue.js";
|
||||
|
||||
export function tests() {
|
||||
class RoomVMMock {
|
||||
constructor(id) {
|
||||
|
@ -209,6 +195,12 @@ export function tests() {
|
|||
}
|
||||
}
|
||||
|
||||
class RoomViewModelObservableMock extends ObservableValue {
|
||||
async initialize() {}
|
||||
dispose() { this.get()?.dispose(); }
|
||||
get id() { return this.get()?.id; }
|
||||
}
|
||||
|
||||
function createNavigationForRoom(rooms, room) {
|
||||
const navigation = createNavigation();
|
||||
navigation.applyPath(navigation.pathFrom([
|
||||
|
@ -233,7 +225,7 @@ export function tests() {
|
|||
"initialize with duplicate set of rooms": assert => {
|
||||
const navigation = createNavigationForRoom(["c", "a", "b", undefined, "a"], "a");
|
||||
const gridVM = new RoomGridViewModel({
|
||||
createRoomViewModel: id => new RoomVMMock(id),
|
||||
createRoomViewModelObservable: id => new RoomViewModelObservableMock(new RoomVMMock(id)),
|
||||
navigation,
|
||||
width: 3,
|
||||
height: 2,
|
||||
|
@ -250,12 +242,12 @@ export function tests() {
|
|||
"transfer room view model": assert => {
|
||||
const navigation = createNavigationForRoom(["a"], "a");
|
||||
const gridVM = new RoomGridViewModel({
|
||||
createRoomViewModel: () => assert.fail("no vms should be created"),
|
||||
createRoomViewModelObservable: () => assert.fail("no vms should be created"),
|
||||
navigation,
|
||||
width: 3,
|
||||
height: 2,
|
||||
});
|
||||
const existingRoomVM = new RoomVMMock("a");
|
||||
const existingRoomVM = new RoomViewModelObservableMock(new RoomVMMock("a"));
|
||||
const transfered = gridVM.initializeRoomIdsAndTransferVM(navigation.path.get("rooms").value, existingRoomVM);
|
||||
assert.equal(transfered, true);
|
||||
assert.equal(gridVM.focusIndex, 0);
|
||||
|
@ -264,12 +256,12 @@ export function tests() {
|
|||
"reject transfer for non-matching room view model": assert => {
|
||||
const navigation = createNavigationForRoom(["a"], "a");
|
||||
const gridVM = new RoomGridViewModel({
|
||||
createRoomViewModel: id => new RoomVMMock(id),
|
||||
createRoomViewModelObservable: id => new RoomViewModelObservableMock(new RoomVMMock(id)),
|
||||
navigation,
|
||||
width: 3,
|
||||
height: 2,
|
||||
});
|
||||
const existingRoomVM = new RoomVMMock("f");
|
||||
const existingRoomVM = new RoomViewModelObservableMock(new RoomVMMock("f"));
|
||||
const transfered = gridVM.initializeRoomIdsAndTransferVM(navigation.path.get("rooms").value, existingRoomVM);
|
||||
assert.equal(transfered, false);
|
||||
assert.equal(gridVM.focusIndex, 0);
|
||||
|
@ -278,7 +270,7 @@ export function tests() {
|
|||
"created & released room view model is not disposed": assert => {
|
||||
const navigation = createNavigationForRoom(["a"], "a");
|
||||
const gridVM = new RoomGridViewModel({
|
||||
createRoomViewModel: id => new RoomVMMock(id),
|
||||
createRoomViewModelObservable: id => new RoomViewModelObservableMock(new RoomVMMock(id)),
|
||||
navigation,
|
||||
width: 3,
|
||||
height: 2,
|
||||
|
@ -287,27 +279,27 @@ export function tests() {
|
|||
assert.equal(transfered, false);
|
||||
const releasedVM = gridVM.releaseRoomViewModel("a");
|
||||
gridVM.dispose();
|
||||
assert.equal(releasedVM.disposed, false);
|
||||
assert.equal(releasedVM.get().disposed, false);
|
||||
},
|
||||
"transfered & released room view model is not disposed": assert => {
|
||||
const navigation = createNavigationForRoom([undefined, "a"], "a");
|
||||
const gridVM = new RoomGridViewModel({
|
||||
createRoomViewModel: () => assert.fail("no vms should be created"),
|
||||
createRoomViewModelObservable: () => assert.fail("no vms should be created"),
|
||||
navigation,
|
||||
width: 3,
|
||||
height: 2,
|
||||
});
|
||||
const existingRoomVM = new RoomVMMock("a");
|
||||
const existingRoomVM = new RoomViewModelObservableMock(new RoomVMMock("a"));
|
||||
const transfered = gridVM.initializeRoomIdsAndTransferVM(navigation.path.get("rooms").value, existingRoomVM);
|
||||
assert.equal(transfered, true);
|
||||
const releasedVM = gridVM.releaseRoomViewModel("a");
|
||||
gridVM.dispose();
|
||||
assert.equal(releasedVM.disposed, false);
|
||||
assert.equal(releasedVM.get().disposed, false);
|
||||
},
|
||||
"try release non-existing room view model is": assert => {
|
||||
const navigation = createNavigationForEmptyTile([undefined, "b"], 3);
|
||||
const gridVM = new RoomGridViewModel({
|
||||
createRoomViewModel: id => new RoomVMMock(id),
|
||||
createRoomViewModelObservable: id => new RoomViewModelObservableMock(new RoomVMMock(id)),
|
||||
navigation,
|
||||
width: 3,
|
||||
height: 2,
|
||||
|
@ -319,7 +311,7 @@ export function tests() {
|
|||
"initial focus is set to empty tile": assert => {
|
||||
const navigation = createNavigationForEmptyTile(["a"], 1);
|
||||
const gridVM = new RoomGridViewModel({
|
||||
createRoomViewModel: id => new RoomVMMock(id),
|
||||
createRoomViewModelObservable: id => new RoomViewModelObservableMock(new RoomVMMock(id)),
|
||||
navigation,
|
||||
width: 3,
|
||||
height: 2,
|
||||
|
@ -331,7 +323,7 @@ export function tests() {
|
|||
"change room ids after creation": assert => {
|
||||
const navigation = createNavigationForRoom(["a", "b"], "a");
|
||||
const gridVM = new RoomGridViewModel({
|
||||
createRoomViewModel: id => new RoomVMMock(id),
|
||||
createRoomViewModelObservable: id => new RoomViewModelObservableMock(new RoomVMMock(id)),
|
||||
navigation,
|
||||
width: 3,
|
||||
height: 2,
|
||||
|
|
78
src/domain/session/RoomViewModelObservable.js
Normal file
78
src/domain/session/RoomViewModelObservable.js
Normal 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();
|
||||
}
|
||||
}
|
|
@ -15,7 +15,6 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {removeRoomFromPath} from "../navigation/index.js";
|
||||
import {LeftPanelViewModel} from "./leftpanel/LeftPanelViewModel.js";
|
||||
import {RoomViewModel} from "./room/RoomViewModel.js";
|
||||
import {InviteViewModel} from "./room/InviteViewModel.js";
|
||||
|
@ -24,6 +23,7 @@ import {SessionStatusViewModel} from "./SessionStatusViewModel.js";
|
|||
import {RoomGridViewModel} from "./RoomGridViewModel.js";
|
||||
import {SettingsViewModel} from "./settings/SettingsViewModel.js";
|
||||
import {ViewModel} from "../ViewModel.js";
|
||||
import {RoomViewModelObservable} from "./RoomViewModelObservable.js";
|
||||
|
||||
export class SessionViewModel extends ViewModel {
|
||||
constructor(options) {
|
||||
|
@ -40,10 +40,8 @@ export class SessionViewModel extends ViewModel {
|
|||
rooms: this._sessionContainer.session.rooms
|
||||
})));
|
||||
this._settingsViewModel = null;
|
||||
this._currentRoomViewModel = null;
|
||||
this._roomViewModelObservable = null;
|
||||
this._gridViewModel = null;
|
||||
this._refreshRoomViewModel = this._refreshRoomViewModel.bind(this);
|
||||
this._createRoomViewModel = this._createRoomViewModel.bind(this);
|
||||
this._setupNavigation();
|
||||
}
|
||||
|
||||
|
@ -90,7 +88,7 @@ export class SessionViewModel extends ViewModel {
|
|||
}
|
||||
|
||||
get activeMiddleViewModel() {
|
||||
return this._currentRoomViewModel || this._gridViewModel || this._settingsViewModel;
|
||||
return this._roomViewModelObservable?.get() || this._gridViewModel || this._settingsViewModel;
|
||||
}
|
||||
|
||||
get roomGridViewModel() {
|
||||
|
@ -110,7 +108,7 @@ export class SessionViewModel extends ViewModel {
|
|||
}
|
||||
|
||||
get currentRoomViewModel() {
|
||||
return this._currentRoomViewModel;
|
||||
return this._roomViewModelObservable?.get();
|
||||
}
|
||||
|
||||
_updateGrid(roomIds) {
|
||||
|
@ -121,12 +119,14 @@ export class SessionViewModel extends ViewModel {
|
|||
this._gridViewModel = this.track(new RoomGridViewModel(this.childOptions({
|
||||
width: 3,
|
||||
height: 2,
|
||||
createRoomViewModel: this._createRoomViewModel,
|
||||
createRoomViewModelObservable: roomId => new RoomViewModelObservable(this, roomId),
|
||||
})));
|
||||
if (this._gridViewModel.initializeRoomIdsAndTransferVM(roomIds, this._currentRoomViewModel)) {
|
||||
this._currentRoomViewModel = this.untrack(this._currentRoomViewModel);
|
||||
} else if (this._currentRoomViewModel) {
|
||||
this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel);
|
||||
// try to transfer the current room view model, so we don't have to reload the timeline
|
||||
this._roomViewModelObservable?.unsubscribeAll();
|
||||
if (this._gridViewModel.initializeRoomIdsAndTransferVM(roomIds, this._roomViewModelObservable)) {
|
||||
this._roomViewModelObservable = this.untrack(this._roomViewModelObservable);
|
||||
} else if (this._roomViewModelObservable) {
|
||||
this._roomViewModelObservable = this.disposeTracked(this._roomViewModelObservable);
|
||||
}
|
||||
} else {
|
||||
this._gridViewModel.setRoomIds(roomIds);
|
||||
|
@ -134,14 +134,12 @@ export class SessionViewModel extends ViewModel {
|
|||
} else if (this._gridViewModel && !roomIds) {
|
||||
// closing grid, try to show focused room in grid
|
||||
if (currentRoomId) {
|
||||
const vm = this._gridViewModel.releaseRoomViewModel(currentRoomId.value);
|
||||
if (vm) {
|
||||
this._currentRoomViewModel = this.track(vm);
|
||||
} else {
|
||||
const newVM = this._createRoomViewModel(currentRoomId.value, this._refreshRoomViewModel);
|
||||
if (newVM) {
|
||||
this._currentRoomViewModel = this.track(newVM);
|
||||
}
|
||||
const vmo = this._gridViewModel.releaseRoomViewModel(currentRoomId.value);
|
||||
if (vmo) {
|
||||
this._roomViewModelObservable = this.track(vmo);
|
||||
this._roomViewModelObservable.subscribe(() => {
|
||||
this.emitChange("activeMiddleViewModel");
|
||||
});
|
||||
}
|
||||
}
|
||||
this._gridViewModel = this.disposeTracked(this._gridViewModel);
|
||||
|
@ -151,63 +149,65 @@ export class SessionViewModel extends ViewModel {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} roomId
|
||||
* @param {function} refreshRoomViewModel passed in as an argument, because the grid needs a different impl of this
|
||||
* @return {RoomViewModel | InviteViewModel}
|
||||
*/
|
||||
_createRoomViewModel(roomId, refreshRoomViewModel) {
|
||||
const invite = this._sessionContainer.session.invites.get(roomId);
|
||||
if (invite) {
|
||||
return new InviteViewModel(this.childOptions({
|
||||
invite,
|
||||
mediaRepository: this._sessionContainer.session.mediaRepository,
|
||||
refreshRoomViewModel,
|
||||
}));
|
||||
} else {
|
||||
_createRoomViewModel(roomId) {
|
||||
const room = this._sessionContainer.session.rooms.get(roomId);
|
||||
if (room) {
|
||||
const roomVM = new RoomViewModel(this.childOptions({
|
||||
room,
|
||||
ownUserId: this._sessionContainer.session.user.id,
|
||||
refreshRoomViewModel
|
||||
}));
|
||||
roomVM.load();
|
||||
return roomVM;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async _createArchivedRoomViewModel(roomId) {
|
||||
const room = await this._sessionContainer.session.loadArchivedRoom(roomId);
|
||||
if (room) {
|
||||
const roomVM = new RoomViewModel(this.childOptions({
|
||||
room,
|
||||
ownUserId: this._sessionContainer.session.user.id,
|
||||
}));
|
||||
roomVM.load();
|
||||
return roomVM;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** refresh the room view model after an internal change that needs
|
||||
to change between invite, room or none state */
|
||||
_refreshRoomViewModel(roomId) {
|
||||
this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel);
|
||||
const roomVM = this._createRoomViewModel(roomId, this._refreshRoomViewModel);
|
||||
if (roomVM) {
|
||||
this._currentRoomViewModel = this.track(roomVM);
|
||||
} else {
|
||||
// close room id
|
||||
this.navigation.applyPath(removeRoomFromPath(this.navigation.path, roomId));
|
||||
_createInviteViewModel(roomId) {
|
||||
const invite = this._sessionContainer.session.invites.get(roomId);
|
||||
if (invite) {
|
||||
return new InviteViewModel(this.childOptions({
|
||||
invite,
|
||||
mediaRepository: this._sessionContainer.session.mediaRepository,
|
||||
}));
|
||||
}
|
||||
this.emitChange("activeMiddleViewModel");
|
||||
return null;
|
||||
}
|
||||
|
||||
_updateRoom(roomId) {
|
||||
// opening a room and already open?
|
||||
if (this._currentRoomViewModel?.id === roomId) {
|
||||
if (this._roomViewModelObservable?.id === roomId) {
|
||||
return;
|
||||
}
|
||||
// close if needed
|
||||
if (this._currentRoomViewModel) {
|
||||
this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel);
|
||||
}
|
||||
// and try opening again
|
||||
const roomVM = this._createRoomViewModel(roomId, this._refreshRoomViewModel);
|
||||
if (roomVM) {
|
||||
this._currentRoomViewModel = this.track(roomVM);
|
||||
if (this._roomViewModelObservable) {
|
||||
this._roomViewModelObservable = this.disposeTracked(this._roomViewModelObservable);
|
||||
}
|
||||
if (!roomId) {
|
||||
// if clearing the activeMiddleViewModel rather than changing to a different one,
|
||||
// emit so the view picks it up and show the placeholder
|
||||
this.emitChange("activeMiddleViewModel");
|
||||
return;
|
||||
}
|
||||
const vmo = new RoomViewModelObservable(this, roomId);
|
||||
this._roomViewModelObservable = this.track(vmo);
|
||||
// subscription is unsubscribed in RoomViewModelObservable.dispose, and thus handled by track
|
||||
this._roomViewModelObservable.subscribe(() => {
|
||||
this.emitChange("activeMiddleViewModel");
|
||||
});
|
||||
vmo.initialize();
|
||||
}
|
||||
|
||||
_updateSettings(settingsOpen) {
|
||||
|
|
|
@ -35,9 +35,8 @@ export class LeftPanelViewModel extends ViewModel {
|
|||
}
|
||||
|
||||
_mapTileViewModels(rooms, invites) {
|
||||
const joinedRooms = rooms.filterValues(room => room.membership === "join");
|
||||
// join is not commutative, invites will take precedence over rooms
|
||||
return invites.join(joinedRooms).mapValues((roomOrInvite, emitChange) => {
|
||||
return invites.join(rooms).mapValues((roomOrInvite, emitChange) => {
|
||||
const isOpen = this.navigation.path.get("room")?.value === roomOrInvite.id;
|
||||
let vm;
|
||||
if (roomOrInvite.isInvite) {
|
||||
|
|
|
@ -21,10 +21,9 @@ import {ViewModel} from "../../ViewModel.js";
|
|||
export class InviteViewModel extends ViewModel {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
const {invite, mediaRepository, refreshRoomViewModel} = options;
|
||||
const {invite, mediaRepository} = options;
|
||||
this._invite = invite;
|
||||
this._mediaRepository = mediaRepository;
|
||||
this._refreshRoomViewModel = refreshRoomViewModel;
|
||||
this._onInviteChange = this._onInviteChange.bind(this);
|
||||
this._error = null;
|
||||
this._closeUrl = this.urlCreator.urlUntilSegment("session");
|
||||
|
@ -107,18 +106,8 @@ export class InviteViewModel extends ViewModel {
|
|||
}
|
||||
|
||||
_onInviteChange() {
|
||||
if (this._invite.accepted || this._invite.rejected) {
|
||||
// close invite if rejected, or open room if accepted.
|
||||
// Done with a callback rather than manipulating the nav,
|
||||
// as closing the invite changes the nav path depending whether
|
||||
// we're in a grid view, and opening the room doesn't change
|
||||
// the nav path because the url is the same for an
|
||||
// invite and the room.
|
||||
this._refreshRoomViewModel(this.id);
|
||||
} else {
|
||||
this.emitChange();
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
super.dispose();
|
||||
|
|
|
@ -22,15 +22,19 @@ import {ViewModel} from "../../ViewModel.js";
|
|||
export class RoomViewModel extends ViewModel {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
const {room, ownUserId, refreshRoomViewModel} = options;
|
||||
const {room, ownUserId} = options;
|
||||
this._room = room;
|
||||
this._ownUserId = ownUserId;
|
||||
this._refreshRoomViewModel = refreshRoomViewModel;
|
||||
this._timelineVM = null;
|
||||
this._onRoomChange = this._onRoomChange.bind(this);
|
||||
this._timelineError = null;
|
||||
this._sendError = null;
|
||||
this._composerVM = null;
|
||||
if (room.isArchived) {
|
||||
this._composerVM = new ArchivedViewModel(this.childOptions({archivedRoom: room}));
|
||||
} else {
|
||||
this._composerVM = new ComposerViewModel(this);
|
||||
}
|
||||
this._clearUnreadTimout = null;
|
||||
this._closeUrl = this.urlCreator.urlUntilSegment("session");
|
||||
}
|
||||
|
@ -55,7 +59,7 @@ export class RoomViewModel extends ViewModel {
|
|||
}
|
||||
|
||||
async _clearUnreadAfterDelay() {
|
||||
if (this._clearUnreadTimout) {
|
||||
if (this._room.isArchived || this._clearUnreadTimout) {
|
||||
return;
|
||||
}
|
||||
this._clearUnreadTimout = this.clock.createTimeout(2000);
|
||||
|
@ -77,6 +81,9 @@ export class RoomViewModel extends ViewModel {
|
|||
dispose() {
|
||||
super.dispose();
|
||||
this._room.off("change", this._onRoomChange);
|
||||
if (this._room.isArchived) {
|
||||
this._room.release();
|
||||
}
|
||||
if (this._clearUnreadTimout) {
|
||||
this._clearUnreadTimout.abort();
|
||||
this._clearUnreadTimout = null;
|
||||
|
@ -86,13 +93,10 @@ export class RoomViewModel extends ViewModel {
|
|||
// room doesn't tell us yet which fields changed,
|
||||
// so emit all fields originating from summary
|
||||
_onRoomChange() {
|
||||
// if there is now an invite on this (left) room,
|
||||
// show the invite view by refreshing the view model
|
||||
if (this._room.invite) {
|
||||
this._refreshRoomViewModel(this.id);
|
||||
} else {
|
||||
this.emitChange("name");
|
||||
if (this._room.isArchived) {
|
||||
this._composerVM.emitChange();
|
||||
}
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
get kind() { return "room"; }
|
||||
|
@ -129,7 +133,7 @@ export class RoomViewModel extends ViewModel {
|
|||
}
|
||||
|
||||
async _sendMessage(message) {
|
||||
if (message) {
|
||||
if (!this._room.isArchived && message) {
|
||||
try {
|
||||
let msgtype = "m.text";
|
||||
if (message.startsWith("/me ")) {
|
||||
|
@ -310,6 +314,10 @@ class ComposerViewModel extends ViewModel {
|
|||
this.emitChange("canSend");
|
||||
}
|
||||
}
|
||||
|
||||
get kind() {
|
||||
return "composer";
|
||||
}
|
||||
}
|
||||
|
||||
function imageToInfo(image) {
|
||||
|
@ -326,3 +334,32 @@ function videoToInfo(video) {
|
|||
info.duration = video.duration;
|
||||
return info;
|
||||
}
|
||||
|
||||
class ArchivedViewModel extends ViewModel {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this._archivedRoom = options.archivedRoom;
|
||||
}
|
||||
|
||||
get description() {
|
||||
if (this._archivedRoom.isKicked) {
|
||||
if (this._archivedRoom.kickReason) {
|
||||
return this.i18n`You were kicked from the room by ${this._archivedRoom.kickedBy.name} because: ${this._archivedRoom.kickReason}`;
|
||||
} else {
|
||||
return this.i18n`You were kicked from the room by ${this._archivedRoom.kickedBy.name}.`;
|
||||
}
|
||||
} else if (this._archivedRoom.isBanned) {
|
||||
if (this._archivedRoom.kickReason) {
|
||||
return this.i18n`You were banned from the room by ${this._archivedRoom.kickedBy.name} because: ${this._archivedRoom.kickReason}`;
|
||||
} else {
|
||||
return this.i18n`You were banned from the room by ${this._archivedRoom.kickedBy.name}.`;
|
||||
}
|
||||
} else {
|
||||
return this.i18n`You left this room`;
|
||||
}
|
||||
}
|
||||
|
||||
get kind() {
|
||||
return "archived";
|
||||
}
|
||||
}
|
|
@ -16,6 +16,8 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import {Room} from "./room/Room.js";
|
||||
import {ArchivedRoom} from "./room/ArchivedRoom.js";
|
||||
import {RoomStatus} from "./room/RoomStatus.js";
|
||||
import {Invite} from "./room/Invite.js";
|
||||
import {Pusher} from "./push/Pusher.js";
|
||||
import { ObservableMap } from "../observable/index.js";
|
||||
|
@ -38,7 +40,7 @@ import {
|
|||
writeKey as ssssWriteKey,
|
||||
} from "./ssss/index.js";
|
||||
import {SecretStorage} from "./ssss/SecretStorage.js";
|
||||
import {ObservableValue} from "../observable/ObservableValue.js";
|
||||
import {ObservableValue, RetainedObservableValue} from "../observable/ObservableValue.js";
|
||||
|
||||
const PICKLE_KEY = "DEFAULT_KEY";
|
||||
const PUSHER_KEY = "pusher";
|
||||
|
@ -54,8 +56,8 @@ export class Session {
|
|||
this._sessionInfo = sessionInfo;
|
||||
this._rooms = new ObservableMap();
|
||||
this._roomUpdateCallback = (room, params) => this._rooms.update(room.id, params);
|
||||
this._activeArchivedRooms = new Map();
|
||||
this._invites = new ObservableMap();
|
||||
this._inviteRemoveCallback = invite => this._invites.remove(invite.id);
|
||||
this._inviteUpdateCallback = (invite, params) => this._invites.update(invite.id, params);
|
||||
this._user = new User(sessionInfo.userId);
|
||||
this._deviceMessageHandler = new DeviceMessageHandler({storage});
|
||||
|
@ -70,6 +72,7 @@ export class Session {
|
|||
this._olmWorker = olmWorker;
|
||||
this._sessionBackup = null;
|
||||
this._hasSecretStorageKey = new ObservableValue(null);
|
||||
this._observedRoomStatus = new Map();
|
||||
|
||||
if (olm) {
|
||||
this._olmUtil = new olm.Utility();
|
||||
|
@ -397,8 +400,21 @@ export class Session {
|
|||
}
|
||||
|
||||
/** @internal */
|
||||
addRoomAfterSync(room) {
|
||||
this._rooms.add(room.id, room);
|
||||
_createArchivedRoom(roomId) {
|
||||
const room = new ArchivedRoom({
|
||||
roomId,
|
||||
getSyncToken: this._getSyncToken,
|
||||
storage: this._storage,
|
||||
emitCollectionChange: () => {},
|
||||
releaseCallback: () => this._activeArchivedRooms.delete(roomId),
|
||||
hsApi: this._hsApi,
|
||||
mediaRepository: this._mediaRepository,
|
||||
user: this._user,
|
||||
createRoomEncryption: this._createRoomEncryption,
|
||||
platform: this._platform
|
||||
});
|
||||
this._activeArchivedRooms.set(roomId, room);
|
||||
return room;
|
||||
}
|
||||
|
||||
get invites() {
|
||||
|
@ -410,7 +426,6 @@ export class Session {
|
|||
return new Invite({
|
||||
roomId,
|
||||
hsApi: this._hsApi,
|
||||
emitCollectionRemove: this._inviteRemoveCallback,
|
||||
emitCollectionUpdate: this._inviteUpdateCallback,
|
||||
mediaRepository: this._mediaRepository,
|
||||
user: this._user,
|
||||
|
@ -418,11 +433,6 @@ export class Session {
|
|||
});
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
addInviteAfterSync(invite) {
|
||||
this._invites.add(invite.id, invite);
|
||||
}
|
||||
|
||||
async obtainSyncLock(syncResponse) {
|
||||
const toDeviceEvents = syncResponse.to_device?.events;
|
||||
if (Array.isArray(toDeviceEvents) && toDeviceEvents.length) {
|
||||
|
@ -502,6 +512,49 @@ export class Session {
|
|||
}
|
||||
}
|
||||
|
||||
applyRoomCollectionChangesAfterSync(inviteStates, roomStates, archivedRoomStates) {
|
||||
// update the collections after sync
|
||||
for (const rs of roomStates) {
|
||||
if (rs.shouldAdd) {
|
||||
this._rooms.add(rs.id, rs.room);
|
||||
} else if (rs.shouldRemove) {
|
||||
this._rooms.remove(rs.id);
|
||||
}
|
||||
}
|
||||
for (const is of inviteStates) {
|
||||
if (is.shouldAdd) {
|
||||
this._invites.add(is.id, is.invite);
|
||||
} else if (is.shouldRemove) {
|
||||
this._invites.remove(is.id);
|
||||
}
|
||||
}
|
||||
// now all the collections are updated, update the room status
|
||||
// so any listeners to the status will find the collections
|
||||
// completely up to date
|
||||
if (this._observedRoomStatus.size !== 0) {
|
||||
for (const ars of archivedRoomStates) {
|
||||
if (ars.shouldAdd) {
|
||||
this._observedRoomStatus.get(ars.id)?.set(RoomStatus.archived);
|
||||
}
|
||||
}
|
||||
for (const rs of roomStates) {
|
||||
if (rs.shouldAdd) {
|
||||
this._observedRoomStatus.get(rs.id)?.set(RoomStatus.joined);
|
||||
}
|
||||
}
|
||||
for (const is of inviteStates) {
|
||||
const statusObservable = this._observedRoomStatus.get(is.id);
|
||||
if (statusObservable) {
|
||||
if (is.shouldAdd) {
|
||||
statusObservable.set(statusObservable.get().withInvited());
|
||||
} else if (is.shouldRemove) {
|
||||
statusObservable.set(statusObservable.get().withoutInvited());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
get syncToken() {
|
||||
return this._syncInfo?.token;
|
||||
|
@ -585,6 +638,76 @@ export class Session {
|
|||
const serverPushers = (serverPushersData?.pushers || []).map(data => new Pusher(data));
|
||||
return serverPushers.some(p => p.equals(myPusher));
|
||||
}
|
||||
|
||||
async getRoomStatus(roomId) {
|
||||
const isJoined = !!this._rooms.get(roomId);
|
||||
if (isJoined) {
|
||||
return RoomStatus.joined;
|
||||
} else {
|
||||
const isInvited = !!this._invites.get(roomId);
|
||||
const txn = await this._storage.readTxn([this._storage.storeNames.archivedRoomSummary]);
|
||||
const isArchived = await txn.archivedRoomSummary.has(roomId);
|
||||
if (isInvited && isArchived) {
|
||||
return RoomStatus.invitedAndArchived;
|
||||
} else if (isInvited) {
|
||||
return RoomStatus.invited;
|
||||
} else if (isArchived) {
|
||||
return RoomStatus.archived;
|
||||
} else {
|
||||
return RoomStatus.none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async observeRoomStatus(roomId) {
|
||||
let observable = this._observedRoomStatus.get(roomId);
|
||||
if (!observable) {
|
||||
const status = await this.getRoomStatus(roomId);
|
||||
observable = new RetainedObservableValue(status, () => {
|
||||
this._observedRoomStatus.delete(roomId);
|
||||
});
|
||||
this._observedRoomStatus.set(roomId, observable);
|
||||
}
|
||||
return observable;
|
||||
}
|
||||
|
||||
/**
|
||||
Creates an empty (summary isn't loaded) the archived room if it isn't
|
||||
loaded already, assuming sync will either remove it (when rejoining) or
|
||||
write a full summary adopting it from the joined room when leaving
|
||||
|
||||
@internal
|
||||
*/
|
||||
createOrGetArchivedRoomForSync(roomId) {
|
||||
let archivedRoom = this._activeArchivedRooms.get(roomId);
|
||||
if (archivedRoom) {
|
||||
archivedRoom.retain();
|
||||
} else {
|
||||
archivedRoom = this._createArchivedRoom(roomId);
|
||||
}
|
||||
return archivedRoom;
|
||||
}
|
||||
|
||||
loadArchivedRoom(roomId, log = null) {
|
||||
return this._platform.logger.wrapOrRun(log, "loadArchivedRoom", async log => {
|
||||
log.set("id", roomId);
|
||||
const activeArchivedRoom = this._activeArchivedRooms.get(roomId);
|
||||
if (activeArchivedRoom) {
|
||||
activeArchivedRoom.retain();
|
||||
return activeArchivedRoom;
|
||||
}
|
||||
const txn = await this._storage.readTxn([
|
||||
this._storage.storeNames.archivedRoomSummary,
|
||||
this._storage.storeNames.roomMembers,
|
||||
]);
|
||||
const summary = await txn.archivedRoomSummary.get(roomId);
|
||||
if (summary) {
|
||||
const room = this._createArchivedRoom(roomId);
|
||||
await room.load(summary, txn, log);
|
||||
return room;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function tests() {
|
||||
|
|
|
@ -192,7 +192,8 @@ export class Sync {
|
|||
const isInitialSync = !syncToken;
|
||||
const sessionState = new SessionSyncProcessState();
|
||||
const inviteStates = this._parseInvites(response.rooms);
|
||||
const roomStates = this._parseRoomsResponse(response.rooms, inviteStates, isInitialSync);
|
||||
const {roomStates, archivedRoomStates} = await this._parseRoomsResponse(
|
||||
response.rooms, inviteStates, isInitialSync, log);
|
||||
|
||||
try {
|
||||
// take a lock on olm sessions used in this sync so sending a message doesn't change them while syncing
|
||||
|
@ -202,12 +203,14 @@ export class Sync {
|
|||
return rs.room.afterPrepareSync(rs.preparation, log);
|
||||
})));
|
||||
await log.wrap("write", async log => this._writeSync(
|
||||
sessionState, inviteStates, roomStates, response, syncFilterId, isInitialSync, log));
|
||||
sessionState, inviteStates, roomStates, archivedRoomStates,
|
||||
response, syncFilterId, isInitialSync, log));
|
||||
} finally {
|
||||
sessionState.dispose();
|
||||
}
|
||||
// sync txn comitted, emit updates and apply changes to in-memory state
|
||||
log.wrap("after", log => this._afterSync(sessionState, inviteStates, roomStates, log));
|
||||
log.wrap("after", log => this._afterSync(
|
||||
sessionState, inviteStates, roomStates, archivedRoomStates, log));
|
||||
|
||||
const toDeviceEvents = response.to_device?.events;
|
||||
return {
|
||||
|
@ -223,7 +226,11 @@ export class Sync {
|
|||
return this._storage.readTxn([
|
||||
storeNames.olmSessions,
|
||||
storeNames.inboundGroupSessions,
|
||||
storeNames.timelineEvents // to read events that can now be decrypted
|
||||
// to read fragments when loading sync writer when rejoining archived room
|
||||
storeNames.timelineFragments,
|
||||
// to read fragments when loading sync writer when rejoining archived room
|
||||
// to read events that can now be decrypted
|
||||
storeNames.timelineEvents,
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -250,15 +257,22 @@ export class Sync {
|
|||
|
||||
await Promise.all(roomStates.map(async rs => {
|
||||
const newKeys = newKeysByRoom?.get(rs.room.id);
|
||||
rs.preparation = await log.wrap("room", log => rs.room.prepareSync(
|
||||
rs.roomResponse, rs.membership, rs.invite, newKeys, prepareTxn, log), log.level.Detail);
|
||||
rs.preparation = await log.wrap("room", async log => {
|
||||
// if previously joined and we still have the timeline for it,
|
||||
// this loads the syncWriter at the correct position to continue writing the timeline
|
||||
if (rs.isNewRoom) {
|
||||
await rs.room.load(null, prepareTxn, log);
|
||||
}
|
||||
return rs.room.prepareSync(
|
||||
rs.roomResponse, rs.membership, rs.invite, newKeys, prepareTxn, log)
|
||||
}, log.level.Detail);
|
||||
}));
|
||||
|
||||
// This is needed for safari to not throw TransactionInactiveErrors on the syncTxn. See docs/INDEXEDDB.md
|
||||
await prepareTxn.complete();
|
||||
}
|
||||
|
||||
async _writeSync(sessionState, inviteStates, roomStates, response, syncFilterId, isInitialSync, log) {
|
||||
async _writeSync(sessionState, inviteStates, roomStates, archivedRoomStates, response, syncFilterId, isInitialSync, log) {
|
||||
const syncTxn = await this._openSyncTxn();
|
||||
try {
|
||||
sessionState.changes = await log.wrap("session", log => this._session.writeSync(
|
||||
|
@ -271,6 +285,13 @@ export class Sync {
|
|||
rs.changes = await log.wrap("room", log => rs.room.writeSync(
|
||||
rs.roomResponse, isInitialSync, rs.preparation, syncTxn, log));
|
||||
}));
|
||||
// important to do this after roomStates,
|
||||
// as we're referring to the roomState to get the summaryChanges
|
||||
await Promise.all(archivedRoomStates.map(async ars => {
|
||||
const summaryChanges = ars.roomState?.summaryChanges;
|
||||
ars.changes = await log.wrap("archivedRoom", log => ars.archivedRoom.writeSync(
|
||||
summaryChanges, ars.roomResponse, ars.membership, syncTxn, log));
|
||||
}));
|
||||
} catch(err) {
|
||||
// avoid corrupting state by only
|
||||
// storing the sync up till the point
|
||||
|
@ -285,30 +306,21 @@ export class Sync {
|
|||
await syncTxn.complete();
|
||||
}
|
||||
|
||||
_afterSync(sessionState, inviteStates, roomStates, log) {
|
||||
_afterSync(sessionState, inviteStates, roomStates, archivedRoomStates, log) {
|
||||
log.wrap("session", log => this._session.afterSync(sessionState.changes, log), log.level.Detail);
|
||||
// emit room related events after txn has been closed
|
||||
for(let ars of archivedRoomStates) {
|
||||
log.wrap("archivedRoom", log => {
|
||||
ars.archivedRoom.afterSync(ars.changes, log);
|
||||
ars.archivedRoom.release();
|
||||
}, log.level.Detail);
|
||||
}
|
||||
for(let rs of roomStates) {
|
||||
log.wrap("room", log => rs.room.afterSync(rs.changes, log), log.level.Detail);
|
||||
if (rs.isNewRoom) {
|
||||
// important to add the room before removing the invite,
|
||||
// so the room will be found if looking for it when the invite
|
||||
// is removed
|
||||
this._session.addRoomAfterSync(rs.room);
|
||||
}
|
||||
}
|
||||
// emit invite related events after txn has been closed
|
||||
for(let is of inviteStates) {
|
||||
log.wrap("invite", () => is.invite.afterSync(is.changes), log.level.Detail);
|
||||
if (is.isNewInvite) {
|
||||
this._session.addInviteAfterSync(is.invite);
|
||||
}
|
||||
// if we haven't archived or forgotten the (left) room yet,
|
||||
// notify there is an invite now, so we can update the UI
|
||||
if (is.room) {
|
||||
is.room.setInvite(is.invite);
|
||||
}
|
||||
log.wrap("invite", log => is.invite.afterSync(is.changes, log), log.level.Detail);
|
||||
}
|
||||
this._session.applyRoomCollectionChangesAfterSync(inviteStates, roomStates, archivedRoomStates);
|
||||
}
|
||||
|
||||
_openSyncTxn() {
|
||||
|
@ -316,6 +328,7 @@ export class Sync {
|
|||
return this._storage.readWriteTxn([
|
||||
storeNames.session,
|
||||
storeNames.roomSummary,
|
||||
storeNames.archivedRoomSummary,
|
||||
storeNames.invites,
|
||||
storeNames.roomState,
|
||||
storeNames.roomMembers,
|
||||
|
@ -336,8 +349,9 @@ export class Sync {
|
|||
]);
|
||||
}
|
||||
|
||||
_parseRoomsResponse(roomsSection, inviteStates, isInitialSync) {
|
||||
async _parseRoomsResponse(roomsSection, inviteStates, isInitialSync, log) {
|
||||
const roomStates = [];
|
||||
const archivedRoomStates = [];
|
||||
if (roomsSection) {
|
||||
const allMemberships = ["join", "leave"];
|
||||
for(const membership of allMemberships) {
|
||||
|
@ -349,28 +363,71 @@ export class Sync {
|
|||
if (isInitialSync && timelineIsEmpty(roomResponse)) {
|
||||
continue;
|
||||
}
|
||||
let isNewRoom = false;
|
||||
let room = this._session.rooms.get(roomId);
|
||||
// don't create a room for a rejected invite
|
||||
if (!room && membership === "join") {
|
||||
room = this._session.createRoom(roomId);
|
||||
isNewRoom = true;
|
||||
}
|
||||
const invite = this._session.invites.get(roomId);
|
||||
// if there is an existing invite, add a process state for it
|
||||
// so its writeSync and afterSync will run and remove the invite
|
||||
if (invite) {
|
||||
inviteStates.push(new InviteSyncProcessState(invite, false, null, membership, null));
|
||||
inviteStates.push(new InviteSyncProcessState(invite, false, null, membership));
|
||||
}
|
||||
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) {
|
||||
roomStates.push(new RoomSyncProcessState(
|
||||
room, isNewRoom, invite, roomResponse, membership));
|
||||
return new RoomSyncProcessState(
|
||||
room, isNewRoom, invite, roomResponse, membership);
|
||||
}
|
||||
}
|
||||
|
||||
async _createArchivedRoomSyncState(roomId, roomState, roomResponse, membership, isInitialSync, log) {
|
||||
let archivedRoom;
|
||||
if (roomState?.shouldAdd && !isInitialSync) {
|
||||
// when adding a joined room during incremental sync,
|
||||
// always create the archived room to write the removal
|
||||
// of the archived summary
|
||||
archivedRoom = this._session.createOrGetArchivedRoomForSync(roomId);
|
||||
} else if (membership === "leave") {
|
||||
if (roomState) {
|
||||
// we still have a roomState, so we just left it
|
||||
// in this case, create a new archivedRoom
|
||||
archivedRoom = this._session.createOrGetArchivedRoomForSync(roomId);
|
||||
} else {
|
||||
// this is an update of an already left room, restore
|
||||
// it from storage first, so we can increment it.
|
||||
// this happens for example when our membership changes
|
||||
// after leaving (e.g. being (un)banned, possibly after being kicked), etc
|
||||
archivedRoom = await this._session.loadArchivedRoom(roomId, log);
|
||||
}
|
||||
}
|
||||
if (archivedRoom) {
|
||||
return new ArchivedRoomSyncProcessState(
|
||||
archivedRoom, roomState, roomResponse, membership);
|
||||
}
|
||||
return roomStates;
|
||||
}
|
||||
|
||||
_parseInvites(roomsSection) {
|
||||
|
@ -383,8 +440,7 @@ export class Sync {
|
|||
invite = this._session.createInvite(roomId);
|
||||
isNewInvite = true;
|
||||
}
|
||||
const room = this._session.rooms.get(roomId);
|
||||
inviteStates.push(new InviteSyncProcessState(invite, isNewInvite, room, "invite", roomResponse));
|
||||
inviteStates.push(new InviteSyncProcessState(invite, isNewInvite, roomResponse, "invite"));
|
||||
}
|
||||
}
|
||||
return inviteStates;
|
||||
|
@ -425,15 +481,66 @@ class RoomSyncProcessState {
|
|||
this.preparation = null;
|
||||
this.changes = null;
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this.room.id;
|
||||
}
|
||||
|
||||
get shouldAdd() {
|
||||
return this.isNewRoom && this.membership === "join";
|
||||
}
|
||||
|
||||
get shouldRemove() {
|
||||
return !this.isNewRoom && this.membership !== "join";
|
||||
}
|
||||
|
||||
get summaryChanges() {
|
||||
return this.changes?.summaryChanges;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ArchivedRoomSyncProcessState {
|
||||
constructor(archivedRoom, roomState, roomResponse, membership, isInitialSync) {
|
||||
this.archivedRoom = archivedRoom;
|
||||
this.roomState = roomState;
|
||||
this.roomResponse = roomResponse;
|
||||
this.membership = membership;
|
||||
this.isInitialSync = isInitialSync;
|
||||
this.changes = null;
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this.archivedRoom.id;
|
||||
}
|
||||
|
||||
get shouldAdd() {
|
||||
return (this.roomState || this.isInitialSync) && this.membership === "leave";
|
||||
}
|
||||
|
||||
get shouldRemove() {
|
||||
return this.membership === "join";
|
||||
}
|
||||
}
|
||||
|
||||
class InviteSyncProcessState {
|
||||
constructor(invite, isNewInvite, room, membership, roomResponse) {
|
||||
constructor(invite, isNewInvite, roomResponse, membership) {
|
||||
this.invite = invite;
|
||||
this.isNewInvite = isNewInvite;
|
||||
this.room = room;
|
||||
this.membership = membership;
|
||||
this.roomResponse = roomResponse;
|
||||
this.changes = null;
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this.invite.id;
|
||||
}
|
||||
|
||||
get shouldAdd() {
|
||||
return this.isNewInvite;
|
||||
}
|
||||
|
||||
get shouldRemove() {
|
||||
return this.membership !== "invite";
|
||||
}
|
||||
}
|
||||
|
|
|
@ -121,26 +121,40 @@ export class DeviceTracker {
|
|||
}
|
||||
}
|
||||
|
||||
async _applyMemberChange(memberChange, txn) {
|
||||
// TODO: depends whether we encrypt for invited users??
|
||||
// add room
|
||||
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);
|
||||
async _removeRoomFromUserIdentity(roomId, userId, txn) {
|
||||
const {userIdentities, deviceIdentities} = txn;
|
||||
const identity = await userIdentities.get(userId);
|
||||
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
|
||||
if (identity.roomIds.length === 0) {
|
||||
userIdentities.remove(identity.userId);
|
||||
userIdentities.remove(userId);
|
||||
deviceIdentities.removeAllForUser(userId);
|
||||
} else {
|
||||
userIdentities.set(identity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _applyMemberChange(memberChange, txn) {
|
||||
// TODO: depends whether we encrypt for invited users??
|
||||
// add room
|
||||
if (memberChange.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) {
|
||||
|
|
|
@ -83,8 +83,9 @@ export class RoomEncryption {
|
|||
}
|
||||
|
||||
async writeMemberChanges(memberChanges, txn, log) {
|
||||
let shouldFlush;
|
||||
let shouldFlush = false;
|
||||
const memberChangesArray = Array.from(memberChanges.values());
|
||||
// this also clears our session if we leave the room ourselves
|
||||
if (memberChangesArray.some(m => m.hasLeft)) {
|
||||
log.log({
|
||||
l: "discardOutboundSession",
|
||||
|
|
193
src/matrix/room/ArchivedRoom.js
Normal file
193
src/matrix/room/ArchivedRoom.js
Normal 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
482
src/matrix/room/BaseRoom.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -152,7 +152,8 @@ export class Invite extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
afterSync(changes) {
|
||||
afterSync(changes, log) {
|
||||
log.set("id", this.id);
|
||||
if (changes) {
|
||||
if (changes.removed) {
|
||||
this._accepting = false;
|
||||
|
@ -162,16 +163,11 @@ export class Invite extends EventEmitter {
|
|||
} else {
|
||||
this._rejected = true;
|
||||
}
|
||||
// important to remove before emitting change
|
||||
// so code checking session.invites.get(id) won't
|
||||
// find the invite anymore on update
|
||||
this._emitCollectionRemove(this);
|
||||
this.emit("change");
|
||||
} else {
|
||||
// no emit change, adding to the collection is done by sync
|
||||
this._inviteData = changes.inviteData;
|
||||
this._inviter = changes.inviter;
|
||||
// sync will add the invite to the collection by
|
||||
// calling session.addInviteAfterSync
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -277,7 +273,7 @@ export function tests() {
|
|||
const txn = createStorage();
|
||||
const changes = await invite.writeSync("invite", roomInviteFixture, txn, new NullLogItem());
|
||||
assert.equal(txn.invitesMap.get(roomId).roomId, roomId);
|
||||
invite.afterSync(changes);
|
||||
invite.afterSync(changes, new NullLogItem());
|
||||
assert.equal(invite.name, "Invite example");
|
||||
assert.equal(invite.avatarUrl, roomAvatarUrl);
|
||||
assert.equal(invite.isPublic, false);
|
||||
|
@ -298,7 +294,7 @@ export function tests() {
|
|||
const txn = createStorage();
|
||||
const changes = await invite.writeSync("invite", dmInviteFixture, txn, new NullLogItem());
|
||||
assert.equal(txn.invitesMap.get(roomId).roomId, roomId);
|
||||
invite.afterSync(changes);
|
||||
invite.afterSync(changes, new NullLogItem());
|
||||
assert.equal(invite.name, "Alice");
|
||||
assert.equal(invite.avatarUrl, aliceAvatarUrl);
|
||||
assert.equal(invite.timestamp, 1003);
|
||||
|
@ -329,28 +325,25 @@ export function tests() {
|
|||
assert.equal(invite.inviter.displayName, "Alice");
|
||||
assert.equal(invite.inviter.avatarUrl, aliceAvatarUrl);
|
||||
},
|
||||
"syncing with membership from invite removes the invite": async assert => {
|
||||
let removedEmitted = false;
|
||||
"syncing join sets accepted": async assert => {
|
||||
let changeEmitCount = 0;
|
||||
const invite = new Invite({
|
||||
roomId,
|
||||
platform: {clock: new MockClock(1003)},
|
||||
user: {id: "@bob:hs.tld"},
|
||||
emitCollectionRemove: emittingInvite => {
|
||||
assert.equal(emittingInvite, invite);
|
||||
removedEmitted = true;
|
||||
}
|
||||
});
|
||||
invite.on("change", () => { changeEmitCount += 1; });
|
||||
const txn = createStorage();
|
||||
const changes = await invite.writeSync("invite", dmInviteFixture, txn, new NullLogItem());
|
||||
assert.equal(txn.invitesMap.get(roomId).roomId, roomId);
|
||||
invite.afterSync(changes);
|
||||
invite.afterSync(changes, new NullLogItem());
|
||||
const joinChanges = await invite.writeSync("join", null, txn, new NullLogItem());
|
||||
assert(!removedEmitted);
|
||||
invite.afterSync(joinChanges);
|
||||
assert.strictEqual(changeEmitCount, 0);
|
||||
invite.afterSync(joinChanges, new NullLogItem());
|
||||
assert.strictEqual(changeEmitCount, 1);
|
||||
assert.equal(txn.invitesMap.get(roomId), undefined);
|
||||
assert.equal(invite.rejected, false);
|
||||
assert.equal(invite.accepted, true);
|
||||
assert(removedEmitted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,180 +14,30 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {EventEmitter} from "../../utils/EventEmitter.js";
|
||||
import {RoomSummary} from "./RoomSummary.js";
|
||||
import {BaseRoom} from "./BaseRoom.js";
|
||||
import {SyncWriter} from "./timeline/persistence/SyncWriter.js";
|
||||
import {GapWriter} from "./timeline/persistence/GapWriter.js";
|
||||
import {Timeline} from "./timeline/Timeline.js";
|
||||
import {FragmentIdComparer} from "./timeline/FragmentIdComparer.js";
|
||||
import {SendQueue} from "./sending/SendQueue.js";
|
||||
import {WrappedError} from "../error.js"
|
||||
import {fetchOrLoadMembers} from "./members/load.js";
|
||||
import {MemberList} from "./members/MemberList.js";
|
||||
import {Heroes} from "./members/Heroes.js";
|
||||
import {EventEntry} from "./timeline/entries/EventEntry.js";
|
||||
import {ObservedEventMap} from "./ObservedEventMap.js";
|
||||
import {AttachmentUpload} from "./AttachmentUpload.js";
|
||||
import {DecryptionSource} from "../e2ee/common.js";
|
||||
import {ensureLogItem} from "../../logging/utils.js";
|
||||
|
||||
const EVENT_ENCRYPTED_TYPE = "m.room.encrypted";
|
||||
|
||||
export class Room extends EventEmitter {
|
||||
constructor({roomId, storage, hsApi, mediaRepository, emitCollectionChange, pendingEvents, user, createRoomEncryption, getSyncToken, platform}) {
|
||||
super();
|
||||
this._roomId = roomId;
|
||||
this._storage = storage;
|
||||
this._hsApi = hsApi;
|
||||
this._mediaRepository = mediaRepository;
|
||||
this._summary = new RoomSummary(roomId);
|
||||
this._fragmentIdComparer = new FragmentIdComparer([]);
|
||||
this._syncWriter = new SyncWriter({roomId, fragmentIdComparer: this._fragmentIdComparer});
|
||||
this._emitCollectionChange = emitCollectionChange;
|
||||
this._sendQueue = new SendQueue({roomId, storage, hsApi, pendingEvents});
|
||||
this._timeline = null;
|
||||
this._user = user;
|
||||
this._changedMembersDuringSync = null;
|
||||
this._memberList = null;
|
||||
this._createRoomEncryption = createRoomEncryption;
|
||||
this._roomEncryption = null;
|
||||
this._getSyncToken = getSyncToken;
|
||||
this._platform = platform;
|
||||
this._observedEvents = null;
|
||||
this._invite = null;
|
||||
}
|
||||
|
||||
async _eventIdsToEntries(eventIds, txn) {
|
||||
const retryEntries = [];
|
||||
await Promise.all(eventIds.map(async eventId => {
|
||||
const storageEntry = await txn.timelineEvents.getByEventId(this._roomId, eventId);
|
||||
if (storageEntry) {
|
||||
retryEntries.push(new EventEntry(storageEntry, this._fragmentIdComparer));
|
||||
}
|
||||
}));
|
||||
return retryEntries;
|
||||
}
|
||||
|
||||
_getAdditionalTimelineRetryEntries(otherRetryEntries, roomKeys) {
|
||||
let retryTimelineEntries = this._roomEncryption.filterUndecryptedEventEntriesForKeys(this._timeline.remoteEntries, roomKeys);
|
||||
// filter out any entries already in retryEntries so we don't decrypt them twice
|
||||
const existingIds = otherRetryEntries.reduce((ids, e) => {ids.add(e.id); return ids;}, new Set());
|
||||
retryTimelineEntries = retryTimelineEntries.filter(e => !existingIds.has(e.id));
|
||||
return retryTimelineEntries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used for retrying decryption from other sources than sync, like key backup.
|
||||
* @internal
|
||||
* @param {RoomKey} roomKey
|
||||
* @param {Array<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();
|
||||
}
|
||||
}
|
||||
export class Room extends BaseRoom {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
const {pendingEvents} = options;
|
||||
this._syncWriter = new SyncWriter({roomId: this.id, fragmentIdComparer: this._fragmentIdComparer});
|
||||
this._sendQueue = new SendQueue({roomId: this.id, storage: this._storage, hsApi: this._hsApi, pendingEvents});
|
||||
}
|
||||
|
||||
_setEncryption(roomEncryption) {
|
||||
if (roomEncryption && !this._roomEncryption) {
|
||||
this._roomEncryption = roomEncryption;
|
||||
if (super._setEncryption(roomEncryption)) {
|
||||
this._sendQueue.enableEncryption(this._roomEncryption);
|
||||
if (this._timeline) {
|
||||
this._timeline.enableEncryption(this._decryptEntries.bind(this, DecryptionSource.Timeline));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Used for decrypting when loading/filling the timeline, and retrying decryption,
|
||||
* not during sync, where it is split up during the multiple phases.
|
||||
*/
|
||||
_decryptEntries(source, entries, inboundSessionTxn, log = null) {
|
||||
const request = new DecryptionRequest(async (r, log) => {
|
||||
if (!inboundSessionTxn) {
|
||||
inboundSessionTxn = await this._storage.readTxn([this._storage.storeNames.inboundGroupSessions]);
|
||||
}
|
||||
if (r.cancelled) return;
|
||||
const events = entries.filter(entry => {
|
||||
return entry.eventType === EVENT_ENCRYPTED_TYPE;
|
||||
}).map(entry => entry.event);
|
||||
r.preparation = await this._roomEncryption.prepareDecryptAll(events, null, source, inboundSessionTxn);
|
||||
if (r.cancelled) return;
|
||||
const changes = await r.preparation.decrypt();
|
||||
r.preparation = null;
|
||||
if (r.cancelled) return;
|
||||
const stores = [this._storage.storeNames.groupSessionDecryptions];
|
||||
const isTimelineOpen = this._isTimelineOpen;
|
||||
if (isTimelineOpen) {
|
||||
// read to fetch devices if timeline is open
|
||||
stores.push(this._storage.storeNames.deviceIdentities);
|
||||
}
|
||||
const writeTxn = await this._storage.readWriteTxn(stores);
|
||||
let decryption;
|
||||
try {
|
||||
decryption = await changes.write(writeTxn, log);
|
||||
if (isTimelineOpen) {
|
||||
await decryption.verifySenders(writeTxn);
|
||||
}
|
||||
} catch (err) {
|
||||
writeTxn.abort();
|
||||
throw err;
|
||||
}
|
||||
await writeTxn.complete();
|
||||
// TODO: log decryption errors here
|
||||
decryption.applyToEntries(entries);
|
||||
if (this._observedEvents) {
|
||||
this._observedEvents.updateEvents(entries);
|
||||
}
|
||||
}, ensureLogItem(log));
|
||||
return request;
|
||||
}
|
||||
|
||||
async _getSyncRetryDecryptEntries(newKeys, roomEncryption, txn) {
|
||||
const entriesPerKey = await Promise.all(newKeys.map(async key => {
|
||||
const retryEventIds = await roomEncryption.getEventIdsForMissingKey(key, txn);
|
||||
if (retryEventIds) {
|
||||
return this._eventIdsToEntries(retryEventIds, txn);
|
||||
}
|
||||
}));
|
||||
let retryEntries = entriesPerKey.reduce((allEntries, entries) => entries ? allEntries.concat(entries) : allEntries, []);
|
||||
// If we have the timeline open, see if there are more entries for the new keys
|
||||
// as we only store missing session information for synced events, not backfilled.
|
||||
// We want to decrypt all events we can though if the user is looking
|
||||
// at them when the timeline is open
|
||||
if (this._timeline) {
|
||||
const retryTimelineEntries = this._getAdditionalTimelineRetryEntries(retryEntries, newKeys);
|
||||
// make copies so we don't modify the original entry in writeSync, before the afterSync stage
|
||||
const retryTimelineEntriesCopies = retryTimelineEntries.map(e => e.clone());
|
||||
// add to other retry entries
|
||||
retryEntries = retryEntries.concat(retryTimelineEntriesCopies);
|
||||
}
|
||||
return retryEntries;
|
||||
return false;
|
||||
}
|
||||
|
||||
async prepareSync(roomResponse, membership, invite, newKeys, txn, log) {
|
||||
|
@ -249,7 +99,13 @@ export class Room extends EventEmitter {
|
|||
/** @package */
|
||||
async writeSync(roomResponse, isInitialSync, {summaryChanges, decryptChanges, roomEncryption, retryEntries}, txn, log) {
|
||||
log.set("id", this.id);
|
||||
const isRejoin = summaryChanges.membership === "join" && this._summary.data.membership === "leave";
|
||||
const isRejoin = summaryChanges.isNewJoin(this._summary.data);
|
||||
if (isRejoin) {
|
||||
// remove all room state before calling syncWriter,
|
||||
// so no old state sticks around
|
||||
txn.roomState.removeAllForRoom(this.id);
|
||||
txn.roomMembers.removeAllForRoom(this.id);
|
||||
}
|
||||
const {entries: newEntries, newLiveKey, memberChanges} =
|
||||
await log.wrap("syncWriter", log => this._syncWriter.writeSync(roomResponse, isRejoin, txn, log), log.level.Detail);
|
||||
let allEntries = newEntries;
|
||||
|
@ -276,8 +132,14 @@ export class Room extends EventEmitter {
|
|||
// also apply (decrypted) timeline entries to the summary changes
|
||||
summaryChanges = summaryChanges.applyTimelineEntries(
|
||||
allEntries, isInitialSync, !this._isTimelineOpen, this._user.id);
|
||||
|
||||
// if we've have left the room, remove the summary
|
||||
if (summaryChanges.membership !== "join") {
|
||||
txn.roomSummary.remove(this.id);
|
||||
} else {
|
||||
// write summary changes, and unset if nothing was actually changed
|
||||
summaryChanges = this._summary.writeData(summaryChanges, txn);
|
||||
}
|
||||
if (summaryChanges) {
|
||||
log.set("summaryChanges", summaryChanges.diff(this._summary.data));
|
||||
}
|
||||
|
@ -345,10 +207,6 @@ export class Room extends EventEmitter {
|
|||
}
|
||||
let emitChange = false;
|
||||
if (summaryChanges) {
|
||||
// if we joined the room, we can't have an invite anymore
|
||||
if (summaryChanges.membership === "join" && this._summary.data.membership !== "join") {
|
||||
this._invite = null;
|
||||
}
|
||||
this._summary.applyChanges(summaryChanges);
|
||||
if (!this._summary.data.needsHeroes) {
|
||||
this._heroes = null;
|
||||
|
@ -413,31 +271,21 @@ export class Room extends EventEmitter {
|
|||
|
||||
/** @package */
|
||||
async load(summary, txn, log) {
|
||||
log.set("id", this.id);
|
||||
try {
|
||||
this._summary.load(summary);
|
||||
if (this._summary.data.encryption) {
|
||||
const roomEncryption = this._createRoomEncryption(this, this._summary.data.encryption);
|
||||
this._setEncryption(roomEncryption);
|
||||
}
|
||||
// need to load members for name?
|
||||
if (this._summary.data.needsHeroes) {
|
||||
this._heroes = new Heroes(this._roomId);
|
||||
const changes = await this._heroes.calculateChanges(this._summary.data.heroes, [], txn);
|
||||
this._heroes.applyChanges(changes, this._summary.data);
|
||||
}
|
||||
return this._syncWriter.load(txn, log);
|
||||
super.load(summary, txn, log);
|
||||
this._syncWriter.load(txn, log);
|
||||
} catch (err) {
|
||||
throw new WrappedError(`Could not load room ${this._roomId}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
setInvite(invite) {
|
||||
// called when an invite comes in for this room
|
||||
// (e.g. when we're in membership leave and haven't been archived or forgotten yet)
|
||||
this._invite = invite;
|
||||
this._emitUpdate();
|
||||
_writeGapFill(gapChunk, txn, log) {
|
||||
const removedPendingEvents = this._sendQueue.removeRemoteEchos(gapChunk, txn, log);
|
||||
return removedPendingEvents;
|
||||
}
|
||||
|
||||
_applyGapFill(removedPendingEvents) {
|
||||
this._sendQueue.emitRemovals(removedPendingEvents);
|
||||
}
|
||||
|
||||
/** @public */
|
||||
|
@ -459,124 +307,6 @@ export class Room extends EventEmitter {
|
|||
});
|
||||
}
|
||||
|
||||
/** @public */
|
||||
async loadMemberList(log = null) {
|
||||
if (this._memberList) {
|
||||
// TODO: also await fetchOrLoadMembers promise here
|
||||
this._memberList.retain();
|
||||
return this._memberList;
|
||||
} else {
|
||||
const members = await fetchOrLoadMembers({
|
||||
summary: this._summary,
|
||||
roomId: this._roomId,
|
||||
hsApi: this._hsApi,
|
||||
storage: this._storage,
|
||||
syncToken: this._getSyncToken(),
|
||||
// to handle race between /members and /sync
|
||||
setChangedMembersMap: map => this._changedMembersDuringSync = map,
|
||||
log,
|
||||
}, this._platform.logger);
|
||||
this._memberList = new MemberList({
|
||||
members,
|
||||
closeCallback: () => { this._memberList = null; }
|
||||
});
|
||||
return this._memberList;
|
||||
}
|
||||
}
|
||||
|
||||
/** @public */
|
||||
fillGap(fragmentEntry, amount, log = null) {
|
||||
// TODO move some/all of this out of Room
|
||||
return this._platform.logger.wrapOrRun(log, "fillGap", async log => {
|
||||
log.set("id", this.id);
|
||||
log.set("fragment", fragmentEntry.fragmentId);
|
||||
log.set("dir", fragmentEntry.direction.asApiString());
|
||||
if (fragmentEntry.edgeReached) {
|
||||
log.set("edgeReached", true);
|
||||
return;
|
||||
}
|
||||
const response = await this._hsApi.messages(this._roomId, {
|
||||
from: fragmentEntry.token,
|
||||
dir: fragmentEntry.direction.asApiString(),
|
||||
limit: amount,
|
||||
filter: {
|
||||
lazy_load_members: true,
|
||||
include_redundant_members: true,
|
||||
}
|
||||
}, {log}).response();
|
||||
|
||||
const txn = await this._storage.readWriteTxn([
|
||||
this._storage.storeNames.pendingEvents,
|
||||
this._storage.storeNames.timelineEvents,
|
||||
this._storage.storeNames.timelineFragments,
|
||||
]);
|
||||
let removedPendingEvents;
|
||||
let gapResult;
|
||||
try {
|
||||
// detect remote echos of pending messages in the gap
|
||||
removedPendingEvents = this._sendQueue.removeRemoteEchos(response.chunk, txn, log);
|
||||
// write new events into gap
|
||||
const gapWriter = new GapWriter({
|
||||
roomId: this._roomId,
|
||||
storage: this._storage,
|
||||
fragmentIdComparer: this._fragmentIdComparer,
|
||||
});
|
||||
gapResult = await gapWriter.writeFragmentFill(fragmentEntry, response, txn, log);
|
||||
} catch (err) {
|
||||
txn.abort();
|
||||
throw err;
|
||||
}
|
||||
await txn.complete();
|
||||
if (this._roomEncryption) {
|
||||
const decryptRequest = this._decryptEntries(DecryptionSource.Timeline, gapResult.entries, null, log);
|
||||
await decryptRequest.complete();
|
||||
}
|
||||
// once txn is committed, update in-memory state & emit events
|
||||
for (const fragment of gapResult.fragments) {
|
||||
this._fragmentIdComparer.add(fragment);
|
||||
}
|
||||
if (removedPendingEvents) {
|
||||
this._sendQueue.emitRemovals(removedPendingEvents);
|
||||
}
|
||||
if (this._timeline) {
|
||||
this._timeline.addOrReplaceEntries(gapResult.entries);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** @public */
|
||||
get name() {
|
||||
if (this._heroes) {
|
||||
return this._heroes.roomName;
|
||||
}
|
||||
const summaryData = this._summary.data;
|
||||
if (summaryData.name) {
|
||||
return summaryData.name;
|
||||
}
|
||||
if (summaryData.canonicalAlias) {
|
||||
return summaryData.canonicalAlias;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
get id() {
|
||||
return this._roomId;
|
||||
}
|
||||
|
||||
get avatarUrl() {
|
||||
if (this._summary.data.avatarUrl) {
|
||||
return this._summary.data.avatarUrl;
|
||||
} else if (this._heroes) {
|
||||
return this._heroes.roomAvatarUrl;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
get lastMessageTimestamp() {
|
||||
return this._summary.data.lastMessageTimestamp;
|
||||
}
|
||||
|
||||
get isUnread() {
|
||||
return this._summary.data.isUnread;
|
||||
}
|
||||
|
@ -589,40 +319,6 @@ export class Room extends EventEmitter {
|
|||
return this._summary.data.highlightCount;
|
||||
}
|
||||
|
||||
get isLowPriority() {
|
||||
const tags = this._summary.data.tags;
|
||||
return !!(tags && tags['m.lowpriority']);
|
||||
}
|
||||
|
||||
get isEncrypted() {
|
||||
return !!this._summary.data.encryption;
|
||||
}
|
||||
|
||||
get membership() {
|
||||
return this._summary.data.membership;
|
||||
}
|
||||
|
||||
/**
|
||||
* The invite for this room, if any.
|
||||
* This will only be set if you've left a room, and
|
||||
* don't archive or forget it, and then receive an invite
|
||||
* for it again
|
||||
* @return {Invite?}
|
||||
*/
|
||||
get invite() {
|
||||
return this._invite;
|
||||
}
|
||||
|
||||
enableSessionBackup(sessionBackup) {
|
||||
this._roomEncryption?.enableSessionBackup(sessionBackup);
|
||||
// TODO: do we really want to do this every time you open the app?
|
||||
if (this._timeline) {
|
||||
this._platform.logger.run("enableSessionBackup", log => {
|
||||
return this._roomEncryption.restoreMissingSessionsFromBackup(this._timeline.remoteEntries, log);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get isTrackingMembers() {
|
||||
return this._summary.data.isTrackingMembers;
|
||||
}
|
||||
|
@ -638,17 +334,6 @@ export class Room extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
get _isTimelineOpen() {
|
||||
return !!this._timeline;
|
||||
}
|
||||
|
||||
_emitUpdate() {
|
||||
// once for event emitter listeners
|
||||
this.emit("change");
|
||||
// and once for collection listeners
|
||||
this._emitCollectionChange(this);
|
||||
}
|
||||
|
||||
async clearUnread(log = null) {
|
||||
if (this.isUnread || this.notificationCount) {
|
||||
return await this._platform.logger.wrapOrRun(log, "clearUnread", async log => {
|
||||
|
@ -682,37 +367,9 @@ export class Room extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
/** @public */
|
||||
openTimeline(log = null) {
|
||||
return this._platform.logger.wrapOrRun(log, "open timeline", async log => {
|
||||
log.set("id", this.id);
|
||||
if (this._timeline) {
|
||||
throw new Error("not dealing with load race here for now");
|
||||
}
|
||||
this._timeline = new Timeline({
|
||||
roomId: this.id,
|
||||
storage: this._storage,
|
||||
fragmentIdComparer: this._fragmentIdComparer,
|
||||
pendingEvents: this._sendQueue.pendingEvents,
|
||||
closeCallback: () => {
|
||||
this._timeline = null;
|
||||
if (this._roomEncryption) {
|
||||
this._roomEncryption.notifyTimelineClosed();
|
||||
}
|
||||
},
|
||||
clock: this._platform.clock,
|
||||
logger: this._platform.logger,
|
||||
});
|
||||
if (this._roomEncryption) {
|
||||
this._timeline.enableEncryption(this._decryptEntries.bind(this, DecryptionSource.Timeline));
|
||||
}
|
||||
await this._timeline.load(this._user, this._summary.data.membership, log);
|
||||
return this._timeline;
|
||||
});
|
||||
}
|
||||
|
||||
get mediaRepository() {
|
||||
return this._mediaRepository;
|
||||
/* called by BaseRoom to pass pendingEvents when opening the timeline */
|
||||
_getPendingEvents() {
|
||||
return this._sendQueue.pendingEvents;
|
||||
}
|
||||
|
||||
/** @package */
|
||||
|
@ -725,75 +382,12 @@ export class Room extends EventEmitter {
|
|||
this._summary.applyChanges(changes);
|
||||
}
|
||||
|
||||
observeEvent(eventId) {
|
||||
if (!this._observedEvents) {
|
||||
this._observedEvents = new ObservedEventMap(() => {
|
||||
this._observedEvents = null;
|
||||
});
|
||||
}
|
||||
let entry = null;
|
||||
if (this._timeline) {
|
||||
entry = this._timeline.getByEventId(eventId);
|
||||
}
|
||||
const observable = this._observedEvents.observe(eventId, entry);
|
||||
if (!entry) {
|
||||
// update in the background
|
||||
this._readEventById(eventId).then(entry => {
|
||||
observable.update(entry);
|
||||
}).catch(err => {
|
||||
console.warn(`could not load event ${eventId} from storage`, err);
|
||||
});
|
||||
}
|
||||
return observable;
|
||||
}
|
||||
|
||||
async _readEventById(eventId) {
|
||||
let stores = [this._storage.storeNames.timelineEvents];
|
||||
if (this.isEncrypted) {
|
||||
stores.push(this._storage.storeNames.inboundGroupSessions);
|
||||
}
|
||||
const txn = await this._storage.readTxn(stores);
|
||||
const storageEntry = await txn.timelineEvents.getByEventId(this._roomId, eventId);
|
||||
if (storageEntry) {
|
||||
const entry = new EventEntry(storageEntry, this._fragmentIdComparer);
|
||||
if (entry.eventType === EVENT_ENCRYPTED_TYPE) {
|
||||
const request = this._decryptEntries(DecryptionSource.Timeline, [entry], txn);
|
||||
await request.complete();
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
createAttachment(blob, filename) {
|
||||
return new AttachmentUpload({blob, filename, platform: this._platform});
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._roomEncryption?.dispose();
|
||||
this._timeline?.dispose();
|
||||
super.dispose();
|
||||
this._sendQueue.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class DecryptionRequest {
|
||||
constructor(decryptFn, log) {
|
||||
this._cancelled = false;
|
||||
this.preparation = null;
|
||||
this._promise = log.wrap("decryptEntries", log => decryptFn(this, log));
|
||||
}
|
||||
|
||||
complete() {
|
||||
return this._promise;
|
||||
}
|
||||
|
||||
get cancelled() {
|
||||
return this._cancelled;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._cancelled = true;
|
||||
if (this.preparation) {
|
||||
this.preparation.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
51
src/matrix/room/RoomStatus.js
Normal file
51
src/matrix/room/RoomStatus.js
Normal 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);
|
|
@ -27,6 +27,24 @@ function applyTimelineEntries(data, timelineEntries, isInitialSync, canMarkUnrea
|
|||
return data;
|
||||
}
|
||||
|
||||
export function reduceStateEvents(roomResponse, callback, value) {
|
||||
const stateEvents = roomResponse?.state?.events;
|
||||
// state comes before timeline
|
||||
if (Array.isArray(stateEvents)) {
|
||||
value = stateEvents.reduce(callback, value);
|
||||
}
|
||||
const timelineEvents = roomResponse?.timeline?.events;
|
||||
// and after that state events in the timeline
|
||||
if (Array.isArray(timelineEvents)) {
|
||||
value = timelineEvents.reduce((data, event) => {
|
||||
if (typeof event.state_key === "string") {
|
||||
value = callback(value, event);
|
||||
}
|
||||
return value;
|
||||
}, value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function applySyncResponse(data, roomResponse, membership) {
|
||||
if (roomResponse.summary) {
|
||||
|
@ -39,25 +57,19 @@ function applySyncResponse(data, roomResponse, membership) {
|
|||
if (roomResponse.account_data) {
|
||||
data = roomResponse.account_data.events.reduce(processRoomAccountData, data);
|
||||
}
|
||||
const stateEvents = roomResponse?.state?.events;
|
||||
// state comes before timeline
|
||||
if (Array.isArray(stateEvents)) {
|
||||
data = stateEvents.reduce(processStateEvent, data);
|
||||
}
|
||||
const timelineEvents = roomResponse?.timeline?.events;
|
||||
// process state events in timeline
|
||||
// process state events in state and in timeline.
|
||||
// non-state events are handled by applyTimelineEntries
|
||||
// so decryption is handled properly
|
||||
if (Array.isArray(timelineEvents)) {
|
||||
data = timelineEvents.reduce((data, event) => {
|
||||
if (typeof event.state_key === "string") {
|
||||
return processStateEvent(data, event);
|
||||
}
|
||||
return data;
|
||||
}, data);
|
||||
}
|
||||
data = reduceStateEvents(roomResponse, processStateEvent, data);
|
||||
const unreadNotifications = roomResponse.unread_notifications;
|
||||
if (unreadNotifications) {
|
||||
data = processNotificationCounts(data, unreadNotifications);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function processNotificationCounts(data, unreadNotifications) {
|
||||
const highlightCount = unreadNotifications.highlight_count || 0;
|
||||
if (highlightCount !== data.highlightCount) {
|
||||
data = data.cloneIfNeeded();
|
||||
|
@ -68,8 +80,6 @@ function applySyncResponse(data, roomResponse, membership) {
|
|||
data = data.cloneIfNeeded();
|
||||
data.notificationCount = notificationCount;
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
|
@ -152,10 +162,11 @@ function applyInvite(data, invite) {
|
|||
if (data.isDirectMessage !== invite.isDirectMessage) {
|
||||
data = data.cloneIfNeeded();
|
||||
data.isDirectMessage = invite.isDirectMessage;
|
||||
}
|
||||
if (data.dmUserId !== invite.inviter?.userId) {
|
||||
data = data.cloneIfNeeded();
|
||||
if (invite.isDirectMessage) {
|
||||
data.dmUserId = invite.inviter?.userId;
|
||||
} else {
|
||||
data.dmUserId = null;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
@ -204,8 +215,12 @@ export class SummaryData {
|
|||
}
|
||||
|
||||
serialize() {
|
||||
const {cloned, ...serializedProps} = this;
|
||||
return serializedProps;
|
||||
return Object.entries(this).reduce((obj, [key, value]) => {
|
||||
if (key !== "cloned" && value !== null) {
|
||||
obj[key] = value;
|
||||
}
|
||||
return obj;
|
||||
}, {});
|
||||
}
|
||||
|
||||
applyTimelineEntries(timelineEntries, isInitialSync, canMarkUnread, ownUserId) {
|
||||
|
@ -223,6 +238,10 @@ export class SummaryData {
|
|||
get needsHeroes() {
|
||||
return !this.name && !this.canonicalAlias && this.heroes && this.heroes.length > 0;
|
||||
}
|
||||
|
||||
isNewJoin(oldData) {
|
||||
return this.membership === "join" && oldData.membership !== "join";
|
||||
}
|
||||
}
|
||||
|
||||
export class RoomSummary {
|
||||
|
@ -265,6 +284,14 @@ export class RoomSummary {
|
|||
}
|
||||
}
|
||||
|
||||
/** move summary to archived store when leaving the room */
|
||||
writeArchivedData(data, txn) {
|
||||
if (data !== this._data) {
|
||||
txn.archivedRoomSummary.set(data.serialize());
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
async writeAndApplyData(data, storage) {
|
||||
if (data === this._data) {
|
||||
return false;
|
||||
|
@ -297,15 +324,15 @@ export class RoomSummary {
|
|||
|
||||
export function tests() {
|
||||
return {
|
||||
"membership trigger change": function(assert) {
|
||||
const summary = new RoomSummary("id");
|
||||
let written = false;
|
||||
let changes = summary.data.applySyncResponse({}, "join");
|
||||
const txn = {roomSummary: {set: () => { written = true; }}};
|
||||
changes = summary.writeData(changes, txn);
|
||||
assert(changes);
|
||||
assert(written);
|
||||
assert.equal(changes.membership, "join");
|
||||
"serialize doesn't include null fields or cloned": assert => {
|
||||
const roomId = "!123:hs.tld";
|
||||
const data = new SummaryData(null, roomId);
|
||||
const clone = data.cloneIfNeeded();
|
||||
const serialized = clone.serialize();
|
||||
assert.strictEqual(serialized.cloned, undefined);
|
||||
assert.equal(serialized.roomId, roomId);
|
||||
const nullCount = Object.values(serialized).reduce((count, value) => count + value === null ? 1 : 0, 0);
|
||||
assert.strictEqual(nullCount, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,15 +15,15 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import {ObservableMap} from "../../../observable/map/ObservableMap.js";
|
||||
import {RetainedValue} from "../../../utils/RetainedValue.js";
|
||||
|
||||
export class MemberList {
|
||||
export class MemberList extends RetainedValue {
|
||||
constructor({members, closeCallback}) {
|
||||
super(closeCallback);
|
||||
this._members = new ObservableMap();
|
||||
for (const member of members) {
|
||||
this._members.add(member.userId, member);
|
||||
}
|
||||
this._closeCallback = closeCallback;
|
||||
this._retentionCount = 1;
|
||||
}
|
||||
|
||||
afterSync(memberChanges) {
|
||||
|
@ -35,15 +35,4 @@ export class MemberList {
|
|||
get members() {
|
||||
return this._members;
|
||||
}
|
||||
|
||||
retain() {
|
||||
this._retentionCount += 1;
|
||||
}
|
||||
|
||||
release() {
|
||||
this._retentionCount -= 1;
|
||||
if (this._retentionCount === 0) {
|
||||
this._closeCallback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {SortedArray, MappedList, ConcatList} from "../../../observable/index.js";
|
||||
import {SortedArray, MappedList, ConcatList, ObservableArray} from "../../../observable/index.js";
|
||||
import {Disposables} from "../../../utils/Disposables.js";
|
||||
import {Direction} from "./Direction.js";
|
||||
import {TimelineReader} from "./persistence/TimelineReader.js";
|
||||
|
@ -36,11 +36,16 @@ export class Timeline {
|
|||
fragmentIdComparer: this._fragmentIdComparer
|
||||
});
|
||||
this._readerRequest = null;
|
||||
const localEntries = new MappedList(pendingEvents, pe => {
|
||||
let localEntries;
|
||||
if (pendingEvents) {
|
||||
localEntries = new MappedList(pendingEvents, pe => {
|
||||
return new PendingEventEntry({pendingEvent: pe, member: this._ownMember, clock});
|
||||
}, (pee, params) => {
|
||||
pee.notifyUpdate(params);
|
||||
});
|
||||
} else {
|
||||
localEntries = new ObservableArray();
|
||||
}
|
||||
this._allEntries = new ConcatList(this._remoteEntries, localEntries);
|
||||
}
|
||||
|
||||
|
|
|
@ -200,13 +200,17 @@ export class SyncWriter {
|
|||
const index = events.findIndex(event => event.event_id === lastEventId);
|
||||
if (index !== -1) {
|
||||
log.set("overlap_event_id", lastEventId);
|
||||
return {
|
||||
return Object.assign({}, timeline, {
|
||||
limited: false,
|
||||
events: events.slice(index + 1)
|
||||
};
|
||||
events: events.slice(index + 1),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!timeline.limited) {
|
||||
log.set("force_limited_without_overlap", true);
|
||||
return Object.assign({}, timeline, {limited: true});
|
||||
}
|
||||
return timeline;
|
||||
}
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@ export const STORE_NAMES = Object.freeze([
|
|||
"session",
|
||||
"roomState",
|
||||
"roomSummary",
|
||||
"archivedRoomSummary",
|
||||
"invites",
|
||||
"roomMembers",
|
||||
"timelineEvents",
|
||||
|
|
|
@ -65,6 +65,10 @@ export class Transaction {
|
|||
return this._store("roomSummary", idbStore => new RoomSummaryStore(idbStore));
|
||||
}
|
||||
|
||||
get archivedRoomSummary() {
|
||||
return this._store("archivedRoomSummary", idbStore => new RoomSummaryStore(idbStore));
|
||||
}
|
||||
|
||||
get invites() {
|
||||
return this._store("invites", idbStore => new InviteStore(idbStore));
|
||||
}
|
||||
|
|
|
@ -42,7 +42,7 @@ export class IDBError extends StorageError {
|
|||
|
||||
export class IDBRequestError extends IDBError {
|
||||
constructor(request, message = "IDBRequest failed") {
|
||||
const source = request?.source;
|
||||
const source = request.source;
|
||||
const cause = request.error;
|
||||
super(message, source, cause);
|
||||
}
|
||||
|
|
|
@ -12,7 +12,8 @@ export const schema = [
|
|||
createE2EEStores,
|
||||
migrateEncryptionFlag,
|
||||
createAccountDataStore,
|
||||
createInviteStore
|
||||
createInviteStore,
|
||||
createArchivedRoomSummaryStore,
|
||||
];
|
||||
// TODO: how to deal with git merge conflicts of this array?
|
||||
|
||||
|
@ -109,3 +110,8 @@ function createAccountDataStore(db) {
|
|||
function createInviteStore(db) {
|
||||
db.createObjectStore("invites", {keyPath: "roomId"});
|
||||
}
|
||||
|
||||
// v8
|
||||
function createArchivedRoomSummaryStore(db) {
|
||||
db.createObjectStore("archivedRoomSummary", {keyPath: "summary.roomId"});
|
||||
}
|
|
@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {MAX_UNICODE, MIN_UNICODE} from "./common.js";
|
||||
|
||||
function encodeKey(userId, deviceId) {
|
||||
return `${userId}|${deviceId}`;
|
||||
}
|
||||
|
@ -66,4 +68,11 @@ export class DeviceIdentityStore {
|
|||
remove(userId, deviceId) {
|
||||
this._store.delete(encodeKey(userId, deviceId));
|
||||
}
|
||||
|
||||
removeAllForUser(userId) {
|
||||
// exclude both keys as they are theoretical min and max,
|
||||
// but we should't have a match for just the room id, or room id with max
|
||||
const range = IDBKeyRange.bound(encodeKey(userId, MIN_UNICODE), encodeKey(userId, MAX_UNICODE), true, true);
|
||||
this._store.delete(range);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
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");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -15,6 +15,8 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {MAX_UNICODE} from "./common.js";
|
||||
|
||||
function encodeKey(roomId, userId) {
|
||||
return `${roomId}|${userId}`;
|
||||
}
|
||||
|
@ -60,4 +62,11 @@ export class RoomMemberStore {
|
|||
});
|
||||
return userIds;
|
||||
}
|
||||
|
||||
removeAllForRoom(roomId) {
|
||||
// exclude both keys as they are theoretical min and max,
|
||||
// but we should't have a match for just the room id, or room id with max
|
||||
const range = IDBKeyRange.bound(roomId, `${roomId}|${MAX_UNICODE}`, true, true);
|
||||
this._roomMembersStore.delete(range);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
/*
|
||||
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");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -14,17 +15,19 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {MAX_UNICODE} from "./common.js";
|
||||
|
||||
export class RoomStateStore {
|
||||
constructor(idbStore) {
|
||||
this._roomStateStore = idbStore;
|
||||
}
|
||||
|
||||
async getAllForType(type) {
|
||||
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
|
||||
async get(type, stateKey) {
|
||||
|
||||
throw new Error("unimplemented");
|
||||
}
|
||||
|
||||
async set(roomId, event) {
|
||||
|
@ -32,4 +35,11 @@ export class RoomStateStore {
|
|||
const entry = {roomId, event, key};
|
||||
return this._roomStateStore.put(entry);
|
||||
}
|
||||
|
||||
removeAllForRoom(roomId) {
|
||||
// exclude both keys as they are theoretical min and max,
|
||||
// but we should't have a match for just the room id, or room id with max
|
||||
const range = IDBKeyRange.bound(roomId, `${roomId}|${MAX_UNICODE}`, true, true);
|
||||
this._roomStateStore.delete(range);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,6 +27,8 @@ store contains:
|
|||
inviteCount
|
||||
joinCount
|
||||
*/
|
||||
|
||||
/** Used for both roomSummary and archivedRoomSummary stores */
|
||||
export class RoomSummaryStore {
|
||||
constructor(summaryStore) {
|
||||
this._summaryStore = summaryStore;
|
||||
|
@ -39,4 +41,17 @@ export class RoomSummaryStore {
|
|||
set(summary) {
|
||||
return this._summaryStore.put(summary);
|
||||
}
|
||||
|
||||
get(roomId) {
|
||||
return this._summaryStore.get(roomId);
|
||||
}
|
||||
|
||||
async has(roomId) {
|
||||
const fetchedKey = await this._summaryStore.getKey(roomId);
|
||||
return roomId === fetchedKey;
|
||||
}
|
||||
|
||||
remove(roomId) {
|
||||
return this._summaryStore.delete(roomId);
|
||||
}
|
||||
}
|
||||
|
|
18
src/matrix/storage/idb/stores/common.js
Normal file
18
src/matrix/storage/idb/stores/common.js
Normal 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}";
|
|
@ -81,21 +81,37 @@ export function reqAsPromise(req) {
|
|||
resolve(event.target.result);
|
||||
needsSyncPromise && Promise._flush && Promise._flush();
|
||||
});
|
||||
req.addEventListener("error", () => {
|
||||
reject(new IDBRequestError(req));
|
||||
req.addEventListener("error", event => {
|
||||
const error = new IDBRequestError(event.target);
|
||||
reject(error);
|
||||
needsSyncPromise && Promise._flush && Promise._flush();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function txnAsPromise(txn) {
|
||||
let error;
|
||||
return new Promise((resolve, reject) => {
|
||||
txn.addEventListener("complete", () => {
|
||||
resolve();
|
||||
needsSyncPromise && Promise._flush && Promise._flush();
|
||||
});
|
||||
txn.addEventListener("abort", () => {
|
||||
reject(new IDBRequestError(txn));
|
||||
txn.addEventListener("error", event => {
|
||||
const request = event.target;
|
||||
// catch first error here, but don't reject yet,
|
||||
// as we don't have access to the failed request in the abort event handler
|
||||
if (!error && request) {
|
||||
error = new IDBRequestError(request);
|
||||
}
|
||||
});
|
||||
txn.addEventListener("abort", event => {
|
||||
if (!error) {
|
||||
const txn = event.target;
|
||||
const dbName = txn.db.name;
|
||||
const storeNames = Array.from(txn.objectStoreNames).join(", ")
|
||||
error = new StorageError(`Transaction on ${dbName} with stores ${storeNames} was aborted.`);
|
||||
}
|
||||
reject(error);
|
||||
needsSyncPromise && Promise._flush && Promise._flush();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -48,6 +48,13 @@ export class BaseObservable {
|
|||
return null;
|
||||
}
|
||||
|
||||
unsubscribeAll() {
|
||||
if (this._handlers.size !== 0) {
|
||||
this._handlers.clear();
|
||||
this.onUnsubscribeLast();
|
||||
}
|
||||
}
|
||||
|
||||
get hasSubscriptions() {
|
||||
return this._handlers.size !== 0;
|
||||
}
|
||||
|
|
|
@ -94,6 +94,18 @@ export class ObservableValue extends BaseObservableValue {
|
|||
}
|
||||
}
|
||||
|
||||
export class RetainedObservableValue extends ObservableValue {
|
||||
constructor(initialValue, freeCallback) {
|
||||
super(initialValue);
|
||||
this._freeCallback = freeCallback;
|
||||
}
|
||||
|
||||
onUnsubscribeLast() {
|
||||
super.onUnsubscribeLast();
|
||||
this._freeCallback();
|
||||
}
|
||||
}
|
||||
|
||||
export function tests() {
|
||||
return {
|
||||
"set emits an update": assert => {
|
||||
|
|
|
@ -897,3 +897,12 @@ button.link {
|
|||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.RoomArchivedView {
|
||||
padding: 12px;
|
||||
background-color: rgba(245, 245, 245, 0.90);
|
||||
}
|
||||
|
||||
.RoomArchivedView h3 {
|
||||
margin: 0;
|
||||
}
|
23
src/platform/web/ui/session/room/RoomArchivedView.js
Normal file
23
src/platform/web/ui/session/room/RoomArchivedView.js
Normal 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));
|
||||
}
|
||||
}
|
|
@ -19,10 +19,17 @@ import {TemplateView} from "../../general/TemplateView.js";
|
|||
import {TimelineList} from "./TimelineList.js";
|
||||
import {TimelineLoadingView} from "./TimelineLoadingView.js";
|
||||
import {MessageComposer} from "./MessageComposer.js";
|
||||
import {RoomArchivedView} from "./RoomArchivedView.js";
|
||||
import {AvatarView} from "../../avatar.js";
|
||||
|
||||
export class RoomView extends TemplateView {
|
||||
render(t, vm) {
|
||||
let bottomView;
|
||||
if (vm.composerViewModel.kind === "composer") {
|
||||
bottomView = new MessageComposer(vm.composerViewModel);
|
||||
} else if (vm.composerViewModel.kind === "archived") {
|
||||
bottomView = new RoomArchivedView(vm.composerViewModel);
|
||||
}
|
||||
return t.main({className: "RoomView middle"}, [
|
||||
t.div({className: "RoomHeader middle-header"}, [
|
||||
t.a({className: "button-utility close-middle", href: vm.closeUrl, title: vm.i18n`Close room`}),
|
||||
|
@ -38,7 +45,7 @@ export class RoomView extends TemplateView {
|
|||
new TimelineList(timelineViewModel) :
|
||||
new TimelineLoadingView(vm); // vm is just needed for i18n
|
||||
}),
|
||||
t.view(new MessageComposer(vm.composerViewModel)),
|
||||
t.view(bottomView),
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
|
33
src/utils/RetainedValue.js
Normal file
33
src/utils/RetainedValue.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue