diff --git a/spec/MockBlob.ts b/spec/MockBlob.ts new file mode 100644 index 000000000..04d01c24e --- /dev/null +++ b/spec/MockBlob.ts @@ -0,0 +1,27 @@ +/* +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. +*/ + +export class MockBlob { + private contents: number[] = []; + + public constructor(private parts: ArrayLike[]) { + parts.forEach(p => Array.from(p).forEach(e => this.contents.push(e))); + } + + public get size(): number { + return this.contents.length; + } +} diff --git a/spec/unit/models/MSC3089Branch.spec.ts b/spec/unit/models/MSC3089Branch.spec.ts new file mode 100644 index 000000000..05c46653e --- /dev/null +++ b/spec/unit/models/MSC3089Branch.spec.ts @@ -0,0 +1,153 @@ +/* +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 { UNSTABLE_MSC3089_BRANCH } from "../../../src/@types/event"; +import { EventTimelineSet } from "../../../src/models/event-timeline-set"; +import { EventTimeline } from "../../../src/models/event-timeline"; +import { MSC3089Branch } from "../../../src/models/MSC3089Branch"; + +describe("MSC3089Branch", () => { + let client: MatrixClient; + // @ts-ignore - TS doesn't know that this is a type + let indexEvent: MatrixEvent; + let branch: MSC3089Branch; + + const branchRoomId = "!room:example.org"; + const fileEventId = "$file"; + + const staticTimelineSets = {} as EventTimelineSet; + const staticRoom = { + getUnfilteredTimelineSet: () => staticTimelineSets, + } as any as Room; // partial + + beforeEach(() => { + // TODO: Use utility functions to create test rooms and clients + client = { + getRoom: (roomId: string) => { + if (roomId === branchRoomId) { + return staticRoom; + } else { + throw new Error("Unexpected fetch for unknown room"); + } + }, + }; + indexEvent = { + getRoomId: () => branchRoomId, + getStateKey: () => fileEventId, + }; + branch = new MSC3089Branch(client, indexEvent); + }); + + it('should know the file event ID', () => { + expect(branch.id).toEqual(fileEventId); + }); + + it('should know if the file is active or not', () => { + indexEvent.getContent = () => ({}); + expect(branch.isActive).toBe(false); + indexEvent.getContent = () => ({ active: false }); + expect(branch.isActive).toBe(false); + indexEvent.getContent = () => ({ active: true }); + expect(branch.isActive).toBe(true); + indexEvent.getContent = () => ({ active: "true" }); // invalid boolean, inactive + expect(branch.isActive).toBe(false); + }); + + it('should be able to delete the file', async () => { + const stateFn = jest.fn().mockImplementation((roomId: string, eventType: string, content: any, stateKey: string) => { + expect(roomId).toEqual(branchRoomId); + expect(eventType).toEqual(UNSTABLE_MSC3089_BRANCH.unstable); // test that we're definitely using the unstable value + expect(content).toMatchObject({}); + expect(content['active']).toBeUndefined(); + expect(stateKey).toEqual(fileEventId); + + return Promise.resolve(); // return value not used + }); + client.sendStateEvent = stateFn; + + const redactFn = jest.fn().mockImplementation((roomId: string, eventId: string) => { + expect(roomId).toEqual(branchRoomId); + expect(eventId).toEqual(fileEventId); + + return Promise.resolve(); // return value not used + }); + client.redactEvent = redactFn; + + await branch.delete(); + + expect(stateFn).toHaveBeenCalledTimes(1); + expect(redactFn).toHaveBeenCalledTimes(1); + }); + + it('should know its name', async () => { + const name = "My File.txt"; + indexEvent.getContent = () => ({ active: true, name: name }); + + const res = branch.getName(); + + expect(res).toEqual(name); + }); + + it('should be able to change its name', async () => { + const name = "My File.txt"; + indexEvent.getContent = () => ({ active: true, retained: true }); + const stateFn = jest.fn().mockImplementation((roomId: string, eventType: string, content: any, stateKey: string) => { + expect(roomId).toEqual(branchRoomId); + expect(eventType).toEqual(UNSTABLE_MSC3089_BRANCH.unstable); // test that we're definitely using the unstable value + expect(content).toMatchObject({ + retained: true, // canary for copying state + active: true, + name: name, + }); + expect(stateKey).toEqual(fileEventId); + + return Promise.resolve(); // return value not used + }); + client.sendStateEvent = stateFn; + + await branch.setName(name); + + expect(stateFn).toHaveBeenCalledTimes(1); + }); + + it('should be able to return event information', async () => { + const mxcLatter = "example.org/file"; + const fileContent = {isFile: "not quite", url: "mxc://" + mxcLatter}; + const eventsArr = [ + {getId: () => "$not-file", getContent: () => ({})}, + {getId: () => fileEventId, getContent: () => ({file: fileContent})}, + ]; + client.getEventTimeline = () => Promise.resolve({ + getEvents: () => eventsArr, + }) as any as Promise; // partial + client.mxcUrlToHttp = (mxc: string) => { + expect(mxc).toEqual("mxc://" + mxcLatter); + return `https://example.org/_matrix/media/v1/download/${mxcLatter}`; + }; + client.decryptEventIfNeeded = () => Promise.resolve(); + + const res = await branch.getFileInfo(); + expect(res).toBeDefined(); + expect(res).toMatchObject({ + info: fileContent, + // Escape regex from MDN guides: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions + httpUrl: expect.stringMatching(`.+${mxcLatter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`), + }); + }); +}); diff --git a/spec/unit/models/MSC3089TreeSpace.spec.ts b/spec/unit/models/MSC3089TreeSpace.spec.ts index ea6078a67..3aebe5af1 100644 --- a/spec/unit/models/MSC3089TreeSpace.spec.ts +++ b/spec/unit/models/MSC3089TreeSpace.spec.ts @@ -17,13 +17,14 @@ 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 { EventType, MsgType, UNSTABLE_MSC3089_BRANCH, UNSTABLE_MSC3089_LEAF } from "../../../src/@types/event"; import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE, MSC3089TreeSpace, TreePermissions } from "../../../src/models/MSC3089TreeSpace"; import { DEFAULT_ALPHABET } from "../../../src/utils"; +import { MockBlob } from "../../MockBlob"; describe("MSC3089TreeSpace", () => { let client: MatrixClient; @@ -37,8 +38,8 @@ describe("MSC3089TreeSpace", () => { beforeEach(() => { // TODO: Use utility functions to create test rooms and clients client = { - getRoom: (roomId: string) => { - if (roomId === roomId) { + getRoom: (fetchRoomId: string) => { + if (fetchRoomId === roomId) { return room; } else { throw new Error("Unexpected fetch for unknown room"); @@ -735,4 +736,112 @@ describe("MSC3089TreeSpace", () => { expectOrder(d, 3); }); }); + + it('should upload files', async () => { + const mxc = "mxc://example.org/file"; + const fileInfo = { + mimetype: "text/plain", + // other fields as required by encryption, but ignored here + }; + const fileEventId = "$file"; + const fileName = "My File.txt"; + const fileContents = "This is a test file"; + + // Mock out Blob for the test environment + (global).Blob = MockBlob; + + const uploadFn = jest.fn().mockImplementation((contents: Blob, opts: any) => { + expect(contents).toBeInstanceOf(Blob); + expect(contents.size).toEqual(fileContents.length); + expect(opts).toMatchObject({ + includeFilename: false, + onlyContentUri: true, // because the tests rely on this - we shouldn't really be testing for this. + }); + return Promise.resolve(mxc); + }); + client.uploadContent = uploadFn; + + const sendMsgFn = jest.fn().mockImplementation((roomId: string, contents: any) => { + expect(roomId).toEqual(tree.roomId); + expect(contents).toMatchObject({ + msgtype: MsgType.File, + body: fileName, + url: mxc, + file: fileInfo, + [UNSTABLE_MSC3089_LEAF.unstable]: {}, // test to ensure we're definitely using unstable + }); + + return Promise.resolve({event_id: fileEventId}); // eslint-disable-line camelcase + }); + client.sendMessage = sendMsgFn; + + const sendStateFn = jest.fn().mockImplementation((roomId: string, eventType: string, content: any, stateKey: string) => { + expect(roomId).toEqual(tree.roomId); + expect(eventType).toEqual(UNSTABLE_MSC3089_BRANCH.unstable); // test to ensure we're definitely using unstable + expect(stateKey).toEqual(fileEventId); + expect(content).toMatchObject({ + active: true, + name: fileName, + }); + + return Promise.resolve(); // return value not used. + }); + client.sendStateEvent = sendStateFn; + + const buf = Uint8Array.from(Array.from(fileContents).map((_, i) => fileContents.charCodeAt(i))); + + // We clone the file info just to make sure it doesn't get mutated for the test. + await tree.createFile(fileName, buf, Object.assign({}, fileInfo)); + + expect(uploadFn).toHaveBeenCalledTimes(1); + expect(sendMsgFn).toHaveBeenCalledTimes(1); + expect(sendStateFn).toHaveBeenCalledTimes(1); + }); + + it('should support getting files', () => { + const fileEventId = "$file"; + const fileEvent = {forTest: true}; // MatrixEvent mock + room.currentState = { + getStateEvents: (eventType: string, stateKey?: string) => { + expect(eventType).toEqual(UNSTABLE_MSC3089_BRANCH.unstable); // test to ensure we're definitely using unstable + expect(stateKey).toEqual(fileEventId); + return fileEvent; + }, + }; + + const file = tree.getFile(fileEventId); + expect(file).toBeDefined(); + expect(file.indexEvent).toBe(fileEvent); + }); + + it('should return falsy for unknown files', () => { + const fileEventId = "$file"; + room.currentState = { + getStateEvents: (eventType: string, stateKey?: string) => { + expect(eventType).toEqual(UNSTABLE_MSC3089_BRANCH.unstable); // test to ensure we're definitely using unstable + expect(stateKey).toEqual(fileEventId); + return null; + }, + }; + + const file = tree.getFile(fileEventId); + expect(file).toBeFalsy(); + }); + + it('should list files', () => { + const firstFile = {getContent: () => ({active: true})}; + const secondFile = {getContent: () => ({active: false})}; // deliberately inactive + room.currentState = { + getStateEvents: (eventType: string, stateKey?: string) => { + expect(eventType).toEqual(UNSTABLE_MSC3089_BRANCH.unstable); // test to ensure we're definitely using unstable + expect(stateKey).toBeUndefined(); + return [firstFile, secondFile]; + }, + }; + + const files = tree.listFiles(); + expect(files).toBeDefined(); + expect(files.length).toEqual(1); + expect(files[0].indexEvent).toBe(firstFile); + }); }); diff --git a/src/@types/event.ts b/src/@types/event.ts index 9e726de77..a4e11fe34 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -130,3 +130,25 @@ export const UNSTABLE_MSC3089_TREE_SUBTYPE = new UnstableValue("m.data_tree", "o * eventual removal. */ export const UNSTABLE_MSC3089_LEAF = new UnstableValue("m.leaf", "org.matrix.msc3089.leaf"); + +/** + * Branch (Leaf Reference) type for the index approach 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_BRANCH = new UnstableValue("m.branch", "org.matrix.msc3089.branch"); + +export interface IEncryptedFile { + url: string; + mimetype?: string; + key: { + alg: string; + key_ops: string[]; // eslint-disable-line camelcase + kty: string; + k: string; + ext: boolean; + }; + iv: string; + hashes: {[alg: string]: string}; + v: string; +} diff --git a/src/client.ts b/src/client.ts index 0d4fa7795..65a42968d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -4295,7 +4295,7 @@ export class MatrixClient extends EventEmitter { * {@link module:models/event-timeline~EventTimeline} including the given * event */ - public getEventTimeline(timelineSet: EventTimelineSet, eventId: string): EventTimeline { + public getEventTimeline(timelineSet: EventTimelineSet, eventId: string): Promise { // don't allow any timeline support unless it's been enabled. if (!this.timelineSupport) { throw new Error("timeline support is disabled. Set the 'timelineSupport'" + diff --git a/src/models/MSC3089Branch.ts b/src/models/MSC3089Branch.ts new file mode 100644 index 000000000..655e374d8 --- /dev/null +++ b/src/models/MSC3089Branch.ts @@ -0,0 +1,102 @@ +/* +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 { IEncryptedFile, UNSTABLE_MSC3089_BRANCH } from "../@types/event"; +import { MatrixEvent } from "./event"; + +/** + * Represents a [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) branch - a reference + * to a file (leaf) in the tree. Note that this is UNSTABLE and subject to breaking changes + * without notice. + */ +export class MSC3089Branch { + public constructor(private client: MatrixClient, public readonly indexEvent: MatrixEvent) { + // Nothing to do + } + + /** + * The file ID. + */ + public get id(): string { + return this.indexEvent.getStateKey(); + } + + /** + * Whether this branch is active/valid. + */ + public get isActive(): boolean { + return this.indexEvent.getContent()["active"] === true; + } + + private get roomId(): string { + return this.indexEvent.getRoomId(); + } + + /** + * Deletes the file from the tree. + * @returns {Promise} Resolves when complete. + */ + public async delete(): Promise { + await this.client.sendStateEvent(this.roomId, UNSTABLE_MSC3089_BRANCH.name, {}, this.id); + await this.client.redactEvent(this.roomId, this.id); + + // TODO: Delete edit history as well + } + + /** + * Gets the name for this file. + * @returns {string} The name, or "Unnamed File" if unknown. + */ + public getName(): string { + return this.indexEvent.getContent()['name'] || "Unnamed File"; + } + + /** + * Sets the name for this file. + * @param {string} name The new name for this file. + * @returns {Promise} Resolves when complete. + */ + public setName(name: string): Promise { + return this.client.sendStateEvent(this.roomId, UNSTABLE_MSC3089_BRANCH.name, { + ...this.indexEvent.getContent(), + name: name, + }, this.id); + } + + /** + * Gets information about the file needed to download it. + * @returns {Promise<{info: IEncryptedFile, httpUrl: string}>} Information about the file. + */ + public async getFileInfo(): Promise<{ info: IEncryptedFile, httpUrl: string }> { + const room = this.client.getRoom(this.roomId); + if (!room) throw new Error("Unknown room"); + + const timeline = await this.client.getEventTimeline(room.getUnfilteredTimelineSet(), this.id); + if (!timeline) throw new Error("Failed to get timeline for room event"); + + const event = timeline.getEvents().find(e => e.getId() === this.id); + if (!event) throw new Error("Failed to find event"); + + // Sometimes the event context doesn't decrypt for us, so do that. + await this.client.decryptEventIfNeeded(event, {emit: false, isRetry: false}); + + const file = event.getContent()['file']; + const httpUrl = this.client.mxcUrlToHttp(file['url']); + + return { info: file, httpUrl: httpUrl }; + } +} diff --git a/src/models/MSC3089TreeSpace.ts b/src/models/MSC3089TreeSpace.ts index b07fca706..153e4b494 100644 --- a/src/models/MSC3089TreeSpace.ts +++ b/src/models/MSC3089TreeSpace.ts @@ -15,11 +15,12 @@ limitations under the License. */ import { MatrixClient } from "../client"; -import { EventType } from "../@types/event"; +import { EventType, IEncryptedFile, MsgType, UNSTABLE_MSC3089_BRANCH, UNSTABLE_MSC3089_LEAF } from "../@types/event"; import { Room } from "./room"; import { logger } from "../logger"; import { MatrixEvent } from "./event"; import { averageBetweenStrings, DEFAULT_ALPHABET, nextString, prevString } from "../utils"; +import { MSC3089Branch } from "./MSC3089Branch"; /** * The recommended defaults for a tree space's power levels. Note that this @@ -180,7 +181,7 @@ export class MSC3089TreeSpace { for (const child of children) { try { const tree = this.client.unstableGetFileTreeSpace(child.getStateKey()); - trees.push(tree); + if (tree) trees.push(tree); } catch (e) { logger.warn("Unable to create tree space instance for listing. Are we joined?", e); } @@ -378,4 +379,51 @@ export class MSC3089TreeSpace { order: newOrder, }, this.roomId); } + + /** + * Creates (uploads) a new file to this tree. The file must have already been encrypted for the room. + * @param {string} name The name of the file. + * @param {ArrayBuffer} encryptedContents The encrypted contents. + * @param {Partial} info The encrypted file information. + * @returns {Promise} Resolves when uploaded. + */ + public async createFile(name: string, encryptedContents: ArrayBuffer, info: Partial): Promise { + const mxc = await this.client.uploadContent(new Blob([encryptedContents]), { + includeFilename: false, + onlyContentUri: true, + }); + info.url = mxc; + + const res = await this.client.sendMessage(this.roomId, { + msgtype: MsgType.File, + body: name, + url: mxc, + file: info, + [UNSTABLE_MSC3089_LEAF.name]: {}, + }); + + await this.client.sendStateEvent(this.roomId, UNSTABLE_MSC3089_BRANCH.name, { + active: true, + name: name, + }, res['event_id']); + } + + /** + * Retrieves a file from the tree. + * @param {string} fileEventId The event ID of the file. + * @returns {MSC3089Branch} The file, or falsy if not found. + */ + public getFile(fileEventId: string): MSC3089Branch { + const branch = this.room.currentState.getStateEvents(UNSTABLE_MSC3089_BRANCH.name, fileEventId); + return branch ? new MSC3089Branch(this.client, branch) : null; + } + + /** + * Gets an array of all known files for the tree. + * @returns {MSC3089Branch[]} The known files. May be empty, but not null. + */ + public listFiles(): MSC3089Branch[] { + const branches = this.room.currentState.getStateEvents(UNSTABLE_MSC3089_BRANCH.name) ?? []; + return branches.map(e => new MSC3089Branch(this.client, e)).filter(b => b.isActive); + } }