1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-07-30 04:23:07 +03:00

Sync knock rooms (#3703)

Signed-off-by: Mikhail Aheichyk <mikhail.aheichyk@nordeck.net>
Co-authored-by: Mikhail Aheichyk <mikhail.aheichyk@nordeck.net>
This commit is contained in:
maheichyk
2023-09-06 20:10:14 +03:00
committed by GitHub
parent 88ec0e3e17
commit 6c307d4c63
7 changed files with 320 additions and 4 deletions

View File

@ -223,6 +223,121 @@ describe("MatrixClient syncing", () => {
expect(fires).toBe(3); 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", () => { it("should honour lazyLoadMembers if user is not a guest", () => {
httpBackend! httpBackend!
.when("GET", "/sync") .when("GET", "/sync")
@ -293,6 +408,46 @@ describe("MatrixClient syncing", () => {
expect(fires).toBe(1); 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 () => { it("should work when all network calls fail", async () => {
httpBackend!.expectedRequests = []; httpBackend!.expectedRequests = [];
httpBackend!.when("GET", "").fail(0, new Error("CORS or something")); httpBackend!.when("GET", "").fail(0, new Error("CORS or something"));
@ -358,6 +513,7 @@ describe("MatrixClient syncing", () => {
join: {}, join: {},
invite: {}, invite: {},
leave: {}, leave: {},
knock: {},
}, },
}; };

View File

@ -376,6 +376,7 @@ describe("MatrixClient syncing", () => {
}, },
[Category.Leave]: {}, [Category.Leave]: {},
[Category.Invite]: {}, [Category.Invite]: {},
[Category.Knock]: {},
}, },
}; };
} }

View File

@ -99,6 +99,7 @@ export function getSyncResponse(roomMembers: string[], roomId = TEST_ROOM_ID): I
join: { [roomId]: roomResponse }, join: { [roomId]: roomResponse },
invite: {}, invite: {},
leave: {}, leave: {},
knock: {},
}, },
account_data: { events: [] }, account_data: { events: [] },
}; };

View File

@ -16,8 +16,9 @@ limitations under the License.
*/ */
import { ReceiptType } from "../../src/@types/read_receipts"; 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 { 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 // The event body & unsigned object get frozen to assert that they don't get altered
// by the impl // by the impl
@ -95,6 +96,13 @@ describe("SyncAccumulator", function () {
}, },
}, },
}, },
knock: {
"!knock": {
knock_state: {
events: [member("alice", "knock")],
},
},
},
}, },
} as unknown as ISyncResponse; } as unknown as ISyncResponse;
sa.accumulate(res); sa.accumulate(res);
@ -287,6 +295,71 @@ describe("SyncAccumulator", function () {
expect(sa.getJSON().accountData[0]).toEqual(acc2); 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", () => { it("should accumulate read receipts", () => {
const receipt1 = { const receipt1 = {
type: "m.receipt", type: "m.receipt",
@ -601,7 +674,7 @@ describe("SyncAccumulator", function () {
}); });
}); });
function syncSkeleton(joinObj: Partial<IJoinedRoom>): ISyncResponse { function syncSkeleton(joinObj: Partial<IJoinedRoom>, knockObj?: Partial<IKnockedRoom>): ISyncResponse {
joinObj = joinObj || {}; joinObj = joinObj || {};
return { return {
next_batch: "abc", next_batch: "abc",
@ -609,6 +682,11 @@ function syncSkeleton(joinObj: Partial<IJoinedRoom>): ISyncResponse {
join: { join: {
"!foo:bar": joinObj, "!foo:bar": joinObj,
}, },
knock: knockObj
? {
"!knock:bar": knockObj,
}
: undefined,
}, },
} as unknown as ISyncResponse; } as unknown as ISyncResponse;
} }

View File

@ -849,7 +849,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
} }
/** /**
* @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 { public getMyMembership(): string {
return this.selfMembership ?? "leave"; return this.selfMembership ?? "leave";

View File

@ -99,6 +99,10 @@ export interface IInviteState {
events: IStrippedState[]; events: IStrippedState[];
} }
export interface IKnockState {
events: IStrippedState[];
}
export interface IInvitedRoom { export interface IInvitedRoom {
invite_state: IInviteState; invite_state: IInviteState;
} }
@ -109,10 +113,15 @@ export interface ILeftRoom {
account_data: IAccountData; account_data: IAccountData;
} }
export interface IKnockedRoom {
knock_state: IKnockState;
}
export interface IRooms { export interface IRooms {
[Category.Join]: Record<string, IJoinedRoom>; [Category.Join]: Record<string, IJoinedRoom>;
[Category.Invite]: Record<string, IInvitedRoom>; [Category.Invite]: Record<string, IInvitedRoom>;
[Category.Leave]: Record<string, ILeftRoom>; [Category.Leave]: Record<string, ILeftRoom>;
[Category.Knock]: Record<string, IKnockedRoom>;
} }
interface IPresence { interface IPresence {
@ -156,6 +165,7 @@ export enum Category {
Invite = "invite", Invite = "invite",
Leave = "leave", Leave = "leave",
Join = "join", Join = "join",
Knock = "knock",
} }
interface IRoom { interface IRoom {
@ -196,6 +206,7 @@ function isTaggedEvent(event: IRoomEvent): event is TaggedEvent {
export class SyncAccumulator { export class SyncAccumulator {
private accountData: Record<string, IMinimalEvent> = {}; // $event_type: Object private accountData: Record<string, IMinimalEvent> = {}; // $event_type: Object
private inviteRooms: Record<string, IInvitedRoom> = {}; // $roomId: { ... sync 'invite' json data ... } private inviteRooms: Record<string, IInvitedRoom> = {}; // $roomId: { ... sync 'invite' json data ... }
private knockRooms: Record<string, IKnockedRoom> = {}; // $roomId: { ... sync 'knock' json data ... }
private joinRooms: { [roomId: string]: IRoom } = {}; private joinRooms: { [roomId: string]: IRoom } = {};
// the /sync token which corresponds to the last time rooms were // the /sync token which corresponds to the last time rooms were
// accumulated. We remember this so that any caller can obtain a // 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); 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.Invite, data: IInvitedRoom, fromDatabase: boolean): void;
private accumulateRoom(roomId: string, category: Category.Join, data: IJoinedRoom, 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.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 { private accumulateRoom(roomId: string, category: Category, data: any, fromDatabase = false): void {
// Valid /sync state transitions // Valid /sync state transitions
// +--------+ <======+ 1: Accept an invite // +--------+ <======+ 1: Accept an invite
@ -269,6 +286,10 @@ export class SyncAccumulator {
this.accumulateInviteState(roomId, data as IInvitedRoom); this.accumulateInviteState(roomId, data as IInvitedRoom);
break; break;
case Category.Knock:
this.accumulateKnockState(roomId, data as IKnockedRoom);
break;
case Category.Join: case Category.Join:
if (this.inviteRooms[roomId]) { if (this.inviteRooms[roomId]) {
// (1) // (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. // Accumulate timeline and state events in a room.
private accumulateJoinState(roomId: string, data: IJoinedRoom, fromDatabase = false): void { private accumulateJoinState(roomId: string, data: IJoinedRoom, fromDatabase = false): void {
// We expect this function to be called a lot (every /sync) so we want // We expect this function to be called a lot (every /sync) so we want
@ -485,6 +536,7 @@ export class SyncAccumulator {
const data: IRooms = { const data: IRooms = {
join: {}, join: {},
invite: {}, invite: {},
knock: {},
// always empty. This is set by /sync when a room was previously // always empty. This is set by /sync when a room was previously
// in 'invite' or 'join'. On fresh startup, the client won't know // in 'invite' or 'join'. On fresh startup, the client won't know
// about any previous room being in 'invite' or 'join' so we can // 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) => { Object.keys(this.inviteRooms).forEach((roomId) => {
data.invite[roomId] = this.inviteRooms[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) => { Object.keys(this.joinRooms).forEach((roomId) => {
const roomData = this.joinRooms[roomId]; const roomData = this.joinRooms[roomId];
const roomJson: IJoinedRoom = { const roomJson: IJoinedRoom = {

View File

@ -40,6 +40,7 @@ import {
IInviteState, IInviteState,
IJoinedRoom, IJoinedRoom,
ILeftRoom, ILeftRoom,
IKnockedRoom,
IMinimalEvent, IMinimalEvent,
IRoomEvent, IRoomEvent,
IStateEvent, IStateEvent,
@ -1240,6 +1241,7 @@ export class SyncApi {
let inviteRooms: WrappedRoom<IInvitedRoom>[] = []; let inviteRooms: WrappedRoom<IInvitedRoom>[] = [];
let joinRooms: WrappedRoom<IJoinedRoom>[] = []; let joinRooms: WrappedRoom<IJoinedRoom>[] = [];
let leaveRooms: WrappedRoom<ILeftRoom>[] = []; let leaveRooms: WrappedRoom<ILeftRoom>[] = [];
let knockRooms: WrappedRoom<IKnockedRoom>[] = [];
if (data.rooms) { if (data.rooms) {
if (data.rooms.invite) { if (data.rooms.invite) {
@ -1251,6 +1253,9 @@ export class SyncApi {
if (data.rooms.leave) { if (data.rooms.leave) {
leaveRooms = this.mapSyncResponseToRoomArray(data.rooms.leave); leaveRooms = this.mapSyncResponseToRoomArray(data.rooms.leave);
} }
if (data.rooms.knock) {
knockRooms = this.mapSyncResponseToRoomArray(data.rooms.knock);
}
} }
this.notifEvents = []; 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. // update the notification timeline, if appropriate.
// we only do this for live events, as otherwise we can't order them sanely // 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. // in the timeline relative to ones paginated in by /notifications.
@ -1629,7 +1654,7 @@ export class SyncApi {
); );
} }
private mapSyncResponseToRoomArray<T extends ILeftRoom | IJoinedRoom | IInvitedRoom>( private mapSyncResponseToRoomArray<T extends ILeftRoom | IJoinedRoom | IInvitedRoom | IKnockedRoom>(
obj: Record<string, T>, obj: Record<string, T>,
): Array<WrappedRoom<T>> { ): Array<WrappedRoom<T>> {
// Maps { roomid: {stuff}, roomid: {stuff} } // Maps { roomid: {stuff}, roomid: {stuff} }