diff --git a/spec/unit/NamespacedValue.spec.ts b/spec/unit/NamespacedValue.spec.ts new file mode 100644 index 000000000..5b0d8e143 --- /dev/null +++ b/spec/unit/NamespacedValue.spec.ts @@ -0,0 +1,78 @@ +/* +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. +*/ + +import {NamespacedValue, UnstableValue} from "../../src/NamespacedValue"; + +describe("NamespacedValue", () => { + it("should prefer stable over unstable", () => { + const ns = new NamespacedValue("stable", "unstable"); + expect(ns.name).toBe(ns.stable); + expect(ns.altName).toBe(ns.unstable); + }); + + it("should return unstable if there is no stable", () => { + const ns = new NamespacedValue(null, "unstable"); + expect(ns.name).toBe(ns.unstable); + expect(ns.altName).toBeFalsy(); + }); + + it("should have a falsey unstable if needed", () => { + const ns = new NamespacedValue("stable", null); + expect(ns.name).toBe(ns.stable); + expect(ns.altName).toBeFalsy(); + }); + + it("should match against either stable or unstable", () => { + const ns = new NamespacedValue("stable", "unstable"); + expect(ns.matches("no")).toBe(false); + expect(ns.matches(ns.stable)).toBe(true); + expect(ns.matches(ns.unstable)).toBe(true); + }); + + it("should not permit falsey values for both parts", () => { + try { + new UnstableValue(null, null); + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toBe("One of stable or unstable values must be supplied"); + } + }); +}); + +describe("UnstableValue", () => { + it("should prefer unstable over stable", () => { + const ns = new UnstableValue("stable", "unstable"); + expect(ns.name).toBe(ns.unstable); + expect(ns.altName).toBe(ns.stable); + }); + + it("should return unstable if there is no stable", () => { + const ns = new UnstableValue(null, "unstable"); + expect(ns.name).toBe(ns.unstable); + expect(ns.altName).toBeFalsy(); + }); + + it("should not permit falsey unstable values", () => { + try { + new UnstableValue("stable", null); + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toBe("Unstable value must be supplied"); + } + }); +}); diff --git a/spec/unit/matrix-client.spec.js b/spec/unit/matrix-client.spec.js index 98c6b127e..80ff2e5a1 100644 --- a/spec/unit/matrix-client.spec.js +++ b/spec/unit/matrix-client.spec.js @@ -1,6 +1,16 @@ import { logger } from "../../src/logger"; import { MatrixClient } from "../../src/client"; import { Filter } from "../../src/filter"; +import {DEFAULT_TREE_POWER_LEVELS_TEMPLATE} from "../../src/models/MSC3089TreeSpace"; +import { + EventType, + RoomCreateTypeField, RoomType, + UNSTABLE_MSC3088_ENABLED, + UNSTABLE_MSC3088_PURPOSE, + UNSTABLE_MSC3089_TREE_SUBTYPE +} from "../../src/@types/event"; +import {MEGOLM_ALGORITHM} from "../../src/crypto/olmlib"; +import {MatrixEvent} from "../../src/models/event"; jest.useFakeTimers(); @@ -171,6 +181,160 @@ describe("MatrixClient", function() { }); }); + it("should create (unstable) file trees", async () => { + const userId = "@test:example.org"; + const roomId = "!room:example.org"; + const roomName = "Test Tree"; + const mockRoom = {}; + const fn = jest.fn().mockImplementation((opts) => { + expect(opts).toMatchObject({ + name: roomName, + preset: "private_chat", + power_level_content_override: { + ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE, + users: { + [userId]: 100, + }, + }, + creation_content: { + [RoomCreateTypeField]: RoomType.Space, + }, + initial_state: [ + { + // We use `unstable` to ensure that the code is actually using the right identifier + type: UNSTABLE_MSC3088_PURPOSE.unstable, + state_key: UNSTABLE_MSC3089_TREE_SUBTYPE.unstable, + content: { + [UNSTABLE_MSC3088_ENABLED.unstable]: true, + }, + }, + { + type: EventType.RoomEncryption, + state_key: "", + content: { + algorithm: MEGOLM_ALGORITHM, + }, + }, + ], + }); + return {room_id: roomId}; + }); + client.getUserId = () => userId; + client.createRoom = fn; + client.getRoom = (getRoomId) => { + expect(getRoomId).toEqual(roomId); + return mockRoom; + }; + const tree = await client.unstableCreateFileTree(roomName); + expect(tree).toBeDefined(); + expect(tree.roomId).toEqual(roomId); + expect(tree.room).toBe(mockRoom); + expect(fn.mock.calls.length).toBe(1); + }); + + it("should get (unstable) file trees with valid state", async () => { + const roomId = "!room:example.org"; + const mockRoom = { + currentState: { + getStateEvents: (eventType, stateKey) => { + if (eventType === EventType.RoomCreate) { + expect(stateKey).toEqual(""); + return new MatrixEvent({ + content: { + [RoomCreateTypeField]: RoomType.Space, + }, + }); + } else if (eventType === UNSTABLE_MSC3088_PURPOSE.unstable) { + // We use `unstable` to ensure that the code is actually using the right identifier + expect(stateKey).toEqual(UNSTABLE_MSC3089_TREE_SUBTYPE.unstable); + return new MatrixEvent({ + content: { + [UNSTABLE_MSC3088_ENABLED.unstable]: true, + }, + }); + } else { + throw new Error("Unexpected event type or state key"); + } + }, + }, + }; + client.getRoom = (getRoomId) => { + expect(getRoomId).toEqual(roomId); + return mockRoom; + }; + const tree = client.unstableGetFileTreeSpace(roomId); + expect(tree).toBeDefined(); + expect(tree.roomId).toEqual(roomId); + expect(tree.room).toBe(mockRoom); + }); + + it("should not get (unstable) file trees with invalid create contents", async () => { + const roomId = "!room:example.org"; + const mockRoom = { + currentState: { + getStateEvents: (eventType, stateKey) => { + if (eventType === EventType.RoomCreate) { + expect(stateKey).toEqual(""); + return new MatrixEvent({ + content: { + [RoomCreateTypeField]: "org.example.not_space", + }, + }); + } else if (eventType === UNSTABLE_MSC3088_PURPOSE.unstable) { + // We use `unstable` to ensure that the code is actually using the right identifier + expect(stateKey).toEqual(UNSTABLE_MSC3089_TREE_SUBTYPE.unstable); + return new MatrixEvent({ + content: { + [UNSTABLE_MSC3088_ENABLED.unstable]: true, + }, + }); + } else { + throw new Error("Unexpected event type or state key"); + } + }, + }, + }; + client.getRoom = (getRoomId) => { + expect(getRoomId).toEqual(roomId); + return mockRoom; + }; + const tree = client.unstableGetFileTreeSpace(roomId); + expect(tree).toBeFalsy(); + }); + + it("should not get (unstable) file trees with invalid purpose/subtype contents", async () => { + const roomId = "!room:example.org"; + const mockRoom = { + currentState: { + getStateEvents: (eventType, stateKey) => { + if (eventType === EventType.RoomCreate) { + expect(stateKey).toEqual(""); + return new MatrixEvent({ + content: { + [RoomCreateTypeField]: RoomType.Space, + }, + }); + } else if (eventType === UNSTABLE_MSC3088_PURPOSE.unstable) { + expect(stateKey).toEqual(UNSTABLE_MSC3089_TREE_SUBTYPE.unstable); + return new MatrixEvent({ + content: { + [UNSTABLE_MSC3088_ENABLED.unstable]: false, + }, + }); + } else { + throw new Error("Unexpected event type or state key"); + } + }, + }, + }; + client.getRoom = (getRoomId) => { + expect(getRoomId).toEqual(roomId); + return mockRoom; + }; + const tree = client.unstableGetFileTreeSpace(roomId); + expect(tree).toBeFalsy(); + }); + it("should not POST /filter if a matching filter already exists", async function() { httpLookups = []; httpLookups.push(PUSH_RULES_RESPONSE); diff --git a/spec/unit/models/MSC3089TreeSpace.spec.ts b/spec/unit/models/MSC3089TreeSpace.spec.ts new file mode 100644 index 000000000..4e3f069c2 --- /dev/null +++ b/spec/unit/models/MSC3089TreeSpace.spec.ts @@ -0,0 +1,181 @@ +/* +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. +*/ + +import { MatrixClient } from "../../../src"; +import { Room } from "../../../src/models/room"; +import { MatrixEvent } from "../../../src/models/event"; +import { EventType } from "../../../src/@types/event"; +import { + DEFAULT_TREE_POWER_LEVELS_TEMPLATE, + MSC3089TreeSpace, + TreePermissions +} from "../../../src/models/MSC3089TreeSpace"; + +describe("MSC3089TreeSpace", () => { + let client: MatrixClient; + let room: Room; + let tree: MSC3089TreeSpace; + const roomId = "!tree:localhost"; + const targetUser = "@target:example.org"; + + let powerLevels; + + beforeEach(() => { + // TODO: Use utility functions to create test rooms and clients + client = { + getRoom: (roomId: string) => { + if (roomId === roomId) { + return room; + } else { + throw new Error("Unexpected fetch for unknown room"); + } + }, + }; + room = { + currentState: { + getStateEvents: (evType: EventType, stateKey: string) => { + if (evType === EventType.RoomPowerLevels && stateKey === "") { + return powerLevels; + } else { + throw new Error("Accessed unexpected state event type or key"); + } + }, + }, + }; + tree = new MSC3089TreeSpace(client, roomId); + makePowerLevels(DEFAULT_TREE_POWER_LEVELS_TEMPLATE); + }); + + function makePowerLevels(content: any) { + powerLevels = new MatrixEvent({ + type: EventType.RoomPowerLevels, + state_key: "", + sender: "@creator:localhost", + event_id: "$powerlevels", + room_id: roomId, + content: content, + }); + } + + it('should populate the room reference', () => { + expect(tree.room).toBe(room); + }); + + it('should proxy the ID member to room ID', () => { + expect(tree.id).toEqual(tree.roomId); + expect(tree.id).toEqual(roomId); + }); + + it('should support setting the name of the space', async () => { + const newName = "NEW NAME"; + const fn = jest.fn().mockImplementation((stateRoomId: string, eventType: EventType, content: any, stateKey: string) => { + expect(stateRoomId).toEqual(roomId); + expect(eventType).toEqual(EventType.RoomName); + expect(stateKey).toEqual(""); + expect(content).toMatchObject({name: newName}); + return Promise.resolve(); + }); + client.sendStateEvent = fn; + await tree.setName(newName); + expect(fn.mock.calls.length).toBe(1); + }); + + it('should support inviting users to the space', async () => { + const target = targetUser; + const fn = jest.fn().mockImplementation((inviteRoomId: string, userId: string) => { + expect(inviteRoomId).toEqual(roomId); + expect(userId).toEqual(target); + return Promise.resolve(); + }); + client.invite = fn; + await tree.invite(target); + expect(fn.mock.calls.length).toBe(1); + }); + + async function evaluatePowerLevels(pls: any, role: TreePermissions, expectedPl: number) { + makePowerLevels(pls); + const fn = jest.fn().mockImplementation((stateRoomId: string, eventType: EventType, content: any, stateKey: string) => { + expect(stateRoomId).toEqual(roomId); + expect(eventType).toEqual(EventType.RoomPowerLevels); + expect(stateKey).toEqual(""); + expect(content).toMatchObject({ + ...pls, + users: { + [targetUser]: expectedPl, + }, + }); + return Promise.resolve(); + }); + client.sendStateEvent = fn; + await tree.setPermissions(targetUser, role); + expect(fn.mock.calls.length).toBe(1); + } + + it('should support setting Viewer permissions', () => { + return evaluatePowerLevels({ + ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE, + users_default: 1024, + }, TreePermissions.Viewer, 1024); + }); + + it('should support setting Editor permissions', () => { + return evaluatePowerLevels({ + ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE, + events_default: 1024, + }, TreePermissions.Editor, 1024); + }); + + it('should support setting Owner permissions', () => { + return evaluatePowerLevels({ + ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE, + events: { + [EventType.RoomPowerLevels]: 1024, + }, + }, TreePermissions.Owner, 1024); + }); + + it('should support demoting permissions', () => { + return evaluatePowerLevels({ + ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE, + users_default: 1024, + users: { + [targetUser]: 2222, + } + }, TreePermissions.Viewer, 1024); + }); + + it('should support promoting permissions', () => { + return evaluatePowerLevels({ + ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE, + events_default: 1024, + users: { + [targetUser]: 5, + } + }, TreePermissions.Editor, 1024); + }); + + it('should support defaults: Viewer', () => { + return evaluatePowerLevels({}, TreePermissions.Viewer, 0); + }); + + it('should support defaults: Editor', () => { + return evaluatePowerLevels({}, TreePermissions.Editor, 50); + }); + + it('should support defaults: Owner', () => { + return evaluatePowerLevels({}, TreePermissions.Owner, 100); + }); +}); diff --git a/src/@types/event.ts b/src/@types/event.ts index 3c905442b..9e726de77 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { UnstableValue } from "../NamespacedValue"; + export enum EventType { // Room state events RoomCanonicalAlias = "m.room.canonical_alias", @@ -100,3 +102,31 @@ export const RoomCreateTypeField = "type"; export enum RoomType { Space = "m.space", } + +/** + * Identifier for an [MSC3088](https://github.com/matrix-org/matrix-doc/pull/3088) + * room purpose. Note that this reference is UNSTABLE and subject to breaking changes, + * including its eventual removal. + */ +export const UNSTABLE_MSC3088_PURPOSE = new UnstableValue("m.room.purpose", "org.matrix.msc3088.purpose"); + +/** + * Enabled flag for an [MSC3088](https://github.com/matrix-org/matrix-doc/pull/3088) + * room purpose. Note that this reference is UNSTABLE and subject to breaking changes, + * including its eventual removal. + */ +export const UNSTABLE_MSC3088_ENABLED = new UnstableValue("m.enabled", "org.matrix.msc3088.enabled"); + +/** + * Subtype for an [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) space-room. + * Note that this reference is UNSTABLE and subject to breaking changes, including its + * eventual removal. + */ +export const UNSTABLE_MSC3089_TREE_SUBTYPE = new UnstableValue("m.data_tree", "org.matrix.msc3089.data_tree"); + +/** + * Leaf type for an event in a [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) space-room. + * Note that this reference is UNSTABLE and subject to breaking changes, including its + * eventual removal. + */ +export const UNSTABLE_MSC3089_LEAF = new UnstableValue("m.leaf", "org.matrix.msc3089.leaf"); diff --git a/src/@types/requests.ts b/src/@types/requests.ts index 581b4a1b6..b402bbdcb 100644 --- a/src/@types/requests.ts +++ b/src/@types/requests.ts @@ -74,6 +74,9 @@ export interface ICreateRoomOpts { name?: string; topic?: string; preset?: string; + power_level_content_override?: any; + creation_content?: any; + initial_state?: {type: string, state_key: string, content: any}[]; // TODO: Types (next line) invite_3pid?: any[]; // eslint-disable-line camelcase } diff --git a/src/NamespacedValue.ts b/src/NamespacedValue.ts new file mode 100644 index 000000000..e20beab2d --- /dev/null +++ b/src/NamespacedValue.ts @@ -0,0 +1,95 @@ +/* +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. +*/ + +/** + * Represents a simple Matrix namespaced value. This will assume that if a stable prefix + * is provided that the stable prefix should be used when representing the identifier. + */ +export class NamespacedValue { + public constructor(public readonly stable: S, public readonly unstable?: U) { + if (!this.unstable && !this.stable) { + throw new Error("One of stable or unstable values must be supplied"); + } + } + + public get tsType(): U | S { + return null; // irrelevant return + } + + public get name(): U | S { + if (this.stable) { + return this.stable; + } + return this.unstable; + } + + public get altName(): U | S | null { + if (!this.stable) { + return null; + } + return this.unstable; + } + + public matches(val: string): boolean { + return this.name === val || this.altName === val; + } + + // this desperately wants https://github.com/microsoft/TypeScript/pull/26349 at the top level of the class + // so we can instantiate `NamespacedValue` as a default type for that namespace. + public findIn(obj: any): T { + let val: T; + if (this.name) { + val = obj?.[this.name]; + } + if (!val && this.altName) { + val = obj?.[this.altName]; + } + return val; + } + + public includedIn(arr: any[]): boolean { + let included = false; + if (this.name) { + included = arr.includes(this.name); + } + if (!included && this.altName) { + included = arr.includes(this.altName); + } + return included; + } +} + +/** + * Represents a namespaced value which prioritizes the unstable value over the stable + * value. + */ +export class UnstableValue extends NamespacedValue { + // Note: Constructor difference is that `unstable` is *required*. + public constructor(stable: S, unstable: U) { + super(stable, unstable); + if (!this.unstable) { + throw new Error("Unstable value must be supplied"); + } + } + + public get name(): U { + return this.unstable; + } + + public get altName(): S { + return this.stable; + } +} diff --git a/src/client.ts b/src/client.ts index e3acfeb9a..0d4fa7795 100644 --- a/src/client.ts +++ b/src/client.ts @@ -99,7 +99,13 @@ import { ISendEventResponse, IUploadOpts, } from "./@types/requests"; -import { EventType } from "./@types/event"; +import { + EventType, + RoomCreateTypeField, + RoomType, + UNSTABLE_MSC3088_ENABLED, + UNSTABLE_MSC3088_PURPOSE, UNSTABLE_MSC3089_TREE_SUBTYPE +} from "./@types/event"; import { IImageInfo } from "./@types/partials"; import { EventMapper, eventMapperFor, MapperOpts } from "./event-mapper"; import url from "url"; @@ -107,6 +113,7 @@ import { randomString } from "./randomstring"; import { ReadStream } from "fs"; import { WebStorageSessionStore } from "./store/session/webstorage"; import { BackupManager } from "./crypto/backup"; +import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE, MSC3089TreeSpace } from "./models/MSC3089TreeSpace"; export type Store = StubStore | MemoryStore | LocalIndexedDBStoreBackend | RemoteIndexedDBStoreBackend; export type SessionStore = WebStorageSessionStore; @@ -7709,6 +7716,74 @@ export class MatrixClient extends EventEmitter { }); } + /** + * 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 + * by the returned class. + * + * Note that this is UNSTABLE and may have breaking changes without notice. + * @param {string} name The name of the tree space. + * @returns {Promise} Resolves to the created space. + */ + public async unstableCreateFileTree(name: string): Promise { + const { room_id: roomId } = await this.createRoom({ + name: name, + preset: "private_chat", + power_level_content_override: { + ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE, + users: { + [this.getUserId()]: 100, + }, + }, + creation_content: { + [RoomCreateTypeField]: RoomType.Space, + }, + initial_state: [ + { + type: UNSTABLE_MSC3088_PURPOSE.name, + state_key: UNSTABLE_MSC3089_TREE_SUBTYPE.name, + content: { + [UNSTABLE_MSC3088_ENABLED.name]: true, + }, + }, + { + type: EventType.RoomEncryption, + state_key: "", + content: { + algorithm: olmlib.MEGOLM_ALGORITHM, + }, + }, + ], + }); + return new MSC3089TreeSpace(this, roomId); + } + + /** + * Gets a reference to a tree space, if the room ID given is a tree space. If the room + * does not appear to be a tree space then null is returned. + * + * Note that this is UNSTABLE and may have breaking changes without notice. + * @param {string} roomId The room ID to get a tree space reference for. + * @returns {MSC3089TreeSpace} The tree space, or null if not a tree space. + */ + public unstableGetFileTreeSpace(roomId: string): MSC3089TreeSpace { + const room = this.getRoom(roomId); + if (!room) return null; + + const createEvent = room.currentState.getStateEvents(EventType.RoomCreate, ""); + const purposeEvent = room.currentState.getStateEvents( + UNSTABLE_MSC3088_PURPOSE.name, + UNSTABLE_MSC3089_TREE_SUBTYPE.name); + + if (!createEvent || Array.isArray(createEvent)) throw new Error("Expected single room create event"); + if (!purposeEvent || Array.isArray(purposeEvent)) return null; + + if (!purposeEvent.getContent()?.[UNSTABLE_MSC3088_ENABLED.name]) return null; + if (createEvent.getContent()?.[RoomCreateTypeField] !== RoomType.Space) return null; + + return new MSC3089TreeSpace(this, roomId); + } + // TODO: Remove this warning, alongside the functions // See https://github.com/vector-im/element-web/issues/17532 // ====================================================== diff --git a/src/models/MSC3089TreeSpace.ts b/src/models/MSC3089TreeSpace.ts new file mode 100644 index 000000000..8eb650bd4 --- /dev/null +++ b/src/models/MSC3089TreeSpace.ts @@ -0,0 +1,138 @@ +/* +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. +*/ + +import { MatrixClient } from "../client"; +import { EventType } from "../@types/event"; +import { Room } from "./room"; + +/** + * The recommended defaults for a tree space's power levels. Note that this + * is UNSTABLE and subject to breaking changes without notice. + */ +export const DEFAULT_TREE_POWER_LEVELS_TEMPLATE = { + // Owner + invite: 100, + kick: 100, + ban: 100, + + // Editor + redact: 50, + state_default: 50, + events_default: 50, + + // Viewer + users_default: 0, + + // Mixed + events: { + [EventType.RoomPowerLevels]: 100, + [EventType.RoomHistoryVisibility]: 100, + [EventType.RoomTombstone]: 100, + [EventType.RoomEncryption]: 100, + [EventType.RoomName]: 50, + [EventType.RoomMessage]: 50, + [EventType.RoomMessageEncrypted]: 50, + [EventType.Sticker]: 50, + }, + users: {}, // defined by calling code +}; + +/** + * Ease-of-use representation for power levels represented as simple roles. + * Note that this is UNSTABLE and subject to breaking changes without notice. + */ +export enum TreePermissions { + Viewer = "viewer", // Default + Editor = "editor", // "Moderator" or ~PL50 + Owner = "owner", // "Admin" or PL100 +} + +/** + * Represents a [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) + * file tree Space. Note that this is UNSTABLE and subject to breaking changes + * without notice. + */ +export class MSC3089TreeSpace { + public readonly room: Room; + + public constructor(private client: MatrixClient, public readonly roomId: string) { + this.room = this.client.getRoom(this.roomId); + } + + /** + * Syntactic sugar for room ID of the Space. + */ + public get id(): string { + return this.roomId; + } + + /** + * Sets the name of the tree space. + * @param {string} name The new name for the space. + * @returns {Promise} Resolves when complete. + */ + public setName(name: string): Promise { + return this.client.sendStateEvent(this.roomId, EventType.RoomName, {name}, ""); + } + + /** + * Invites a user to the tree space. They will be given the default Viewer + * permission level unless specified elsewhere. + * @param {string} userId The user ID to invite. + * @returns {Promise} Resolves when complete. + */ + public invite(userId: string): Promise { + // TODO: [@@TR] Reliable invites + // TODO: [@@TR] Share keys + return this.client.invite(this.roomId, userId); + } + + /** + * Sets the permissions of a user to the given role. Note that if setting a user + * to Owner then they will NOT be able to be demoted. If the user does not have + * permission to change the power level of the target, an error will be thrown. + * @param {string} userId The user ID to change the role of. + * @param {TreePermissions} role The role to assign. + * @returns {Promise} Resolves when complete. + */ + public async setPermissions(userId: string, role: TreePermissions): Promise { + const currentPls = this.room.currentState.getStateEvents(EventType.RoomPowerLevels, ""); + if (Array.isArray(currentPls)) throw new Error("Unexpected return type for power levels"); + + const pls = currentPls.getContent() || {}; + const viewLevel = pls['users_default'] || 0; + const editLevel = pls['events_default'] || 50; + const adminLevel = pls['events']?.[EventType.RoomPowerLevels] || 100; + + const users = pls['users'] || {}; + switch (role) { + case TreePermissions.Viewer: + users[userId] = viewLevel; + break; + case TreePermissions.Editor: + users[userId] = editLevel; + break; + case TreePermissions.Owner: + users[userId] = adminLevel; + break; + default: + throw new Error("Invalid role: " + role); + } + pls['users'] = users; + + return this.client.sendStateEvent(this.roomId, EventType.RoomPowerLevels, pls, ""); + } +}