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

Early directory management

This commit is contained in:
Travis Ralston
2021-06-09 19:47:51 -06:00
parent 9084b4e7aa
commit baaf76668f
4 changed files with 982 additions and 0 deletions

View File

@ -23,6 +23,7 @@ import {
MSC3089TreeSpace,
TreePermissions
} from "../../../src/models/MSC3089TreeSpace";
import { DEFAULT_ALPHABET } from "../../../src/utils";
describe("MSC3089TreeSpace", () => {
let client: MatrixClient;
@ -178,4 +179,560 @@ describe("MSC3089TreeSpace", () => {
it('should support defaults: Owner', () => {
return evaluatePowerLevels({}, TreePermissions.Owner, 100);
});
it('should create subdirectories', async () => {
const subspaceName = "subdirectory";
const subspaceId = "!subspace:localhost";
const domain = "domain.example.com";
client.getRoom = (roomId: string) => {
if (roomId === tree.roomId) {
return tree.room;
} else if (roomId === subspaceId) {
return {} as Room; // we don't need anything important off of this
} else {
throw new Error("Unexpected getRoom call");
}
};
client.getDomain = () => domain;
const createFn = jest.fn().mockImplementation(async (name: string) => {
expect(name).toEqual(subspaceName);
return new MSC3089TreeSpace(client, subspaceId);
});
const sendStateFn = jest.fn().mockImplementation(async (roomId: string, eventType: EventType, content: any, stateKey: string) => {
expect([tree.roomId, subspaceId]).toContain(roomId);
if (roomId === subspaceId) {
expect(eventType).toEqual(EventType.SpaceParent);
expect(stateKey).toEqual(tree.roomId);
} else {
expect(eventType).toEqual(EventType.SpaceChild);
expect(stateKey).toEqual(subspaceId);
}
expect(content).toMatchObject({via: [domain]});
// return value not used
});
client.unstableCreateFileTree = createFn;
client.sendStateEvent = sendStateFn;
const directory = await tree.createDirectory(subspaceName);
expect(directory).toBeDefined();
expect(directory).not.toBeNull();
expect(directory).not.toBe(tree);
expect(directory.roomId).toEqual(subspaceId);
expect(createFn).toHaveBeenCalledTimes(1);
expect(sendStateFn).toHaveBeenCalledTimes(2);
const content = expect.objectContaining({via: [domain]});
expect(sendStateFn).toHaveBeenCalledWith(subspaceId, EventType.SpaceParent, content, tree.roomId);
expect(sendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, content, subspaceId);
});
it('should find subdirectories', () => {
const firstChildRoom = "!one:example.org";
const secondChildRoom = "!two:example.org";
const thirdChildRoom = "!three:example.org"; // to ensure it doesn't end up in the subdirectories
room.currentState = {
getStateEvents: (eventType: EventType, stateKey?: string) => {
expect(eventType).toEqual(EventType.SpaceChild);
expect(stateKey).toBeUndefined();
return [
// Partial implementations of Room
{getStateKey: () => firstChildRoom},
{getStateKey: () => secondChildRoom},
{getStateKey: () => thirdChildRoom},
];
},
};
client.getRoom = () => ({} as Room); // to appease the TreeSpace constructor
const getFn = jest.fn().mockImplementation((roomId: string) => {
if (roomId === thirdChildRoom) {
throw new Error("Mock not-a-space room case called (expected)");
}
expect([firstChildRoom, secondChildRoom]).toContain(roomId);
return new MSC3089TreeSpace(client, roomId);
});
client.unstableGetFileTreeSpace = getFn;
const subdirectories = tree.getDirectories();
expect(subdirectories).toBeDefined();
expect(subdirectories.length).toBe(2);
expect(subdirectories[0].roomId).toBe(firstChildRoom);
expect(subdirectories[1].roomId).toBe(secondChildRoom);
expect(getFn).toHaveBeenCalledTimes(3);
expect(getFn).toHaveBeenCalledWith(firstChildRoom);
expect(getFn).toHaveBeenCalledWith(secondChildRoom);
expect(getFn).toHaveBeenCalledWith(thirdChildRoom); // check to make sure it tried
});
it('should find specific directories', () => {
client.getRoom = () => ({} as Room); // to appease the TreeSpace constructor
// Only mocking used API
const firstSubdirectory = {roomId: "!first:example.org"} as any as MSC3089TreeSpace;
const searchedSubdirectory = {roomId: "!find_me:example.org"} as any as MSC3089TreeSpace;
const thirdSubdirectory = {roomId: "!third:example.org"} as any as MSC3089TreeSpace;
tree.getDirectories = () => [firstSubdirectory, searchedSubdirectory, thirdSubdirectory];
let result = tree.getDirectory(searchedSubdirectory.roomId);
expect(result).toBe(searchedSubdirectory);
result = tree.getDirectory("not a subdirectory");
expect(result).toBeFalsy();
});
it('should be able to delete itself', async () => {
const delete1 = jest.fn().mockImplementation(() => Promise.resolve());
const subdir1 = {delete: delete1} as any as MSC3089TreeSpace; // mock tested bits
const delete2 = jest.fn().mockImplementation(() => Promise.resolve());
const subdir2 = {delete: delete2} as any as MSC3089TreeSpace; // mock tested bits
const joinMemberId = "@join:example.org";
const knockMemberId = "@knock:example.org";
const inviteMemberId = "@invite:example.org";
const leaveMemberId = "@leave:example.org";
const banMemberId = "@ban:example.org";
const selfUserId = "@self:example.org";
tree.getDirectories = () => [subdir1, subdir2];
room.currentState = {
getStateEvents: (eventType: EventType, stateKey?: string) => {
expect(eventType).toEqual(EventType.RoomMember);
expect(stateKey).toBeUndefined();
return [
// Partial implementations
{getContent: () => ({membership: "join"}), getStateKey: () => joinMemberId},
{getContent: () => ({membership: "knock"}), getStateKey: () => knockMemberId},
{getContent: () => ({membership: "invite"}), getStateKey: () => inviteMemberId},
{getContent: () => ({membership: "leave"}), getStateKey: () => leaveMemberId},
{getContent: () => ({membership: "ban"}), getStateKey: () => banMemberId},
// ensure we don't kick ourselves
{getContent: () => ({membership: "join"}), getStateKey: () => selfUserId},
]
},
};
// These two functions are tested by input expectations, so no expectations in the function bodies
const kickFn = jest.fn().mockImplementation((userId) => Promise.resolve());
const leaveFn = jest.fn().mockImplementation(() => Promise.resolve());
client.kick = kickFn;
client.leave = leaveFn;
client.getUserId = () => selfUserId;
await tree.delete();
expect(delete1).toHaveBeenCalledTimes(1);
expect(delete2).toHaveBeenCalledTimes(1);
expect(kickFn).toHaveBeenCalledTimes(3);
expect(kickFn).toHaveBeenCalledWith(tree.roomId, joinMemberId, expect.any(String));
expect(kickFn).toHaveBeenCalledWith(tree.roomId, knockMemberId, expect.any(String));
expect(kickFn).toHaveBeenCalledWith(tree.roomId, inviteMemberId, expect.any(String));
expect(leaveFn).toHaveBeenCalledTimes(1);
});
describe('get and set order', () => {
// Danger: these are partial implementations for testing purposes only
// @ts-ignore - "MatrixEvent is a value but used as a type", which is true but not important
let childState: {[roomId: string]: MatrixEvent[]} = {};
// @ts-ignore - "MatrixEvent is a value but used as a type", which is true but not important
let parentState: MatrixEvent[] = [];
let parentRoom: Room;
let childTrees: MSC3089TreeSpace[];
let rooms: {[roomId: string]: Room};
let clientSendStateFn: jest.MockedFunction<typeof client.sendStateEvent>;
const staticDomain = "static.example.org";
function addSubspace(roomId: string, createTs?: number, order?: string) {
const content = {
via: [staticDomain],
};
if (order) content['order'] = order;
parentState.push({
getType: () => EventType.SpaceChild,
getStateKey: () => roomId,
getContent: () => content,
});
childState[roomId] = [
{
getType: () => EventType.SpaceParent,
getStateKey: () => tree.roomId,
getContent: () => ({
via: [staticDomain]
}),
},
];
if (createTs) {
childState[roomId].push({
getType: () => EventType.RoomCreate,
getStateKey: () => "",
getContent: () => ({}),
getTs: () => createTs,
});
}
rooms[roomId] = makeMockChildRoom(roomId);
childTrees.push(new MSC3089TreeSpace(client, roomId));
}
function expectOrder(childRoomId: string, order: number) {
const child = childTrees.find(c => c.roomId === childRoomId);
expect(child).toBeDefined();
expect(child.getOrder()).toEqual(order);
}
function makeMockChildRoom(roomId: string): Room {
return {
currentState: {
getStateEvents: (eventType: EventType, stateKey?: string) => {
expect([EventType.SpaceParent, EventType.RoomCreate]).toContain(eventType);
if (eventType === EventType.RoomCreate) {
expect(stateKey).toEqual("");
return childState[roomId].find(e => e.getType() === EventType.RoomCreate);
} else {
expect(stateKey).toBeUndefined();
return childState[roomId].filter(e => e.getType() === eventType);
}
},
},
} as Room; // partial
}
beforeEach(() => {
childState = {};
parentState = [];
parentRoom = {
...tree.room,
roomId: tree.roomId,
currentState: {
getStateEvents: (eventType: EventType, stateKey?: string) => {
expect([EventType.SpaceChild, EventType.RoomCreate, EventType.SpaceParent]).toContain(eventType);
if (eventType === EventType.RoomCreate) {
expect(stateKey).toEqual("");
return parentState.filter(e => e.getType() === EventType.RoomCreate)[0];
} else {
if (stateKey !== undefined) {
expect(Object.keys(rooms)).toContain(stateKey);
expect(stateKey).not.toEqual(tree.roomId);
return parentState.find(e => e.getType() === eventType && e.getStateKey() === stateKey);
} // else fine
return parentState.filter(e => e.getType() === eventType);
}
},
},
} as Room;
childTrees = [];
rooms = {};
rooms[tree.roomId] = parentRoom;
(<any>tree).room = parentRoom; // override readonly
client.getRoom = (r) => rooms[r];
clientSendStateFn = jest.fn().mockImplementation((roomId: string, eventType: EventType, content: any, stateKey: string) => {
expect(roomId).toEqual(tree.roomId);
expect(eventType).toEqual(EventType.SpaceChild);
expect(content).toMatchObject(expect.objectContaining({
via: expect.any(Array),
order: expect.any(String),
}));
expect(Object.keys(rooms)).toContain(stateKey);
expect(stateKey).not.toEqual(tree.roomId);
const stateEvent = parentState.find(e => e.getType() === eventType && e.getStateKey() === stateKey);
expect(stateEvent).toBeDefined();
stateEvent.getContent = () => content;
return Promise.resolve(); // return value not used
});
client.sendStateEvent = clientSendStateFn;
});
it('should know when something is top level', () => {
const a = "!a:example.org";
addSubspace(a);
expect(tree.isTopLevel).toBe(true);
expect(childTrees[0].isTopLevel).toBe(false); // a bit of a hack to get at this, but it's fine
});
it('should return -1 for top level spaces', () => {
// The tree is what we've defined as top level, so it should work
expect(tree.getOrder()).toEqual(-1);
});
it('should throw when setting an order at the top level space', async () => {
try {
// The tree is what we've defined as top level, so it should work
await tree.setOrder(2);
// noinspection ExceptionCaughtLocallyJS
throw new Error("Failed to fail");
} catch (e) {
expect(e.message).toEqual("Cannot set order of top level spaces currently");
}
});
it('should return a stable order for unordered children', () => {
const a = "!a:example.org";
const b = "!b:example.org";
const c = "!c:example.org";
// Add in reverse order to make sure it gets ordered correctly
addSubspace(c, 3);
addSubspace(b, 2);
addSubspace(a, 1);
expectOrder(a, 0);
expectOrder(b, 1);
expectOrder(c, 2);
});
it('should return a stable order for ordered children', () => {
const a = "!a:example.org";
const b = "!b:example.org";
const c = "!c:example.org";
// Add in reverse order to make sure it gets ordered correctly
addSubspace(a, 1, "Z");
addSubspace(b, 2, "Y");
addSubspace(c, 3, "X");
expectOrder(c, 0);
expectOrder(b, 1);
expectOrder(a, 2);
});
it('should return a stable order for partially ordered children', () => {
const a = "!a:example.org";
const b = "!b:example.org";
const c = "!c:example.org";
const d = "!d:example.org";
// Add in reverse order to make sure it gets ordered correctly
addSubspace(a, 1);
addSubspace(b, 2);
addSubspace(c, 3, "Y");
addSubspace(d, 4, "X");
expectOrder(d, 0);
expectOrder(c, 1);
expectOrder(b, 3); // note order diff due to room ID comparison expectation
expectOrder(a, 2);
});
it('should return a stable order if the create event timestamps are the same', () => {
const a = "!a:example.org";
const b = "!b:example.org";
const c = "!c:example.org";
// Add in reverse order to make sure it gets ordered correctly
addSubspace(c, 3);
addSubspace(b, 3); // same as C
addSubspace(a, 3); // same as C
expectOrder(a, 0);
expectOrder(b, 1);
expectOrder(c, 2);
});
it('should return a stable order if there are no known create events', () => {
const a = "!a:example.org";
const b = "!b:example.org";
const c = "!c:example.org";
// Add in reverse order to make sure it gets ordered correctly
addSubspace(c);
addSubspace(b);
addSubspace(a);
expectOrder(a, 0);
expectOrder(b, 1);
expectOrder(c, 2);
});
// XXX: These tests rely on `getOrder()` re-calculating and not caching values.
it('should allow reordering within unordered children', async () => {
const a = "!a:example.org";
const b = "!b:example.org";
const c = "!c:example.org";
// Add in reverse order to make sure it gets ordered correctly
addSubspace(c, 3);
addSubspace(b, 2);
addSubspace(a, 1);
// Order of this state is validated by other tests.
const treeA = childTrees.find(c => c.roomId === a);
expect(treeA).toBeDefined();
await treeA.setOrder(1);
expect(clientSendStateFn).toHaveBeenCalledTimes(3);
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
via: [staticDomain], // should retain domain independent of client.getDomain()
// Because of how the reordering works (maintain stable ordering before moving), we end up calling this
// function twice for the same room.
order: DEFAULT_ALPHABET[0],
}), a);
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
via: [staticDomain], // should retain domain independent of client.getDomain()
order: DEFAULT_ALPHABET[1],
}), b);
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
via: [staticDomain], // should retain domain independent of client.getDomain()
order: DEFAULT_ALPHABET[2],
}), a);
expectOrder(a, 1);
expectOrder(b, 0);
expectOrder(c, 2);
});
it('should allow reordering within ordered children', async () => {
const a = "!a:example.org";
const b = "!b:example.org";
const c = "!c:example.org";
// Add in reverse order to make sure it gets ordered correctly
addSubspace(c, 3, "Z");
addSubspace(b, 2, "X");
addSubspace(a, 1, "V");
// Order of this state is validated by other tests.
const treeA = childTrees.find(c => c.roomId === a);
expect(treeA).toBeDefined();
await treeA.setOrder(1);
expect(clientSendStateFn).toHaveBeenCalledTimes(1);
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
via: [staticDomain], // should retain domain independent of client.getDomain()
order: 'Y',
}), a);
expectOrder(a, 1);
expectOrder(b, 0);
expectOrder(c, 2);
});
it('should allow reordering within partially ordered children', async () => {
const a = "!a:example.org";
const b = "!b:example.org";
const c = "!c:example.org";
const d = "!d:example.org";
// Add in reverse order to make sure it gets ordered correctly
addSubspace(a, 1);
addSubspace(b, 2);
addSubspace(c, 3, "Y");
addSubspace(d, 4, "W");
// Order of this state is validated by other tests.
const treeA = childTrees.find(c => c.roomId === a);
expect(treeA).toBeDefined();
await treeA.setOrder(2);
expect(clientSendStateFn).toHaveBeenCalledTimes(1);
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
via: [staticDomain], // should retain domain independent of client.getDomain()
order: 'Z',
}), a);
expectOrder(a, 2);
expectOrder(b, 3);
expectOrder(c, 1);
expectOrder(d, 0);
});
it('should support moving upwards', async () => {
const a = "!a:example.org";
const b = "!b:example.org";
const c = "!c:example.org";
const d = "!d:example.org";
// Add in reverse order to make sure it gets ordered correctly
addSubspace(d, 4, "Z")
addSubspace(c, 3, "X");
addSubspace(b, 2, "V");
addSubspace(a, 1, "T");
// Order of this state is validated by other tests.
const treeB = childTrees.find(c => c.roomId === b);
expect(treeB).toBeDefined();
await treeB.setOrder(2);
expect(clientSendStateFn).toHaveBeenCalledTimes(1);
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
via: [staticDomain], // should retain domain independent of client.getDomain()
order: 'Y',
}), b);
expectOrder(a, 0);
expectOrder(b, 2);
expectOrder(c, 1);
expectOrder(d, 3);
});
it('should support moving downwards', async () => {
const a = "!a:example.org";
const b = "!b:example.org";
const c = "!c:example.org";
const d = "!d:example.org";
// Add in reverse order to make sure it gets ordered correctly
addSubspace(d, 4, "Z")
addSubspace(c, 3, "X");
addSubspace(b, 2, "V");
addSubspace(a, 1, "T");
// Order of this state is validated by other tests.
const treeC = childTrees.find(ch => ch.roomId === c);
expect(treeC).toBeDefined();
await treeC.setOrder(1);
expect(clientSendStateFn).toHaveBeenCalledTimes(1);
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
via: [staticDomain], // should retain domain independent of client.getDomain()
order: 'U',
}), c);
expectOrder(a, 0);
expectOrder(b, 2);
expectOrder(c, 1);
expectOrder(d, 3);
});
it('should support moving over the partial ordering boundary', async () => {
const a = "!a:example.org";
const b = "!b:example.org";
const c = "!c:example.org";
const d = "!d:example.org";
// Add in reverse order to make sure it gets ordered correctly
addSubspace(d, 4)
addSubspace(c, 3);
addSubspace(b, 2, "V");
addSubspace(a, 1, "T");
// Order of this state is validated by other tests.
const treeB = childTrees.find(ch => ch.roomId === b);
expect(treeB).toBeDefined();
await treeB.setOrder(2);
expect(clientSendStateFn).toHaveBeenCalledTimes(2);
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
via: [staticDomain], // should retain domain independent of client.getDomain()
order: 'W',
}), c);
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
via: [staticDomain], // should retain domain independent of client.getDomain()
order: 'X',
}), b);
expectOrder(a, 0);
expectOrder(b, 2);
expectOrder(c, 1);
expectOrder(d, 3);
});
});
});

View File

@ -1,4 +1,12 @@
import * as utils from "../../src/utils";
import {
alphabetPad,
averageBetweenStrings,
baseToString,
DEFAULT_ALPHABET,
nextString, prevString,
stringToBase
} from "../../src/utils";
describe("utils", function() {
describe("encodeParams", function() {
@ -259,4 +267,73 @@ describe("utils", function() {
expect(promiseCount).toEqual(2);
});
});
describe('DEFAULT_ALPHABET', () => {
it('should be usefully printable ASCII in order', () => {
expect(DEFAULT_ALPHABET).toEqual(" !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~");
});
});
describe('alphabetPad', () => {
it('should pad to the alphabet length', () => {
const defaultPrefixFor1char = [""].reduce(() => {
let s = "";
for (let i = 0; i < DEFAULT_ALPHABET.length - 1; i++) {
s += DEFAULT_ALPHABET[0];
}
return s;
}, "");
expect(alphabetPad("a")).toEqual(defaultPrefixFor1char + "a");
expect(alphabetPad("a", "123")).toEqual("11a");
});
});
describe('baseToString', () => {
it('should calculate the appropriate string from numbers', () => {
expect(baseToString(10)).toEqual(DEFAULT_ALPHABET[10]);
expect(baseToString(10, "abcdefghijklmnopqrstuvwxyz")).toEqual('k');
expect(baseToString(6241)).toEqual("ab");
expect(baseToString(53, "abcdefghijklmnopqrstuvwxyz")).toEqual('cb');
});
});
describe('stringToBase', () => {
it('should calculate the appropriate number for a string', () => {
expect(stringToBase(" ")).toEqual(0);
expect(stringToBase("a", "abcdefghijklmnopqrstuvwxyz")).toEqual(0);
expect(stringToBase("a")).toEqual(65);
expect(stringToBase("c", "abcdefghijklmnopqrstuvwxyz")).toEqual(2);
expect(stringToBase("ab")).toEqual(6241);
expect(stringToBase("cb", "abcdefghijklmnopqrstuvwxyz")).toEqual(53);
});
});
describe('averageBetweenStrings', () => {
it('should average appropriately', () => {
expect(averageBetweenStrings('A', 'z')).toEqual(']');
expect(averageBetweenStrings('a', 'z', "abcdefghijklmnopqrstuvwxyz")).toEqual('m');
expect(averageBetweenStrings('AA', 'zz')).toEqual('^.');
expect(averageBetweenStrings('aa', 'zz', "abcdefghijklmnopqrstuvwxyz")).toEqual('mz');
expect(averageBetweenStrings('cat', 'doggo')).toEqual("BH65B");
expect(averageBetweenStrings('cat', 'doggo', "abcdefghijklmnopqrstuvwxyz")).toEqual("buedq");
});
});
describe('nextString', () => {
it('should find the next string appropriately', () => {
expect(nextString('A')).toEqual('B');
expect(nextString('b', 'abcdefghijklmnopqrstuvwxyz')).toEqual('c');
expect(nextString('cat')).toEqual('cau');
expect(nextString('cat', 'abcdefghijklmnopqrstuvwxyz')).toEqual('cau');
});
});
describe('prevString', () => {
it('should find the next string appropriately', () => {
expect(prevString('B')).toEqual('A');
expect(prevString('c', 'abcdefghijklmnopqrstuvwxyz')).toEqual('b');
expect(prevString('cau')).toEqual('cat');
expect(prevString('cau', 'abcdefghijklmnopqrstuvwxyz')).toEqual('cat');
});
});
});

View File

@ -17,6 +17,9 @@ limitations under the License.
import { MatrixClient } from "../client";
import { EventType } from "../@types/event";
import { Room } from "./room";
import { logger } from "../logger";
import { MatrixEvent } from "./event";
import { averageBetweenStrings, DEFAULT_ALPHABET, nextString, prevString } from "../utils";
/**
* The recommended defaults for a tree space's power levels. Note that this
@ -47,6 +50,7 @@ export const DEFAULT_TREE_POWER_LEVELS_TEMPLATE = {
[EventType.RoomMessageEncrypted]: 50,
[EventType.Sticker]: 50,
},
users: {}, // defined by calling code
};
@ -70,6 +74,8 @@ export class MSC3089TreeSpace {
public constructor(private client: MatrixClient, public readonly roomId: string) {
this.room = this.client.getRoom(this.roomId);
if (!this.room) throw new Error("Unknown room");
}
/**
@ -79,6 +85,15 @@ export class MSC3089TreeSpace {
return this.roomId;
}
/**
* Whether or not this is a top level space.
*/
public get isTopLevel(): boolean {
const parentEvents = this.room.currentState.getStateEvents(EventType.SpaceParent);
if (!parentEvents?.length) return true;
return parentEvents.every(e => !e.getContent()?.['via']);
}
/**
* Sets the name of the tree space.
* @param {string} name The new name for the space.
@ -135,4 +150,232 @@ export class MSC3089TreeSpace {
return this.client.sendStateEvent(this.roomId, EventType.RoomPowerLevels, pls, "");
}
/**
* Creates a directory under this tree space, represented as another tree space.
* @param {string} name The name for the directory.
* @returns {Promise<MSC3089TreeSpace>} Resolves to the created directory.
*/
public async createDirectory(name: string): Promise<MSC3089TreeSpace> {
const directory = await this.client.unstableCreateFileTree(name);
await this.client.sendStateEvent(this.roomId, EventType.SpaceChild, {
via: [this.client.getDomain()],
}, directory.roomId);
await this.client.sendStateEvent(directory.roomId, EventType.SpaceParent, {
via: [this.client.getDomain()],
}, this.roomId);
return directory;
}
/**
* Gets a list of all known immediate subdirectories to this tree space.
* @returns {MSC3089TreeSpace[]} The tree spaces (directories). May be empty, but not null.
*/
public getDirectories(): MSC3089TreeSpace[] {
const trees: MSC3089TreeSpace[] = [];
const children = this.room.currentState.getStateEvents(EventType.SpaceChild);
for (const child of children) {
try {
const tree = this.client.unstableGetFileTreeSpace(child.getStateKey());
trees.push(tree);
} catch (e) {
logger.warn("Unable to create tree space instance for listing. Are we joined?", e);
}
}
return trees;
}
/**
* Gets a subdirectory of a given ID under this tree space. Note that this will not recurse
* into children and instead only look one level deep.
* @param {string} roomId The room ID (directory ID) to find.
* @returns {MSC3089TreeSpace} The directory, or falsy if not found.
*/
public getDirectory(roomId: string): MSC3089TreeSpace {
return this.getDirectories().find(r => r.roomId === roomId);
}
/**
* Deletes the tree, kicking all members and deleting **all subdirectories**.
* @returns {Promise<void>} Resolves when complete.
*/
public async delete(): Promise<void> {
const subdirectories = this.getDirectories();
for (const dir of subdirectories) {
await dir.delete();
}
const kickMemberships = ["invite", "knock", "join"];
const members = this.room.currentState.getStateEvents(EventType.RoomMember);
for (const member of members) {
if (member.getStateKey() !== this.client.getUserId() && kickMemberships.includes(member.getContent()['membership'])) {
await this.client.kick(this.roomId, member.getStateKey(), "Room deleted");
}
}
await this.client.leave(this.roomId);
}
private getOrderedChildren(children: MatrixEvent[]): {roomId: string, order: string}[] {
const ordered: {roomId: string, order: string}[] = children
.map(c => ({roomId: c.getStateKey(), order: c.getContent()['order']}));
ordered.sort((a, b) => {
if (a.order && !b.order) {
return -1;
} else if (!a.order && b.order) {
return 1;
} else if (!a.order && !b.order) {
const roomA = this.client.getRoom(a.roomId);
const roomB = this.client.getRoom(b.roomId);
if (!roomA || !roomB) { // just don't bother trying to do more partial sorting
return a.roomId.localeCompare(b.roomId);
}
const createTsA = roomA.currentState.getStateEvents(EventType.RoomCreate, "")?.getTs() ?? 0;
const createTsB = roomB.currentState.getStateEvents(EventType.RoomCreate, "")?.getTs() ?? 0;
if (createTsA === createTsB) {
return a.roomId.localeCompare(b.roomId);
}
return createTsA - createTsB;
} else { // both not-null orders
return a.order.localeCompare(b.order);
}
});
return ordered;
}
private getParentRoom(): Room {
const parents = this.room.currentState.getStateEvents(EventType.SpaceParent);
const parent = parents[0]; // XXX: Wild assumption
if (!parent) throw new Error("Expected to have a parent in a non-top level space");
// XXX: We are assuming the parent is a valid tree space.
// We probably don't need to validate the parent room state for this usecase though.
const parentRoom = this.client.getRoom(parent.getStateKey());
if (!parentRoom) throw new Error("Unable to locate room for parent");
return parentRoom;
}
/**
* Gets the current order index for this directory. Note that if this is the top level space
* then -1 will be returned.
* @returns {number} The order index of this space.
*/
public getOrder(): number {
if (this.isTopLevel) return -1;
const parentRoom = this.getParentRoom();
const children = parentRoom.currentState.getStateEvents(EventType.SpaceChild);
const ordered = this.getOrderedChildren(children);
return ordered.findIndex(c => c.roomId === this.roomId);
}
/**
* Sets the order index for this directory within its parent. Note that if this is a top level
* space then an error will be thrown. -1 can be used to move the child to the start, and numbers
* larger than the number of children can be used to move the child to the end.
* @param {number} index The new order index for this space.
* @returns {Promise<void>} Resolves when complete.
* @throws Throws if this is a top level space.
*/
public async setOrder(index: number): Promise<void> {
if (this.isTopLevel) throw new Error("Cannot set order of top level spaces currently");
const parentRoom = this.getParentRoom();
const children = parentRoom.currentState.getStateEvents(EventType.SpaceChild);
const ordered = this.getOrderedChildren(children);
index = Math.max(Math.min(index, ordered.length - 1), 0);
const currentIndex = this.getOrder();
const movingUp = currentIndex < index;
if (movingUp && index === (ordered.length - 1)) {
index--;
} else if (!movingUp && index === 0) {
index++;
}
const prev = ordered[movingUp ? index : (index - 1)];
const next = ordered[movingUp ? (index + 1) : index];
let newOrder = DEFAULT_ALPHABET[0];
let ensureBeforeIsSane = false;
if (!prev) {
// Move to front
if (next?.order) {
newOrder = prevString(next.order);
}
} else if (index === (ordered.length - 1)) {
// Move to back
if (next?.order) {
newOrder = nextString(next.order);
}
} else {
// Move somewhere in the middle
const startOrder = prev?.order;
const endOrder = next?.order;
if (startOrder && endOrder) {
if (startOrder === endOrder) {
// Error case: just move +1 to break out of awful math
newOrder = nextString(startOrder);
} else {
newOrder = averageBetweenStrings(startOrder, endOrder);
}
} else {
if (startOrder) {
// We're at the end (endOrder is null, so no explicit order)
newOrder = nextString(startOrder);
} else if (endOrder) {
// We're at the start (startOrder is null, so nothing before us)
newOrder = prevString(endOrder);
} else {
// Both points are unknown. We're likely in a range where all the children
// don't have particular order values, so we may need to update them too.
// The other possibility is there's only us as a child, but we should have
// shown up in the other states.
ensureBeforeIsSane = true;
}
}
}
if (ensureBeforeIsSane) {
// We were asked by the order algorithm to prepare the moving space for a landing
// in the undefined order part of the order array, which means we need to update the
// spaces that come before it with a stable order value.
let lastOrder: string;
for (let i = 0; i <= index; i++) {
const target = ordered[i];
if (i === 0) {
lastOrder = target.order;
}
if (!target.order) {
// XXX: We should be creating gaps to avoid conflicts
lastOrder = lastOrder ? nextString(lastOrder) : DEFAULT_ALPHABET[0];
const currentChild = parentRoom.currentState.getStateEvents(EventType.SpaceChild, target.roomId);
const content = currentChild?.getContent() ?? {via: [this.client.getDomain()]};
await this.client.sendStateEvent(parentRoom.roomId, EventType.SpaceChild, {
...content,
order: lastOrder,
}, target.roomId);
} else {
lastOrder = target.order;
}
}
newOrder = nextString(lastOrder);
}
// TODO: Deal with order conflicts by reordering
// Now we can finally update our own order state
const currentChild = parentRoom.currentState.getStateEvents(EventType.SpaceChild, this.roomId);
const content = currentChild?.getContent() ?? {via: [this.client.getDomain()]};
await this.client.sendStateEvent(parentRoom.roomId, EventType.SpaceChild, {
...content,
order: newOrder,
}, this.roomId);
}
}

View File

@ -456,3 +456,108 @@ export function setCrypto(c: Object) {
export function getCrypto(): Object {
return crypto;
}
// String averaging based upon https://stackoverflow.com/a/2510816
// Dev note: We make the alphabet a string because it's easier to write syntactically
// than arrays. Thankfully, strings implement the useful parts of the Array interface
// anyhow.
/**
* The default alphabet used by string averaging in this SDK. This matches
* all usefully printable ASCII characters (0x20-0x7E, inclusive).
*/
export const DEFAULT_ALPHABET = [""].reduce(() => {
let str = "";
for (let c = 0x20; c <= 0x7E; c++) {
str += String.fromCharCode(c);
}
return str;
}, "");
/**
* Pads a string using the given alphabet as a base. The returned string will be the
* same length as the alphabet, and padded with the first character in the alphabet.
*
* This is intended for use with string averaging.
* @param {string} s The string to pad.
* @param {string} alphabet The alphabet to use as a single string.
* @returns {string} The padded string.
*/
export function alphabetPad(s: string, alphabet = DEFAULT_ALPHABET): string {
while (s.length < alphabet.length) {
s = alphabet[0] + s;
}
return s;
}
/**
* Converts a baseN number to a string, where N is the alphabet's length.
*
* This is intended for use with string averaging.
* @param {number} n The baseN number.
* @param {string} alphabet The alphabet to use as a single string.
* @returns {string} The baseN number encoded as a string from the alphabet.
*/
export function baseToString(n: number, alphabet = DEFAULT_ALPHABET): string {
const len = alphabet.length;
if (n < len) {
return alphabet[n];
}
return baseToString(Math.floor(n / len), alphabet) + alphabet[n % len];
}
/**
* Converts a string to a baseN number, where N is the alphabet's length.
*
* This is intended for use with string averaging.
* @param {string} s The string to convert to a number.
* @param {string} alphabet The alphabet to use as a single string.
* @returns {number} The baseN number.
*/
export function stringToBase(s: string, alphabet = DEFAULT_ALPHABET): number {
s = alphabetPad(s, alphabet);
const len = alphabet.length;
const reversedStr = Array.from(s).reverse();
let result = 0;
for (let i = 0; i < len; i++) {
result += alphabet.indexOf(reversedStr[i]) * (len ** i);
}
return result;
}
/**
* Averages two strings, returning the midpoint between them. This is accomplished by
* converting both to baseN numbers (where N is the alphabet's length) then averaging
* those before re-encoding as a string.
* @param {string} a The first string.
* @param {string} b The second string.
* @param {string} alphabet The alphabet to use as a single string.
* @returns {string} The midpoint between the strings, as a string.
*/
export function averageBetweenStrings(a: string, b: string, alphabet = DEFAULT_ALPHABET): string {
return baseToString(Math.floor((stringToBase(a, alphabet) + stringToBase(b, alphabet)) / 2), alphabet);
}
/**
* Finds the next string using the alphabet provided. This is done by converting the
* string to a baseN number, where N is the alphabet's length, then adding 1 before
* converting back to a string.
* @param {string} s The string to start at.
* @param {string} alphabet The alphabet to use as a single string.
* @returns {string} The string which follows the input string.
*/
export function nextString(s: string, alphabet = DEFAULT_ALPHABET): string {
return baseToString(stringToBase(s, alphabet) + 1, alphabet);
}
/**
* Finds the previous string using the alphabet provided. This is done by converting the
* string to a baseN number, where N is the alphabet's length, then subtracting 1 before
* converting back to a string.
* @param {string} s The string to start at.
* @param {string} alphabet The alphabet to use as a single string.
* @returns {string} The string which precedes the input string.
*/
export function prevString(s: string, alphabet = DEFAULT_ALPHABET): string {
return baseToString(stringToBase(s, alphabet) - 1, alphabet);
}