Merge branch 'bwindels/calls' into thirdroom/dev

This commit is contained in:
Robert Long 2022-05-12 21:11:11 -07:00
commit 190a405e33
17 changed files with 412 additions and 76 deletions

View File

@ -17,6 +17,7 @@ module.exports = {
"globals": { "globals": {
"DEFINE_VERSION": "readonly", "DEFINE_VERSION": "readonly",
"DEFINE_GLOBAL_HASH": "readonly", "DEFINE_GLOBAL_HASH": "readonly",
"DEFINE_PROJECT_DIR": "readonly",
// only available in sw.js // only available in sw.js
"DEFINE_UNHASHED_PRECACHED_ASSETS": "readonly", "DEFINE_UNHASHED_PRECACHED_ASSETS": "readonly",
"DEFINE_HASHED_PRECACHED_ASSETS": "readonly", "DEFINE_HASHED_PRECACHED_ASSETS": "readonly",

View File

@ -31,7 +31,8 @@ import {
createNavigation, createNavigation,
createRouter, createRouter,
RoomViewModel, RoomViewModel,
TimelineView TimelineView,
viewClassForTile
} from "hydrogen-view-sdk"; } from "hydrogen-view-sdk";
import downloadSandboxPath from 'hydrogen-view-sdk/download-sandbox.html?url'; import downloadSandboxPath from 'hydrogen-view-sdk/download-sandbox.html?url';
import workerPath from 'hydrogen-view-sdk/main.js?url'; import workerPath from 'hydrogen-view-sdk/main.js?url';
@ -53,7 +54,7 @@ import "hydrogen-view-sdk/theme-element-light.css";
async function main() { async function main() {
const app = document.querySelector<HTMLDivElement>('#app')! const app = document.querySelector<HTMLDivElement>('#app')!
const config = {}; 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(); const navigation = createNavigation();
platform.setNavigation(navigation); platform.setNavigation(navigation);
const urlRouter = createRouter({ const urlRouter = createRouter({
@ -88,7 +89,7 @@ async function main() {
navigation, navigation,
}); });
await vm.load(); await vm.load();
const view = new TimelineView(vm.timelineViewModel); const view = new TimelineView(vm.timelineViewModel, viewClassForTile);
app.appendChild(view.mount()); app.appendChild(view.mount());
} }
} }

View File

@ -40,9 +40,9 @@ export type Options = {
export class ViewModel<O extends Options = Options> extends EventEmitter<{change: never}> { export class ViewModel<O extends Options = Options> extends EventEmitter<{change: never}> {
private disposables?: Disposables; private disposables?: Disposables;
private _isDisposed = false; private _isDisposed = false;
private _options: O; private _options: Readonly<O>;
constructor(options: O) { constructor(options: Readonly<O>) {
super(); super();
this._options = options; this._options = options;
} }
@ -51,7 +51,7 @@ export class ViewModel<O extends Options = Options> extends EventEmitter<{change
return Object.assign({}, this._options, explicitOptions); return Object.assign({}, this._options, explicitOptions);
} }
get options(): O { return this._options; } get options(): Readonly<O> { return this._options; }
// makes it easier to pass through dependencies of a sub-view model // makes it easier to pass through dependencies of a sub-view model
getOption<N extends keyof O>(name: N): O[N] { getOption<N extends keyof O>(name: N): O[N] {
@ -115,10 +115,6 @@ export class ViewModel<O extends Options = Options> extends EventEmitter<{change
return result; return result;
} }
updateOptions(options: O): void {
this._options = Object.assign(this._options, options);
}
emitChange(changedProps: any): void { emitChange(changedProps: any): void {
if (this._options.emitChange) { if (this._options.emitChange) {
this._options.emitChange(changedProps); this._options.emitChange(changedProps);

View File

@ -22,6 +22,7 @@ export class SimpleTile extends ViewModel {
constructor(entry, options) { constructor(entry, options) {
super(options); super(options);
this._entry = entry; this._entry = entry;
this._emitUpdate = undefined;
} }
// view model props for all subclasses // view model props for all subclasses
// hmmm, could also do instanceof ... ? // hmmm, could also do instanceof ... ?
@ -67,16 +68,20 @@ export class SimpleTile extends ViewModel {
// TilesCollection contract below // TilesCollection contract below
setUpdateEmit(emitUpdate) { 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 // it can happen that after some network call
// we switched away from the room and the response // we switched away from the room and the response
// comes in, triggering an emitChange in a tile that // comes in, triggering an emitChange in a tile that
// has been disposed already (and hence the change // has been disposed already (and hence the change
// callback has been cleared by dispose) We should just ignore this. // callback has been cleared by dispose) We should just ignore this.
if (emitUpdate) { this._emitUpdate(this, changedProps);
emitUpdate(this, paramName); }
} super.emitChange(changedProps);
}});
} }
get upperEntry() { get upperEntry() {

View File

@ -15,12 +15,20 @@ limitations under the License.
*/ */
export {Logger} from "./logging/Logger"; export {Logger} from "./logging/Logger";
export type {ILogItem} from "./logging/types";
export {IDBLogPersister} from "./logging/IDBLogPersister"; export {IDBLogPersister} from "./logging/IDBLogPersister";
export {ConsoleReporter} from "./logging/ConsoleReporter"; export {ConsoleReporter} from "./logging/ConsoleReporter";
export {Platform} from "./platform/web/Platform.js"; export {Platform} from "./platform/web/Platform.js";
export {Client, LoadStatus} from "./matrix/Client.js"; export {Client, LoadStatus} from "./matrix/Client.js";
export {RoomStatus} from "./matrix/room/common"; export {RoomStatus} from "./matrix/room/common";
export {CallIntent} from "./matrix/calls/callEventTypes"; export {CallIntent} from "./matrix/calls/callEventTypes";
// export everything needed to observe state events on all rooms using session.observeRoomState
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";
export type {StateEvent} from "./matrix/storage/types";
// export main view & view models // export main view & view models
export {createNavigation, createRouter} from "./domain/navigation/index.js"; export {createNavigation, createRouter} from "./domain/navigation/index.js";
export {RootViewModel} from "./domain/RootViewModel.js"; export {RootViewModel} from "./domain/RootViewModel.js";

View File

@ -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 request = this._platform.request;
const hsApi = new HomeServerApi({homeserver, request}); const hsApi = new HomeServerApi({homeserver, request});
const registration = new Registration(hsApi, { const registration = new Registration(hsApi, {
username, username,
password, password,
initialDeviceDisplayName, initialDeviceDisplayName,
}); },
flowSelector);
return registration; return registration;
} }

View File

@ -48,6 +48,7 @@ import {SecretStorage} from "./ssss/SecretStorage";
import {ObservableValue} from "../observable/value/ObservableValue"; import {ObservableValue} from "../observable/value/ObservableValue";
import {RetainedObservableValue} from "../observable/value/RetainedObservableValue"; import {RetainedObservableValue} from "../observable/value/RetainedObservableValue";
import {CallHandler} from "./calls/CallHandler"; import {CallHandler} from "./calls/CallHandler";
import {RoomStateHandlerSet} from "./room/state/RoomStateHandlerSet";
const PICKLE_KEY = "DEFAULT_KEY"; const PICKLE_KEY = "DEFAULT_KEY";
const PUSHER_KEY = "pusher"; const PUSHER_KEY = "pusher";
@ -101,6 +102,8 @@ export class Session {
}], }],
forceTURN: false, forceTURN: false,
}); });
this._roomStateHandler = new RoomStateHandlerSet();
this.observeRoomState(this._callHandler);
this._deviceMessageHandler = new DeviceMessageHandler({storage, callHandler: this._callHandler}); this._deviceMessageHandler = new DeviceMessageHandler({storage, callHandler: this._callHandler});
this._olm = olm; this._olm = olm;
this._olmUtil = null; this._olmUtil = null;
@ -595,7 +598,7 @@ export class Session {
user: this._user, user: this._user,
createRoomEncryption: this._createRoomEncryption, createRoomEncryption: this._createRoomEncryption,
platform: this._platform, platform: this._platform,
callHandler: this._callHandler roomStateHandler: this._roomStateHandler
}); });
} }
@ -937,6 +940,10 @@ export class Session {
return observable; return observable;
} }
observeRoomState(roomStateHandler) {
return this._roomStateHandler.subscribe(roomStateHandler);
}
/** /**
Creates an empty (summary isn't loaded) the archived room if it isn't 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 loaded already, assuming sync will either remove it (when rejoining) or

View File

@ -35,6 +35,7 @@ import type {Options as GroupCallOptions} from "./group/GroupCall";
import type {Transaction} from "../storage/idb/Transaction"; import type {Transaction} from "../storage/idb/Transaction";
import type {CallEntry} from "../storage/idb/stores/CallStore"; import type {CallEntry} from "../storage/idb/stores/CallStore";
import type {Clock} from "../../platform/web/dom/Clock"; import type {Clock} from "../../platform/web/dom/Clock";
import type {RoomStateHandler} from "../room/state/types";
export type Options = Omit<GroupCallOptions, "emitUpdate" | "createTimeout"> & { export type Options = Omit<GroupCallOptions, "emitUpdate" | "createTimeout"> & {
clock: Clock clock: Clock
@ -44,7 +45,7 @@ function getRoomMemberKey(roomId: string, userId: string): string {
return JSON.stringify(roomId)+`,`+JSON.stringify(userId); return JSON.stringify(roomId)+`,`+JSON.stringify(userId);
} }
export class CallHandler { export class CallHandler implements RoomStateHandler {
// group calls by call id // group calls by call id
private readonly _calls: ObservableMap<string, GroupCall> = new ObservableMap<string, GroupCall>(); private readonly _calls: ObservableMap<string, GroupCall> = new ObservableMap<string, GroupCall>();
// map of `"roomId","userId"` to set of conf_id's they are in // 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 // TODO: check and poll turn server credentials here
/** @internal */ /** @internal */
handleRoomState(room: Room, events: StateEvent[], txn: Transaction, log: ILogItem) { handleRoomState(room: Room, event: StateEvent, txn: Transaction, log: ILogItem) {
// first update call events if (event.type === EventType.GroupCall) {
for (const event of events) { this.handleCallEvent(event, room.id, txn, log);
if (event.type === EventType.GroupCall) {
this.handleCallEvent(event, room.id, txn, log);
}
} }
// then update members if (event.type === EventType.GroupCallMember) {
for (const event of events) { this.handleCallMemberEvent(event, room.id, log);
if (event.type === EventType.GroupCallMember) {
this.handleCallMemberEvent(event, room.id, log);
}
} }
} }

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {reduceStateEvents} from "./RoomSummary.js"; import {iterateResponseStateEvents} from "./common";
import {BaseRoom} from "./BaseRoom.js"; import {BaseRoom} from "./BaseRoom.js";
import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "./members/RoomMember.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) { function findKickDetails(roomResponse, ownUserId) {
const kickEvent = reduceStateEvents(roomResponse, (kickEvent, event) => { let kickEvent;
iterateResponseStateEvents(roomResponse, event => {
if (event.type === MEMBER_EVENT_TYPE) { if (event.type === MEMBER_EVENT_TYPE) {
// did we get kicked? // did we get kicked?
if (event.state_key === ownUserId && event.sender !== event.state_key) { if (event.state_key === ownUserId && event.sender !== event.state_key) {
kickEvent = event; kickEvent = event;
} }
} }
return kickEvent; });
}, null);
if (kickEvent) { if (kickEvent) {
return { return {
// this is different from the room membership in the sync section, which can only be leave // this is different from the room membership in the sync section, which can only be leave

View File

@ -31,6 +31,8 @@ import {ensureLogItem} from "../../logging/utils";
import {PowerLevels} from "./PowerLevels.js"; import {PowerLevels} from "./PowerLevels.js";
import {RetainedObservableValue} from "../../observable/value/RetainedObservableValue"; import {RetainedObservableValue} from "../../observable/value/RetainedObservableValue";
import {TimelineReader} from "./timeline/persistence/TimelineReader"; import {TimelineReader} from "./timeline/persistence/TimelineReader";
import {ObservedStateTypeMap} from "./state/ObservedStateTypeMap";
import {ObservedStateKeyValue} from "./state/ObservedStateKeyValue";
const EVENT_ENCRYPTED_TYPE = "m.room.encrypted"; const EVENT_ENCRYPTED_TYPE = "m.room.encrypted";
@ -53,11 +55,35 @@ export class BaseRoom extends EventEmitter {
this._getSyncToken = getSyncToken; this._getSyncToken = getSyncToken;
this._platform = platform; this._platform = platform;
this._observedEvents = null; this._observedEvents = null;
this._roomStateObservers = new Set();
this._powerLevels = null; this._powerLevels = null;
this._powerLevelLoading = null; this._powerLevelLoading = null;
this._observedMembers = 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) { async _eventIdsToEntries(eventIds, txn) {
const retryEntries = []; const retryEntries = [];
await Promise.all(eventIds.map(async eventId => { await Promise.all(eventIds.map(async eventId => {

View File

@ -23,6 +23,7 @@ import {WrappedError} from "../error.js"
import {Heroes} from "./members/Heroes.js"; import {Heroes} from "./members/Heroes.js";
import {AttachmentUpload} from "./AttachmentUpload.js"; import {AttachmentUpload} from "./AttachmentUpload.js";
import {DecryptionSource} from "../e2ee/common.js"; import {DecryptionSource} from "../e2ee/common.js";
import {iterateResponseStateEvents} from "./common.js";
import {PowerLevels, EVENT_TYPE as POWERLEVELS_EVENT_TYPE } from "./PowerLevels.js"; import {PowerLevels, EVENT_TYPE as POWERLEVELS_EVENT_TYPE } from "./PowerLevels.js";
const EVENT_ENCRYPTED_TYPE = "m.room.encrypted"; const EVENT_ENCRYPTED_TYPE = "m.room.encrypted";
@ -30,7 +31,7 @@ const EVENT_ENCRYPTED_TYPE = "m.room.encrypted";
export class Room extends BaseRoom { export class Room extends BaseRoom {
constructor(options) { constructor(options) {
super(options); super(options);
this._callHandler = options.callHandler; this._roomStateHandler = options.roomStateHandler;
// TODO: pass pendingEvents to start like pendingOperations? // TODO: pass pendingEvents to start like pendingOperations?
const {pendingEvents} = options; const {pendingEvents} = options;
const relationWriter = new RelationWriter({ const relationWriter = new RelationWriter({
@ -179,8 +180,9 @@ export class Room extends BaseRoom {
removedPendingEvents = await this._sendQueue.removeRemoteEchos(roomResponse.timeline.events, txn, log); removedPendingEvents = await this._sendQueue.removeRemoteEchos(roomResponse.timeline.events, txn, log);
} }
const powerLevelsEvent = this._getPowerLevelsEvent(roomResponse); const powerLevelsEvent = this._getPowerLevelsEvent(roomResponse);
this._updateCallHandler(roomResponse, txn, log); this._runRoomStateHandlers(roomResponse, txn, log);
return { return {
roomResponse,
summaryChanges, summaryChanges,
roomEncryption, roomEncryption,
newEntries, newEntries,
@ -203,7 +205,7 @@ export class Room extends BaseRoom {
const { const {
summaryChanges, newEntries, updatedEntries, newLiveKey, summaryChanges, newEntries, updatedEntries, newLiveKey,
removedPendingEvents, memberChanges, powerLevelsEvent, removedPendingEvents, memberChanges, powerLevelsEvent,
heroChanges, roomEncryption heroChanges, roomEncryption, roomResponse
} = changes; } = changes;
log.set("id", this.id); log.set("id", this.id);
this._syncWriter.afterSync(newLiveKey); this._syncWriter.afterSync(newLiveKey);
@ -217,9 +219,7 @@ export class Room extends BaseRoom {
if (this._memberList) { if (this._memberList) {
this._memberList.afterSync(memberChanges); this._memberList.afterSync(memberChanges);
} }
if (this._callHandler) { this._roomStateHandler.updateRoomMembers(this, memberChanges);
this._callHandler.updateRoomMembers(this, memberChanges);
}
if (this._observedMembers) { if (this._observedMembers) {
this._updateObservedMembers(memberChanges); this._updateObservedMembers(memberChanges);
} }
@ -265,6 +265,7 @@ export class Room extends BaseRoom {
if (removedPendingEvents) { if (removedPendingEvents) {
this._sendQueue.emitRemovals(removedPendingEvents); this._sendQueue.emitRemovals(removedPendingEvents);
} }
this._emitSyncRoomState(roomResponse);
} }
_updateObservedMembers(memberChanges) { _updateObservedMembers(memberChanges) {
@ -277,8 +278,13 @@ export class Room extends BaseRoom {
} }
_getPowerLevelsEvent(roomResponse) { _getPowerLevelsEvent(roomResponse) {
const isPowerlevelEvent = event => event.state_key === "" && event.type === POWERLEVELS_EVENT_TYPE; let powerLevelEvent;
const powerLevelEvent = roomResponse.timeline?.events.find(isPowerlevelEvent) ?? roomResponse.state?.events.find(isPowerlevelEvent); iterateResponseStateEvents(roomResponse, event => {
if(event.state_key === "" && event.type === POWERLEVELS_EVENT_TYPE) {
powerLevelEvent = event;
}
});
return powerLevelEvent; return powerLevelEvent;
} }
@ -447,20 +453,20 @@ export class Room extends BaseRoom {
return this._sendQueue.pendingEvents; return this._sendQueue.pendingEvents;
} }
_updateCallHandler(roomResponse, txn, log) { /** global room state handlers, run during write sync step */
if (this._callHandler) { _runRoomStateHandlers(roomResponse, txn, log) {
const stateEvents = roomResponse.state?.events; iterateResponseStateEvents(roomResponse, event => {
if (stateEvents?.length) { this._roomStateHandler.handleRoomState(this, event, txn, log);
this._callHandler.handleRoomState(this, stateEvents, 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);
} }
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);
}
}
}
} }
/** @package */ /** @package */

View File

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import {MEGOLM_ALGORITHM} from "../e2ee/common.js"; import {MEGOLM_ALGORITHM} from "../e2ee/common.js";
import {iterateResponseStateEvents} from "./common";
function applyTimelineEntries(data, timelineEntries, isInitialSync, canMarkUnread, ownUserId) { function applyTimelineEntries(data, timelineEntries, isInitialSync, canMarkUnread, ownUserId) {
if (timelineEntries.length) { if (timelineEntries.length) {
@ -27,25 +27,6 @@ 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, ownUserId) { function applySyncResponse(data, roomResponse, membership, ownUserId) {
if (roomResponse.summary) { if (roomResponse.summary) {
data = updateSummary(data, 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. // process state events in state and 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
data = reduceStateEvents(roomResponse, (data, event) => processStateEvent(data, event, ownUserId), data); iterateResponseStateEvents(roomResponse, event => {
data = processStateEvent(data, event, ownUserId);
});
const unreadNotifications = roomResponse.unread_notifications; const unreadNotifications = roomResponse.unread_notifications;
if (unreadNotifications) { if (unreadNotifications) {
data = processNotificationCounts(data, unreadNotifications); data = processNotificationCounts(data, unreadNotifications);

View File

@ -14,6 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import type {Room} from "./Room";
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";
export function getPrevContentFromStateEvent(event) { export function getPrevContentFromStateEvent(event) {
// where to look for prev_content is a bit of a mess, // 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 // see https://matrix.to/#/!NasysSDfxKxZBzJJoE:matrix.org/$DvrAbZJiILkOmOIuRsNoHmh2v7UO5CWp_rYhlGk34fQ?via=matrix.org&via=pixie.town&via=amorgan.xyz
@ -40,3 +46,72 @@ export enum RoomType {
Private, Private,
Public Public
} }
type RoomResponse = {
state?: {
events?: Array<StateEvent>
},
timeline?: {
events?: Array<StateEvent>
}
}
/** 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");
});
}
}
}

View File

@ -0,0 +1,104 @@
/*
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<StateEvent | undefined> 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<void> {
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?.();
}
}
import {createMockStorage} from "../../../mocks/Storage";
export async function tests() {
return {
"test load and update": async assert => {
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<StateEvent | undefined> = [];
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);
}
}
}

View File

@ -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<string, StateEvent> implements StateObserver {
private removeCallback?: () => void;
constructor(private readonly type: string) {
super();
}
/** @internal */
async load(roomId: string, txn: Transaction): Promise<void> {
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?.();
}
}

View File

@ -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";
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<RoomStateHandler> 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<string, MemberChange>) {
for(let h of this._handlers) {
h.updateRoomMembers(room, memberChanges);
}
}
}

View File

@ -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<string, MemberChange>);
}
/**
* used for Room.observeStateType and Room.observeStateTypeAndKey
* @internal
* */
export interface StateObserver {
handleStateEvent(event: StateEvent);
load(roomId: string, txn: Transaction): Promise<void>;
setRemoveCallback(callback: () => void);
}