From 049a477008f380e26d2a66b25dbedbfbfdd6eba5 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Wed, 27 Apr 2022 12:27:19 +0530 Subject: [PATCH 01/14] Pass flowSelector from Client.startRegistration --- src/matrix/Client.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/matrix/Client.js b/src/matrix/Client.js index b24c1ec9..21175a7f 100644 --- a/src/matrix/Client.js +++ b/src/matrix/Client.js @@ -132,14 +132,15 @@ export class Client { }); } - async startRegistration(homeserver, username, password, initialDeviceDisplayName) { + async startRegistration(homeserver, username, password, initialDeviceDisplayName, flowSelector) { const request = this._platform.request; const hsApi = new HomeServerApi({homeserver, request}); const registration = new Registration(hsApi, { username, password, initialDeviceDisplayName, - }); + }, + flowSelector); return registration; } From c07a42292cb5ef7829d938f860afbb1175717f28 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Wed, 27 Apr 2022 12:28:48 +0530 Subject: [PATCH 02/14] Include Platform change in sdk docs --- doc/SDK.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/SDK.md b/doc/SDK.md index 54e37cca..cd81f15b 100644 --- a/doc/SDK.md +++ b/doc/SDK.md @@ -53,7 +53,7 @@ import "hydrogen-view-sdk/theme-element-light.css"; async function main() { const app = document.querySelector('#app')! const config = {}; - const platform = new Platform(app, assetPaths, config, { development: import.meta.env.DEV }); + const platform = new Platform({container: app, assetPaths, config, options: { development: import.meta.env.DEV }}); const navigation = createNavigation(); platform.setNavigation(navigation); const urlRouter = createRouter({ From 83664a1b13d266eb0b6d49f09a37f28753b293dc Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Wed, 27 Apr 2022 12:38:12 +0530 Subject: [PATCH 03/14] viewClassForTile is needed for TimelineView --- doc/SDK.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/doc/SDK.md b/doc/SDK.md index cd81f15b..3f5bdb09 100644 --- a/doc/SDK.md +++ b/doc/SDK.md @@ -31,7 +31,8 @@ import { createNavigation, createRouter, RoomViewModel, - TimelineView + TimelineView, + viewClassForTile } from "hydrogen-view-sdk"; import downloadSandboxPath from 'hydrogen-view-sdk/download-sandbox.html?url'; import workerPath from 'hydrogen-view-sdk/main.js?url'; @@ -88,7 +89,7 @@ async function main() { navigation, }); await vm.load(); - const view = new TimelineView(vm.timelineViewModel); + const view = new TimelineView(vm.timelineViewModel, viewClassForTile); app.appendChild(view.mount()); } } From 139a87de994463f4ce95efb498832be4b5b2a49c Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Sun, 8 May 2022 19:14:51 +0530 Subject: [PATCH 04/14] Pass a copy of the options to the tiles --- src/domain/session/room/timeline/TilesCollection.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/room/timeline/TilesCollection.js b/src/domain/session/room/timeline/TilesCollection.js index 173b0cf6..d8dd660d 100644 --- a/src/domain/session/room/timeline/TilesCollection.js +++ b/src/domain/session/room/timeline/TilesCollection.js @@ -35,7 +35,7 @@ export class TilesCollection extends BaseObservableList { _createTile(entry) { const Tile = this._tileOptions.tileClassForEntry(entry); if (Tile) { - return new Tile(entry, this._tileOptions); + return new Tile(entry, { ...this._tileOptions }); } } From 6beff7e55255a8c6635dd855d2906113d360c261 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Mon, 9 May 2022 14:09:45 +0200 Subject: [PATCH 05/14] override emitChange so no need to clone option object for all tiles instead, we don't store the emitChange in the options but rather on the tile itself. --- .../session/room/timeline/TilesCollection.js | 2 +- .../session/room/timeline/tiles/SimpleTile.js | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/domain/session/room/timeline/TilesCollection.js b/src/domain/session/room/timeline/TilesCollection.js index d8dd660d..173b0cf6 100644 --- a/src/domain/session/room/timeline/TilesCollection.js +++ b/src/domain/session/room/timeline/TilesCollection.js @@ -35,7 +35,7 @@ export class TilesCollection extends BaseObservableList { _createTile(entry) { const Tile = this._tileOptions.tileClassForEntry(entry); if (Tile) { - return new Tile(entry, { ...this._tileOptions }); + return new Tile(entry, this._tileOptions); } } diff --git a/src/domain/session/room/timeline/tiles/SimpleTile.js b/src/domain/session/room/timeline/tiles/SimpleTile.js index b8a7121e..04141576 100644 --- a/src/domain/session/room/timeline/tiles/SimpleTile.js +++ b/src/domain/session/room/timeline/tiles/SimpleTile.js @@ -22,6 +22,7 @@ export class SimpleTile extends ViewModel { constructor(entry, options) { super(options); this._entry = entry; + this._emitUpdate = undefined; } // view model props for all subclasses // hmmm, could also do instanceof ... ? @@ -67,16 +68,20 @@ export class SimpleTile extends ViewModel { // TilesCollection contract below setUpdateEmit(emitUpdate) { - this.updateOptions({emitChange: paramName => { + this._emitUpdate = emitUpdate; + } + + /** overrides the emitChange in ViewModel to also emit the update over the tiles collection */ + emitChange(changedProps) { + if (this._emitUpdate) { // it can happen that after some network call // we switched away from the room and the response // comes in, triggering an emitChange in a tile that // has been disposed already (and hence the change // callback has been cleared by dispose) We should just ignore this. - if (emitUpdate) { - emitUpdate(this, paramName); - } - }}); + this._emitUpdate(this, changedProps); + } + super.emitChange(changedProps); } get upperEntry() { From 3888291758a82fd59d20c31549861a048b2fe67c Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Mon, 9 May 2022 14:10:50 +0200 Subject: [PATCH 06/14] updateOptions is unused,not the best idea since options is/can be shared --- src/domain/ViewModel.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/domain/ViewModel.ts b/src/domain/ViewModel.ts index 8b8581ae..8c12829a 100644 --- a/src/domain/ViewModel.ts +++ b/src/domain/ViewModel.ts @@ -115,10 +115,6 @@ export class ViewModel extends EventEmitter<{change return result; } - updateOptions(options: O): void { - this._options = Object.assign(this._options, options); - } - emitChange(changedProps: any): void { if (this._options.emitChange) { this._options.emitChange(changedProps); From e903d3a6a47200d950b180ffab6d7d9a8226b3bf Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Mon, 9 May 2022 14:12:31 +0200 Subject: [PATCH 07/14] mark options as readonly --- src/domain/ViewModel.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/domain/ViewModel.ts b/src/domain/ViewModel.ts index 8c12829a..0bc52f6e 100644 --- a/src/domain/ViewModel.ts +++ b/src/domain/ViewModel.ts @@ -40,9 +40,9 @@ export type Options = { export class ViewModel extends EventEmitter<{change: never}> { private disposables?: Disposables; private _isDisposed = false; - private _options: O; + private _options: Readonly; - constructor(options: O) { + constructor(options: Readonly) { super(); this._options = options; } @@ -51,7 +51,7 @@ export class ViewModel extends EventEmitter<{change return Object.assign({}, this._options, explicitOptions); } - get options(): O { return this._options; } + get options(): Readonly { return this._options; } // makes it easier to pass through dependencies of a sub-view model getOption(name: N): O[N] { From b7675f46c4ea7ed956c0a48e5fa6ec11f03a603e Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Wed, 27 Apr 2022 10:20:22 +0100 Subject: [PATCH 08/14] bump sdk version --- scripts/sdk/base-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdk/base-manifest.json b/scripts/sdk/base-manifest.json index 7730bbac..312f2913 100644 --- a/scripts/sdk/base-manifest.json +++ b/scripts/sdk/base-manifest.json @@ -1,7 +1,7 @@ { "name": "hydrogen-view-sdk", "description": "Embeddable matrix client library, including view components", - "version": "0.0.10", + "version": "0.0.11", "main": "./hydrogen.es.js", "type": "module" } From 6fde6bbf6b63aecbe5348fa7497df0345bf9204a Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Wed, 11 May 2022 14:58:57 +0200 Subject: [PATCH 09/14] bump sdk version --- scripts/sdk/base-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdk/base-manifest.json b/scripts/sdk/base-manifest.json index 312f2913..0ed9fdab 100644 --- a/scripts/sdk/base-manifest.json +++ b/scripts/sdk/base-manifest.json @@ -1,7 +1,7 @@ { "name": "hydrogen-view-sdk", "description": "Embeddable matrix client library, including view components", - "version": "0.0.11", + "version": "0.0.12", "main": "./hydrogen.es.js", "type": "module" } From ec1568cf1c1a2545d32b3e3bf3342760fdfb75b7 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Thu, 12 May 2022 11:53:29 +0200 Subject: [PATCH 10/14] fix lint error --- .eslintrc.js | 1 + 1 file changed, 1 insertion(+) diff --git a/.eslintrc.js b/.eslintrc.js index cb28f4c8..eb23d387 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -17,6 +17,7 @@ module.exports = { "globals": { "DEFINE_VERSION": "readonly", "DEFINE_GLOBAL_HASH": "readonly", + "DEFINE_PROJECT_DIR": "readonly", // only available in sw.js "DEFINE_UNHASHED_PRECACHED_ASSETS": "readonly", "DEFINE_HASHED_PRECACHED_ASSETS": "readonly", From d727dfd843a5167197c783c5d1ba07748bde17a3 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Thu, 12 May 2022 11:58:28 +0200 Subject: [PATCH 11/14] add session.observeRoomState to observe state changes in all rooms and use it for calls this won't be called for state already received and stored in storage, that you need to still do yourself --- src/lib.ts | 8 +++++++ src/matrix/RoomStateHandlerSet.ts | 37 +++++++++++++++++++++++++++++++ src/matrix/Session.js | 9 +++++++- src/matrix/calls/CallHandler.ts | 19 ++++++---------- src/matrix/room/Room.js | 30 ++++++++++++------------- src/matrix/room/common.ts | 11 +++++++++ 6 files changed, 86 insertions(+), 28 deletions(-) create mode 100644 src/matrix/RoomStateHandlerSet.ts diff --git a/src/lib.ts b/src/lib.ts index 0fc6f539..8c5c4e8e 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -15,11 +15,19 @@ limitations under the License. */ export {Logger} from "./logging/Logger"; +export type {ILogItem} from "./logging/types"; export {IDBLogPersister} from "./logging/IDBLogPersister"; export {ConsoleReporter} from "./logging/ConsoleReporter"; export {Platform} from "./platform/web/Platform.js"; export {Client, LoadStatus} from "./matrix/Client.js"; export {RoomStatus} from "./matrix/room/common"; +// export everything needed to observe state events on all rooms using session.observeRoomState +export type {RoomStateHandler} from "./matrix/room/common"; +export type {MemberChange} from "./matrix/room/members/RoomMember"; +export type {Transaction} from "./matrix/storage/idb/Transaction"; +export type {Room} from "./matrix/room/Room"; +export type {StateEvent} from "./matrix/storage/types"; + // export main view & view models export {createNavigation, createRouter} from "./domain/navigation/index.js"; export {RootViewModel} from "./domain/RootViewModel.js"; diff --git a/src/matrix/RoomStateHandlerSet.ts b/src/matrix/RoomStateHandlerSet.ts new file mode 100644 index 00000000..cf202097 --- /dev/null +++ b/src/matrix/RoomStateHandlerSet.ts @@ -0,0 +1,37 @@ +/* +Copyright 2022 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 type {ILogItem} from "../logging/types"; +import type {StateEvent} from "./storage/types"; +import type {Transaction} from "./storage/idb/Transaction"; +import type {Room} from "./room/Room"; +import type {MemberChange} from "./room/members/RoomMember"; +import type {RoomStateHandler} from "./room/common"; +import {BaseObservable} from "../observable/BaseObservable"; + +/** keeps track of all handlers registered with Session.observeRoomState */ +export class RoomStateHandlerSet extends BaseObservable implements RoomStateHandler { + handleRoomState(room: Room, stateEvent: StateEvent, txn: Transaction, log: ILogItem) { + for(let h of this._handlers) { + h.handleRoomState(room, stateEvent, txn, log); + } + } + updateRoomMembers(room: Room, memberChanges: Map) { + for(let h of this._handlers) { + h.updateRoomMembers(room, memberChanges); + } + } +} diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 9d63d335..6211c456 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -48,6 +48,7 @@ import {SecretStorage} from "./ssss/SecretStorage"; import {ObservableValue} from "../observable/value/ObservableValue"; import {RetainedObservableValue} from "../observable/value/RetainedObservableValue"; import {CallHandler} from "./calls/CallHandler"; +import {RoomStateHandlerSet} from "./RoomStateHandlerSet"; const PICKLE_KEY = "DEFAULT_KEY"; const PUSHER_KEY = "pusher"; @@ -101,6 +102,8 @@ export class Session { }], forceTURN: false, }); + this._roomStateHandler = new RoomStateHandlerSet(); + this.observeRoomState(this._callHandler); this._deviceMessageHandler = new DeviceMessageHandler({storage, callHandler: this._callHandler}); this._olm = olm; this._olmUtil = null; @@ -595,7 +598,7 @@ export class Session { user: this._user, createRoomEncryption: this._createRoomEncryption, platform: this._platform, - callHandler: this._callHandler + roomStateHandler: this._roomStateHandler }); } @@ -937,6 +940,10 @@ export class Session { return observable; } + observeRoomState(roomStateHandler) { + return this._roomStateHandler.subscribe(roomStateHandler); + } + /** 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 diff --git a/src/matrix/calls/CallHandler.ts b/src/matrix/calls/CallHandler.ts index 07ed8492..2386076b 100644 --- a/src/matrix/calls/CallHandler.ts +++ b/src/matrix/calls/CallHandler.ts @@ -35,6 +35,7 @@ import type {Options as GroupCallOptions} from "./group/GroupCall"; import type {Transaction} from "../storage/idb/Transaction"; import type {CallEntry} from "../storage/idb/stores/CallStore"; import type {Clock} from "../../platform/web/dom/Clock"; +import type {RoomStateHandler} from "../room/common"; export type Options = Omit & { clock: Clock @@ -44,7 +45,7 @@ function getRoomMemberKey(roomId: string, userId: string): string { return JSON.stringify(roomId)+`,`+JSON.stringify(userId); } -export class CallHandler { +export class CallHandler implements RoomStateHandler { // group calls by call id private readonly _calls: ObservableMap = new ObservableMap(); // map of `"roomId","userId"` to set of conf_id's they are in @@ -143,18 +144,12 @@ export class CallHandler { // TODO: check and poll turn server credentials here /** @internal */ - handleRoomState(room: Room, events: StateEvent[], txn: Transaction, log: ILogItem) { - // first update call events - for (const event of events) { - if (event.type === EventType.GroupCall) { - this.handleCallEvent(event, room.id, txn, log); - } + handleRoomState(room: Room, event: StateEvent, txn: Transaction, log: ILogItem) { + if (event.type === EventType.GroupCall) { + this.handleCallEvent(event, room.id, txn, log); } - // then update members - for (const event of events) { - if (event.type === EventType.GroupCallMember) { - this.handleCallMemberEvent(event, room.id, log); - } + if (event.type === EventType.GroupCallMember) { + this.handleCallMemberEvent(event, room.id, log); } } diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 34f35af8..556cef7a 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -30,7 +30,7 @@ const EVENT_ENCRYPTED_TYPE = "m.room.encrypted"; export class Room extends BaseRoom { constructor(options) { super(options); - this._callHandler = options.callHandler; + this._roomStateHandler = options.roomStateHandler; // TODO: pass pendingEvents to start like pendingOperations? const {pendingEvents} = options; const relationWriter = new RelationWriter({ @@ -179,7 +179,7 @@ export class Room extends BaseRoom { removedPendingEvents = await this._sendQueue.removeRemoteEchos(roomResponse.timeline.events, txn, log); } const powerLevelsEvent = this._getPowerLevelsEvent(roomResponse); - this._updateCallHandler(roomResponse, txn, log); + this._updateRoomStateHandler(roomResponse, txn, log); return { summaryChanges, roomEncryption, @@ -217,9 +217,7 @@ export class Room extends BaseRoom { if (this._memberList) { this._memberList.afterSync(memberChanges); } - if (this._callHandler) { - this._callHandler.updateRoomMembers(this, memberChanges); - } + this._roomStateHandler.updateRoomMembers(this, memberChanges); if (this._observedMembers) { this._updateObservedMembers(memberChanges); } @@ -447,17 +445,19 @@ export class Room extends BaseRoom { return this._sendQueue.pendingEvents; } - _updateCallHandler(roomResponse, txn, log) { - if (this._callHandler) { - const stateEvents = roomResponse.state?.events; - if (stateEvents?.length) { - this._callHandler.handleRoomState(this, stateEvents, txn, log); + _updateRoomStateHandler(roomResponse, txn, log) { + const stateEvents = roomResponse.state?.events; + if (stateEvents) { + for (let i = 0; i < stateEvents.length; i++) { + this._roomStateHandler.handleRoomState(this, stateEvents[i], txn, log); } - let timelineEvents = roomResponse.timeline?.events; - if (timelineEvents) { - const timelineStateEvents = timelineEvents.filter(e => typeof e.state_key === "string"); - if (timelineEvents.length !== 0) { - this._callHandler.handleRoomState(this, timelineStateEvents, txn, log); + } + let timelineEvents = roomResponse.timeline?.events; + if (timelineEvents) { + for (let i = 0; i < timelineEvents.length; i++) { + const event = timelineEvents[i]; + if (typeof event.state_key === "string") { + this._roomStateHandler.handleRoomState(this, event, txn, log); } } } diff --git a/src/matrix/room/common.ts b/src/matrix/room/common.ts index 57ab7023..2fdc2483 100644 --- a/src/matrix/room/common.ts +++ b/src/matrix/room/common.ts @@ -14,6 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ +import type {Room} from "./Room"; +import type {StateEvent} from "../storage/types"; +import type {Transaction} from "../storage/idb/Transaction"; +import type {ILogItem} from "../../logging/types"; +import type {MemberChange} from "./members/RoomMember"; + export function getPrevContentFromStateEvent(event) { // where to look for prev_content is a bit of a mess, // see https://matrix.to/#/!NasysSDfxKxZBzJJoE:matrix.org/$DvrAbZJiILkOmOIuRsNoHmh2v7UO5CWp_rYhlGk34fQ?via=matrix.org&via=pixie.town&via=amorgan.xyz @@ -40,3 +46,8 @@ export enum RoomType { Private, Public } + +export interface RoomStateHandler { + handleRoomState(room: Room, stateEvent: StateEvent, txn: Transaction, log: ILogItem); + updateRoomMembers(room: Room, memberChanges: Map); +} From db053385962e0272c86f0ca9b998633f8325a3a1 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Thu, 12 May 2022 17:26:29 +0200 Subject: [PATCH 12/14] extract function to iterate over room response state events --- src/matrix/room/ArchivedRoom.js | 8 ++-- src/matrix/room/Room.js | 32 +++++++-------- src/matrix/room/RoomSummary.js | 25 ++---------- src/matrix/room/common.ts | 69 ++++++++++++++++++++++++++++++++- 4 files changed, 91 insertions(+), 43 deletions(-) diff --git a/src/matrix/room/ArchivedRoom.js b/src/matrix/room/ArchivedRoom.js index 1a23d25b..86595163 100644 --- a/src/matrix/room/ArchivedRoom.js +++ b/src/matrix/room/ArchivedRoom.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {reduceStateEvents} from "./RoomSummary.js"; +import {iterateResponseStateEvents} from "./common"; import {BaseRoom} from "./BaseRoom.js"; import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "./members/RoomMember.js"; @@ -173,15 +173,15 @@ export class ArchivedRoom extends BaseRoom { } function findKickDetails(roomResponse, ownUserId) { - const kickEvent = reduceStateEvents(roomResponse, (kickEvent, event) => { + let kickEvent; + iterateResponseStateEvents(roomResponse, 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 diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 556cef7a..a2eadfeb 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -23,6 +23,7 @@ import {WrappedError} from "../error.js" import {Heroes} from "./members/Heroes.js"; import {AttachmentUpload} from "./AttachmentUpload.js"; import {DecryptionSource} from "../e2ee/common.js"; +import {iterateResponseStateEvents} from "./common.js"; import {PowerLevels, EVENT_TYPE as POWERLEVELS_EVENT_TYPE } from "./PowerLevels.js"; const EVENT_ENCRYPTED_TYPE = "m.room.encrypted"; @@ -179,7 +180,7 @@ export class Room extends BaseRoom { removedPendingEvents = await this._sendQueue.removeRemoteEchos(roomResponse.timeline.events, txn, log); } const powerLevelsEvent = this._getPowerLevelsEvent(roomResponse); - this._updateRoomStateHandler(roomResponse, txn, log); + this._runRoomStateHandlers(roomResponse, txn, log); return { summaryChanges, roomEncryption, @@ -275,8 +276,13 @@ export class Room extends BaseRoom { } _getPowerLevelsEvent(roomResponse) { - const isPowerlevelEvent = event => event.state_key === "" && event.type === POWERLEVELS_EVENT_TYPE; - const powerLevelEvent = roomResponse.timeline?.events.find(isPowerlevelEvent) ?? roomResponse.state?.events.find(isPowerlevelEvent); + let powerLevelEvent; + iterateResponseStateEvents(roomResponse, event => { + if(event.state_key === "" && event.type === POWERLEVELS_EVENT_TYPE) { + powerLevelEvent = event; + } + + }); return powerLevelEvent; } @@ -445,20 +451,12 @@ export class Room extends BaseRoom { return this._sendQueue.pendingEvents; } - _updateRoomStateHandler(roomResponse, txn, log) { - const stateEvents = roomResponse.state?.events; - if (stateEvents) { - for (let i = 0; i < stateEvents.length; i++) { - this._roomStateHandler.handleRoomState(this, stateEvents[i], txn, log); - } - } - let timelineEvents = roomResponse.timeline?.events; - if (timelineEvents) { - for (let i = 0; i < timelineEvents.length; i++) { - const event = timelineEvents[i]; - if (typeof event.state_key === "string") { - this._roomStateHandler.handleRoomState(this, event, txn, log); - } + /** global room state handlers, run during write sync step */ + _runRoomStateHandlers(roomResponse, txn, log) { + iterateResponseStateEvents(roomResponse, event => { + this._roomStateHandler.handleRoomState(this, event, txn, log); + }); + } } } } diff --git a/src/matrix/room/RoomSummary.js b/src/matrix/room/RoomSummary.js index a3dec467..62608683 100644 --- a/src/matrix/room/RoomSummary.js +++ b/src/matrix/room/RoomSummary.js @@ -15,7 +15,7 @@ limitations under the License. */ import {MEGOLM_ALGORITHM} from "../e2ee/common.js"; - +import {iterateResponseStateEvents} from "./common"; function applyTimelineEntries(data, timelineEntries, isInitialSync, canMarkUnread, ownUserId) { if (timelineEntries.length) { @@ -27,25 +27,6 @@ function applyTimelineEntries(data, timelineEntries, isInitialSync, canMarkUnrea return data; } -export function reduceStateEvents(roomResponse, callback, value) { - const stateEvents = roomResponse?.state?.events; - // state comes before timeline - if (Array.isArray(stateEvents)) { - value = stateEvents.reduce(callback, value); - } - const timelineEvents = roomResponse?.timeline?.events; - // and after that state events in the timeline - if (Array.isArray(timelineEvents)) { - value = timelineEvents.reduce((data, event) => { - if (typeof event.state_key === "string") { - value = callback(value, event); - } - return value; - }, value); - } - return value; -} - function applySyncResponse(data, roomResponse, membership, ownUserId) { if (roomResponse.summary) { data = updateSummary(data, roomResponse.summary); @@ -60,7 +41,9 @@ function applySyncResponse(data, roomResponse, membership, ownUserId) { // process state events in state and in timeline. // non-state events are handled by applyTimelineEntries // so decryption is handled properly - data = reduceStateEvents(roomResponse, (data, event) => processStateEvent(data, event, ownUserId), data); + iterateResponseStateEvents(roomResponse, event => { + data = processStateEvent(data, event, ownUserId); + }); const unreadNotifications = roomResponse.unread_notifications; if (unreadNotifications) { data = processNotificationCounts(data, unreadNotifications); diff --git a/src/matrix/room/common.ts b/src/matrix/room/common.ts index 2fdc2483..38070925 100644 --- a/src/matrix/room/common.ts +++ b/src/matrix/room/common.ts @@ -15,7 +15,7 @@ limitations under the License. */ import type {Room} from "./Room"; -import type {StateEvent} from "../storage/types"; +import type {StateEvent, TimelineEvent} from "../storage/types"; import type {Transaction} from "../storage/idb/Transaction"; import type {ILogItem} from "../../logging/types"; import type {MemberChange} from "./members/RoomMember"; @@ -50,4 +50,71 @@ export enum RoomType { export interface RoomStateHandler { handleRoomState(room: Room, stateEvent: StateEvent, txn: Transaction, log: ILogItem); updateRoomMembers(room: Room, memberChanges: Map); +type RoomResponse = { + state?: { + events?: Array + }, + timeline?: { + events?: Array + } +} + +/** iterates over any state events in a sync room response, in the order that they should be applied (from older to younger events) */ +export function iterateResponseStateEvents(roomResponse: RoomResponse, callback: (StateEvent) => void) { + // first iterate over state events, they precede the timeline + const stateEvents = roomResponse.state?.events; + if (stateEvents) { + for (let i = 0; i < stateEvents.length; i++) { + callback(stateEvents[i]); + } + } + // now see if there are any state events within the timeline + let timelineEvents = roomResponse.timeline?.events; + if (timelineEvents) { + for (let i = 0; i < timelineEvents.length; i++) { + const event = timelineEvents[i]; + if (typeof event.state_key === "string") { + callback(event); + } + } + } +} + +export function tests() { + return { + "test iterateResponseStateEvents with both state and timeline sections": assert => { + const roomResponse = { + state: { + events: [ + {type: "m.room.member", state_key: "1"}, + {type: "m.room.member", state_key: "2", content: {a: 1}}, + ] + }, + timeline: { + events: [ + {type: "m.room.message"}, + {type: "m.room.member", state_key: "3"}, + {type: "m.room.message"}, + {type: "m.room.member", state_key: "2", content: {a: 2}}, + ] + } + } as unknown as RoomResponse; + const expectedStateKeys = ["1", "2", "3", "2"]; + const expectedAForMember2 = [1, 2]; + iterateResponseStateEvents(roomResponse, event => { + assert.strictEqual(event.type, "m.room.member"); + assert.strictEqual(expectedStateKeys.shift(), event.state_key); + if (event.state_key === "2") { + assert.strictEqual(expectedAForMember2.shift(), event.content.a); + } + }); + assert.strictEqual(expectedStateKeys.length, 0); + assert.strictEqual(expectedAForMember2.length, 0); + }, + "test iterateResponseStateEvents with empty response": assert => { + iterateResponseStateEvents({}, () => { + assert.fail("no events expected"); + }); + } + } } From a50ea7e77b84dbe955bf4dc062d0a098924e81d4 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Thu, 12 May 2022 17:27:03 +0200 Subject: [PATCH 13/14] add support for observing room state for single room + initial state --- src/lib.ts | 2 +- src/matrix/Session.js | 2 +- src/matrix/calls/CallHandler.ts | 2 +- src/matrix/room/BaseRoom.js | 26 +++++++++ src/matrix/room/Room.js | 12 +++- src/matrix/room/common.ts | 3 - .../room/state/ObservedStateKeyValue.ts | 55 +++++++++++++++++++ src/matrix/room/state/ObservedStateTypeMap.ts | 53 ++++++++++++++++++ .../{ => room/state}/RoomStateHandlerSet.ts | 14 ++--- src/matrix/room/state/types.ts | 38 +++++++++++++ 10 files changed, 192 insertions(+), 15 deletions(-) create mode 100644 src/matrix/room/state/ObservedStateKeyValue.ts create mode 100644 src/matrix/room/state/ObservedStateTypeMap.ts rename src/matrix/{ => room/state}/RoomStateHandlerSet.ts (75%) create mode 100644 src/matrix/room/state/types.ts diff --git a/src/lib.ts b/src/lib.ts index 8c5c4e8e..839fbb15 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -22,7 +22,7 @@ export {Platform} from "./platform/web/Platform.js"; export {Client, LoadStatus} from "./matrix/Client.js"; export {RoomStatus} from "./matrix/room/common"; // export everything needed to observe state events on all rooms using session.observeRoomState -export type {RoomStateHandler} from "./matrix/room/common"; +export type {RoomStateHandler} from "./matrix/room/state/types"; export type {MemberChange} from "./matrix/room/members/RoomMember"; export type {Transaction} from "./matrix/storage/idb/Transaction"; export type {Room} from "./matrix/room/Room"; diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 6211c456..cd676fc5 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -48,7 +48,7 @@ import {SecretStorage} from "./ssss/SecretStorage"; import {ObservableValue} from "../observable/value/ObservableValue"; import {RetainedObservableValue} from "../observable/value/RetainedObservableValue"; import {CallHandler} from "./calls/CallHandler"; -import {RoomStateHandlerSet} from "./RoomStateHandlerSet"; +import {RoomStateHandlerSet} from "./room/state/RoomStateHandlerSet"; const PICKLE_KEY = "DEFAULT_KEY"; const PUSHER_KEY = "pusher"; diff --git a/src/matrix/calls/CallHandler.ts b/src/matrix/calls/CallHandler.ts index 2386076b..e585bb40 100644 --- a/src/matrix/calls/CallHandler.ts +++ b/src/matrix/calls/CallHandler.ts @@ -35,7 +35,7 @@ import type {Options as GroupCallOptions} from "./group/GroupCall"; import type {Transaction} from "../storage/idb/Transaction"; import type {CallEntry} from "../storage/idb/stores/CallStore"; import type {Clock} from "../../platform/web/dom/Clock"; -import type {RoomStateHandler} from "../room/common"; +import type {RoomStateHandler} from "../room/state/types"; export type Options = Omit & { clock: Clock diff --git a/src/matrix/room/BaseRoom.js b/src/matrix/room/BaseRoom.js index cc65a320..b8f172d0 100644 --- a/src/matrix/room/BaseRoom.js +++ b/src/matrix/room/BaseRoom.js @@ -31,6 +31,8 @@ import {ensureLogItem} from "../../logging/utils"; import {PowerLevels} from "./PowerLevels.js"; import {RetainedObservableValue} from "../../observable/value/RetainedObservableValue"; import {TimelineReader} from "./timeline/persistence/TimelineReader"; +import {ObservedStateTypeMap} from "./state/ObservedStateTypeMap"; +import {ObservedStateKeyValue} from "./state/ObservedStateKeyValue"; const EVENT_ENCRYPTED_TYPE = "m.room.encrypted"; @@ -53,11 +55,35 @@ export class BaseRoom extends EventEmitter { this._getSyncToken = getSyncToken; this._platform = platform; this._observedEvents = null; + this._roomStateObservers = new Set(); this._powerLevels = null; this._powerLevelLoading = null; this._observedMembers = null; } + async observeStateType(type, txn = undefined) { + const map = new ObservedStateTypeMap(type); + await this._addStateObserver(map, txn); + return map; + } + + async observeStateTypeAndKey(type, stateKey, txn = undefined) { + const value = new ObservedStateKeyValue(type, stateKey); + await this._addStateObserver(value, txn); + return value; + } + + async _addStateObserver(stateObserver, txn) { + if (!txn) { + txn = await this._storage.readTxn([this._storage.storeNames.roomState]); + } + await stateObserver.load(this.id, txn); + this._roomStateObservers.add(stateObserver); + stateObserver.setRemoveCallback(() => { + this._roomStateObservers.delete(stateObserver); + }); + } + async _eventIdsToEntries(eventIds, txn) { const retryEntries = []; await Promise.all(eventIds.map(async eventId => { diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index a2eadfeb..796474d3 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -182,6 +182,7 @@ export class Room extends BaseRoom { const powerLevelsEvent = this._getPowerLevelsEvent(roomResponse); this._runRoomStateHandlers(roomResponse, txn, log); return { + roomResponse, summaryChanges, roomEncryption, newEntries, @@ -204,7 +205,7 @@ export class Room extends BaseRoom { const { summaryChanges, newEntries, updatedEntries, newLiveKey, removedPendingEvents, memberChanges, powerLevelsEvent, - heroChanges, roomEncryption + heroChanges, roomEncryption, roomResponse } = changes; log.set("id", this.id); this._syncWriter.afterSync(newLiveKey); @@ -264,6 +265,7 @@ export class Room extends BaseRoom { if (removedPendingEvents) { this._sendQueue.emitRemovals(removedPendingEvents); } + this._emitSyncRoomState(roomResponse); } _updateObservedMembers(memberChanges) { @@ -457,8 +459,14 @@ export class Room extends BaseRoom { this._roomStateHandler.handleRoomState(this, event, txn, log); }); } + + /** local room state observers, run during after sync step */ + _emitSyncRoomState(roomResponse) { + iterateResponseStateEvents(roomResponse, event => { + for (const handler of this._roomStateObservers) { + handler.handleStateEvent(event); } - } + }); } /** @package */ diff --git a/src/matrix/room/common.ts b/src/matrix/room/common.ts index 38070925..7556cfb0 100644 --- a/src/matrix/room/common.ts +++ b/src/matrix/room/common.ts @@ -47,9 +47,6 @@ export enum RoomType { Public } -export interface RoomStateHandler { - handleRoomState(room: Room, stateEvent: StateEvent, txn: Transaction, log: ILogItem); - updateRoomMembers(room: Room, memberChanges: Map); type RoomResponse = { state?: { events?: Array diff --git a/src/matrix/room/state/ObservedStateKeyValue.ts b/src/matrix/room/state/ObservedStateKeyValue.ts new file mode 100644 index 00000000..41cc3c7b --- /dev/null +++ b/src/matrix/room/state/ObservedStateKeyValue.ts @@ -0,0 +1,55 @@ +/* +Copyright 2022 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 type {StateObserver} from "./types"; +import type {StateEvent} from "../../storage/types"; +import type {Transaction} from "../../storage/idb/Transaction"; +import {BaseObservableValue} from "../../../observable/value/BaseObservableValue"; + +/** + * Observable value for a state event with a given type and state key. + * Unsubscribes when last subscription is removed */ +export class ObservedStateKeyValue extends BaseObservableValue implements StateObserver { + private event?: StateEvent; + private removeCallback?: () => void; + + constructor(private readonly type: string, private readonly stateKey: string) { + super(); + } + /** @internal */ + async load(roomId: string, txn: Transaction): Promise { + this.event = (await txn.roomState.get(roomId, this.type, this.stateKey))?.event; + } + /** @internal */ + handleStateEvent(event: StateEvent) { + if (event.type === this.type && event.state_key === this.stateKey) { + this.event = event; + this.emit(this.get()); + } + } + + get(): StateEvent | undefined { + return this.event; + } + + setRemoveCallback(callback: () => void) { + this.removeCallback = callback; + } + + onUnsubscribeLast() { + this.removeCallback?.(); + } +} diff --git a/src/matrix/room/state/ObservedStateTypeMap.ts b/src/matrix/room/state/ObservedStateTypeMap.ts new file mode 100644 index 00000000..e8fa6f7b --- /dev/null +++ b/src/matrix/room/state/ObservedStateTypeMap.ts @@ -0,0 +1,53 @@ +/* +Copyright 2022 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 type {StateObserver} from "./types"; +import type {StateEvent} from "../../storage/types"; +import type {Transaction} from "../../storage/idb/Transaction"; +import {ObservableMap} from "../../../observable/map/ObservableMap"; + +/** + * Observable map for a given type with state keys as map keys. + * Unsubscribes when last subscription is removed */ +export class ObservedStateTypeMap extends ObservableMap implements StateObserver { + private removeCallback?: () => void; + + constructor(private readonly type: string) { + super(); + } + /** @internal */ + async load(roomId: string, txn: Transaction): Promise { + const events = await txn.roomState.getAllForType(roomId, this.type); + for (let i = 0; i < events.length; ++i) { + const {event} = events[i]; + this.add(event.state_key, event); + } + } + /** @internal */ + handleStateEvent(event: StateEvent) { + if (event.type === this.type) { + this.set(event.state_key, event); + } + } + + setRemoveCallback(callback: () => void) { + this.removeCallback = callback; + } + + onUnsubscribeLast() { + this.removeCallback?.(); + } +} diff --git a/src/matrix/RoomStateHandlerSet.ts b/src/matrix/room/state/RoomStateHandlerSet.ts similarity index 75% rename from src/matrix/RoomStateHandlerSet.ts rename to src/matrix/room/state/RoomStateHandlerSet.ts index cf202097..986cb0f9 100644 --- a/src/matrix/RoomStateHandlerSet.ts +++ b/src/matrix/room/state/RoomStateHandlerSet.ts @@ -14,13 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import type {ILogItem} from "../logging/types"; -import type {StateEvent} from "./storage/types"; -import type {Transaction} from "./storage/idb/Transaction"; -import type {Room} from "./room/Room"; -import type {MemberChange} from "./room/members/RoomMember"; -import type {RoomStateHandler} from "./room/common"; -import {BaseObservable} from "../observable/BaseObservable"; +import type {ILogItem} from "../../../logging/types"; +import type {StateEvent} from "../../storage/types"; +import type {Transaction} from "../../storage/idb/Transaction"; +import type {Room} from "../Room"; +import type {MemberChange} from "../members/RoomMember"; +import type {RoomStateHandler} from "./types"; +import {BaseObservable} from "../../../observable/BaseObservable"; /** keeps track of all handlers registered with Session.observeRoomState */ export class RoomStateHandlerSet extends BaseObservable implements RoomStateHandler { diff --git a/src/matrix/room/state/types.ts b/src/matrix/room/state/types.ts new file mode 100644 index 00000000..ef99c727 --- /dev/null +++ b/src/matrix/room/state/types.ts @@ -0,0 +1,38 @@ +/* +Copyright 2022 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 type {Room} from "../Room"; +import type {StateEvent} from "../../storage/types"; +import type {Transaction} from "../../storage/idb/Transaction"; +import type {ILogItem} from "../../../logging/types"; +import type {MemberChange} from "../members/RoomMember"; + +/** used for Session.observeRoomState, which observes in all room, but without loading from storage + * It receives the sync write transaction, so other stores can be updated as part of the same transaction. */ +export interface RoomStateHandler { + handleRoomState(room: Room, stateEvent: StateEvent, syncWriteTxn: Transaction, log: ILogItem); + updateRoomMembers(room: Room, memberChanges: Map); +} + +/** + * used for Room.observeStateType and Room.observeStateTypeAndKey + * @internal + * */ +export interface StateObserver { + handleStateEvent(event: StateEvent); + load(roomId: string, txn: Transaction): Promise; + setRemoveCallback(callback: () => void); +} From 6225574df61650e3d792c9640b77e18e0af6ddac Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Thu, 12 May 2022 17:52:17 +0200 Subject: [PATCH 14/14] write test for ObservedStateKeyValue --- .../room/state/ObservedStateKeyValue.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/matrix/room/state/ObservedStateKeyValue.ts b/src/matrix/room/state/ObservedStateKeyValue.ts index 41cc3c7b..ce380458 100644 --- a/src/matrix/room/state/ObservedStateKeyValue.ts +++ b/src/matrix/room/state/ObservedStateKeyValue.ts @@ -53,3 +53,52 @@ export class ObservedStateKeyValue extends BaseObservableValue { + const storage = await createMockStorage(); + const writeTxn = await storage.readWriteTxn([storage.storeNames.roomState]); + writeTxn.roomState.set("!abc", { + event_id: "$abc", + type: "m.room.member", + state_key: "@alice", + sender: "@alice", + origin_server_ts: 5, + content: {} + }); + await writeTxn.complete(); + const txn = await storage.readTxn([storage.storeNames.roomState]); + const value = new ObservedStateKeyValue("m.room.member", "@alice"); + await value.load("!abc", txn); + const updates: Array = []; + assert.strictEqual(value.get()?.origin_server_ts, 5); + const unsubscribe = value.subscribe(value => updates.push(value)); + value.handleStateEvent({ + event_id: "$abc", + type: "m.room.member", + state_key: "@bob", + sender: "@alice", + origin_server_ts: 10, + content: {} + }); + assert.strictEqual(updates.length, 0); + value.handleStateEvent({ + event_id: "$abc", + type: "m.room.member", + state_key: "@alice", + sender: "@alice", + origin_server_ts: 10, + content: {} + }); + assert.strictEqual(updates.length, 1); + assert.strictEqual(updates[0]?.origin_server_ts, 10); + let removed = false; + value.setRemoveCallback(() => removed = true); + unsubscribe(); + assert(removed); + } + } +}