This commit is contained in:
R Midhun Suresh 2025-10-24 01:54:52 +05:30
parent c31d4fea8d
commit fff9fd9798
No known key found for this signature in database
19 changed files with 530 additions and 22 deletions

View File

@ -98,7 +98,7 @@ declare global {
mxToastStore: ToastStore;
mxDeviceListener: DeviceListener;
mxRoomListStore: RoomListStore;
mxRoomListStoreV3: RoomListStoreV3Class;
getRoomListStoreV3: () => RoomListStoreV3Class;
mxRoomListLayoutStore: RoomListLayoutStore;
mxPlatformPeg: PlatformPeg;
mxIntegrationManagers: typeof IntegrationManagers;

View File

@ -14,7 +14,7 @@ import Timer from "../../utils/Timer";
import AutoHideScrollbar from "./AutoHideScrollbar";
import { getKeyBindingsManager } from "../../KeyBindingsManager";
import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
import { SDKContext } from "../../contexts/SDKContext";
import type { SDKContext } from "../../contexts/SDKContext";
// The amount of extra scroll distance to allow prior to unfilling.
// See getExcessHeight.
@ -184,7 +184,6 @@ export default class ScrollPanel extends React.Component<IProps> {
private heightUpdateInProgress = false;
public divScroll: HTMLDivElement | null = null;
public static contextType = SDKContext;
declare public context: React.ContextType<typeof SDKContext>;
public constructor(props: IProps, context: React.ContextType<typeof SDKContext>) {

View File

@ -0,0 +1,52 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { Watchable, type AccountDataApi as IAccountDataApi } from "@element-hq/element-web-module-api";
import { ClientEvent, type MatrixEvent, type MatrixClient } from "matrix-js-sdk/src/matrix";
import { getSafeCli } from "./common";
export class AccountDataApi implements IAccountDataApi {
public get(eventType: string): Watchable<unknown> {
const cli = getSafeCli();
return new AccountDataWatchable(cli, eventType);
}
public async set(eventType: string, content: any): Promise<void> {
//@ts-expect-error: JS-SDK accepts known event-types, intentionally allow arbitrary types.
await getSafeCli().setAccountData(eventType, content);
}
public async delete(eventType: string): Promise<void> {
//@ts-expect-error: JS-SDK accepts known event-types, intentionally allow arbitrary types.
getSafeCli().deleteAccountData(eventType);
}
}
class AccountDataWatchable extends Watchable<unknown> {
public constructor(
private cli: MatrixClient,
private eventType: string,
) {
//@ts-expect-error: JS-SDK accepts known event-types, intentionally allow arbitrary types.
super(cli.getAccountData(eventType)?.getContent());
}
private onAccountData = (event: MatrixEvent): void => {
if (event.getType() === this.eventType) {
this.value = event.getContent();
}
};
protected onFirstWatch(): void {
this.cli.on(ClientEvent.AccountData, this.onAccountData);
}
protected onLastWatch(): void {
this.cli.off(ClientEvent.AccountData, this.onAccountData);
}
}

21
src/modules/ActionsApi.ts Normal file
View File

@ -0,0 +1,21 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import type { ActionsApi as IActionsApi } from "@element-hq/element-web-module-api";
import type { ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload";
import dispatcher from "../dispatcher/dispatcher";
import { Action } from "../dispatcher/actions";
export class ActionsApi implements IActionsApi {
public openRoom(roomId: string): void {
dispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: roomId,
metricsTrigger: undefined, // other
});
}
}

View File

@ -27,7 +27,10 @@ import { NavigationApi } from "./Navigation.ts";
import { openDialog } from "./Dialog.tsx";
import { overwriteAccountAuth } from "./Auth.ts";
import { ElementWebExtrasApi } from "./ExtrasApi.ts";
import { ElementWebBuiltinsApi } from "./BuiltinsApi.ts";
import { ElementWebBuiltinsApi } from "./BuiltinsApi.tsx";
import { StoresApi } from "./StoresApi.ts";
import { ClientApi } from "./ClientApi.ts";
import { ActionsApi } from "./ActionsApi.ts";
const legacyCustomisationsFactory = <T extends object>(baseCustomisations: T) => {
let used = false;
@ -84,6 +87,9 @@ export class ModuleApi implements Api {
public readonly extras = new ElementWebExtrasApi();
public readonly builtins = new ElementWebBuiltinsApi();
public readonly rootNode = document.getElementById("matrixchat")!;
public readonly stores = new StoresApi();
public readonly client = new ClientApi();
public readonly actions = new ActionsApi();
public createRoot(element: Element): Root {
return createRoot(element);

View File

@ -5,8 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { type RoomViewProps, type BuiltinsApi } from "@element-hq/element-web-module-api";
import RoomAvatar from "../components/views/avatars/RoomAvatar";
import { getSafeCli } from "./common";
export class ElementWebBuiltinsApi implements BuiltinsApi {
private _roomView?: React.ComponentType<RoomViewProps>;
@ -30,4 +34,17 @@ export class ElementWebBuiltinsApi implements BuiltinsApi {
return this._roomView;
}
public renderRoomView(roomId: string): React.ReactNode {
const Component = this.getRoomViewComponent();
return <Component roomId={roomId} />;
}
public renderRoomAvatar(roomId: string, size?: string): React.ReactNode {
const room = getSafeCli().getRoom(roomId);
if (!room) {
throw new Error(`No room such room: ${roomId}`);
}
return <RoomAvatar room={room} size={size} />;
}
}

27
src/modules/ClientApi.ts Normal file
View File

@ -0,0 +1,27 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import type { ClientApi as IClientApi, Room } from "@element-hq/element-web-module-api";
import { Room as ModuleRoom } from "./models/Room";
import { AccountDataApi } from "./AccountDataApi";
import { getSafeCli } from "./common";
export class ClientApi implements IClientApi {
private accountDataApi?: AccountDataApi;
public getRoom(roomId: string): Room | null {
const sdkRoom = getSafeCli().getRoom(roomId);
if (sdkRoom) return new ModuleRoom(sdkRoom);
return null;
}
public get accountData(): AccountDataApi {
if (!this.accountDataApi) {
this.accountDataApi = new AccountDataApi();
}
return this.accountDataApi;
}
}

65
src/modules/StoresApi.ts Normal file
View File

@ -0,0 +1,65 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import {
type StoresApi as IStoresApi,
type RoomListStoreApi as IRoomListStore,
type Room,
Watchable,
} from "@element-hq/element-web-module-api";
import RoomListStoreV3, { LISTS_LOADED_EVENT, LISTS_UPDATE_EVENT } from "../stores/room-list-v3/RoomListStoreV3";
import { Room as ModuleRoom } from "./models/Room";
class RoomListStoreApi implements IRoomListStore {
public getRooms(): RoomsWatchable {
return new RoomsWatchable();
}
public async waitForReady(): Promise<void> {
// Check if RLS is already loaded
if (!RoomListStoreV3.instance.isLoadingRooms) return;
// Return a promise that resolves when RLS has loaded
let resolve: () => void;
const promise: Promise<void> = new Promise((_resolve) => {
resolve = _resolve;
});
RoomListStoreV3.instance.once(LISTS_LOADED_EVENT, () => {
resolve();
});
return promise;
}
}
class RoomsWatchable extends Watchable<Room[]> {
public constructor() {
super(RoomListStoreV3.instance.getSortedRooms().map((sdkRoom) => new ModuleRoom(sdkRoom)));
}
private onRlsUpdate = (): void => {
this.value = RoomListStoreV3.instance.getSortedRooms().map((sdkRoom) => new ModuleRoom(sdkRoom));
};
protected onFirstWatch(): void {
RoomListStoreV3.instance.on(LISTS_UPDATE_EVENT, this.onRlsUpdate);
}
protected onLastWatch(): void {
RoomListStoreV3.instance.off(LISTS_UPDATE_EVENT, this.onRlsUpdate);
}
}
export class StoresApi implements IStoresApi {
private roomListStoreApi?: IRoomListStore;
public get roomListStore(): IRoomListStore {
if (!this.roomListStoreApi) {
this.roomListStoreApi = new RoomListStoreApi();
}
return this.roomListStoreApi;
}
}

22
src/modules/common.ts Normal file
View File

@ -0,0 +1,22 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
import { SdkContextClass } from "../contexts/SDKContext";
/**
* Get MatrixClient instance from SdkContextClass.
* @throws Will throw error if cli is not instantiated in SdkContextClass
* @returns MatrixClient object
*/
export function getSafeCli(): MatrixClient {
const cli = SdkContextClass.instance.client;
if (!cli) {
throw new Error("Could not get MatrixClient from SdkContextClass");
}
return cli;
}

View File

@ -0,0 +1,45 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type Room as IRoom, Watchable } from "@element-hq/element-web-module-api";
import { RoomEvent, type Room as SdkRoom } from "matrix-js-sdk/src/matrix";
export class Room implements IRoom {
public name: Watchable<string>;
public constructor(private sdkRoom: SdkRoom) {
this.name = new WatchableName(sdkRoom);
}
public getLastActiveTimestamp(): number {
return this.sdkRoom.getLastActiveTimestamp();
}
public get id(): string {
return this.sdkRoom.roomId;
}
}
/**
* A custom watchable for room name.
*/
class WatchableName extends Watchable<string> {
public constructor(private sdkRoom: SdkRoom) {
super(sdkRoom.name);
}
private onNameUpdate = (): void => {
super.value = this.sdkRoom.name;
};
protected onFirstWatch(): void {
this.sdkRoom.on(RoomEvent.Name, this.onNameUpdate);
}
protected onLastWatch(): void {
this.sdkRoom.off(RoomEvent.Name, this.onNameUpdate);
}
}

View File

@ -373,4 +373,4 @@ export default class RoomListStoreV3 {
}
}
window.mxRoomListStoreV3 = RoomListStoreV3.instance;
window.getRoomListStoreV3 = () => RoomListStoreV3.instance;

View File

@ -650,6 +650,7 @@ export function mkStubRoom(
getJoinedMembers: jest.fn().mockReturnValue([]),
getLiveTimeline: jest.fn().mockReturnValue(stubTimeline),
getLastLiveEvent: jest.fn().mockReturnValue(undefined),
getLastActiveTimestamp: jest.fn().mockReturnValue(1183140000),
getMember: jest.fn().mockReturnValue({
userId: "@member:domain.bla",
name: "Member",

View File

@ -0,0 +1,39 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { AccountDataApi } from "../../../src/modules/AccountDataApi";
import * as utils from "../../../src/modules/common";
import { mkEvent, stubClient } from "../../test-utils/test-utils";
describe("AccountDataApi", () => {
it("should return content of account data event on get()", () => {
const cli = stubClient();
jest.spyOn(utils, "getSafeCli").mockReturnValue(cli);
const api = new AccountDataApi();
// Mock cli to return a event
const content = { foo: "bar" };
const event = mkEvent({ content, type: "m.test", user: "@foobar:matrix.org", event: true });
cli.getAccountData = () => event;
expect(api.get("m.test").value).toStrictEqual(content);
});
it("should set account data via js-sdk on set()", async () => {
const cli = stubClient();
jest.spyOn(utils, "getSafeCli").mockReturnValue(cli);
const api = new AccountDataApi();
await api.set("m.test", { foo: "bar" });
expect(cli.setAccountData).toHaveBeenCalledTimes(1);
});
it("should delete account data via js-sdk on set()", async () => {
const cli = stubClient();
jest.spyOn(utils, "getSafeCli").mockReturnValue(cli);
const api = new AccountDataApi();
await api.delete("m.test");
expect(cli.deleteAccountData).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,27 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { waitFor } from "jest-matrix-react";
import { Action } from "../../../src/dispatcher/actions";
import dispatcher from "../../../src/dispatcher/dispatcher";
import { ActionsApi } from "../../../src/modules/ActionsApi";
describe("ActionsApi", () => {
it("should dispatch view room action", async () => {
const api = new ActionsApi();
const fn = jest.fn();
dispatcher.register(fn);
api.openRoom("!foo:m.org");
await waitFor(() =>
expect(fn).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: "!foo:m.org",
}),
);
});
});

View File

@ -1,17 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { ElementWebBuiltinsApi } from "../../../src/modules/BuiltinsApi";
describe("ElementWebBuiltinsApi", () => {
it("returns the RoomView component thats been set", () => {
const builtinsApi = new ElementWebBuiltinsApi();
const sentinel = {};
builtinsApi.setRoomViewComponent(sentinel as any);
expect(builtinsApi.getRoomViewComponent()).toBe(sentinel);
});
});

View File

@ -0,0 +1,55 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render } from "jest-matrix-react";
import { ElementWebBuiltinsApi } from "../../../src/modules/BuiltinsApi";
import { stubClient } from "../../test-utils/test-utils";
import * as utils from "../../../src/modules/common";
jest.mock("../../../src/components/views/avatars/RoomAvatar", () => {
const Avatar: React.FC<{ room: { roomId: string }; size: string }> = ({ room, size }) => {
return (
<div>
Avatar, {room.roomId}, {size}
</div>
);
};
return {
__esModule: true,
default: Avatar,
};
});
describe("ElementWebBuiltinsApi", () => {
it("returns the RoomView component thats been set", () => {
const builtinsApi = new ElementWebBuiltinsApi();
const sentinel = {};
builtinsApi.setRoomViewComponent(sentinel as any);
expect(builtinsApi.getRoomViewComponent()).toBe(sentinel);
});
it("returns rendered RoomView component", () => {
const builtinsApi = new ElementWebBuiltinsApi();
const RoomView = () => <div>hello world</div>;
builtinsApi.setRoomViewComponent(RoomView as any);
const { container } = render(<> {builtinsApi.renderRoomView("!foo:m.org")}</>);
expect(container).toHaveTextContent("hello world");
});
it("returns rendered RoomAvatar component", () => {
const cli = stubClient();
jest.spyOn(utils, "getSafeCli").mockReturnValue(cli);
const builtinsApi = new ElementWebBuiltinsApi();
const { container } = render(<> {builtinsApi.renderRoomAvatar("!foo:m.org", "50")}</>);
expect(container).toHaveTextContent("Avatar");
expect(container).toHaveTextContent("!foo:m.org");
expect(container).toHaveTextContent("50");
});
});

View File

@ -0,0 +1,22 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { ClientApi } from "../../../src/modules/ClientApi";
import * as utils from "../../../src/modules/common";
import { Room } from "../../../src/modules/models/Room";
import { stubClient } from "../../test-utils/test-utils";
describe("ClientApi", () => {
it("should return module room from getRoom()", () => {
const cli = stubClient();
jest.spyOn(utils, "getSafeCli").mockReturnValue(cli);
const client = new ClientApi();
const moduleRoom = client.getRoom("!foo:matrix.org");
expect(moduleRoom).toBeInstanceOf(Room);
expect(moduleRoom?.id).toStrictEqual("!foo:matrix.org");
});
});

View File

@ -0,0 +1,77 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { waitFor } from "jest-matrix-react";
import { StoresApi } from "../../../src/modules/StoresApi";
import RoomListStoreV3, {
LISTS_LOADED_EVENT,
LISTS_UPDATE_EVENT,
} from "../../../src/stores/room-list-v3/RoomListStoreV3";
import { mkRoom, stubClient } from "../../test-utils/test-utils";
import { Room } from "../../../src/modules/models/Room";
import {} from "../../../src/stores/room-list/algorithms/Algorithm";
describe("StoresApi", () => {
describe("RoomListStoreApi", () => {
it("should return promise that resolves when RLS is ready", async () => {
jest.spyOn(RoomListStoreV3.instance, "isLoadingRooms", "get").mockReturnValue(true);
const store = new StoresApi();
let hasResolved = false;
// The following async function will set hasResolved to false
// only when waitForReady resolves.
(async () => {
await store.roomListStore.waitForReady();
hasResolved = true;
})();
// Shouldn't have resolved yet.
expect(hasResolved).toStrictEqual(false);
// Emit the loaded event.
RoomListStoreV3.instance.emit(LISTS_LOADED_EVENT);
// Should resolve now.
await waitFor(() => {
expect(hasResolved).toStrictEqual(true);
});
});
describe("getRooms()", () => {
it("should return rooms from RLS", () => {
const cli = stubClient();
const room1 = mkRoom(cli, "!foo1:m.org");
const room2 = mkRoom(cli, "!foo2:m.org");
const room3 = mkRoom(cli, "!foo3:m.org");
jest.spyOn(RoomListStoreV3.instance, "getSortedRooms").mockReturnValue([room1, room2, room3]);
const store = new StoresApi();
const watchable = store.roomListStore.getRooms();
expect(watchable.value).toHaveLength(3);
expect(watchable.value[0]).toBeInstanceOf(Room);
});
it("should update from RLS", () => {
const cli = stubClient();
const room1 = mkRoom(cli, "!foo1:m.org");
const room2 = mkRoom(cli, "!foo2:m.org");
const rooms = [room1, room2];
jest.spyOn(RoomListStoreV3.instance, "getSortedRooms").mockReturnValue(rooms);
const store = new StoresApi();
const watchable = store.roomListStore.getRooms();
const fn = jest.fn();
watchable.watch(fn);
expect(watchable.value).toHaveLength(2);
const room3 = mkRoom(cli, "!foo3:m.org");
rooms.push(room3);
RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT);
expect(fn).toHaveBeenCalledTimes(1);
expect(watchable.value).toHaveLength(3);
});
});
});
});

View File

@ -0,0 +1,50 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { Room } from "../../../../src/modules/models/Room";
import { mkRoom, stubClient } from "../../../test-utils";
describe("Room", () => {
it("should return id from sdk room", () => {
const cli = stubClient();
const sdkRoom = mkRoom(cli, "!foo:m.org");
const room = new Room(sdkRoom);
expect(room.id).toStrictEqual("!foo:m.org");
});
it("should return last timestamp from sdk room", () => {
const cli = stubClient();
const sdkRoom = mkRoom(cli, "!foo:m.org");
const room = new Room(sdkRoom);
expect(room.getLastActiveTimestamp()).toStrictEqual(sdkRoom.getLastActiveTimestamp());
});
describe("watchableName", () => {
it("should return name from sdkRoom", () => {
const cli = stubClient();
const sdkRoom = mkRoom(cli, "!foo:m.org");
sdkRoom.name = "Foo Name";
const room = new Room(sdkRoom);
expect(room.name.value).toStrictEqual("Foo Name");
});
it("should add/remove event listener on sdk room", () => {
const cli = stubClient();
const sdkRoom = mkRoom(cli, "!foo:m.org");
sdkRoom.name = "Foo Name";
const room = new Room(sdkRoom);
const fn = jest.fn();
room.name.watch(fn);
expect(sdkRoom.on).toHaveBeenCalledTimes(1);
room.name.unwatch(fn);
expect(sdkRoom.off).toHaveBeenCalledTimes(1);
});
});
});