1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-07-31 15:24:23 +03:00

Early file management APIs

This commit is contained in:
Travis Ralston
2021-06-09 21:54:07 -06:00
parent baaf76668f
commit b3a11030f2
7 changed files with 467 additions and 6 deletions

27
spec/MockBlob.ts Normal file
View File

@ -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<number>[]) {
parts.forEach(p => Array.from(p).forEach(e => this.contents.push(e)));
}
public get size(): number {
return this.contents.length;
}
}

View File

@ -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 = <MatrixClient>{
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<EventTimeline>; // 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, '\\$&')}$`),
});
});
});

View File

@ -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 = <MatrixClient>{
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
(<any>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);
});
});

View File

@ -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;
}

View File

@ -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<EventTimeline> {
// don't allow any timeline support unless it's been enabled.
if (!this.timelineSupport) {
throw new Error("timeline support is disabled. Set the 'timelineSupport'" +

102
src/models/MSC3089Branch.ts Normal file
View File

@ -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<void>} Resolves when complete.
*/
public async delete(): Promise<void> {
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<void>} Resolves when complete.
*/
public setName(name: string): Promise<void> {
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 };
}
}

View File

@ -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<IEncryptedFile>} info The encrypted file information.
* @returns {Promise<void>} Resolves when uploaded.
*/
public async createFile(name: string, encryptedContents: ArrayBuffer, info: Partial<IEncryptedFile>): Promise<void> {
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);
}
}