diff --git a/spec/MockBlob.ts b/spec/MockBlob.ts new file mode 100644 index 00000000000..04d01c24e1d --- /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/NamespacedValue.spec.ts b/spec/unit/NamespacedValue.spec.ts new file mode 100644 index 00000000000..834acd0c9fa --- /dev/null +++ b/spec/unit/NamespacedValue.spec.ts @@ -0,0 +1,78 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { NamespacedValue, UnstableValue } from "../../src/NamespacedValue"; + +describe("NamespacedValue", () => { + it("should prefer stable over unstable", () => { + const ns = new NamespacedValue("stable", "unstable"); + expect(ns.name).toBe(ns.stable); + expect(ns.altName).toBe(ns.unstable); + }); + + it("should return unstable if there is no stable", () => { + const ns = new NamespacedValue(null, "unstable"); + expect(ns.name).toBe(ns.unstable); + expect(ns.altName).toBeFalsy(); + }); + + it("should have a falsey unstable if needed", () => { + const ns = new NamespacedValue("stable", null); + expect(ns.name).toBe(ns.stable); + expect(ns.altName).toBeFalsy(); + }); + + it("should match against either stable or unstable", () => { + const ns = new NamespacedValue("stable", "unstable"); + expect(ns.matches("no")).toBe(false); + expect(ns.matches(ns.stable)).toBe(true); + expect(ns.matches(ns.unstable)).toBe(true); + }); + + it("should not permit falsey values for both parts", () => { + try { + new UnstableValue(null, null); + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toBe("One of stable or unstable values must be supplied"); + } + }); +}); + +describe("UnstableValue", () => { + it("should prefer unstable over stable", () => { + const ns = new UnstableValue("stable", "unstable"); + expect(ns.name).toBe(ns.unstable); + expect(ns.altName).toBe(ns.stable); + }); + + it("should return unstable if there is no stable", () => { + const ns = new UnstableValue(null, "unstable"); + expect(ns.name).toBe(ns.unstable); + expect(ns.altName).toBeFalsy(); + }); + + it("should not permit falsey unstable values", () => { + try { + new UnstableValue("stable", null); + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toBe("Unstable value must be supplied"); + } + }); +}); diff --git a/spec/unit/matrix-client.spec.js b/spec/unit/matrix-client.spec.js index 98c6b127e4e..2523c98bdfb 100644 --- a/spec/unit/matrix-client.spec.js +++ b/spec/unit/matrix-client.spec.js @@ -1,6 +1,17 @@ import { logger } from "../../src/logger"; import { MatrixClient } from "../../src/client"; import { Filter } from "../../src/filter"; +import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE } from "../../src/models/MSC3089TreeSpace"; +import { + EventType, + RoomCreateTypeField, + RoomType, + UNSTABLE_MSC3088_ENABLED, + UNSTABLE_MSC3088_PURPOSE, + UNSTABLE_MSC3089_TREE_SUBTYPE, +} from "../../src/@types/event"; +import { MEGOLM_ALGORITHM } from "../../src/crypto/olmlib"; +import { MatrixEvent } from "../../src/models/event"; jest.useFakeTimers(); @@ -171,6 +182,160 @@ describe("MatrixClient", function() { }); }); + it("should create (unstable) file trees", async () => { + const userId = "@test:example.org"; + const roomId = "!room:example.org"; + const roomName = "Test Tree"; + const mockRoom = {}; + const fn = jest.fn().mockImplementation((opts) => { + expect(opts).toMatchObject({ + name: roomName, + preset: "private_chat", + power_level_content_override: { + ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE, + users: { + [userId]: 100, + }, + }, + creation_content: { + [RoomCreateTypeField]: RoomType.Space, + }, + initial_state: [ + { + // We use `unstable` to ensure that the code is actually using the right identifier + type: UNSTABLE_MSC3088_PURPOSE.unstable, + state_key: UNSTABLE_MSC3089_TREE_SUBTYPE.unstable, + content: { + [UNSTABLE_MSC3088_ENABLED.unstable]: true, + }, + }, + { + type: EventType.RoomEncryption, + state_key: "", + content: { + algorithm: MEGOLM_ALGORITHM, + }, + }, + ], + }); + return { room_id: roomId }; + }); + client.getUserId = () => userId; + client.createRoom = fn; + client.getRoom = (getRoomId) => { + expect(getRoomId).toEqual(roomId); + return mockRoom; + }; + const tree = await client.unstableCreateFileTree(roomName); + expect(tree).toBeDefined(); + expect(tree.roomId).toEqual(roomId); + expect(tree.room).toBe(mockRoom); + expect(fn.mock.calls.length).toBe(1); + }); + + it("should get (unstable) file trees with valid state", async () => { + const roomId = "!room:example.org"; + const mockRoom = { + currentState: { + getStateEvents: (eventType, stateKey) => { + if (eventType === EventType.RoomCreate) { + expect(stateKey).toEqual(""); + return new MatrixEvent({ + content: { + [RoomCreateTypeField]: RoomType.Space, + }, + }); + } else if (eventType === UNSTABLE_MSC3088_PURPOSE.unstable) { + // We use `unstable` to ensure that the code is actually using the right identifier + expect(stateKey).toEqual(UNSTABLE_MSC3089_TREE_SUBTYPE.unstable); + return new MatrixEvent({ + content: { + [UNSTABLE_MSC3088_ENABLED.unstable]: true, + }, + }); + } else { + throw new Error("Unexpected event type or state key"); + } + }, + }, + }; + client.getRoom = (getRoomId) => { + expect(getRoomId).toEqual(roomId); + return mockRoom; + }; + const tree = client.unstableGetFileTreeSpace(roomId); + expect(tree).toBeDefined(); + expect(tree.roomId).toEqual(roomId); + expect(tree.room).toBe(mockRoom); + }); + + it("should not get (unstable) file trees with invalid create contents", async () => { + const roomId = "!room:example.org"; + const mockRoom = { + currentState: { + getStateEvents: (eventType, stateKey) => { + if (eventType === EventType.RoomCreate) { + expect(stateKey).toEqual(""); + return new MatrixEvent({ + content: { + [RoomCreateTypeField]: "org.example.not_space", + }, + }); + } else if (eventType === UNSTABLE_MSC3088_PURPOSE.unstable) { + // We use `unstable` to ensure that the code is actually using the right identifier + expect(stateKey).toEqual(UNSTABLE_MSC3089_TREE_SUBTYPE.unstable); + return new MatrixEvent({ + content: { + [UNSTABLE_MSC3088_ENABLED.unstable]: true, + }, + }); + } else { + throw new Error("Unexpected event type or state key"); + } + }, + }, + }; + client.getRoom = (getRoomId) => { + expect(getRoomId).toEqual(roomId); + return mockRoom; + }; + const tree = client.unstableGetFileTreeSpace(roomId); + expect(tree).toBeFalsy(); + }); + + it("should not get (unstable) file trees with invalid purpose/subtype contents", async () => { + const roomId = "!room:example.org"; + const mockRoom = { + currentState: { + getStateEvents: (eventType, stateKey) => { + if (eventType === EventType.RoomCreate) { + expect(stateKey).toEqual(""); + return new MatrixEvent({ + content: { + [RoomCreateTypeField]: RoomType.Space, + }, + }); + } else if (eventType === UNSTABLE_MSC3088_PURPOSE.unstable) { + expect(stateKey).toEqual(UNSTABLE_MSC3089_TREE_SUBTYPE.unstable); + return new MatrixEvent({ + content: { + [UNSTABLE_MSC3088_ENABLED.unstable]: false, + }, + }); + } else { + throw new Error("Unexpected event type or state key"); + } + }, + }, + }; + client.getRoom = (getRoomId) => { + expect(getRoomId).toEqual(roomId); + return mockRoom; + }; + const tree = client.unstableGetFileTreeSpace(roomId); + expect(tree).toBeFalsy(); + }); + it("should not POST /filter if a matching filter already exists", async function() { httpLookups = []; httpLookups.push(PUSH_RULES_RESPONSE); diff --git a/spec/unit/models/MSC3089Branch.spec.ts b/spec/unit/models/MSC3089Branch.spec.ts new file mode 100644 index 00000000000..fc8b35815c3 --- /dev/null +++ b/spec/unit/models/MSC3089Branch.spec.ts @@ -0,0 +1,155 @@ +/* +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 new file mode 100644 index 00000000000..a99140036bb --- /dev/null +++ b/spec/unit/models/MSC3089TreeSpace.spec.ts @@ -0,0 +1,857 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixClient } from "../../../src"; +import { Room } from "../../../src/models/room"; +import { MatrixEvent } from "../../../src/models/event"; +import { EventType, 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; + let room: Room; + let tree: MSC3089TreeSpace; + const roomId = "!tree:localhost"; + const targetUser = "@target:example.org"; + + let powerLevels; + + beforeEach(() => { + // TODO: Use utility functions to create test rooms and clients + client = { + getRoom: (fetchRoomId: string) => { + if (fetchRoomId === roomId) { + return room; + } else { + throw new Error("Unexpected fetch for unknown room"); + } + }, + }; + room = { + currentState: { + getStateEvents: (evType: EventType, stateKey: string) => { + if (evType === EventType.RoomPowerLevels && stateKey === "") { + return powerLevels; + } else { + throw new Error("Accessed unexpected state event type or key"); + } + }, + }, + }; + tree = new MSC3089TreeSpace(client, roomId); + makePowerLevels(DEFAULT_TREE_POWER_LEVELS_TEMPLATE); + }); + + function makePowerLevels(content: any) { + powerLevels = new MatrixEvent({ + type: EventType.RoomPowerLevels, + state_key: "", + sender: "@creator:localhost", + event_id: "$powerlevels", + room_id: roomId, + content: content, + }); + } + + it('should populate the room reference', () => { + expect(tree.room).toBe(room); + }); + + it('should proxy the ID member to room ID', () => { + expect(tree.id).toEqual(tree.roomId); + expect(tree.id).toEqual(roomId); + }); + + it('should support setting the name of the space', async () => { + const newName = "NEW NAME"; + const fn = jest.fn() + .mockImplementation((stateRoomId: string, eventType: EventType, content: any, stateKey: string) => { + expect(stateRoomId).toEqual(roomId); + expect(eventType).toEqual(EventType.RoomName); + expect(stateKey).toEqual(""); + expect(content).toMatchObject({ name: newName }); + return Promise.resolve(); + }); + client.sendStateEvent = fn; + await tree.setName(newName); + expect(fn.mock.calls.length).toBe(1); + }); + + it('should support inviting users to the space', async () => { + const target = targetUser; + const fn = jest.fn().mockImplementation((inviteRoomId: string, userId: string) => { + expect(inviteRoomId).toEqual(roomId); + expect(userId).toEqual(target); + return Promise.resolve(); + }); + client.invite = fn; + await tree.invite(target); + expect(fn.mock.calls.length).toBe(1); + }); + + async function evaluatePowerLevels(pls: any, role: TreePermissions, expectedPl: number) { + makePowerLevels(pls); + const fn = jest.fn() + .mockImplementation((stateRoomId: string, eventType: EventType, content: any, stateKey: string) => { + expect(stateRoomId).toEqual(roomId); + expect(eventType).toEqual(EventType.RoomPowerLevels); + expect(stateKey).toEqual(""); + expect(content).toMatchObject({ + ...pls, + users: { + [targetUser]: expectedPl, + }, + }); + return Promise.resolve(); + }); + client.sendStateEvent = fn; + await tree.setPermissions(targetUser, role); + expect(fn.mock.calls.length).toBe(1); + } + + it('should support setting Viewer permissions', () => { + return evaluatePowerLevels({ + ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE, + users_default: 1024, + }, TreePermissions.Viewer, 1024); + }); + + it('should support setting Editor permissions', () => { + return evaluatePowerLevels({ + ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE, + events_default: 1024, + }, TreePermissions.Editor, 1024); + }); + + it('should support setting Owner permissions', () => { + return evaluatePowerLevels({ + ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE, + events: { + [EventType.RoomPowerLevels]: 1024, + }, + }, TreePermissions.Owner, 1024); + }); + + it('should support demoting permissions', () => { + return evaluatePowerLevels({ + ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE, + users_default: 1024, + users: { + [targetUser]: 2222, + }, + }, TreePermissions.Viewer, 1024); + }); + + it('should support promoting permissions', () => { + return evaluatePowerLevels({ + ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE, + events_default: 1024, + users: { + [targetUser]: 5, + }, + }, TreePermissions.Editor, 1024); + }); + + it('should support defaults: Viewer', () => { + return evaluatePowerLevels({}, TreePermissions.Viewer, 0); + }); + + it('should support defaults: Editor', () => { + return evaluatePowerLevels({}, TreePermissions.Editor, 50); + }); + + it('should support defaults: Owner', () => { + return evaluatePowerLevels({}, TreePermissions.Owner, 100); + }); + + 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); + }); + }); + + 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/spec/unit/utils.spec.js b/spec/unit/utils.spec.js deleted file mode 100644 index 703326f4611..00000000000 --- a/spec/unit/utils.spec.js +++ /dev/null @@ -1,262 +0,0 @@ -import * as utils from "../../src/utils"; - -describe("utils", function() { - describe("encodeParams", function() { - it("should url encode and concat with &s", function() { - const params = { - foo: "bar", - baz: "beer@", - }; - expect(utils.encodeParams(params)).toEqual( - "foo=bar&baz=beer%40", - ); - }); - }); - - describe("encodeUri", function() { - it("should replace based on object keys and url encode", function() { - const path = "foo/bar/%something/%here"; - const vals = { - "%something": "baz", - "%here": "beer@", - }; - expect(utils.encodeUri(path, vals)).toEqual( - "foo/bar/baz/beer%40", - ); - }); - }); - - describe("removeElement", function() { - it("should remove only 1 element if there is a match", function() { - const matchFn = function() { - return true; - }; - const arr = [55, 66, 77]; - utils.removeElement(arr, matchFn); - expect(arr).toEqual([66, 77]); - }); - it("should be able to remove in reverse order", function() { - const matchFn = function() { - return true; - }; - const arr = [55, 66, 77]; - utils.removeElement(arr, matchFn, true); - expect(arr).toEqual([55, 66]); - }); - it("should remove nothing if the function never returns true", function() { - const matchFn = function() { - return false; - }; - const arr = [55, 66, 77]; - utils.removeElement(arr, matchFn); - expect(arr).toEqual(arr); - }); - }); - - describe("isFunction", function() { - it("should return true for functions", function() { - expect(utils.isFunction([])).toBe(false); - expect(utils.isFunction([5, 3, 7])).toBe(false); - expect(utils.isFunction()).toBe(false); - expect(utils.isFunction(null)).toBe(false); - expect(utils.isFunction({})).toBe(false); - expect(utils.isFunction("foo")).toBe(false); - expect(utils.isFunction(555)).toBe(false); - - expect(utils.isFunction(function() {})).toBe(true); - const s = { foo: function() {} }; - expect(utils.isFunction(s.foo)).toBe(true); - }); - }); - - describe("checkObjectHasKeys", function() { - it("should throw for missing keys", function() { - expect(function() { - utils.checkObjectHasKeys({}, ["foo"]); - }).toThrow(); - expect(function() { - utils.checkObjectHasKeys({ - foo: "bar", - }, ["foo"]); - }).not.toThrow(); - }); - }); - - describe("checkObjectHasNoAdditionalKeys", function() { - it("should throw for extra keys", function() { - expect(function() { - utils.checkObjectHasNoAdditionalKeys({ - foo: "bar", - baz: 4, - }, ["foo"]); - }).toThrow(); - - expect(function() { - utils.checkObjectHasNoAdditionalKeys({ - foo: "bar", - }, ["foo"]); - }).not.toThrow(); - }); - }); - - describe("deepCompare", function() { - const assert = { - isTrue: function(x) { - expect(x).toBe(true); - }, - isFalse: function(x) { - expect(x).toBe(false); - }, - }; - - it("should handle primitives", function() { - assert.isTrue(utils.deepCompare(null, null)); - assert.isFalse(utils.deepCompare(null, undefined)); - assert.isTrue(utils.deepCompare("hi", "hi")); - assert.isTrue(utils.deepCompare(5, 5)); - assert.isFalse(utils.deepCompare(5, 10)); - }); - - it("should handle regexps", function() { - assert.isTrue(utils.deepCompare(/abc/, /abc/)); - assert.isFalse(utils.deepCompare(/abc/, /123/)); - const r = /abc/; - assert.isTrue(utils.deepCompare(r, r)); - }); - - it("should handle dates", function() { - assert.isTrue(utils.deepCompare(new Date("2011-03-31"), - new Date("2011-03-31"))); - assert.isFalse(utils.deepCompare(new Date("2011-03-31"), - new Date("1970-01-01"))); - }); - - it("should handle arrays", function() { - assert.isTrue(utils.deepCompare([], [])); - assert.isTrue(utils.deepCompare([1, 2], [1, 2])); - assert.isFalse(utils.deepCompare([1, 2], [2, 1])); - assert.isFalse(utils.deepCompare([1, 2], [1, 2, 3])); - }); - - it("should handle simple objects", function() { - assert.isTrue(utils.deepCompare({}, {})); - assert.isTrue(utils.deepCompare({ a: 1, b: 2 }, { a: 1, b: 2 })); - assert.isTrue(utils.deepCompare({ a: 1, b: 2 }, { b: 2, a: 1 })); - assert.isFalse(utils.deepCompare({ a: 1, b: 2 }, { a: 1, b: 3 })); - - assert.isTrue(utils.deepCompare({ 1: { name: "mhc", age: 28 }, - 2: { name: "arb", age: 26 } }, - { 1: { name: "mhc", age: 28 }, - 2: { name: "arb", age: 26 } })); - - assert.isFalse(utils.deepCompare({ 1: { name: "mhc", age: 28 }, - 2: { name: "arb", age: 26 } }, - { 1: { name: "mhc", age: 28 }, - 2: { name: "arb", age: 27 } })); - - assert.isFalse(utils.deepCompare({}, null)); - assert.isFalse(utils.deepCompare({}, undefined)); - }); - - it("should handle functions", function() { - // no two different function is equal really, they capture their - // context variables so even if they have same toString(), they - // won't have same functionality - const func = function(x) { - return true; - }; - const func2 = function(x) { - return true; - }; - assert.isTrue(utils.deepCompare(func, func)); - assert.isFalse(utils.deepCompare(func, func2)); - assert.isTrue(utils.deepCompare({ a: { b: func } }, { a: { b: func } })); - assert.isFalse(utils.deepCompare({ a: { b: func } }, { a: { b: func2 } })); - }); - }); - - describe("extend", function() { - const SOURCE = { "prop2": 1, "string2": "x", "newprop": "new" }; - - it("should extend", function() { - const target = { - "prop1": 5, "prop2": 7, "string1": "baz", "string2": "foo", - }; - const merged = { - "prop1": 5, "prop2": 1, "string1": "baz", "string2": "x", - "newprop": "new", - }; - const sourceOrig = JSON.stringify(SOURCE); - - utils.extend(target, SOURCE); - expect(JSON.stringify(target)).toEqual(JSON.stringify(merged)); - - // check the originial wasn't modified - expect(JSON.stringify(SOURCE)).toEqual(sourceOrig); - }); - - it("should ignore null", function() { - const target = { - "prop1": 5, "prop2": 7, "string1": "baz", "string2": "foo", - }; - const merged = { - "prop1": 5, "prop2": 1, "string1": "baz", "string2": "x", - "newprop": "new", - }; - const sourceOrig = JSON.stringify(SOURCE); - - utils.extend(target, null, SOURCE); - expect(JSON.stringify(target)).toEqual(JSON.stringify(merged)); - - // check the originial wasn't modified - expect(JSON.stringify(SOURCE)).toEqual(sourceOrig); - }); - - it("should handle properties created with defineProperties", function() { - const source = Object.defineProperties({}, { - "enumerableProp": { - get: function() { - return true; - }, - enumerable: true, - }, - "nonenumerableProp": { - get: function() { - return true; - }, - }, - }); - - const target = {}; - utils.extend(target, source); - expect(target.enumerableProp).toBe(true); - expect(target.nonenumerableProp).toBe(undefined); - }); - }); - - describe("chunkPromises", function() { - it("should execute promises in chunks", async function() { - let promiseCount = 0; - - function fn1() { - return new Promise(async function(resolve, reject) { - await utils.sleep(1); - expect(promiseCount).toEqual(0); - ++promiseCount; - resolve(); - }); - } - - function fn2() { - return new Promise(function(resolve, reject) { - expect(promiseCount).toEqual(1); - ++promiseCount; - resolve(); - }); - } - - await utils.chunkPromises([fn1, fn2], 1); - expect(promiseCount).toEqual(2); - }); - }); -}); diff --git a/spec/unit/utils.spec.ts b/spec/unit/utils.spec.ts new file mode 100644 index 00000000000..76123d1ca86 --- /dev/null +++ b/spec/unit/utils.spec.ts @@ -0,0 +1,432 @@ +import * as utils from "../../src/utils"; +import { + alphabetPad, + averageBetweenStrings, + baseToString, + DEFAULT_ALPHABET, + lexicographicCompare, + nextString, + prevString, + stringToBase, +} from "../../src/utils"; +import { logger } from "../../src/logger"; + +// TODO: Fix types throughout + +describe("utils", function() { + describe("encodeParams", function() { + it("should url encode and concat with &s", function() { + const params = { + foo: "bar", + baz: "beer@", + }; + expect(utils.encodeParams(params)).toEqual( + "foo=bar&baz=beer%40", + ); + }); + }); + + describe("encodeUri", function() { + it("should replace based on object keys and url encode", function() { + const path = "foo/bar/%something/%here"; + const vals = { + "%something": "baz", + "%here": "beer@", + }; + expect(utils.encodeUri(path, vals)).toEqual( + "foo/bar/baz/beer%40", + ); + }); + }); + + describe("removeElement", function() { + it("should remove only 1 element if there is a match", function() { + const matchFn = function() { + return true; + }; + const arr = [55, 66, 77]; + utils.removeElement(arr, matchFn); + expect(arr).toEqual([66, 77]); + }); + it("should be able to remove in reverse order", function() { + const matchFn = function() { + return true; + }; + const arr = [55, 66, 77]; + utils.removeElement(arr, matchFn, true); + expect(arr).toEqual([55, 66]); + }); + it("should remove nothing if the function never returns true", function() { + const matchFn = function() { + return false; + }; + const arr = [55, 66, 77]; + utils.removeElement(arr, matchFn); + expect(arr).toEqual(arr); + }); + }); + + describe("isFunction", function() { + it("should return true for functions", function() { + expect(utils.isFunction([])).toBe(false); + expect(utils.isFunction([5, 3, 7])).toBe(false); + expect(utils.isFunction(undefined)).toBe(false); + expect(utils.isFunction(null)).toBe(false); + expect(utils.isFunction({})).toBe(false); + expect(utils.isFunction("foo")).toBe(false); + expect(utils.isFunction(555)).toBe(false); + + expect(utils.isFunction(function() {})).toBe(true); + const s = { foo: function() {} }; + expect(utils.isFunction(s.foo)).toBe(true); + }); + }); + + describe("checkObjectHasKeys", function() { + it("should throw for missing keys", function() { + expect(function() { + utils.checkObjectHasKeys({}, ["foo"]); + }).toThrow(); + expect(function() { + utils.checkObjectHasKeys({ + foo: "bar", + }, ["foo"]); + }).not.toThrow(); + }); + }); + + describe("checkObjectHasNoAdditionalKeys", function() { + it("should throw for extra keys", function() { + expect(function() { + utils.checkObjectHasNoAdditionalKeys({ foo: "bar", baz: 4 }, ["foo"]); + }).toThrow(); + + expect(function() { + utils.checkObjectHasNoAdditionalKeys({ foo: "bar" }, ["foo"]); + }).not.toThrow(); + }); + }); + + describe("deepCompare", function() { + const assert = { + isTrue: function(x) { + expect(x).toBe(true); + }, + isFalse: function(x) { + expect(x).toBe(false); + }, + }; + + it("should handle primitives", function() { + assert.isTrue(utils.deepCompare(null, null)); + assert.isFalse(utils.deepCompare(null, undefined)); + assert.isTrue(utils.deepCompare("hi", "hi")); + assert.isTrue(utils.deepCompare(5, 5)); + assert.isFalse(utils.deepCompare(5, 10)); + }); + + it("should handle regexps", function() { + assert.isTrue(utils.deepCompare(/abc/, /abc/)); + assert.isFalse(utils.deepCompare(/abc/, /123/)); + const r = /abc/; + assert.isTrue(utils.deepCompare(r, r)); + }); + + it("should handle dates", function() { + assert.isTrue(utils.deepCompare(new Date("2011-03-31"), new Date("2011-03-31"))); + assert.isFalse(utils.deepCompare(new Date("2011-03-31"), new Date("1970-01-01"))); + }); + + it("should handle arrays", function() { + assert.isTrue(utils.deepCompare([], [])); + assert.isTrue(utils.deepCompare([1, 2], [1, 2])); + assert.isFalse(utils.deepCompare([1, 2], [2, 1])); + assert.isFalse(utils.deepCompare([1, 2], [1, 2, 3])); + }); + + it("should handle simple objects", function() { + assert.isTrue(utils.deepCompare({}, {})); + assert.isTrue(utils.deepCompare({ a: 1, b: 2 }, { a: 1, b: 2 })); + assert.isTrue(utils.deepCompare({ a: 1, b: 2 }, { b: 2, a: 1 })); + assert.isFalse(utils.deepCompare({ a: 1, b: 2 }, { a: 1, b: 3 })); + + assert.isTrue(utils.deepCompare({ + 1: { name: "mhc", age: 28 }, + 2: { name: "arb", age: 26 }, + }, { + 1: { name: "mhc", age: 28 }, + 2: { name: "arb", age: 26 }, + })); + + assert.isFalse(utils.deepCompare({ + 1: { name: "mhc", age: 28 }, + 2: { name: "arb", age: 26 }, + }, { + 1: { name: "mhc", age: 28 }, + 2: { name: "arb", age: 27 }, + })); + + assert.isFalse(utils.deepCompare({}, null)); + assert.isFalse(utils.deepCompare({}, undefined)); + }); + + it("should handle functions", function() { + // no two different function is equal really, they capture their + // context variables so even if they have same toString(), they + // won't have same functionality + const func = function(x) { + return true; + }; + const func2 = function(x) { + return true; + }; + assert.isTrue(utils.deepCompare(func, func)); + assert.isFalse(utils.deepCompare(func, func2)); + assert.isTrue(utils.deepCompare({ a: { b: func } }, { a: { b: func } })); + assert.isFalse(utils.deepCompare({ a: { b: func } }, { a: { b: func2 } })); + }); + }); + + describe("extend", function() { + const SOURCE = { "prop2": 1, "string2": "x", "newprop": "new" }; + + it("should extend", function() { + const target = { + "prop1": 5, "prop2": 7, "string1": "baz", "string2": "foo", + }; + const merged = { + "prop1": 5, "prop2": 1, "string1": "baz", "string2": "x", + "newprop": "new", + }; + const sourceOrig = JSON.stringify(SOURCE); + + utils.extend(target, SOURCE); + expect(JSON.stringify(target)).toEqual(JSON.stringify(merged)); + + // check the originial wasn't modified + expect(JSON.stringify(SOURCE)).toEqual(sourceOrig); + }); + + it("should ignore null", function() { + const target = { + "prop1": 5, "prop2": 7, "string1": "baz", "string2": "foo", + }; + const merged = { + "prop1": 5, "prop2": 1, "string1": "baz", "string2": "x", + "newprop": "new", + }; + const sourceOrig = JSON.stringify(SOURCE); + + utils.extend(target, null, SOURCE); + expect(JSON.stringify(target)).toEqual(JSON.stringify(merged)); + + // check the originial wasn't modified + expect(JSON.stringify(SOURCE)).toEqual(sourceOrig); + }); + + it("should handle properties created with defineProperties", function() { + const source = Object.defineProperties({}, { + "enumerableProp": { + get: function() { + return true; + }, + enumerable: true, + }, + "nonenumerableProp": { + get: function() { + return true; + }, + }, + }); + + // TODO: Fix type + const target: any = {}; + utils.extend(target, source); + expect(target.enumerableProp).toBe(true); + expect(target.nonenumerableProp).toBe(undefined); + }); + }); + + describe("chunkPromises", function() { + it("should execute promises in chunks", async function() { + let promiseCount = 0; + + async function fn1() { + await utils.sleep(1); + expect(promiseCount).toEqual(0); + ++promiseCount; + } + + async function fn2() { + expect(promiseCount).toEqual(1); + ++promiseCount; + } + + await utils.chunkPromises([fn1, fn2], 1); + 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 len = 12; + expect(alphabetPad("a", len)).toEqual("a" + ("".padEnd(len - 1, DEFAULT_ALPHABET[0]))); + expect(alphabetPad("a", len, "123")).toEqual("a" + ("".padEnd(len - 1, '1'))); + }); + }); + + describe('baseToString', () => { + it('should calculate the appropriate string from numbers', () => { + // Verify the whole alphabet + for (let i = BigInt(1); i <= DEFAULT_ALPHABET.length; i++) { + logger.log({ i }); // for debugging + expect(baseToString(i)).toEqual(DEFAULT_ALPHABET[Number(i) - 1]); + } + + // Just quickly double check that repeated characters aren't treated as padding, particularly + // at the beginning of the alphabet where they are most vulnerable to this behaviour. + expect(baseToString(BigInt(1))).toEqual(DEFAULT_ALPHABET[0].repeat(1)); + expect(baseToString(BigInt(96))).toEqual(DEFAULT_ALPHABET[0].repeat(2)); + expect(baseToString(BigInt(9121))).toEqual(DEFAULT_ALPHABET[0].repeat(3)); + expect(baseToString(BigInt(866496))).toEqual(DEFAULT_ALPHABET[0].repeat(4)); + expect(baseToString(BigInt(82317121))).toEqual(DEFAULT_ALPHABET[0].repeat(5)); + expect(baseToString(BigInt(7820126496))).toEqual(DEFAULT_ALPHABET[0].repeat(6)); + + expect(baseToString(BigInt(10))).toEqual(DEFAULT_ALPHABET[9]); + expect(baseToString(BigInt(10), "abcdefghijklmnopqrstuvwxyz")).toEqual('j'); + expect(baseToString(BigInt(6337))).toEqual("ab"); + expect(baseToString(BigInt(80), "abcdefghijklmnopqrstuvwxyz")).toEqual('cb'); + }); + }); + + describe('stringToBase', () => { + it('should calculate the appropriate number for a string', () => { + expect(stringToBase(DEFAULT_ALPHABET[0].repeat(1))).toEqual(BigInt(1)); + expect(stringToBase(DEFAULT_ALPHABET[0].repeat(2))).toEqual(BigInt(96)); + expect(stringToBase(DEFAULT_ALPHABET[0].repeat(3))).toEqual(BigInt(9121)); + expect(stringToBase(DEFAULT_ALPHABET[0].repeat(4))).toEqual(BigInt(866496)); + expect(stringToBase(DEFAULT_ALPHABET[0].repeat(5))).toEqual(BigInt(82317121)); + expect(stringToBase(DEFAULT_ALPHABET[0].repeat(6))).toEqual(BigInt(7820126496)); + expect(stringToBase("a", "abcdefghijklmnopqrstuvwxyz")).toEqual(BigInt(1)); + expect(stringToBase("a")).toEqual(BigInt(66)); + expect(stringToBase("c", "abcdefghijklmnopqrstuvwxyz")).toEqual(BigInt(3)); + expect(stringToBase("ab")).toEqual(BigInt(6337)); + expect(stringToBase("cb", "abcdefghijklmnopqrstuvwxyz")).toEqual(BigInt(80)); + }); + }); + + describe('averageBetweenStrings', () => { + it('should average appropriately', () => { + expect(averageBetweenStrings(" ", "!!")).toEqual(" P"); + expect(averageBetweenStrings(" ", "!")).toEqual(" "); + expect(averageBetweenStrings('A', 'B')).toEqual('A '); + expect(averageBetweenStrings('AA', 'BB')).toEqual('Aq'); + 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("d9>Cw"); + expect(averageBetweenStrings('cat', 'doggo', "abcdefghijklmnopqrstuvwxyz")).toEqual("cumqh"); + }); + }); + + 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'); + }); + }); + + // Let's just ensure the ordering is sensible for lexicographic ordering + describe('string averaging unified', () => { + it('should be truly previous and next', () => { + let midpoint = "cat"; + + // We run this test 100 times to ensure we end up with a sane sequence. + for (let i = 0; i < 100; i++) { + const next = nextString(midpoint); + const prev = prevString(midpoint); + logger.log({ i, midpoint, next, prev }); // for test debugging + + expect(lexicographicCompare(midpoint, next) < 0).toBe(true); + expect(lexicographicCompare(midpoint, prev) > 0).toBe(true); + expect(averageBetweenStrings(prev, next)).toBe(midpoint); + + midpoint = next; + } + }); + + it('should roll over', () => { + const lastAlpha = DEFAULT_ALPHABET[DEFAULT_ALPHABET.length - 1]; + const firstAlpha = DEFAULT_ALPHABET[0]; + + const highRoll = firstAlpha + firstAlpha; + const lowRoll = lastAlpha; + + expect(nextString(lowRoll)).toEqual(highRoll); + expect(prevString(highRoll)).toEqual(lowRoll); + }); + + it('should be reversible on small strings', () => { + // Large scale reversibility is tested for max space order value + const input = "cats"; + expect(prevString(nextString(input))).toEqual(input); + }); + + // We want to explicitly make sure that Space order values are supported and roll appropriately + it('should properly handle rolling over at 50 characters', () => { + // Note: we also test reversibility of large strings here. + + const maxSpaceValue = DEFAULT_ALPHABET[DEFAULT_ALPHABET.length - 1].repeat(50); + const fiftyFirstChar = DEFAULT_ALPHABET[0].repeat(51); + + expect(nextString(maxSpaceValue)).toBe(fiftyFirstChar); + expect(prevString(fiftyFirstChar)).toBe(maxSpaceValue); + + // We're testing that the rollover happened, which means that the next string come before + // the maximum space order value lexicographically. + expect(lexicographicCompare(maxSpaceValue, fiftyFirstChar) > 0).toBe(true); + }); + }); + + describe('lexicographicCompare', () => { + it('should work', () => { + // Simple tests + expect(lexicographicCompare('a', 'b') < 0).toBe(true); + expect(lexicographicCompare('ab', 'b') < 0).toBe(true); + expect(lexicographicCompare('cat', 'dog') < 0).toBe(true); + + // Simple tests (reversed) + expect(lexicographicCompare('b', 'a') > 0).toBe(true); + expect(lexicographicCompare('b', 'ab') > 0).toBe(true); + expect(lexicographicCompare('dog', 'cat') > 0).toBe(true); + + // Simple equality tests + expect(lexicographicCompare('a', 'a') === 0).toBe(true); + expect(lexicographicCompare('A', 'A') === 0).toBe(true); + + // ASCII rule testing + expect(lexicographicCompare('A', 'a') < 0).toBe(true); + expect(lexicographicCompare('a', 'A') > 0).toBe(true); + }); + }); +}); diff --git a/src/@types/event.ts b/src/@types/event.ts index 3c905442b61..a4e11fe3485 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { UnstableValue } from "../NamespacedValue"; + export enum EventType { // Room state events RoomCanonicalAlias = "m.room.canonical_alias", @@ -100,3 +102,53 @@ export const RoomCreateTypeField = "type"; export enum RoomType { Space = "m.space", } + +/** + * Identifier for an [MSC3088](https://github.com/matrix-org/matrix-doc/pull/3088) + * room purpose. Note that this reference is UNSTABLE and subject to breaking changes, + * including its eventual removal. + */ +export const UNSTABLE_MSC3088_PURPOSE = new UnstableValue("m.room.purpose", "org.matrix.msc3088.purpose"); + +/** + * Enabled flag for an [MSC3088](https://github.com/matrix-org/matrix-doc/pull/3088) + * room purpose. Note that this reference is UNSTABLE and subject to breaking changes, + * including its eventual removal. + */ +export const UNSTABLE_MSC3088_ENABLED = new UnstableValue("m.enabled", "org.matrix.msc3088.enabled"); + +/** + * Subtype for an [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) space-room. + * Note that this reference is UNSTABLE and subject to breaking changes, including its + * eventual removal. + */ +export const UNSTABLE_MSC3089_TREE_SUBTYPE = new UnstableValue("m.data_tree", "org.matrix.msc3089.data_tree"); + +/** + * Leaf type for an event in a [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) space-room. + * Note that this reference is UNSTABLE and subject to breaking changes, including its + * eventual removal. + */ +export const UNSTABLE_MSC3089_LEAF = new UnstableValue("m.leaf", "org.matrix.msc3089.leaf"); + +/** + * 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/@types/requests.ts b/src/@types/requests.ts index 581b4a1b6c0..a149875d85e 100644 --- a/src/@types/requests.ts +++ b/src/@types/requests.ts @@ -68,15 +68,21 @@ export interface IEventSearchOpts { term: string; } +// allow camelcase as these are things go onto the wire +/* eslint-disable camelcase */ export interface ICreateRoomOpts { - room_alias_name?: string; // eslint-disable-line camelcase + room_alias_name?: string; visibility?: "public" | "private"; name?: string; topic?: string; preset?: string; + power_level_content_override?: any; + creation_content?: any; + initial_state?: {type: string, state_key: string, content: any}[]; // TODO: Types (next line) - invite_3pid?: any[]; // eslint-disable-line camelcase + invite_3pid?: any[]; } +/* eslint-enable camelcase */ export interface IRoomDirectoryOptions { server?: string; diff --git a/src/NamespacedValue.ts b/src/NamespacedValue.ts new file mode 100644 index 00000000000..d493f38aa5b --- /dev/null +++ b/src/NamespacedValue.ts @@ -0,0 +1,93 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Represents a simple Matrix namespaced value. This will assume that if a stable prefix + * is provided that the stable prefix should be used when representing the identifier. + */ +export class NamespacedValue { + // Stable is optional, but one of the two parameters is required, hence the weird-looking types. + // Goal is to to have developers explicitly say there is no stable value (if applicable). + public constructor(public readonly stable: S | null | undefined, public readonly unstable?: U) { + if (!this.unstable && !this.stable) { + throw new Error("One of stable or unstable values must be supplied"); + } + } + + public get name(): U | S { + if (this.stable) { + return this.stable; + } + return this.unstable; + } + + public get altName(): U | S | null { + if (!this.stable) { + return null; + } + return this.unstable; + } + + public matches(val: string): boolean { + return this.name === val || this.altName === val; + } + + // this desperately wants https://github.com/microsoft/TypeScript/pull/26349 at the top level of the class + // so we can instantiate `NamespacedValue` as a default type for that namespace. + public findIn(obj: any): T { + let val: T; + if (this.name) { + val = obj?.[this.name]; + } + if (!val && this.altName) { + val = obj?.[this.altName]; + } + return val; + } + + public includedIn(arr: any[]): boolean { + let included = false; + if (this.name) { + included = arr.includes(this.name); + } + if (!included && this.altName) { + included = arr.includes(this.altName); + } + return included; + } +} + +/** + * Represents a namespaced value which prioritizes the unstable value over the stable + * value. + */ +export class UnstableValue extends NamespacedValue { + // Note: Constructor difference is that `unstable` is *required*. + public constructor(stable: S, unstable: U) { + super(stable, unstable); + if (!this.unstable) { + throw new Error("Unstable value must be supplied"); + } + } + + public get name(): U { + return this.unstable; + } + + public get altName(): S { + return this.stable; + } +} diff --git a/src/client.ts b/src/client.ts index e3acfeb9ae6..1e494a93a1e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -99,7 +99,14 @@ import { ISendEventResponse, IUploadOpts, } from "./@types/requests"; -import { EventType } from "./@types/event"; +import { + EventType, + RoomCreateTypeField, + RoomType, + UNSTABLE_MSC3088_ENABLED, + UNSTABLE_MSC3088_PURPOSE, + UNSTABLE_MSC3089_TREE_SUBTYPE, +} from "./@types/event"; import { IImageInfo } from "./@types/partials"; import { EventMapper, eventMapperFor, MapperOpts } from "./event-mapper"; import url from "url"; @@ -107,6 +114,7 @@ import { randomString } from "./randomstring"; import { ReadStream } from "fs"; import { WebStorageSessionStore } from "./store/session/webstorage"; import { BackupManager } from "./crypto/backup"; +import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE, MSC3089TreeSpace } from "./models/MSC3089TreeSpace"; export type Store = StubStore | MemoryStore | LocalIndexedDBStoreBackend | RemoteIndexedDBStoreBackend; export type SessionStore = WebStorageSessionStore; @@ -4288,7 +4296,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'" + @@ -7709,6 +7717,73 @@ export class MatrixClient extends EventEmitter { }); } + /** + * Creates a new file tree space with the given name. The client will pick + * defaults for how it expects to be able to support the remaining API offered + * by the returned class. + * + * Note that this is UNSTABLE and may have breaking changes without notice. + * @param {string} name The name of the tree space. + * @returns {Promise} Resolves to the created space. + */ + public async unstableCreateFileTree(name: string): Promise { + const { room_id: roomId } = await this.createRoom({ + name: name, + preset: "private_chat", + power_level_content_override: { + ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE, + users: { + [this.getUserId()]: 100, + }, + }, + creation_content: { + [RoomCreateTypeField]: RoomType.Space, + }, + initial_state: [ + { + type: UNSTABLE_MSC3088_PURPOSE.name, + state_key: UNSTABLE_MSC3089_TREE_SUBTYPE.name, + content: { + [UNSTABLE_MSC3088_ENABLED.name]: true, + }, + }, + { + type: EventType.RoomEncryption, + state_key: "", + content: { + algorithm: olmlib.MEGOLM_ALGORITHM, + }, + }, + ], + }); + return new MSC3089TreeSpace(this, roomId); + } + + /** + * Gets a reference to a tree space, if the room ID given is a tree space. If the room + * does not appear to be a tree space then null is returned. + * + * Note that this is UNSTABLE and may have breaking changes without notice. + * @param {string} roomId The room ID to get a tree space reference for. + * @returns {MSC3089TreeSpace} The tree space, or null if not a tree space. + */ + public unstableGetFileTreeSpace(roomId: string): MSC3089TreeSpace { + const room = this.getRoom(roomId); + if (!room) return null; + + const createEvent = room.currentState.getStateEvents(EventType.RoomCreate, ""); + const purposeEvent = room.currentState.getStateEvents( + UNSTABLE_MSC3088_PURPOSE.name, + UNSTABLE_MSC3089_TREE_SUBTYPE.name); + + if (!createEvent) throw new Error("Expected single room create event"); + + if (!purposeEvent?.getContent()?.[UNSTABLE_MSC3088_ENABLED.name]) return null; + if (createEvent.getContent()?.[RoomCreateTypeField] !== RoomType.Space) return null; + + return new MSC3089TreeSpace(this, roomId); + } + // TODO: Remove this warning, alongside the functions // See https://github.com/vector-im/element-web/issues/17532 // ====================================================== diff --git a/src/models/MSC3089Branch.ts b/src/models/MSC3089Branch.ts new file mode 100644 index 00000000000..87acee0f8a5 --- /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 new file mode 100644 index 00000000000..f36642a8f79 --- /dev/null +++ b/src/models/MSC3089TreeSpace.ts @@ -0,0 +1,437 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixClient } from "../client"; +import { EventType, 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, lexicographicCompare, nextString, prevString } from "../utils"; +import { MSC3089Branch } from "./MSC3089Branch"; + +/** + * The recommended defaults for a tree space's power levels. Note that this + * is UNSTABLE and subject to breaking changes without notice. + */ +export const DEFAULT_TREE_POWER_LEVELS_TEMPLATE = { + // Owner + invite: 100, + kick: 100, + ban: 100, + + // Editor + redact: 50, + state_default: 50, + events_default: 50, + + // Viewer + users_default: 0, + + // Mixed + events: { + [EventType.RoomPowerLevels]: 100, + [EventType.RoomHistoryVisibility]: 100, + [EventType.RoomTombstone]: 100, + [EventType.RoomEncryption]: 100, + [EventType.RoomName]: 50, + [EventType.RoomMessage]: 50, + [EventType.RoomMessageEncrypted]: 50, + [EventType.Sticker]: 50, + }, + + users: {}, // defined by calling code +}; + +/** + * Ease-of-use representation for power levels represented as simple roles. + * Note that this is UNSTABLE and subject to breaking changes without notice. + */ +export enum TreePermissions { + Viewer = "viewer", // Default + Editor = "editor", // "Moderator" or ~PL50 + Owner = "owner", // "Admin" or PL100 +} + +/** + * Represents a [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) + * file tree Space. Note that this is UNSTABLE and subject to breaking changes + * without notice. + */ +export class MSC3089TreeSpace { + public readonly room: Room; + + public constructor(private client: MatrixClient, public readonly roomId: string) { + this.room = this.client.getRoom(this.roomId); + + if (!this.room) throw new Error("Unknown room"); + } + + /** + * Syntactic sugar for room ID of the Space. + */ + public get id(): string { + return this.roomId; + } + + /** + * Whether or not this is a top level space. + */ + public get isTopLevel(): boolean { + // XXX: This is absolutely not how you find out if the space is top level + // but is safe for a managed usecase like we offer in the SDK. + 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. + * @returns {Promise} Resolves when complete. + */ + public setName(name: string): Promise { + return this.client.sendStateEvent(this.roomId, EventType.RoomName, { name }, ""); + } + + /** + * Invites a user to the tree space. They will be given the default Viewer + * permission level unless specified elsewhere. + * @param {string} userId The user ID to invite. + * @returns {Promise} Resolves when complete. + */ + public invite(userId: string): Promise { + // TODO: [@@TR] Reliable invites + // TODO: [@@TR] Share keys + return this.client.invite(this.roomId, userId); + } + + /** + * Sets the permissions of a user to the given role. Note that if setting a user + * to Owner then they will NOT be able to be demoted. If the user does not have + * permission to change the power level of the target, an error will be thrown. + * @param {string} userId The user ID to change the role of. + * @param {TreePermissions} role The role to assign. + * @returns {Promise} Resolves when complete. + */ + public async setPermissions(userId: string, role: TreePermissions): Promise { + const currentPls = this.room.currentState.getStateEvents(EventType.RoomPowerLevels, ""); + if (Array.isArray(currentPls)) throw new Error("Unexpected return type for power levels"); + + const pls = currentPls.getContent() || {}; + const viewLevel = pls['users_default'] || 0; + const editLevel = pls['events_default'] || 50; + const adminLevel = pls['events']?.[EventType.RoomPowerLevels] || 100; + + const users = pls['users'] || {}; + switch (role) { + case TreePermissions.Viewer: + users[userId] = viewLevel; + break; + case TreePermissions.Editor: + users[userId] = editLevel; + break; + case TreePermissions.Owner: + users[userId] = adminLevel; + break; + default: + throw new Error("Invalid role: " + role); + } + pls['users'] = users; + + return this.client.sendStateEvent(this.roomId, EventType.RoomPowerLevels, pls, ""); + } + + /** + * 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()); + if (tree) 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) { + const isNotUs = member.getStateKey() !== this.client.getUserId(); + if (isNotUs && 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 lexicographicCompare(a.roomId, b.roomId); + } + + const createTsA = roomA.currentState.getStateEvents(EventType.RoomCreate, "")?.getTs() ?? 0; + const createTsB = roomB.currentState.getStateEvents(EventType.RoomCreate, "")?.getTs() ?? 0; + if (createTsA === createTsB) { + return lexicographicCompare(a.roomId, b.roomId); + } + return createTsA - createTsB; + } else { // both not-null orders + return lexicographicCompare(a.order, 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, + + // TODO: Safely constrain to 50 character limit required by spaces. + 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); + } +} diff --git a/src/utils.ts b/src/utils.ts index a4a50153ac4..e50c512b0d6 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -456,3 +456,163 @@ export function setCrypto(c: Object) { export function getCrypto(): Object { return crypto; } + +// String averaging inspired by 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 = (() => { + 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 + * padded at the end with the first character in the alphabet. + * + * This is intended for use with string averaging. + * @param {string} s The string to pad. + * @param {number} n The length to pad to. + * @param {string} alphabet The alphabet to use as a single string. + * @returns {string} The padded string. + */ +export function alphabetPad(s: string, n: number, alphabet = DEFAULT_ALPHABET): string { + return s.padEnd(n, alphabet[0]); +} + +/** + * Converts a baseN number to a string, where N is the alphabet's length. + * + * This is intended for use with string averaging. + * @param {bigint} 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: bigint, alphabet = DEFAULT_ALPHABET): string { + // Developer note: the stringToBase() function offsets the character set by 1 so that repeated + // characters (ie: "aaaaaa" in a..z) don't come out as zero. We have to reverse this here as + // otherwise we'll be wrong in our conversion. Undoing a +1 before an exponent isn't very fun + // though, so we rely on a lengthy amount of `x - 1` and integer division rules to reach a + // sane state. This also means we have to do rollover detection: see below. + + const len = BigInt(alphabet.length); + if (n <= len) { + return alphabet[Number(n) - 1]; + } + + let d = n / len; + let r = Number(n % len) - 1; + + // Rollover detection: if the remainder is negative, it means that the string needs + // to roll over by 1 character downwards (ie: in a..z, the previous to "aaa" would be + // "zz"). + if (r < 0) { + d -= BigInt(Math.abs(r)); // abs() is just to be clear what we're doing. Could also `+= r`. + r = Number(len) - 1; + } + + return baseToString(d, alphabet) + alphabet[r]; +} + +/** + * 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 {bigint} The baseN number. + */ +export function stringToBase(s: string, alphabet = DEFAULT_ALPHABET): bigint { + const len = BigInt(alphabet.length); + + // In our conversion to baseN we do a couple performance optimizations to avoid using + // excess CPU and such. To create baseN numbers, the input string needs to be reversed + // so the exponents stack up appropriately, as the last character in the unreversed + // string has less impact than the first character (in "abc" the A is a lot more important + // for lexicographic sorts). We also do a trick with the character codes to optimize the + // alphabet lookup, avoiding an index scan of `alphabet.indexOf(reversedStr[i])` - we know + // that the alphabet and (theoretically) the input string are constrained on character sets + // and thus can do simple subtraction to end up with the same result. + + // Developer caution: we carefully cast to BigInt here to avoid losing precision. We cannot + // rely on Math.pow() (for example) to be capable of handling our insane numbers. + + let result = BigInt(0); + for (let i = s.length - 1, j = BigInt(0); i >= 0; i--, j++) { + const charIndex = s.charCodeAt(i) - alphabet.charCodeAt(0); + + // We add 1 to the char index to offset the whole numbering scheme. We unpack this in + // the baseToString() function. + result += BigInt(1 + charIndex) * (len ** j); + } + 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 { + const padN = Math.max(a.length, b.length); + const baseA = stringToBase(alphabetPad(a, padN, alphabet), alphabet); + const baseB = stringToBase(alphabetPad(b, padN, alphabet), alphabet); + const avg = (baseA + baseB) / BigInt(2); + + // Detect integer division conflicts. This happens when two numbers are divided too close so + // we lose a .5 precision. We need to add a padding character in these cases. + if (avg === baseA || avg == baseB) { + return baseToString(avg, alphabet) + alphabet[0]; + } + + return baseToString(avg, 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) + BigInt(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) - BigInt(1), alphabet); +} + +/** + * Compares strings lexicographically as a sort-safe function. + * @param {string} a The first (reference) string. + * @param {string} b The second (compare) string. + * @returns {number} Negative if the reference string is before the compare string; + * positive if the reference string is after; and zero if equal. + */ +export function lexicographicCompare(a: string, b: string): number { + // Dev note: this exists because I'm sad that you can use math operators on strings, so I've + // hidden the operation in this function. + return (a < b) ? -1 : ((a === b) ? 0 : 1); +}