From baaf76668f658ca94befe9c78cde63bca1bc590f Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 9 Jun 2021 19:47:51 -0600 Subject: [PATCH] Early directory management --- spec/unit/models/MSC3089TreeSpace.spec.ts | 557 ++++++++++++++++++++++ spec/unit/utils.spec.js | 77 +++ src/models/MSC3089TreeSpace.ts | 243 ++++++++++ src/utils.ts | 105 ++++ 4 files changed, 982 insertions(+) diff --git a/spec/unit/models/MSC3089TreeSpace.spec.ts b/spec/unit/models/MSC3089TreeSpace.spec.ts index 4e3f069c2..ea6078a67 100644 --- a/spec/unit/models/MSC3089TreeSpace.spec.ts +++ b/spec/unit/models/MSC3089TreeSpace.spec.ts @@ -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; + 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; + (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); + }); + }); }); diff --git a/spec/unit/utils.spec.js b/spec/unit/utils.spec.js index 703326f46..3f461c714 100644 --- a/spec/unit/utils.spec.js +++ b/spec/unit/utils.spec.js @@ -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'); + }); + }); }); diff --git a/src/models/MSC3089TreeSpace.ts b/src/models/MSC3089TreeSpace.ts index 8eb650bd4..b07fca706 100644 --- a/src/models/MSC3089TreeSpace.ts +++ b/src/models/MSC3089TreeSpace.ts @@ -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} Resolves to the created directory. + */ + public async createDirectory(name: string): Promise { + 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} Resolves when complete. + */ + public async delete(): Promise { + 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} Resolves when complete. + * @throws Throws if this is a top level space. + */ + public async setOrder(index: number): Promise { + 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); + } } diff --git a/src/utils.ts b/src/utils.ts index a4a50153a..fd403a32e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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); +}