From 03d92b687ef05f4da08c9e1b1cc783dd50ea1522 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Apr 2021 13:02:33 +0200 Subject: [PATCH 01/74] doc with design rationale --- doc/invites.md | 116 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 115 insertions(+), 1 deletion(-) diff --git a/doc/invites.md b/doc/invites.md index ae90f76b..ec24c7f8 100644 --- a/doc/invites.md +++ b/doc/invites.md @@ -3,4 +3,118 @@ - invite_state doesn't update over /sync - can we reuse room summary? need to clear when joining - rely on filter operator to split membership=join from membership=invite? - - + + - invite_state comes once, and then not again + - only state (no heroes for example, but we do get the members) + - wants: + - different class to represent invited room, with accept or reject method? + - make it somewhat easy to render just joined rooms (rely on filter and still put them all in the same observable map) + - make the transition from invite to joined smooth + - reuse room summary logic? + + InvitedRoom + isDM + isEncrypted + name + + timestamp + accept() + reject() + JoiningRoom + to store intent of room you joined through directory, invite, or just /join roomid + also joining is retried when coming back online + + forget() + Room + + so, also taking into account that other types of room we might not want to expose through session.rooms will have invites, + perhaps it is best to expose invites through a different observable collection. You can always join/concat them to show in + the same list. + + How do we handle a smooth UI transition when accepting an invite though? + For looking at the room itself: + - we would attach to the Invite event emitter, and we can have a property "joined" that we would update. Then you know you can go look for the room (or even allow to access the room through a property?) + - so this way the view model can know when to switch and signal the view + For the room list: + - the new Room will be added at exactly the same moment the Invite is removed, + so it should already be fairly smooth whether they are rendered in the same list or not. + + How will we locate the Invite/Room during sync when we go from invite => join? + - have both adhere to sync target api (e.g. prepareSync, ...) and look in invite map + if room id is not found in room map in session.getroom. + - how do we remove the invite when join? + - we ca + Where to store? + - room summaries? + - do we have an interest in keeping the raw events? + - room versions will add another layer of indirection to the room summaries (or will it? once you've upgraded the room, we don't care too much anymore about the details of the old room? hmmm, we do care about whether it is encrypted or not... we need everything to be able to show the timeline in any case) + + + Invite => accept() => Room (ends up in session.rooms) + (.type) => Space (ends up in session.spaces) + Invite: + - isEncrypted + - isDM + - type + - id + - name + - avatarUrl + - timestamp + - joinRule (to say wheter you cannot join this room again if you reject) + + + + new "memberships": + joining (when we want to join/are joining but haven't received remote echo yet) + leaving (needed?) + + maybe it's slightly overkill to persist the intent of joining or leaving a room, + but I do want a way to local echo joining a room, + so that it immediately appears in the room list when clicking join in the room directory / from a url ... how would we sort these rooms though? we can always add another collection, but I'm not sure invites should be treated the same, they can already local echo on the invite object itself. + + + since invites don't update, we could, in sync when processing a new join just set a flag on the roomsyncstate if a room is newly created and in writeSync/afterSync check if there is a `session.invites.get(id)` and call `writeSync/afterSync` on it as well. We need to handle leave => invite as well. So don't check for invites only if it is a new room, but also if membership is leave + + transitions are: + invite => join + invite => leave + invite => ban + join => left + join => ban + leave => invite + leave => join + leave => ban + ban => leave + none => invite + none => join + none => ban + + kick should keep the room & timeline visible (even in room list, until you archive?) + leave should close the room. So explicit archive() step on room ? + + Room => leave() => ArchivedRoom (just a Room loaded from archived_room_summaries) => .forget() + => .forget() + + Room receives leave membership + - if sender === state_key, we left, and we archive the room (remove it from the room list, but keep it in storage) + - if sender !== state_key, we got kicked, and we write the membership but don't archive so it stays in the room list until you call archive/forget on the room + when calling room.leave(), do you have to call archive() or forget() after as well? or rather param of leave and stored intent? sounds like non-atomical operation to me ... + we should be able to archive or forget before leave remote echo arrives + + if two stores, this could mean we could have both an invite and a room with kicked state for a given room id? + + we should avoid key collisions between `session.invites` and `session.rooms` (also `session.archivedRooms` once supported?) in any case, + because if we join them to display in one list, things get complicated. + + avoiding key collisions can happen both with 1 or multiple stores for different room states and is just a matter + of carefully removing one state representation before adding another one. + so a kicked or left room would disappear from session.rooms when an invite is synced? + this would prevent you from seeing the old timeline for example, and if you reject, the old state would come back? + + +# Decisions + - we expose session.invites separate from session.rooms because they are of a different type. + This way, you only have methods on the object that make sense (accept on Room does not make sense, like Invite.openTimeline doesn't make sense) + - we store invites (and likely also archived rooms) in a different store, so that we don't have to clear/add properties where they both differ when transitioning. Also, this gives us the possibility to show the timeline on a room that you have previously joined, as the room summary and invite can exist at the same time. (need to resolve key collision question though for this) + - we want to keep kicked rooms in the room list until explicitly archived + - room id collisions between invites and rooms, can we implement a strategy to prefer invites in the join operator? From 7c4a6fbe4bd3145df6071956dac113663229f40f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Apr 2021 13:02:50 +0200 Subject: [PATCH 02/74] invite store --- src/matrix/storage/common.js | 1 + src/matrix/storage/idb/Transaction.js | 5 +++ src/matrix/storage/idb/schema.js | 8 ++++- src/matrix/storage/idb/stores/InviteStore.js | 33 ++++++++++++++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 src/matrix/storage/idb/stores/InviteStore.js diff --git a/src/matrix/storage/common.js b/src/matrix/storage/common.js index 3e9aca4f..438cf6b3 100644 --- a/src/matrix/storage/common.js +++ b/src/matrix/storage/common.js @@ -18,6 +18,7 @@ export const STORE_NAMES = Object.freeze([ "session", "roomState", "roomSummary", + "invites", "roomMembers", "timelineEvents", "timelineFragments", diff --git a/src/matrix/storage/idb/Transaction.js b/src/matrix/storage/idb/Transaction.js index 8d5ba232..162f821f 100644 --- a/src/matrix/storage/idb/Transaction.js +++ b/src/matrix/storage/idb/Transaction.js @@ -19,6 +19,7 @@ import {StorageError} from "../common.js"; import {Store} from "./Store.js"; import {SessionStore} from "./stores/SessionStore.js"; import {RoomSummaryStore} from "./stores/RoomSummaryStore.js"; +import {InviteStore} from "./stores/InviteStore.js"; import {TimelineEventStore} from "./stores/TimelineEventStore.js"; import {RoomStateStore} from "./stores/RoomStateStore.js"; import {RoomMemberStore} from "./stores/RoomMemberStore.js"; @@ -64,6 +65,10 @@ export class Transaction { return this._store("roomSummary", idbStore => new RoomSummaryStore(idbStore)); } + get invites() { + return this._store("invites", idbStore => new InviteStore(idbStore)); + } + get timelineFragments() { return this._store("timelineFragments", idbStore => new TimelineFragmentStore(idbStore)); } diff --git a/src/matrix/storage/idb/schema.js b/src/matrix/storage/idb/schema.js index 4d0d45ac..7cf100aa 100644 --- a/src/matrix/storage/idb/schema.js +++ b/src/matrix/storage/idb/schema.js @@ -11,7 +11,8 @@ export const schema = [ migrateSession, createE2EEStores, migrateEncryptionFlag, - createAccountDataStore + createAccountDataStore, + createInviteStore ]; // TODO: how to deal with git merge conflicts of this array? @@ -103,3 +104,8 @@ async function migrateEncryptionFlag(db, txn) { function createAccountDataStore(db) { db.createObjectStore("accountData", {keyPath: "type"}); } + +// v7 +function createInviteStore(db) { + db.createObjectStore("invites", {keyPath: "roomId"}); +} diff --git a/src/matrix/storage/idb/stores/InviteStore.js b/src/matrix/storage/idb/stores/InviteStore.js new file mode 100644 index 00000000..a3c7517a --- /dev/null +++ b/src/matrix/storage/idb/stores/InviteStore.js @@ -0,0 +1,33 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export class InviteStore { + constructor(inviteStore) { + this._inviteStore = inviteStore; + } + + getAll() { + return this._inviteStore.selectAll(); + } + + set(invite) { + return this._inviteStore.put(invite); + } + + remove(roomId) { + this._store.delete(roomId); + } +} From 81a35639ba57a41ae2f5f5744b3c45a37558a050 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Apr 2021 16:11:21 +0200 Subject: [PATCH 03/74] add Invite class calculating the room name, avatar, etc ... with empty accept and reject methods for now --- src/fixtures/matrix/invites/dm.js | 52 ++++++ src/fixtures/matrix/invites/room.js | 59 +++++++ src/logging/NullLogger.js | 2 +- src/matrix/Session.js | 1 + src/matrix/room/Invite.js | 252 ++++++++++++++++++++++++++++ src/matrix/room/RoomSummary.js | 4 +- 6 files changed, 367 insertions(+), 3 deletions(-) create mode 100644 src/fixtures/matrix/invites/dm.js create mode 100644 src/fixtures/matrix/invites/room.js create mode 100644 src/matrix/room/Invite.js diff --git a/src/fixtures/matrix/invites/dm.js b/src/fixtures/matrix/invites/dm.js new file mode 100644 index 00000000..cc63ddce --- /dev/null +++ b/src/fixtures/matrix/invites/dm.js @@ -0,0 +1,52 @@ +const inviteFixture = { + "invite_state": { + "events": [ + { + "type": "m.room.create", + "state_key": "", + "content": { + "creator": "@alice:hs.tld", + }, + "sender": "@alice:hs.tld" + }, + { + "type": "m.room.encryption", + "state_key": "", + "content": { + "algorithm": "m.megolm.v1.aes-sha2" + }, + "sender": "@alice:hs.tld" + }, + { + "type": "m.room.join_rules", + "state_key": "", + "content": { + "join_rule": "invite" + }, + "sender": "@alice:hs.tld" + }, + { + "type": "m.room.member", + "state_key": "@alice:hs.tld", + "content": { + "avatar_url": "mxc://hs.tld/def456", + "displayname": "Alice", + "membership": "join" + }, + "sender": "@alice:hs.tld" + }, + { + "content": { + "avatar_url": "mxc://hs.tld/abc123", + "displayname": "Bob", + "is_direct": true, + "membership": "invite" + }, + "sender": "@alice:hs.tld", + "state_key": "@bob:hs.tld", + "type": "m.room.member", + } + ] + } + }; +export default inviteFixture; diff --git a/src/fixtures/matrix/invites/room.js b/src/fixtures/matrix/invites/room.js new file mode 100644 index 00000000..41835d42 --- /dev/null +++ b/src/fixtures/matrix/invites/room.js @@ -0,0 +1,59 @@ +const inviteFixture = { + "invite_state": { + "events": [ + { + "type": "m.room.create", + "state_key": "", + "content": { + "creator": "@alice:hs.tld", + }, + "sender": "@alice:hs.tld" + }, + { + "type": "m.room.join_rules", + "state_key": "", + "content": { + "join_rule": "invite" + }, + "sender": "@alice:hs.tld" + }, + { + "type": "m.room.member", + "state_key": "@alice:hs.tld", + "content": { + "avatar_url": "mxc://hs.tld/def456", + "displayname": "Alice", + "membership": "join" + }, + "sender": "@alice:hs.tld" + }, + { + "type": "m.room.name", + "state_key": "", + "content": { + "name": "Invite example" + }, + "sender": "@alice:hs.tld" + }, + { + "content": { + "avatar_url": "mxc://hs.tld/abc123", + "displayname": "Bob", + "membership": "invite" + }, + "sender": "@alice:hs.tld", + "state_key": "@bob:hs.tld", + "type": "m.room.member", + }, + { + "content": { + "url": "mxc://hs.tld/roomavatar" + }, + "sender": "@alice:hs.tld", + "state_key": "", + "type": "m.room.avatar", + } + ] + } +}; +export default inviteFixture; \ No newline at end of file diff --git a/src/logging/NullLogger.js b/src/logging/NullLogger.js index 1860e697..614dc291 100644 --- a/src/logging/NullLogger.js +++ b/src/logging/NullLogger.js @@ -50,7 +50,7 @@ export class NullLogger { } } -class NullLogItem { +export class NullLogItem { wrap(_, callback) { return callback(this); } diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 0ddf44ae..c510bfb8 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -15,6 +15,7 @@ limitations under the License. */ import {Room} from "./room/Room.js"; +import {Invite} from "./room/Invite.js"; import {Pusher} from "./push/Pusher.js"; import { ObservableMap } from "../observable/index.js"; import {User} from "./User.js"; diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js new file mode 100644 index 00000000..6215498a --- /dev/null +++ b/src/matrix/room/Invite.js @@ -0,0 +1,252 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {EventEmitter} from "../../utils/EventEmitter.js"; +import {SummaryData, processStateEvent} from "./RoomSummary.js"; +import {Heroes} from "./members/Heroes.js"; +import {MemberChange, RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "./members/RoomMember.js"; + +export class Invite extends EventEmitter { + constructor({roomId, user, hsApi, emitCollectionRemove, clock}) { + super(); + this._roomId = roomId; + this._user = user; + this._hsApi = hsApi; + this._emitCollectionRemove = emitCollectionRemove; + this._clock = clock; + this._inviteData = null; + } + + get id() { + return this._roomId; + } + + get name() { + return this._inviteData.name || this._inviteData.canonicalAlias; + } + + get isDirectMessage() { + return this._inviteData.isDirectMessage; + } + + get avatarUrl() { + return this._inviteData.avatarUrl; + } + + get timestamp() { + return this._inviteData.timestamp; + } + + get isEncrypted() { + return this._inviteData.isEncrypted; + } + + get inviter() { + return this._inviter; + } + + get joinRule() { + return this._inviteData.joinRule; + } + + async accept() { + + } + + async reject() { + + } + + load(inviteData) { + this._inviteData = inviteData; + this._inviter = inviteData.inviter ? new RoomMember(inviteData.inviter) : null; + } + + async writeSync(membership, roomResponse, txn, log) { + if (membership === "invite") { + return log.wrap("new invite", async log => { + log.set("id", this.id); + const inviteState = roomResponse["invite_state"]?.events; + if (!Array.isArray(inviteState)) { + return null; + } + const summaryData = this._createSummaryData(inviteState); + let heroes; + if (!summaryData.name && !summaryData.canonicalAlias) { + heroes = await this._createHeroes(inviteState); + } + const myInvite = this._getMyInvite(inviteState); + if (!myInvite) { + return null; + } + const inviter = this._getInviter(myInvite, inviteState); + const inviteData = this._createData(inviteState, myInvite, inviter, summaryData, heroes); + txn.invites.set(inviteData); + return {inviteData, inviter}; + }); + } else { + return log.wrap("remove invite", log => { + log.set("id", this.id); + log.set("membership", membership); + txn.invites.remove(this.id); + return null; + }); + } + } + + afterSync(changes, room) { + if (changes) { + this._inviteData = changes.inviteData; + this._inviter = changes.inviter; + // emit update/add + } else { + this._emitCollectionRemove(this); + this.emit("change"); + } + } + + _createData(inviteState, myInvite, inviter, summaryData, heroes) { + const name = heroes ? heroes.roomName : summaryData.name; + const avatarUrl = heroes ? heroes.roomAvatarUrl : summaryData.avatarUrl; + return { + roomId: this.id, + isEncrypted: !!summaryData.encryption, + isDirectMessage: this._isDirectMessage(myInvite), +// type: + name, + avatarUrl, + canonicalAlias: summaryData.canonicalAlias, + timestamp: this._clock.now(), + joinRule: this._getJoinRule(inviteState), + inviter: inviter?.serialize(), + }; + } + + _isDirectMessage(myInvite) { + return !!(myInvite?.content?.is_direct); + } + + _createSummaryData(inviteState) { + return inviteState.reduce(processStateEvent, new SummaryData(null, this.id)); + } + + async _createHeroes(inviteState) { + const members = inviteState.filter(e => e.type === MEMBER_EVENT_TYPE); + const otherMembers = members.filter(e => e.state_key !== this._user.id); + const memberChanges = otherMembers.reduce((map, e) => { + const member = RoomMember.fromMemberEvent(this.id, e); + map.set(member.userId, new MemberChange(member, null)); + return map; + }, new Map()); + const otherUserIds = otherMembers.map(e => e.state_key); + const heroes = new Heroes(this.id); + const changes = await heroes.calculateChanges(otherUserIds, memberChanges, null); + // we don't get an actual lazy-loading m.heroes summary on invites, + // so just count the members by hand + const countSummary = new SummaryData(null, this.id); + countSummary.joinCount = members.reduce((sum, e) => sum + (e.content?.membership === "join" ? 1 : 0), 0); + countSummary.inviteCount = members.reduce((sum, e) => sum + (e.content?.membership === "invite" ? 1 : 0), 0); + heroes.applyChanges(changes, countSummary); + return heroes; + } + + _getMyInvite(inviteState) { + return inviteState.find(e => e.type === MEMBER_EVENT_TYPE && e.state_key === this._user.id); + } + + _getInviter(myInvite, inviteState) { + const inviterMemberEvent = inviteState.find(e => e.type === MEMBER_EVENT_TYPE && e.state_key === myInvite.sender); + if (inviterMemberEvent) { + return RoomMember.fromMemberEvent(this.id, inviterMemberEvent); + } + } + + _getJoinRule(inviteState) { + const event = inviteState.find(e => e.type === "m.room.join_rules"); + if (event) { + return event.content?.join_rule; + } + return null; + } +} + +import {NullLogItem} from "../../logging/NullLogger.js"; +import {Clock as MockClock} from "../../mocks/Clock.js"; +import {default as roomInviteFixture} from "../../fixtures/matrix/invites/room.js"; +import {default as dmInviteFixture} from "../../fixtures/matrix/invites/dm.js"; + +export function tests() { + + function createStorage() { + const invitesMap = new Map(); + return { + invitesMap, + invites: { + set(invite) { + invitesMap.set(invite.roomId, invite); + } + } + } + } + + const roomId = "!123:hs.tld"; + const aliceAvatarUrl = "mxc://hs.tld/def456"; + const roomAvatarUrl = "mxc://hs.tld/roomavatar"; + + return { + "invite for room has correct fields": async assert => { + const invite = new Invite({ + roomId, + clock: new MockClock(1001), + user: {id: "@bob:hs.tld"} + }); + const txn = createStorage(); + const changes = await invite.writeSync("invite", roomInviteFixture, txn, new NullLogItem()); + assert.equal(txn.invitesMap.get(roomId).roomId, roomId); + invite.afterSync(changes); + assert.equal(invite.name, "Invite example"); + assert.equal(invite.avatarUrl, roomAvatarUrl); + assert.equal(invite.joinRule, "invite"); + assert.equal(invite.timestamp, 1001); + assert.equal(invite.isEncrypted, false); + assert.equal(invite.isDirectMessage, false); + assert(invite.inviter); + assert.equal(invite.inviter.userId, "@alice:hs.tld"); + assert.equal(invite.inviter.displayName, "Alice"); + assert.equal(invite.inviter.avatarUrl, aliceAvatarUrl); + }, + "invite for encrypted DM has correct fields": async assert => { + const invite = new Invite({ + roomId, + clock: new MockClock(1003), + user: {id: "@bob:hs.tld"} + }); + const txn = createStorage(); + const changes = await invite.writeSync("invite", dmInviteFixture, txn, new NullLogItem()); + assert.equal(txn.invitesMap.get(roomId).roomId, roomId); + invite.afterSync(changes); + assert.equal(invite.name, "Alice"); + assert.equal(invite.avatarUrl, aliceAvatarUrl); + assert.equal(invite.timestamp, 1003); + assert.equal(invite.isEncrypted, true); + assert.equal(invite.isDirectMessage, true); + assert(invite.inviter); + assert.equal(invite.inviter.userId, "@alice:hs.tld"); + assert.equal(invite.inviter.displayName, "Alice"); + assert.equal(invite.inviter.avatarUrl, aliceAvatarUrl); + }, + } +} diff --git a/src/matrix/room/RoomSummary.js b/src/matrix/room/RoomSummary.js index 759b275a..d385c0a3 100644 --- a/src/matrix/room/RoomSummary.js +++ b/src/matrix/room/RoomSummary.js @@ -85,7 +85,7 @@ function processRoomAccountData(data, event) { return data; } -function processStateEvent(data, event) { +export function processStateEvent(data, event) { if (event.type === "m.room.encryption") { const algorithm = event.content?.algorithm; if (!data.encryption && algorithm === MEGOLM_ALGORITHM) { @@ -148,7 +148,7 @@ function updateSummary(data, summary) { return data; } -class SummaryData { +export class SummaryData { constructor(copy, roomId) { this.roomId = copy ? copy.roomId : roomId; this.name = copy ? copy.name : null; From 39c772300a087e466b10d4db813c8ffd60fc2308 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Apr 2021 16:21:35 +0200 Subject: [PATCH 04/74] fix indenting --- src/matrix/storage/idb/stores/InviteStore.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/matrix/storage/idb/stores/InviteStore.js b/src/matrix/storage/idb/stores/InviteStore.js index a3c7517a..41a2351a 100644 --- a/src/matrix/storage/idb/stores/InviteStore.js +++ b/src/matrix/storage/idb/stores/InviteStore.js @@ -15,17 +15,17 @@ limitations under the License. */ export class InviteStore { - constructor(inviteStore) { - this._inviteStore = inviteStore; - } + constructor(inviteStore) { + this._inviteStore = inviteStore; + } - getAll() { - return this._inviteStore.selectAll(); - } + getAll() { + return this._inviteStore.selectAll(); + } - set(invite) { - return this._inviteStore.put(invite); - } + set(invite) { + return this._inviteStore.put(invite); + } remove(roomId) { this._store.delete(roomId); From de125441d151d45f83c81a65c39ffa2a3791f24d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Apr 2021 17:34:45 +0200 Subject: [PATCH 05/74] add write/load test for Invite --- src/matrix/room/Invite.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index 6215498a..967241d2 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -248,5 +248,25 @@ export function tests() { assert.equal(invite.inviter.displayName, "Alice"); assert.equal(invite.inviter.avatarUrl, aliceAvatarUrl); }, + "load persisted invite has correct fields": async assert => { + const writeInvite = new Invite({ + roomId, + clock: new MockClock(1003), + user: {id: "@bob:hs.tld"} + }); + const txn = createStorage(); + await writeInvite.writeSync("invite", dmInviteFixture, txn, new NullLogItem()); + const invite = new Invite({roomId}); + invite.load(txn.invitesMap.get(roomId)); + assert.equal(invite.name, "Alice"); + assert.equal(invite.avatarUrl, aliceAvatarUrl); + assert.equal(invite.timestamp, 1003); + assert.equal(invite.isEncrypted, true); + assert.equal(invite.isDirectMessage, true); + assert(invite.inviter); + assert.equal(invite.inviter.userId, "@alice:hs.tld"); + assert.equal(invite.inviter.displayName, "Alice"); + assert.equal(invite.inviter.avatarUrl, aliceAvatarUrl); + }, } } From a072426e07759db6652b5f62b90ca6b6f2dcfa32 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Apr 2021 17:39:46 +0200 Subject: [PATCH 06/74] integrate invites into the sync lifecycle and expose them on session --- src/matrix/Session.js | 37 +++++++++++++++--- src/matrix/Sync.js | 68 ++++++++++++++++++++++++++++++---- src/matrix/room/Invite.js | 2 +- src/matrix/room/Room.js | 9 +++-- src/matrix/room/RoomSummary.js | 18 +++++++++ 5 files changed, 117 insertions(+), 17 deletions(-) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index c510bfb8..b18898bf 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -1,5 +1,6 @@ /* Copyright 2020 Bruno Windels +Copyright 2020, 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -53,6 +54,8 @@ export class Session { this._sessionInfo = sessionInfo; this._rooms = new ObservableMap(); this._roomUpdateCallback = (room, params) => this._rooms.update(room.id, params); + this._invites = new ObservableMap(); + this._inviteRemoveCallback = invite => this._invites.remove(invite.id); this._user = new User(sessionInfo.userId); this._deviceMessageHandler = new DeviceMessageHandler({storage}); this._olm = olm; @@ -281,9 +284,10 @@ export class Session { const pendingEventsByRoomId = await this._getPendingEventsByRoom(txn); // load rooms const rooms = await txn.roomSummary.getAll(); - await Promise.all(rooms.map(summary => { + await Promise.all(rooms.map(async summary => { const room = this.createRoom(summary.roomId, pendingEventsByRoomId.get(summary.roomId)); - return log.wrap("room", log => room.load(summary, txn, log)); + await log.wrap("room", log => room.load(summary, txn, log)); + this._rooms.add(room.id, room); })); } @@ -361,7 +365,7 @@ export class Session { /** @internal */ createRoom(roomId, pendingEvents) { - const room = new Room({ + return new Room({ roomId, getSyncToken: this._getSyncToken, storage: this._storage, @@ -373,8 +377,31 @@ export class Session { createRoomEncryption: this._createRoomEncryption, platform: this._platform }); - this._rooms.add(roomId, room); - return room; + } + + /** @internal */ + addRoomAfterSync(room) { + this._rooms.add(room.id, room); + } + + get invites() { + return this._invites; + } + + /** @internal */ + createInvite(roomId) { + return new Invite({ + roomId, + hsApi: this._hsApi, + emitCollectionRemove: this._inviteRemoveCallback, + user: this._user, + clock: this._platform.clock, + }); + } + + /** @internal */ + addInviteAfterSync(invite) { + this._invites.add(invite.id, invite); } async obtainSyncLock(syncResponse) { diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index 73ff0207..7e64d2f8 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -1,6 +1,6 @@ /* Copyright 2020 Bruno Windels -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020, 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -191,7 +191,8 @@ export class Sync { const isInitialSync = !syncToken; const sessionState = new SessionSyncProcessState(); - const roomStates = this._parseRoomsResponse(response.rooms, isInitialSync); + const inviteStates = this._parseInvites(response.rooms); + const roomStates = this._parseRoomsResponse(response.rooms, inviteStates, isInitialSync); try { // take a lock on olm sessions used in this sync so sending a message doesn't change them while syncing @@ -205,6 +206,10 @@ export class Sync { try { sessionState.changes = await log.wrap("session", log => this._session.writeSync( response, syncFilterId, sessionState.preparation, syncTxn, log)); + await Promise.all(inviteStates.map(async is => { + is.changes = await log.wrap("invite", log => is.invite.writeSync( + is.membership, is.roomResponse, syncTxn, log)); + })); await Promise.all(roomStates.map(async rs => { rs.changes = await log.wrap("room", log => rs.room.writeSync( rs.roomResponse, isInitialSync, rs.preparation, syncTxn, log)); @@ -228,9 +233,19 @@ export class Sync { log.wrap("after", log => { log.wrap("session", log => this._session.afterSync(sessionState.changes, log), log.level.Detail); + // emit invite related events after txn has been closed + for(let is of inviteStates) { + log.wrap("invite", () => is.invite.afterSync(is.changes), log.level.Detail); + if (is.isNewInvite) { + this._session.addInviteAfterSync(is.invite); + } + } // emit room related events after txn has been closed for(let rs of roomStates) { log.wrap("room", log => rs.room.afterSync(rs.changes, log), log.level.Detail); + if (rs.isNewRoom) { + this._session.addRoomAfterSync(rs.room); + } } }); @@ -267,7 +282,7 @@ export class Sync { if (!isRoomInResponse) { let room = this._session.rooms.get(roomId); if (room) { - roomStates.push(new RoomSyncProcessState(room, {}, room.membership)); + roomStates.push(new RoomSyncProcessState(room, false, null, {}, room.membership)); } } } @@ -276,7 +291,7 @@ export class Sync { await Promise.all(roomStates.map(async rs => { const newKeys = newKeysByRoom?.get(rs.room.id); rs.preparation = await log.wrap("room", log => rs.room.prepareSync( - rs.roomResponse, rs.membership, newKeys, prepareTxn, log), log.level.Detail); + rs.roomResponse, rs.membership, rs.invite, newKeys, prepareTxn, log), log.level.Detail); })); // This is needed for safari to not throw TransactionInactiveErrors on the syncTxn. See docs/INDEXEDDB.md @@ -288,6 +303,7 @@ export class Sync { return this._storage.readWriteTxn([ storeNames.session, storeNames.roomSummary, + storeNames.invites, storeNames.roomState, storeNames.roomMembers, storeNames.timelineEvents, @@ -307,10 +323,10 @@ export class Sync { ]); } - _parseRoomsResponse(roomsSection, isInitialSync) { + _parseRoomsResponse(roomsSection, inviteStates, isInitialSync) { const roomStates = []; if (roomsSection) { - // don't do "invite", "leave" for now + // don't do "leave" for now const allMemberships = ["join"]; for(const membership of allMemberships) { const membershipSection = roomsSection[membership]; @@ -321,11 +337,20 @@ export class Sync { if (isInitialSync && timelineIsEmpty(roomResponse)) { continue; } + let isNewRoom = false; let room = this._session.rooms.get(roomId); if (!room) { room = this._session.createRoom(roomId); + isNewRoom = true; } - roomStates.push(new RoomSyncProcessState(room, roomResponse, membership)); + const invite = this._session.invites.get(roomId); + // if there is an existing invite, add a process state for it + // so its writeSync and afterSync will run and remove the invite + if (invite) { + inviteStates.push(new InviteSyncProcessState(invite, false, membership, null)); + } + roomStates.push(new RoomSyncProcessState( + room, isNewRoom, invite, roomResponse, membership)); } } } @@ -333,6 +358,21 @@ export class Sync { return roomStates; } + _parseInvites(invites, roomsSection) { + const inviteStates = []; + if (roomsSection.invite) { + for (const [roomId, roomResponse] of Object.entries(roomsSection.invite)) { + let invite = this._session.invites.get(roomId); + let isNewInvite = false; + if (!invite) { + invite = this._session.createInvite(roomId); + isNewInvite = true; + } + inviteStates.push(new InviteSyncProcessState(invite, isNewInvite, "invite", roomResponse)); + } + } + return inviteStates; + } stop() { if (this._status.get() === SyncStatus.Stopped) { @@ -360,11 +400,23 @@ class SessionSyncProcessState { } class RoomSyncProcessState { - constructor(room, roomResponse, membership) { + constructor(room, isNewRoom, invite, roomResponse, membership) { this.room = room; + this.isNewRoom = isNewRoom; + this.invite = invite; this.roomResponse = roomResponse; this.membership = membership; this.preparation = null; this.changes = null; } } + +class InviteSyncProcessState { + constructor(invite, isNewInvite, membership, roomResponse) { + this.invite = invite; + this.isNewInvite = isNewInvite; + this.membership = membership; + this.roomResponse = roomResponse; + this.changes = null; + } +} diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index 967241d2..58f598a4 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -107,7 +107,7 @@ export class Invite extends EventEmitter { } } - afterSync(changes, room) { + afterSync(changes) { if (changes) { this._inviteData = changes.inviteData; this._inviter = changes.inviter; diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 91cc3ceb..1d4b650e 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -189,12 +189,15 @@ export class Room extends EventEmitter { return retryEntries; } - async prepareSync(roomResponse, membership, newKeys, txn, log) { + async prepareSync(roomResponse, membership, invite, newKeys, txn, log) { log.set("id", this.id); if (newKeys) { log.set("newKeys", newKeys.length); } - const summaryChanges = this._summary.data.applySyncResponse(roomResponse, membership) + let summaryChanges = this._summary.data.applySyncResponse(roomResponse, membership); + if (invite) { + summaryChanges = summaryChanges.applyInvite(invite); + } let roomEncryption = this._roomEncryption; // encryption is enabled in this sync if (!roomEncryption && summaryChanges.encryption) { @@ -379,7 +382,7 @@ export class Room extends EventEmitter { * Can be used to do longer running operations that resulted from the last sync, * like network operations. */ - async afterSyncCompleted(changes, log) { + async afterSyncCompleted(changes, isNewRoom, log) { log.set("id", this.id); if (this._roomEncryption) { await this._roomEncryption.flushPendingRoomKeyShares(this._hsApi, null, log); diff --git a/src/matrix/room/RoomSummary.js b/src/matrix/room/RoomSummary.js index d385c0a3..88b2c45b 100644 --- a/src/matrix/room/RoomSummary.js +++ b/src/matrix/room/RoomSummary.js @@ -148,6 +148,18 @@ function updateSummary(data, summary) { return data; } +function applyInvite(data, invite) { + if (data.isDirectMessage !== invite.isDirectMessage) { + data = data.cloneIfNeeded(); + data.isDirectMessage = invite.isDirectMessage; + } + if (data.dmUserId !== invite.inviter?.userId) { + data = data.cloneIfNeeded(); + data.dmUserId = invite.inviter?.userId; + } + return data; +} + export class SummaryData { constructor(copy, roomId) { this.roomId = copy ? copy.roomId : roomId; @@ -166,6 +178,8 @@ export class SummaryData { this.notificationCount = copy ? copy.notificationCount : 0; this.highlightCount = copy ? copy.highlightCount : 0; this.tags = copy ? copy.tags : null; + this.isDirectMessage = copy ? copy.isDirectMessage : false; + this.dmUserId = copy ? copy.dmUserId : null; this.cloned = copy ? true : false; } @@ -202,6 +216,10 @@ export class SummaryData { return applySyncResponse(this, roomResponse, membership); } + applyInvite(invite) { + return applyInvite(this, invite); + } + get needsHeroes() { return !this.name && !this.canonicalAlias && this.heroes && this.heroes.length > 0; } From d1dc4c9e9c36f48cc583bcef66e6e940816ed518 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Apr 2021 17:56:53 +0200 Subject: [PATCH 07/74] remove extra layer of log items --- src/matrix/room/Invite.js | 49 ++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index 58f598a4..a9846ccc 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -77,33 +77,30 @@ export class Invite extends EventEmitter { async writeSync(membership, roomResponse, txn, log) { if (membership === "invite") { - return log.wrap("new invite", async log => { - log.set("id", this.id); - const inviteState = roomResponse["invite_state"]?.events; - if (!Array.isArray(inviteState)) { - return null; - } - const summaryData = this._createSummaryData(inviteState); - let heroes; - if (!summaryData.name && !summaryData.canonicalAlias) { - heroes = await this._createHeroes(inviteState); - } - const myInvite = this._getMyInvite(inviteState); - if (!myInvite) { - return null; - } - const inviter = this._getInviter(myInvite, inviteState); - const inviteData = this._createData(inviteState, myInvite, inviter, summaryData, heroes); - txn.invites.set(inviteData); - return {inviteData, inviter}; - }); - } else { - return log.wrap("remove invite", log => { - log.set("id", this.id); - log.set("membership", membership); - txn.invites.remove(this.id); + log.set("id", this.id); + log.set("add", true); + const inviteState = roomResponse["invite_state"]?.events; + if (!Array.isArray(inviteState)) { return null; - }); + } + const summaryData = this._createSummaryData(inviteState); + let heroes; + if (!summaryData.name && !summaryData.canonicalAlias) { + heroes = await this._createHeroes(inviteState); + } + const myInvite = this._getMyInvite(inviteState); + if (!myInvite) { + return null; + } + const inviter = this._getInviter(myInvite, inviteState); + const inviteData = this._createData(inviteState, myInvite, inviter, summaryData, heroes); + txn.invites.set(inviteData); + return {inviteData, inviter}; + } else { + log.set("id", this.id); + log.set("membership", membership); + txn.invites.remove(this.id); + return null; } } From 09ac503e220c55f74127182030bc947abec251ee Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Apr 2021 17:57:03 +0200 Subject: [PATCH 08/74] add test for removing invite --- src/matrix/room/Invite.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index a9846ccc..f71344cd 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -194,6 +194,9 @@ export function tests() { invites: { set(invite) { invitesMap.set(invite.roomId, invite); + }, + remove(roomId) { + invitesMap.delete(roomId); } } } @@ -265,5 +268,26 @@ export function tests() { assert.equal(invite.inviter.displayName, "Alice"); assert.equal(invite.inviter.avatarUrl, aliceAvatarUrl); }, + "syncing with membership from invite removes the invite": async assert => { + let removedEmitted = false; + const invite = new Invite({ + roomId, + clock: new MockClock(1003), + user: {id: "@bob:hs.tld"}, + emitCollectionRemove: emittingInvite => { + assert.equal(emittingInvite, invite); + removedEmitted = true; + } + }); + const txn = createStorage(); + const changes = await invite.writeSync("invite", dmInviteFixture, txn, new NullLogItem()); + assert.equal(txn.invitesMap.get(roomId).roomId, roomId); + invite.afterSync(changes); + const joinChanges = await invite.writeSync("join", null, txn, new NullLogItem()); + assert(!removedEmitted); + invite.afterSync(joinChanges); + assert.equal(txn.invitesMap.get(roomId), undefined); + assert(removedEmitted); + } } } From 4560e0e4916c136a3f314feeb7b3f1efe5f85b3c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Apr 2021 17:57:17 +0200 Subject: [PATCH 09/74] split sync lifecycle steps out in different methods to keep it smaller --- src/matrix/Sync.js | 97 ++++++++++++++++++++++++---------------------- 1 file changed, 51 insertions(+), 46 deletions(-) diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index 7e64d2f8..8b81d18e 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -197,57 +197,17 @@ export class Sync { try { // take a lock on olm sessions used in this sync so sending a message doesn't change them while syncing sessionState.lock = await log.wrap("obtainSyncLock", () => this._session.obtainSyncLock(response)); - await log.wrap("prepare", log => this._prepareSessionAndRooms(sessionState, roomStates, response, log)); + await log.wrap("prepare", log => this._prepareSync(sessionState, roomStates, response, log)); await log.wrap("afterPrepareSync", log => Promise.all(roomStates.map(rs => { return rs.room.afterPrepareSync(rs.preparation, log); }))); - await log.wrap("write", async log => { - const syncTxn = await this._openSyncTxn(); - try { - sessionState.changes = await log.wrap("session", log => this._session.writeSync( - response, syncFilterId, sessionState.preparation, syncTxn, log)); - await Promise.all(inviteStates.map(async is => { - is.changes = await log.wrap("invite", log => is.invite.writeSync( - is.membership, is.roomResponse, syncTxn, log)); - })); - await Promise.all(roomStates.map(async rs => { - rs.changes = await log.wrap("room", log => rs.room.writeSync( - rs.roomResponse, isInitialSync, rs.preparation, syncTxn, log)); - })); - } catch(err) { - // avoid corrupting state by only - // storing the sync up till the point - // the exception occurred - try { - syncTxn.abort(); - } catch (abortErr) { - log.set("couldNotAbortTxn", true); - } - throw err; - } - await syncTxn.complete(); - }); + await log.wrap("write", async log => this._writeSync( + sessionState, inviteStates, roomStates, response, syncFilterId, isInitialSync, log)); } finally { sessionState.dispose(); } - - log.wrap("after", log => { - log.wrap("session", log => this._session.afterSync(sessionState.changes, log), log.level.Detail); - // emit invite related events after txn has been closed - for(let is of inviteStates) { - log.wrap("invite", () => is.invite.afterSync(is.changes), log.level.Detail); - if (is.isNewInvite) { - this._session.addInviteAfterSync(is.invite); - } - } - // emit room related events after txn has been closed - for(let rs of roomStates) { - log.wrap("room", log => rs.room.afterSync(rs.changes, log), log.level.Detail); - if (rs.isNewRoom) { - this._session.addRoomAfterSync(rs.room); - } - } - }); + // sync txn comitted, emit updates and apply changes to in-memory state + log.wrap("after", log => this._afterSync(sessionState, inviteStates, roomStates, log)); const toDeviceEvents = response.to_device?.events; return { @@ -267,7 +227,7 @@ export class Sync { ]); } - async _prepareSessionAndRooms(sessionState, roomStates, response, log) { + async _prepareSync(sessionState, roomStates, response, log) { const prepareTxn = await this._openPrepareSyncTxn(); sessionState.preparation = await log.wrap("session", log => this._session.prepareSync( response, sessionState.lock, prepareTxn, log)); @@ -298,6 +258,51 @@ export class Sync { await prepareTxn.complete(); } + async _writeSync(sessionState, inviteStates, roomStates, response, syncFilterId, isInitialSync, log) { + const syncTxn = await this._openSyncTxn(); + try { + sessionState.changes = await log.wrap("session", log => this._session.writeSync( + response, syncFilterId, sessionState.preparation, syncTxn, log)); + await Promise.all(inviteStates.map(async is => { + is.changes = await log.wrap("invite", log => is.invite.writeSync( + is.membership, is.roomResponse, syncTxn, log)); + })); + await Promise.all(roomStates.map(async rs => { + rs.changes = await log.wrap("room", log => rs.room.writeSync( + rs.roomResponse, isInitialSync, rs.preparation, syncTxn, log)); + })); + } catch(err) { + // avoid corrupting state by only + // storing the sync up till the point + // the exception occurred + try { + syncTxn.abort(); + } catch (abortErr) { + log.set("couldNotAbortTxn", true); + } + throw err; + } + await syncTxn.complete(); + } + + _afterSync(sessionState, inviteStates, roomStates, log) { + log.wrap("session", log => this._session.afterSync(sessionState.changes, log), log.level.Detail); + // emit invite related events after txn has been closed + for(let is of inviteStates) { + log.wrap("invite", () => is.invite.afterSync(is.changes), log.level.Detail); + if (is.isNewInvite) { + this._session.addInviteAfterSync(is.invite); + } + } + // emit room related events after txn has been closed + for(let rs of roomStates) { + log.wrap("room", log => rs.room.afterSync(rs.changes, log), log.level.Detail); + if (rs.isNewRoom) { + this._session.addRoomAfterSync(rs.room); + } + } + } + _openSyncTxn() { const storeNames = this._storage.storeNames; return this._storage.readWriteTxn([ From 5876e5200b519d2fac8868e4dcd1a7bb915a8a8a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Apr 2021 18:03:27 +0200 Subject: [PATCH 10/74] don't need this for now --- src/matrix/room/Room.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 1d4b650e..02b19a18 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -382,7 +382,7 @@ export class Room extends EventEmitter { * Can be used to do longer running operations that resulted from the last sync, * like network operations. */ - async afterSyncCompleted(changes, isNewRoom, log) { + async afterSyncCompleted(changes, log) { log.set("id", this.id); if (this._roomEncryption) { await this._roomEncryption.flushPendingRoomKeyShares(this._hsApi, null, log); From ee98eaa640ba14e869560008ea601c1bf1817947 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Apr 2021 18:09:48 +0200 Subject: [PATCH 11/74] restore invites when loading the session --- src/matrix/Session.js | 13 +++++++++++++ src/matrix/room/Invite.js | 5 +++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index b18898bf..8eb0efbf 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -258,6 +258,7 @@ export class Session { const txn = await this._storage.readTxn([ this._storage.storeNames.session, this._storage.storeNames.roomSummary, + this._storage.storeNames.invites, this._storage.storeNames.roomMembers, this._storage.storeNames.timelineEvents, this._storage.storeNames.timelineFragments, @@ -289,6 +290,13 @@ export class Session { await log.wrap("room", log => room.load(summary, txn, log)); this._rooms.add(room.id, room); })); + // load invites + const invites = await txn.invites.getAll(); + await Promise.all(invites.map(async inviteData => { + const invite = this.createInvite(inviteData.roomId); + log.wrap("invite", log => invite.load(inviteData, log)); + this._invites.add(invite.id, invite); + })); } dispose() { @@ -583,6 +591,11 @@ export function tests() { getAll() { return Promise.resolve([]); } + }, + invites: { + getAll() { + return Promise.resolve([]); + } } }; }, diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index f71344cd..56304cfd 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -70,7 +70,8 @@ export class Invite extends EventEmitter { } - load(inviteData) { + load(inviteData, log) { + log.set("id", this.id); this._inviteData = inviteData; this._inviter = inviteData.inviter ? new RoomMember(inviteData.inviter) : null; } @@ -257,7 +258,7 @@ export function tests() { const txn = createStorage(); await writeInvite.writeSync("invite", dmInviteFixture, txn, new NullLogItem()); const invite = new Invite({roomId}); - invite.load(txn.invitesMap.get(roomId)); + invite.load(txn.invitesMap.get(roomId), new NullLogItem()); assert.equal(invite.name, "Alice"); assert.equal(invite.avatarUrl, aliceAvatarUrl); assert.equal(invite.timestamp, 1003); From bb1c64e1acfea1fed8ca9b92bbe874b18d4e0520 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Apr 2021 19:01:40 +0200 Subject: [PATCH 12/74] add left panel tile view model for invites and track in-progress state in Invite so it is shared by left panel and open invite --- .../session/leftpanel/BaseTileViewModel.js | 89 +++++++++++++++++++ .../session/leftpanel/InviteTileViewModel.js | 55 ++++++++++++ .../session/leftpanel/RoomTileViewModel.js | 69 +++----------- src/matrix/Session.js | 2 + src/matrix/room/Invite.js | 56 ++++++++++-- 5 files changed, 205 insertions(+), 66 deletions(-) create mode 100644 src/domain/session/leftpanel/BaseTileViewModel.js create mode 100644 src/domain/session/leftpanel/InviteTileViewModel.js diff --git a/src/domain/session/leftpanel/BaseTileViewModel.js b/src/domain/session/leftpanel/BaseTileViewModel.js new file mode 100644 index 00000000..7b2a3258 --- /dev/null +++ b/src/domain/session/leftpanel/BaseTileViewModel.js @@ -0,0 +1,89 @@ +/* +Copyright 2020 Bruno Windels +Copyright 2020, 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {avatarInitials, getIdentifierColorNumber} from "../../avatar.js"; +import {ViewModel} from "../../ViewModel.js"; + +const KIND_ORDER = ["invite", "room"]; + +export class BaseTileViewModel extends ViewModel { + constructor(options) { + super(options); + this._isOpen = false; + this._hidden = false; + if (options.isOpen) { + this.open(); + } + } + + get hidden() { + return this._hidden; + } + + set hidden(value) { + if (value !== this._hidden) { + this._hidden = value; + this.emitChange("hidden"); + } + } + + close() { + if (this._isOpen) { + this._isOpen = false; + this.emitChange("isOpen"); + } + } + + open() { + if (!this._isOpen) { + this._isOpen = true; + this.emitChange("isOpen"); + } + } + + get isOpen() { + return this._isOpen; + } + + compare(other) { + if (other.kind !== this.kind) { + return KIND_ORDER.indexOf(this.kind) - KIND_ORDER.indexOf(other.kind); + } + return 0; + } + + // Avatar view model contract + get avatarLetter() { + return avatarInitials(this.name); + } + + get avatarColorNumber() { + return getIdentifierColorNumber(this._avatarSource) + } + + get avatarUrl() { + if (this._avatarSource.avatarUrl) { + const size = 32 * this.platform.devicePixelRatio; + return this._room.mediaRepository.mxcUrlThumbnail(this._avatarSource.avatarUrl, size, size, "crop"); + } + return null; + } + + get avatarTitle() { + return this.name; + } +} diff --git a/src/domain/session/leftpanel/InviteTileViewModel.js b/src/domain/session/leftpanel/InviteTileViewModel.js new file mode 100644 index 00000000..5c3082c0 --- /dev/null +++ b/src/domain/session/leftpanel/InviteTileViewModel.js @@ -0,0 +1,55 @@ +/* +Copyright 2020 Bruno Windels +Copyright 2020, 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {BaseTileViewModel} from "./BaseTileViewModel.js"; + +export class InviteTileViewModel extends BaseTileViewModel { + constructor(options) { + super(options); + const {invite} = options; + this._invite = invite; + this._url = this.urlCreator.openRoomActionUrl(this._invite.id); + } + + get busy() { + return this._invite.accepting || this._invite.rejecting; + } + + get kind() { + return "invite"; + } + + get url() { + return this._url; + } + + compare(other) { + const parentComparison = super.compare(other); + if (parentComparison !== 0) { + return parentComparison; + } + return this._invite.timestamp - other._invite.timestamp; + } + + get name() { + return this._invite.name; + } + + get _avatarSource() { + return this._invite; + } +} diff --git a/src/domain/session/leftpanel/RoomTileViewModel.js b/src/domain/session/leftpanel/RoomTileViewModel.js index 1858fce7..50164b18 100644 --- a/src/domain/session/leftpanel/RoomTileViewModel.js +++ b/src/domain/session/leftpanel/RoomTileViewModel.js @@ -15,51 +15,23 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {avatarInitials, getIdentifierColorNumber} from "../../avatar.js"; -import {ViewModel} from "../../ViewModel.js"; +import {BaseTileViewModel} from "./BaseTileViewModel.js"; function isSortedAsUnread(vm) { return vm.isUnread || (vm.isOpen && vm._wasUnreadWhenOpening); } -export class RoomTileViewModel extends ViewModel { +export class RoomTileViewModel extends BaseTileViewModel { constructor(options) { super(options); const {room} = options; this._room = room; - this._isOpen = false; this._wasUnreadWhenOpening = false; - this._hidden = false; this._url = this.urlCreator.openRoomActionUrl(this._room.id); - if (options.isOpen) { - this.open(); - } } - get hidden() { - return this._hidden; - } - - set hidden(value) { - if (value !== this._hidden) { - this._hidden = value; - this.emitChange("hidden"); - } - } - - close() { - if (this._isOpen) { - this._isOpen = false; - this.emitChange("isOpen"); - } - } - - open() { - if (!this._isOpen) { - this._isOpen = true; - this._wasUnreadWhenOpening = this._room.isUnread; - this.emitChange("isOpen"); - } + get kind() { + return "room"; } get url() { @@ -67,6 +39,10 @@ export class RoomTileViewModel extends ViewModel { } compare(other) { + const parentComparison = super.compare(other); + if (parentComparison !== 0) { + return parentComparison; + } /* put unread rooms first then put rooms with a timestamp first, and sort by name @@ -110,10 +86,6 @@ export class RoomTileViewModel extends ViewModel { return timeDiff; } - get isOpen() { - return this._isOpen; - } - get isUnread() { return this._room.isUnread; } @@ -122,27 +94,6 @@ export class RoomTileViewModel extends ViewModel { return this._room.name || this.i18n`Empty Room`; } - // Avatar view model contract - get avatarLetter() { - return avatarInitials(this.name); - } - - get avatarColorNumber() { - return getIdentifierColorNumber(this._room.id) - } - - get avatarUrl() { - if (this._room.avatarUrl) { - const size = 32 * this.platform.devicePixelRatio; - return this._room.mediaRepository.mxcUrlThumbnail(this._room.avatarUrl, size, size, "crop"); - } - return null; - } - - get avatarTitle() { - return this.name; - } - get badgeCount() { return this._room.notificationCount; } @@ -150,4 +101,8 @@ export class RoomTileViewModel extends ViewModel { get isHighlighted() { return this._room.highlightCount !== 0; } + + get _avatarSource() { + return this._room; + } } diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 8eb0efbf..d762eac2 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -56,6 +56,7 @@ export class Session { this._roomUpdateCallback = (room, params) => this._rooms.update(room.id, params); this._invites = new ObservableMap(); this._inviteRemoveCallback = invite => this._invites.remove(invite.id); + this._inviteUpdateCallback = (invite, params) => this._invites.update(invite.id, params); this._user = new User(sessionInfo.userId); this._deviceMessageHandler = new DeviceMessageHandler({storage}); this._olm = olm; @@ -402,6 +403,7 @@ export class Session { roomId, hsApi: this._hsApi, emitCollectionRemove: this._inviteRemoveCallback, + emitCollectionUpdate: this._inviteUpdateCallback, user: this._user, clock: this._platform.clock, }); diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index 56304cfd..3a59594e 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -20,14 +20,19 @@ import {Heroes} from "./members/Heroes.js"; import {MemberChange, RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "./members/RoomMember.js"; export class Invite extends EventEmitter { - constructor({roomId, user, hsApi, emitCollectionRemove, clock}) { + constructor({roomId, user, hsApi, emitCollectionRemove, emitCollectionUpdate, clock}) { super(); this._roomId = roomId; this._user = user; this._hsApi = hsApi; this._emitCollectionRemove = emitCollectionRemove; + this._emitCollectionUpdate = emitCollectionUpdate; this._clock = clock; this._inviteData = null; + this._accepting = false; + this._rejecting = false; + this._accepted = false; + this._rejected = false; } get id() { @@ -63,11 +68,34 @@ export class Invite extends EventEmitter { } async accept() { - + this._accepting = true; + this._emitChange("accepting"); } async reject() { + this._rejecting = true; + this._emitChange("rejecting"); + } + get accepting() { + return this._accepting; + } + + get accepted() { + return this._accepted; + } + + get rejecting() { + return this._rejecting; + } + + get rejected() { + return this._rejected; + } + + _emitChange(params) { + this.emit("change"); + this._emitCollectionUpdate(params); } load(inviteData, log) { @@ -101,18 +129,28 @@ export class Invite extends EventEmitter { log.set("id", this.id); log.set("membership", membership); txn.invites.remove(this.id); - return null; + return {removed: true, membership}; } } afterSync(changes) { if (changes) { - this._inviteData = changes.inviteData; - this._inviter = changes.inviter; - // emit update/add - } else { - this._emitCollectionRemove(this); - this.emit("change"); + if (changes.removed) { + this._accepting = false; + this._rejecting = false; + if (changes.membership === "join") { + this._accepted = true; + } else { + this._rejected = true; + } + this._emitCollectionRemove(this); + this.emit("change"); + } else { + this._inviteData = changes.inviteData; + this._inviter = changes.inviter; + // sync will add the invite to the collection by + // calling session.addInviteAfterSync + } } } From 5ce138539b272dd275b793747dc7b1481d854873 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Apr 2021 19:02:45 +0200 Subject: [PATCH 13/74] network calls to accept and reject invite --- src/matrix/net/HomeServerApi.js | 8 ++++++++ src/matrix/room/Invite.js | 2 ++ 2 files changed, 10 insertions(+) diff --git a/src/matrix/net/HomeServerApi.js b/src/matrix/net/HomeServerApi.js index 051bb44a..77e02d76 100644 --- a/src/matrix/net/HomeServerApi.js +++ b/src/matrix/net/HomeServerApi.js @@ -185,6 +185,14 @@ export class HomeServerApi { getPushers(options = null) { return this._get("/pushers", null, null, options); } + + join(roomId, options = null) { + return this._post(`/rooms/${encodeURIComponent(roomId)}/join`, null, null, options); + } + + leave(roomId, options = null) { + return this._post(`/rooms/${encodeURIComponent(roomId)}/leave`, null, null, options); + } } import {Request as MockRequest} from "../../mocks/Request.js"; diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index 3a59594e..3508d111 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -70,11 +70,13 @@ export class Invite extends EventEmitter { async accept() { this._accepting = true; this._emitChange("accepting"); + await this._hsApi.join(this._roomId).response(); } async reject() { this._rejecting = true; this._emitChange("rejecting"); + await this._hsApi.leave(this._roomId).response(); } get accepting() { From 465e0c191f6bc553405ab81543c054025096c0cc Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 15:31:55 +0200 Subject: [PATCH 14/74] add logging to accepting or rejecting an invite --- src/matrix/Session.js | 2 +- src/matrix/room/Invite.js | 34 +++++++++++++++++++--------------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index d762eac2..c19fc006 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -405,7 +405,7 @@ export class Session { emitCollectionRemove: this._inviteRemoveCallback, emitCollectionUpdate: this._inviteUpdateCallback, user: this._user, - clock: this._platform.clock, + platform: this._platform, }); } diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index 3508d111..6aeb1d04 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -20,14 +20,14 @@ import {Heroes} from "./members/Heroes.js"; import {MemberChange, RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "./members/RoomMember.js"; export class Invite extends EventEmitter { - constructor({roomId, user, hsApi, emitCollectionRemove, emitCollectionUpdate, clock}) { + constructor({roomId, user, hsApi, emitCollectionRemove, emitCollectionUpdate, platform}) { super(); this._roomId = roomId; this._user = user; this._hsApi = hsApi; this._emitCollectionRemove = emitCollectionRemove; this._emitCollectionUpdate = emitCollectionUpdate; - this._clock = clock; + this._platform = platform; this._inviteData = null; this._accepting = false; this._rejecting = false; @@ -67,16 +67,20 @@ export class Invite extends EventEmitter { return this._inviteData.joinRule; } - async accept() { - this._accepting = true; - this._emitChange("accepting"); - await this._hsApi.join(this._roomId).response(); + async accept(log = null) { + await this._platform.logger.wrapOrRun(log, "acceptInvite", async log => { + this._accepting = true; + this._emitChange("accepting"); + await this._hsApi.join(this._roomId, {log}).response(); + }); } - async reject() { - this._rejecting = true; - this._emitChange("rejecting"); - await this._hsApi.leave(this._roomId).response(); + async reject(log = null) { + await this._platform.logger.wrapOrRun(log, "rejectInvite", async log => { + this._rejecting = true; + this._emitChange("rejecting"); + await this._hsApi.leave(this._roomId, {log}).response(); + }); } get accepting() { @@ -167,7 +171,7 @@ export class Invite extends EventEmitter { name, avatarUrl, canonicalAlias: summaryData.canonicalAlias, - timestamp: this._clock.now(), + timestamp: this._platform.clock.now(), joinRule: this._getJoinRule(inviteState), inviter: inviter?.serialize(), }; @@ -251,7 +255,7 @@ export function tests() { "invite for room has correct fields": async assert => { const invite = new Invite({ roomId, - clock: new MockClock(1001), + platform: {clock: new MockClock(1001)}, user: {id: "@bob:hs.tld"} }); const txn = createStorage(); @@ -272,7 +276,7 @@ export function tests() { "invite for encrypted DM has correct fields": async assert => { const invite = new Invite({ roomId, - clock: new MockClock(1003), + platform: {clock: new MockClock(1003)}, user: {id: "@bob:hs.tld"} }); const txn = createStorage(); @@ -292,7 +296,7 @@ export function tests() { "load persisted invite has correct fields": async assert => { const writeInvite = new Invite({ roomId, - clock: new MockClock(1003), + platform: {clock: new MockClock(1003)}, user: {id: "@bob:hs.tld"} }); const txn = createStorage(); @@ -313,7 +317,7 @@ export function tests() { let removedEmitted = false; const invite = new Invite({ roomId, - clock: new MockClock(1003), + platform: {clock: new MockClock(1003)}, user: {id: "@bob:hs.tld"}, emitCollectionRemove: emittingInvite => { assert.equal(emittingInvite, invite); From a5e629459338bdf90d814b62831e6ca1cb9a64e9 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 15:32:13 +0200 Subject: [PATCH 15/74] test accepted/rejected fields in invite tests --- src/matrix/room/Invite.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index 6aeb1d04..1335b46e 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -332,6 +332,8 @@ export function tests() { assert(!removedEmitted); invite.afterSync(joinChanges); assert.equal(txn.invitesMap.get(roomId), undefined); + assert.equal(invite.rejected, false); + assert.equal(invite.accepted, true); assert(removedEmitted); } } From ad5d7fc9f031c4613a036fcc96787b8fec23088c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 15:32:37 +0200 Subject: [PATCH 16/74] add note collection emit should happen first in Invite --- src/matrix/room/Invite.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index 1335b46e..f9547828 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -149,6 +149,9 @@ export class Invite extends EventEmitter { } else { this._rejected = true; } + // important to remove before emitting change + // so code checking session.invites.get(id) won't + // find the invite anymore on update this._emitCollectionRemove(this); this.emit("change"); } else { From 9c19fa5c63050a23a3e494fbab79966f677837d1 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 15:33:08 +0200 Subject: [PATCH 17/74] utility to remove room from path (with our without grid) which will be used when rejecting an invite --- src/domain/navigation/Navigation.js | 16 +++++ src/domain/navigation/index.js | 99 ++++++++++++++++++++++++++++- 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/src/domain/navigation/Navigation.js b/src/domain/navigation/Navigation.js index fa1c7142..9d059ec9 100644 --- a/src/domain/navigation/Navigation.js +++ b/src/domain/navigation/Navigation.js @@ -147,6 +147,22 @@ class Path { return this._segments.find(s => s.type === type); } + replace(segment) { + const index = this._segments.findIndex(s => s.type === segment.type); + if (index !== -1) { + const parent = this._segments[index - 1]; + if (this._allowsChild(parent, segment)) { + const child = this._segments[index + 1]; + if (!child || this._allowsChild(segment, child)) { + const newSegments = this._segments.slice(); + newSegments[index] = segment; + return new Path(newSegments, this._allowsChild); + } + } + } + return null; + } + get segments() { return this._segments; } diff --git a/src/domain/navigation/index.js b/src/domain/navigation/index.js index 44f81026..5de73aef 100644 --- a/src/domain/navigation/index.js +++ b/src/domain/navigation/index.js @@ -43,6 +43,30 @@ function allowsChild(parent, child) { } } +export function removeRoomFromPath(path, roomId) { + const rooms = path.get("rooms"); + let roomIdGridIndex = -1; + // first delete from rooms segment + if (rooms) { + roomIdGridIndex = rooms.value.indexOf(roomId); + if (roomIdGridIndex !== -1) { + const idsWithoutRoom = rooms.value.slice(); + idsWithoutRoom[roomIdGridIndex] = ""; + path = path.replace(new Segment("rooms", idsWithoutRoom)); + } + } + const room = path.get("room"); + // then from room (which occurs with or without rooms) + if (room && room.value === roomId) { + if (roomIdGridIndex !== -1) { + path = path.with(new Segment("empty-grid-tile", roomIdGridIndex)); + } else { + path = path.until("session"); + } + } + return path; +} + function roomsSegmentWithRoom(rooms, roomId, path) { if(!rooms.value.includes(roomId)) { const emptyGridTile = path.get("empty-grid-tile"); @@ -243,6 +267,79 @@ export function tests() { assert.equal(segments.length, 1); assert.equal(segments[0].type, "session"); assert.strictEqual(segments[0].value, true); - } + }, + "remove active room from grid path turns it into empty tile": assert => { + const nav = new Navigation(allowsChild); + const path = nav.pathFrom([ + new Segment("session", 1), + new Segment("rooms", ["a", "b", "c"]), + new Segment("room", "b") + ]); + const newPath = removeRoomFromPath(path, "b"); + assert.equal(newPath.segments.length, 3); + assert.equal(newPath.segments[0].type, "session"); + assert.equal(newPath.segments[0].value, 1); + assert.equal(newPath.segments[1].type, "rooms"); + assert.deepEqual(newPath.segments[1].value, ["a", "", "c"]); + assert.equal(newPath.segments[2].type, "empty-grid-tile"); + assert.equal(newPath.segments[2].value, 1); + }, + "remove inactive room from grid path": assert => { + const nav = new Navigation(allowsChild); + const path = nav.pathFrom([ + new Segment("session", 1), + new Segment("rooms", ["a", "b", "c"]), + new Segment("room", "b") + ]); + const newPath = removeRoomFromPath(path, "a"); + assert.equal(newPath.segments.length, 3); + assert.equal(newPath.segments[0].type, "session"); + assert.equal(newPath.segments[0].value, 1); + assert.equal(newPath.segments[1].type, "rooms"); + assert.deepEqual(newPath.segments[1].value, ["", "b", "c"]); + assert.equal(newPath.segments[2].type, "room"); + assert.equal(newPath.segments[2].value, "b"); + }, + "remove inactive room from grid path with empty tile": assert => { + const nav = new Navigation(allowsChild); + const path = nav.pathFrom([ + new Segment("session", 1), + new Segment("rooms", ["a", "b", ""]), + new Segment("empty-grid-tile", 3) + ]); + const newPath = removeRoomFromPath(path, "b"); + assert.equal(newPath.segments.length, 3); + assert.equal(newPath.segments[0].type, "session"); + assert.equal(newPath.segments[0].value, 1); + assert.equal(newPath.segments[1].type, "rooms"); + assert.deepEqual(newPath.segments[1].value, ["a", "", ""]); + assert.equal(newPath.segments[2].type, "empty-grid-tile"); + assert.equal(newPath.segments[2].value, 3); + }, + "remove active room": assert => { + const nav = new Navigation(allowsChild); + const path = nav.pathFrom([ + new Segment("session", 1), + new Segment("room", "b") + ]); + const newPath = removeRoomFromPath(path, "b"); + assert.equal(newPath.segments.length, 1); + assert.equal(newPath.segments[0].type, "session"); + assert.equal(newPath.segments[0].value, 1); + }, + "remove inactive room doesn't do anything": assert => { + const nav = new Navigation(allowsChild); + const path = nav.pathFrom([ + new Segment("session", 1), + new Segment("room", "b") + ]); + const newPath = removeRoomFromPath(path, "a"); + assert.equal(newPath.segments.length, 2); + assert.equal(newPath.segments[0].type, "session"); + assert.equal(newPath.segments[0].value, 1); + assert.equal(newPath.segments[1].type, "room"); + assert.equal(newPath.segments[1].value, "b"); + }, + } } From a91a584201c104980bf1fa07faffac83b3117c9b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 15:33:45 +0200 Subject: [PATCH 18/74] not used anymore --- src/domain/session/room/RoomViewModel.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 43eeb75c..feff2561 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -86,12 +86,6 @@ export class RoomViewModel extends ViewModel { } } - // called from view to close room - // parent vm will dispose this vm - close() { - this._closeCallback(); - } - // room doesn't tell us yet which fields changed, // so emit all fields originating from summary _onRoomChange() { From 16f275ca34821ae46c7147c41dad28138080cad8 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 15:34:35 +0200 Subject: [PATCH 19/74] make pass-through props smaller --- src/domain/session/room/RoomViewModel.js | 24 +++++------------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index feff2561..a99718be 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -34,10 +34,6 @@ export class RoomViewModel extends ViewModel { this._closeUrl = this.urlCreator.urlUntilSegment("session"); } - get closeUrl() { - return this._closeUrl; - } - async load() { this._room.on("change", this._onRoomChange); try { @@ -92,21 +88,11 @@ export class RoomViewModel extends ViewModel { this.emitChange("name"); } - get name() { - return this._room.name || this.i18n`Empty Room`; - } - - get id() { - return this._room.id; - } - - get timelineViewModel() { - return this._timelineVM; - } - - get isEncrypted() { - return this._room.isEncrypted; - } + get closeUrl() { return this._closeUrl; } + get name() { return this._room.name || this.i18n`Empty Room`; } + get id() { return this._room.id; } + get timelineViewModel() { return this._timelineVM; } + get isEncrypted() { return this._room.isEncrypted; } get error() { if (this._timelineError) { From 0cc95f5083308c9873db96e240ee047247f288e3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 15:45:51 +0200 Subject: [PATCH 20/74] first draft of InviteViewModel --- src/domain/session/room/InviteViewModel.js | 144 +++++++++++++++++++++ src/domain/session/room/README.md | 9 ++ src/domain/session/room/RoomViewModel.js | 1 + src/matrix/Session.js | 4 + 4 files changed, 158 insertions(+) create mode 100644 src/domain/session/room/InviteViewModel.js create mode 100644 src/domain/session/room/README.md diff --git a/src/domain/session/room/InviteViewModel.js b/src/domain/session/room/InviteViewModel.js new file mode 100644 index 00000000..2b43feb0 --- /dev/null +++ b/src/domain/session/room/InviteViewModel.js @@ -0,0 +1,144 @@ +/* +Copyright 2020 Bruno Windels +Copyright 2020, 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {avatarInitials, getIdentifierColorNumber} from "../../avatar.js"; +import {ViewModel} from "../../ViewModel.js"; + +export class InviteViewModel extends ViewModel { + constructor(options) { + super(options); + const {invite, mediaRepository, closeCallback} = options; + this._invite = invite; + this._mediaRepository = mediaRepository; + this._closeCallback = closeCallback; + this._onInviteChange = this._onInviteChange.bind(this); + this._error = null; + this._closeUrl = this.urlCreator.urlUntilSegment("session"); + this._invite.on("change", this._onInviteChange); + this._inviter = null; + if (this._invite.inviter && ! this._invite.isDirectMessage) { + this._inviter = new RoomMemberViewModel(this._invite.inviter, mediaRepository, this.platform); + } + } + + get kind() { return "invite"; } + get closeUrl() { return this._closeUrl; } + get name() { return this._invite.name; } + get id() { return this._invite.id; } + get isEncrypted() { return this._invite.isEncrypted; } + get isDirectMessage() { return this._invite.isDirectMessage; } + get inviter() { return this._inviter; } + get busy() { return this._invite.accepting || this._invite.rejecting; } + + get error() { + if (this._error) { + return `Something went wrong: ${this._error.message}`; + } + return ""; + } + + get avatarLetter() { + return avatarInitials(this.name); + } + + get avatarColorNumber() { + return getIdentifierColorNumber(this._invite.id) + } + + get avatarUrl() { + if (this._invite.avatarUrl) { + const size = 32 * this.platform.devicePixelRatio; + return this._mediaRepository.mxcUrlThumbnail(this._invite.avatarUrl, size, size, "crop"); + } + return null; + } + + get avatarTitle() { + return this.name; + } + + focus() {} + + async accept() { + try { + await this._invite.accept(); + } catch (err) { + this._error = err; + this.emitChange("error"); + } + } + + async reject() { + try { + await this._invite.reject(); + } catch (err) { + this._error = err; + this.emitChange("error"); + } + } + + _onInviteChange() { + if (this._invite.accepted || this._invite.rejected) { + // close invite if rejected, or open room if accepted. + // Done with a callback rather than manipulating the nav, + // as closing the invite changes the nav path depending whether + // we're in a grid view, and opening the room doesn't change + // the nav path because the url is the same for an + // invite and the room. + this._closeCallback(this._invite.accepted); + } else { + this.emitChange(); + } + } + + dispose() { + super.dispose(); + this._invite.off("change", this._onInviteChange); + } +} + +class RoomMemberViewModel { + constructor(member, mediaRepository, platform) { + this._member = member; + this._mediaRepository = mediaRepository; + this._platform = platform; + } + + get name() { + return this._member.name; + } + + get avatarLetter() { + return avatarInitials(this.name); + } + + get avatarColorNumber() { + return getIdentifierColorNumber(this._member.userId) + } + + get avatarUrl() { + if (this._member.avatarUrl) { + const size = 32 * this.platform.devicePixelRatio; + return this._mediaRepository.mxcUrlThumbnail(this._member.avatarUrl, size, size, "crop"); + } + return null; + } + + get avatarTitle() { + return this.name; + } +} diff --git a/src/domain/session/room/README.md b/src/domain/session/room/README.md new file mode 100644 index 00000000..28fa7173 --- /dev/null +++ b/src/domain/session/room/README.md @@ -0,0 +1,9 @@ +# "Room" view models + +InviteViewModel and RoomViewModel are interchangebly used as "room view model": + - SessionViewModel.roomViewModel can be an instance of either + - RoomGridViewModel.roomViewModelAt(i) can return an instance of either + +This is because they are accessed by the same url and need to transition into each other, in these two locations. Having two methods, especially in RoomGridViewModel would have been more cumbersome, even though this is not in line with how different view models are exposed in SessionViewModel. + +They share an `id` and `kind` property, the latter can be used to differentiate them from the view. diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index a99718be..6a48f9d9 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -88,6 +88,7 @@ export class RoomViewModel extends ViewModel { this.emitChange("name"); } + get kind() { return "room"; } get closeUrl() { return this._closeUrl; } get name() { return this._room.name || this.i18n`Empty Room`; } get id() { return this._room.id; } diff --git a/src/matrix/Session.js b/src/matrix/Session.js index c19fc006..11bca80b 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -507,6 +507,10 @@ export class Session { return this._user; } + get mediaRepository() { + return this._mediaRepository; + } + enablePushNotifications(enable) { if (enable) { return this._enablePush(); From 7e2870acef9855b2fb72a5a4ddfe69c0317a9e43 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 15:47:39 +0200 Subject: [PATCH 21/74] wire up InviteViewModel in Session/RoomGridViewModel and: - switch to room once accepted - close invite if rejected --- src/domain/session/RoomGridViewModel.js | 39 +++++++++---- src/domain/session/SessionViewModel.js | 67 +++++++++++++++++----- src/platform/web/ui/session/SessionView.js | 17 +++--- 3 files changed, 89 insertions(+), 34 deletions(-) diff --git a/src/domain/session/RoomGridViewModel.js b/src/domain/session/RoomGridViewModel.js index b9b62153..535d454a 100644 --- a/src/domain/session/RoomGridViewModel.js +++ b/src/domain/session/RoomGridViewModel.js @@ -32,10 +32,11 @@ export class RoomGridViewModel extends ViewModel { this._width = options.width; this._height = options.height; - this._createRoomViewModel = options.createRoomViewModel; + this._createRoomOrInviteViewModel = options.createRoomOrInviteViewModel; this._selectedIndex = 0; this._viewModels = []; + this._replaceInviteWithRoom = this._replaceInviteWithRoom.bind(this); this._setupNavigation(); } @@ -63,6 +64,24 @@ export class RoomGridViewModel extends ViewModel { // initial focus for a room is set by initializeRoomIdsAndTransferVM } + _replaceInviteWithRoom(roomId) { + const index = this._viewModels.findIndex(vm => vm?.id === roomId); + if (index === -1) { + return; + } + this._viewModels[index] = this.disposeTracked(this._viewModels[index]); + // this will create a RoomViewModel because the invite is already + // removed from the collection (see Invite.afterSync) + const roomVM = this._createRoomOrInviteViewModel(roomId, this._replaceInviteWithRoom); + if (roomVM) { + this._viewModels[index] = this.track(roomVM); + if (this.focusIndex === index) { + roomVM.focus(); + } + } + this.emitChange(); + } + roomViewModelAt(i) { return this._viewModels[i]; } @@ -128,7 +147,7 @@ export class RoomGridViewModel extends ViewModel { this._viewModels[i] = this.disposeTracked(vm); } if (newId) { - const newVM = this._createRoomViewModel(newId); + const newVM = this._createRoomOrInviteViewModel(newId, this._replaceInviteWithRoom); if (newVM) { this._viewModels[i] = this.track(newVM); } @@ -211,7 +230,7 @@ export function tests() { "initialize with duplicate set of rooms": assert => { const navigation = createNavigationForRoom(["c", "a", "b", undefined, "a"], "a"); const gridVM = new RoomGridViewModel({ - createRoomViewModel: id => new RoomVMMock(id), + createRoomOrInviteViewModel: id => new RoomVMMock(id), navigation, width: 3, height: 2, @@ -228,7 +247,7 @@ export function tests() { "transfer room view model": assert => { const navigation = createNavigationForRoom(["a"], "a"); const gridVM = new RoomGridViewModel({ - createRoomViewModel: () => assert.fail("no vms should be created"), + createRoomOrInviteViewModel: () => assert.fail("no vms should be created"), navigation, width: 3, height: 2, @@ -242,7 +261,7 @@ export function tests() { "reject transfer for non-matching room view model": assert => { const navigation = createNavigationForRoom(["a"], "a"); const gridVM = new RoomGridViewModel({ - createRoomViewModel: id => new RoomVMMock(id), + createRoomOrInviteViewModel: id => new RoomVMMock(id), navigation, width: 3, height: 2, @@ -256,7 +275,7 @@ export function tests() { "created & released room view model is not disposed": assert => { const navigation = createNavigationForRoom(["a"], "a"); const gridVM = new RoomGridViewModel({ - createRoomViewModel: id => new RoomVMMock(id), + createRoomOrInviteViewModel: id => new RoomVMMock(id), navigation, width: 3, height: 2, @@ -270,7 +289,7 @@ export function tests() { "transfered & released room view model is not disposed": assert => { const navigation = createNavigationForRoom([undefined, "a"], "a"); const gridVM = new RoomGridViewModel({ - createRoomViewModel: () => assert.fail("no vms should be created"), + createRoomOrInviteViewModel: () => assert.fail("no vms should be created"), navigation, width: 3, height: 2, @@ -285,7 +304,7 @@ export function tests() { "try release non-existing room view model is": assert => { const navigation = createNavigationForEmptyTile([undefined, "b"], 3); const gridVM = new RoomGridViewModel({ - createRoomViewModel: id => new RoomVMMock(id), + createRoomOrInviteViewModel: id => new RoomVMMock(id), navigation, width: 3, height: 2, @@ -297,7 +316,7 @@ export function tests() { "initial focus is set to empty tile": assert => { const navigation = createNavigationForEmptyTile(["a"], 1); const gridVM = new RoomGridViewModel({ - createRoomViewModel: id => new RoomVMMock(id), + createRoomOrInviteViewModel: id => new RoomVMMock(id), navigation, width: 3, height: 2, @@ -309,7 +328,7 @@ export function tests() { "change room ids after creation": assert => { const navigation = createNavigationForRoom(["a", "b"], "a"); const gridVM = new RoomGridViewModel({ - createRoomViewModel: id => new RoomVMMock(id), + createRoomOrInviteViewModel: id => new RoomVMMock(id), navigation, width: 3, height: 2, diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 2f7e341e..4992c9e2 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -15,8 +15,10 @@ See the License for the specific language governing permissions and limitations under the License. */ +import {removeRoomFromPath} from "../navigation/index.js"; import {LeftPanelViewModel} from "./leftpanel/LeftPanelViewModel.js"; import {RoomViewModel} from "./room/RoomViewModel.js"; +import {InviteViewModel} from "./room/InviteViewModel.js"; import {LightboxViewModel} from "./room/LightboxViewModel.js"; import {SessionStatusViewModel} from "./SessionStatusViewModel.js"; import {RoomGridViewModel} from "./RoomGridViewModel.js"; @@ -39,6 +41,8 @@ export class SessionViewModel extends ViewModel { this._settingsViewModel = null; this._currentRoomViewModel = null; this._gridViewModel = null; + this._replaceInviteWithRoom = this._replaceInviteWithRoom.bind(this); + this._createRoomOrInviteViewModel = this._createRoomOrInviteViewModel.bind(this); this._setupNavigation(); } @@ -84,15 +88,8 @@ export class SessionViewModel extends ViewModel { this._sessionStatusViewModel.start(); } - get activeSection() { - if (this._currentRoomViewModel) { - return this._currentRoomViewModel.id; - } else if (this._gridViewModel) { - return "roomgrid"; - } else if (this._settingsViewModel) { - return "settings"; - } - return "placeholder"; + get activeMiddleViewModel() { + return this._currentRoomViewModel || this._gridViewModel || this._settingsViewModel; } get roomGridViewModel() { @@ -127,7 +124,7 @@ export class SessionViewModel extends ViewModel { this._gridViewModel = this.track(new RoomGridViewModel(this.childOptions({ width: 3, height: 2, - createRoomViewModel: roomId => this._createRoomViewModel(roomId), + createRoomOrInviteViewModel: this._createRoomOrInviteViewModel, }))); if (this._gridViewModel.initializeRoomIdsAndTransferVM(roomIds, this._currentRoomViewModel)) { this._currentRoomViewModel = this.untrack(this._currentRoomViewModel); @@ -138,6 +135,7 @@ export class SessionViewModel extends ViewModel { this._gridViewModel.setRoomIds(roomIds); } } else if (this._gridViewModel && !roomIds) { + // closing grid, try to show focused room in grid if (currentRoomId) { const vm = this._gridViewModel.releaseRoomViewModel(currentRoomId.value); if (vm) { @@ -152,7 +150,7 @@ export class SessionViewModel extends ViewModel { this._gridViewModel = this.disposeTracked(this._gridViewModel); } if (changed) { - this.emitChange("activeSection"); + this.emitChange("activeMiddleViewModel"); } } @@ -169,11 +167,50 @@ export class SessionViewModel extends ViewModel { return roomVM; } + _createInviteViewModel(roomId, replaceInviteWithRoom) { + const invite = this._sessionContainer.session.invites.get(roomId); + if (!invite) { + return null; + } + return new InviteViewModel(this.childOptions({ + invite, + mediaRepository: this._sessionContainer.session.mediaRepository, + closeCallback: accepted => this._closeInvite(roomId, accepted, replaceInviteWithRoom), + })); + } + + _createRoomOrInviteViewModel(roomId, replaceInviteWithRoom) { + const inviteVM = this._createInviteViewModel(roomId, replaceInviteWithRoom); + if (inviteVM) { + return inviteVM; + } + return this._createRoomViewModel(roomId); + } + + _closeInvite(roomId, accepted, replaceInviteWithRoom) { + if (accepted) { + replaceInviteWithRoom(roomId); + } else { + // close invite + this.navigation.applyPath(removeRoomFromPath(this.navigation.path, roomId)); + } + } + + _replaceInviteWithRoom(roomId) { + this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); + const roomVM = this._createRoomViewModel(roomId); + if (roomVM) { + this._currentRoomViewModel = this.track(roomVM); + } + this.emitChange("activeMiddleViewModel"); + } + _updateRoom(roomId) { if (!roomId) { + // closing invite or room view? if (this._currentRoomViewModel) { this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); - this.emitChange("currentRoom"); + this.emitChange("activeMiddleViewModel"); } return; } @@ -182,11 +219,11 @@ export class SessionViewModel extends ViewModel { return; } this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); - const roomVM = this._createRoomViewModel(roomId); + const roomVM = this._createRoomOrInviteViewModel(roomId, this._replaceInviteWithRoom); if (roomVM) { this._currentRoomViewModel = this.track(roomVM); } - this.emitChange("currentRoom"); + this.emitChange("activeMiddleViewModel"); } _updateSettings(settingsOpen) { @@ -199,7 +236,7 @@ export class SessionViewModel extends ViewModel { }))); this._settingsViewModel.load(); } - this.emitChange("activeSection"); + this.emitChange("activeMiddleViewModel"); } _updateLightbox(eventId) { diff --git a/src/platform/web/ui/session/SessionView.js b/src/platform/web/ui/session/SessionView.js index fa7a492a..e05d97ad 100644 --- a/src/platform/web/ui/session/SessionView.js +++ b/src/platform/web/ui/session/SessionView.js @@ -34,16 +34,15 @@ export class SessionView extends TemplateView { }, [ t.view(new SessionStatusView(vm.sessionStatusViewModel)), t.view(new LeftPanelView(vm.leftPanelViewModel)), - t.mapView(vm => vm.activeSection, activeSection => { - switch (activeSection) { - case "roomgrid": - return new RoomGridView(vm.roomGridViewModel); - case "placeholder": - return new StaticView(t => t.div({className: "room-placeholder"}, t.h2(vm.i18n`Choose a room on the left side.`))); - case "settings": - return new SettingsView(vm.settingsViewModel); - default: //room id + t.mapView(vm => vm.activeMiddleViewModel, () => { + if (vm.roomGridViewModel) { + return new RoomGridView(vm.roomGridViewModel); + } else if (vm.settingsViewModel) { + return new SettingsView(vm.settingsViewModel); + } else if (vm.currentRoomViewModel) { return new RoomView(vm.currentRoomViewModel); + } else { + return new StaticView(t => t.div({className: "room-placeholder"}, t.h2(vm.i18n`Choose a room on the left side.`))); } }), t.mapView(vm => vm.lightboxViewModel, lightboxViewModel => lightboxViewModel ? new LightboxView(lightboxViewModel) : null) From 9961d3e4fc7e4311f5c507756d296f93f1a13101 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 15:48:27 +0200 Subject: [PATCH 22/74] unused code --- src/domain/session/SessionViewModel.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 4992c9e2..25e13162 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -108,10 +108,6 @@ export class SessionViewModel extends ViewModel { return this._settingsViewModel; } - get roomList() { - return this._roomList; - } - get currentRoomViewModel() { return this._currentRoomViewModel; } From 0dc796b8637be57ee49d6f07917eaa08aa56ce0d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 15:48:41 +0200 Subject: [PATCH 23/74] spacing --- src/platform/web/ui/session/RoomGridView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/session/RoomGridView.js b/src/platform/web/ui/session/RoomGridView.js index f2a73068..685dcb99 100644 --- a/src/platform/web/ui/session/RoomGridView.js +++ b/src/platform/web/ui/session/RoomGridView.js @@ -30,7 +30,7 @@ export class RoomGridView extends TemplateView { [`tile${i}`]: true, "focused": vm => vm.focusIndex === i }, - },t.mapView(vm => vm.roomViewModelAt(i), roomVM => { + }, t.mapView(vm => vm.roomViewModelAt(i), roomVM => { if (roomVM) { return new RoomView(roomVM); } else { From 1d3a2aca0e155afbf5a76270b31b2b29a2c12447 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 17:23:49 +0200 Subject: [PATCH 24/74] add 'get' method to common observable map api --- src/observable/map/ApplyMap.js | 4 ++++ src/observable/map/BaseObservableMap.js | 5 +++++ src/observable/map/FilteredMap.js | 7 +++++++ 3 files changed, 16 insertions(+) diff --git a/src/observable/map/ApplyMap.js b/src/observable/map/ApplyMap.js index 1a2976ac..feab968c 100644 --- a/src/observable/map/ApplyMap.js +++ b/src/observable/map/ApplyMap.js @@ -82,4 +82,8 @@ export class ApplyMap extends BaseObservableMap { get size() { return this._source.size; } + + get(key) { + return this._source.get(key); + } } diff --git a/src/observable/map/BaseObservableMap.js b/src/observable/map/BaseObservableMap.js index 61de18dc..79df21f6 100644 --- a/src/observable/map/BaseObservableMap.js +++ b/src/observable/map/BaseObservableMap.js @@ -49,4 +49,9 @@ export class BaseObservableMap extends BaseObservable { get size() { throw new Error("unimplemented"); } + + // eslint-disable-next-line no-unused-vars + get(key) { + throw new Error("unimplemented"); + } } diff --git a/src/observable/map/FilteredMap.js b/src/observable/map/FilteredMap.js index e164aae3..290dcea6 100644 --- a/src/observable/map/FilteredMap.js +++ b/src/observable/map/FilteredMap.js @@ -128,6 +128,13 @@ export class FilteredMap extends BaseObservableMap { }); return count; } + + get(key) { + const value = this._source.get(key); + if (value && this._filter(value, key)) { + return value; + } + } } class FilterIterator { From f596b34cacee2d2bf1382f43baa2750fb12acdb5 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 17:24:07 +0200 Subject: [PATCH 25/74] add very basic join observable map, joining several maps into one will be used to join invites and rooms into one map --- src/observable/index.js | 5 ++ src/observable/map/JoinedMap.js | 112 ++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 src/observable/map/JoinedMap.js diff --git a/src/observable/index.js b/src/observable/index.js index eb6f1579..351c25b8 100644 --- a/src/observable/index.js +++ b/src/observable/index.js @@ -17,6 +17,7 @@ limitations under the License. import {SortedMapList} from "./list/SortedMapList.js"; import {FilteredMap} from "./map/FilteredMap.js"; import {MappedMap} from "./map/MappedMap.js"; +import {JoinedMap} from "./map/JoinedMap.js"; import {BaseObservableMap} from "./map/BaseObservableMap.js"; // re-export "root" (of chain) collections export { ObservableArray } from "./list/ObservableArray.js"; @@ -38,5 +39,9 @@ Object.assign(BaseObservableMap.prototype, { filterValues(filter) { return new FilteredMap(this, filter); + }, + + join(...otherMaps) { + return new JoinedMap([this].concat(otherMaps)); } }); diff --git a/src/observable/map/JoinedMap.js b/src/observable/map/JoinedMap.js new file mode 100644 index 00000000..b50a524a --- /dev/null +++ b/src/observable/map/JoinedMap.js @@ -0,0 +1,112 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {BaseObservableMap} from "./BaseObservableMap.js"; + +export class JoinedMap extends BaseObservableMap { + constructor(sources) { + super(); + this._sources = sources; + } + + onAdd(key, value) { + this.emitAdd(key, value); + } + + onRemove(key, value) { + this.emitRemove(key, value); + } + + onUpdate(key, value, params) { + this.emitUpdate(key, value, params); + } + + onSubscribeFirst() { + this._subscriptions = this._sources.map(source => source.subscribe(this)); + super.onSubscribeFirst(); + } + + onUnsubscribeLast() { + super.onUnsubscribeLast(); + for (const s of this._subscriptions) { + s(); + } + } + + onReset() { + this.emitReset(); + } + + [Symbol.iterator]() { + return new JoinedIterator(this._sources); + } + + get size() { + return this._sources.reduce((sum, s) => sum + s.size, 0); + } + + get(key) { + for (const s of this._sources) { + const value = s.get(key); + if (value) { + return value; + } + } + return null; + } +} + +class JoinedIterator { + constructor(sources) { + this._sources = sources; + this._sourceIndex = -1; + this._currentIterator = null; + } + + next() { + let result; + while (!result) { + if (!this._currentIterator) { + this._sourceIndex += 1; + if (this._sources.length <= this._sourceIndex) { + return {done: true}; + } + this._currentIterator = this._sources[this._sourceIndex][Symbol.iterator](); + } + const sourceResult = this._currentIterator.next(); + if (sourceResult.done) { + this._currentIterator = null; + continue; + } else { + result = sourceResult; + } + } + return result; + } +} + +export function tests() { + return { + "joined iterator": assert => { + const it = new JoinedIterator([[1, 2], [3, 4]]); + assert.equal(it.next().value, 1); + assert.equal(it.next().value, 2); + assert.equal(it.next().value, 3); + assert.equal(it.next().value, 4); + assert.equal(it.next().done, true); + } + }; +} From bfb7f58a3dfd9d5f0e34b36421b23c270f6ea663 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 17:25:59 +0200 Subject: [PATCH 26/74] add very basic invite view --- src/platform/web/ui/session/RoomGridView.js | 7 +++- src/platform/web/ui/session/SessionView.js | 5 +++ .../web/ui/session/room/InviteView.js | 40 +++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 src/platform/web/ui/session/room/InviteView.js diff --git a/src/platform/web/ui/session/RoomGridView.js b/src/platform/web/ui/session/RoomGridView.js index 685dcb99..043137fb 100644 --- a/src/platform/web/ui/session/RoomGridView.js +++ b/src/platform/web/ui/session/RoomGridView.js @@ -15,6 +15,7 @@ limitations under the License. */ import {RoomView} from "./room/RoomView.js"; +import {InviteView} from "./room/InviteView.js"; import {TemplateView} from "../general/TemplateView.js"; import {StaticView} from "../general/StaticView.js"; @@ -32,7 +33,11 @@ export class RoomGridView extends TemplateView { }, }, t.mapView(vm => vm.roomViewModelAt(i), roomVM => { if (roomVM) { - return new RoomView(roomVM); + if (roomVM.kind === "invite") { + return new InviteView(roomVM); + } else { + return new RoomView(roomVM); + } } else { return new StaticView(t => t.div({className: "room-placeholder"}, [ t.h2({className: "focused"}, vm.i18n`Select a room on the left`), diff --git a/src/platform/web/ui/session/SessionView.js b/src/platform/web/ui/session/SessionView.js index e05d97ad..c7abd4db 100644 --- a/src/platform/web/ui/session/SessionView.js +++ b/src/platform/web/ui/session/SessionView.js @@ -17,6 +17,7 @@ limitations under the License. import {LeftPanelView} from "./leftpanel/LeftPanelView.js"; import {RoomView} from "./room/RoomView.js"; +import {InviteView} from "./room/InviteView.js"; import {LightboxView} from "./room/LightboxView.js"; import {TemplateView} from "../general/TemplateView.js"; import {StaticView} from "../general/StaticView.js"; @@ -40,7 +41,11 @@ export class SessionView extends TemplateView { } else if (vm.settingsViewModel) { return new SettingsView(vm.settingsViewModel); } else if (vm.currentRoomViewModel) { + if (vm.currentRoomViewModel.kind === "invite") { + return new InviteView(vm.currentRoomViewModel); + } else { return new RoomView(vm.currentRoomViewModel); + } } else { return new StaticView(t => t.div({className: "room-placeholder"}, t.h2(vm.i18n`Choose a room on the left side.`))); } diff --git a/src/platform/web/ui/session/room/InviteView.js b/src/platform/web/ui/session/room/InviteView.js new file mode 100644 index 00000000..8fdf6cf7 --- /dev/null +++ b/src/platform/web/ui/session/room/InviteView.js @@ -0,0 +1,40 @@ +/* +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 {TemplateView} from "../../general/TemplateView.js"; +// import {TimelineList} from "./TimelineList.js"; +// import {TimelineLoadingView} from "./TimelineLoadingView.js"; +// import {MessageComposer} from "./MessageComposer.js"; +import {renderStaticAvatar} from "../../avatar.js"; + +export class InviteView extends TemplateView { + render(t, vm) { + return t.main({className: "InviteView middle"}, [ + t.div({className: "TimelinePanel"}, [ + t.div({className: "RoomHeader middle-header"}, [ + t.a({className: "button-utility close-middle", href: vm.closeUrl, title: vm.i18n`Close invite`}), + renderStaticAvatar(vm, 32), + t.div({className: "room-description"}, [ + t.h2(vm => vm.name), + ]), + ]), + t.div({className: "RoomView_error"}, vm => vm.error), + t.div(`You were invited into this room!`) + ]) + ]); + } +} From 16df40331b6d3e172db2320aaa12cebfc54f2c16 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 17:26:10 +0200 Subject: [PATCH 27/74] fix refactor mistake in sync --- src/matrix/Sync.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index 8b81d18e..9fb57948 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -363,7 +363,7 @@ export class Sync { return roomStates; } - _parseInvites(invites, roomsSection) { + _parseInvites(roomsSection) { const inviteStates = []; if (roomsSection.invite) { for (const [roomId, roomResponse] of Object.entries(roomsSection.invite)) { From 1cf49688d65d91c52def50e253956e285bd220b7 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 17:26:34 +0200 Subject: [PATCH 28/74] show invites at the top of the room list --- src/domain/session/SessionViewModel.js | 1 + .../session/leftpanel/LeftPanelViewModel.js | 64 +++++++++++-------- .../ui/session/leftpanel/InviteTileView.js | 37 +++++++++++ .../web/ui/session/leftpanel/LeftPanelView.js | 11 +++- 4 files changed, 86 insertions(+), 27 deletions(-) create mode 100644 src/platform/web/ui/session/leftpanel/InviteTileView.js diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 25e13162..45b97c3e 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -36,6 +36,7 @@ export class SessionViewModel extends ViewModel { session: sessionContainer.session, }))); this._leftPanelViewModel = this.track(new LeftPanelViewModel(this.childOptions({ + invites: this._sessionContainer.session.invites, rooms: this._sessionContainer.session.rooms }))); this._settingsViewModel = null; diff --git a/src/domain/session/leftpanel/LeftPanelViewModel.js b/src/domain/session/leftpanel/LeftPanelViewModel.js index de70245a..c9a8b436 100644 --- a/src/domain/session/leftpanel/LeftPanelViewModel.js +++ b/src/domain/session/leftpanel/LeftPanelViewModel.js @@ -17,37 +17,51 @@ limitations under the License. import {ViewModel} from "../../ViewModel.js"; import {RoomTileViewModel} from "./RoomTileViewModel.js"; +import {InviteTileViewModel} from "./InviteTileViewModel.js"; import {RoomFilter} from "./RoomFilter.js"; import {ApplyMap} from "../../../observable/map/ApplyMap.js"; export class LeftPanelViewModel extends ViewModel { constructor(options) { super(options); - const {rooms} = options; - this._roomTileViewModels = rooms.mapValues((room, emitChange) => { - const isOpen = this.navigation.path.get("room")?.value === room.id; - const vm = new RoomTileViewModel(this.childOptions({ - isOpen, - room, - emitChange - })); - // need to also update the current vm here as - // we can't call `_open` from the ctor as the map - // is only populated when the view subscribes. - if (isOpen) { - this._currentTileVM?.close(); - this._currentTileVM = vm; - } - return vm; - }); - this._roomListFilterMap = new ApplyMap(this._roomTileViewModels); - this._roomList = this._roomListFilterMap.sortValues((a, b) => a.compare(b)); + const {rooms, invites} = options; + this._tileViewModelsMap = this._mapTileViewModels(rooms, invites); + this._tileViewModelsFilterMap = new ApplyMap(this._tileViewModelsMap); + this._tileViewModels = this._tileViewModelsFilterMap.sortValues((a, b) => a.compare(b)); this._currentTileVM = null; this._setupNavigation(); this._closeUrl = this.urlCreator.urlForSegment("session"); this._settingsUrl = this.urlCreator.urlForSegment("settings"); } + _mapTileViewModels(rooms, invites) { + const roomTileViewModels = rooms.mapValues((room, emitChange) => { + const isOpen = this.navigation.path.get("room")?.value === room.id; + const vm = new RoomTileViewModel(this.childOptions({isOpen, room, emitChange})); + if (isOpen) { + this._updateCurrentVM(vm); + } + return vm; + }); + const inviteTileViewModels = invites.mapValues((invite, emitChange) => { + const isOpen = this.navigation.path.get("room")?.value === invite.id; + const vm = new InviteTileViewModel(this.childOptions({isOpen, invite, emitChange})); + if (isOpen) { + this._updateCurrentVM(vm); + } + return vm; + }); + return roomTileViewModels.join(inviteTileViewModels); + } + + _updateCurrentVM(vm) { + // need to also update the current vm here as + // we can't call `_open` from the ctor as the map + // is only populated when the view subscribes. + this._currentTileVM?.close(); + this._currentTileVM = vm; + } + get closeUrl() { return this._closeUrl; } @@ -75,7 +89,7 @@ export class LeftPanelViewModel extends ViewModel { this._currentTileVM?.close(); this._currentTileVM = null; if (roomId) { - this._currentTileVM = this._roomTileViewModels.get(roomId); + this._currentTileVM = this._tileViewModelsMap.get(roomId); this._currentTileVM?.open(); } } @@ -102,13 +116,13 @@ export class LeftPanelViewModel extends ViewModel { } } - get roomList() { - return this._roomList; + get tileViewModels() { + return this._tileViewModels; } clearFilter() { - this._roomListFilterMap.setApply(null); - this._roomListFilterMap.applyOnce((roomId, vm) => vm.hidden = false); + this._tileViewModelsFilterMap.setApply(null); + this._tileViewModelsFilterMap.applyOnce((roomId, vm) => vm.hidden = false); } setFilter(query) { @@ -117,7 +131,7 @@ export class LeftPanelViewModel extends ViewModel { this.clearFilter(); } else { const filter = new RoomFilter(query); - this._roomListFilterMap.setApply((roomId, vm) => { + this._tileViewModelsFilterMap.setApply((roomId, vm) => { vm.hidden = !filter.matches(vm); }); } diff --git a/src/platform/web/ui/session/leftpanel/InviteTileView.js b/src/platform/web/ui/session/leftpanel/InviteTileView.js new file mode 100644 index 00000000..1ac8b40e --- /dev/null +++ b/src/platform/web/ui/session/leftpanel/InviteTileView.js @@ -0,0 +1,37 @@ +/* +Copyright 2020 Bruno Windels +Copyright 2020, 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {TemplateView} from "../../general/TemplateView.js"; +import {renderStaticAvatar} from "../../avatar.js"; + +export class InviteTileView extends TemplateView { + render(t, vm) { + const classes = { + "active": vm => vm.isOpen, + "hidden": vm => vm.hidden + }; + return t.li({"className": classes}, [ + t.a({href: vm.url}, [ + renderStaticAvatar(vm, 32), + t.div({className: "description"}, [ + t.div({className: "name"}, vm.name), + t.div({className: "badge highlighted"}, "!"), + ]) + ]) + ]); + } +} diff --git a/src/platform/web/ui/session/leftpanel/LeftPanelView.js b/src/platform/web/ui/session/leftpanel/LeftPanelView.js index a681b326..5b56fa4a 100644 --- a/src/platform/web/ui/session/leftpanel/LeftPanelView.js +++ b/src/platform/web/ui/session/leftpanel/LeftPanelView.js @@ -17,6 +17,7 @@ limitations under the License. import {ListView} from "../../general/ListView.js"; import {TemplateView} from "../../general/TemplateView.js"; import {RoomTileView} from "./RoomTileView.js"; +import {InviteTileView} from "./InviteTileView.js"; class FilterField extends TemplateView { render(t, options) { @@ -84,9 +85,15 @@ export class LeftPanelView extends TemplateView { t.view(new ListView( { className: "RoomList", - list: vm.roomList, + list: vm.tileViewModels, }, - roomTileVM => new RoomTileView(roomTileVM) + tileVM => { + if (tileVM.kind === "invite") { + return new InviteTileView(tileVM); + } else { + return new RoomTileView(tileVM); + } + } )) ]); } From 76933e51aa1ce785cd41661e9fbfc9b4ff5e44e1 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 17:48:40 +0200 Subject: [PATCH 29/74] fix default avatar color being broken in room list --- src/domain/session/leftpanel/BaseTileViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/leftpanel/BaseTileViewModel.js b/src/domain/session/leftpanel/BaseTileViewModel.js index 7b2a3258..ac9ea3f9 100644 --- a/src/domain/session/leftpanel/BaseTileViewModel.js +++ b/src/domain/session/leftpanel/BaseTileViewModel.js @@ -72,7 +72,7 @@ export class BaseTileViewModel extends ViewModel { } get avatarColorNumber() { - return getIdentifierColorNumber(this._avatarSource) + return getIdentifierColorNumber(this._avatarSource.id) } get avatarUrl() { From ac312cbdc713d57b192fb75ab8ff601bb84c6095 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 17:49:59 +0200 Subject: [PATCH 30/74] fix c/p error in invite store --- src/matrix/storage/idb/stores/InviteStore.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/storage/idb/stores/InviteStore.js b/src/matrix/storage/idb/stores/InviteStore.js index 41a2351a..b0eefe60 100644 --- a/src/matrix/storage/idb/stores/InviteStore.js +++ b/src/matrix/storage/idb/stores/InviteStore.js @@ -28,6 +28,6 @@ export class InviteStore { } remove(roomId) { - this._store.delete(roomId); + this._inviteStore.delete(roomId); } } From b4ad6142f4d03cf7d27c872013c3a535cdcca47d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 17:50:22 +0200 Subject: [PATCH 31/74] show spinner in room list while accepting or rejecting invite --- src/platform/web/ui/css/themes/element/theme.css | 2 +- src/platform/web/ui/session/leftpanel/InviteTileView.js | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index c5667b53..a053f07e 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -276,7 +276,7 @@ a.button-action { } .RoomList .description { - align-items: baseline; + align-items: center; } .RoomList .name.unread { diff --git a/src/platform/web/ui/session/leftpanel/InviteTileView.js b/src/platform/web/ui/session/leftpanel/InviteTileView.js index 1ac8b40e..09b9401f 100644 --- a/src/platform/web/ui/session/leftpanel/InviteTileView.js +++ b/src/platform/web/ui/session/leftpanel/InviteTileView.js @@ -17,6 +17,7 @@ limitations under the License. import {TemplateView} from "../../general/TemplateView.js"; import {renderStaticAvatar} from "../../avatar.js"; +import {spinner} from "../../common.js"; export class InviteTileView extends TemplateView { render(t, vm) { @@ -29,7 +30,13 @@ export class InviteTileView extends TemplateView { renderStaticAvatar(vm, 32), t.div({className: "description"}, [ t.div({className: "name"}, vm.name), - t.div({className: "badge highlighted"}, "!"), + t.map(vm => vm.busy, busy => { + if (busy) { + return spinner(t); + } else { + return t.div({className: "badge highlighted"}, "!"); + } + }) ]) ]) ]); From 35992ac2fc76b3666deeeb66d243d9f02e16ca82 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 17:50:41 +0200 Subject: [PATCH 32/74] show very basic buttons to accept or reject invite --- src/platform/web/ui/session/room/InviteView.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/platform/web/ui/session/room/InviteView.js b/src/platform/web/ui/session/room/InviteView.js index 8fdf6cf7..ae241be2 100644 --- a/src/platform/web/ui/session/room/InviteView.js +++ b/src/platform/web/ui/session/room/InviteView.js @@ -33,7 +33,11 @@ export class InviteView extends TemplateView { ]), ]), t.div({className: "RoomView_error"}, vm => vm.error), - t.div(`You were invited into this room!`) + t.div([ + t.p(`You were invited into this room!`), + t.p(t.button({onClick: () => vm.accept()}, vm.i18n`Accept`)), + t.p(t.button({onClick: () => vm.reject()}, vm.i18n`Reject`)), + ]) ]) ]); } From 20f4474eb6a725fd6e8faf520a57048d5723577b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 18:13:29 +0200 Subject: [PATCH 33/74] missing semicolon --- src/domain/session/leftpanel/BaseTileViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/leftpanel/BaseTileViewModel.js b/src/domain/session/leftpanel/BaseTileViewModel.js index ac9ea3f9..6e074caa 100644 --- a/src/domain/session/leftpanel/BaseTileViewModel.js +++ b/src/domain/session/leftpanel/BaseTileViewModel.js @@ -72,7 +72,7 @@ export class BaseTileViewModel extends ViewModel { } get avatarColorNumber() { - return getIdentifierColorNumber(this._avatarSource.id) + return getIdentifierColorNumber(this._avatarSource.id); } get avatarUrl() { From 4e3127c4cf023f34a2324a5cc2c6db17543edc4e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 22 Apr 2021 13:28:14 +0200 Subject: [PATCH 34/74] handle key collisions in JoinedMap --- src/observable/map/JoinedMap.js | 214 ++++++++++++++++++++++++++++---- 1 file changed, 191 insertions(+), 23 deletions(-) diff --git a/src/observable/map/JoinedMap.js b/src/observable/map/JoinedMap.js index b50a524a..7d099136 100644 --- a/src/observable/map/JoinedMap.js +++ b/src/observable/map/JoinedMap.js @@ -22,27 +22,33 @@ export class JoinedMap extends BaseObservableMap { this._sources = sources; } - onAdd(key, value) { - this.emitAdd(key, value); + onAdd(source, key, value) { + if (!this._isKeyAtSourceOccluded(source, key)) { + const occludingValue = this._getValueFromOccludedSources(source, key); + if (occludingValue !== undefined) { + // adding a value that will occlude another one should + // first emit a remove + this.emitRemove(key, occludingValue); + } + this.emitAdd(key, value); + } } - onRemove(key, value) { - this.emitRemove(key, value); + onRemove(source, key, value) { + if (!this._isKeyAtSourceOccluded(source, key)) { + this.emitRemove(key, value); + const occludedValue = this._getValueFromOccludedSources(source, key); + if (occludedValue !== undefined) { + // removing a value that so far occluded another one should + // emit an add for the occluded value after the removal + this.emitAdd(key, occludedValue); + } + } } - onUpdate(key, value, params) { - this.emitUpdate(key, value, params); - } - - onSubscribeFirst() { - this._subscriptions = this._sources.map(source => source.subscribe(this)); - super.onSubscribeFirst(); - } - - onUnsubscribeLast() { - super.onUnsubscribeLast(); - for (const s of this._subscriptions) { - s(); + onUpdate(source, key, value, params) { + if (!this._isKeyAtSourceOccluded(source, key)) { + this.emitUpdate(key, value, params); } } @@ -50,6 +56,49 @@ export class JoinedMap extends BaseObservableMap { this.emitReset(); } + onSubscribeFirst() { + this._subscriptions = this._sources.map(source => new SourceSubscriptionHandler(source, this).subscribe()); + super.onSubscribeFirst(); + } + + _isKeyAtSourceOccluded(source, key) { + // sources that come first in the sources array can + // hide the keys in later sources, to prevent events + // being emitted for the same key and different values, + // so check the key is not present in earlier sources + const index = this._sources.indexOf(source); + for (let i = 0; i < index; i += 1) { + if (this._sources[i].get(key) !== undefined) { + return true; + } + } + return false; + } + + // get the value that the given source and key occlude, if any + _getValueFromOccludedSources(source, key) { + // sources that come first in the sources array can + // hide the keys in later sources, to prevent events + // being emitted for the same key and different values, + // so check the key is not present in earlier sources + const index = this._sources.indexOf(source); + for (let i = index + 1; i < this._sources.length; i += 1) { + const source = this._sources[i]; + const occludedValue = source.get(key); + if (occludedValue !== undefined) { + return occludedValue; + } + } + return undefined; + } + + onUnsubscribeLast() { + super.onUnsubscribeLast(); + for (const s of this._subscriptions) { + s.dispose(); + } + } + [Symbol.iterator]() { return new JoinedIterator(this._sources); } @@ -74,6 +123,7 @@ class JoinedIterator { this._sources = sources; this._sourceIndex = -1; this._currentIterator = null; + this._encounteredKeys = new Set(); } next() { @@ -91,22 +141,140 @@ class JoinedIterator { this._currentIterator = null; continue; } else { - result = sourceResult; + const key = sourceResult.value[0]; + if (!this._encounteredKeys.has(key)) { + this._encounteredKeys.add(key); + result = sourceResult; + } } } return result; } } +class SourceSubscriptionHandler { + constructor(source, joinedMap) { + this._source = source; + this._joinedMap = joinedMap; + this._subscription = null; + } + + subscribe() { + this._source.subscribe(this); + return this; + } + + dispose() { + this._subscription = this._subscription(); + } + + onAdd(key, value) { + this._joinedMap.onAdd(this._source, key, value); + } + + onRemove(key, value) { + this._joinedMap.onRemove(this._source, key, value); + } + + onUpdate(key, value, params) { + this._joinedMap.onUpdate(this._source, key, value, params); + } + + onReset() { + this._joinedMap.onReset(this._source); + } +} + + +import { ObservableMap } from "./ObservableMap.js"; + export function tests() { + + function observeMap(map) { + const events = []; + map.subscribe({ + onAdd(key, value) { events.push({type: "add", key, value}); }, + onRemove(key, value) { events.push({type: "remove", key, value}); }, + onUpdate(key, value, params) { events.push({type: "update", key, value, params}); } + }); + return events; + } + return { "joined iterator": assert => { - const it = new JoinedIterator([[1, 2], [3, 4]]); - assert.equal(it.next().value, 1); - assert.equal(it.next().value, 2); - assert.equal(it.next().value, 3); - assert.equal(it.next().value, 4); + const firstKV = ["a", 1]; + const secondKV = ["b", 2]; + const thirdKV = ["c", 3]; + const it = new JoinedIterator([[firstKV, secondKV], [thirdKV]]); + assert.equal(it.next().value, firstKV); + assert.equal(it.next().value, secondKV); + assert.equal(it.next().value, thirdKV); assert.equal(it.next().done, true); + }, + "prevent key collision during iteration": assert => { + const first = new ObservableMap(); + const second = new ObservableMap(); + const join = new JoinedMap([first, second]); + second.add("a", 2); + second.add("b", 3); + first.add("a", 1); + const it = join[Symbol.iterator](); + assert.deepEqual(it.next().value, ["a", 1]); + assert.deepEqual(it.next().value, ["b", 3]); + assert.equal(it.next().done, true); + }, + "adding occluded key doesn't emit add": assert => { + const first = new ObservableMap(); + const second = new ObservableMap(); + const join = new JoinedMap([first, second]); + const events = observeMap(join); + first.add("a", 1); + second.add("a", 2); + assert.equal(events.length, 1); + assert.equal(events[0].type, "add"); + assert.equal(events[0].key, "a"); + assert.equal(events[0].value, 1); + }, + "updating occluded key doesn't emit update": assert => { + const first = new ObservableMap(); + const second = new ObservableMap(); + const join = new JoinedMap([first, second]); + first.add("a", 1); + second.add("a", 2); + const events = observeMap(join); + second.update("a", 3); + assert.equal(events.length, 0); + }, + "removal of occluding key emits add after remove": assert => { + const first = new ObservableMap(); + const second = new ObservableMap(); + const join = new JoinedMap([first, second]); + first.add("a", 1); + second.add("a", 2); + const events = observeMap(join); + first.remove("a"); + assert.equal(events.length, 2); + assert.equal(events[0].type, "remove"); + assert.equal(events[0].key, "a"); + assert.equal(events[0].value, 1); + assert.equal(events[1].type, "add"); + assert.equal(events[1].key, "a"); + assert.equal(events[1].value, 2); + }, + "adding occluding key emits remove first": assert => { + const first = new ObservableMap(); + const second = new ObservableMap(); + const join = new JoinedMap([first, second]); + second.add("a", 2); + const events = observeMap(join); + first.add("a", 1); + assert.equal(events.length, 2); + assert.equal(events[0].type, "remove"); + assert.equal(events[0].key, "a"); + assert.equal(events[0].value, 2); + assert.equal(events[1].type, "add"); + assert.equal(events[1].key, "a"); + assert.equal(events[1].value, 1); } }; } From ff4abbc5ba0ba546feb7e1164b50095d1bd6426c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 22 Apr 2021 17:18:07 +0200 Subject: [PATCH 35/74] make dispose not fail --- src/observable/map/JoinedMap.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/observable/map/JoinedMap.js b/src/observable/map/JoinedMap.js index 7d099136..e5d0caa7 100644 --- a/src/observable/map/JoinedMap.js +++ b/src/observable/map/JoinedMap.js @@ -20,6 +20,7 @@ export class JoinedMap extends BaseObservableMap { constructor(sources) { super(); this._sources = sources; + this._subscriptions = null; } onAdd(source, key, value) { @@ -160,7 +161,7 @@ class SourceSubscriptionHandler { } subscribe() { - this._source.subscribe(this); + this._subscription = this._source.subscribe(this); return this; } From ec0de15da60524df83cb4942a6a38352377ec10d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 22 Apr 2021 17:21:29 +0200 Subject: [PATCH 36/74] handle overlap with existing timeline when rejoining room --- src/matrix/room/Room.js | 3 +- .../room/timeline/persistence/SyncWriter.js | 31 +++++++++++++++++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 02b19a18..f75b1148 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -248,8 +248,9 @@ export class Room extends EventEmitter { /** @package */ async writeSync(roomResponse, isInitialSync, {summaryChanges, decryptChanges, roomEncryption, retryEntries}, txn, log) { log.set("id", this.id); + const isRejoin = summaryChanges.membership === "join" && this._summary.data.membership === "leave"; const {entries: newEntries, newLiveKey, memberChanges} = - await log.wrap("syncWriter", log => this._syncWriter.writeSync(roomResponse, txn, log), log.level.Detail); + await log.wrap("syncWriter", log => this._syncWriter.writeSync(roomResponse, isRejoin, txn, log), log.level.Detail); let allEntries = newEntries; if (decryptChanges) { const decryption = await log.wrap("decryptChanges", log => decryptChanges.write(txn, log)); diff --git a/src/matrix/room/timeline/persistence/SyncWriter.js b/src/matrix/room/timeline/persistence/SyncWriter.js index 4913ac53..dc2344e5 100644 --- a/src/matrix/room/timeline/persistence/SyncWriter.js +++ b/src/matrix/room/timeline/persistence/SyncWriter.js @@ -190,6 +190,26 @@ export class SyncWriter { return currentKey; } + async _handleRejoinOverlap(timeline, txn, log) { + if (this._lastLiveKey) { + const {fragmentId} = this._lastLiveKey; + const [lastEvent] = await txn.timelineEvents.lastEvents(this._roomId, fragmentId, 1); + if (lastEvent) { + const lastEventId = lastEvent.event.event_id; + const {events} = timeline; + const index = events.findIndex(event => event.event_id === lastEventId); + if (index !== -1) { + log.set("overlap_event_id", lastEventId); + return { + limited: false, + events: events.slice(index + 1) + }; + } + } + } + return timeline; + } + /** * @type {SyncWriterResult} * @property {Array} entries new timeline entries written @@ -197,12 +217,19 @@ export class SyncWriter { * @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, txn, log) { + async writeSync(roomResponse, isRejoin, txn, log) { const entries = []; - const {timeline} = roomResponse; + let {timeline} = roomResponse; + // we have rejoined the room after having synced it before, + // check for overlap with the last synced event + log.set("isRejoin", isRejoin); + 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 From 2e9ddf9c2c86e8f5119f305dd995a7fafc3cd95c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 22 Apr 2021 17:22:47 +0200 Subject: [PATCH 37/74] prevent key collisions between rooms and invites before creating tile vm --- .../session/leftpanel/LeftPanelViewModel.js | 21 ++++++++----------- src/matrix/room/Invite.js | 4 ++++ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/domain/session/leftpanel/LeftPanelViewModel.js b/src/domain/session/leftpanel/LeftPanelViewModel.js index c9a8b436..a1a577a9 100644 --- a/src/domain/session/leftpanel/LeftPanelViewModel.js +++ b/src/domain/session/leftpanel/LeftPanelViewModel.js @@ -35,23 +35,20 @@ export class LeftPanelViewModel extends ViewModel { } _mapTileViewModels(rooms, invites) { - const roomTileViewModels = rooms.mapValues((room, emitChange) => { - const isOpen = this.navigation.path.get("room")?.value === room.id; - const vm = new RoomTileViewModel(this.childOptions({isOpen, room, emitChange})); + // join is not commutative, invites will take precedence over rooms + return invites.join(rooms).mapValues((roomOrInvite, emitChange) => { + const isOpen = this.navigation.path.get("room")?.value === roomOrInvite.id; + let vm; + if (roomOrInvite.isInvite) { + vm = new InviteTileViewModel(this.childOptions({isOpen, invite: roomOrInvite, emitChange})); + } else { + vm = new RoomTileViewModel(this.childOptions({isOpen, room: roomOrInvite, emitChange})); + } if (isOpen) { this._updateCurrentVM(vm); } return vm; }); - const inviteTileViewModels = invites.mapValues((invite, emitChange) => { - const isOpen = this.navigation.path.get("room")?.value === invite.id; - const vm = new InviteTileViewModel(this.childOptions({isOpen, invite, emitChange})); - if (isOpen) { - this._updateCurrentVM(vm); - } - return vm; - }); - return roomTileViewModels.join(inviteTileViewModels); } _updateCurrentVM(vm) { diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index f9547828..db6306d6 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -35,6 +35,10 @@ export class Invite extends EventEmitter { this._rejected = false; } + get isInvite() { + return true; + } + get id() { return this._roomId; } From d289a44624c40637c479c5e96e5fd385740766a6 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 22 Apr 2021 17:23:29 +0200 Subject: [PATCH 38/74] also sync leave now that we can handle it --- src/matrix/Sync.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index 9fb57948..5f8d4e06 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -331,8 +331,7 @@ export class Sync { _parseRoomsResponse(roomsSection, inviteStates, isInitialSync) { const roomStates = []; if (roomsSection) { - // don't do "leave" for now - const allMemberships = ["join"]; + const allMemberships = ["join", "leave"]; for(const membership of allMemberships) { const membershipSection = roomsSection[membership]; if (membershipSection) { From b77d0f75ce23f56e0bfeeb669a940cb4a6ad7779 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 22 Apr 2021 17:23:41 +0200 Subject: [PATCH 39/74] can just use vm here --- src/platform/web/ui/session/room/RoomView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index b445dcac..a218964b 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -38,7 +38,7 @@ export class RoomView extends TemplateView { new TimelineList(timelineViewModel) : new TimelineLoadingView(vm); // vm is just needed for i18n }), - t.view(new MessageComposer(this.value.composerViewModel)), + t.view(new MessageComposer(vm.composerViewModel)), ]) ]); } From b9f145caa42ed2a4459349ab321307569ac7b4d0 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 23 Apr 2021 18:04:10 +0200 Subject: [PATCH 40/74] emit events for rooms before invites so the room is already there when removing the invite --- src/matrix/Sync.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index 5f8d4e06..c8454dd6 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -287,18 +287,19 @@ export class Sync { _afterSync(sessionState, inviteStates, roomStates, log) { log.wrap("session", log => this._session.afterSync(sessionState.changes, log), log.level.Detail); + // emit room related events after txn has been closed + for(let rs of roomStates) { + log.wrap("room", log => rs.room.afterSync(rs.changes, log), log.level.Detail); + if (rs.isNewRoom) { + this._session.addRoomAfterSync(rs.room); + } + } // emit invite related events after txn has been closed for(let is of inviteStates) { log.wrap("invite", () => is.invite.afterSync(is.changes), log.level.Detail); if (is.isNewInvite) { this._session.addInviteAfterSync(is.invite); } - } - // emit room related events after txn has been closed - for(let rs of roomStates) { - log.wrap("room", log => rs.room.afterSync(rs.changes, log), log.level.Detail); - if (rs.isNewRoom) { - this._session.addRoomAfterSync(rs.room); } } } From 847738a76c97e2c712aeab42c97318b1c73a0d7f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 23 Apr 2021 18:05:14 +0200 Subject: [PATCH 41/74] set invite on left room so we can detect a vm refresh is needed --- src/matrix/Session.js | 24 ++++++++++++++++-------- src/matrix/Sync.js | 13 ++++++++++--- src/matrix/room/Room.js | 24 ++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 11 deletions(-) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 11bca80b..99ed5035 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -284,20 +284,28 @@ export class Session { } } const pendingEventsByRoomId = await this._getPendingEventsByRoom(txn); - // load rooms - const rooms = await txn.roomSummary.getAll(); - await Promise.all(rooms.map(async summary => { - const room = this.createRoom(summary.roomId, pendingEventsByRoomId.get(summary.roomId)); - await log.wrap("room", log => room.load(summary, txn, log)); - this._rooms.add(room.id, room); - })); // load invites const invites = await txn.invites.getAll(); - await Promise.all(invites.map(async inviteData => { + const inviteLoadPromise = Promise.all(invites.map(async inviteData => { const invite = this.createInvite(inviteData.roomId); log.wrap("invite", log => invite.load(inviteData, log)); this._invites.add(invite.id, invite); })); + // load rooms + const rooms = await txn.roomSummary.getAll(); + const roomLoadPromise = Promise.all(rooms.map(async summary => { + const room = this.createRoom(summary.roomId, pendingEventsByRoomId.get(summary.roomId)); + await log.wrap("room", log => room.load(summary, txn, log)); + this._rooms.add(room.id, room); + })); + // load invites and rooms in parallel + await Promise.all([inviteLoadPromise, roomLoadPromise]); + for (const [roomId, invite] of this.invites) { + const room = this.rooms.get(roomId); + if (room) { + room.setInvite(invite); + } + } } dispose() { diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index c8454dd6..8c8e3423 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -300,6 +300,10 @@ export class Sync { if (is.isNewInvite) { this._session.addInviteAfterSync(is.invite); } + // if we haven't archived or forgotten the (left) room yet, + // notify there is an invite now, so we can update the UI + if (is.room) { + is.room.setInvite(is.invite); } } } @@ -352,7 +356,7 @@ export class Sync { // if there is an existing invite, add a process state for it // so its writeSync and afterSync will run and remove the invite if (invite) { - inviteStates.push(new InviteSyncProcessState(invite, false, membership, null)); + inviteStates.push(new InviteSyncProcessState(invite, false, null, membership, null)); } roomStates.push(new RoomSyncProcessState( room, isNewRoom, invite, roomResponse, membership)); @@ -373,7 +377,9 @@ export class Sync { invite = this._session.createInvite(roomId); isNewInvite = true; } - inviteStates.push(new InviteSyncProcessState(invite, isNewInvite, "invite", roomResponse)); + const room = this._session.rooms.get(roomId); + // TODO let the room know there is an invite now, so + inviteStates.push(new InviteSyncProcessState(invite, isNewInvite, room, "invite", roomResponse)); } } return inviteStates; @@ -417,9 +423,10 @@ class RoomSyncProcessState { } class InviteSyncProcessState { - constructor(invite, isNewInvite, membership, roomResponse) { + constructor(invite, isNewInvite, room, membership, roomResponse) { this.invite = invite; this.isNewInvite = isNewInvite; + this.room = room; this.membership = membership; this.roomResponse = roomResponse; this.changes = null; diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index f75b1148..8564f860 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -54,6 +54,7 @@ export class Room extends EventEmitter { this._getSyncToken = getSyncToken; this._platform = platform; this._observedEvents = null; + this._invite = null; } async _eventIdsToEntries(eventIds, txn) { @@ -344,6 +345,10 @@ export class Room extends EventEmitter { } let emitChange = false; if (summaryChanges) { + // if we joined the room, we can't have an invite anymore + if (summaryChanges.membership === "join" && this._summary.data.membership !== "join") { + this._invite = null; + } this._summary.applyChanges(summaryChanges); if (!this._summary.data.needsHeroes) { this._heroes = null; @@ -427,6 +432,14 @@ export class Room extends EventEmitter { } } + /** @internal */ + setInvite(invite) { + // called when an invite comes in for this room + // (e.g. when we're in membership leave and haven't been archived or forgotten yet) + this._invite = invite; + this._emitUpdate(); + } + /** @public */ sendEvent(eventType, content, attachments, log = null) { this._platform.logger.wrapOrRun(log, "send", log => { @@ -589,6 +602,17 @@ export class Room extends EventEmitter { return this._summary.data.membership; } + /** + * The invite for this room, if any. + * This will only be set if you've left a room, and + * don't archive or forget it, and then receive an invite + * for it again + * @return {Invite?} + */ + get invite() { + return this._invite; + } + enableSessionBackup(sessionBackup) { this._roomEncryption?.enableSessionBackup(sessionBackup); // TODO: do we really want to do this every time you open the app? From 827075bc37e88b1cc7f18ba92d6799d20708532c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 23 Apr 2021 18:05:46 +0200 Subject: [PATCH 42/74] only apply the invite when joining --- src/matrix/room/Room.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 8564f860..6038ae19 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -196,7 +196,7 @@ export class Room extends EventEmitter { log.set("newKeys", newKeys.length); } let summaryChanges = this._summary.data.applySyncResponse(roomResponse, membership); - if (invite) { + if (membership === "join" && invite) { summaryChanges = summaryChanges.applyInvite(invite); } let roomEncryption = this._roomEncryption; From f2d7f5e4dad8751141e14fc63a66f4945840ca49 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 23 Apr 2021 18:06:38 +0200 Subject: [PATCH 43/74] refresh vm when left room receives invite clean up room vm switching in the process --- src/domain/session/RoomGridViewModel.js | 27 +++--- src/domain/session/SessionViewModel.js | 98 ++++++++++------------ src/domain/session/room/InviteViewModel.js | 6 +- src/domain/session/room/RoomViewModel.js | 13 ++- 4 files changed, 70 insertions(+), 74 deletions(-) diff --git a/src/domain/session/RoomGridViewModel.js b/src/domain/session/RoomGridViewModel.js index 535d454a..05c55041 100644 --- a/src/domain/session/RoomGridViewModel.js +++ b/src/domain/session/RoomGridViewModel.js @@ -32,11 +32,10 @@ export class RoomGridViewModel extends ViewModel { this._width = options.width; this._height = options.height; - this._createRoomOrInviteViewModel = options.createRoomOrInviteViewModel; - + this._createRoomViewModel = options.createRoomViewModel; this._selectedIndex = 0; this._viewModels = []; - this._replaceInviteWithRoom = this._replaceInviteWithRoom.bind(this); + this._refreshRoomViewModel = this._refreshRoomViewModel.bind(this); this._setupNavigation(); } @@ -64,7 +63,7 @@ export class RoomGridViewModel extends ViewModel { // initial focus for a room is set by initializeRoomIdsAndTransferVM } - _replaceInviteWithRoom(roomId) { + _refreshRoomViewModel(roomId) { const index = this._viewModels.findIndex(vm => vm?.id === roomId); if (index === -1) { return; @@ -72,7 +71,7 @@ export class RoomGridViewModel extends ViewModel { this._viewModels[index] = this.disposeTracked(this._viewModels[index]); // this will create a RoomViewModel because the invite is already // removed from the collection (see Invite.afterSync) - const roomVM = this._createRoomOrInviteViewModel(roomId, this._replaceInviteWithRoom); + const roomVM = this._createRoomViewModel(roomId, this._refreshRoomViewModel); if (roomVM) { this._viewModels[index] = this.track(roomVM); if (this.focusIndex === index) { @@ -147,7 +146,7 @@ export class RoomGridViewModel extends ViewModel { this._viewModels[i] = this.disposeTracked(vm); } if (newId) { - const newVM = this._createRoomOrInviteViewModel(newId, this._replaceInviteWithRoom); + const newVM = this._createRoomViewModel(newId, this._refreshRoomViewModel); if (newVM) { this._viewModels[i] = this.track(newVM); } @@ -230,7 +229,7 @@ export function tests() { "initialize with duplicate set of rooms": assert => { const navigation = createNavigationForRoom(["c", "a", "b", undefined, "a"], "a"); const gridVM = new RoomGridViewModel({ - createRoomOrInviteViewModel: id => new RoomVMMock(id), + createRoomViewModel: id => new RoomVMMock(id), navigation, width: 3, height: 2, @@ -247,7 +246,7 @@ export function tests() { "transfer room view model": assert => { const navigation = createNavigationForRoom(["a"], "a"); const gridVM = new RoomGridViewModel({ - createRoomOrInviteViewModel: () => assert.fail("no vms should be created"), + createRoomViewModel: () => assert.fail("no vms should be created"), navigation, width: 3, height: 2, @@ -261,7 +260,7 @@ export function tests() { "reject transfer for non-matching room view model": assert => { const navigation = createNavigationForRoom(["a"], "a"); const gridVM = new RoomGridViewModel({ - createRoomOrInviteViewModel: id => new RoomVMMock(id), + createRoomViewModel: id => new RoomVMMock(id), navigation, width: 3, height: 2, @@ -275,7 +274,7 @@ export function tests() { "created & released room view model is not disposed": assert => { const navigation = createNavigationForRoom(["a"], "a"); const gridVM = new RoomGridViewModel({ - createRoomOrInviteViewModel: id => new RoomVMMock(id), + createRoomViewModel: id => new RoomVMMock(id), navigation, width: 3, height: 2, @@ -289,7 +288,7 @@ export function tests() { "transfered & released room view model is not disposed": assert => { const navigation = createNavigationForRoom([undefined, "a"], "a"); const gridVM = new RoomGridViewModel({ - createRoomOrInviteViewModel: () => assert.fail("no vms should be created"), + createRoomViewModel: () => assert.fail("no vms should be created"), navigation, width: 3, height: 2, @@ -304,7 +303,7 @@ export function tests() { "try release non-existing room view model is": assert => { const navigation = createNavigationForEmptyTile([undefined, "b"], 3); const gridVM = new RoomGridViewModel({ - createRoomOrInviteViewModel: id => new RoomVMMock(id), + createRoomViewModel: id => new RoomVMMock(id), navigation, width: 3, height: 2, @@ -316,7 +315,7 @@ export function tests() { "initial focus is set to empty tile": assert => { const navigation = createNavigationForEmptyTile(["a"], 1); const gridVM = new RoomGridViewModel({ - createRoomOrInviteViewModel: id => new RoomVMMock(id), + createRoomViewModel: id => new RoomVMMock(id), navigation, width: 3, height: 2, @@ -328,7 +327,7 @@ export function tests() { "change room ids after creation": assert => { const navigation = createNavigationForRoom(["a", "b"], "a"); const gridVM = new RoomGridViewModel({ - createRoomOrInviteViewModel: id => new RoomVMMock(id), + createRoomViewModel: id => new RoomVMMock(id), navigation, width: 3, height: 2, diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 45b97c3e..6bf334b5 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -42,8 +42,8 @@ export class SessionViewModel extends ViewModel { this._settingsViewModel = null; this._currentRoomViewModel = null; this._gridViewModel = null; - this._replaceInviteWithRoom = this._replaceInviteWithRoom.bind(this); - this._createRoomOrInviteViewModel = this._createRoomOrInviteViewModel.bind(this); + this._refreshRoomViewModel = this._refreshRoomViewModel.bind(this); + this._createRoomViewModel = this._createRoomViewModel.bind(this); this._setupNavigation(); } @@ -121,7 +121,7 @@ export class SessionViewModel extends ViewModel { this._gridViewModel = this.track(new RoomGridViewModel(this.childOptions({ width: 3, height: 2, - createRoomOrInviteViewModel: this._createRoomOrInviteViewModel, + createRoomViewModel: this._createRoomViewModel, }))); if (this._gridViewModel.initializeRoomIdsAndTransferVM(roomIds, this._currentRoomViewModel)) { this._currentRoomViewModel = this.untrack(this._currentRoomViewModel); @@ -138,7 +138,7 @@ export class SessionViewModel extends ViewModel { if (vm) { this._currentRoomViewModel = this.track(vm); } else { - const newVM = this._createRoomViewModel(currentRoomId.value); + const newVM = this._createRoomViewModel(currentRoomId.value, this._refreshRoomViewModel); if (newVM) { this._currentRoomViewModel = this.track(newVM); } @@ -151,72 +151,62 @@ export class SessionViewModel extends ViewModel { } } - _createRoomViewModel(roomId) { - const room = this._sessionContainer.session.rooms.get(roomId); - if (!room) { - return null; - } - const roomVM = new RoomViewModel(this.childOptions({ - room, - ownUserId: this._sessionContainer.session.user.id, - })); - roomVM.load(); - return roomVM; - } - - _createInviteViewModel(roomId, replaceInviteWithRoom) { + /** + * @param {string} roomId + * @param {function} refreshRoomViewModel passed in as an argument, because the grid needs a different impl of this + * @return {RoomViewModel | InviteViewModel} + */ + _createRoomViewModel(roomId, refreshRoomViewModel) { const invite = this._sessionContainer.session.invites.get(roomId); - if (!invite) { - return null; - } - return new InviteViewModel(this.childOptions({ - invite, - mediaRepository: this._sessionContainer.session.mediaRepository, - closeCallback: accepted => this._closeInvite(roomId, accepted, replaceInviteWithRoom), - })); - } - - _createRoomOrInviteViewModel(roomId, replaceInviteWithRoom) { - const inviteVM = this._createInviteViewModel(roomId, replaceInviteWithRoom); - if (inviteVM) { - return inviteVM; - } - return this._createRoomViewModel(roomId); - } - - _closeInvite(roomId, accepted, replaceInviteWithRoom) { - if (accepted) { - replaceInviteWithRoom(roomId); + if (invite) { + console.log("got invite"); + return new InviteViewModel(this.childOptions({ + invite, + mediaRepository: this._sessionContainer.session.mediaRepository, + refreshRoomViewModel, + })); } else { - // close invite - this.navigation.applyPath(removeRoomFromPath(this.navigation.path, roomId)); + const room = this._sessionContainer.session.rooms.get(roomId); + if (room) { + console.log("got room"); + const roomVM = new RoomViewModel(this.childOptions({ + room, + ownUserId: this._sessionContainer.session.user.id, + refreshRoomViewModel + })); + roomVM.load(); + return roomVM; + } } + return null; } - _replaceInviteWithRoom(roomId) { + /** refresh the room view model after an internal change that needs + to change between invite, room or none state */ + _refreshRoomViewModel(roomId) { this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); - const roomVM = this._createRoomViewModel(roomId); + const roomVM = this._createRoomViewModel(roomId, this._refreshRoomViewModel); if (roomVM) { this._currentRoomViewModel = this.track(roomVM); + } else { + // close room id + this.navigation.applyPath(removeRoomFromPath(this.navigation.path, roomId)); } this.emitChange("activeMiddleViewModel"); } _updateRoom(roomId) { - if (!roomId) { - // closing invite or room view? - if (this._currentRoomViewModel) { - this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); - this.emitChange("activeMiddleViewModel"); - } - return; - } - // already open? + // opening a room and already open? if (this._currentRoomViewModel?.id === roomId) { + console.log("bailing out"); return; } - this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); - const roomVM = this._createRoomOrInviteViewModel(roomId, this._replaceInviteWithRoom); + // close if needed + if (this._currentRoomViewModel) { + this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); + } + // and try opening again + const roomVM = this._createRoomViewModel(roomId, this._refreshRoomViewModel); if (roomVM) { this._currentRoomViewModel = this.track(roomVM); } diff --git a/src/domain/session/room/InviteViewModel.js b/src/domain/session/room/InviteViewModel.js index 2b43feb0..31171b88 100644 --- a/src/domain/session/room/InviteViewModel.js +++ b/src/domain/session/room/InviteViewModel.js @@ -21,10 +21,10 @@ import {ViewModel} from "../../ViewModel.js"; export class InviteViewModel extends ViewModel { constructor(options) { super(options); - const {invite, mediaRepository, closeCallback} = options; + const {invite, mediaRepository, refreshRoomViewModel} = options; this._invite = invite; this._mediaRepository = mediaRepository; - this._closeCallback = closeCallback; + this._refreshRoomViewModel = refreshRoomViewModel; this._onInviteChange = this._onInviteChange.bind(this); this._error = null; this._closeUrl = this.urlCreator.urlUntilSegment("session"); @@ -99,7 +99,7 @@ export class InviteViewModel extends ViewModel { // we're in a grid view, and opening the room doesn't change // the nav path because the url is the same for an // invite and the room. - this._closeCallback(this._invite.accepted); + this._refreshRoomViewModel(this.id); } else { this.emitChange(); } diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 6a48f9d9..e3e40c2a 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -22,9 +22,10 @@ import {ViewModel} from "../../ViewModel.js"; export class RoomViewModel extends ViewModel { constructor(options) { super(options); - const {room, ownUserId} = options; + const {room, ownUserId, refreshRoomViewModel} = options; this._room = room; this._ownUserId = ownUserId; + this._refreshRoomViewModel = refreshRoomViewModel; this._timelineVM = null; this._onRoomChange = this._onRoomChange.bind(this); this._timelineError = null; @@ -65,7 +66,7 @@ export class RoomViewModel extends ViewModel { } catch (err) { if (err.name !== "AbortError") { throw err; - } + } } } @@ -85,7 +86,13 @@ export class RoomViewModel extends ViewModel { // room doesn't tell us yet which fields changed, // so emit all fields originating from summary _onRoomChange() { - this.emitChange("name"); + // if there is now an invite on this (left) room, + // show the invite view by refreshing the view model + if (this._room.invite) { + this._refreshRoomViewModel(this.id); + } else { + this.emitChange("name"); + } } get kind() { return "room"; } From 47b2eb0bdb61989acd3f75e7b84ad93e6de79643 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 26 Apr 2021 10:41:21 +0200 Subject: [PATCH 44/74] don't create a room when rejecting an invite --- src/matrix/Sync.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index 8c8e3423..9265d894 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -348,7 +348,8 @@ export class Sync { } let isNewRoom = false; let room = this._session.rooms.get(roomId); - if (!room) { + // don't create a room for a rejected invite + if (!room && membership === "join") { room = this._session.createRoom(roomId); isNewRoom = true; } @@ -358,8 +359,10 @@ export class Sync { if (invite) { inviteStates.push(new InviteSyncProcessState(invite, false, null, membership, null)); } - roomStates.push(new RoomSyncProcessState( - room, isNewRoom, invite, roomResponse, membership)); + if (room) { + roomStates.push(new RoomSyncProcessState( + room, isNewRoom, invite, roomResponse, membership)); + } } } } From bd748549f7c84d53a2baac3bd2f0b30280768e1c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 26 Apr 2021 10:45:27 +0200 Subject: [PATCH 45/74] fix updates in Invite not firing on collection --- src/matrix/room/Invite.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index db6306d6..1c8f1bcc 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -105,7 +105,7 @@ export class Invite extends EventEmitter { _emitChange(params) { this.emit("change"); - this._emitCollectionUpdate(params); + this._emitCollectionUpdate(this, params); } load(inviteData, log) { From 55b576a299ceaffe57bd763bba6cd617d0633bef Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 10:28:09 +0200 Subject: [PATCH 46/74] rename .TimelinePanel to .RoomView_body --- src/platform/web/ui/css/layout.css | 6 +++--- src/platform/web/ui/css/timeline.css | 5 +---- src/platform/web/ui/session/room/RoomView.js | 14 +++++++------- 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/src/platform/web/ui/css/layout.css b/src/platform/web/ui/css/layout.css index 9917ca74..e47f7777 100644 --- a/src/platform/web/ui/css/layout.css +++ b/src/platform/web/ui/css/layout.css @@ -122,8 +122,8 @@ main { height: 100%; } -.TimelinePanel { - flex: 3; +.RoomView_body { + flex: 1; min-height: 0; min-width: 0; display: flex; @@ -131,7 +131,7 @@ main { height: 100%; } -.TimelinePanel .Timeline, .TimelinePanel .TimelineLoadingView { +.RoomView_body .Timeline, .RoomView_body .TimelineLoadingView { flex: 1 0 0; } diff --git a/src/platform/web/ui/css/timeline.css b/src/platform/web/ui/css/timeline.css index 8a766a54..5d082c08 100644 --- a/src/platform/web/ui/css/timeline.css +++ b/src/platform/web/ui/css/timeline.css @@ -15,7 +15,7 @@ limitations under the License. */ -.TimelinePanel ul { +.RoomView_body ul { overflow-y: auto; overscroll-behavior: contain; list-style: none; @@ -23,9 +23,6 @@ limitations under the License. margin: 0; } -.TimelinePanel li { -} - .message-container { flex: 0 1 auto; /* first try break-all, then break-word, which isn't supported everywhere */ diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index a218964b..470e940d 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -24,14 +24,14 @@ import {AvatarView} from "../../avatar.js"; export class RoomView extends TemplateView { render(t, vm) { return t.main({className: "RoomView middle"}, [ - t.div({className: "TimelinePanel"}, [ - t.div({className: "RoomHeader middle-header"}, [ - t.a({className: "button-utility close-middle", href: vm.closeUrl, title: vm.i18n`Close room`}), - t.view(new AvatarView(vm, 32)), - t.div({className: "room-description"}, [ - t.h2(vm => vm.name), - ]), + t.div({className: "RoomHeader middle-header"}, [ + t.a({className: "button-utility close-middle", href: vm.closeUrl, title: vm.i18n`Close room`}), + t.view(new AvatarView(vm, 32)), + t.div({className: "room-description"}, [ + t.h2([vm => vm.name, vm => vm.membership]), ]), + ]), + t.div({className: "RoomView_body"}, [ t.div({className: "RoomView_error"}, vm => vm.error), t.mapView(vm => vm.timelineViewModel, timelineViewModel => { return timelineViewModel ? From ab8111f847ac558f8f509508fc5317fc713d04bb Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 10:28:25 +0200 Subject: [PATCH 47/74] make all middle containers column flexboxes, not just the room view --- src/platform/web/ui/css/layout.css | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/platform/web/ui/css/layout.css b/src/platform/web/ui/css/layout.css index e47f7777..7b4e8e86 100644 --- a/src/platform/web/ui/css/layout.css +++ b/src/platform/web/ui/css/layout.css @@ -98,12 +98,8 @@ main { min-height: 0; /* make popups relative to this element so changing the left panel width doesn't affect their position */ position: relative; -} - -.RoomView { - min-width: 0; - min-height: 0; display: flex; + flex-direction: column; } .SessionStatusView { From bb6664a9a82583fc00d2563d824505592746cda2 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 10:31:47 +0200 Subject: [PATCH 48/74] fix room header height --- src/platform/web/ui/css/themes/element/theme.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index a053f07e..f12ca768 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -406,7 +406,7 @@ a { .middle-header { box-sizing: border-box; - height: 58px; /* 12 + 36 + 12 to align with filter field + margin */ + flex: 0 0 56px; /* 12 + 32 + 12 to align with filter field + margin */ background: white; padding: 0 16px; border-bottom: 1px solid rgba(245, 245, 245, 0.90); From 9b9e6fceda7521d672abb86f3b2fb097c5eec849 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 10:32:06 +0200 Subject: [PATCH 49/74] refine action buttons style --- src/platform/web/ui/css/themes/element/theme.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index f12ca768..d2ec276a 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -106,6 +106,11 @@ a.button-action { background-color: #03B381; border-radius: 8px; color: white; + font-weight: bold; +} + +.button-action.primary:disabled { + color: #fffa; } .button-action.primary.destructive { From aaf4e79a73b1797f252207cb0b2c8c19d95db866 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 11:01:33 +0200 Subject: [PATCH 50/74] make media repo available on invite --- src/domain/session/leftpanel/BaseTileViewModel.js | 2 +- src/matrix/Session.js | 1 + src/matrix/room/Invite.js | 7 ++++++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/domain/session/leftpanel/BaseTileViewModel.js b/src/domain/session/leftpanel/BaseTileViewModel.js index 6e074caa..d4ca3293 100644 --- a/src/domain/session/leftpanel/BaseTileViewModel.js +++ b/src/domain/session/leftpanel/BaseTileViewModel.js @@ -78,7 +78,7 @@ export class BaseTileViewModel extends ViewModel { get avatarUrl() { if (this._avatarSource.avatarUrl) { const size = 32 * this.platform.devicePixelRatio; - return this._room.mediaRepository.mxcUrlThumbnail(this._avatarSource.avatarUrl, size, size, "crop"); + return this._avatarSource.mediaRepository.mxcUrlThumbnail(this._avatarSource.avatarUrl, size, size, "crop"); } return null; } diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 99ed5035..a9076169 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -412,6 +412,7 @@ export class Session { hsApi: this._hsApi, emitCollectionRemove: this._inviteRemoveCallback, emitCollectionUpdate: this._inviteUpdateCallback, + mediaRepository: this._mediaRepository, user: this._user, platform: this._platform, }); diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index 1c8f1bcc..4ce74ea8 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -20,13 +20,14 @@ import {Heroes} from "./members/Heroes.js"; import {MemberChange, RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "./members/RoomMember.js"; export class Invite extends EventEmitter { - constructor({roomId, user, hsApi, emitCollectionRemove, emitCollectionUpdate, platform}) { + constructor({roomId, user, hsApi, mediaRepository, emitCollectionRemove, emitCollectionUpdate, platform}) { super(); this._roomId = roomId; this._user = user; this._hsApi = hsApi; this._emitCollectionRemove = emitCollectionRemove; this._emitCollectionUpdate = emitCollectionUpdate; + this._mediaRepository = mediaRepository; this._platform = platform; this._inviteData = null; this._accepting = false; @@ -103,6 +104,10 @@ export class Invite extends EventEmitter { return this._rejected; } + get mediaRepository() { + return this._mediaRepository; + } + _emitChange(params) { this.emit("change"); this._emitCollectionUpdate(this, params); From 86e8b27cb3043d56b16d988d153d5d65da4332dc Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 11:01:51 +0200 Subject: [PATCH 51/74] fix middle-shown to use new active vm --- src/platform/web/ui/session/SessionView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/session/SessionView.js b/src/platform/web/ui/session/SessionView.js index c7abd4db..214db2a3 100644 --- a/src/platform/web/ui/session/SessionView.js +++ b/src/platform/web/ui/session/SessionView.js @@ -30,7 +30,7 @@ export class SessionView extends TemplateView { return t.div({ className: { "SessionView": true, - "middle-shown": vm => vm.activeSection !== "placeholder" + "middle-shown": vm => !!vm.activeMiddleViewModel }, }, [ t.view(new SessionStatusView(vm.sessionStatusViewModel)), From fd454f1e2044ca34349d5d2309f5a169bda8f4ce Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 14:26:40 +0200 Subject: [PATCH 52/74] don't expose joinRule prop, as it is protocol specific --- src/matrix/room/Invite.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index 4ce74ea8..d34ffa8e 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -68,8 +68,12 @@ export class Invite extends EventEmitter { return this._inviter; } - get joinRule() { - return this._inviteData.joinRule; + get isPublic() { + return this._inviteData.joinRule === "public"; + } + + get canonicalAlias() { + return this._inviteData.canonicalAlias; } async accept(log = null) { @@ -276,7 +280,7 @@ export function tests() { invite.afterSync(changes); assert.equal(invite.name, "Invite example"); assert.equal(invite.avatarUrl, roomAvatarUrl); - assert.equal(invite.joinRule, "invite"); + assert.equal(invite.isPublic, false); assert.equal(invite.timestamp, 1001); assert.equal(invite.isEncrypted, false); assert.equal(invite.isDirectMessage, false); From 87defbfb5186191e6c4463a72f162222d2cb7646 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 14:27:14 +0200 Subject: [PATCH 53/74] sort invites most recent first --- src/domain/session/leftpanel/InviteTileViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/leftpanel/InviteTileViewModel.js b/src/domain/session/leftpanel/InviteTileViewModel.js index 5c3082c0..10c84628 100644 --- a/src/domain/session/leftpanel/InviteTileViewModel.js +++ b/src/domain/session/leftpanel/InviteTileViewModel.js @@ -42,7 +42,7 @@ export class InviteTileViewModel extends BaseTileViewModel { if (parentComparison !== 0) { return parentComparison; } - return this._invite.timestamp - other._invite.timestamp; + return other._invite.timestamp - this._invite.timestamp; } get name() { From b6573258fb3e6e42afab232103d990535c89f3fc Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 14:42:12 +0200 Subject: [PATCH 54/74] always show inviter if present --- src/domain/session/room/InviteViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/room/InviteViewModel.js b/src/domain/session/room/InviteViewModel.js index 31171b88..fe629dc4 100644 --- a/src/domain/session/room/InviteViewModel.js +++ b/src/domain/session/room/InviteViewModel.js @@ -30,7 +30,7 @@ export class InviteViewModel extends ViewModel { this._closeUrl = this.urlCreator.urlUntilSegment("session"); this._invite.on("change", this._onInviteChange); this._inviter = null; - if (this._invite.inviter && ! this._invite.isDirectMessage) { + if (this._invite.inviter) { this._inviter = new RoomMemberViewModel(this._invite.inviter, mediaRepository, this.platform); } } From f902f255a42f596d9a6cefab06ce00093437df33 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 14:42:37 +0200 Subject: [PATCH 55/74] add roomDescription property --- src/domain/session/room/InviteViewModel.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/domain/session/room/InviteViewModel.js b/src/domain/session/room/InviteViewModel.js index fe629dc4..377c7ab5 100644 --- a/src/domain/session/room/InviteViewModel.js +++ b/src/domain/session/room/InviteViewModel.js @@ -33,6 +33,7 @@ export class InviteViewModel extends ViewModel { if (this._invite.inviter) { this._inviter = new RoomMemberViewModel(this._invite.inviter, mediaRepository, this.platform); } + this._roomDescription = this._createRoomDescription(); } get kind() { return "invite"; } @@ -67,6 +68,24 @@ export class InviteViewModel extends ViewModel { return null; } + _createRoomDescription() { + const parts = []; + if (this._invite.isPublic) { + parts.push("Public room"); + } else { + parts.push("Private room"); + } + + if (this._invite.canonicalAlias) { + parts.push(this._invite.canonicalAlias); + } + return parts.join(" • ") + } + + get roomDescription() { + return this._roomDescription; + } + get avatarTitle() { return this.name; } From 781235cb071ef4765579a2532c8e590114c0815d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 14:43:13 +0200 Subject: [PATCH 56/74] scale up room avatar --- src/domain/session/room/InviteViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/room/InviteViewModel.js b/src/domain/session/room/InviteViewModel.js index 377c7ab5..bc052cd9 100644 --- a/src/domain/session/room/InviteViewModel.js +++ b/src/domain/session/room/InviteViewModel.js @@ -62,7 +62,7 @@ export class InviteViewModel extends ViewModel { get avatarUrl() { if (this._invite.avatarUrl) { - const size = 32 * this.platform.devicePixelRatio; + const size = 128 * this.platform.devicePixelRatio; return this._mediaRepository.mxcUrlThumbnail(this._invite.avatarUrl, size, size, "crop"); } return null; From 15fba7a07d4240da29eaf044899eb992c82cee91 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 14:43:26 +0200 Subject: [PATCH 57/74] missing semicolon --- src/domain/session/room/InviteViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/room/InviteViewModel.js b/src/domain/session/room/InviteViewModel.js index bc052cd9..a7807e9f 100644 --- a/src/domain/session/room/InviteViewModel.js +++ b/src/domain/session/room/InviteViewModel.js @@ -146,7 +146,7 @@ class RoomMemberViewModel { } get avatarColorNumber() { - return getIdentifierColorNumber(this._member.userId) + return getIdentifierColorNumber(this._member.userId); } get avatarUrl() { From a95df54978064a1795734988b39fa9d7ea567084 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 15:01:20 +0200 Subject: [PATCH 58/74] scale down inviter avatar --- src/domain/session/room/InviteViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/room/InviteViewModel.js b/src/domain/session/room/InviteViewModel.js index a7807e9f..b31f79ad 100644 --- a/src/domain/session/room/InviteViewModel.js +++ b/src/domain/session/room/InviteViewModel.js @@ -151,7 +151,7 @@ class RoomMemberViewModel { get avatarUrl() { if (this._member.avatarUrl) { - const size = 32 * this.platform.devicePixelRatio; + const size = 24 * this._platform.devicePixelRatio; return this._mediaRepository.mxcUrlThumbnail(this._member.avatarUrl, size, size, "crop"); } return null; From 32d9f6e83086537fe4b8f447b7955457b762372c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 15:01:35 +0200 Subject: [PATCH 59/74] expose inviter user id --- src/domain/session/room/InviteViewModel.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/domain/session/room/InviteViewModel.js b/src/domain/session/room/InviteViewModel.js index b31f79ad..57691a34 100644 --- a/src/domain/session/room/InviteViewModel.js +++ b/src/domain/session/room/InviteViewModel.js @@ -137,6 +137,10 @@ class RoomMemberViewModel { this._platform = platform; } + get id() { + return this._member.userId; + } + get name() { return this._member.name; } From 799ea50c68f0acba7fa7ceb58124a4d848010dcd Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 15:02:00 +0200 Subject: [PATCH 60/74] remove obsolete logging --- src/domain/session/SessionViewModel.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 6bf334b5..1e59a9d5 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -159,7 +159,6 @@ export class SessionViewModel extends ViewModel { _createRoomViewModel(roomId, refreshRoomViewModel) { const invite = this._sessionContainer.session.invites.get(roomId); if (invite) { - console.log("got invite"); return new InviteViewModel(this.childOptions({ invite, mediaRepository: this._sessionContainer.session.mediaRepository, @@ -168,7 +167,6 @@ export class SessionViewModel extends ViewModel { } else { const room = this._sessionContainer.session.rooms.get(roomId); if (room) { - console.log("got room"); const roomVM = new RoomViewModel(this.childOptions({ room, ownUserId: this._sessionContainer.session.user.id, @@ -198,7 +196,6 @@ export class SessionViewModel extends ViewModel { _updateRoom(roomId) { // opening a room and already open? if (this._currentRoomViewModel?.id === roomId) { - console.log("bailing out"); return; } // close if needed From 1f3612228301737b88b69d2f0fbd56c69dcdc7a2 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 15:02:29 +0200 Subject: [PATCH 61/74] remove debugging code --- src/platform/web/ui/session/room/RoomView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index 470e940d..327af046 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -28,7 +28,7 @@ export class RoomView extends TemplateView { t.a({className: "button-utility close-middle", href: vm.closeUrl, title: vm.i18n`Close room`}), t.view(new AvatarView(vm, 32)), t.div({className: "room-description"}, [ - t.h2([vm => vm.name, vm => vm.membership]), + t.h2(vm => vm.name), ]), ]), t.div({className: "RoomView_body"}, [ From 34cca2ba0f791daedc770f00e2c4e52da223030f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 15:02:43 +0200 Subject: [PATCH 62/74] add invite view to view gallery --- src/platform/web/ui/view-gallery.html | 49 +++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/platform/web/ui/view-gallery.html b/src/platform/web/ui/view-gallery.html index 46b69e16..7887d44f 100644 --- a/src/platform/web/ui/view-gallery.html +++ b/src/platform/web/ui/view-gallery.html @@ -74,5 +74,54 @@ })); document.getElementById("session-loading").appendChild(view.mount()); +

Invite DM view

+
+ +

Invite Room view

+
+ From 441bb4d5d7d77caa0913386c5170ea0adfb2e38d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 15:03:13 +0200 Subject: [PATCH 63/74] keep .room-placeholder display settings separate from .middle --- src/platform/web/ui/css/layout.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/platform/web/ui/css/layout.css b/src/platform/web/ui/css/layout.css index 7b4e8e86..b4089d9b 100644 --- a/src/platform/web/ui/css/layout.css +++ b/src/platform/web/ui/css/layout.css @@ -98,6 +98,9 @@ main { min-height: 0; /* make popups relative to this element so changing the left panel width doesn't affect their position */ position: relative; +} + +.middle { display: flex; flex-direction: column; } From fbe255f8374a8e9aab0d2d9e38f08bdaf068318f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 15:04:01 +0200 Subject: [PATCH 64/74] support extra classes in static avatar --- src/platform/web/ui/avatar.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/platform/web/ui/avatar.js b/src/platform/web/ui/avatar.js index c68d5496..4e502de9 100644 --- a/src/platform/web/ui/avatar.js +++ b/src/platform/web/ui/avatar.js @@ -103,12 +103,15 @@ export class AvatarView extends BaseUpdateView { * @param {Number} size * @return {Element} */ -export function renderStaticAvatar(vm, size) { +export function renderStaticAvatar(vm, size, extraClasses = undefined) { const hasAvatar = !!vm.avatarUrl; - const avatarClasses = classNames({ + let avatarClasses = classNames({ avatar: true, [`usercolor${vm.avatarColorNumber}`]: !hasAvatar, }); + if (extraClasses) { + avatarClasses += ` ${extraClasses}`; + } const avatarContent = hasAvatar ? renderImg(vm, size) : text(vm.avatarLetter); return tag.div({className: avatarClasses}, [avatarContent]); } From a9838fed06320c5f023867e8d7675494e9779be5 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 15:09:29 +0200 Subject: [PATCH 65/74] don't set a default avatar size in the css, makes it easier to change it --- src/platform/web/ui/css/avatar.css | 1 - src/platform/web/ui/css/themes/element/theme.css | 5 +++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/platform/web/ui/css/avatar.css b/src/platform/web/ui/css/avatar.css index 4c7d1074..8aa482a9 100644 --- a/src/platform/web/ui/css/avatar.css +++ b/src/platform/web/ui/css/avatar.css @@ -16,7 +16,6 @@ limitations under the License. */ .avatar { - --avatar-size: 32px; width: var(--avatar-size); height: var(--avatar-size); overflow: hidden; diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index d2ec276a..28cee2f7 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -250,6 +250,7 @@ a.button-action { /* make scrollbar hit right edge of parent */ padding-right: 8px; margin-right: -8px; + --avatar-size: 32px; } .RoomList > li { @@ -434,6 +435,10 @@ a { background-position-x: 10px; } +.RoomHeader { + --avatar-size: 32px; +} + .RoomHeader .topic { font-size: 14rem; } From f4f153ac4b05af04831eb5027b24c0046c7a641a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 15:09:45 +0200 Subject: [PATCH 66/74] don't seems to throw the letter off-center more than anything else apart for some rare cases ... shrug --- src/platform/web/ui/css/avatar.css | 1 - 1 file changed, 1 deletion(-) diff --git a/src/platform/web/ui/css/avatar.css b/src/platform/web/ui/css/avatar.css index 8aa482a9..04d837e6 100644 --- a/src/platform/web/ui/css/avatar.css +++ b/src/platform/web/ui/css/avatar.css @@ -24,7 +24,6 @@ limitations under the License. line-height: var(--avatar-size); font-size: calc(var(--avatar-size) * 0.6); text-align: center; - letter-spacing: calc(var(--avatar-size) * -0.05); speak: none; } From c47b27428b8a6acd17efcca8561d980542dd5b3f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 15:10:12 +0200 Subject: [PATCH 67/74] implement invite view + styling --- .../web/ui/css/themes/element/theme.css | 77 +++++++++++++++++++ .../web/ui/session/room/InviteView.js | 60 +++++++++++---- 2 files changed, 122 insertions(+), 15 deletions(-) diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 28cee2f7..53c09dd8 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -92,12 +92,17 @@ limitations under the License. display: block; } +.button-action { + cursor: pointer; +} + a.button-action { text-decoration: none; text-align: center; display: block; } + .button-action.secondary { color: #03B381; } @@ -832,3 +837,75 @@ button.link { background-color: #03B381; color: white; } + +.InviteView_body { + display: flex; + justify-content: space-around; + align-items: center; + flex: 1; + overflow: auto; +} + +.InviteView_invite { + display: flex; + width: 100%; + max-width: 400px; + flex-direction: column; + padding: 0 24px; +} + +.InviteView_roomProfile { + display: grid; + gap: 4px; + grid-template: + "avatar name" auto + "avatar description" 1fr / + 72px 1fr; + align-self: center; + margin-bottom: 24px; +} + +.InviteView_roomProfile h3 { + grid-area: name; + margin: 0; +} + +.InviteView_roomDescription { + grid-area: description; + font-size: 1.2rem; + margin: 0; + color: #777; +} + +.InviteView_roomAvatar { + grid-area: avatar; + --avatar-size: 64px; +} + +.InviteView_dmAvatar { + align-self: center; + --avatar-size: 128px; +} + +.InviteView_inviter { + text-align: center; + margin: 24px 0px; +} + +.InviteView_inviter .avatar { + --avatar-size: 24px; + display: inline-block; + vertical-align: middle; + margin-right: 4px; +} + +.InviteView_buttonRow { + margin: 10px auto; + max-width: 200px; + width: 100%; +} + +.InviteView_buttonRow button { + display: block; + width: 100%; +} diff --git a/src/platform/web/ui/session/room/InviteView.js b/src/platform/web/ui/session/room/InviteView.js index ae241be2..1d1e7db4 100644 --- a/src/platform/web/ui/session/room/InviteView.js +++ b/src/platform/web/ui/session/room/InviteView.js @@ -16,27 +16,57 @@ limitations under the License. */ import {TemplateView} from "../../general/TemplateView.js"; -// import {TimelineList} from "./TimelineList.js"; -// import {TimelineLoadingView} from "./TimelineLoadingView.js"; -// import {MessageComposer} from "./MessageComposer.js"; import {renderStaticAvatar} from "../../avatar.js"; export class InviteView extends TemplateView { render(t, vm) { + let inviteNodes = []; + if (vm.isDirectMessage) { + inviteNodes.push(renderStaticAvatar(vm, 128, "InviteView_dmAvatar")); + } + let inviterNodes; + if (vm.isDirectMessage) { + inviterNodes = [t.strong(vm.name), ` (${vm.inviter?.id}) wants to chat with you.`]; + } else if (vm.inviter) { + inviterNodes = [renderStaticAvatar(vm.inviter, 24), t.strong(vm.inviter.name), ` (${vm.inviter.id}) invited you.`]; + } else { + inviterNodes = `You were invited to join.`; + } + inviteNodes.push(t.p({className: "InviteView_inviter"}, inviterNodes)); + if (!vm.isDirectMessage) { + inviteNodes.push(t.div({className: "InviteView_roomProfile"}, [ + renderStaticAvatar(vm, 64, "InviteView_roomAvatar"), + t.h3(vm.name), + t.p({className: "InviteView_roomDescription"}, vm.roomDescription) + ])); + } + return t.main({className: "InviteView middle"}, [ - t.div({className: "TimelinePanel"}, [ - t.div({className: "RoomHeader middle-header"}, [ - t.a({className: "button-utility close-middle", href: vm.closeUrl, title: vm.i18n`Close invite`}), - renderStaticAvatar(vm, 32), - t.div({className: "room-description"}, [ - t.h2(vm => vm.name), - ]), + t.div({className: "RoomHeader middle-header"}, [ + t.a({className: "button-utility close-middle", href: vm.closeUrl, title: vm.i18n`Close invite`}), + renderStaticAvatar(vm, 32), + t.div({className: "room-description"}, [ + t.h2(vm => vm.name), ]), - t.div({className: "RoomView_error"}, vm => vm.error), - t.div([ - t.p(`You were invited into this room!`), - t.p(t.button({onClick: () => vm.accept()}, vm.i18n`Accept`)), - t.p(t.button({onClick: () => vm.reject()}, vm.i18n`Reject`)), + ]), + t.if(vm => vm.error, t => t.div({className: "RoomView_error"}, vm => vm.error)), + t.div({className: "InviteView_body"}, [ + t.div({className: "InviteView_invite"}, [ + ...inviteNodes, + t.div({className: "InviteView_buttonRow"}, + t.button({ + className: "button-action primary", + disabled: vm => vm.busy, + onClick: () => vm.accept() + }, vm.i18n`Accept`) + ), + t.div({className: "InviteView_buttonRow"}, + t.button({ + className: "button-action primary destructive", + disabled: vm => vm.busy, + onClick: () => vm.reject() + }, vm.i18n`Reject`) + ), ]) ]) ]); From 396ec4dfd96ff545d684b2cbdbe1cf3c28ad4ddc Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 15:33:12 +0200 Subject: [PATCH 68/74] also in grid remove room id from nav path when refreshing room vm fails --- src/domain/session/RoomGridViewModel.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/domain/session/RoomGridViewModel.js b/src/domain/session/RoomGridViewModel.js index 05c55041..ce31e22c 100644 --- a/src/domain/session/RoomGridViewModel.js +++ b/src/domain/session/RoomGridViewModel.js @@ -15,6 +15,7 @@ limitations under the License. */ import {ViewModel} from "../ViewModel.js"; +import {removeRoomFromPath} from "../navigation/index.js"; function dedupeSparse(roomIds) { return roomIds.map((id, idx) => { @@ -77,6 +78,9 @@ export class RoomGridViewModel extends ViewModel { if (this.focusIndex === index) { roomVM.focus(); } + } else { + // close room id + this.navigation.applyPath(removeRoomFromPath(this.navigation.path, roomId)); } this.emitChange(); } From 025320b83e9b0c245003bb3ef0e77e5fbd536570 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 16:15:20 +0200 Subject: [PATCH 69/74] fix filtered map --- src/observable/map/FilteredMap.js | 70 +++++++++++++++++-------------- 1 file changed, 39 insertions(+), 31 deletions(-) diff --git a/src/observable/map/FilteredMap.js b/src/observable/map/FilteredMap.js index 290dcea6..71b7bbeb 100644 --- a/src/observable/map/FilteredMap.js +++ b/src/observable/map/FilteredMap.js @@ -28,26 +28,29 @@ export class FilteredMap extends BaseObservableMap { setFilter(filter) { this._filter = filter; - this.update(); + if (this._subscription) { + this._reapplyFilter(); + } } /** * reapply the filter */ - update() { - // TODO: need to check if we have a subscriber already? If not, we really should not iterate the source? + _reapplyFilter(silent = false) { if (this._filter) { - const hadFilterBefore = !!this._included; + const oldIncluded = this._included; this._included = this._included || new Map(); for (const [key, value] of this._source) { const isIncluded = this._filter(value, key); - const wasIncluded = hadFilterBefore ? this._included.get(key) : true; this._included.set(key, isIncluded); - this._emitForUpdate(wasIncluded, isIncluded, key, value); + if (!silent) { + const wasIncluded = oldIncluded ? oldIncluded.get(key) : true; + this._emitForUpdate(wasIncluded, isIncluded, key, value); + } } } else { // no filter // did we have a filter before? - if (this._included) { + if (this._included && !silent) { // add any non-included items again for (const [key, value] of this._source) { if (!this._included.get(key)) { @@ -100,7 +103,7 @@ export class FilteredMap extends BaseObservableMap { onSubscribeFirst() { this._subscription = this._source.subscribe(this); - this.update(); + this._reapplyFilter(true); super.onSubscribeFirst(); } @@ -111,7 +114,7 @@ export class FilteredMap extends BaseObservableMap { } onReset() { - this.update(); + this._reapplyFilter(); this.emitReset(); } @@ -140,7 +143,7 @@ export class FilteredMap extends BaseObservableMap { class FilterIterator { constructor(map, _included) { this._included = _included; - this._sourceIterator = map.entries(); + this._sourceIterator = map[Symbol.iterator](); } next() { @@ -150,7 +153,7 @@ class FilterIterator { if (sourceResult.done) { return sourceResult; } - const key = sourceResult.value[1]; + const key = sourceResult.value[0]; if (this._included.get(key)) { return sourceResult; } @@ -158,26 +161,31 @@ class FilterIterator { } } -// import {ObservableMap} from "./ObservableMap.js"; -// export function tests() { -// return { -// "filter preloaded list": assert => { -// const source = new ObservableMap(); -// source.add("one", 1); -// source.add("two", 2); -// source.add("three", 3); -// const odds = Array.from(new FilteredMap(source, x => x % 2 !== 0)); -// assert.equal(odds.length, 2); +import {ObservableMap} from "./ObservableMap.js"; +export function tests() { + return { + "filter preloaded list": assert => { + const source = new ObservableMap(); + source.add("one", 1); + source.add("two", 2); + source.add("three", 3); + const oddNumbers = new FilteredMap(source, x => x % 2 !== 0); + // can only iterate after subscribing + oddNumbers.subscribe({}); + assert.equal(oddNumbers.size, 2); + const it = oddNumbers[Symbol.iterator](); + assert.deepEqual(it.next().value, ["one", 1]); + assert.deepEqual(it.next().value, ["three", 3]); + assert.equal(it.next().done, true); + }, + // "filter added values": assert => { -// }, -// "filter added values": assert => { + // }, + // "filter removed values": assert => { -// }, -// "filter removed values": assert => { + // }, + // "filter changed values": assert => { -// }, -// "filter changed values": assert => { - -// }, -// } -// } + // }, + } +} From 1c5b953026f8676ed00fe8c4fdc1faeef0512f0d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 16:16:07 +0200 Subject: [PATCH 70/74] hide non-joined rooms in left panel for now until we support archiving/forgetting the room --- src/domain/session/leftpanel/LeftPanelViewModel.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/domain/session/leftpanel/LeftPanelViewModel.js b/src/domain/session/leftpanel/LeftPanelViewModel.js index a1a577a9..dd9c89ac 100644 --- a/src/domain/session/leftpanel/LeftPanelViewModel.js +++ b/src/domain/session/leftpanel/LeftPanelViewModel.js @@ -35,8 +35,9 @@ export class LeftPanelViewModel extends ViewModel { } _mapTileViewModels(rooms, invites) { + const joinedRooms = rooms.filterValues(room => room.membership === "join"); // join is not commutative, invites will take precedence over rooms - return invites.join(rooms).mapValues((roomOrInvite, emitChange) => { + return invites.join(joinedRooms).mapValues((roomOrInvite, emitChange) => { const isOpen = this.navigation.path.get("room")?.value === roomOrInvite.id; let vm; if (roomOrInvite.isInvite) { From ef6f10c5a2c7636cb598c1027cfad4d179906918 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 28 Apr 2021 09:53:44 +0200 Subject: [PATCH 71/74] test for Path.replace --- src/domain/navigation/Navigation.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/domain/navigation/Navigation.js b/src/domain/navigation/Navigation.js index 9d059ec9..3167475f 100644 --- a/src/domain/navigation/Navigation.js +++ b/src/domain/navigation/Navigation.js @@ -245,6 +245,17 @@ export function tests() { const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true); assert.equal(path.get("foo").value, 5); assert.equal(path.get("bar").value, 6); + }, + "path.replace success": assert => { + const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true); + const newPath = path.replace(new Segment("foo", 1)); + assert.equal(newPath.get("foo").value, 1); + assert.equal(newPath.get("bar").value, 6); + }, + "path.replace not found": assert => { + const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true); + const newPath = path.replace(new Segment("baz", 1)); + assert.equal(newPath, null); } }; } From 15dfb6c20244cbb8edb459f5d13343f73757def1 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 28 Apr 2021 10:05:21 +0200 Subject: [PATCH 72/74] clarify --- src/domain/session/room/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/room/README.md b/src/domain/session/room/README.md index 28fa7173..adb673eb 100644 --- a/src/domain/session/room/README.md +++ b/src/domain/session/room/README.md @@ -6,4 +6,4 @@ InviteViewModel and RoomViewModel are interchangebly used as "room view model": This is because they are accessed by the same url and need to transition into each other, in these two locations. Having two methods, especially in RoomGridViewModel would have been more cumbersome, even though this is not in line with how different view models are exposed in SessionViewModel. -They share an `id` and `kind` property, the latter can be used to differentiate them from the view. +They share an `id` and `kind` property, the latter can be used to differentiate them from the view, and a `focus` method. From e85087401960f961f328bcce64f92d6e1df8b589 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 28 Apr 2021 10:05:34 +0200 Subject: [PATCH 73/74] clarify order --- src/matrix/Sync.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index 9265d894..d018900e 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -291,6 +291,9 @@ export class Sync { for(let rs of roomStates) { log.wrap("room", log => rs.room.afterSync(rs.changes, log), log.level.Detail); if (rs.isNewRoom) { + // important to add the room before removing the invite, + // so the room will be found if looking for it when the invite + // is removed this._session.addRoomAfterSync(rs.room); } } From b23ec5ef702216d11f40c7e7e9fba1c93eea0ec3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 28 Apr 2021 10:08:11 +0200 Subject: [PATCH 74/74] remove obsolete comment --- src/matrix/Sync.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index d018900e..cf31f3c2 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -384,7 +384,6 @@ export class Sync { isNewInvite = true; } const room = this._session.rooms.get(roomId); - // TODO let the room know there is an invite now, so inviteStates.push(new InviteSyncProcessState(invite, isNewInvite, room, "invite", roomResponse)); } }