Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for break-out rooms #3753

Draft
wants to merge 12 commits into
base: develop
Choose a base branch
from
2 changes: 1 addition & 1 deletion spec/unit/sync-accumulator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {
SyncAccumulator,
IInviteState,
} from "../../src/sync-accumulator";
import { IRoomSummary } from "../../src";
import { IRoomSummary } from "../../src/models/room-summary";
import * as utils from "../test-utils/test-utils";

// The event body & unsigned object get frozen to assert that they don't get altered
Expand Down
39 changes: 39 additions & 0 deletions src/@types/breakout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
Copyright 2023 Šimon Brandner <[email protected]>

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 { IRoomSummaryAPIResponse } from "../client";

export interface BreakoutEventContentRoom {
via: string[];
users: string[];
}

export interface BreakoutEventContentRooms {
[key: string]: BreakoutEventContentRoom;
}

export interface BreakoutEventContent {
"m.breakout": BreakoutEventContentRooms;
}

export interface BreakoutRoom {
users: string[];
roomId: string;
}

export interface BreakoutRoomWithSummary extends BreakoutRoom {
roomSummary: IRoomSummaryAPIResponse;
}
3 changes: 3 additions & 0 deletions src/@types/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ export enum EventType {
Reaction = "m.reaction",
PollStart = "org.matrix.msc3381.poll.start",

Breakout = "m.breakout",
PrefixedBreakout = "org.matrix.msc3985.breakout",

// Room ephemeral events
Typing = "m.typing",
Receipt = "m.receipt",
Expand Down
25 changes: 23 additions & 2 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ import {
} from "./secret-storage";
import { RegisterRequest, RegisterResponse } from "./@types/registration";
import { MatrixRTCSessionManager } from "./matrixrtc/MatrixRTCSessionManager";
import { BreakoutEventContentRooms, BreakoutRoom } from "./@types/breakout";
import { getRelationsThreadFilter } from "./thread-utils";

export type Store = IStore;
Expand Down Expand Up @@ -875,7 +876,7 @@ interface IThirdPartyUser {
fields: object;
}

interface IRoomSummary extends Omit<IPublicRoomsChunkRoom, "canonical_alias" | "aliases"> {
export interface IRoomSummaryAPIResponse extends Omit<IPublicRoomsChunkRoom, "canonical_alias" | "aliases"> {
room_type?: RoomType;
membership?: string;
is_encrypted: boolean;
Expand Down Expand Up @@ -4631,6 +4632,26 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return this.sendStateEvent(roomId, M_BEACON_INFO.name, beaconInfoContent, this.getUserId()!);
}

public async createBreakoutRooms(parentRoomId: string, rooms: BreakoutRoom[]): Promise<ISendEventResponse> {
if (rooms.length === 0) {
throw new Error("Called with an empty array of rooms");
}

const breakoutContentRooms: BreakoutEventContentRooms = {};
for (const room of rooms) {
const roomId = room.roomId;
const domain = this.getDomain();
breakoutContentRooms[roomId] = {
via: domain ? [domain] : [],
users: room.users,
};
}

return await this.sendStateEvent(parentRoomId, EventType.PrefixedBreakout, {
"m.breakout": breakoutContentRooms,
});
}

public sendEvent(roomId: string, eventType: string, content: IContent, txnId?: string): Promise<ISendEventResponse>;
public sendEvent(
roomId: string,
Expand Down Expand Up @@ -9870,7 +9891,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param roomIdOrAlias - The ID or alias of the room to get the summary of.
* @param via - The list of servers which know about the room if only an ID was provided.
*/
public async getRoomSummary(roomIdOrAlias: string, via?: string[]): Promise<IRoomSummary> {
public async getRoomSummary(roomIdOrAlias: string, via?: string[]): Promise<IRoomSummaryAPIResponse> {
const path = utils.encodeUri("/rooms/$roomid/summary", { $roomid: roomIdOrAlias });
return this.http.authedRequest(Method.Get, path, { via }, undefined, {
prefix: "/_matrix/client/unstable/im.nheko.summary",
Expand Down
93 changes: 93 additions & 0 deletions src/models/breakoutRooms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
Copyright 2023 Šimon Brandner <[email protected]>

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 { BreakoutEventContent, BreakoutRoomWithSummary } from "../@types/breakout";
import { EventType } from "../@types/event";
import { logger } from "../logger";
import { deepCompare } from "../utils";
import { MatrixEvent } from "./event";
import { Direction } from "./event-timeline";
import { Room, RoomEvent } from "./room";
import { TypedEventEmitter } from "./typed-event-emitter";

export enum BreakoutRoomsEvent {
RoomsChanged = "rooms_changed",
}

export type BreakoutRoomsEventHandlerMap = {
[BreakoutRoomsEvent.RoomsChanged]: (room: BreakoutRoomWithSummary[]) => void;
};

export class BreakoutRooms extends TypedEventEmitter<BreakoutRoomsEvent, BreakoutRoomsEventHandlerMap> {
private currentBreakoutRooms?: BreakoutRoomWithSummary[];

public constructor(private room: Room) {
super();

room.addListener(RoomEvent.Timeline, this.onEvent);

const breakoutEvent = this.getBreakoutEvent();
if (!breakoutEvent) return;
this.parseBreakoutEvent(breakoutEvent).then((rooms) => {
this.currentBreakoutRooms = rooms;
});
}

public getCurrentBreakoutRooms(): BreakoutRoomWithSummary[] | null {
return this.currentBreakoutRooms ? [...this.currentBreakoutRooms] : null;
}

private getBreakoutEvent(): MatrixEvent | null {
const state = this.room.getLiveTimeline().getState(Direction.Forward);
if (!state) return null;

return state.getStateEvents(EventType.Breakout, "") ?? state?.getStateEvents(EventType.PrefixedBreakout, "");
}

private async parseBreakoutEvent(event: MatrixEvent): Promise<BreakoutRoomWithSummary[]> {
const content = event.getContent() as BreakoutEventContent;
if (!content["m.breakout"]) throw new Error("m.breakout is null or undefined");
if (Array.isArray(content["m.breakout"])) throw new Error("m.breakout is an array");

const breakoutRooms: BreakoutRoomWithSummary[] = [];
for (const [roomId, room] of Object.entries(content["m.breakout"])) {
if (!Array.isArray(room.users)) throw new Error("users is not an array");

try {
const summary = await this.room.client.getRoomSummary(roomId, room.via);

breakoutRooms.push({ roomId, roomSummary: summary, users: room.users });
} catch (error) {
logger.error("Failed...", error);
}
}
return breakoutRooms;
}

private onEvent = async (event: MatrixEvent): Promise<void> => {
const type = event.getType() as EventType;
if (![EventType.PrefixedBreakout, EventType.Breakout].includes(type)) return;

const breakoutEvent = this.getBreakoutEvent();
if (!breakoutEvent) return;
const rooms = await this.parseBreakoutEvent(breakoutEvent);

if (!deepCompare(rooms, this.currentBreakoutRooms)) {
this.currentBreakoutRooms = rooms;
this.emit(BreakoutRoomsEvent.RoomsChanged, this.currentBreakoutRooms);
}
};
}
17 changes: 15 additions & 2 deletions src/models/room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ import { IStateEventWithRoomId } from "../@types/search";
import { RelationsContainer } from "./relations-container";
import { ReadReceipt, synthesizeReceipt } from "./read-receipt";
import { isPollEvent, Poll, PollEvent } from "./poll";
import { BreakoutRooms, BreakoutRoomsEvent, BreakoutRoomsEventHandlerMap } from "./breakoutRooms";
import { BreakoutRoomWithSummary } from "../@types/breakout";
import { RoomReceipts } from "./room-receipts";
import { compareEventOrdering } from "./compare-event-ordering";

Expand Down Expand Up @@ -167,7 +169,8 @@ export type RoomEmittedEvents =
| BeaconEvent.Update
| BeaconEvent.Destroy
| BeaconEvent.LivenessChange
| PollEvent.New;
| PollEvent.New
| BreakoutRoomsEvent.RoomsChanged;

export type RoomEventHandlerMap = {
/**
Expand Down Expand Up @@ -320,7 +323,8 @@ export type RoomEventHandlerMap = {
| RoomStateEvent.Marker
| BeaconEvent.New
> &
Pick<BeaconEventHandlerMap, BeaconEvent.Update | BeaconEvent.Destroy | BeaconEvent.LivenessChange>;
Pick<BeaconEventHandlerMap, BeaconEvent.Update | BeaconEvent.Destroy | BeaconEvent.LivenessChange> &
BreakoutRoomsEventHandlerMap;

export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
public readonly reEmitter: TypedReEmitter<RoomEmittedEvents, RoomEventHandlerMap>;
Expand Down Expand Up @@ -427,6 +431,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
*/
private visibilityEvents = new Map<string, MatrixEvent[]>();

private breakoutRooms: BreakoutRooms;

/**
* The latest receipts (synthetic and real) for each user in each thread
* (and unthreaded).
Expand Down Expand Up @@ -502,6 +508,13 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
} else {
this.membersPromise = undefined;
}

this.breakoutRooms = new BreakoutRooms(this);
this.reEmitter.reEmit(this.breakoutRooms, [BreakoutRoomsEvent.RoomsChanged]);
}

public getBreakoutRooms(): BreakoutRoomWithSummary[] | null {
return this.breakoutRooms.getCurrentBreakoutRooms();
}

private threadTimelineSetsPromise: Promise<[EventTimelineSet, EventTimelineSet]> | null = null;
Expand Down
Loading