You've already forked matrix-js-sdk
mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2025-07-30 04:23:07 +03:00
Early directory management
This commit is contained in:
@ -23,6 +23,7 @@ import {
|
||||
MSC3089TreeSpace,
|
||||
TreePermissions
|
||||
} from "../../../src/models/MSC3089TreeSpace";
|
||||
import { DEFAULT_ALPHABET } from "../../../src/utils";
|
||||
|
||||
describe("MSC3089TreeSpace", () => {
|
||||
let client: MatrixClient;
|
||||
@ -178,4 +179,560 @@ describe("MSC3089TreeSpace", () => {
|
||||
it('should support defaults: Owner', () => {
|
||||
return evaluatePowerLevels({}, TreePermissions.Owner, 100);
|
||||
});
|
||||
|
||||
it('should create subdirectories', async () => {
|
||||
const subspaceName = "subdirectory";
|
||||
const subspaceId = "!subspace:localhost";
|
||||
const domain = "domain.example.com";
|
||||
client.getRoom = (roomId: string) => {
|
||||
if (roomId === tree.roomId) {
|
||||
return tree.room;
|
||||
} else if (roomId === subspaceId) {
|
||||
return {} as Room; // we don't need anything important off of this
|
||||
} else {
|
||||
throw new Error("Unexpected getRoom call");
|
||||
}
|
||||
};
|
||||
client.getDomain = () => domain;
|
||||
const createFn = jest.fn().mockImplementation(async (name: string) => {
|
||||
expect(name).toEqual(subspaceName);
|
||||
return new MSC3089TreeSpace(client, subspaceId);
|
||||
});
|
||||
const sendStateFn = jest.fn().mockImplementation(async (roomId: string, eventType: EventType, content: any, stateKey: string) => {
|
||||
expect([tree.roomId, subspaceId]).toContain(roomId);
|
||||
if (roomId === subspaceId) {
|
||||
expect(eventType).toEqual(EventType.SpaceParent);
|
||||
expect(stateKey).toEqual(tree.roomId);
|
||||
} else {
|
||||
expect(eventType).toEqual(EventType.SpaceChild);
|
||||
expect(stateKey).toEqual(subspaceId);
|
||||
}
|
||||
expect(content).toMatchObject({via: [domain]});
|
||||
|
||||
// return value not used
|
||||
});
|
||||
client.unstableCreateFileTree = createFn;
|
||||
client.sendStateEvent = sendStateFn;
|
||||
|
||||
const directory = await tree.createDirectory(subspaceName);
|
||||
expect(directory).toBeDefined();
|
||||
expect(directory).not.toBeNull();
|
||||
expect(directory).not.toBe(tree);
|
||||
expect(directory.roomId).toEqual(subspaceId);
|
||||
expect(createFn).toHaveBeenCalledTimes(1);
|
||||
expect(sendStateFn).toHaveBeenCalledTimes(2);
|
||||
|
||||
const content = expect.objectContaining({via: [domain]});
|
||||
expect(sendStateFn).toHaveBeenCalledWith(subspaceId, EventType.SpaceParent, content, tree.roomId);
|
||||
expect(sendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, content, subspaceId);
|
||||
});
|
||||
|
||||
it('should find subdirectories', () => {
|
||||
const firstChildRoom = "!one:example.org";
|
||||
const secondChildRoom = "!two:example.org";
|
||||
const thirdChildRoom = "!three:example.org"; // to ensure it doesn't end up in the subdirectories
|
||||
room.currentState = {
|
||||
getStateEvents: (eventType: EventType, stateKey?: string) => {
|
||||
expect(eventType).toEqual(EventType.SpaceChild);
|
||||
expect(stateKey).toBeUndefined();
|
||||
return [
|
||||
// Partial implementations of Room
|
||||
{getStateKey: () => firstChildRoom},
|
||||
{getStateKey: () => secondChildRoom},
|
||||
{getStateKey: () => thirdChildRoom},
|
||||
];
|
||||
},
|
||||
};
|
||||
client.getRoom = () => ({} as Room); // to appease the TreeSpace constructor
|
||||
|
||||
const getFn = jest.fn().mockImplementation((roomId: string) => {
|
||||
if (roomId === thirdChildRoom) {
|
||||
throw new Error("Mock not-a-space room case called (expected)");
|
||||
}
|
||||
expect([firstChildRoom, secondChildRoom]).toContain(roomId);
|
||||
return new MSC3089TreeSpace(client, roomId);
|
||||
});
|
||||
client.unstableGetFileTreeSpace = getFn;
|
||||
|
||||
const subdirectories = tree.getDirectories();
|
||||
expect(subdirectories).toBeDefined();
|
||||
expect(subdirectories.length).toBe(2);
|
||||
expect(subdirectories[0].roomId).toBe(firstChildRoom);
|
||||
expect(subdirectories[1].roomId).toBe(secondChildRoom);
|
||||
expect(getFn).toHaveBeenCalledTimes(3);
|
||||
expect(getFn).toHaveBeenCalledWith(firstChildRoom);
|
||||
expect(getFn).toHaveBeenCalledWith(secondChildRoom);
|
||||
expect(getFn).toHaveBeenCalledWith(thirdChildRoom); // check to make sure it tried
|
||||
});
|
||||
|
||||
it('should find specific directories', () => {
|
||||
client.getRoom = () => ({} as Room); // to appease the TreeSpace constructor
|
||||
|
||||
// Only mocking used API
|
||||
const firstSubdirectory = {roomId: "!first:example.org"} as any as MSC3089TreeSpace;
|
||||
const searchedSubdirectory = {roomId: "!find_me:example.org"} as any as MSC3089TreeSpace;
|
||||
const thirdSubdirectory = {roomId: "!third:example.org"} as any as MSC3089TreeSpace;
|
||||
tree.getDirectories = () => [firstSubdirectory, searchedSubdirectory, thirdSubdirectory];
|
||||
|
||||
let result = tree.getDirectory(searchedSubdirectory.roomId);
|
||||
expect(result).toBe(searchedSubdirectory);
|
||||
|
||||
result = tree.getDirectory("not a subdirectory");
|
||||
expect(result).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should be able to delete itself', async () => {
|
||||
const delete1 = jest.fn().mockImplementation(() => Promise.resolve());
|
||||
const subdir1 = {delete: delete1} as any as MSC3089TreeSpace; // mock tested bits
|
||||
|
||||
const delete2 = jest.fn().mockImplementation(() => Promise.resolve());
|
||||
const subdir2 = {delete: delete2} as any as MSC3089TreeSpace; // mock tested bits
|
||||
|
||||
const joinMemberId = "@join:example.org";
|
||||
const knockMemberId = "@knock:example.org";
|
||||
const inviteMemberId = "@invite:example.org";
|
||||
const leaveMemberId = "@leave:example.org";
|
||||
const banMemberId = "@ban:example.org";
|
||||
const selfUserId = "@self:example.org";
|
||||
|
||||
tree.getDirectories = () => [subdir1, subdir2];
|
||||
room.currentState = {
|
||||
getStateEvents: (eventType: EventType, stateKey?: string) => {
|
||||
expect(eventType).toEqual(EventType.RoomMember);
|
||||
expect(stateKey).toBeUndefined();
|
||||
return [
|
||||
// Partial implementations
|
||||
{getContent: () => ({membership: "join"}), getStateKey: () => joinMemberId},
|
||||
{getContent: () => ({membership: "knock"}), getStateKey: () => knockMemberId},
|
||||
{getContent: () => ({membership: "invite"}), getStateKey: () => inviteMemberId},
|
||||
{getContent: () => ({membership: "leave"}), getStateKey: () => leaveMemberId},
|
||||
{getContent: () => ({membership: "ban"}), getStateKey: () => banMemberId},
|
||||
|
||||
// ensure we don't kick ourselves
|
||||
{getContent: () => ({membership: "join"}), getStateKey: () => selfUserId},
|
||||
]
|
||||
},
|
||||
};
|
||||
|
||||
// These two functions are tested by input expectations, so no expectations in the function bodies
|
||||
const kickFn = jest.fn().mockImplementation((userId) => Promise.resolve());
|
||||
const leaveFn = jest.fn().mockImplementation(() => Promise.resolve());
|
||||
client.kick = kickFn;
|
||||
client.leave = leaveFn;
|
||||
client.getUserId = () => selfUserId;
|
||||
|
||||
await tree.delete();
|
||||
|
||||
expect(delete1).toHaveBeenCalledTimes(1);
|
||||
expect(delete2).toHaveBeenCalledTimes(1);
|
||||
expect(kickFn).toHaveBeenCalledTimes(3);
|
||||
expect(kickFn).toHaveBeenCalledWith(tree.roomId, joinMemberId, expect.any(String));
|
||||
expect(kickFn).toHaveBeenCalledWith(tree.roomId, knockMemberId, expect.any(String));
|
||||
expect(kickFn).toHaveBeenCalledWith(tree.roomId, inviteMemberId, expect.any(String));
|
||||
expect(leaveFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('get and set order', () => {
|
||||
// Danger: these are partial implementations for testing purposes only
|
||||
|
||||
// @ts-ignore - "MatrixEvent is a value but used as a type", which is true but not important
|
||||
let childState: {[roomId: string]: MatrixEvent[]} = {};
|
||||
// @ts-ignore - "MatrixEvent is a value but used as a type", which is true but not important
|
||||
let parentState: MatrixEvent[] = [];
|
||||
let parentRoom: Room;
|
||||
let childTrees: MSC3089TreeSpace[];
|
||||
let rooms: {[roomId: string]: Room};
|
||||
let clientSendStateFn: jest.MockedFunction<typeof client.sendStateEvent>;
|
||||
const staticDomain = "static.example.org";
|
||||
|
||||
function addSubspace(roomId: string, createTs?: number, order?: string) {
|
||||
const content = {
|
||||
via: [staticDomain],
|
||||
};
|
||||
if (order) content['order'] = order;
|
||||
parentState.push({
|
||||
getType: () => EventType.SpaceChild,
|
||||
getStateKey: () => roomId,
|
||||
getContent: () => content,
|
||||
});
|
||||
childState[roomId] = [
|
||||
{
|
||||
getType: () => EventType.SpaceParent,
|
||||
getStateKey: () => tree.roomId,
|
||||
getContent: () => ({
|
||||
via: [staticDomain]
|
||||
}),
|
||||
},
|
||||
];
|
||||
if (createTs) {
|
||||
childState[roomId].push({
|
||||
getType: () => EventType.RoomCreate,
|
||||
getStateKey: () => "",
|
||||
getContent: () => ({}),
|
||||
getTs: () => createTs,
|
||||
});
|
||||
}
|
||||
rooms[roomId] = makeMockChildRoom(roomId);
|
||||
childTrees.push(new MSC3089TreeSpace(client, roomId));
|
||||
}
|
||||
|
||||
function expectOrder(childRoomId: string, order: number) {
|
||||
const child = childTrees.find(c => c.roomId === childRoomId);
|
||||
expect(child).toBeDefined();
|
||||
expect(child.getOrder()).toEqual(order);
|
||||
}
|
||||
|
||||
function makeMockChildRoom(roomId: string): Room {
|
||||
return {
|
||||
currentState: {
|
||||
getStateEvents: (eventType: EventType, stateKey?: string) => {
|
||||
expect([EventType.SpaceParent, EventType.RoomCreate]).toContain(eventType);
|
||||
if (eventType === EventType.RoomCreate) {
|
||||
expect(stateKey).toEqual("");
|
||||
return childState[roomId].find(e => e.getType() === EventType.RoomCreate);
|
||||
} else {
|
||||
expect(stateKey).toBeUndefined();
|
||||
return childState[roomId].filter(e => e.getType() === eventType);
|
||||
}
|
||||
},
|
||||
},
|
||||
} as Room; // partial
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
childState = {};
|
||||
parentState = [];
|
||||
parentRoom = {
|
||||
...tree.room,
|
||||
roomId: tree.roomId,
|
||||
currentState: {
|
||||
getStateEvents: (eventType: EventType, stateKey?: string) => {
|
||||
expect([EventType.SpaceChild, EventType.RoomCreate, EventType.SpaceParent]).toContain(eventType);
|
||||
if (eventType === EventType.RoomCreate) {
|
||||
expect(stateKey).toEqual("");
|
||||
return parentState.filter(e => e.getType() === EventType.RoomCreate)[0];
|
||||
} else {
|
||||
if (stateKey !== undefined) {
|
||||
expect(Object.keys(rooms)).toContain(stateKey);
|
||||
expect(stateKey).not.toEqual(tree.roomId);
|
||||
return parentState.find(e => e.getType() === eventType && e.getStateKey() === stateKey);
|
||||
} // else fine
|
||||
return parentState.filter(e => e.getType() === eventType);
|
||||
}
|
||||
},
|
||||
},
|
||||
} as Room;
|
||||
childTrees = [];
|
||||
rooms = {};
|
||||
rooms[tree.roomId] = parentRoom;
|
||||
(<any>tree).room = parentRoom; // override readonly
|
||||
client.getRoom = (r) => rooms[r];
|
||||
|
||||
clientSendStateFn = jest.fn().mockImplementation((roomId: string, eventType: EventType, content: any, stateKey: string) => {
|
||||
expect(roomId).toEqual(tree.roomId);
|
||||
expect(eventType).toEqual(EventType.SpaceChild);
|
||||
expect(content).toMatchObject(expect.objectContaining({
|
||||
via: expect.any(Array),
|
||||
order: expect.any(String),
|
||||
}));
|
||||
expect(Object.keys(rooms)).toContain(stateKey);
|
||||
expect(stateKey).not.toEqual(tree.roomId);
|
||||
|
||||
const stateEvent = parentState.find(e => e.getType() === eventType && e.getStateKey() === stateKey);
|
||||
expect(stateEvent).toBeDefined();
|
||||
stateEvent.getContent = () => content;
|
||||
|
||||
return Promise.resolve(); // return value not used
|
||||
});
|
||||
client.sendStateEvent = clientSendStateFn;
|
||||
});
|
||||
|
||||
it('should know when something is top level', () => {
|
||||
const a = "!a:example.org";
|
||||
addSubspace(a);
|
||||
|
||||
expect(tree.isTopLevel).toBe(true);
|
||||
expect(childTrees[0].isTopLevel).toBe(false); // a bit of a hack to get at this, but it's fine
|
||||
});
|
||||
|
||||
it('should return -1 for top level spaces', () => {
|
||||
// The tree is what we've defined as top level, so it should work
|
||||
expect(tree.getOrder()).toEqual(-1);
|
||||
});
|
||||
|
||||
it('should throw when setting an order at the top level space', async () => {
|
||||
try {
|
||||
// The tree is what we've defined as top level, so it should work
|
||||
await tree.setOrder(2);
|
||||
|
||||
// noinspection ExceptionCaughtLocallyJS
|
||||
throw new Error("Failed to fail");
|
||||
} catch (e) {
|
||||
expect(e.message).toEqual("Cannot set order of top level spaces currently");
|
||||
}
|
||||
});
|
||||
|
||||
it('should return a stable order for unordered children', () => {
|
||||
const a = "!a:example.org";
|
||||
const b = "!b:example.org";
|
||||
const c = "!c:example.org";
|
||||
|
||||
// Add in reverse order to make sure it gets ordered correctly
|
||||
addSubspace(c, 3);
|
||||
addSubspace(b, 2);
|
||||
addSubspace(a, 1);
|
||||
|
||||
expectOrder(a, 0);
|
||||
expectOrder(b, 1);
|
||||
expectOrder(c, 2);
|
||||
});
|
||||
|
||||
it('should return a stable order for ordered children', () => {
|
||||
const a = "!a:example.org";
|
||||
const b = "!b:example.org";
|
||||
const c = "!c:example.org";
|
||||
|
||||
// Add in reverse order to make sure it gets ordered correctly
|
||||
addSubspace(a, 1, "Z");
|
||||
addSubspace(b, 2, "Y");
|
||||
addSubspace(c, 3, "X");
|
||||
|
||||
expectOrder(c, 0);
|
||||
expectOrder(b, 1);
|
||||
expectOrder(a, 2);
|
||||
});
|
||||
|
||||
it('should return a stable order for partially ordered children', () => {
|
||||
const a = "!a:example.org";
|
||||
const b = "!b:example.org";
|
||||
const c = "!c:example.org";
|
||||
const d = "!d:example.org";
|
||||
|
||||
// Add in reverse order to make sure it gets ordered correctly
|
||||
addSubspace(a, 1);
|
||||
addSubspace(b, 2);
|
||||
addSubspace(c, 3, "Y");
|
||||
addSubspace(d, 4, "X");
|
||||
|
||||
expectOrder(d, 0);
|
||||
expectOrder(c, 1);
|
||||
expectOrder(b, 3); // note order diff due to room ID comparison expectation
|
||||
expectOrder(a, 2);
|
||||
});
|
||||
|
||||
it('should return a stable order if the create event timestamps are the same', () => {
|
||||
const a = "!a:example.org";
|
||||
const b = "!b:example.org";
|
||||
const c = "!c:example.org";
|
||||
|
||||
// Add in reverse order to make sure it gets ordered correctly
|
||||
addSubspace(c, 3);
|
||||
addSubspace(b, 3); // same as C
|
||||
addSubspace(a, 3); // same as C
|
||||
|
||||
expectOrder(a, 0);
|
||||
expectOrder(b, 1);
|
||||
expectOrder(c, 2);
|
||||
});
|
||||
|
||||
it('should return a stable order if there are no known create events', () => {
|
||||
const a = "!a:example.org";
|
||||
const b = "!b:example.org";
|
||||
const c = "!c:example.org";
|
||||
|
||||
// Add in reverse order to make sure it gets ordered correctly
|
||||
addSubspace(c);
|
||||
addSubspace(b);
|
||||
addSubspace(a);
|
||||
|
||||
expectOrder(a, 0);
|
||||
expectOrder(b, 1);
|
||||
expectOrder(c, 2);
|
||||
});
|
||||
|
||||
// XXX: These tests rely on `getOrder()` re-calculating and not caching values.
|
||||
|
||||
it('should allow reordering within unordered children', async () => {
|
||||
const a = "!a:example.org";
|
||||
const b = "!b:example.org";
|
||||
const c = "!c:example.org";
|
||||
|
||||
// Add in reverse order to make sure it gets ordered correctly
|
||||
addSubspace(c, 3);
|
||||
addSubspace(b, 2);
|
||||
addSubspace(a, 1);
|
||||
|
||||
// Order of this state is validated by other tests.
|
||||
|
||||
const treeA = childTrees.find(c => c.roomId === a);
|
||||
expect(treeA).toBeDefined();
|
||||
await treeA.setOrder(1);
|
||||
|
||||
expect(clientSendStateFn).toHaveBeenCalledTimes(3);
|
||||
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
|
||||
via: [staticDomain], // should retain domain independent of client.getDomain()
|
||||
|
||||
// Because of how the reordering works (maintain stable ordering before moving), we end up calling this
|
||||
// function twice for the same room.
|
||||
order: DEFAULT_ALPHABET[0],
|
||||
}), a);
|
||||
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
|
||||
via: [staticDomain], // should retain domain independent of client.getDomain()
|
||||
order: DEFAULT_ALPHABET[1],
|
||||
}), b);
|
||||
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
|
||||
via: [staticDomain], // should retain domain independent of client.getDomain()
|
||||
order: DEFAULT_ALPHABET[2],
|
||||
}), a);
|
||||
expectOrder(a, 1);
|
||||
expectOrder(b, 0);
|
||||
expectOrder(c, 2);
|
||||
});
|
||||
|
||||
it('should allow reordering within ordered children', async () => {
|
||||
const a = "!a:example.org";
|
||||
const b = "!b:example.org";
|
||||
const c = "!c:example.org";
|
||||
|
||||
// Add in reverse order to make sure it gets ordered correctly
|
||||
addSubspace(c, 3, "Z");
|
||||
addSubspace(b, 2, "X");
|
||||
addSubspace(a, 1, "V");
|
||||
|
||||
// Order of this state is validated by other tests.
|
||||
|
||||
const treeA = childTrees.find(c => c.roomId === a);
|
||||
expect(treeA).toBeDefined();
|
||||
await treeA.setOrder(1);
|
||||
|
||||
expect(clientSendStateFn).toHaveBeenCalledTimes(1);
|
||||
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
|
||||
via: [staticDomain], // should retain domain independent of client.getDomain()
|
||||
order: 'Y',
|
||||
}), a);
|
||||
expectOrder(a, 1);
|
||||
expectOrder(b, 0);
|
||||
expectOrder(c, 2);
|
||||
});
|
||||
|
||||
it('should allow reordering within partially ordered children', async () => {
|
||||
const a = "!a:example.org";
|
||||
const b = "!b:example.org";
|
||||
const c = "!c:example.org";
|
||||
const d = "!d:example.org";
|
||||
|
||||
// Add in reverse order to make sure it gets ordered correctly
|
||||
addSubspace(a, 1);
|
||||
addSubspace(b, 2);
|
||||
addSubspace(c, 3, "Y");
|
||||
addSubspace(d, 4, "W");
|
||||
|
||||
// Order of this state is validated by other tests.
|
||||
|
||||
const treeA = childTrees.find(c => c.roomId === a);
|
||||
expect(treeA).toBeDefined();
|
||||
await treeA.setOrder(2);
|
||||
|
||||
expect(clientSendStateFn).toHaveBeenCalledTimes(1);
|
||||
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
|
||||
via: [staticDomain], // should retain domain independent of client.getDomain()
|
||||
order: 'Z',
|
||||
}), a);
|
||||
expectOrder(a, 2);
|
||||
expectOrder(b, 3);
|
||||
expectOrder(c, 1);
|
||||
expectOrder(d, 0);
|
||||
});
|
||||
|
||||
it('should support moving upwards', async () => {
|
||||
const a = "!a:example.org";
|
||||
const b = "!b:example.org";
|
||||
const c = "!c:example.org";
|
||||
const d = "!d:example.org";
|
||||
|
||||
// Add in reverse order to make sure it gets ordered correctly
|
||||
addSubspace(d, 4, "Z")
|
||||
addSubspace(c, 3, "X");
|
||||
addSubspace(b, 2, "V");
|
||||
addSubspace(a, 1, "T");
|
||||
|
||||
// Order of this state is validated by other tests.
|
||||
|
||||
const treeB = childTrees.find(c => c.roomId === b);
|
||||
expect(treeB).toBeDefined();
|
||||
await treeB.setOrder(2);
|
||||
|
||||
expect(clientSendStateFn).toHaveBeenCalledTimes(1);
|
||||
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
|
||||
via: [staticDomain], // should retain domain independent of client.getDomain()
|
||||
order: 'Y',
|
||||
}), b);
|
||||
expectOrder(a, 0);
|
||||
expectOrder(b, 2);
|
||||
expectOrder(c, 1);
|
||||
expectOrder(d, 3);
|
||||
});
|
||||
|
||||
it('should support moving downwards', async () => {
|
||||
const a = "!a:example.org";
|
||||
const b = "!b:example.org";
|
||||
const c = "!c:example.org";
|
||||
const d = "!d:example.org";
|
||||
|
||||
// Add in reverse order to make sure it gets ordered correctly
|
||||
addSubspace(d, 4, "Z")
|
||||
addSubspace(c, 3, "X");
|
||||
addSubspace(b, 2, "V");
|
||||
addSubspace(a, 1, "T");
|
||||
|
||||
// Order of this state is validated by other tests.
|
||||
|
||||
const treeC = childTrees.find(ch => ch.roomId === c);
|
||||
expect(treeC).toBeDefined();
|
||||
await treeC.setOrder(1);
|
||||
|
||||
expect(clientSendStateFn).toHaveBeenCalledTimes(1);
|
||||
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
|
||||
via: [staticDomain], // should retain domain independent of client.getDomain()
|
||||
order: 'U',
|
||||
}), c);
|
||||
expectOrder(a, 0);
|
||||
expectOrder(b, 2);
|
||||
expectOrder(c, 1);
|
||||
expectOrder(d, 3);
|
||||
});
|
||||
|
||||
it('should support moving over the partial ordering boundary', async () => {
|
||||
const a = "!a:example.org";
|
||||
const b = "!b:example.org";
|
||||
const c = "!c:example.org";
|
||||
const d = "!d:example.org";
|
||||
|
||||
// Add in reverse order to make sure it gets ordered correctly
|
||||
addSubspace(d, 4)
|
||||
addSubspace(c, 3);
|
||||
addSubspace(b, 2, "V");
|
||||
addSubspace(a, 1, "T");
|
||||
|
||||
// Order of this state is validated by other tests.
|
||||
|
||||
const treeB = childTrees.find(ch => ch.roomId === b);
|
||||
expect(treeB).toBeDefined();
|
||||
await treeB.setOrder(2);
|
||||
|
||||
expect(clientSendStateFn).toHaveBeenCalledTimes(2);
|
||||
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
|
||||
via: [staticDomain], // should retain domain independent of client.getDomain()
|
||||
order: 'W',
|
||||
}), c);
|
||||
expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({
|
||||
via: [staticDomain], // should retain domain independent of client.getDomain()
|
||||
order: 'X',
|
||||
}), b);
|
||||
expectOrder(a, 0);
|
||||
expectOrder(b, 2);
|
||||
expectOrder(c, 1);
|
||||
expectOrder(d, 3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,4 +1,12 @@
|
||||
import * as utils from "../../src/utils";
|
||||
import {
|
||||
alphabetPad,
|
||||
averageBetweenStrings,
|
||||
baseToString,
|
||||
DEFAULT_ALPHABET,
|
||||
nextString, prevString,
|
||||
stringToBase
|
||||
} from "../../src/utils";
|
||||
|
||||
describe("utils", function() {
|
||||
describe("encodeParams", function() {
|
||||
@ -259,4 +267,73 @@ describe("utils", function() {
|
||||
expect(promiseCount).toEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEFAULT_ALPHABET', () => {
|
||||
it('should be usefully printable ASCII in order', () => {
|
||||
expect(DEFAULT_ALPHABET).toEqual(" !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~");
|
||||
});
|
||||
});
|
||||
|
||||
describe('alphabetPad', () => {
|
||||
it('should pad to the alphabet length', () => {
|
||||
const defaultPrefixFor1char = [""].reduce(() => {
|
||||
let s = "";
|
||||
for (let i = 0; i < DEFAULT_ALPHABET.length - 1; i++) {
|
||||
s += DEFAULT_ALPHABET[0];
|
||||
}
|
||||
return s;
|
||||
}, "");
|
||||
expect(alphabetPad("a")).toEqual(defaultPrefixFor1char + "a");
|
||||
expect(alphabetPad("a", "123")).toEqual("11a");
|
||||
});
|
||||
});
|
||||
|
||||
describe('baseToString', () => {
|
||||
it('should calculate the appropriate string from numbers', () => {
|
||||
expect(baseToString(10)).toEqual(DEFAULT_ALPHABET[10]);
|
||||
expect(baseToString(10, "abcdefghijklmnopqrstuvwxyz")).toEqual('k');
|
||||
expect(baseToString(6241)).toEqual("ab");
|
||||
expect(baseToString(53, "abcdefghijklmnopqrstuvwxyz")).toEqual('cb');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stringToBase', () => {
|
||||
it('should calculate the appropriate number for a string', () => {
|
||||
expect(stringToBase(" ")).toEqual(0);
|
||||
expect(stringToBase("a", "abcdefghijklmnopqrstuvwxyz")).toEqual(0);
|
||||
expect(stringToBase("a")).toEqual(65);
|
||||
expect(stringToBase("c", "abcdefghijklmnopqrstuvwxyz")).toEqual(2);
|
||||
expect(stringToBase("ab")).toEqual(6241);
|
||||
expect(stringToBase("cb", "abcdefghijklmnopqrstuvwxyz")).toEqual(53);
|
||||
});
|
||||
});
|
||||
|
||||
describe('averageBetweenStrings', () => {
|
||||
it('should average appropriately', () => {
|
||||
expect(averageBetweenStrings('A', 'z')).toEqual(']');
|
||||
expect(averageBetweenStrings('a', 'z', "abcdefghijklmnopqrstuvwxyz")).toEqual('m');
|
||||
expect(averageBetweenStrings('AA', 'zz')).toEqual('^.');
|
||||
expect(averageBetweenStrings('aa', 'zz', "abcdefghijklmnopqrstuvwxyz")).toEqual('mz');
|
||||
expect(averageBetweenStrings('cat', 'doggo')).toEqual("BH65B");
|
||||
expect(averageBetweenStrings('cat', 'doggo', "abcdefghijklmnopqrstuvwxyz")).toEqual("buedq");
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextString', () => {
|
||||
it('should find the next string appropriately', () => {
|
||||
expect(nextString('A')).toEqual('B');
|
||||
expect(nextString('b', 'abcdefghijklmnopqrstuvwxyz')).toEqual('c');
|
||||
expect(nextString('cat')).toEqual('cau');
|
||||
expect(nextString('cat', 'abcdefghijklmnopqrstuvwxyz')).toEqual('cau');
|
||||
});
|
||||
});
|
||||
|
||||
describe('prevString', () => {
|
||||
it('should find the next string appropriately', () => {
|
||||
expect(prevString('B')).toEqual('A');
|
||||
expect(prevString('c', 'abcdefghijklmnopqrstuvwxyz')).toEqual('b');
|
||||
expect(prevString('cau')).toEqual('cat');
|
||||
expect(prevString('cau', 'abcdefghijklmnopqrstuvwxyz')).toEqual('cat');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -17,6 +17,9 @@ limitations under the License.
|
||||
import { MatrixClient } from "../client";
|
||||
import { EventType } from "../@types/event";
|
||||
import { Room } from "./room";
|
||||
import { logger } from "../logger";
|
||||
import { MatrixEvent } from "./event";
|
||||
import { averageBetweenStrings, DEFAULT_ALPHABET, nextString, prevString } from "../utils";
|
||||
|
||||
/**
|
||||
* The recommended defaults for a tree space's power levels. Note that this
|
||||
@ -47,6 +50,7 @@ export const DEFAULT_TREE_POWER_LEVELS_TEMPLATE = {
|
||||
[EventType.RoomMessageEncrypted]: 50,
|
||||
[EventType.Sticker]: 50,
|
||||
},
|
||||
|
||||
users: {}, // defined by calling code
|
||||
};
|
||||
|
||||
@ -70,6 +74,8 @@ export class MSC3089TreeSpace {
|
||||
|
||||
public constructor(private client: MatrixClient, public readonly roomId: string) {
|
||||
this.room = this.client.getRoom(this.roomId);
|
||||
|
||||
if (!this.room) throw new Error("Unknown room");
|
||||
}
|
||||
|
||||
/**
|
||||
@ -79,6 +85,15 @@ export class MSC3089TreeSpace {
|
||||
return this.roomId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether or not this is a top level space.
|
||||
*/
|
||||
public get isTopLevel(): boolean {
|
||||
const parentEvents = this.room.currentState.getStateEvents(EventType.SpaceParent);
|
||||
if (!parentEvents?.length) return true;
|
||||
return parentEvents.every(e => !e.getContent()?.['via']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the name of the tree space.
|
||||
* @param {string} name The new name for the space.
|
||||
@ -135,4 +150,232 @@ export class MSC3089TreeSpace {
|
||||
|
||||
return this.client.sendStateEvent(this.roomId, EventType.RoomPowerLevels, pls, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a directory under this tree space, represented as another tree space.
|
||||
* @param {string} name The name for the directory.
|
||||
* @returns {Promise<MSC3089TreeSpace>} Resolves to the created directory.
|
||||
*/
|
||||
public async createDirectory(name: string): Promise<MSC3089TreeSpace> {
|
||||
const directory = await this.client.unstableCreateFileTree(name);
|
||||
|
||||
await this.client.sendStateEvent(this.roomId, EventType.SpaceChild, {
|
||||
via: [this.client.getDomain()],
|
||||
}, directory.roomId);
|
||||
|
||||
await this.client.sendStateEvent(directory.roomId, EventType.SpaceParent, {
|
||||
via: [this.client.getDomain()],
|
||||
}, this.roomId);
|
||||
|
||||
return directory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a list of all known immediate subdirectories to this tree space.
|
||||
* @returns {MSC3089TreeSpace[]} The tree spaces (directories). May be empty, but not null.
|
||||
*/
|
||||
public getDirectories(): MSC3089TreeSpace[] {
|
||||
const trees: MSC3089TreeSpace[] = [];
|
||||
const children = this.room.currentState.getStateEvents(EventType.SpaceChild);
|
||||
for (const child of children) {
|
||||
try {
|
||||
const tree = this.client.unstableGetFileTreeSpace(child.getStateKey());
|
||||
trees.push(tree);
|
||||
} catch (e) {
|
||||
logger.warn("Unable to create tree space instance for listing. Are we joined?", e);
|
||||
}
|
||||
}
|
||||
return trees;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a subdirectory of a given ID under this tree space. Note that this will not recurse
|
||||
* into children and instead only look one level deep.
|
||||
* @param {string} roomId The room ID (directory ID) to find.
|
||||
* @returns {MSC3089TreeSpace} The directory, or falsy if not found.
|
||||
*/
|
||||
public getDirectory(roomId: string): MSC3089TreeSpace {
|
||||
return this.getDirectories().find(r => r.roomId === roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the tree, kicking all members and deleting **all subdirectories**.
|
||||
* @returns {Promise<void>} Resolves when complete.
|
||||
*/
|
||||
public async delete(): Promise<void> {
|
||||
const subdirectories = this.getDirectories();
|
||||
for (const dir of subdirectories) {
|
||||
await dir.delete();
|
||||
}
|
||||
|
||||
const kickMemberships = ["invite", "knock", "join"];
|
||||
const members = this.room.currentState.getStateEvents(EventType.RoomMember);
|
||||
for (const member of members) {
|
||||
if (member.getStateKey() !== this.client.getUserId() && kickMemberships.includes(member.getContent()['membership'])) {
|
||||
await this.client.kick(this.roomId, member.getStateKey(), "Room deleted");
|
||||
}
|
||||
}
|
||||
|
||||
await this.client.leave(this.roomId);
|
||||
}
|
||||
|
||||
private getOrderedChildren(children: MatrixEvent[]): {roomId: string, order: string}[] {
|
||||
const ordered: {roomId: string, order: string}[] = children
|
||||
.map(c => ({roomId: c.getStateKey(), order: c.getContent()['order']}));
|
||||
ordered.sort((a, b) => {
|
||||
if (a.order && !b.order) {
|
||||
return -1;
|
||||
} else if (!a.order && b.order) {
|
||||
return 1;
|
||||
} else if (!a.order && !b.order) {
|
||||
const roomA = this.client.getRoom(a.roomId);
|
||||
const roomB = this.client.getRoom(b.roomId);
|
||||
if (!roomA || !roomB) { // just don't bother trying to do more partial sorting
|
||||
return a.roomId.localeCompare(b.roomId);
|
||||
}
|
||||
|
||||
const createTsA = roomA.currentState.getStateEvents(EventType.RoomCreate, "")?.getTs() ?? 0;
|
||||
const createTsB = roomB.currentState.getStateEvents(EventType.RoomCreate, "")?.getTs() ?? 0;
|
||||
if (createTsA === createTsB) {
|
||||
return a.roomId.localeCompare(b.roomId);
|
||||
}
|
||||
return createTsA - createTsB;
|
||||
} else { // both not-null orders
|
||||
return a.order.localeCompare(b.order);
|
||||
}
|
||||
});
|
||||
return ordered;
|
||||
}
|
||||
|
||||
private getParentRoom(): Room {
|
||||
const parents = this.room.currentState.getStateEvents(EventType.SpaceParent);
|
||||
const parent = parents[0]; // XXX: Wild assumption
|
||||
if (!parent) throw new Error("Expected to have a parent in a non-top level space");
|
||||
|
||||
// XXX: We are assuming the parent is a valid tree space.
|
||||
// We probably don't need to validate the parent room state for this usecase though.
|
||||
const parentRoom = this.client.getRoom(parent.getStateKey());
|
||||
if (!parentRoom) throw new Error("Unable to locate room for parent");
|
||||
|
||||
return parentRoom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current order index for this directory. Note that if this is the top level space
|
||||
* then -1 will be returned.
|
||||
* @returns {number} The order index of this space.
|
||||
*/
|
||||
public getOrder(): number {
|
||||
if (this.isTopLevel) return -1;
|
||||
|
||||
const parentRoom = this.getParentRoom();
|
||||
const children = parentRoom.currentState.getStateEvents(EventType.SpaceChild);
|
||||
const ordered = this.getOrderedChildren(children);
|
||||
|
||||
return ordered.findIndex(c => c.roomId === this.roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the order index for this directory within its parent. Note that if this is a top level
|
||||
* space then an error will be thrown. -1 can be used to move the child to the start, and numbers
|
||||
* larger than the number of children can be used to move the child to the end.
|
||||
* @param {number} index The new order index for this space.
|
||||
* @returns {Promise<void>} Resolves when complete.
|
||||
* @throws Throws if this is a top level space.
|
||||
*/
|
||||
public async setOrder(index: number): Promise<void> {
|
||||
if (this.isTopLevel) throw new Error("Cannot set order of top level spaces currently");
|
||||
|
||||
const parentRoom = this.getParentRoom();
|
||||
const children = parentRoom.currentState.getStateEvents(EventType.SpaceChild);
|
||||
const ordered = this.getOrderedChildren(children);
|
||||
index = Math.max(Math.min(index, ordered.length - 1), 0);
|
||||
|
||||
const currentIndex = this.getOrder();
|
||||
const movingUp = currentIndex < index;
|
||||
if (movingUp && index === (ordered.length - 1)) {
|
||||
index--;
|
||||
} else if (!movingUp && index === 0) {
|
||||
index++;
|
||||
}
|
||||
|
||||
const prev = ordered[movingUp ? index : (index - 1)];
|
||||
const next = ordered[movingUp ? (index + 1) : index];
|
||||
|
||||
let newOrder = DEFAULT_ALPHABET[0];
|
||||
let ensureBeforeIsSane = false;
|
||||
if (!prev) {
|
||||
// Move to front
|
||||
if (next?.order) {
|
||||
newOrder = prevString(next.order);
|
||||
}
|
||||
} else if (index === (ordered.length - 1)) {
|
||||
// Move to back
|
||||
if (next?.order) {
|
||||
newOrder = nextString(next.order);
|
||||
}
|
||||
} else {
|
||||
// Move somewhere in the middle
|
||||
const startOrder = prev?.order;
|
||||
const endOrder = next?.order;
|
||||
if (startOrder && endOrder) {
|
||||
if (startOrder === endOrder) {
|
||||
// Error case: just move +1 to break out of awful math
|
||||
newOrder = nextString(startOrder);
|
||||
} else {
|
||||
newOrder = averageBetweenStrings(startOrder, endOrder);
|
||||
}
|
||||
} else {
|
||||
if (startOrder) {
|
||||
// We're at the end (endOrder is null, so no explicit order)
|
||||
newOrder = nextString(startOrder);
|
||||
} else if (endOrder) {
|
||||
// We're at the start (startOrder is null, so nothing before us)
|
||||
newOrder = prevString(endOrder);
|
||||
} else {
|
||||
// Both points are unknown. We're likely in a range where all the children
|
||||
// don't have particular order values, so we may need to update them too.
|
||||
// The other possibility is there's only us as a child, but we should have
|
||||
// shown up in the other states.
|
||||
ensureBeforeIsSane = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ensureBeforeIsSane) {
|
||||
// We were asked by the order algorithm to prepare the moving space for a landing
|
||||
// in the undefined order part of the order array, which means we need to update the
|
||||
// spaces that come before it with a stable order value.
|
||||
let lastOrder: string;
|
||||
for (let i = 0; i <= index; i++) {
|
||||
const target = ordered[i];
|
||||
if (i === 0) {
|
||||
lastOrder = target.order;
|
||||
}
|
||||
if (!target.order) {
|
||||
// XXX: We should be creating gaps to avoid conflicts
|
||||
lastOrder = lastOrder ? nextString(lastOrder) : DEFAULT_ALPHABET[0];
|
||||
const currentChild = parentRoom.currentState.getStateEvents(EventType.SpaceChild, target.roomId);
|
||||
const content = currentChild?.getContent() ?? {via: [this.client.getDomain()]};
|
||||
await this.client.sendStateEvent(parentRoom.roomId, EventType.SpaceChild, {
|
||||
...content,
|
||||
order: lastOrder,
|
||||
}, target.roomId);
|
||||
} else {
|
||||
lastOrder = target.order;
|
||||
}
|
||||
}
|
||||
newOrder = nextString(lastOrder);
|
||||
}
|
||||
|
||||
// TODO: Deal with order conflicts by reordering
|
||||
|
||||
// Now we can finally update our own order state
|
||||
const currentChild = parentRoom.currentState.getStateEvents(EventType.SpaceChild, this.roomId);
|
||||
const content = currentChild?.getContent() ?? {via: [this.client.getDomain()]};
|
||||
await this.client.sendStateEvent(parentRoom.roomId, EventType.SpaceChild, {
|
||||
...content,
|
||||
order: newOrder,
|
||||
}, this.roomId);
|
||||
}
|
||||
}
|
||||
|
105
src/utils.ts
105
src/utils.ts
@ -456,3 +456,108 @@ export function setCrypto(c: Object) {
|
||||
export function getCrypto(): Object {
|
||||
return crypto;
|
||||
}
|
||||
|
||||
// String averaging based upon https://stackoverflow.com/a/2510816
|
||||
// Dev note: We make the alphabet a string because it's easier to write syntactically
|
||||
// than arrays. Thankfully, strings implement the useful parts of the Array interface
|
||||
// anyhow.
|
||||
|
||||
/**
|
||||
* The default alphabet used by string averaging in this SDK. This matches
|
||||
* all usefully printable ASCII characters (0x20-0x7E, inclusive).
|
||||
*/
|
||||
export const DEFAULT_ALPHABET = [""].reduce(() => {
|
||||
let str = "";
|
||||
for (let c = 0x20; c <= 0x7E; c++) {
|
||||
str += String.fromCharCode(c);
|
||||
}
|
||||
return str;
|
||||
}, "");
|
||||
|
||||
/**
|
||||
* Pads a string using the given alphabet as a base. The returned string will be the
|
||||
* same length as the alphabet, and padded with the first character in the alphabet.
|
||||
*
|
||||
* This is intended for use with string averaging.
|
||||
* @param {string} s The string to pad.
|
||||
* @param {string} alphabet The alphabet to use as a single string.
|
||||
* @returns {string} The padded string.
|
||||
*/
|
||||
export function alphabetPad(s: string, alphabet = DEFAULT_ALPHABET): string {
|
||||
while (s.length < alphabet.length) {
|
||||
s = alphabet[0] + s;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a baseN number to a string, where N is the alphabet's length.
|
||||
*
|
||||
* This is intended for use with string averaging.
|
||||
* @param {number} n The baseN number.
|
||||
* @param {string} alphabet The alphabet to use as a single string.
|
||||
* @returns {string} The baseN number encoded as a string from the alphabet.
|
||||
*/
|
||||
export function baseToString(n: number, alphabet = DEFAULT_ALPHABET): string {
|
||||
const len = alphabet.length;
|
||||
if (n < len) {
|
||||
return alphabet[n];
|
||||
}
|
||||
return baseToString(Math.floor(n / len), alphabet) + alphabet[n % len];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a string to a baseN number, where N is the alphabet's length.
|
||||
*
|
||||
* This is intended for use with string averaging.
|
||||
* @param {string} s The string to convert to a number.
|
||||
* @param {string} alphabet The alphabet to use as a single string.
|
||||
* @returns {number} The baseN number.
|
||||
*/
|
||||
export function stringToBase(s: string, alphabet = DEFAULT_ALPHABET): number {
|
||||
s = alphabetPad(s, alphabet);
|
||||
const len = alphabet.length;
|
||||
const reversedStr = Array.from(s).reverse();
|
||||
let result = 0;
|
||||
for (let i = 0; i < len; i++) {
|
||||
result += alphabet.indexOf(reversedStr[i]) * (len ** i);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Averages two strings, returning the midpoint between them. This is accomplished by
|
||||
* converting both to baseN numbers (where N is the alphabet's length) then averaging
|
||||
* those before re-encoding as a string.
|
||||
* @param {string} a The first string.
|
||||
* @param {string} b The second string.
|
||||
* @param {string} alphabet The alphabet to use as a single string.
|
||||
* @returns {string} The midpoint between the strings, as a string.
|
||||
*/
|
||||
export function averageBetweenStrings(a: string, b: string, alphabet = DEFAULT_ALPHABET): string {
|
||||
return baseToString(Math.floor((stringToBase(a, alphabet) + stringToBase(b, alphabet)) / 2), alphabet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the next string using the alphabet provided. This is done by converting the
|
||||
* string to a baseN number, where N is the alphabet's length, then adding 1 before
|
||||
* converting back to a string.
|
||||
* @param {string} s The string to start at.
|
||||
* @param {string} alphabet The alphabet to use as a single string.
|
||||
* @returns {string} The string which follows the input string.
|
||||
*/
|
||||
export function nextString(s: string, alphabet = DEFAULT_ALPHABET): string {
|
||||
return baseToString(stringToBase(s, alphabet) + 1, alphabet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the previous string using the alphabet provided. This is done by converting the
|
||||
* string to a baseN number, where N is the alphabet's length, then subtracting 1 before
|
||||
* converting back to a string.
|
||||
* @param {string} s The string to start at.
|
||||
* @param {string} alphabet The alphabet to use as a single string.
|
||||
* @returns {string} The string which precedes the input string.
|
||||
*/
|
||||
export function prevString(s: string, alphabet = DEFAULT_ALPHABET): string {
|
||||
return baseToString(stringToBase(s, alphabet) - 1, alphabet);
|
||||
}
|
||||
|
Reference in New Issue
Block a user