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