diff --git a/src/@types/spaces.ts b/src/@types/spaces.ts index da35bc0b938..088864bd46b 100644 --- a/src/@types/spaces.ts +++ b/src/@types/spaces.ts @@ -15,21 +15,26 @@ limitations under the License. */ import { IPublicRoomsChunkRoom } from "../client"; +import { RoomType } from "./event"; +import { IStrippedState } from "../sync-accumulator"; // Types relating to Rooms of type `m.space` and related APIs /* eslint-disable camelcase */ +/** @deprecated Use hierarchy instead where possible. */ export interface ISpaceSummaryRoom extends IPublicRoomsChunkRoom { num_refs: number; room_type: string; } +/** @deprecated Use hierarchy instead where possible. */ export interface ISpaceSummaryEvent { room_id: string; event_id: string; origin_server_ts: number; type: string; state_key: string; + sender: string; content: { order?: string; suggested?: boolean; @@ -37,4 +42,19 @@ export interface ISpaceSummaryEvent { via?: string[]; }; } + +export interface IHierarchyRelation extends IStrippedState { + room_id: string; + origin_server_ts: number; + content: { + order?: string; + suggested?: boolean; + via?: string[]; + }; +} + +export interface IHierarchyRoom extends IPublicRoomsChunkRoom { + room_type?: RoomType | string; + children_state: IHierarchyRelation[]; +} /* eslint-enable camelcase */ diff --git a/src/client.ts b/src/client.ts index 83b484c44e6..21013952db1 100644 --- a/src/client.ts +++ b/src/client.ts @@ -140,7 +140,7 @@ import { SearchOrderBy, } from "./@types/search"; import { ISynapseAdminDeactivateResponse, ISynapseAdminWhoisResponse } from "./@types/synapse"; -import { ISpaceSummaryEvent, ISpaceSummaryRoom } from "./@types/spaces"; +import { IHierarchyRoom, ISpaceSummaryEvent, ISpaceSummaryRoom } from "./@types/spaces"; import { IPusher, IPusherRequest, IPushRules, PushRuleAction, PushRuleKind, RuleId } from "./@types/PushRules"; import { IThreepid } from "./@types/threepids"; import { CryptoStore } from "./crypto/store/base"; @@ -7973,14 +7973,15 @@ export class MatrixClient extends EventEmitter { } /** - * Fetches or paginates a summary of a space as defined by MSC2946 + * Fetches or paginates a summary of a space as defined by an initial version of MSC2946 * @param {string} roomId The ID of the space-room to use as the root of the summary. * @param {number?} maxRoomsPerSpace The maximum number of rooms to return per subspace. * @param {boolean?} suggestedOnly Whether to only return rooms with suggested=true. * @param {boolean?} autoJoinOnly Whether to only return rooms with auto_join=true. * @param {number?} limit The maximum number of rooms to return in total. * @param {string?} batch The opaque token to paginate a previous summary request. - * @returns {Promise} the response, with next_batch, rooms, events fields. + * @returns {Promise} the response, with next_token, rooms fields. + * @deprecated in favour of `getRoomHierarchy` due to the MSC changing paths. */ public getSpaceSummary( roomId: string, @@ -8008,6 +8009,60 @@ export class MatrixClient extends EventEmitter { }); } + /** + * Fetches or paginates a room hierarchy as defined by MSC2946. + * Falls back gracefully to sourcing its data from `getSpaceSummary` if this API is not yet supported by the server. + * @param {string} roomId The ID of the space-room to use as the root of the summary. + * @param {number?} limit The maximum number of rooms to return per page. + * @param {number?} maxDepth The maximum depth in the tree from the root room to return. + * @param {boolean?} suggestedOnly Whether to only return rooms with suggested=true. + * @param {string?} fromToken The opaque token to paginate a previous request. + * @returns {Promise} the response, with next_batch & rooms fields. + */ + public getRoomHierarchy( + roomId: string, + limit?: number, + maxDepth?: number, + suggestedOnly = false, + fromToken?: string, + ): Promise<{ + rooms: IHierarchyRoom[]; + next_batch?: string; // eslint-disable-line camelcase + }> { + const path = utils.encodeUri("/rooms/$roomId/hierarchy", { + $roomId: roomId, + }); + + return this.http.authedRequest(undefined, "GET", path, { + suggested_only: suggestedOnly, + max_depth: maxDepth, + from: fromToken, + limit, + }, undefined, { + prefix: "/_matrix/client/unstable/org.matrix.msc2946", + }).catch(e => { + if (e.errcode === "M_UNRECOGNIZED") { + // fall back to the older space summary API as it exposes the same data just in a different shape. + return this.getSpaceSummary(roomId, undefined, suggestedOnly, undefined, limit) + .then(({ rooms, events }) => { + // Translate response from `/spaces` to that we expect in this API. + const roomMap = new Map(rooms.map(r => { + return [r.room_id, { ...r, children_state: [] }]; + })); + events.forEach(e => { + roomMap.get(e.room_id)?.children_state.push(e); + }); + + return { + rooms: Array.from(roomMap.values()), + }; + }); + } + + throw e; + }); + } + /** * Creates a new file tree space with the given name. The client will pick * defaults for how it expects to be able to support the remaining API offered diff --git a/src/room-hierarchy.ts b/src/room-hierarchy.ts new file mode 100644 index 00000000000..93b04dd0db3 --- /dev/null +++ b/src/room-hierarchy.ts @@ -0,0 +1,149 @@ +/* +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. +*/ + +/** + * @module room-hierarchy + */ + +import { Room } from "./models/room"; +import { IHierarchyRoom, IHierarchyRelation } from "./@types/spaces"; +import { MatrixClient } from "./client"; +import { EventType } from "./@types/event"; + +export class RoomHierarchy { + // Map from room id to list of servers which are listed as a via somewhere in the loaded hierarchy + public readonly viaMap = new Map>(); + // Map from room id to list of rooms which claim this room as their child + public readonly backRefs = new Map(); + // Map from room id to object + public readonly roomMap = new Map(); + private loadRequest: ReturnType; + private nextBatch?: string; + private _rooms?: IHierarchyRoom[]; + private serverSupportError?: Error; + + /** + * Construct a new RoomHierarchy + * + * A RoomHierarchy instance allows you to easily make use of the /hierarchy API and paginate it. + * + * @param {Room} root the root of this hierarchy + * @param {number} pageSize the maximum number of rooms to return per page, can be overridden per load request. + * @param {number} maxDepth the maximum depth to traverse the hierarchy to + * @param {boolean} suggestedOnly whether to only return rooms with suggested=true. + * @constructor + */ + constructor( + private readonly root: Room, + private readonly pageSize?: number, + private readonly maxDepth?: number, + private readonly suggestedOnly = false, + ) {} + + public get noSupport(): boolean { + return !!this.serverSupportError; + } + + public get canLoadMore(): boolean { + return !!this.serverSupportError || !!this.nextBatch || !this._rooms; + } + + public get rooms(): IHierarchyRoom[] { + return this._rooms; + } + + public async load(pageSize = this.pageSize): Promise { + if (this.loadRequest) return this.loadRequest.then(r => r.rooms); + + this.loadRequest = this.root.client.getRoomHierarchy( + this.root.roomId, + pageSize, + this.maxDepth, + this.suggestedOnly, + this.nextBatch, + ); + + let rooms: IHierarchyRoom[]; + try { + ({ rooms, next_batch: this.nextBatch } = await this.loadRequest); + } catch (e) { + if (e.errcode === "M_UNRECOGNIZED") { + this.serverSupportError = e; + } else { + throw e; + } + + return []; + } finally { + this.loadRequest = null; + } + + if (this._rooms) { + this._rooms = this._rooms.concat(rooms); + } else { + this._rooms = rooms; + } + + rooms.forEach(room => { + this.roomMap.set(room.room_id, room); + + room.children_state.forEach(ev => { + if (ev.type !== EventType.SpaceChild) return; + const childRoomId = ev.state_key; + + // track backrefs for quicker hierarchy navigation + if (!this.backRefs.has(childRoomId)) { + this.backRefs.set(childRoomId, []); + } + this.backRefs.get(childRoomId).push(ev.room_id); + + // fill viaMap + if (Array.isArray(ev.content.via)) { + if (!this.viaMap.has(childRoomId)) { + this.viaMap.set(childRoomId, new Set()); + } + const vias = this.viaMap.get(childRoomId); + ev.content.via.forEach(via => vias.add(via)); + } + }); + }); + + return rooms; + } + + public getRelation(parentId: string, childId: string): IHierarchyRelation { + return this.roomMap.get(parentId)?.children_state.find(e => e.state_key === childId); + } + + public isSuggested(parentId: string, childId: string): boolean { + return this.getRelation(parentId, childId)?.content.suggested; + } + + // locally remove a relation as a form of local echo + public removeRelation(parentId: string, childId: string): void { + const backRefs = this.backRefs.get(childId); + if (backRefs?.length === 1) { + this.backRefs.delete(childId); + } else if (backRefs?.length) { + this.backRefs.set(childId, backRefs.filter(ref => ref !== parentId)); + } + + const room = this.roomMap.get(parentId); + if (room) { + room.children_state = room.children_state.filter(ev => ev.state_key !== childId); + } + } +}