From d0c1ddb51b41c23d234bdb0af7a4a4cf527ef48f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 26 Aug 2021 15:16:51 +0200 Subject: [PATCH 01/17] add failing test --- src/matrix/room/timeline/persistence/MemberWriter.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/matrix/room/timeline/persistence/MemberWriter.js b/src/matrix/room/timeline/persistence/MemberWriter.js index db649f68..3d35ff47 100644 --- a/src/matrix/room/timeline/persistence/MemberWriter.js +++ b/src/matrix/room/timeline/persistence/MemberWriter.js @@ -243,6 +243,17 @@ export function tests() { assert.equal(change.member.membership, "join"); assert.equal(txn.members.get(alice).displayName, "Alice"); }, + "new join through both timeline and state": async assert => { + const writer = new MemberWriter(roomId); + const txn = createStorage(); + const aliceJoin = createMemberEvent("join", alice, "Alice"); + const change = await writer.writeStateMemberEvent(aliceJoin, false, txn); + assert(!change.hasJoined); + assert(!change.hasLeft); + const timelineChange = await writer.writeTimelineMemberEvent(aliceJoin, txn); + assert(change.hasJoined); + assert(!change.hasLeft); + }, "newly joined member causes a change with lookup done first": async assert => { const event = createMemberEvent("join", alice, "Alice"); const writer = new MemberWriter(roomId); From 826de7e9cb13ff5b9d50e9266892784875d7dea3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 27 Aug 2021 11:49:40 +0200 Subject: [PATCH 02/17] Write all members of a sync in one go so state member events written but not causing a memberChange.hasJoined don't prevent timeline member events for the same user from doing so --- src/matrix/room/Room.js | 4 +- src/matrix/room/members/RoomMember.js | 10 + .../room/timeline/persistence/MemberWriter.js | 363 +++++++++++++----- .../room/timeline/persistence/SyncWriter.js | 117 +++--- 4 files changed, 332 insertions(+), 162 deletions(-) diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index f5c42097..cab0e13b 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -30,6 +30,7 @@ const EVENT_ENCRYPTED_TYPE = "m.room.encrypted"; export class Room extends BaseRoom { constructor(options) { super(options); + // TODO: pass pendingEvents to start like pendingOperations? const {pendingEvents} = options; const relationWriter = new RelationWriter({ roomId: this.id, @@ -120,7 +121,8 @@ export class Room extends BaseRoom { txn.roomMembers.removeAllForRoom(this.id); } const {entries: newEntries, updatedEntries, newLiveKey, memberChanges} = - await log.wrap("syncWriter", log => this._syncWriter.writeSync(roomResponse, isRejoin, txn, log), log.level.Detail); + await log.wrap("syncWriter", log => this._syncWriter.writeSync( + roomResponse, isRejoin, summaryChanges.hasFetchedMembers, txn, log), log.level.Detail); if (decryptChanges) { const decryption = await log.wrap("decryptChanges", log => decryptChanges.write(txn, log)); log.set("decryptionResults", decryption.results.size); diff --git a/src/matrix/room/members/RoomMember.js b/src/matrix/room/members/RoomMember.js index f810a9fe..78096060 100644 --- a/src/matrix/room/members/RoomMember.js +++ b/src/matrix/room/members/RoomMember.js @@ -141,6 +141,16 @@ export class MemberChange { return this.previousMembership === "join" && this.membership !== "join"; } + /** The result can be a false negative when all of these apply: + * - the complete set of room members hasn't been fetched yet. + * - the member event for this change was received in the + * state section and wasn't present in the timeline section. + * - the room response was limited, e.g. there was a gap. + * + * This is because during sync, in this case it is not possible + * to distinguish between a new member that joined the room + * during a gap and a lazy-loading member. + * */ get hasJoined() { return this.previousMembership !== "join" && this.membership === "join"; } diff --git a/src/matrix/room/timeline/persistence/MemberWriter.js b/src/matrix/room/timeline/persistence/MemberWriter.js index 3d35ff47..0c24b06f 100644 --- a/src/matrix/room/timeline/persistence/MemberWriter.js +++ b/src/matrix/room/timeline/persistence/MemberWriter.js @@ -23,57 +23,27 @@ export class MemberWriter { this._cache = new LRUCache(5, member => member.userId); } - writeTimelineMemberEvent(event, txn) { - return this._writeMemberEvent(event, false, txn); + prepareMemberSync(stateEvents, timelineEvents, hasFetchedMembers) { + return new MemberSync(this, stateEvents, timelineEvents, hasFetchedMembers); } - writeStateMemberEvent(event, isLimited, txn) { - // member events in the state section when the room response - // is not limited must always be lazy loaded members. - // If they are not, they will be repeated in the timeline anyway. - return this._writeMemberEvent(event, !isLimited, txn); - } - - async _writeMemberEvent(event, isLazyLoadingMember, txn) { - const userId = event.state_key; - if (!userId) { - return; - } - const member = RoomMember.fromMemberEvent(this._roomId, event); - if (!member) { - return; - } - - let existingMember = this._cache.get(userId); + async _writeMember(member, txn) { + let existingMember = this._cache.get(member.userId); if (!existingMember) { - const memberData = await txn.roomMembers.get(this._roomId, userId); + const memberData = await txn.roomMembers.get(this._roomId, member.userId); if (memberData) { existingMember = new RoomMember(memberData); } } - // either never heard of the member, or something changed if (!existingMember || !existingMember.equals(member)) { txn.roomMembers.set(member.serialize()); this._cache.set(member); - // we also return a member change for lazy loading members if something changed, - // so when the dupe timeline event comes and it doesn't see a diff - // with the cache, we already returned the event here. - // - // it's just important that we don't consider the first LL event - // for a user we see as a membership change, or we'll share keys with - // them, etc... - if (isLazyLoadingMember && !existingMember) { - // we don't have a previous member, but we know this is not a - // membership change as it's a lazy loaded - // member so take the membership from the member - return new MemberChange(member, member.membership); - } return new MemberChange(member, existingMember?.membership); } } - async lookupMember(userId, event, timelineEvents, txn) { + async lookupMember(userId, txn) { let member = this._cache.get(userId); if (!member) { const memberData = await txn.roomMembers.get(this._roomId, userId); @@ -82,60 +52,170 @@ export class MemberWriter { this._cache.set(member); } } - if (!member) { - // 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 last one before the event, or if none is found, - // the least recent matching member event in the timeline. - // The latter is needed because of new joins picking up their own display name - let foundEvent = false; - let memberEventBefore; - let firstMemberEvent; - for (let i = timelineEvents.length - 1; i >= 0; i -= 1) { - const e = timelineEvents[i]; - let matchingEvent; - if (e.type === MEMBER_EVENT_TYPE && e.state_key === userId) { - matchingEvent = e; - firstMemberEvent = matchingEvent; + return member; + } +} + +class MemberSync { + constructor(memberWriter, stateEvents, timelineEvents, hasFetchedMembers) { + this._memberWriter = memberWriter; + this._timelineEvents = timelineEvents; + this._hasFetchedMembers = hasFetchedMembers; + this._newStateMembers = stateEvents && this._stateEventsToMembers(stateEvents); + } + + get _roomId() { + return this._memberWriter._roomId; + } + + _stateEventsToMembers(stateEvents) { + let members; + for (const event of stateEvents) { + if (event.type === MEMBER_EVENT_TYPE) { + const member = RoomMember.fromMemberEvent(this._roomId, event); + if (member) { + if (!members) { + members = new Map(); + } + members.set(member.userId, member); + } + } + } + return members; + } + + _timelineEventsToMembers() { + let members; + // iterate backwards to only add the last member in the timeline + for (let i = this._timelineEvents.length - 1; i >= 0; i--) { + const e = this._timelineEvents[i]; + const userId = e.state_key; + if (e.type === MEMBER_EVENT_TYPE && !members?.has(userId)) { + const member = RoomMember.fromMemberEvent(this._roomId, e); + if (member) { + if (!members) { + members = new Map(); + } + members.set(member.userId, member); + } + } + } + return members; + } + + async lookupMemberAtEvent(userId, event, txn) { + let member = this._findPrecedingMemberEventInTimeline(userId, event); + if (member) { + return member; + } + member = this._newStateMembers?.get(userId); + if (member) { + return member; + } + return await this._memberWriter.lookupMember(userId, txn); + } + + async write(txn) { + const memberChanges = new Map(); + const newTimelineMembers = this._timelineEventsToMembers(); + if (this._newStateMembers) { + for (const member of this._newStateMembers.values()) { + if (!newTimelineMembers?.has(member.userId)) { + const memberChange = await this._memberWriter._writeMember(member, txn); + if (memberChange) { + // if the member event appeared only in the state section, + // AND we haven't heard about it AND we haven't fetched all members yet (to avoid #470), + // this may be a lazy loading member (if it's not in a gap, we are certain + // it is a ll member, in a gap, we can't tell), so we pass in our own membership as + // as the previous one so we won't consider it a join to not have false positives (to avoid #192). + // see also MemberChange.hasJoined + const maybeLazyLoadingMember = !this._hasFetchedMembers && !memberChange.previousMembership; + if (maybeLazyLoadingMember) { + memberChange.previousMembership = member.membership; + } + memberChanges.set(memberChange.userId, memberChange); + } + } + } + } + if (newTimelineMembers) { + for (const member of newTimelineMembers.values()) { + const memberChange = await this._memberWriter._writeMember(member, txn); + if (memberChange) { + memberChanges.set(memberChange.userId, memberChange); + } + } + } + return memberChanges; + } + + // try to find the first member event before the given event, + // so we respect historical display names within the chunk of timeline + _findPrecedingMemberEventInTimeline(userId, event) { + let eventIndex = -1; + for (let i = this._timelineEvents.length - 1; i >= 0; i--) { + const e = this._timelineEvents[i]; + if (e.event_id === event.event_id) { + eventIndex = i; + break; + } + } + for (let i = eventIndex - 1; i >= 0; i--) { + const e = this._timelineEvents[i]; + if (e.type === MEMBER_EVENT_TYPE && e.state_key === userId) { + const member = RoomMember.fromMemberEvent(this._roomId, e); + if (member) { + return member; } - if (!foundEvent) { - if (e.event_id === event.event_id) { - foundEvent = true; - } - } else if (matchingEvent) { - memberEventBefore = matchingEvent; - break; - } - } - // first see if we found a member event before the event we're looking up the sender for - if (memberEventBefore) { - member = RoomMember.fromMemberEvent(this._roomId, memberEventBefore); - } - // and only if we didn't, fall back to the first member event, - // regardless of where it is positioned relative to the lookup event - else if (firstMemberEvent) { - member = RoomMember.fromMemberEvent(this._roomId, firstMemberEvent); } } - return member; } } export function tests() { + let idCounter = 0; + function createMemberEvent(membership, userId, displayName, avatarUrl) { + idCounter += 1; return { content: { membership, "displayname": displayName, "avatar_url": avatarUrl }, + event_id: `$${idCounter}`, sender: userId, "state_key": userId, type: "m.room.member" }; } + function createRoomResponse(stateEvents, timelineEvents) { + if (!Array.isArray(timelineEvents)) { + timelineEvents = [timelineEvents]; + } + if (!Array.isArray(stateEvents)) { + stateEvents = [stateEvents]; + } + return { + timeline: { + limited: false, + events: timelineEvents, + }, + state: { + events: stateEvents + } + }; + } + + function createTimelineResponse(timelineEvents) { + return createRoomResponse([], timelineEvents) + } + + function createStateReponse(stateEvents) { + return createRoomResponse(stateEvents, []); + } function createStorage(initialMembers = []) { const members = new Map(); @@ -163,81 +243,126 @@ export function tests() { const alice = "@alice:hs.tld"; const avatar = "mxc://hs.tld/def"; +/* + join without previous membership + join during gap with hasFetchedMembers=false + join during gap with hasFetchedMembers=true + join after invite + +*/ + return { - "new join through state": async assert => { + "new join": async assert => { const writer = new MemberWriter(roomId); const txn = createStorage(); - const change = await writer.writeStateMemberEvent(createMemberEvent("join", alice), true, txn); + const memberSync = writer.prepareMemberSync([], [createMemberEvent("join", alice)], false); + const changes = await memberSync.write(txn); + assert.equal(changes.size, 1); + const change = changes.get(alice); assert(change.hasJoined); assert.equal(txn.members.get(alice).membership, "join"); }, - "accept invite through state": async assert => { + "accept invite": async assert => { const writer = new MemberWriter(roomId); const txn = createStorage([member("invite", alice)]); - const change = await writer.writeStateMemberEvent(createMemberEvent("join", alice), true, txn); + const memberSync = writer.prepareMemberSync([], [createMemberEvent("join", alice)], false); + const changes = await memberSync.write(txn); + assert.equal(changes.size, 1); + const change = changes.get(alice); assert.equal(change.previousMembership, "invite"); assert(change.hasJoined); assert.equal(txn.members.get(alice).membership, "join"); }, - "change display name through timeline": async assert => { + "change display name": async assert => { const writer = new MemberWriter(roomId); const txn = createStorage([member("join", alice, "Alice")]); - const change = await writer.writeTimelineMemberEvent(createMemberEvent("join", alice, "Alies"), txn); + const memberSync = writer.prepareMemberSync([], [createMemberEvent("join", alice, "Alies")], false); + const changes = await memberSync.write(txn); + assert.equal(changes.size, 1); + const change = changes.get(alice); assert(!change.hasJoined); assert.equal(change.member.displayName, "Alies"); assert.equal(txn.members.get(alice).displayName, "Alies"); }, - "set avatar through timeline": async assert => { + "set avatar": async assert => { const writer = new MemberWriter(roomId); const txn = createStorage([member("join", alice, "Alice")]); - const change = await writer.writeTimelineMemberEvent(createMemberEvent("join", alice, "Alice", avatar), txn); + const memberSync = writer.prepareMemberSync([], [createMemberEvent("join", alice, "Alice", avatar)], false); + const changes = await memberSync.write(txn); + assert.equal(changes.size, 1); + const change = changes.get(alice); assert(!change.hasJoined); assert.equal(change.member.avatarUrl, avatar); assert.equal(txn.members.get(alice).avatarUrl, avatar); }, - "ignore redundant member event": async assert => { + "ignore redundant member event in timeline": async assert => { const writer = new MemberWriter(roomId); const txn = createStorage([member("join", alice, "Alice", avatar)]); - const change = await writer.writeTimelineMemberEvent(createMemberEvent("join", alice, "Alice", avatar), txn); - assert(!change); + const memberSync = writer.prepareMemberSync([], [createMemberEvent("join", alice, "Alice", avatar)], false); + const changes = await memberSync.write(txn); + assert.equal(changes.size, 0); + }, + "ignore redundant member event in state": async assert => { + const writer = new MemberWriter(roomId); + const txn = createStorage([member("join", alice, "Alice", avatar)]); + const memberSync = writer.prepareMemberSync([createMemberEvent("join", alice, "Alice", avatar)], [], false); + const changes = await memberSync.write(txn); + assert.equal(changes.size, 0); }, "leave": async assert => { const writer = new MemberWriter(roomId); const txn = createStorage([member("join", alice, "Alice")]); - const change = await writer.writeTimelineMemberEvent(createMemberEvent("leave", alice, "Alice"), txn); + const memberSync = writer.prepareMemberSync([], [createMemberEvent("leave", alice, "Alice")], false); + const changes = await memberSync.write(txn); + assert.equal(changes.size, 1); + const change = changes.get(alice); assert(change.hasLeft); assert(!change.hasJoined); }, "ban": async assert => { const writer = new MemberWriter(roomId); const txn = createStorage([member("join", alice, "Alice")]); - const change = await writer.writeTimelineMemberEvent(createMemberEvent("ban", alice, "Alice"), txn); + const memberSync = writer.prepareMemberSync([], [createMemberEvent("ban", alice, "Alice")], false); + const changes = await memberSync.write(txn); + assert.equal(changes.size, 1); + const change = changes.get(alice); assert(change.hasLeft); assert(!change.hasJoined); }, "reject invite": async assert => { const writer = new MemberWriter(roomId); const txn = createStorage([member("invite", alice, "Alice")]); - const change = await writer.writeTimelineMemberEvent(createMemberEvent("leave", alice, "Alice"), txn); + const memberSync = writer.prepareMemberSync([], [createMemberEvent("leave", alice, "Alice")], false); + const changes = await memberSync.write(txn); + assert.equal(changes.size, 1); + const change = changes.get(alice); assert(!change.hasLeft); assert(!change.hasJoined); }, "lazy loaded member we already know about doens't return change": async assert => { const writer = new MemberWriter(roomId); const txn = createStorage([member("join", alice, "Alice")]); - const change = await writer.writeStateMemberEvent(createMemberEvent("join", alice, "Alice"), false, txn); - assert(!change); + const memberSync = writer.prepareMemberSync([createMemberEvent("join", alice, "Alice")], [], false); + const changes = await memberSync.write(txn); + assert.equal(changes.size, 0); }, "lazy loaded member we already know about changes display name": async assert => { const writer = new MemberWriter(roomId); const txn = createStorage([member("join", alice, "Alice")]); - const change = await writer.writeStateMemberEvent(createMemberEvent("join", alice, "Alies"), false, txn); + const memberSync = writer.prepareMemberSync([createMemberEvent("join", alice, "Alies")], [], false); + const changes = await memberSync.write(txn); + assert.equal(changes.size, 1); + const change = changes.get(alice); + assert(!change.hasJoined); assert.equal(change.member.displayName, "Alies"); }, - "unknown lazy loaded member returns change, but not considered a membership change": async assert => { + "unknown lazy loaded member returns change, but not considered a join": async assert => { const writer = new MemberWriter(roomId); const txn = createStorage(); - const change = await writer.writeStateMemberEvent(createMemberEvent("join", alice, "Alice"), false, txn); + const memberSync = writer.prepareMemberSync([createMemberEvent("join", alice, "Alice")], [], false); + const changes = await memberSync.write(txn); + assert.equal(changes.size, 1); + const change = changes.get(alice); assert(!change.hasJoined); assert(!change.hasLeft); assert.equal(change.member.membership, "join"); @@ -247,30 +372,68 @@ export function tests() { const writer = new MemberWriter(roomId); const txn = createStorage(); const aliceJoin = createMemberEvent("join", alice, "Alice"); - const change = await writer.writeStateMemberEvent(aliceJoin, false, txn); - assert(!change.hasJoined); - assert(!change.hasLeft); - const timelineChange = await writer.writeTimelineMemberEvent(aliceJoin, txn); + const memberSync = writer.prepareMemberSync([aliceJoin], [aliceJoin], false); + const changes = await memberSync.write(txn); + assert.equal(changes.size, 1); + const change = changes.get(alice); assert(change.hasJoined); assert(!change.hasLeft); }, - "newly joined member causes a change with lookup done first": async assert => { - const event = createMemberEvent("join", alice, "Alice"); + "change display name in timeline with lazy loaded member in state": async assert => { const writer = new MemberWriter(roomId); const txn = createStorage(); - const member = await writer.lookupMember(event.sender, event, [event], txn); - assert(member); - const change = await writer.writeTimelineMemberEvent(event, txn); - assert(change); + const memberSync = writer.prepareMemberSync( + [createMemberEvent("join", alice, "Alice")], + [createMemberEvent("join", alice, "Alies")], + false); + const changes = await memberSync.write(txn); + assert.equal(changes.size, 1); + const change = changes.get(alice); + assert(change.hasJoined); + assert(!change.hasLeft); + assert.equal(change.member.displayName, "Alies"); }, - "lookupMember returns closest member in the past": async assert => { + "lookupMemberAtEvent returns closest member in the past": async assert => { const event1 = createMemberEvent("join", alice, "Alice"); const event2 = createMemberEvent("join", alice, "Alies"); const event3 = createMemberEvent("join", alice, "Alys"); + const events = [event1, event2, event3]; + // we write first because the MemberWriter assumes it is called before + // the SyncWriter does any lookups const writer = new MemberWriter(roomId); const txn = createStorage(); - const member = await writer.lookupMember(event3.sender, event3, [event1, event2, event3], txn); + const memberSync = await writer.prepareMemberSync([], events, false); + let member = await memberSync.lookupMemberAtEvent(event1.sender, event1, txn); + assert.equal(member, undefined); + member = await memberSync.lookupMemberAtEvent(event2.sender, event2, txn); + assert.equal(member.displayName, "Alice"); + member = await memberSync.lookupMemberAtEvent(event3.sender, event3, txn); assert.equal(member.displayName, "Alies"); + + assert.equal(txn.members.size, 0); + const changes = await memberSync.write(txn); + assert.equal(txn.members.size, 1); + assert.equal(changes.size, 1); + const change = changes.get(alice); + assert(change.hasJoined); + }, + "lookupMemberAtEvent falls back on state event": async assert => { + const event1 = createMemberEvent("join", alice, "Alice"); + const event2 = createMemberEvent("join", alice, "Alies"); + // we write first because the MemberWriter assumes it is called before + // the SyncWriter does any lookups + const writer = new MemberWriter(roomId); + const txn = createStorage(); + const memberSync = await writer.prepareMemberSync([event1], [event2], false); + const member = await memberSync.lookupMemberAtEvent(event2.sender, event2, txn); + assert.equal(member.displayName, "Alice"); + + assert.equal(txn.members.size, 0); + const changes = await memberSync.write(txn); + assert.equal(txn.members.size, 1); + assert.equal(changes.size, 1); + const change = changes.get(alice); + assert(change.hasJoined); }, }; } diff --git a/src/matrix/room/timeline/persistence/SyncWriter.js b/src/matrix/room/timeline/persistence/SyncWriter.js index 96551056..b0970ca0 100644 --- a/src/matrix/room/timeline/persistence/SyncWriter.js +++ b/src/matrix/room/timeline/persistence/SyncWriter.js @@ -133,66 +133,51 @@ export class SyncWriter { return currentKey; } - async _writeStateEvents(roomResponse, memberChanges, isLimited, txn, log) { - // persist state - const {state} = roomResponse; - if (Array.isArray(state?.events)) { - log.set("stateEvents", state.events.length); - for (const event of state.events) { - if (event.type === MEMBER_EVENT_TYPE) { - const memberChange = await this._memberWriter.writeStateMemberEvent(event, isLimited, txn); - if (memberChange) { - memberChanges.set(memberChange.userId, memberChange); - } - } else { - txn.roomState.set(this._roomId, event); - } + async _writeStateEvents(stateEvents, txn, log) { + let nonMemberStateEvents = 0; + for (const event of stateEvents) { + // member events are written prior by MemberWriter + if (event.type !== MEMBER_EVENT_TYPE) { + txn.roomState.set(this._roomId, event); + nonMemberStateEvents += 1; } } + log.set("stateEvents", nonMemberStateEvents); } - async _writeTimeline(timeline, currentKey, memberChanges, txn, log) { + async _writeTimeline(timelineEvents, timeline, memberSync, currentKey, txn, log) { const entries = []; const updatedEntries = []; - if (Array.isArray(timeline?.events) && timeline.events.length) { - // only create a fragment when we will really write an event - currentKey = await this._ensureLiveFragment(currentKey, entries, timeline, txn, log); - const events = deduplicateEvents(timeline.events); - log.set("timelineEvents", events.length); - let timelineStateEventCount = 0; - for(const event of events) { - // store event in timeline - currentKey = currentKey.nextKey(); - const storageEntry = createEventEntry(currentKey, this._roomId, event); - let member = await this._memberWriter.lookupMember(event.sender, event, events, txn); - if (member) { - storageEntry.displayName = member.displayName; - storageEntry.avatarUrl = member.avatarUrl; - } - txn.timelineEvents.insert(storageEntry); - const entry = new EventEntry(storageEntry, this._fragmentIdComparer); - entries.push(entry); - const updatedRelationTargetEntries = await this._relationWriter.writeRelation(entry, txn, log); - if (updatedRelationTargetEntries) { - updatedEntries.push(...updatedRelationTargetEntries); - } - // update state events after writing event, so for a member event, - // we only update the member info after having written the member event - // to the timeline, as we want that event to have the old profile info - if (typeof event.state_key === "string") { - timelineStateEventCount += 1; - if (event.type === MEMBER_EVENT_TYPE) { - const memberChange = await this._memberWriter.writeTimelineMemberEvent(event, txn); - if (memberChange) { - memberChanges.set(memberChange.userId, memberChange); - } - } else { - txn.roomState.set(this._roomId, event); - } - } + // only create a fragment when we will really write an event + currentKey = await this._ensureLiveFragment(currentKey, entries, timeline, txn, log); + log.set("timelineEvents", timelineEvents.length); + let timelineStateEventCount = 0; + for(const event of timelineEvents) { + // store event in timeline + currentKey = currentKey.nextKey(); + const storageEntry = createEventEntry(currentKey, this._roomId, event); + let member = await memberSync.lookupMemberAtEvent(event.sender, event, txn); + if (member) { + storageEntry.displayName = member.displayName; + storageEntry.avatarUrl = member.avatarUrl; + } + txn.timelineEvents.insert(storageEntry); + const entry = new EventEntry(storageEntry, this._fragmentIdComparer); + entries.push(entry); + const updatedRelationTargetEntries = await this._relationWriter.writeRelation(entry, txn, log); + if (updatedRelationTargetEntries) { + updatedEntries.push(...updatedRelationTargetEntries); + } + // update state events after writing event, so for a member event, + // we only update the member info after having written the member event + // to the timeline, as we want that event to have the old profile info. + // member events are written prior by MemberWriter. + if (typeof event.state_key === "string" && event.type !== MEMBER_EVENT_TYPE) { + timelineStateEventCount += 1; + txn.roomState.set(this._roomId, event); } - log.set("timelineStateEventCount", timelineStateEventCount); } + log.set("timelineStateEventCount", timelineStateEventCount); return {currentKey, entries, updatedEntries}; } @@ -224,14 +209,13 @@ export class SyncWriter { * @type {SyncWriterResult} * @property {Array} entries new timeline entries written * @property {EventKey} newLiveKey the advanced key to write events at - * @property {Map} memberChanges member changes in the processed sync ny user id * * @param {Object} roomResponse [description] * @param {boolean} isRejoin whether the room was rejoined in the sync being processed * @param {Transaction} txn * @return {SyncWriterResult} */ - async writeSync(roomResponse, isRejoin, txn, log) { + async writeSync(roomResponse, isRejoin, hasFetchedMembers, txn, log) { let {timeline} = roomResponse; // we have rejoined the room after having synced it before, // check for overlap with the last synced event @@ -239,13 +223,24 @@ export class SyncWriter { if (isRejoin) { timeline = await this._handleRejoinOverlap(timeline, txn, log); } - const memberChanges = new Map(); - // important this happens before _writeTimeline so - // members are available in the transaction - await this._writeStateEvents(roomResponse, memberChanges, timeline?.limited, txn, log); - const {currentKey, entries, updatedEntries} = - await this._writeTimeline(timeline, this._lastLiveKey, memberChanges, txn, log); - log.set("memberChanges", memberChanges.size); + let timelineEvents; + if (Array.isArray(timeline?.events)) { + timelineEvents = deduplicateEvents(timeline.events); + } + const {state} = roomResponse; + let stateEvents; + if (Array.isArray(state?.events)) { + stateEvents = state.events; + } + const memberSync = this._memberWriter.prepareMemberSync(stateEvents, timelineEvents, hasFetchedMembers); + if (stateEvents) { + await this._writeStateEvents(roomResponse, txn, log); + } + if (timelineEvents?.length) { + const {currentKey, entries, updatedEntries} = + await this._writeTimeline(timelineEvents, timeline, memberSync, this._lastLiveKey, txn, log); + } + const memberChanges = await memberSync.write(txn); return {entries, updatedEntries, newLiveKey: currentKey, memberChanges}; } From a61f052fe86c7e4f2ca7175d81968213f1a281dd Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 27 Aug 2021 17:23:32 +0200 Subject: [PATCH 03/17] fix lint --- .../room/timeline/persistence/MemberWriter.js | 26 -------- .../room/timeline/persistence/SyncWriter.js | 62 +++++++++---------- 2 files changed, 31 insertions(+), 57 deletions(-) diff --git a/src/matrix/room/timeline/persistence/MemberWriter.js b/src/matrix/room/timeline/persistence/MemberWriter.js index 0c24b06f..fba43ddf 100644 --- a/src/matrix/room/timeline/persistence/MemberWriter.js +++ b/src/matrix/room/timeline/persistence/MemberWriter.js @@ -191,32 +191,6 @@ export function tests() { }; } - function createRoomResponse(stateEvents, timelineEvents) { - if (!Array.isArray(timelineEvents)) { - timelineEvents = [timelineEvents]; - } - if (!Array.isArray(stateEvents)) { - stateEvents = [stateEvents]; - } - return { - timeline: { - limited: false, - events: timelineEvents, - }, - state: { - events: stateEvents - } - }; - } - - function createTimelineResponse(timelineEvents) { - return createRoomResponse([], timelineEvents) - } - - function createStateReponse(stateEvents) { - return createRoomResponse(stateEvents, []); - } - function createStorage(initialMembers = []) { const members = new Map(); for (const m of initialMembers) { diff --git a/src/matrix/room/timeline/persistence/SyncWriter.js b/src/matrix/room/timeline/persistence/SyncWriter.js index b0970ca0..f6c167e8 100644 --- a/src/matrix/room/timeline/persistence/SyncWriter.js +++ b/src/matrix/room/timeline/persistence/SyncWriter.js @@ -149,35 +149,37 @@ export class SyncWriter { const entries = []; const updatedEntries = []; // only create a fragment when we will really write an event - currentKey = await this._ensureLiveFragment(currentKey, entries, timeline, txn, log); - log.set("timelineEvents", timelineEvents.length); - let timelineStateEventCount = 0; - for(const event of timelineEvents) { - // store event in timeline - currentKey = currentKey.nextKey(); - const storageEntry = createEventEntry(currentKey, this._roomId, event); - let member = await memberSync.lookupMemberAtEvent(event.sender, event, txn); - if (member) { - storageEntry.displayName = member.displayName; - storageEntry.avatarUrl = member.avatarUrl; - } - txn.timelineEvents.insert(storageEntry); - const entry = new EventEntry(storageEntry, this._fragmentIdComparer); - entries.push(entry); - const updatedRelationTargetEntries = await this._relationWriter.writeRelation(entry, txn, log); - if (updatedRelationTargetEntries) { - updatedEntries.push(...updatedRelationTargetEntries); - } - // update state events after writing event, so for a member event, - // we only update the member info after having written the member event - // to the timeline, as we want that event to have the old profile info. - // member events are written prior by MemberWriter. - if (typeof event.state_key === "string" && event.type !== MEMBER_EVENT_TYPE) { - timelineStateEventCount += 1; - txn.roomState.set(this._roomId, event); + if (timelineEvents?.length) { + currentKey = await this._ensureLiveFragment(currentKey, entries, timeline, txn, log); + log.set("timelineEvents", timelineEvents.length); + let timelineStateEventCount = 0; + for(const event of timelineEvents) { + // store event in timeline + currentKey = currentKey.nextKey(); + const storageEntry = createEventEntry(currentKey, this._roomId, event); + let member = await memberSync.lookupMemberAtEvent(event.sender, event, txn); + if (member) { + storageEntry.displayName = member.displayName; + storageEntry.avatarUrl = member.avatarUrl; + } + txn.timelineEvents.insert(storageEntry); + const entry = new EventEntry(storageEntry, this._fragmentIdComparer); + entries.push(entry); + const updatedRelationTargetEntries = await this._relationWriter.writeRelation(entry, txn, log); + if (updatedRelationTargetEntries) { + updatedEntries.push(...updatedRelationTargetEntries); + } + // update state events after writing event, so for a member event, + // we only update the member info after having written the member event + // to the timeline, as we want that event to have the old profile info. + // member events are written prior by MemberWriter. + if (typeof event.state_key === "string" && event.type !== MEMBER_EVENT_TYPE) { + timelineStateEventCount += 1; + txn.roomState.set(this._roomId, event); + } } + log.set("timelineStateEventCount", timelineStateEventCount); } - log.set("timelineStateEventCount", timelineStateEventCount); return {currentKey, entries, updatedEntries}; } @@ -236,10 +238,8 @@ export class SyncWriter { if (stateEvents) { await this._writeStateEvents(roomResponse, txn, log); } - if (timelineEvents?.length) { - const {currentKey, entries, updatedEntries} = - await this._writeTimeline(timelineEvents, timeline, memberSync, this._lastLiveKey, txn, log); - } + const {currentKey, entries, updatedEntries} = + await this._writeTimeline(timelineEvents, timeline, memberSync, this._lastLiveKey, txn, log); const memberChanges = await memberSync.write(txn); return {entries, updatedEntries, newLiveKey: currentKey, memberChanges}; } From 0c05e974653aa7db53652d352d4ade94cb9b678d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 27 Aug 2021 19:07:27 +0200 Subject: [PATCH 04/17] abort upgrade txn on error --- src/matrix/storage/idb/utils.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/matrix/storage/idb/utils.js b/src/matrix/storage/idb/utils.js index fbb82fc5..b4e6209d 100644 --- a/src/matrix/storage/idb/utils.js +++ b/src/matrix/storage/idb/utils.js @@ -66,11 +66,18 @@ export function decodeUint32(str) { export function openDatabase(name, createObjectStore, version, idbFactory = window.indexedDB) { const req = idbFactory.open(name, version); - req.onupgradeneeded = (ev) => { + req.onupgradeneeded = async (ev) => { const db = ev.target.result; const txn = ev.target.transaction; const oldVersion = ev.oldVersion; - createObjectStore(db, txn, oldVersion, version); + try { + await createObjectStore(db, txn, oldVersion, version); + } catch (err) { + console.error("Aborting upgrade transaction because migration threw error"); + console.log(err.message); + console.log(err.stack); + txn.abort(); + } }; return reqAsPromise(req); } From fa555bedf0cb94fb0bbbec9b7338f66252ffb230 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 27 Aug 2021 19:34:53 +0200 Subject: [PATCH 05/17] log storage migration --- src/matrix/SessionContainer.js | 2 +- src/matrix/storage/idb/StorageFactory.js | 19 +++++++++++-------- src/mocks/Storage.js | 5 +++-- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/matrix/SessionContainer.js b/src/matrix/SessionContainer.js index f375fdd7..b58589d0 100644 --- a/src/matrix/SessionContainer.js +++ b/src/matrix/SessionContainer.js @@ -204,7 +204,7 @@ export class SessionContainer { reconnector: this._reconnector, }); this._sessionId = sessionInfo.id; - this._storage = await this._platform.storageFactory.create(sessionInfo.id); + this._storage = await this._platform.storageFactory.create(sessionInfo.id, log); // no need to pass access token to session const filteredSessionInfo = { id: sessionInfo.id, diff --git a/src/matrix/storage/idb/StorageFactory.js b/src/matrix/storage/idb/StorageFactory.js index 719d2672..de166a60 100644 --- a/src/matrix/storage/idb/StorageFactory.js +++ b/src/matrix/storage/idb/StorageFactory.js @@ -21,8 +21,9 @@ import { schema } from "./schema.js"; import { detectWebkitEarlyCloseTxnBug } from "./quirks.js"; const sessionName = sessionId => `hydrogen_session_${sessionId}`; -const openDatabaseWithSessionId = function(sessionId, idbFactory) { - return openDatabase(sessionName(sessionId), createStores, schema.length, idbFactory); +const openDatabaseWithSessionId = function(sessionId, idbFactory, log) { + const create = (db, txn, oldVersion, version) => createStores(db, txn, oldVersion, version, log); + return openDatabase(sessionName(sessionId), create, schema.length, idbFactory); } async function requestPersistedStorage() { @@ -49,7 +50,7 @@ export class StorageFactory { this._IDBKeyRange = IDBKeyRange; } - async create(sessionId) { + async create(sessionId, log) { await this._serviceWorkerHandler?.preventConcurrentSessionAccess(sessionId); requestPersistedStorage().then(persisted => { // Firefox lies here though, and returns true even if the user denied the request @@ -59,7 +60,7 @@ export class StorageFactory { }); const hasWebkitEarlyCloseTxnBug = await detectWebkitEarlyCloseTxnBug(this._idbFactory); - const db = await openDatabaseWithSessionId(sessionId, this._idbFactory); + const db = await openDatabaseWithSessionId(sessionId, this._idbFactory, log); return new Storage(db, this._IDBKeyRange, hasWebkitEarlyCloseTxnBug); } @@ -80,10 +81,12 @@ export class StorageFactory { } } -async function createStores(db, txn, oldVersion, version) { +async function createStores(db, txn, oldVersion, version, log) { const startIdx = oldVersion || 0; + return log.wrap({l: "storage migration", oldVersion, version}, async log => { + for(let i = startIdx; i < version; ++i) { + await log.wrap(`v${i + 1}`, log => schema[i](db, txn, log)); + } + }); - for(let i = startIdx; i < version; ++i) { - await schema[i](db, txn); - } } diff --git a/src/mocks/Storage.js b/src/mocks/Storage.js index 0cddc7bc..48e7056c 100644 --- a/src/mocks/Storage.js +++ b/src/mocks/Storage.js @@ -16,7 +16,8 @@ limitations under the License. import {FDBFactory, FDBKeyRange} from "../../lib/fake-indexeddb/index.js"; import {StorageFactory} from "../matrix/storage/idb/StorageFactory.js"; +import {NullLogItem} from "../logging/NullLogger.js"; export function createMockStorage() { - return new StorageFactory(null, new FDBFactory(), FDBKeyRange).create(1); -} \ No newline at end of file + return new StorageFactory(null, new FDBFactory(), FDBKeyRange).create(1, new NullLogItem()); +} From 8e6bd6a7a182486b5a638c69e792e45b7abace1f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 27 Aug 2021 19:39:24 +0200 Subject: [PATCH 06/17] add missing room ids to identities for tracked rooms & clear outbound session --- src/matrix/e2ee/DeviceTracker.js | 16 +++++++++++ src/matrix/storage/idb/schema.js | 47 ++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/src/matrix/e2ee/DeviceTracker.js b/src/matrix/e2ee/DeviceTracker.js index a14b42f3..32e2d00c 100644 --- a/src/matrix/e2ee/DeviceTracker.js +++ b/src/matrix/e2ee/DeviceTracker.js @@ -19,6 +19,22 @@ import {verifyEd25519Signature, SIGNATURE_ALGORITHM} from "./common.js"; const TRACKING_STATUS_OUTDATED = 0; const TRACKING_STATUS_UPTODATE = 1; +export function addRoomToIdentity(identity, userId, roomId) { + if (!identity) { + identity = { + userId: userId, + roomIds: [roomId], + deviceTrackingStatus: TRACKING_STATUS_OUTDATED, + }; + return identity; + } else { + if (!identity.roomIds.includes(roomId)) { + identity.roomIds.push(roomId); + return identity; + } + } +} + // map 1 device from /keys/query response to DeviceIdentity function deviceKeysAsDeviceIdentity(deviceSection) { const deviceId = deviceSection["device_id"]; diff --git a/src/matrix/storage/idb/schema.js b/src/matrix/storage/idb/schema.js index 352c810c..6f02b828 100644 --- a/src/matrix/storage/idb/schema.js +++ b/src/matrix/storage/idb/schema.js @@ -1,8 +1,10 @@ import {iterateCursor, reqAsPromise} from "./utils.js"; import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../room/members/RoomMember.js"; +import {addRoomToIdentity} from "../../e2ee/DeviceTracker.js"; import {RoomMemberStore} from "./stores/RoomMemberStore.js"; import {SessionStore} from "./stores/SessionStore.js"; import {encodeScopeTypeKey} from "./stores/OperationStore.js"; +import {MAX_UNICODE} from "./stores/common.js"; // FUNCTIONS SHOULD ONLY BE APPENDED!! // the index in the array is the database version @@ -17,6 +19,7 @@ export const schema = [ createArchivedRoomSummaryStore, migrateOperationScopeIndex, createTimelineRelationsStore, + fixMissingRoomsInUserIdentities ]; // TODO: how to deal with git merge conflicts of this array? @@ -142,3 +145,47 @@ async function migrateOperationScopeIndex(db, txn) { function createTimelineRelationsStore(db) { db.createObjectStore("timelineRelations", {keyPath: "key"}); } + +//v11 doesn't change the schema, but ensures all userIdentities have all the roomIds they should (see #470) +async function fixMissingRoomsInUserIdentities(db, txn, log) { + const roomSummaryStore = txn.objectStore("roomSummary"); + const trackedRoomIds = []; + await iterateCursor(roomSummaryStore.openCursor(), roomSummary => { + if (roomSummary.isTrackingMembers) { + trackedRoomIds.push(roomSummary.roomId); + } + }); + const outboundGroupSessionsStore = txn.objectStore("outboundGroupSessions"); + const userIdentitiesStore = txn.objectStore("userIdentities"); + const roomMemberStore = txn.objectStore("roomMembers"); + for (const roomId of trackedRoomIds) { + let foundMissing = false; + const joinedUserIds = []; + const memberRange = IDBKeyRange.bound(roomId, `${roomId}|${MAX_UNICODE}`, true, true); + await log.wrap({l: "room", id: roomId}, async log => { + await iterateCursor(roomMemberStore.openCursor(memberRange), member => { + if (member.membership === "join") { + joinedUserIds.push(member.userId); + } + }); + log.set("joinedUserIds", joinedUserIds.length); + for (const userId of joinedUserIds) { + const identity = await reqAsPromise(userIdentitiesStore.get(userId)); + const originalRoomCount = identity?.roomIds?.length; + const updatedIdentity = addRoomToIdentity(identity, userId, roomId); + if (updatedIdentity) { + log.log({l: `fixing up`, id: userId, + roomsBefore: originalRoomCount, roomsAfter: updatedIdentity.roomIds.length}); + userIdentitiesStore.put(updatedIdentity); + foundMissing = true; + } + } + log.set("foundMissing", foundMissing); + if (foundMissing) { + // clear outbound megolm session, + // so we'll create a new one on the next message that will be properly shared + outboundGroupSessionsStore.delete(roomId); + } + }); + } +} From 6a6762f03682f6dfbc5f656158e60f3a702fced6 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 27 Aug 2021 20:05:34 +0200 Subject: [PATCH 07/17] ensure memberwriter works with undefined for state/timeline events array --- .../room/timeline/persistence/MemberWriter.js | 40 +++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/src/matrix/room/timeline/persistence/MemberWriter.js b/src/matrix/room/timeline/persistence/MemberWriter.js index fba43ddf..b21a4461 100644 --- a/src/matrix/room/timeline/persistence/MemberWriter.js +++ b/src/matrix/room/timeline/persistence/MemberWriter.js @@ -61,7 +61,10 @@ class MemberSync { this._memberWriter = memberWriter; this._timelineEvents = timelineEvents; this._hasFetchedMembers = hasFetchedMembers; - this._newStateMembers = stateEvents && this._stateEventsToMembers(stateEvents); + this._newStateMembers = null; + if (stateEvents) { + this._newStateMembers = this._stateEventsToMembers(stateEvents); + } } get _roomId() { @@ -84,11 +87,11 @@ class MemberSync { return members; } - _timelineEventsToMembers() { + _timelineEventsToMembers(timelineEvents) { let members; // iterate backwards to only add the last member in the timeline - for (let i = this._timelineEvents.length - 1; i >= 0; i--) { - const e = this._timelineEvents[i]; + for (let i = timelineEvents.length - 1; i >= 0; i--) { + const e = timelineEvents[i]; const userId = e.state_key; if (e.type === MEMBER_EVENT_TYPE && !members?.has(userId)) { const member = RoomMember.fromMemberEvent(this._roomId, e); @@ -104,9 +107,12 @@ class MemberSync { } async lookupMemberAtEvent(userId, event, txn) { - let member = this._findPrecedingMemberEventInTimeline(userId, event); - if (member) { - return member; + let member; + if (this._timelineEvents) { + member = this._findPrecedingMemberEventInTimeline(userId, event); + if (member) { + return member; + } } member = this._newStateMembers?.get(userId); if (member) { @@ -117,7 +123,10 @@ class MemberSync { async write(txn) { const memberChanges = new Map(); - const newTimelineMembers = this._timelineEventsToMembers(); + let newTimelineMembers; + if (this._timelineEvents) { + newTimelineMembers = this._timelineEventsToMembers(this._timelineEvents); + } if (this._newStateMembers) { for (const member of this._newStateMembers.values()) { if (!newTimelineMembers?.has(member.userId)) { @@ -217,14 +226,6 @@ export function tests() { const alice = "@alice:hs.tld"; const avatar = "mxc://hs.tld/def"; -/* - join without previous membership - join during gap with hasFetchedMembers=false - join during gap with hasFetchedMembers=true - join after invite - -*/ - return { "new join": async assert => { const writer = new MemberWriter(roomId); @@ -409,5 +410,12 @@ export function tests() { const change = changes.get(alice); assert(change.hasJoined); }, + "write works without event arrays": async assert => { + const writer = new MemberWriter(roomId); + const txn = createStorage(); + const memberSync = await writer.prepareMemberSync(undefined, undefined, false); + const changes = await memberSync.write(txn); + assert.equal(changes.size, 0); + }, }; } From ddb6753f8d7eed6f10948aa2ddd5a60708692e67 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 27 Aug 2021 20:05:53 +0200 Subject: [PATCH 08/17] fix refactor error --- src/matrix/room/timeline/persistence/SyncWriter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/room/timeline/persistence/SyncWriter.js b/src/matrix/room/timeline/persistence/SyncWriter.js index f6c167e8..240feb76 100644 --- a/src/matrix/room/timeline/persistence/SyncWriter.js +++ b/src/matrix/room/timeline/persistence/SyncWriter.js @@ -236,7 +236,7 @@ export class SyncWriter { } const memberSync = this._memberWriter.prepareMemberSync(stateEvents, timelineEvents, hasFetchedMembers); if (stateEvents) { - await this._writeStateEvents(roomResponse, txn, log); + await this._writeStateEvents(stateEvents, txn, log); } const {currentKey, entries, updatedEntries} = await this._writeTimeline(timelineEvents, timeline, memberSync, this._lastLiveKey, txn, log); From 0ca46bf2ac621d25ab357508ea7111f5a5da5567 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 30 Aug 2021 14:52:04 +0200 Subject: [PATCH 09/17] don't log here as we log at a lower level, and don't fail on abort --- src/matrix/storage/idb/utils.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/matrix/storage/idb/utils.js b/src/matrix/storage/idb/utils.js index b4e6209d..3b9ca8bc 100644 --- a/src/matrix/storage/idb/utils.js +++ b/src/matrix/storage/idb/utils.js @@ -73,10 +73,10 @@ export function openDatabase(name, createObjectStore, version, idbFactory = wind try { await createObjectStore(db, txn, oldVersion, version); } catch (err) { - console.error("Aborting upgrade transaction because migration threw error"); - console.log(err.message); - console.log(err.stack); - txn.abort(); + // try aborting on error, if that hasn't been done already + try { + txn.abort(); + } catch (err) {} } }; return reqAsPromise(req); From a4373324821e53d3404afbabfa01e2b0d149a3dd Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 30 Aug 2021 14:52:20 +0200 Subject: [PATCH 10/17] whitespace --- src/matrix/storage/idb/StorageFactory.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/matrix/storage/idb/StorageFactory.js b/src/matrix/storage/idb/StorageFactory.js index de166a60..a9b86273 100644 --- a/src/matrix/storage/idb/StorageFactory.js +++ b/src/matrix/storage/idb/StorageFactory.js @@ -88,5 +88,4 @@ async function createStores(db, txn, oldVersion, version, log) { await log.wrap(`v${i + 1}`, log => schema[i](db, txn, log)); } }); - } From 0d6ae19d99600d4e9717985309c617c2ef47b9ba Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 30 Aug 2021 15:05:57 +0200 Subject: [PATCH 11/17] use same code to add room to identity in migration as in device tracker --- src/matrix/e2ee/DeviceTracker.js | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/matrix/e2ee/DeviceTracker.js b/src/matrix/e2ee/DeviceTracker.js index 32e2d00c..e230ea7d 100644 --- a/src/matrix/e2ee/DeviceTracker.js +++ b/src/matrix/e2ee/DeviceTracker.js @@ -123,17 +123,9 @@ export class DeviceTracker { async _writeMember(member, txn) { const {userIdentities} = txn; const identity = await userIdentities.get(member.userId); - if (!identity) { - userIdentities.set({ - userId: member.userId, - roomIds: [member.roomId], - deviceTrackingStatus: TRACKING_STATUS_OUTDATED, - }); - } else { - if (!identity.roomIds.includes(member.roomId)) { - identity.roomIds.push(member.roomId); - userIdentities.set(identity); - } + const updatedIdentity = addRoomToIdentity(identity, member.userId, member.roomId); + if (updatedIdentity) { + userIdentities.set(updatedIdentity); } } From 7fb541217686b6af5b8eaccf35cbdbd9da9e3dfd Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 30 Aug 2021 15:12:25 +0200 Subject: [PATCH 12/17] keep comment where it was --- src/matrix/room/timeline/persistence/SyncWriter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/room/timeline/persistence/SyncWriter.js b/src/matrix/room/timeline/persistence/SyncWriter.js index 240feb76..54fd5a0b 100644 --- a/src/matrix/room/timeline/persistence/SyncWriter.js +++ b/src/matrix/room/timeline/persistence/SyncWriter.js @@ -148,8 +148,8 @@ export class SyncWriter { async _writeTimeline(timelineEvents, timeline, memberSync, currentKey, txn, log) { const entries = []; const updatedEntries = []; - // only create a fragment when we will really write an event if (timelineEvents?.length) { + // only create a fragment when we will really write an event currentKey = await this._ensureLiveFragment(currentKey, entries, timeline, txn, log); log.set("timelineEvents", timelineEvents.length); let timelineStateEventCount = 0; From 8d414970c695d9432c09cebc5eb9ee46b39d9675 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 30 Aug 2021 15:23:09 +0200 Subject: [PATCH 13/17] release v0.2.8 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c7e0cd9f..3116863c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydrogen-web", - "version": "0.2.7", + "version": "0.2.8", "description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB", "main": "index.js", "directories": { From 3ded5b20d3a812a62393e46ee6f73d8784ce8a1f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 31 Aug 2021 08:16:27 +0200 Subject: [PATCH 14/17] dedupe some code here --- src/matrix/storage/idb/error.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/matrix/storage/idb/error.ts b/src/matrix/storage/idb/error.ts index bd3fb0a8..0d723837 100644 --- a/src/matrix/storage/idb/error.ts +++ b/src/matrix/storage/idb/error.ts @@ -33,15 +33,10 @@ export class IDBError extends StorageError { storeName: string; databaseName: string; - constructor(message: string, source: IDBIndex | IDBCursor | IDBObjectStore, cause: DOMException | null = null) { - let storeName: string, databaseName: string; - if (source instanceof IDBCursor) { - storeName = _sourceName(source.source); - databaseName = _sourceDatabase(source.source); - } else { - storeName = _sourceName(source); - databaseName = _sourceDatabase(source); - } + constructor(message: string, sourceOrCursor: IDBIndex | IDBCursor | IDBObjectStore, cause: DOMException | null = null) { + const source = "source" in sourceOrCursor ? sourceOrCursor.source : sourceOrCursor; + const storeName = _sourceName(source); + const databaseName = _sourceDatabase(source); let fullMessage = `${message} on ${databaseName}.${storeName}`; if (cause) { fullMessage += ": "; From f466266a5f93e6ee637de9a9cd8cfb3efb82e71a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 31 Aug 2021 08:16:37 +0200 Subject: [PATCH 15/17] bring back extra caution --- src/matrix/storage/idb/error.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/matrix/storage/idb/error.ts b/src/matrix/storage/idb/error.ts index 0d723837..388ad4c0 100644 --- a/src/matrix/storage/idb/error.ts +++ b/src/matrix/storage/idb/error.ts @@ -25,8 +25,8 @@ function _sourceName(source: IDBIndex | IDBObjectStore): string { function _sourceDatabase(source: IDBIndex | IDBObjectStore): string { return "objectStore" in source ? - source.objectStore.transaction.db.name : - source.transaction.db.name; + source.objectStore?.transaction?.db?.name : + source.transaction?.db?.name; } export class IDBError extends StorageError { From 995ed23b3ef4d726e0cdcb12fb5ff4f69d548595 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 31 Aug 2021 08:43:39 +0200 Subject: [PATCH 16/17] tell TS we're certain to have a txn --- src/matrix/storage/idb/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/storage/idb/utils.ts b/src/matrix/storage/idb/utils.ts index 76c4d1ed..bd1683ea 100644 --- a/src/matrix/storage/idb/utils.ts +++ b/src/matrix/storage/idb/utils.ts @@ -74,7 +74,7 @@ export function openDatabase(name: string, createObjectStore: CreateObjectStore, req.onupgradeneeded = async (ev : IDBVersionChangeEvent) => { const req = ev.target as IDBRequest; const db = req.result; - const txn = req.transaction; + const txn = req.transaction!; const oldVersion = ev.oldVersion; try { await createObjectStore(db, txn, oldVersion, version); From 0a13c12b1f342a14b2f71fe369cc7fb8d2a2e899 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 31 Aug 2021 12:16:59 +0000 Subject: [PATCH 17/17] Update FAQ.md mention olm issues when integrating for now --- doc/FAQ.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doc/FAQ.md b/doc/FAQ.md index 1a4dbe9e..c6445c72 100644 --- a/doc/FAQ.md +++ b/doc/FAQ.md @@ -37,3 +37,5 @@ There are no npm modules yet published for Hydrogen. The easiest is probably to For example, for a single room chat, you could create an instance of `Platform`, you create a new `SessionContainer` with it, call `startWithLogin` on it, observe `sessionContainer.loadStatus` to know when initial sync is done, then do `sessionContainer.session.rooms.get('roomid')` and you create a `RoomViewModel` with it and pass that to a `RoomView`. Then you call `document.appendChild(roomView.mount())` and you should see a syncing room. Feel free to ask for pointers in #hydrogen:matrix.org as the documentation is still lacking considerably. Note that at this early, pre 1.0 stage of the project, there is no promise of API stability yet. + +Also, to make end-to-end encryption work, you'll likely need some tweaks to your build system, see [this issue](https://github.com/vector-im/hydrogen-web/issues/467).