From d49115c69be77667033a07a41e214c6e5c02b03f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 26 Mar 2020 21:14:31 +0100 Subject: [PATCH 01/36] olm prototype from tutorial --- lib/olm.js | 1 + lib/olm.wasm | 1 + package.json | 3 +++ prototypes/olmtest.html | 58 +++++++++++++++++++++++++++++++++++++++++ yarn.lock | 4 +++ 5 files changed, 67 insertions(+) create mode 120000 lib/olm.js create mode 120000 lib/olm.wasm create mode 100644 prototypes/olmtest.html diff --git a/lib/olm.js b/lib/olm.js new file mode 120000 index 00000000..9f16c77b --- /dev/null +++ b/lib/olm.js @@ -0,0 +1 @@ +../node_modules/olm/olm.js \ No newline at end of file diff --git a/lib/olm.wasm b/lib/olm.wasm new file mode 120000 index 00000000..8d848e89 --- /dev/null +++ b/lib/olm.wasm @@ -0,0 +1 @@ +../node_modules/olm/olm.wasm \ No newline at end of file diff --git a/package.json b/package.json index 34d8227b..84b6f36a 100644 --- a/package.json +++ b/package.json @@ -29,5 +29,8 @@ "postcss-import": "^12.0.1", "rollup": "^1.15.6", "serve-static": "^1.13.2" + }, + "dependencies": { + "olm": "https://packages.matrix.org/npm/olm/olm-3.1.4.tgz" } } diff --git a/prototypes/olmtest.html b/prototypes/olmtest.html new file mode 100644 index 00000000..9a17a189 --- /dev/null +++ b/prototypes/olmtest.html @@ -0,0 +1,58 @@ + + + + + + + + + + + + diff --git a/yarn.lock b/yarn.lock index bac88dfc..1f2bcf4c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -274,6 +274,10 @@ nth-check@~1.0.1: dependencies: boolbase "~1.0.0" +"olm@https://packages.matrix.org/npm/olm/olm-3.1.4.tgz": + version "3.1.4" + resolved "https://packages.matrix.org/npm/olm/olm-3.1.4.tgz#0f03128b7d3b2f614d2216409a1dfccca765fdb3" + on-finished@~2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" From 5ebd6bb0925c27c8361f536f5b84f703a928b561 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 25 Jun 2020 00:09:58 +0200 Subject: [PATCH 02/36] resolve doubt --- doc/impl-thoughts/E2EE.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/doc/impl-thoughts/E2EE.md b/doc/impl-thoughts/E2EE.md index 66ef31d5..1fdff8a6 100644 --- a/doc/impl-thoughts/E2EE.md +++ b/doc/impl-thoughts/E2EE.md @@ -2,7 +2,10 @@ ## Olm - implement MemberList as ObservableMap - make sure we have all members (as we're using lazy loading members), and store these somehow + - keep in mind that the server might not support lazy loading? E.g. we should store in a memberlist all the membership events passed by sync, perhaps with a flag if we already attempted to fetch all. We could also check if the server announces lazy loading support in the version response (I think r0.6.0). - do we need to update /members on every limited sync response or did we find a way around this? + - I don't think we need to ... we get all state events that were sent during the gap in `room.state` + - I tested this with riot and synapse, and indeed, we get membership events from the gap on a limited sync. This could be clearer in the spec though. - fields: - user id - room id From 625598be9065bb82f157b712d79dc05f8d455294 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 25 Jun 2020 00:14:47 +0200 Subject: [PATCH 03/36] add olm api decls link to doc --- doc/impl-thoughts/E2EE.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/doc/impl-thoughts/E2EE.md b/doc/impl-thoughts/E2EE.md index 1fdff8a6..41f56f6e 100644 --- a/doc/impl-thoughts/E2EE.md +++ b/doc/impl-thoughts/E2EE.md @@ -121,7 +121,8 @@ we'll need to pass an implementation of EventSender or something to SendQueue th - use AES-CTR from https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto ## Notes - - libolm api docs (also for js api) would be great + - libolm api docs (also for js api) would be great. Found something that could work: + https://gitlab.matrix.org/matrix-org/olm/-/blob/master/javascript/index.d.ts ## OO Design From f5d3092031c6139719f25897b3e44d79957fb215 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 26 Jun 2020 23:26:24 +0200 Subject: [PATCH 04/36] WIP --- doc/impl-thoughts/MEMBERS.md | 8 ++ doc/impl-thoughts/timeline-member.md | 7 ++ src/domain/SessionLoadViewModel.js | 1 + src/domain/SessionPickerViewModel.js | 19 ++-- src/matrix/ServerFeatures.js | 19 ++++ src/matrix/SessionContainer.js | 9 +- src/matrix/Sync.js | 2 + src/matrix/room/Room.js | 36 ++++++- src/matrix/room/RoomMember.js | 69 +++++++++++++ src/matrix/room/RoomSummary.js | 14 ++- .../room/timeline/persistence/SyncWriter.js | 98 ++++++++++++++----- src/matrix/storage/common.js | 1 + src/matrix/storage/idb/StorageFactory.js | 29 ++---- src/matrix/storage/idb/Transaction.js | 5 + src/matrix/storage/idb/schema.js | 65 ++++++++++++ src/matrix/storage/idb/stores/MemberStore.js | 18 ---- .../storage/idb/stores/RoomMemberStore.js | 24 +++++ .../storage/idb/stores/RoomStateStore.js | 8 +- src/matrix/storage/idb/utils.js | 8 +- 19 files changed, 358 insertions(+), 82 deletions(-) create mode 100644 doc/impl-thoughts/timeline-member.md create mode 100644 src/matrix/ServerFeatures.js create mode 100644 src/matrix/room/RoomMember.js create mode 100644 src/matrix/storage/idb/schema.js delete mode 100644 src/matrix/storage/idb/stores/MemberStore.js create mode 100644 src/matrix/storage/idb/stores/RoomMemberStore.js diff --git a/doc/impl-thoughts/MEMBERS.md b/doc/impl-thoughts/MEMBERS.md index d2b1db3e..0103b879 100644 --- a/doc/impl-thoughts/MEMBERS.md +++ b/doc/impl-thoughts/MEMBERS.md @@ -1,3 +1,11 @@ +# TODO + +## Member list + + - support migrations in StorageFactory + - migrate all stores from key to key_path + - how to deal with members coming from backfill? do we even need to store them? + # How to store members? All of this is assuming we'll use lazy loading of members. diff --git a/doc/impl-thoughts/timeline-member.md b/doc/impl-thoughts/timeline-member.md new file mode 100644 index 00000000..a0c4eccb --- /dev/null +++ b/doc/impl-thoughts/timeline-member.md @@ -0,0 +1,7 @@ +## Get member for timeline event + +so when writing sync, we persist the display name and avatar + +the server might or might not support lazy loading + +if it is a room we just joined \ No newline at end of file diff --git a/src/domain/SessionLoadViewModel.js b/src/domain/SessionLoadViewModel.js index 2a7de3be..1e51a322 100644 --- a/src/domain/SessionLoadViewModel.js +++ b/src/domain/SessionLoadViewModel.js @@ -51,6 +51,7 @@ export class SessionLoadViewModel extends ViewModel { this._error = err; } finally { this._loading = false; + // loadLabel in case of sc.loadError also gets updated through this this.emitChange("loading"); } } diff --git a/src/domain/SessionPickerViewModel.js b/src/domain/SessionPickerViewModel.js index 78cb5bac..3c0bde03 100644 --- a/src/domain/SessionPickerViewModel.js +++ b/src/domain/SessionPickerViewModel.js @@ -159,13 +159,18 @@ export class SessionPickerViewModel extends ViewModel { } async import(json) { - const data = JSON.parse(json); - const {sessionInfo} = data; - sessionInfo.comment = `Imported on ${new Date().toLocaleString()} from id ${sessionInfo.id}.`; - sessionInfo.id = this._createSessionContainer().createNewSessionId(); - await this._storageFactory.import(sessionInfo.id, data.stores); - await this._sessionInfoStorage.add(sessionInfo); - this._sessions.set(new SessionItemViewModel(sessionInfo, this)); + try { + const data = JSON.parse(json); + const {sessionInfo} = data; + sessionInfo.comment = `Imported on ${new Date().toLocaleString()} from id ${sessionInfo.id}.`; + sessionInfo.id = this._createSessionContainer().createNewSessionId(); + await this._storageFactory.import(sessionInfo.id, data.stores); + await this._sessionInfoStorage.add(sessionInfo); + this._sessions.set(new SessionItemViewModel(sessionInfo, this)); + } catch (err) { + alert(err.message); + console.error(err); + } } async delete(id) { diff --git a/src/matrix/ServerFeatures.js b/src/matrix/ServerFeatures.js new file mode 100644 index 00000000..c2875942 --- /dev/null +++ b/src/matrix/ServerFeatures.js @@ -0,0 +1,19 @@ +const R0_5_0 = "r0.5.0"; + +export class ServerFeatures { + constructor(versionResponse) { + this._versionResponse = versionResponse; + } + + _supportsVersion(version) { + if (!this._versionResponse) { + return false; + } + const {versions} = this._versionResponse; + return Array.isArray(versions) && versions.includes(version); + } + + get lazyLoadMembers() { + return this._supportsVersion(R0_5_0); + } +} diff --git a/src/matrix/SessionContainer.js b/src/matrix/SessionContainer.js index f7e33c61..e5cd684c 100644 --- a/src/matrix/SessionContainer.js +++ b/src/matrix/SessionContainer.js @@ -94,7 +94,7 @@ export class SessionContainer { this._status.set(LoadStatus.LoginFailed); } else if (err instanceof ConnectionError) { this._loginFailure = LoginFailure.Connection; - this._status.set(LoadStatus.LoginFailure); + this._status.set(LoadStatus.LoginFailed); } else { this._status.set(LoadStatus.Error); } @@ -175,9 +175,14 @@ export class SessionContainer { } } // only transition into Ready once the first sync has succeeded - this._waitForFirstSyncHandle = this._sync.status.waitFor(s => s === SyncStatus.Syncing); + this._waitForFirstSyncHandle = this._sync.status.waitFor(s => s === SyncStatus.Syncing || s === SyncStatus.Stopped); try { await this._waitForFirstSyncHandle.promise; + if (this._sync.status.get() === SyncStatus.Stopped) { + if (this._sync.error) { + throw this._sync.error; + } + } } catch (err) { // if dispose is called from stop, bail out if (err instanceof AbortError) { diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index de079e4d..aba1ae30 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -92,6 +92,7 @@ export class Sync { storeNames.session, storeNames.roomSummary, storeNames.roomState, + storeNames.roomMembers, storeNames.timelineEvents, storeNames.timelineFragments, storeNames.pendingEvents, @@ -116,6 +117,7 @@ export class Sync { } } catch(err) { console.warn("aborting syncTxn because of error"); + console.error(err); // avoid corrupting state by only // storing the sync up till the point // the exception occurred diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index cc8e60d5..f7e9b335 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -34,7 +34,7 @@ export class Room extends EventEmitter { afterSync({summaryChanges, newTimelineEntries, newLiveKey, removedPendingEvents}) { this._syncWriter.afterSync(newLiveKey); if (summaryChanges) { - this._summary.afterSync(summaryChanges); + this._summary.applyChanges(summaryChanges); this.emit("change"); this._emitCollectionChange(this); } @@ -59,6 +59,40 @@ export class Room extends EventEmitter { return this._sendQueue.enqueueEvent(eventType, content); } + async loadMemberList() { + let members; + if (!this._summary.hasFetchedMembers) { + // we need to get the syncToken here! + const memberResponse = await this._hsApi.members(this._roomId, syncToken).response; + + const txn = await this._storage.readWriteTxn([ + this._storage.storeNames.roomSummary, + this._storage.storeNames.roomMembers, + ]); + const summaryChanges = this._summary.writeHasFetchedMembers(true, txn); + const {roomMembers} = txn; + const memberEvents = memberResponse.chunk; + if (!Array.isArray(memberEvents)) { + throw new Error("malformed"); + } + members = await Promise.all(memberEvents.map(async memberEvent => { + const userId = memberEvent && memberEvent.state_key; + if (!userId) { + throw new Error("malformed"); + } + const memberData = await roomMembers.get(this._roomId, userId); + const member = updateOrCreateMember(this._roomId, memberData, event); + if (member) { + roomMembers.set(member.serialize()); + } + return member; + })); + await txn.complete(); + this._summary.applyChanges(summaryChanges); + } + return new MemberList(this._roomId, members, this._storage); + } + /** @public */ async fillGap(fragmentEntry, amount) { diff --git a/src/matrix/room/RoomMember.js b/src/matrix/room/RoomMember.js new file mode 100644 index 00000000..f6e5237d --- /dev/null +++ b/src/matrix/room/RoomMember.js @@ -0,0 +1,69 @@ +export const EVENT_TYPE = "m.room.member"; + +export class RoomMember { + constructor(data) { + this._data = data; + } + + static async updateOrCreateMember(roomId, memberData, memberEvent) { + if (!memberEvent) { + return; + } + + const userId = memberEvent.state_key; + const {content} = memberEvent; + + if (!userId || !content) { + return; + } + + let member; + if (memberData) { + member = new RoomMember(memberData); + member.updateWithMemberEvent(memberEvent); + } else { + member = RoomMember.fromMemberEvent(this._roomId, memberEvent); + } + return member; + } + + static fromMemberEvent(roomId, memberEvent) { + const userId = memberEvent && memberEvent.state_key; + if (!userId) { + return; + } + + const member = new RoomMember({ + roomId: roomId, + userId: userId, + avatarUrl: null, + displayName: null, + membership: null, + deviceTrackingStatus: 0, + }); + member.updateWithMemberEvent(memberEvent); + return member; + } + + get roomId() { + return this._data.roomId; + } + + get userId() { + return this._data.userId; + } + + updateWithMemberEvent(event) { + if (!event || !event.content) { + return; + } + const {content} = event; + this._data.membership = content.membership; + this._data.avatarUrl = content.avatar_url; + this._data.displayName = content.displayname; + } + + serialize() { + return this.data; + } +} diff --git a/src/matrix/room/RoomSummary.js b/src/matrix/room/RoomSummary.js index 177c1bc4..b15d9998 100644 --- a/src/matrix/room/RoomSummary.js +++ b/src/matrix/room/RoomSummary.js @@ -82,6 +82,7 @@ class SummaryData { this.heroes = copy ? copy.heroes : null; this.canonicalAlias = copy ? copy.canonicalAlias : null; this.altAliases = copy ? copy.altAliases : null; + this.hasFetchedMembers = copy ? copy.hasFetchedMembers : false; this.cloned = copy ? true : false; } @@ -132,6 +133,17 @@ export class RoomSummary { return this._data.joinCount; } + get hasFetchedMembers() { + return this._data.hasFetchedMembers; + } + + writeHasFetchedMembers(value, txn) { + const data = new SummaryData(this._data); + data.hasFetchedMembers = value; + txn.roomSummary.set(data.serialize()); + return data; + } + writeSync(roomResponse, membership, txn) { // clear cloned flag, so cloneIfNeeded makes a copy and // this._data is not modified if any field is changed. @@ -149,7 +161,7 @@ export class RoomSummary { } } - afterSync(data) { + applyChanges(data) { this._data = data; } diff --git a/src/matrix/room/timeline/persistence/SyncWriter.js b/src/matrix/room/timeline/persistence/SyncWriter.js index 92907b5a..6400aa9d 100644 --- a/src/matrix/room/timeline/persistence/SyncWriter.js +++ b/src/matrix/room/timeline/persistence/SyncWriter.js @@ -2,6 +2,7 @@ import {EventKey} from "../EventKey.js"; import {EventEntry} from "../entries/EventEntry.js"; import {FragmentBoundaryEntry} from "../entries/FragmentBoundaryEntry.js"; import {createEventEntry} from "./common.js"; +import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../RoomMember.js"; // Synapse bug? where the m.room.create event appears twice in sync response // when first syncing the room @@ -81,9 +82,76 @@ export class SyncWriter { return {oldFragment, newFragment}; } + async _writeMember(event, txn) { + if (!event) { + return; + } + + const userId = event.state_key; + const {content} = event; + + if (!userId || !content) { + return; + } + + let member; + if (memberData) { + member = new RoomMember(memberData); + member.updateWithMemberEvent(event); + } else { + member = RoomMember.fromMemberEvent(this._roomId, event); + } + } + + async _writeStateEvent(event, txn) { + if (event.type === MEMBER_EVENT_TYPE) { + const userId = event && event.state_key; + if (userId) { + const memberData = await txn.roomMembers.get(this._roomId, userId); + const member = updateOrCreateMember(this._roomId, memberData, event); + if (member) { + txn.roomMembers.set(member.serialize()); + } + } + } else { + txn.roomState.set(this._roomId, event); + } + } + + async _writeStateEvents(roomResponse, txn) { + // persist state + const {state, timeline} = roomResponse; + if (state.events) { + for (const event of state.events) { + await this._writeStateEvent(event, txn); + } + } + // persist live state events in timeline + if (timeline.events) { + for (const event of timeline.events) { + if (typeof event.state_key === "string") { + this._writeStateEvent(event, txn); + } + } + } + } + + _writeTimeline(entries, timeline, currentKey, txn) { + if (timeline.events) { + const events = deduplicateEvents(timeline.events); + for(const event of events) { + currentKey = currentKey.nextKey(); + const entry = createEventEntry(currentKey, this._roomId, event); + txn.timelineEvents.insert(entry); + entries.push(new EventEntry(entry, this._fragmentIdComparer)); + } + } + return currentKey; + } + async writeSync(roomResponse, txn) { const entries = []; - const timeline = roomResponse.timeline; + const {timeline} = roomResponse; let currentKey = this._lastLiveKey; if (!currentKey) { // means we haven't synced this room yet (just joined or did initial sync) @@ -101,30 +169,10 @@ export class SyncWriter { entries.push(FragmentBoundaryEntry.end(oldFragment, this._fragmentIdComparer)); entries.push(FragmentBoundaryEntry.start(newFragment, this._fragmentIdComparer)); } - if (timeline.events) { - const events = deduplicateEvents(timeline.events); - for(const event of events) { - currentKey = currentKey.nextKey(); - const entry = createEventEntry(currentKey, this._roomId, event); - txn.timelineEvents.insert(entry); - entries.push(new EventEntry(entry, this._fragmentIdComparer)); - } - } - // persist state - const state = roomResponse.state; - if (state.events) { - for (const event of state.events) { - txn.roomState.setStateEvent(this._roomId, event); - } - } - // persist live state events in timeline - if (timeline.events) { - for (const event of timeline.events) { - if (typeof event.state_key === "string") { - txn.roomState.setStateEvent(this._roomId, event); - } - } - } + + await this._writeStateEvents(roomResponse, txn); + + currentKey = this._writeTimeline(entries, timeline, currentKey, txn); return {entries, newLiveKey: currentKey}; } diff --git a/src/matrix/storage/common.js b/src/matrix/storage/common.js index 4ae55e65..fe0eebba 100644 --- a/src/matrix/storage/common.js +++ b/src/matrix/storage/common.js @@ -2,6 +2,7 @@ export const STORE_NAMES = Object.freeze([ "session", "roomState", "roomSummary", + "roomMembers", "timelineEvents", "timelineFragments", "pendingEvents", diff --git a/src/matrix/storage/idb/StorageFactory.js b/src/matrix/storage/idb/StorageFactory.js index 8f5babcd..00c8ead9 100644 --- a/src/matrix/storage/idb/StorageFactory.js +++ b/src/matrix/storage/idb/StorageFactory.js @@ -1,9 +1,10 @@ import {Storage} from "./Storage.js"; import { openDatabase, reqAsPromise } from "./utils.js"; import { exportSession, importSession } from "./export.js"; +import { schema } from "./schema.js"; const sessionName = sessionId => `brawl_session_${sessionId}`; -const openDatabaseWithSessionId = sessionId => openDatabase(sessionName(sessionId), createStores, 1); +const openDatabaseWithSessionId = sessionId => openDatabase(sessionName(sessionId), createStores, schema.length); export class StorageFactory { async create(sessionId) { @@ -28,26 +29,10 @@ export class StorageFactory { } } -function createStores(db) { - db.createObjectStore("session", {keyPath: "key"}); - // any way to make keys unique here? (just use put?) - db.createObjectStore("roomSummary", {keyPath: "roomId"}); +async function createStores(db, txn, oldVersion, version) { + const startIdx = oldVersion || 0; - // need index to find live fragment? prooobably ok without for now - //key = room_id | fragment_id - db.createObjectStore("timelineFragments", {keyPath: "key"}); - //key = room_id | fragment_id | event_index - const timelineEvents = db.createObjectStore("timelineEvents", {keyPath: "key"}); - //eventIdKey = room_id | event_id - timelineEvents.createIndex("byEventId", "eventIdKey", {unique: true}); - //key = room_id | event.type | event.state_key, - db.createObjectStore("roomState", {keyPath: "key"}); - db.createObjectStore("pendingEvents", {keyPath: "key"}); - - // const roomMembers = db.createObjectStore("roomMembers", {keyPath: [ - // "event.room_id", - // "event.content.membership", - // "event.state_key" - // ]}); - // roomMembers.createIndex("byName", ["room_id", "content.name"]); + for(let i = startIdx; i < version; ++i) { + await schema[i](db, txn); + } } diff --git a/src/matrix/storage/idb/Transaction.js b/src/matrix/storage/idb/Transaction.js index dc18321b..8e0dcc57 100644 --- a/src/matrix/storage/idb/Transaction.js +++ b/src/matrix/storage/idb/Transaction.js @@ -5,6 +5,7 @@ import {SessionStore} from "./stores/SessionStore.js"; import {RoomSummaryStore} from "./stores/RoomSummaryStore.js"; import {TimelineEventStore} from "./stores/TimelineEventStore.js"; import {RoomStateStore} from "./stores/RoomStateStore.js"; +import {RoomMemberStore} from "./stores/RoomMemberStore.js"; import {TimelineFragmentStore} from "./stores/TimelineFragmentStore.js"; import {PendingEventStore} from "./stores/PendingEventStore.js"; @@ -56,6 +57,10 @@ export class Transaction { return this._store("roomState", idbStore => new RoomStateStore(idbStore)); } + get roomMembers() { + return this._store("roomMembers", idbStore => new RoomMemberStore(idbStore)); + } + get pendingEvents() { return this._store("pendingEvents", idbStore => new PendingEventStore(idbStore)); } diff --git a/src/matrix/storage/idb/schema.js b/src/matrix/storage/idb/schema.js new file mode 100644 index 00000000..fc88b42f --- /dev/null +++ b/src/matrix/storage/idb/schema.js @@ -0,0 +1,65 @@ +import {iterateCursor} from "./utils.js"; +import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../room/RoomMember.js"; + +// FUNCTIONS SHOULD ONLY BE APPENDED!! +// the index in the array is the database version +export const schema = [ + createInitialStores, + createMemberStore, +]; +// TODO: how to deal with git merge conflicts of this array? + + +// how do we deal with schema updates vs existing data migration in a way that +//v1 +function createInitialStores(db) { + db.createObjectStore("session", {keyPath: "key"}); + // any way to make keys unique here? (just use put?) + db.createObjectStore("roomSummary", {keyPath: "roomId"}); + + // need index to find live fragment? prooobably ok without for now + //key = room_id | fragment_id + db.createObjectStore("timelineFragments", {keyPath: "key"}); + //key = room_id | fragment_id | event_index + const timelineEvents = db.createObjectStore("timelineEvents", {keyPath: "key"}); + //eventIdKey = room_id | event_id + timelineEvents.createIndex("byEventId", "eventIdKey", {unique: true}); + //key = room_id | event.type | event.state_key, + db.createObjectStore("roomState", {keyPath: "key"}); + db.createObjectStore("pendingEvents", {keyPath: "key"}); +} +//v2 +async function createMemberStore(db, txn) { + const roomMembers = db.createObjectStore("roomMembers", {keyPath: [ + "roomId", + "userId" + ]}); + // migrate existing member state events over + const roomState = txn.objectStore("roomState"); + await iterateCursor(roomState.openCursor(), entry => { + if (entry.event.type === MEMBER_EVENT_TYPE) { + roomState.delete(entry.key); + const member = RoomMember.fromMemberEvent(entry.roomId, entry.event); + if (member) { + roomMembers.add(member.serialize()); + } + } + }); +} + +function migrateKeyPathToArray(db, isNew) { + if (isNew) { + // create the new stores with the final name + } else { + // create the new stores with a tmp name + // migrate the data over + // change the name + } + + // maybe it is ok to just run all the migration steps? + // it might be a bit slower to create a store twice ... + // but at least the path of migration or creating a new store + // will go through the same code + // + // might not even be slower, as this is all happening within one transaction +} diff --git a/src/matrix/storage/idb/stores/MemberStore.js b/src/matrix/storage/idb/stores/MemberStore.js deleted file mode 100644 index e05da3e3..00000000 --- a/src/matrix/storage/idb/stores/MemberStore.js +++ /dev/null @@ -1,18 +0,0 @@ -// no historical members for now -class MemberStore { - async getMember(roomId, userId) { - - } - - /* async getMemberAtSortKey(roomId, userId, sortKey) { - - } */ - // multiple members here? does it happen at same sort key? - async setMembers(roomId, members) { - - } - - async getSortedMembers(roomId, offset, amount) { - - } -} diff --git a/src/matrix/storage/idb/stores/RoomMemberStore.js b/src/matrix/storage/idb/stores/RoomMemberStore.js new file mode 100644 index 00000000..67527ce2 --- /dev/null +++ b/src/matrix/storage/idb/stores/RoomMemberStore.js @@ -0,0 +1,24 @@ +// no historical members for now +export class RoomMemberStore { + constructor(roomMembersStore) { + this._roomMembersStore = roomMembersStore; + } + + get(roomId, userId) { + return this._roomMembersStore.get([roomId, userId]); + } + + async set(member) { + return this._roomMembersStore.put(member); + } + + /* + async getMemberAtSortKey(roomId, userId, sortKey) { + + } + + async getSortedMembers(roomId, offset, amount) { + + } + */ +} diff --git a/src/matrix/storage/idb/stores/RoomStateStore.js b/src/matrix/storage/idb/stores/RoomStateStore.js index 21c17c5a..65a973b0 100644 --- a/src/matrix/storage/idb/stores/RoomStateStore.js +++ b/src/matrix/storage/idb/stores/RoomStateStore.js @@ -3,15 +3,15 @@ export class RoomStateStore { this._roomStateStore = idbStore; } - async getEvents(type) { + async getAllForType(type) { } - async getEventsForKey(type, stateKey) { - + async get(type, stateKey) { + } - async setStateEvent(roomId, event) { + async set(roomId, event) { const key = `${roomId}|${event.type}|${event.state_key}`; const entry = {roomId, event, key}; return this._roomStateStore.put(entry); diff --git a/src/matrix/storage/idb/utils.js b/src/matrix/storage/idb/utils.js index 26f16eba..f80fe582 100644 --- a/src/matrix/storage/idb/utils.js +++ b/src/matrix/storage/idb/utils.js @@ -15,8 +15,9 @@ export function openDatabase(name, createObjectStore, version) { const req = window.indexedDB.open(name, version); req.onupgradeneeded = (ev) => { const db = ev.target.result; + const txn = ev.target.transaction; const oldVersion = ev.oldVersion; - createObjectStore(db, oldVersion, version); + createObjectStore(db, txn, oldVersion, version); }; return reqAsPromise(req); } @@ -52,7 +53,10 @@ export function iterateCursor(cursorRequest, processValue) { resolve(false); return; // end of results } - const {done, jumpTo} = processValue(cursor.value, cursor.key); + const result = processValue(cursor.value, cursor.key); + const done = result && result.done; + const jumpTo = result && result.jumpTo; + if (done) { resolve(true); } else if(jumpTo) { From 5ad7b74b2b63b73c62a33861362aadb3b57fb574 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 19 Aug 2020 16:07:58 +0200 Subject: [PATCH 05/36] keep string key since we have to support IE11 --- src/matrix/storage/idb/schema.js | 25 ++----------- .../storage/idb/stores/RoomMemberStore.js | 35 +++++++++++++------ 2 files changed, 27 insertions(+), 33 deletions(-) diff --git a/src/matrix/storage/idb/schema.js b/src/matrix/storage/idb/schema.js index fc88b42f..011f1e02 100644 --- a/src/matrix/storage/idb/schema.js +++ b/src/matrix/storage/idb/schema.js @@ -1,5 +1,6 @@ import {iterateCursor} from "./utils.js"; import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../room/RoomMember.js"; +import {RoomMemberStore} from "./stores/RoomMemberStore.js"; // FUNCTIONS SHOULD ONLY BE APPENDED!! // the index in the array is the database version @@ -30,10 +31,7 @@ function createInitialStores(db) { } //v2 async function createMemberStore(db, txn) { - const roomMembers = db.createObjectStore("roomMembers", {keyPath: [ - "roomId", - "userId" - ]}); + const roomMembers = new RoomMemberStore(db.createObjectStore("roomMembers", {keyPath: "key"})); // migrate existing member state events over const roomState = txn.objectStore("roomState"); await iterateCursor(roomState.openCursor(), entry => { @@ -41,25 +39,8 @@ async function createMemberStore(db, txn) { roomState.delete(entry.key); const member = RoomMember.fromMemberEvent(entry.roomId, entry.event); if (member) { - roomMembers.add(member.serialize()); + roomMembers.set(member.serialize()); } } }); } - -function migrateKeyPathToArray(db, isNew) { - if (isNew) { - // create the new stores with the final name - } else { - // create the new stores with a tmp name - // migrate the data over - // change the name - } - - // maybe it is ok to just run all the migration steps? - // it might be a bit slower to create a store twice ... - // but at least the path of migration or creating a new store - // will go through the same code - // - // might not even be slower, as this is all happening within one transaction -} diff --git a/src/matrix/storage/idb/stores/RoomMemberStore.js b/src/matrix/storage/idb/stores/RoomMemberStore.js index 67527ce2..677f41f0 100644 --- a/src/matrix/storage/idb/stores/RoomMemberStore.js +++ b/src/matrix/storage/idb/stores/RoomMemberStore.js @@ -1,24 +1,37 @@ -// no historical members for now +/* +Copyright 2020 Bruno Windels +Copyright 2020 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. +*/ + +function encodeKey(roomId, userId) { + return `${roomId}|${userId}`; +} + +// no historical members export class RoomMemberStore { constructor(roomMembersStore) { this._roomMembersStore = roomMembersStore; } get(roomId, userId) { - return this._roomMembersStore.get([roomId, userId]); + return this._roomMembersStore.get(encodeKey(roomId, userId)); } async set(member) { + member.key = encodeKey(member.roomId, member.userId); return this._roomMembersStore.put(member); } - /* - async getMemberAtSortKey(roomId, userId, sortKey) { - - } - - async getSortedMembers(roomId, offset, amount) { - - } - */ } From 2ad9b17ad74d82351aadda0f3de9dbcd702d2f41 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 19 Aug 2020 16:10:07 +0200 Subject: [PATCH 06/36] no need to update members, as all the info is in the member event as we won't store deviceTrackingStatus in the member --- src/matrix/room/RoomMember.js | 57 +++++-------------- .../room/timeline/persistence/SyncWriter.js | 30 ++-------- 2 files changed, 19 insertions(+), 68 deletions(-) diff --git a/src/matrix/room/RoomMember.js b/src/matrix/room/RoomMember.js index f6e5237d..7ac0141d 100644 --- a/src/matrix/room/RoomMember.js +++ b/src/matrix/room/RoomMember.js @@ -5,44 +5,25 @@ export class RoomMember { this._data = data; } - static async updateOrCreateMember(roomId, memberData, memberEvent) { - if (!memberEvent) { - return; - } - - const userId = memberEvent.state_key; - const {content} = memberEvent; - - if (!userId || !content) { - return; - } - - let member; - if (memberData) { - member = new RoomMember(memberData); - member.updateWithMemberEvent(memberEvent); - } else { - member = RoomMember.fromMemberEvent(this._roomId, memberEvent); - } - return member; - } - static fromMemberEvent(roomId, memberEvent) { const userId = memberEvent && memberEvent.state_key; if (!userId) { return; } - - const member = new RoomMember({ - roomId: roomId, - userId: userId, - avatarUrl: null, - displayName: null, - membership: null, - deviceTrackingStatus: 0, + const {content} = memberEvent; + const membership = content?.membership; + const avatarUrl = content?.avatar_url; + const displayName = content?.displayname; + if (typeof membership !== "string") { + return; + } + return new RoomMember({ + roomId, + userId, + membership, + avatarUrl, + displayName, }); - member.updateWithMemberEvent(memberEvent); - return member; } get roomId() { @@ -53,17 +34,7 @@ export class RoomMember { return this._data.userId; } - updateWithMemberEvent(event) { - if (!event || !event.content) { - return; - } - const {content} = event; - this._data.membership = content.membership; - this._data.avatarUrl = content.avatar_url; - this._data.displayName = content.displayname; - } - serialize() { - return this.data; + return this._data; } } diff --git a/src/matrix/room/timeline/persistence/SyncWriter.js b/src/matrix/room/timeline/persistence/SyncWriter.js index 51858164..62a2fb7e 100644 --- a/src/matrix/room/timeline/persistence/SyncWriter.js +++ b/src/matrix/room/timeline/persistence/SyncWriter.js @@ -98,34 +98,14 @@ export class SyncWriter { return {oldFragment, newFragment}; } - async _writeMember(event, txn) { - if (!event) { - return; - } - - const userId = event.state_key; - const {content} = event; - - if (!userId || !content) { - return; - } - - let member; - if (memberData) { - member = new RoomMember(memberData); - member.updateWithMemberEvent(event); - } else { - member = RoomMember.fromMemberEvent(this._roomId, event); - } - } - - async _writeStateEvent(event, txn) { + _writeStateEvent(event, txn) { if (event.type === MEMBER_EVENT_TYPE) { - const userId = event && event.state_key; + const userId = event.state_key; if (userId) { - const memberData = await txn.roomMembers.get(this._roomId, userId); - const member = updateOrCreateMember(this._roomId, memberData, event); + const member = RoomMember.fromMemberEvent(this._roomId, event); if (member) { + // as this is sync, we can just replace the member + // if it is there already txn.roomMembers.set(member.serialize()); } } From a5595570f9431fd2ec47ed0a99b19ffba8f28e9e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 19 Aug 2020 16:11:33 +0200 Subject: [PATCH 07/36] members hs api call --- src/matrix/net/HomeServerApi.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/matrix/net/HomeServerApi.js b/src/matrix/net/HomeServerApi.js index c71ef0a5..992bcd4a 100644 --- a/src/matrix/net/HomeServerApi.js +++ b/src/matrix/net/HomeServerApi.js @@ -126,6 +126,11 @@ export class HomeServerApi { return this._get(`/rooms/${encodeURIComponent(roomId)}/messages`, params, null, options); } + // params is at, membership and not_membership + members(roomId, params, options = null) { + return this._get(`/rooms/${encodeURIComponent(roomId)}/members`, params, null, options); + } + send(roomId, eventType, txnId, content, options = null) { return this._put(`/rooms/${encodeURIComponent(roomId)}/send/${encodeURIComponent(eventType)}/${encodeURIComponent(txnId)}`, {}, content, options); } From bfc5eb3ee5ea749f28d6cf7bf4fbea2e04acd680 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 19 Aug 2020 16:12:49 +0200 Subject: [PATCH 08/36] return changed members from sync writer we will use it to handle race between /sync and /members and to update the member list if it loaded --- src/matrix/room/Room.js | 17 +++++++++++--- .../room/timeline/persistence/SyncWriter.js | 22 ++++++++++++++----- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index ca7618f2..79f0b36d 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -36,20 +36,31 @@ export class Room extends EventEmitter { this._sendQueue = new SendQueue({roomId, storage, sendScheduler, pendingEvents}); this._timeline = null; this._user = user; + this._changedMembersDuringSync = null; } async writeSync(roomResponse, membership, txn) { const summaryChanges = this._summary.writeSync(roomResponse, membership, txn); - const {entries, newLiveKey} = await this._syncWriter.writeSync(roomResponse, txn); + const {entries, newLiveKey, changedMembers} = await this._syncWriter.writeSync(roomResponse, txn); let removedPendingEvents; if (roomResponse.timeline && roomResponse.timeline.events) { removedPendingEvents = this._sendQueue.removeRemoteEchos(roomResponse.timeline.events, txn); } - return {summaryChanges, newTimelineEntries: entries, newLiveKey, removedPendingEvents}; + return {summaryChanges, newTimelineEntries: entries, newLiveKey, removedPendingEvents, changedMembers}; } - afterSync({summaryChanges, newTimelineEntries, newLiveKey, removedPendingEvents}) { + afterSync({summaryChanges, newTimelineEntries, newLiveKey, removedPendingEvents, changedMembers}) { this._syncWriter.afterSync(newLiveKey); + if (changedMembers.length) { + if (this._changedMembersDuringSync) { + for (const member of changedMembers) { + this._changedMembersDuringSync.set(member.userId, member); + } + } + if (this._memberList) { + this._memberList.afterSync(changedMembers); + } + } if (summaryChanges) { this._summary.applyChanges(summaryChanges); this.emit("change"); diff --git a/src/matrix/room/timeline/persistence/SyncWriter.js b/src/matrix/room/timeline/persistence/SyncWriter.js index 62a2fb7e..dff28118 100644 --- a/src/matrix/room/timeline/persistence/SyncWriter.js +++ b/src/matrix/room/timeline/persistence/SyncWriter.js @@ -108,28 +108,37 @@ export class SyncWriter { // if it is there already txn.roomMembers.set(member.serialize()); } + return member; } } else { txn.roomState.set(this._roomId, event); } } - async _writeStateEvents(roomResponse, txn) { + _writeStateEvents(roomResponse, txn) { + const changedMembers = []; // persist state const {state, timeline} = roomResponse; if (state.events) { for (const event of state.events) { - await this._writeStateEvent(event, txn); + const member = this._writeStateEvent(event, txn); + if (member) { + changedMembers.push(member); + } } } // persist live state events in timeline if (timeline.events) { for (const event of timeline.events) { if (typeof event.state_key === "string") { - this._writeStateEvent(event, txn); + const member = this._writeStateEvent(event, txn); + if (member) { + changedMembers.push(member); + } } } } + return changedMembers; } _writeTimeline(entries, timeline, currentKey, txn) { @@ -165,12 +174,13 @@ export class SyncWriter { entries.push(FragmentBoundaryEntry.end(oldFragment, this._fragmentIdComparer)); entries.push(FragmentBoundaryEntry.start(newFragment, this._fragmentIdComparer)); } - - await this._writeStateEvents(roomResponse, txn); + // important this happens before _writeTimeline so + // members are available in the transaction + const changedMembers = this._writeStateEvents(roomResponse, txn); currentKey = this._writeTimeline(entries, timeline, currentKey, txn); - return {entries, newLiveKey: currentKey}; + return {entries, newLiveKey: currentKey, changedMembers}; } afterSync(newLiveKey) { From 9edd1bb0bbb23cbd782a073aea7993f1a5c0b021 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 19 Aug 2020 16:13:30 +0200 Subject: [PATCH 09/36] implement loading all members --- src/matrix/storage/idb/QueryTarget.js | 15 +++++++++------ src/matrix/storage/idb/stores/RoomMemberStore.js | 6 ++++++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/matrix/storage/idb/QueryTarget.js b/src/matrix/storage/idb/QueryTarget.js index 9b6f3036..8a542710 100644 --- a/src/matrix/storage/idb/QueryTarget.js +++ b/src/matrix/storage/idb/QueryTarget.js @@ -62,11 +62,11 @@ export class QueryTarget { } selectWhile(range, predicate) { - return this._selectWhile(range, predicate, "next"); + return this._selectWhile(range, predicate, "next", false); } selectWhileReverse(range, predicate) { - return this._selectWhile(range, predicate, "prev"); + return this._selectWhile(range, predicate, "prev", false); } async selectAll(range, direction) { @@ -153,15 +153,18 @@ export class QueryTarget { _selectLimit(range, amount, direction) { return this._selectWhile(range, (results) => { return results.length === amount; - }, direction); + }, direction, true); } - async _selectWhile(range, predicate, direction) { + async _selectWhile(range, predicate, direction, includeFailingPredicateResult) { const cursor = this._openCursor(range, direction); const results = []; await iterateCursor(cursor, (value) => { - results.push(value); - return {done: predicate(results)}; + const passesPredicate = predicate(results, value); + if (passesPredicate || includeFailingPredicateResult) { + results.push(value); + } + return {done: passesPredicate}; }); return results; } diff --git a/src/matrix/storage/idb/stores/RoomMemberStore.js b/src/matrix/storage/idb/stores/RoomMemberStore.js index 677f41f0..aa979056 100644 --- a/src/matrix/storage/idb/stores/RoomMemberStore.js +++ b/src/matrix/storage/idb/stores/RoomMemberStore.js @@ -34,4 +34,10 @@ export class RoomMemberStore { return this._roomMembersStore.put(member); } + getAll(roomId) { + const range = IDBKeyRange.lowerBound(encodeKey(roomId, "")); + return this._roomMembersStore.selectWhile(range, member => { + return member.roomId === roomId; + }); + } } From 6abdcd6b58b5ba1e9750d9ce0b7b5aee68ce83ec Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 19 Aug 2020 16:13:47 +0200 Subject: [PATCH 10/36] finish draft of member list loading method --- src/matrix/room/Room.js | 87 ++++++++++++++++++++++++++++------------- 1 file changed, 59 insertions(+), 28 deletions(-) diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 79f0b36d..debf6755 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -22,6 +22,7 @@ import {Timeline} from "./timeline/Timeline.js"; import {FragmentIdComparer} from "./timeline/FragmentIdComparer.js"; import {SendQueue} from "./sending/SendQueue.js"; import {WrappedError} from "../error.js" +import {RoomMember} from "./RoomMember.js"; export class Room extends EventEmitter { constructor({roomId, storage, hsApi, emitCollectionChange, sendScheduler, pendingEvents, user}) { @@ -92,42 +93,72 @@ export class Room extends EventEmitter { } async loadMemberList() { - let members; - if (!this._summary.hasFetchedMembers) { - // we need to get the syncToken here! - const memberResponse = await this._hsApi.members(this._roomId, syncToken).response; + if (this._memberList) { + this._memberList.retain(); + return this._memberList; + } else { + let members; + if (!this._summary.hasFetchedMembers) { + const paginationToken = throw new Error("not implemented"); + // TODO: move all of this out of Room + + // if any members are changed by sync while we're fetching members, + // they will end up here, so we check not to override them + this._changedMembersDuringSync = new Map(); + + const memberResponse = await this._hsApi.members(this._roomId, {at: paginationToken}).response; - const txn = await this._storage.readWriteTxn([ - this._storage.storeNames.roomSummary, - this._storage.storeNames.roomMembers, - ]); - const summaryChanges = this._summary.writeHasFetchedMembers(true, txn); - const {roomMembers} = txn; - const memberEvents = memberResponse.chunk; - if (!Array.isArray(memberEvents)) { - throw new Error("malformed"); - } - members = await Promise.all(memberEvents.map(async memberEvent => { - const userId = memberEvent && memberEvent.state_key; - if (!userId) { + const txn = await this._storage.readWriteTxn([ + this._storage.storeNames.roomSummary, + this._storage.storeNames.roomMembers, + ]); + const summaryChanges = this._summary.writeHasFetchedMembers(true, txn); + const {roomMembers} = txn; + const memberEvents = memberResponse.chunk; + if (!Array.isArray(memberEvents)) { throw new Error("malformed"); } - const memberData = await roomMembers.get(this._roomId, userId); - const member = updateOrCreateMember(this._roomId, memberData, event); - if (member) { - roomMembers.set(member.serialize()); - } - return member; - })); - await txn.complete(); - this._summary.applyChanges(summaryChanges); + members = await Promise.all(memberEvents.map(async memberEvent => { + const userId = memberEvent?.state_key; + if (!userId) { + throw new Error("malformed"); + } + // this member was changed during a sync that happened while calling /members + // and thus is more recent. Fetch it instead of overwriting. + if (this._changedMembersDuringSync.has(userId)) { + const memberData = await roomMembers.get(this._roomId, userId); + if (memberData) { + return new RoomMember(memberData); + } + } else { + const member = RoomMember.fromMemberEvent(this._roomId, memberEvent); + if (member) { + roomMembers.set(member.serialize()); + } + return member; + } + })); + this._changedMembersDuringSync = null; + await txn.complete(); + this._summary.applyChanges(summaryChanges); + } else { + const txn = await this._storage.readTxn([ + this._storage.storeNames.roomMembers, + ]); + const memberDatas = await txn.roomMembers.getAll(this._roomId); + members = memberDatas.map(d => new RoomMember(d)); + } + this._memberList = new MemberList({ + members, + closeCallback: () => { this._memberList = null; } + }); + return this._memberList; } - return new MemberList(this._roomId, members, this._storage); } - /** @public */ async fillGap(fragmentEntry, amount) { + // TODO move some/all of this out of Room if (fragmentEntry.edgeReached) { return; } From cc1f35a074ab9961062900349208046e320f4975 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 19 Aug 2020 16:14:10 +0200 Subject: [PATCH 11/36] fix c/p error --- src/matrix/storage/common.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/storage/common.js b/src/matrix/storage/common.js index 1d980998..0cf5b9b0 100644 --- a/src/matrix/storage/common.js +++ b/src/matrix/storage/common.js @@ -38,7 +38,7 @@ export class StorageError extends Error { fullMessage += `(name: ${cause.name}) `; } if (typeof cause.code === "number") { - fullMessage += `(code: ${cause.name}) `; + fullMessage += `(code: ${cause.code}) `; } } if (value) { From d08297d1e05661ae54033e90d33d40e408871bc6 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 19 Aug 2020 16:28:09 +0200 Subject: [PATCH 12/36] move RoomMembers to own dir --- src/matrix/room/Room.js | 2 +- src/matrix/room/{ => members}/RoomMember.js | 2 +- src/matrix/room/timeline/persistence/SyncWriter.js | 2 +- src/matrix/storage/idb/schema.js | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename src/matrix/room/{ => members}/RoomMember.js (95%) diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index debf6755..32434b62 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -22,7 +22,7 @@ import {Timeline} from "./timeline/Timeline.js"; import {FragmentIdComparer} from "./timeline/FragmentIdComparer.js"; import {SendQueue} from "./sending/SendQueue.js"; import {WrappedError} from "../error.js" -import {RoomMember} from "./RoomMember.js"; +import {RoomMember} from "./members/RoomMember.js"; export class Room extends EventEmitter { constructor({roomId, storage, hsApi, emitCollectionChange, sendScheduler, pendingEvents, user}) { diff --git a/src/matrix/room/RoomMember.js b/src/matrix/room/members/RoomMember.js similarity index 95% rename from src/matrix/room/RoomMember.js rename to src/matrix/room/members/RoomMember.js index 7ac0141d..067c6478 100644 --- a/src/matrix/room/RoomMember.js +++ b/src/matrix/room/members/RoomMember.js @@ -7,7 +7,7 @@ export class RoomMember { static fromMemberEvent(roomId, memberEvent) { const userId = memberEvent && memberEvent.state_key; - if (!userId) { + if (typeof userId !== "string") { return; } const {content} = memberEvent; diff --git a/src/matrix/room/timeline/persistence/SyncWriter.js b/src/matrix/room/timeline/persistence/SyncWriter.js index dff28118..6667cc5f 100644 --- a/src/matrix/room/timeline/persistence/SyncWriter.js +++ b/src/matrix/room/timeline/persistence/SyncWriter.js @@ -18,7 +18,7 @@ import {EventKey} from "../EventKey.js"; import {EventEntry} from "../entries/EventEntry.js"; import {FragmentBoundaryEntry} from "../entries/FragmentBoundaryEntry.js"; import {createEventEntry} from "./common.js"; -import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../RoomMember.js"; +import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../members/RoomMember.js"; // Synapse bug? where the m.room.create event appears twice in sync response // when first syncing the room diff --git a/src/matrix/storage/idb/schema.js b/src/matrix/storage/idb/schema.js index 011f1e02..21a108c8 100644 --- a/src/matrix/storage/idb/schema.js +++ b/src/matrix/storage/idb/schema.js @@ -1,5 +1,5 @@ import {iterateCursor} from "./utils.js"; -import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../room/RoomMember.js"; +import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../room/members/RoomMember.js"; import {RoomMemberStore} from "./stores/RoomMemberStore.js"; // FUNCTIONS SHOULD ONLY BE APPENDED!! From f84c9d51b420131687469e168851ca814eb3e0f5 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 19 Aug 2020 16:29:08 +0200 Subject: [PATCH 13/36] store last pagination token --- src/matrix/room/Room.js | 2 +- src/matrix/room/RoomSummary.js | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 32434b62..8357bc1d 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -99,7 +99,7 @@ export class Room extends EventEmitter { } else { let members; if (!this._summary.hasFetchedMembers) { - const paginationToken = throw new Error("not implemented"); + const paginationToken = this._summary.lastPaginationToken; // TODO: move all of this out of Room // if any members are changed by sync while we're fetching members, diff --git a/src/matrix/room/RoomSummary.js b/src/matrix/room/RoomSummary.js index 96eba2a8..dd443be3 100644 --- a/src/matrix/room/RoomSummary.js +++ b/src/matrix/room/RoomSummary.js @@ -27,7 +27,12 @@ function applySyncResponse(data, roomResponse, membership) { data = roomResponse.state.events.reduce(processEvent, data); } if (roomResponse.timeline) { - data = roomResponse.timeline.events.reduce(processEvent, data); + const {timeline} = roomResponse; + if (timeline.prev_batch) { + data = data.cloneIfNeeded(); + data.lastPaginationToken = timeline.prev_batch; + } + data = timeline.events.reduce(processEvent, data); } return data; @@ -99,6 +104,7 @@ class SummaryData { this.canonicalAlias = copy ? copy.canonicalAlias : null; this.altAliases = copy ? copy.altAliases : null; this.hasFetchedMembers = copy ? copy.hasFetchedMembers : false; + this.lastPaginationToken = copy ? copy.lastPaginationToken : null; this.cloned = copy ? true : false; } @@ -153,6 +159,10 @@ export class RoomSummary { return this._data.hasFetchedMembers; } + get lastPaginationToken() { + return this._data.lastPaginationToken; + } + writeHasFetchedMembers(value, txn) { const data = new SummaryData(this._data); data.hasFetchedMembers = value; From 463b9b745e6d7c87da3d5b518a855c0671f9c72c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 19 Aug 2020 16:29:54 +0200 Subject: [PATCH 14/36] base impl of member list --- src/matrix/room/Room.js | 1 + src/matrix/room/members/MemberList.js | 49 +++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 src/matrix/room/members/MemberList.js diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 8357bc1d..e2937b65 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -23,6 +23,7 @@ import {FragmentIdComparer} from "./timeline/FragmentIdComparer.js"; import {SendQueue} from "./sending/SendQueue.js"; import {WrappedError} from "../error.js" import {RoomMember} from "./members/RoomMember.js"; +import {MemberList} from "./members/MemberList.js"; export class Room extends EventEmitter { constructor({roomId, storage, hsApi, emitCollectionChange, sendScheduler, pendingEvents, user}) { diff --git a/src/matrix/room/members/MemberList.js b/src/matrix/room/members/MemberList.js new file mode 100644 index 00000000..f428ed6c --- /dev/null +++ b/src/matrix/room/members/MemberList.js @@ -0,0 +1,49 @@ +/* +Copyright 2020 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 {ObservableMap} from "../../../observable/map/ObservableMap.js"; + +export class MemberList { + constructor({members, closeCallback}) { + this._members = new ObservableMap(); + for (const member of members) { + this._members.add(member.userId, member); + } + this._closeCallback = closeCallback; + this._retentionCount = 1; + } + + afterSync(updatedMembers) { + for (const member of updatedMembers) { + this._members.add(member.userId, member); + } + } + + get members() { + return this._members; + } + + retain() { + this._retentionCount += 1; + } + + release() { + this._retentionCount -= 1; + if (this._retentionCount === 0) { + this._closeCallback(); + } + } +} From f7314990e4eff81c4ee782c55797d069262c6dce Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 19 Aug 2020 16:30:05 +0200 Subject: [PATCH 15/36] add copyright header --- src/matrix/room/members/RoomMember.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/matrix/room/members/RoomMember.js b/src/matrix/room/members/RoomMember.js index 067c6478..b794369e 100644 --- a/src/matrix/room/members/RoomMember.js +++ b/src/matrix/room/members/RoomMember.js @@ -1,3 +1,20 @@ +/* +Copyright 2020 Bruno Windels +Copyright 2020 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 EVENT_TYPE = "m.room.member"; export class RoomMember { From 4144b0b2813d07fc28bd483f50e70c33fd1c15f5 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 19 Aug 2020 16:44:09 +0200 Subject: [PATCH 16/36] move memberlist load code out of Room --- src/matrix/room/Room.js | 61 ++++--------------------- src/matrix/room/members/load.js | 79 +++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 52 deletions(-) create mode 100644 src/matrix/room/members/load.js diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index e2937b65..8695db97 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -22,7 +22,7 @@ import {Timeline} from "./timeline/Timeline.js"; import {FragmentIdComparer} from "./timeline/FragmentIdComparer.js"; import {SendQueue} from "./sending/SendQueue.js"; import {WrappedError} from "../error.js" -import {RoomMember} from "./members/RoomMember.js"; +import {fetchOrloadMembers} from "./members/load.js"; import {MemberList} from "./members/MemberList.js"; export class Room extends EventEmitter { @@ -98,57 +98,14 @@ export class Room extends EventEmitter { this._memberList.retain(); return this._memberList; } else { - let members; - if (!this._summary.hasFetchedMembers) { - const paginationToken = this._summary.lastPaginationToken; - // TODO: move all of this out of Room - - // if any members are changed by sync while we're fetching members, - // they will end up here, so we check not to override them - this._changedMembersDuringSync = new Map(); - - const memberResponse = await this._hsApi.members(this._roomId, {at: paginationToken}).response; - - const txn = await this._storage.readWriteTxn([ - this._storage.storeNames.roomSummary, - this._storage.storeNames.roomMembers, - ]); - const summaryChanges = this._summary.writeHasFetchedMembers(true, txn); - const {roomMembers} = txn; - const memberEvents = memberResponse.chunk; - if (!Array.isArray(memberEvents)) { - throw new Error("malformed"); - } - members = await Promise.all(memberEvents.map(async memberEvent => { - const userId = memberEvent?.state_key; - if (!userId) { - throw new Error("malformed"); - } - // this member was changed during a sync that happened while calling /members - // and thus is more recent. Fetch it instead of overwriting. - if (this._changedMembersDuringSync.has(userId)) { - const memberData = await roomMembers.get(this._roomId, userId); - if (memberData) { - return new RoomMember(memberData); - } - } else { - const member = RoomMember.fromMemberEvent(this._roomId, memberEvent); - if (member) { - roomMembers.set(member.serialize()); - } - return member; - } - })); - this._changedMembersDuringSync = null; - await txn.complete(); - this._summary.applyChanges(summaryChanges); - } else { - const txn = await this._storage.readTxn([ - this._storage.storeNames.roomMembers, - ]); - const memberDatas = await txn.roomMembers.getAll(this._roomId); - members = memberDatas.map(d => new RoomMember(d)); - } + const members = await fetchOrloadMembers({ + summary: this._summary, + roomId: this._roomId, + hsApi: this._hsApi, + storage: this._storage, + // to handle race between /members and /sync + setChangedMembersMap: map => this._changedMembersDuringSync = map, + }); this._memberList = new MemberList({ members, closeCallback: () => { this._memberList = null; } diff --git a/src/matrix/room/members/load.js b/src/matrix/room/members/load.js new file mode 100644 index 00000000..083eb13a --- /dev/null +++ b/src/matrix/room/members/load.js @@ -0,0 +1,79 @@ +/* +Copyright 2020 Bruno Windels +Copyright 2020 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 {RoomMember} from "./RoomMember.js"; + +async function loadMembers({roomId, storage}) { + const txn = await storage.readTxn([ + storage.storeNames.roomMembers, + ]); + const memberDatas = await txn.roomMembers.getAll(roomId); + return memberDatas.map(d => new RoomMember(d)); +} + +async function fetchMembers({summary, roomId, hsApi, storage, setChangedMembersMap}) { + // if any members are changed by sync while we're fetching members, + // they will end up here, so we check not to override them + const changedMembersDuringSync = new Map(); + setChangedMembersMap(changedMembersDuringSync); + + const memberResponse = await hsApi.members(roomId, {at: summary.lastPaginationToken}).response; + + const txn = await storage.readWriteTxn([ + storage.storeNames.roomSummary, + storage.storeNames.roomMembers, + ]); + const summaryChanges = summary.writeHasFetchedMembers(true, txn); + const {roomMembers} = txn; + const memberEvents = memberResponse.chunk; + if (!Array.isArray(memberEvents)) { + throw new Error("malformed"); + } + const members = await Promise.all(memberEvents.map(async memberEvent => { + const userId = memberEvent?.state_key; + if (!userId) { + throw new Error("malformed"); + } + // this member was changed during a sync that happened while calling /members + // and thus is more recent, so don't overwrite + if (changedMembersDuringSync.has(userId)) { + const memberData = await roomMembers.get(roomId, userId); + if (memberData) { + return new RoomMember(memberData); + } + } else { + const member = RoomMember.fromMemberEvent(roomId, memberEvent); + if (member) { + roomMembers.set(member.serialize()); + } + return member; + } + })); + setChangedMembersMap(null); + await txn.complete(); + summary.applyChanges(summaryChanges); + return members; +} + +export async function fetchOrLoadMembers(options) { + const {summary} = options; + if (!summary.hasFetchedMembers) { + return fetchMembers(options); + } else { + return loadMembers(options); + } +} From faaabd183704c7947218fce71768ab25cf20769b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 19 Aug 2020 16:51:16 +0200 Subject: [PATCH 17/36] fix changed member scenario (no need to fetch from storage) and errors --- src/matrix/room/members/load.js | 57 ++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 23 deletions(-) diff --git a/src/matrix/room/members/load.js b/src/matrix/room/members/load.js index 083eb13a..54d7c3dc 100644 --- a/src/matrix/room/members/load.js +++ b/src/matrix/room/members/load.js @@ -37,33 +37,44 @@ async function fetchMembers({summary, roomId, hsApi, storage, setChangedMembersM storage.storeNames.roomSummary, storage.storeNames.roomMembers, ]); - const summaryChanges = summary.writeHasFetchedMembers(true, txn); - const {roomMembers} = txn; - const memberEvents = memberResponse.chunk; - if (!Array.isArray(memberEvents)) { - throw new Error("malformed"); - } - const members = await Promise.all(memberEvents.map(async memberEvent => { - const userId = memberEvent?.state_key; - if (!userId) { + + let summaryChanges; + let members; + + try { + summaryChanges = summary.writeHasFetchedMembers(true, txn); + const {roomMembers} = txn; + const memberEvents = memberResponse.chunk; + if (!Array.isArray(memberEvents)) { throw new Error("malformed"); } - // this member was changed during a sync that happened while calling /members - // and thus is more recent, so don't overwrite - if (changedMembersDuringSync.has(userId)) { - const memberData = await roomMembers.get(roomId, userId); - if (memberData) { - return new RoomMember(memberData); + members = await Promise.all(memberEvents.map(async memberEvent => { + const userId = memberEvent?.state_key; + if (!userId) { + throw new Error("malformed"); } - } else { - const member = RoomMember.fromMemberEvent(roomId, memberEvent); - if (member) { - roomMembers.set(member.serialize()); + // this member was changed during a sync that happened while calling /members + // and thus is more recent, so don't overwrite + const changedMember = changedMembersDuringSync.get(userId); + if (changedMember) { + return changedMember; + } else { + const member = RoomMember.fromMemberEvent(roomId, memberEvent); + if (member) { + roomMembers.set(member.serialize()); + } + return member; } - return member; - } - })); - setChangedMembersMap(null); + })); + } catch (err) { + // abort txn on any error + txn.abort(); + throw err; + } finally { + // important this gets cleared + // or otherwise Room remains in "fetching-members" mode + setChangedMembersMap(null); + } await txn.complete(); summary.applyChanges(summaryChanges); return members; From 113c9e13b3271ca88de9874f774b0880d494e7be Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 19 Aug 2020 16:58:19 +0200 Subject: [PATCH 18/36] fix typo --- src/matrix/room/Room.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 8695db97..28cd0a50 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -22,7 +22,7 @@ import {Timeline} from "./timeline/Timeline.js"; import {FragmentIdComparer} from "./timeline/FragmentIdComparer.js"; import {SendQueue} from "./sending/SendQueue.js"; import {WrappedError} from "../error.js" -import {fetchOrloadMembers} from "./members/load.js"; +import {fetchOrLoadMembers} from "./members/load.js"; import {MemberList} from "./members/MemberList.js"; export class Room extends EventEmitter { @@ -98,7 +98,7 @@ export class Room extends EventEmitter { this._memberList.retain(); return this._memberList; } else { - const members = await fetchOrloadMembers({ + const members = await fetchOrLoadMembers({ summary: this._summary, roomId: this._roomId, hsApi: this._hsApi, From a90cebcabb2acd9804cd43cc1c6a61f6db62f9fb Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 19 Aug 2020 16:58:28 +0200 Subject: [PATCH 19/36] document access levels --- src/matrix/room/Room.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 28cd0a50..954dc62f 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -41,6 +41,7 @@ export class Room extends EventEmitter { this._changedMembersDuringSync = null; } + /** @package */ async writeSync(roomResponse, membership, txn) { const summaryChanges = this._summary.writeSync(roomResponse, membership, txn); const {entries, newLiveKey, changedMembers} = await this._syncWriter.writeSync(roomResponse, txn); @@ -51,6 +52,7 @@ export class Room extends EventEmitter { return {summaryChanges, newTimelineEntries: entries, newLiveKey, removedPendingEvents, changedMembers}; } + /** @package */ afterSync({summaryChanges, newTimelineEntries, newLiveKey, removedPendingEvents, changedMembers}) { this._syncWriter.afterSync(newLiveKey); if (changedMembers.length) { @@ -76,10 +78,12 @@ export class Room extends EventEmitter { } } + /** @package */ resumeSending() { this._sendQueue.resumeSending(); } + /** @package */ load(summary, txn) { try { this._summary.load(summary); @@ -89,10 +93,12 @@ export class Room extends EventEmitter { } } + /** @public */ sendEvent(eventType, content) { return this._sendQueue.enqueueEvent(eventType, content); } + /** @public */ async loadMemberList() { if (this._memberList) { this._memberList.retain(); @@ -161,14 +167,17 @@ export class Room extends EventEmitter { } } + /** @public */ get name() { return this._summary.name; } + /** @public */ get id() { return this._roomId; } + /** @public */ async openTimeline() { if (this._timeline) { throw new Error("not dealing with load race here for now"); @@ -189,10 +198,12 @@ export class Room extends EventEmitter { return this._timeline; } + /** @public */ mxcUrlThumbnail(url, width, height, method) { return this._hsApi.mxcUrlThumbnail(url, width, height, method); } + /** @public */ mxcUrl(url) { return this._hsApi.mxcUrl(url); } From 79363ed1d74378fefb27e7f1e44ad1805e26e471 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 19 Aug 2020 18:25:38 +0200 Subject: [PATCH 20/36] use optional chaining --- src/matrix/storage/idb/utils.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/matrix/storage/idb/utils.js b/src/matrix/storage/idb/utils.js index 75f2b08b..7cdc30fd 100644 --- a/src/matrix/storage/idb/utils.js +++ b/src/matrix/storage/idb/utils.js @@ -76,8 +76,8 @@ export function iterateCursor(cursorRequest, processValue) { return; // end of results } const result = processValue(cursor.value, cursor.key); - const done = result && result.done; - const jumpTo = result && result.jumpTo; + const done = result?.done; + const jumpTo = result?.jumpTo; if (done) { resolve(true); From fe7cc08287e0f76cb3807244475d6117334e6a23 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 19 Aug 2020 18:25:49 +0200 Subject: [PATCH 21/36] add _selectWhile with proper "while" semantics the existing _selectWhile method was more like _selectUntil, which is what we want for _selectLimit but not for selectWhile(Reverse) The changes we had made before also broke _selectLimit as it would look at the results length before the value got added so you always got 1 value more than requested, breaking sync. --- src/matrix/storage/idb/QueryTarget.js | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/matrix/storage/idb/QueryTarget.js b/src/matrix/storage/idb/QueryTarget.js index 8a542710..0738df60 100644 --- a/src/matrix/storage/idb/QueryTarget.js +++ b/src/matrix/storage/idb/QueryTarget.js @@ -62,11 +62,11 @@ export class QueryTarget { } selectWhile(range, predicate) { - return this._selectWhile(range, predicate, "next", false); + return this._selectWhile(range, predicate, "next"); } selectWhileReverse(range, predicate) { - return this._selectWhile(range, predicate, "prev", false); + return this._selectWhile(range, predicate, "prev"); } async selectAll(range, direction) { @@ -151,20 +151,31 @@ export class QueryTarget { } _selectLimit(range, amount, direction) { - return this._selectWhile(range, (results) => { + return this._selectUntil(range, (results) => { return results.length === amount; - }, direction, true); + }, direction); } - async _selectWhile(range, predicate, direction, includeFailingPredicateResult) { + async _selectUntil(range, predicate, direction) { const cursor = this._openCursor(range, direction); const results = []; await iterateCursor(cursor, (value) => { - const passesPredicate = predicate(results, value); - if (passesPredicate || includeFailingPredicateResult) { + results.push(value); + return {done: predicate(results, value)}; + }); + return results; + } + + // allows you to fetch one too much that won't get added when the predicate fails + async _selectWhile(range, predicate, direction) { + const cursor = this._openCursor(range, direction); + const results = []; + await iterateCursor(cursor, (value) => { + const passesPredicate = predicate(value); + if (passesPredicate) { results.push(value); } - return {done: passesPredicate}; + return {done: !passesPredicate}; }); return results; } From ba3a4ab8b2fc88493fd641696eba921a5ade9ab5 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 20 Aug 2020 09:39:19 +0200 Subject: [PATCH 22/36] process state events in the timeline together with other timeline events so member info gets overwritten after all the previous events have already been written --- .../room/timeline/persistence/SyncWriter.js | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/src/matrix/room/timeline/persistence/SyncWriter.js b/src/matrix/room/timeline/persistence/SyncWriter.js index 6667cc5f..967c56ce 100644 --- a/src/matrix/room/timeline/persistence/SyncWriter.js +++ b/src/matrix/room/timeline/persistence/SyncWriter.js @@ -118,7 +118,7 @@ export class SyncWriter { _writeStateEvents(roomResponse, txn) { const changedMembers = []; // persist state - const {state, timeline} = roomResponse; + const {state} = roomResponse; if (state.events) { for (const event of state.events) { const member = this._writeStateEvent(event, txn); @@ -127,31 +127,29 @@ export class SyncWriter { } } } - // persist live state events in timeline + return changedMembers; + } + + _writeTimeline(entries, timeline, currentKey, txn) { + const changedMembers = []; if (timeline.events) { - for (const event of timeline.events) { + const events = deduplicateEvents(timeline.events); + for(const event of events) { + // process live state events first, so new member info is available if (typeof event.state_key === "string") { const member = this._writeStateEvent(event, txn); if (member) { changedMembers.push(member); } } - } - } - return changedMembers; - } - - _writeTimeline(entries, timeline, currentKey, txn) { - if (timeline.events) { - const events = deduplicateEvents(timeline.events); - for(const event of events) { + // store event in timeline currentKey = currentKey.nextKey(); const entry = createEventEntry(currentKey, this._roomId, event); txn.timelineEvents.insert(entry); entries.push(new EventEntry(entry, this._fragmentIdComparer)); } } - return currentKey; + return {currentKey, changedMembers}; } async writeSync(roomResponse, txn) { @@ -177,8 +175,9 @@ export class SyncWriter { // important this happens before _writeTimeline so // members are available in the transaction const changedMembers = this._writeStateEvents(roomResponse, txn); - - currentKey = this._writeTimeline(entries, timeline, currentKey, txn); + const timelineResult = this._writeTimeline(entries, timeline, currentKey, txn); + currentKey = timelineResult.currentKey; + changedMembers.push(...timelineResult.changedMembers); return {entries, newLiveKey: currentKey, changedMembers}; } From 41c00ce44a2fd1eee18a5108f54d0724ab50d72d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 20 Aug 2020 10:26:08 +0200 Subject: [PATCH 23/36] write display name and avatar on event during sync --- .../room/timeline/persistence/SyncWriter.js | 34 +++++++++++++++---- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/src/matrix/room/timeline/persistence/SyncWriter.js b/src/matrix/room/timeline/persistence/SyncWriter.js index 967c56ce..5ab775a2 100644 --- a/src/matrix/room/timeline/persistence/SyncWriter.js +++ b/src/matrix/room/timeline/persistence/SyncWriter.js @@ -130,11 +130,21 @@ export class SyncWriter { return changedMembers; } - _writeTimeline(entries, timeline, currentKey, txn) { + async _writeTimeline(entries, timeline, currentKey, txn) { const changedMembers = []; if (timeline.events) { const events = deduplicateEvents(timeline.events); for(const event of events) { + // store event in timeline + currentKey = currentKey.nextKey(); + const entry = createEventEntry(currentKey, this._roomId, event); + let member = this._findMember(event.sender); + if (member) { + entry.displayName = member.displayName; + entry.avatarUrl = member.avatarUrl; + } + txn.timelineEvents.insert(entry); + entries.push(new EventEntry(entry, this._fragmentIdComparer)); // process live state events first, so new member info is available if (typeof event.state_key === "string") { const member = this._writeStateEvent(event, txn); @@ -142,16 +152,26 @@ export class SyncWriter { changedMembers.push(member); } } - // store event in timeline - currentKey = currentKey.nextKey(); - const entry = createEventEntry(currentKey, this._roomId, event); - txn.timelineEvents.insert(entry); - entries.push(new EventEntry(entry, this._fragmentIdComparer)); } } return {currentKey, changedMembers}; } + async _findMember(userId, events, txn) { + // TODO: perhaps add a small cache here? + const memberData = await txn.roomMembers.get(this._roomId, event.sender); + if (memberData) { + return new RoomMember(memberData); + } else { + const memberEvent = events.find(e => { + return e.type === MEMBER_EVENT_TYPE && e.state_key === event.sender; + }); + if (memberEvent) { + return RoomMember.fromMemberEvent(this._roomId, memberEvent); + } + } + } + async writeSync(roomResponse, txn) { const entries = []; const {timeline} = roomResponse; @@ -175,7 +195,7 @@ export class SyncWriter { // important this happens before _writeTimeline so // members are available in the transaction const changedMembers = this._writeStateEvents(roomResponse, txn); - const timelineResult = this._writeTimeline(entries, timeline, currentKey, txn); + const timelineResult = await this._writeTimeline(entries, timeline, currentKey, txn); currentKey = timelineResult.currentKey; changedMembers.push(...timelineResult.changedMembers); From d31a1b5fff0cde07c3444919c73ebec52a667688 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 20 Aug 2020 10:26:28 +0200 Subject: [PATCH 24/36] render display name in timeline --- .../room/timeline/tiles/MessageTile.js | 2 +- .../room/timeline/tiles/RoomMemberTile.js | 23 ++++++++++--------- .../room/timeline/entries/EventEntry.js | 8 +++++++ 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/domain/session/room/timeline/tiles/MessageTile.js b/src/domain/session/room/timeline/tiles/MessageTile.js index 8ad0b22e..550764c7 100644 --- a/src/domain/session/room/timeline/tiles/MessageTile.js +++ b/src/domain/session/room/timeline/tiles/MessageTile.js @@ -31,7 +31,7 @@ export class MessageTile extends SimpleTile { } get sender() { - return this._entry.sender; + return this._entry.displayName || this._entry.sender; } get senderColorNumber() { diff --git a/src/domain/session/room/timeline/tiles/RoomMemberTile.js b/src/domain/session/room/timeline/tiles/RoomMemberTile.js index 0abc0766..f1f790e2 100644 --- a/src/domain/session/room/timeline/tiles/RoomMemberTile.js +++ b/src/domain/session/room/timeline/tiles/RoomMemberTile.js @@ -23,35 +23,36 @@ export class RoomMemberTile extends SimpleTile { } get announcement() { - const {sender, content, prevContent, stateKey} = this._entry; + const {sender, content, prevContent} = this._entry; + const name = this._entry.displayName || sender; const membership = content && content.membership; const prevMembership = prevContent && prevContent.membership; if (prevMembership === "join" && membership === "join") { if (content.avatar_url !== prevContent.avatar_url) { - return `${stateKey} changed their avatar`; + return `${name} changed their avatar`; } else if (content.displayname !== prevContent.displayname) { - return `${stateKey} changed their name to ${content.displayname}`; + return `${name} changed their name to ${content.displayname}`; } } else if (membership === "join") { - return `${stateKey} joined the room`; + return `${name} joined the room`; } else if (membership === "invite") { - return `${stateKey} was invited to the room by ${sender}`; + return `${name} was invited to the room by ${sender}`; } else if (prevMembership === "invite") { if (membership === "join") { - return `${stateKey} accepted the invitation to join the room`; + return `${name} accepted the invitation to join the room`; } else if (membership === "leave") { - return `${stateKey} declined the invitation to join the room`; + return `${name} declined the invitation to join the room`; } } else if (membership === "leave") { - if (stateKey === sender) { - return `${stateKey} left the room`; + if (name === sender) { + return `${name} left the room`; } else { const reason = content.reason; - return `${stateKey} was kicked from the room by ${sender}${reason ? `: ${reason}` : ""}`; + return `${name} was kicked from the room by ${sender}${reason ? `: ${reason}` : ""}`; } } else if (membership === "ban") { - return `${stateKey} was banned from the room by ${sender}`; + return `${name} was banned from the room by ${sender}`; } return `${sender} membership changed to ${content.membership}`; diff --git a/src/matrix/room/timeline/entries/EventEntry.js b/src/matrix/room/timeline/entries/EventEntry.js index ead383fa..d1d5b64c 100644 --- a/src/matrix/room/timeline/entries/EventEntry.js +++ b/src/matrix/room/timeline/entries/EventEntry.js @@ -50,6 +50,14 @@ export class EventEntry extends BaseEntry { return this._eventEntry.event.sender; } + get displayName() { + return this._eventEntry.displayName; + } + + get avatarUrl() { + return this._eventEntry.avatarUrl; + } + get timestamp() { return this._eventEntry.event.origin_server_ts; } From 4b275529f7f8feca164139d92d1b5cf0104b1f36 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 20 Aug 2020 10:47:14 +0200 Subject: [PATCH 25/36] fixup: writing member info during sync --- .../room/timeline/persistence/SyncWriter.js | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/matrix/room/timeline/persistence/SyncWriter.js b/src/matrix/room/timeline/persistence/SyncWriter.js index 5ab775a2..d40dd86e 100644 --- a/src/matrix/room/timeline/persistence/SyncWriter.js +++ b/src/matrix/room/timeline/persistence/SyncWriter.js @@ -138,13 +138,14 @@ export class SyncWriter { // store event in timeline currentKey = currentKey.nextKey(); const entry = createEventEntry(currentKey, this._roomId, event); - let member = this._findMember(event.sender); - if (member) { - entry.displayName = member.displayName; - entry.avatarUrl = member.avatarUrl; + let memberData = await this._findMemberData(event.sender, events, txn); + if (memberData) { + entry.displayName = memberData.displayName; + entry.avatarUrl = memberData.avatarUrl; } txn.timelineEvents.insert(entry); entries.push(new EventEntry(entry, this._fragmentIdComparer)); + // process live state events first, so new member info is available if (typeof event.state_key === "string") { const member = this._writeStateEvent(event, txn); @@ -157,17 +158,21 @@ export class SyncWriter { return {currentKey, changedMembers}; } - async _findMember(userId, events, txn) { + async _findMemberData(userId, events, txn) { // TODO: perhaps add a small cache here? - const memberData = await txn.roomMembers.get(this._roomId, event.sender); + const memberData = await txn.roomMembers.get(this._roomId, userId); if (memberData) { - return new RoomMember(memberData); + console.log("got memberData from store", this._roomId, userId, memberData); + return memberData; } else { + // sometimes the member event isn't included in state, but rather in the timeline, + // even if it is not the first event in the timeline. In this case, go look for the + // first occurence const memberEvent = events.find(e => { - return e.type === MEMBER_EVENT_TYPE && e.state_key === event.sender; + return e.type === MEMBER_EVENT_TYPE && e.state_key === userId; }); if (memberEvent) { - return RoomMember.fromMemberEvent(this._roomId, memberEvent); + return RoomMember.fromMemberEvent(this._roomId, memberEvent)?.serialize(); } } } From 229502ca43b4449c180b862d516e602fdd47516b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 20 Aug 2020 10:48:59 +0200 Subject: [PATCH 26/36] remove logging --- src/matrix/room/timeline/persistence/SyncWriter.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/matrix/room/timeline/persistence/SyncWriter.js b/src/matrix/room/timeline/persistence/SyncWriter.js index d40dd86e..5cbd6779 100644 --- a/src/matrix/room/timeline/persistence/SyncWriter.js +++ b/src/matrix/room/timeline/persistence/SyncWriter.js @@ -162,7 +162,6 @@ export class SyncWriter { // TODO: perhaps add a small cache here? const memberData = await txn.roomMembers.get(this._roomId, userId); if (memberData) { - console.log("got memberData from store", this._roomId, userId, memberData); return memberData; } else { // sometimes the member event isn't included in state, but rather in the timeline, From 514d1d95799adcbbc2f8b2f8fe4a4d4e98606d75 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 20 Aug 2020 14:39:03 +0200 Subject: [PATCH 27/36] first draft of adding profile info while filling gap --- src/matrix/room/Room.js | 5 ++- .../room/timeline/persistence/GapWriter.js | 41 +++++++++++++++++-- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 954dc62f..44d021f8 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -130,7 +130,10 @@ export class Room extends EventEmitter { from: fragmentEntry.token, dir: fragmentEntry.direction.asApiString(), limit: amount, - filter: {lazy_load_members: true} + filter: { + lazy_load_members: true, + include_redundant_members: true, + } }).response(); const txn = await this._storage.readWriteTxn([ diff --git a/src/matrix/room/timeline/persistence/GapWriter.js b/src/matrix/room/timeline/persistence/GapWriter.js index 11b774d3..cfdefbd6 100644 --- a/src/matrix/room/timeline/persistence/GapWriter.js +++ b/src/matrix/room/timeline/persistence/GapWriter.js @@ -17,6 +17,7 @@ limitations under the License. import {EventKey} from "../EventKey.js"; import {EventEntry} from "../entries/EventEntry.js"; import {createEventEntry, directionalAppend} from "./common.js"; +import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../members/RoomMember.js"; export class GapWriter { constructor({roomId, storage, fragmentIdComparer}) { @@ -98,14 +99,21 @@ export class GapWriter { } } - _storeEvents(events, startKey, direction, txn) { + _storeEvents(events, startKey, direction, state, txn) { const entries = []; // events is in reverse chronological order for backwards pagination, // e.g. order is moving away from the `from` point. let key = startKey; - for(let event of events) { + for (let i = 0; i < events.length; ++i) { + const event = events[0]; key = key.nextKeyForDirection(direction); const eventStorageEntry = createEventEntry(key, this._roomId, event); + const memberEvent = this._findMemberEvent(event.sender, state, events, i, direction); + if (memberEvent) { + const memberData = RoomMember.fromMemberEvent(memberEvent)?.serialize(); + eventStorageEntry.displayName = memberData?.displayName; + eventStorageEntry.avatarUrl = memberData?.avatarUrl; + } txn.timelineEvents.insert(eventStorageEntry); const eventEntry = new EventEntry(eventStorageEntry, this._fragmentIdComparer); directionalAppend(entries, eventEntry, direction); @@ -113,6 +121,31 @@ export class GapWriter { return entries; } + _findMemberEvent(userId, state, events, index, direction) { + function isOurUser(event) { + return event.type === MEMBER_EVENT_TYPE && event.state_key === userId; + } + // older messages are further in the array when going backwards + const inc = direction.isBackward ? 1 : -1; + for (let i = index + inc; i >= 0 && i < events.length; i += inc) { + const event = events[i]; + if (isOurUser(event)) { + return event; + } + } + const stateMemberEvent = state.find(isOurUser); + if (stateMemberEvent) { + return stateMemberEvent; + } + // look into newer events as a fallback, even though it is techically not correct + for (let i = index - inc; i >= 0 && i < events.length; i -= inc) { + const event = events[i]; + if (isOurUser(event)) { + return event; + } + } + } + async _updateFragments(fragmentEntry, neighbourFragmentEntry, end, entries, txn) { const {direction} = fragmentEntry; const changedFragments = []; @@ -158,7 +191,7 @@ export class GapWriter { async writeFragmentFill(fragmentEntry, response, txn) { const {fragmentId, direction} = fragmentEntry; // chunk is in reverse-chronological order when backwards - const {chunk, start, end} = response; + const {chunk, start, end, state} = response; let entries; if (!Array.isArray(chunk)) { @@ -195,7 +228,7 @@ export class GapWriter { } = await this._findOverlappingEvents(fragmentEntry, chunk, txn); // create entries for all events in chunk, add them to entries - entries = this._storeEvents(nonOverlappingEvents, lastKey, direction, txn); + entries = this._storeEvents(nonOverlappingEvents, lastKey, direction, state, txn); const fragments = await this._updateFragments(fragmentEntry, neighbourFragmentEntry, end, entries, txn); return {entries, fragments}; From 830c300102e979e6ba9bcff6216efc4496fd491d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 20 Aug 2020 15:23:29 +0200 Subject: [PATCH 28/36] fix typo that broke the txn --- src/matrix/room/timeline/persistence/GapWriter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/room/timeline/persistence/GapWriter.js b/src/matrix/room/timeline/persistence/GapWriter.js index cfdefbd6..f64b1690 100644 --- a/src/matrix/room/timeline/persistence/GapWriter.js +++ b/src/matrix/room/timeline/persistence/GapWriter.js @@ -105,7 +105,7 @@ export class GapWriter { // e.g. order is moving away from the `from` point. let key = startKey; for (let i = 0; i < events.length; ++i) { - const event = events[0]; + const event = events[i]; key = key.nextKeyForDirection(direction); const eventStorageEntry = createEventEntry(key, this._roomId, event); const memberEvent = this._findMemberEvent(event.sender, state, events, i, direction); From fafdf669db16a6bcbbc1bb946795371ccaeede34 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 20 Aug 2020 15:23:56 +0200 Subject: [PATCH 29/36] use prev_content from later events before state --- src/matrix/room/members/RoomMember.js | 13 +++++++++- .../room/timeline/persistence/GapWriter.js | 26 +++++++++---------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/matrix/room/members/RoomMember.js b/src/matrix/room/members/RoomMember.js index b794369e..4c38f66b 100644 --- a/src/matrix/room/members/RoomMember.js +++ b/src/matrix/room/members/RoomMember.js @@ -27,7 +27,18 @@ export class RoomMember { if (typeof userId !== "string") { return; } - const {content} = memberEvent; + return this._fromMemberEventContent(roomId, userId, memberEvent.content); + } + + static fromReplacingMemberEvent(roomId, memberEvent) { + const userId = memberEvent && memberEvent.state_key; + if (typeof userId !== "string") { + return; + } + return this._fromMemberEventContent(roomId, userId, memberEvent.prev_content); + } + + static _fromMemberEventContent(roomId, userId, content) { const membership = content?.membership; const avatarUrl = content?.avatar_url; const displayName = content?.displayname; diff --git a/src/matrix/room/timeline/persistence/GapWriter.js b/src/matrix/room/timeline/persistence/GapWriter.js index f64b1690..7a7ab4f5 100644 --- a/src/matrix/room/timeline/persistence/GapWriter.js +++ b/src/matrix/room/timeline/persistence/GapWriter.js @@ -108,9 +108,8 @@ export class GapWriter { const event = events[i]; key = key.nextKeyForDirection(direction); const eventStorageEntry = createEventEntry(key, this._roomId, event); - const memberEvent = this._findMemberEvent(event.sender, state, events, i, direction); - if (memberEvent) { - const memberData = RoomMember.fromMemberEvent(memberEvent)?.serialize(); + const memberData = this._findMemberData(event.sender, state, events, i, direction); + if (memberData) { eventStorageEntry.displayName = memberData?.displayName; eventStorageEntry.avatarUrl = memberData?.avatarUrl; } @@ -121,29 +120,30 @@ export class GapWriter { return entries; } - _findMemberEvent(userId, state, events, index, direction) { + _findMemberData(userId, state, events, index, direction) { function isOurUser(event) { return event.type === MEMBER_EVENT_TYPE && event.state_key === userId; } - // older messages are further in the array when going backwards + // older messages are at a higher index in the array when going backwards const inc = direction.isBackward ? 1 : -1; for (let i = index + inc; i >= 0 && i < events.length; i += inc) { const event = events[i]; if (isOurUser(event)) { - return event; + return RoomMember.fromMemberEvent(this._roomId, event)?.serialize(); } } - const stateMemberEvent = state.find(isOurUser); - if (stateMemberEvent) { - return stateMemberEvent; - } - // look into newer events as a fallback, even though it is techically not correct - for (let i = index - inc; i >= 0 && i < events.length; i -= inc) { + // look into newer events, but using prev_content if found + for (let i = index; i >= 0 && i < events.length; i -= inc) { const event = events[i]; if (isOurUser(event)) { - return event; + return RoomMember.fromReplacingMemberEvent(this._roomId, event)?.serialize(); } } + // assuming the member hasn't changed within the chunk, just take it from state if it's there + const stateMemberEvent = state.find(isOurUser); + if (stateMemberEvent) { + return RoomMember.fromMemberEvent(this._roomId, stateMemberEvent)?.serialize(); + } } async _updateFragments(fragmentEntry, neighbourFragmentEntry, end, entries, txn) { From 036b305c96daa24b5062682894590fa3aee47dd5 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 20 Aug 2020 15:24:16 +0200 Subject: [PATCH 30/36] use display name for room name change tiles --- src/domain/session/room/timeline/tiles/RoomNameTile.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/room/timeline/tiles/RoomNameTile.js b/src/domain/session/room/timeline/tiles/RoomNameTile.js index cf5705dd..a7a785d0 100644 --- a/src/domain/session/room/timeline/tiles/RoomNameTile.js +++ b/src/domain/session/room/timeline/tiles/RoomNameTile.js @@ -24,6 +24,6 @@ export class RoomNameTile extends SimpleTile { get announcement() { const content = this._entry.content; - return `${this._entry.sender} named the room "${content.name}"` + return `${this._entry.displayName || this._entry.sender} named the room "${content.name}"` } } From 9e8d1ed2906e3c4a7195590e3ff32e1efdf44c4e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 20 Aug 2020 15:24:39 +0200 Subject: [PATCH 31/36] better naming --- src/ui/web/session/room/TimelineList.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ui/web/session/room/TimelineList.js b/src/ui/web/session/room/TimelineList.js index 8838963c..b43fcc27 100644 --- a/src/ui/web/session/room/TimelineList.js +++ b/src/ui/web/session/room/TimelineList.js @@ -48,8 +48,8 @@ export class TimelineList extends ListView { while (predicate()) { // fill, not enough content to fill timeline this._topLoadingPromise = this._viewModel.loadAtTop(); - const startReached = await this._topLoadingPromise; - if (startReached) { + const shouldStop = await this._topLoadingPromise; + if (shouldStop) { break; } } From 843f4fa0f7ed8074287c939ee6409752a97d84a7 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 20 Aug 2020 15:28:22 +0200 Subject: [PATCH 32/36] fix flood issue when back-filling isn't available --- src/domain/session/room/timeline/tiles/GapTile.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/domain/session/room/timeline/tiles/GapTile.js b/src/domain/session/room/timeline/tiles/GapTile.js index 0cfbb491..98d197b9 100644 --- a/src/domain/session/room/timeline/tiles/GapTile.js +++ b/src/domain/session/room/timeline/tiles/GapTile.js @@ -36,6 +36,9 @@ export class GapTile extends SimpleTile { console.error(`timeline.fillGap(): ${err.message}:\n${err.stack}`); this._error = err; this.emitChange("error"); + // rethrow so caller of this method + // knows not to keep calling this for now + throw err; } finally { this._loading = false; this.emitChange("isLoading"); From 5d0ee21267a182b9cad4e4f2f7165ea9982fb9ab Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 20 Aug 2020 15:40:43 +0200 Subject: [PATCH 33/36] move mxcUrl functions to media repo class --- .../session/room/timeline/tiles/ImageTile.js | 9 +--- .../room/timeline/tiles/MessageTile.js | 1 + .../session/room/timeline/tilesCreator.js | 5 +- src/matrix/net/HomeServerApi.js | 53 +++++++++++-------- src/matrix/room/Room.js | 10 +--- 5 files changed, 40 insertions(+), 38 deletions(-) diff --git a/src/domain/session/room/timeline/tiles/ImageTile.js b/src/domain/session/room/timeline/tiles/ImageTile.js index 8bdaa514..e72d28e4 100644 --- a/src/domain/session/room/timeline/tiles/ImageTile.js +++ b/src/domain/session/room/timeline/tiles/ImageTile.js @@ -20,15 +20,10 @@ const MAX_HEIGHT = 300; const MAX_WIDTH = 400; export class ImageTile extends MessageTile { - constructor(options, room) { - super(options); - this._room = room; - } - get thumbnailUrl() { const mxcUrl = this._getContent()?.url; if (typeof mxcUrl === "string") { - return this._room.mxcUrlThumbnail(mxcUrl, this.thumbnailWidth, this.thumbnailHeight, "scale"); + return this._mediaRepository.mxcUrlThumbnail(mxcUrl, this.thumbnailWidth, this.thumbnailHeight, "scale"); } return null; } @@ -36,7 +31,7 @@ export class ImageTile extends MessageTile { get url() { const mxcUrl = this._getContent()?.url; if (typeof mxcUrl === "string") { - return this._room.mxcUrl(mxcUrl); + return this._mediaRepository.mxcUrl(mxcUrl); } return null; } diff --git a/src/domain/session/room/timeline/tiles/MessageTile.js b/src/domain/session/room/timeline/tiles/MessageTile.js index 550764c7..3c5e5b56 100644 --- a/src/domain/session/room/timeline/tiles/MessageTile.js +++ b/src/domain/session/room/timeline/tiles/MessageTile.js @@ -20,6 +20,7 @@ import {getIdentifierColorNumber} from "../../../../avatar.js"; export class MessageTile extends SimpleTile { constructor(options) { super(options); + this._mediaRepository = options.mediaRepository; this._clock = options.clock; this._isOwn = this._entry.sender === options.ownUserId; this._date = this._entry.timestamp ? new Date(this._entry.timestamp) : null; diff --git a/src/domain/session/room/timeline/tilesCreator.js b/src/domain/session/room/timeline/tilesCreator.js index 1ae17bdc..567e9e7d 100644 --- a/src/domain/session/room/timeline/tilesCreator.js +++ b/src/domain/session/room/timeline/tilesCreator.js @@ -24,7 +24,8 @@ import {EncryptedEventTile} from "./tiles/EncryptedEventTile.js"; export function tilesCreator({room, ownUserId, clock}) { return function tilesCreator(entry, emitUpdate) { - const options = {entry, emitUpdate, ownUserId, clock}; + const options = {entry, emitUpdate, ownUserId, clock, + mediaRepository: room.mediaRepository}; if (entry.isGap) { return new GapTile(options, room); } else if (entry.eventType) { @@ -38,7 +39,7 @@ export function tilesCreator({room, ownUserId, clock}) { case "m.emote": return new TextTile(options); case "m.image": - return new ImageTile(options, room); + return new ImageTile(options); case "m.location": return new LocationTile(options); default: diff --git a/src/matrix/net/HomeServerApi.js b/src/matrix/net/HomeServerApi.js index 992bcd4a..ba4adb7f 100644 --- a/src/matrix/net/HomeServerApi.js +++ b/src/matrix/net/HomeServerApi.js @@ -45,6 +45,18 @@ class RequestWrapper { } } +function encodeQueryParams(queryParams) { + return Object.entries(queryParams || {}) + .filter(([, value]) => value !== undefined) + .map(([name, value]) => { + if (typeof value === "object") { + value = JSON.stringify(value); + } + return `${encodeURIComponent(name)}=${encodeURIComponent(value)}`; + }) + .join("&"); +} + export class HomeServerApi { constructor({homeServer, accessToken, request, createTimeout, reconnector}) { // store these both in a closure somehow so it's harder to get at in case of XSS? @@ -54,26 +66,15 @@ export class HomeServerApi { this._requestFn = request; this._createTimeout = createTimeout; this._reconnector = reconnector; + this._mediaRepository = new MediaRepository(homeServer); } _url(csPath) { return `${this._homeserver}/_matrix/client/r0${csPath}`; } - _encodeQueryParams(queryParams) { - return Object.entries(queryParams || {}) - .filter(([, value]) => value !== undefined) - .map(([name, value]) => { - if (typeof value === "object") { - value = JSON.stringify(value); - } - return `${encodeURIComponent(name)}=${encodeURIComponent(value)}`; - }) - .join("&"); - } - _request(method, url, queryParams, body, options) { - const queryString = this._encodeQueryParams(queryParams); + const queryString = encodeQueryParams(queryParams); url = `${url}?${queryString}`; let bodyString; const headers = new Map(); @@ -154,13 +155,14 @@ export class HomeServerApi { return this._request("GET", `${this._homeserver}/_matrix/client/versions`, null, null, options); } - _parseMxcUrl(url) { - const prefix = "mxc://"; - if (url.startsWith(prefix)) { - return url.substr(prefix.length).split("/", 2); - } else { - return null; - } + get mediaRepository() { + return this._mediaRepository; + } +} + +class MediaRepository { + constructor(homeserver) { + this._homeserver = homeserver; } mxcUrlThumbnail(url, width, height, method) { @@ -168,7 +170,7 @@ export class HomeServerApi { if (parts) { const [serverName, mediaId] = parts; const httpUrl = `${this._homeserver}/_matrix/media/r0/thumbnail/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`; - return httpUrl + "?" + this._encodeQueryParams({width, height, method}); + return httpUrl + "?" + encodeQueryParams({width, height, method}); } return null; } @@ -182,6 +184,15 @@ export class HomeServerApi { return null; } } + + _parseMxcUrl(url) { + const prefix = "mxc://"; + if (url.startsWith(prefix)) { + return url.substr(prefix.length).split("/", 2); + } else { + return null; + } + } } export function tests() { diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 44d021f8..7bea8362 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -201,14 +201,8 @@ export class Room extends EventEmitter { return this._timeline; } - /** @public */ - mxcUrlThumbnail(url, width, height, method) { - return this._hsApi.mxcUrlThumbnail(url, width, height, method); - } - - /** @public */ - mxcUrl(url) { - return this._hsApi.mxcUrl(url); + get mediaRepository() { + return this._hsApi.mediaRepository; } } From 225d46fad6f7150ac04a83728c373fbcd9c3ff0f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 20 Aug 2020 16:03:26 +0200 Subject: [PATCH 34/36] prepare styles to have other images (like avatar) in timeline --- src/ui/web/css/timeline.css | 2 +- src/ui/web/session/room/timeline/ImageView.js | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ui/web/css/timeline.css b/src/ui/web/css/timeline.css index 14b60b26..469e117f 100644 --- a/src/ui/web/css/timeline.css +++ b/src/ui/web/css/timeline.css @@ -45,7 +45,7 @@ limitations under the License. replace with css aspect-ratio once supported */ } -.message-container img { +.message-container img.picture { display: block; position: absolute; top: 0; diff --git a/src/ui/web/session/room/timeline/ImageView.js b/src/ui/web/session/room/timeline/ImageView.js index 4770510c..69360b75 100644 --- a/src/ui/web/session/room/timeline/ImageView.js +++ b/src/ui/web/session/room/timeline/ImageView.js @@ -22,6 +22,7 @@ export class ImageView extends TemplateView { // replace with css aspect-ratio once supported const heightRatioPercent = (vm.thumbnailHeight / vm.thumbnailWidth) * 100; const image = t.img({ + className: "picture", src: vm.thumbnailUrl, width: vm.thumbnailWidth, height: vm.thumbnailHeight, From 59bdd2b01562a021c7d192cb505ac7d101be1528 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 20 Aug 2020 16:03:52 +0200 Subject: [PATCH 35/36] render avatar on message tiles --- .../session/room/timeline/tiles/MessageTile.js | 15 +++++++++++++-- src/ui/web/css/themes/element/theme.css | 13 ++++++++++++- src/ui/web/session/room/timeline/common.js | 16 ++++++++++++++-- 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/domain/session/room/timeline/tiles/MessageTile.js b/src/domain/session/room/timeline/tiles/MessageTile.js index 3c5e5b56..794ba1a9 100644 --- a/src/domain/session/room/timeline/tiles/MessageTile.js +++ b/src/domain/session/room/timeline/tiles/MessageTile.js @@ -15,7 +15,7 @@ limitations under the License. */ import {SimpleTile} from "./SimpleTile.js"; -import {getIdentifierColorNumber} from "../../../../avatar.js"; +import {getIdentifierColorNumber, avatarInitials} from "../../../../avatar.js"; export class MessageTile extends SimpleTile { constructor(options) { @@ -35,10 +35,21 @@ export class MessageTile extends SimpleTile { return this._entry.displayName || this._entry.sender; } - get senderColorNumber() { + get avatarColorNumber() { return getIdentifierColorNumber(this._entry.sender); } + get avatarUrl() { + if (this._entry.avatarUrl) { + return this._mediaRepository.mxcUrlThumbnail(this._entry.avatarUrl, 30, 30, "crop"); + } + return null; + } + + get avatarLetter() { + return avatarInitials(this.sender); + } + get date() { return this._date && this._date.toLocaleDateString({}, {month: "numeric", day: "numeric"}); } diff --git a/src/ui/web/css/themes/element/theme.css b/src/ui/web/css/themes/element/theme.css index 53ccb2f8..82754140 100644 --- a/src/ui/web/css/themes/element/theme.css +++ b/src/ui/web/css/themes/element/theme.css @@ -297,15 +297,25 @@ ul.Timeline > li:not(.continuation) { margin-top: 7px; } -ul.Timeline > li.continuation .sender { +ul.Timeline > li.continuation .profile { display: none; } + .message-container { padding: 1px 10px 0px 10px; margin: 5px 10px 0 10px; } +.message-container .profile { + display: flex; + align-items: center; +} + +.message-container .avatar { + --avatar-size: 25px; +} + .TextMessageView.continuation .message-container { margin-top: 0; margin-bottom: 0; @@ -313,6 +323,7 @@ ul.Timeline > li.continuation .sender { .message-container .sender { margin: 6px 0; + margin-left: 6px; font-weight: bold; line-height: 1.7rem; } diff --git a/src/ui/web/session/room/timeline/common.js b/src/ui/web/session/room/timeline/common.js index 18bf0be0..b7965905 100644 --- a/src/ui/web/session/room/timeline/common.js +++ b/src/ui/web/session/room/timeline/common.js @@ -22,8 +22,20 @@ export function renderMessage(t, vm, children) { pending: vm.isPending, continuation: vm => vm.isContinuation, }; - const sender = t.div({className: `sender usercolor${vm.senderColorNumber}`}, vm.sender); - children = [sender].concat(children); + + const hasAvatar = !!vm.avatarUrl; + const avatarClasses = { + avatar: true, + [`usercolor${vm.avatarColorNumber}`]: !hasAvatar, + }; + const avatarContent = hasAvatar ? + t.img({src: vm.avatarUrl, width: "30", height: "30", title: vm.sender}) : + vm.avatarLetter; + const profile = t.div({className: "profile"}, [ + t.div({className: avatarClasses}, [avatarContent]), + t.div({className: `sender usercolor${vm.avatarColorNumber}`}, vm.sender) + ]); + children = [profile].concat(children); return t.li( {className: classes}, t.div({className: "message-container"}, children) From f5acee02be061c49eaee42405c518d66d612c800 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 20 Aug 2020 16:20:35 +0200 Subject: [PATCH 36/36] add comment why we do things in this order --- src/matrix/room/timeline/persistence/GapWriter.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/matrix/room/timeline/persistence/GapWriter.js b/src/matrix/room/timeline/persistence/GapWriter.js index 7a7ab4f5..834239f7 100644 --- a/src/matrix/room/timeline/persistence/GapWriter.js +++ b/src/matrix/room/timeline/persistence/GapWriter.js @@ -132,7 +132,10 @@ export class GapWriter { return RoomMember.fromMemberEvent(this._roomId, event)?.serialize(); } } - // look into newer events, but using prev_content if found + // look into newer events, but using prev_content if found. + // We do this before looking into `state` because it is not well specified + // in the spec whether the events in there represent state before or after `chunk`. + // So we look both directions first in chunk to make sure it doesn't matter. for (let i = index; i >= 0 && i < events.length; i -= inc) { const event = events[i]; if (isOurUser(event)) {