From 6c307d4c63e310fa824a030aacb99d2834374202 Mon Sep 17 00:00:00 2001 From: maheichyk Date: Wed, 6 Sep 2023 20:10:14 +0300 Subject: [PATCH] Sync knock rooms (#3703) Signed-off-by: Mikhail Aheichyk Co-authored-by: Mikhail Aheichyk --- spec/integ/matrix-client-syncing.spec.ts | 156 ++++++++++++++++++ ...matrix-client-unread-notifications.spec.ts | 1 + spec/test-utils/test-utils.ts | 1 + spec/unit/sync-accumulator.spec.ts | 82 ++++++++- src/models/room.ts | 2 +- src/sync-accumulator.ts | 55 ++++++ src/sync.ts | 27 ++- 7 files changed, 320 insertions(+), 4 deletions(-) diff --git a/spec/integ/matrix-client-syncing.spec.ts b/spec/integ/matrix-client-syncing.spec.ts index 10bb7fc3e..9a7db5fa5 100644 --- a/spec/integ/matrix-client-syncing.spec.ts +++ b/spec/integ/matrix-client-syncing.spec.ts @@ -223,6 +223,121 @@ describe("MatrixClient syncing", () => { expect(fires).toBe(3); }); + it("should emit RoomEvent.MyMembership for knock->leave->knock cycles", async () => { + await client!.initCrypto(); + + const roomId = "!cycles:example.org"; + + // First sync: an knock + const knockSyncRoomSection = { + knock: { + [roomId]: { + knock_state: { + events: [ + { + type: "m.room.member", + state_key: selfUserId, + content: { + membership: "knock", + }, + }, + ], + }, + }, + }, + }; + httpBackend!.when("GET", "/sync").respond(200, { + ...syncData, + rooms: knockSyncRoomSection, + }); + + // Second sync: a leave (reject of some kind) + httpBackend!.when("POST", "/leave").respond(200, {}); + httpBackend!.when("GET", "/sync").respond(200, { + ...syncData, + rooms: { + leave: { + [roomId]: { + account_data: { events: [] }, + ephemeral: { events: [] }, + state: { + events: [ + { + type: "m.room.member", + state_key: selfUserId, + content: { + membership: "leave", + }, + prev_content: { + membership: "knock", + }, + // XXX: And other fields required on an event + }, + ], + }, + timeline: { + limited: false, + events: [ + { + type: "m.room.member", + state_key: selfUserId, + content: { + membership: "leave", + }, + prev_content: { + membership: "knock", + }, + // XXX: And other fields required on an event + }, + ], + }, + }, + }, + }, + }); + + // Third sync: another knock + httpBackend!.when("GET", "/sync").respond(200, { + ...syncData, + rooms: knockSyncRoomSection, + }); + + // First fire: an initial knock + let fires = 0; + client!.once(RoomEvent.MyMembership, (room, membership, oldMembership) => { + // Room, string, string + fires++; + expect(room.roomId).toBe(roomId); + expect(membership).toBe("knock"); + expect(oldMembership).toBeFalsy(); + + // Second fire: a leave + client!.once(RoomEvent.MyMembership, (room, membership, oldMembership) => { + fires++; + expect(room.roomId).toBe(roomId); + expect(membership).toBe("leave"); + expect(oldMembership).toBe("knock"); + + // Third/final fire: a second knock + client!.once(RoomEvent.MyMembership, (room, membership, oldMembership) => { + fires++; + expect(room.roomId).toBe(roomId); + expect(membership).toBe("knock"); + expect(oldMembership).toBe("leave"); + }); + }); + + // For maximum safety, "leave" the room after we register the handler + client!.leave(roomId); + }); + + // noinspection ES6MissingAwait + client!.startClient(); + await httpBackend!.flushAllExpected(); + + expect(fires).toBe(3); + }); + it("should honour lazyLoadMembers if user is not a guest", () => { httpBackend! .when("GET", "/sync") @@ -293,6 +408,46 @@ describe("MatrixClient syncing", () => { expect(fires).toBe(1); }); + it("should emit ClientEvent.Room when knocked while crypto is disabled", async () => { + const roomId = "!knock:example.org"; + + // First sync: a knock + const knockSyncRoomSection = { + knock: { + [roomId]: { + knock_state: { + events: [ + { + type: "m.room.member", + state_key: selfUserId, + content: { + membership: "knock", + }, + }, + ], + }, + }, + }, + }; + httpBackend!.when("GET", "/sync").respond(200, { + ...syncData, + rooms: knockSyncRoomSection, + }); + + // First fire: an initial knock + let fires = 0; + client!.once(ClientEvent.Room, (room) => { + fires++; + expect(room.roomId).toBe(roomId); + }); + + // noinspection ES6MissingAwait + client!.startClient(); + await httpBackend!.flushAllExpected(); + + expect(fires).toBe(1); + }); + it("should work when all network calls fail", async () => { httpBackend!.expectedRequests = []; httpBackend!.when("GET", "").fail(0, new Error("CORS or something")); @@ -358,6 +513,7 @@ describe("MatrixClient syncing", () => { join: {}, invite: {}, leave: {}, + knock: {}, }, }; diff --git a/spec/integ/matrix-client-unread-notifications.spec.ts b/spec/integ/matrix-client-unread-notifications.spec.ts index 884b897ed..95f977820 100644 --- a/spec/integ/matrix-client-unread-notifications.spec.ts +++ b/spec/integ/matrix-client-unread-notifications.spec.ts @@ -376,6 +376,7 @@ describe("MatrixClient syncing", () => { }, [Category.Leave]: {}, [Category.Invite]: {}, + [Category.Knock]: {}, }, }; } diff --git a/spec/test-utils/test-utils.ts b/spec/test-utils/test-utils.ts index 9fe48528a..cb154e6ec 100644 --- a/spec/test-utils/test-utils.ts +++ b/spec/test-utils/test-utils.ts @@ -99,6 +99,7 @@ export function getSyncResponse(roomMembers: string[], roomId = TEST_ROOM_ID): I join: { [roomId]: roomResponse }, invite: {}, leave: {}, + knock: {}, }, account_data: { events: [] }, }; diff --git a/spec/unit/sync-accumulator.spec.ts b/spec/unit/sync-accumulator.spec.ts index e257aa960..1287cd98f 100644 --- a/spec/unit/sync-accumulator.spec.ts +++ b/spec/unit/sync-accumulator.spec.ts @@ -16,8 +16,9 @@ limitations under the License. */ import { ReceiptType } from "../../src/@types/read_receipts"; -import { IJoinedRoom, ISyncResponse, SyncAccumulator } from "../../src/sync-accumulator"; +import { IJoinedRoom, IKnockedRoom, IStrippedState, ISyncResponse, SyncAccumulator } from "../../src/sync-accumulator"; import { IRoomSummary } from "../../src"; +import * as utils from "../test-utils/test-utils"; // The event body & unsigned object get frozen to assert that they don't get altered // by the impl @@ -95,6 +96,13 @@ describe("SyncAccumulator", function () { }, }, }, + knock: { + "!knock": { + knock_state: { + events: [member("alice", "knock")], + }, + }, + }, }, } as unknown as ISyncResponse; sa.accumulate(res); @@ -287,6 +295,71 @@ describe("SyncAccumulator", function () { expect(sa.getJSON().accountData[0]).toEqual(acc2); }); + it("should accumulate knock state", () => { + const initKnockState = { + events: [member("alice", "knock")], + }; + sa.accumulate( + syncSkeleton( + {}, + { + knock_state: initKnockState, + }, + ), + ); + expect(sa.getJSON().roomsData.knock["!knock:bar"].knock_state).toBe(initKnockState); + + sa.accumulate( + syncSkeleton( + {}, + { + knock_state: { + events: [ + utils.mkEvent({ + user: "alice", + room: "!knock:bar", + type: "m.room.name", + content: { + name: "Room 1", + }, + }) as IStrippedState, + ], + }, + }, + ), + ); + + expect( + sa.getJSON().roomsData.knock["!knock:bar"].knock_state.events.find((e) => e.type === "m.room.name")?.content + .name, + ).toEqual("Room 1"); + + sa.accumulate( + syncSkeleton( + {}, + { + knock_state: { + events: [ + utils.mkEvent({ + user: "alice", + room: "!knock:bar", + type: "m.room.name", + content: { + name: "Room 2", + }, + }) as IStrippedState, + ], + }, + }, + ), + ); + + expect( + sa.getJSON().roomsData.knock["!knock:bar"].knock_state.events.find((e) => e.type === "m.room.name")?.content + .name, + ).toEqual("Room 2"); + }); + it("should accumulate read receipts", () => { const receipt1 = { type: "m.receipt", @@ -601,7 +674,7 @@ describe("SyncAccumulator", function () { }); }); -function syncSkeleton(joinObj: Partial): ISyncResponse { +function syncSkeleton(joinObj: Partial, knockObj?: Partial): ISyncResponse { joinObj = joinObj || {}; return { next_batch: "abc", @@ -609,6 +682,11 @@ function syncSkeleton(joinObj: Partial): ISyncResponse { join: { "!foo:bar": joinObj, }, + knock: knockObj + ? { + "!knock:bar": knockObj, + } + : undefined, }, } as unknown as ISyncResponse; } diff --git a/src/models/room.ts b/src/models/room.ts index b87a99404..472a6d6cd 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -849,7 +849,7 @@ export class Room extends ReadReceipt { } /** - * @returns the membership type (join | leave | invite) for the logged in user + * @returns the membership type (join | leave | invite | knock) for the logged in user */ public getMyMembership(): string { return this.selfMembership ?? "leave"; diff --git a/src/sync-accumulator.ts b/src/sync-accumulator.ts index e25ace530..5f4f452ec 100644 --- a/src/sync-accumulator.ts +++ b/src/sync-accumulator.ts @@ -99,6 +99,10 @@ export interface IInviteState { events: IStrippedState[]; } +export interface IKnockState { + events: IStrippedState[]; +} + export interface IInvitedRoom { invite_state: IInviteState; } @@ -109,10 +113,15 @@ export interface ILeftRoom { account_data: IAccountData; } +export interface IKnockedRoom { + knock_state: IKnockState; +} + export interface IRooms { [Category.Join]: Record; [Category.Invite]: Record; [Category.Leave]: Record; + [Category.Knock]: Record; } interface IPresence { @@ -156,6 +165,7 @@ export enum Category { Invite = "invite", Leave = "leave", Join = "join", + Knock = "knock", } interface IRoom { @@ -196,6 +206,7 @@ function isTaggedEvent(event: IRoomEvent): event is TaggedEvent { export class SyncAccumulator { private accountData: Record = {}; // $event_type: Object private inviteRooms: Record = {}; // $roomId: { ... sync 'invite' json data ... } + private knockRooms: Record = {}; // $roomId: { ... sync 'knock' json data ... } private joinRooms: { [roomId: string]: IRoom } = {}; // the /sync token which corresponds to the last time rooms were // accumulated. We remember this so that any caller can obtain a @@ -247,11 +258,17 @@ export class SyncAccumulator { this.accumulateRoom(roomId, Category.Leave, syncResponse.rooms.leave[roomId], fromDatabase); }); } + if (syncResponse.rooms.knock) { + Object.keys(syncResponse.rooms.knock).forEach((roomId) => { + this.accumulateRoom(roomId, Category.Knock, syncResponse.rooms.knock[roomId], fromDatabase); + }); + } } private accumulateRoom(roomId: string, category: Category.Invite, data: IInvitedRoom, fromDatabase: boolean): void; private accumulateRoom(roomId: string, category: Category.Join, data: IJoinedRoom, fromDatabase: boolean): void; private accumulateRoom(roomId: string, category: Category.Leave, data: ILeftRoom, fromDatabase: boolean): void; + private accumulateRoom(roomId: string, category: Category.Knock, data: IKnockedRoom, fromDatabase: boolean): void; private accumulateRoom(roomId: string, category: Category, data: any, fromDatabase = false): void { // Valid /sync state transitions // +--------+ <======+ 1: Accept an invite @@ -269,6 +286,10 @@ export class SyncAccumulator { this.accumulateInviteState(roomId, data as IInvitedRoom); break; + case Category.Knock: + this.accumulateKnockState(roomId, data as IKnockedRoom); + break; + case Category.Join: if (this.inviteRooms[roomId]) { // (1) @@ -326,6 +347,36 @@ export class SyncAccumulator { }); } + private accumulateKnockState(roomId: string, data: IKnockedRoom): void { + if (!data.knock_state || !data.knock_state.events) { + // no new data + return; + } + if (!this.knockRooms[roomId]) { + this.knockRooms[roomId] = { + knock_state: data.knock_state, + }; + return; + } + // accumulate extra keys + // clobber based on event type / state key + // We expect knock_state to be small, so just loop over the events + const currentData = this.knockRooms[roomId]; + data.knock_state.events.forEach((e) => { + let hasAdded = false; + for (let i = 0; i < currentData.knock_state.events.length; i++) { + const current = currentData.knock_state.events[i]; + if (current.type === e.type && current.state_key == e.state_key) { + currentData.knock_state.events[i] = e; // update + hasAdded = true; + } + } + if (!hasAdded) { + currentData.knock_state.events.push(e); + } + }); + } + // Accumulate timeline and state events in a room. private accumulateJoinState(roomId: string, data: IJoinedRoom, fromDatabase = false): void { // We expect this function to be called a lot (every /sync) so we want @@ -485,6 +536,7 @@ export class SyncAccumulator { const data: IRooms = { join: {}, invite: {}, + knock: {}, // always empty. This is set by /sync when a room was previously // in 'invite' or 'join'. On fresh startup, the client won't know // about any previous room being in 'invite' or 'join' so we can @@ -501,6 +553,9 @@ export class SyncAccumulator { Object.keys(this.inviteRooms).forEach((roomId) => { data.invite[roomId] = this.inviteRooms[roomId]; }); + Object.keys(this.knockRooms).forEach((roomId) => { + data.knock[roomId] = this.knockRooms[roomId]; + }); Object.keys(this.joinRooms).forEach((roomId) => { const roomData = this.joinRooms[roomId]; const roomJson: IJoinedRoom = { diff --git a/src/sync.ts b/src/sync.ts index 8e8fde219..be13b0cce 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -40,6 +40,7 @@ import { IInviteState, IJoinedRoom, ILeftRoom, + IKnockedRoom, IMinimalEvent, IRoomEvent, IStateEvent, @@ -1240,6 +1241,7 @@ export class SyncApi { let inviteRooms: WrappedRoom[] = []; let joinRooms: WrappedRoom[] = []; let leaveRooms: WrappedRoom[] = []; + let knockRooms: WrappedRoom[] = []; if (data.rooms) { if (data.rooms.invite) { @@ -1251,6 +1253,9 @@ export class SyncApi { if (data.rooms.leave) { leaveRooms = this.mapSyncResponseToRoomArray(data.rooms.leave); } + if (data.rooms.knock) { + knockRooms = this.mapSyncResponseToRoomArray(data.rooms.knock); + } } this.notifEvents = []; @@ -1511,6 +1516,26 @@ export class SyncApi { }); }); + // Handle knocks + await promiseMapSeries(knockRooms, async (knockObj) => { + const room = knockObj.room; + const stateEvents = this.mapSyncEventsFormat(knockObj.knock_state, room); + + await this.injectRoomEvents(room, stateEvents); + + if (knockObj.isBrandNewRoom) { + room.recalculate(); + client.store.storeRoom(room); + client.emit(ClientEvent.Room, room); + } else { + // Update room state for knock->leave->knock cycles + room.recalculate(); + } + stateEvents.forEach(function (e) { + client.emit(ClientEvent.Event, e); + }); + }); + // update the notification timeline, if appropriate. // we only do this for live events, as otherwise we can't order them sanely // in the timeline relative to ones paginated in by /notifications. @@ -1629,7 +1654,7 @@ export class SyncApi { ); } - private mapSyncResponseToRoomArray( + private mapSyncResponseToRoomArray( obj: Record, ): Array> { // Maps { roomid: {stuff}, roomid: {stuff} }