diff --git a/spec/unit/sync-accumulator.spec.ts b/spec/unit/sync-accumulator.spec.ts index d95c8d056bb..42bb7690bc0 100644 --- a/spec/unit/sync-accumulator.spec.ts +++ b/spec/unit/sync-accumulator.spec.ts @@ -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 diff --git a/src/@types/breakout.ts b/src/@types/breakout.ts new file mode 100644 index 00000000000..4e9004d64a7 --- /dev/null +++ b/src/@types/breakout.ts @@ -0,0 +1,39 @@ +/* +Copyright 2023 Šimon Brandner + +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; +} diff --git a/src/@types/event.ts b/src/@types/event.ts index caa87f9f82e..43b94cae6c3 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -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", diff --git a/src/client.ts b/src/client.ts index 23bf2af75fd..edda801cf49 100644 --- a/src/client.ts +++ b/src/client.ts @@ -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; @@ -875,7 +876,7 @@ interface IThirdPartyUser { fields: object; } -interface IRoomSummary extends Omit { +export interface IRoomSummaryAPIResponse extends Omit { room_type?: RoomType; membership?: string; is_encrypted: boolean; @@ -4631,6 +4632,26 @@ export class MatrixClient extends TypedEventEmitter { + 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; public sendEvent( roomId: string, @@ -9870,7 +9891,7 @@ export class MatrixClient extends TypedEventEmitter { + public async getRoomSummary(roomIdOrAlias: string, via?: string[]): Promise { 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", diff --git a/src/models/breakoutRooms.ts b/src/models/breakoutRooms.ts new file mode 100644 index 00000000000..0d1861a14e7 --- /dev/null +++ b/src/models/breakoutRooms.ts @@ -0,0 +1,93 @@ +/* +Copyright 2023 Šimon Brandner + +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 { + 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 { + 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 => { + 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); + } + }; +} diff --git a/src/models/room.ts b/src/models/room.ts index 67aea690756..a568596d064 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -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"; @@ -167,7 +169,8 @@ export type RoomEmittedEvents = | BeaconEvent.Update | BeaconEvent.Destroy | BeaconEvent.LivenessChange - | PollEvent.New; + | PollEvent.New + | BreakoutRoomsEvent.RoomsChanged; export type RoomEventHandlerMap = { /** @@ -320,7 +323,8 @@ export type RoomEventHandlerMap = { | RoomStateEvent.Marker | BeaconEvent.New > & - Pick; + Pick & + BreakoutRoomsEventHandlerMap; export class Room extends ReadReceipt { public readonly reEmitter: TypedReEmitter; @@ -427,6 +431,8 @@ export class Room extends ReadReceipt { */ private visibilityEvents = new Map(); + private breakoutRooms: BreakoutRooms; + /** * The latest receipts (synthetic and real) for each user in each thread * (and unthreaded). @@ -502,6 +508,13 @@ export class Room extends ReadReceipt { } 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;