diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 289bda3f494..9d43a13ca4a 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -98,7 +98,7 @@ declare global { mxToastStore: ToastStore; mxDeviceListener: DeviceListener; mxRoomListStore: RoomListStore; - mxRoomListStoreV3: RoomListStoreV3Class; + getRoomListStoreV3: () => RoomListStoreV3Class; mxRoomListLayoutStore: RoomListLayoutStore; mxPlatformPeg: PlatformPeg; mxIntegrationManagers: typeof IntegrationManagers; diff --git a/src/components/structures/ScrollPanel.tsx b/src/components/structures/ScrollPanel.tsx index e206fc3c70c..a84b8544eae 100644 --- a/src/components/structures/ScrollPanel.tsx +++ b/src/components/structures/ScrollPanel.tsx @@ -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 { private heightUpdateInProgress = false; public divScroll: HTMLDivElement | null = null; - public static contextType = SDKContext; declare public context: React.ContextType; public constructor(props: IProps, context: React.ContextType) { diff --git a/src/modules/AccountDataApi.ts b/src/modules/AccountDataApi.ts new file mode 100644 index 00000000000..1a83fab6e79 --- /dev/null +++ b/src/modules/AccountDataApi.ts @@ -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 { + const cli = getSafeCli(); + return new AccountDataWatchable(cli, eventType); + } + + public async set(eventType: string, content: any): Promise { + //@ts-expect-error: JS-SDK accepts known event-types, intentionally allow arbitrary types. + await getSafeCli().setAccountData(eventType, content); + } + + public async delete(eventType: string): Promise { + //@ts-expect-error: JS-SDK accepts known event-types, intentionally allow arbitrary types. + getSafeCli().deleteAccountData(eventType); + } +} + +class AccountDataWatchable extends Watchable { + 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); + } +} diff --git a/src/modules/ActionsApi.ts b/src/modules/ActionsApi.ts new file mode 100644 index 00000000000..da330ad4ff7 --- /dev/null +++ b/src/modules/ActionsApi.ts @@ -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({ + action: Action.ViewRoom, + room_id: roomId, + metricsTrigger: undefined, // other + }); + } +} diff --git a/src/modules/Api.ts b/src/modules/Api.ts index e463f6c085f..7bc349d3e19 100644 --- a/src/modules/Api.ts +++ b/src/modules/Api.ts @@ -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 = (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); diff --git a/src/modules/BuiltinsApi.ts b/src/modules/BuiltinsApi.tsx similarity index 66% rename from src/modules/BuiltinsApi.ts rename to src/modules/BuiltinsApi.tsx index 64c2dc4728d..9bb81848730 100644 --- a/src/modules/BuiltinsApi.ts +++ b/src/modules/BuiltinsApi.tsx @@ -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; @@ -30,4 +34,17 @@ export class ElementWebBuiltinsApi implements BuiltinsApi { return this._roomView; } + + public renderRoomView(roomId: string): React.ReactNode { + const Component = this.getRoomViewComponent(); + return ; + } + + public renderRoomAvatar(roomId: string, size?: string): React.ReactNode { + const room = getSafeCli().getRoom(roomId); + if (!room) { + throw new Error(`No room such room: ${roomId}`); + } + return ; + } } diff --git a/src/modules/ClientApi.ts b/src/modules/ClientApi.ts new file mode 100644 index 00000000000..e07d93608e7 --- /dev/null +++ b/src/modules/ClientApi.ts @@ -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; + } +} diff --git a/src/modules/StoresApi.ts b/src/modules/StoresApi.ts new file mode 100644 index 00000000000..22e9f500518 --- /dev/null +++ b/src/modules/StoresApi.ts @@ -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 { + // 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 = new Promise((_resolve) => { + resolve = _resolve; + }); + RoomListStoreV3.instance.once(LISTS_LOADED_EVENT, () => { + resolve(); + }); + return promise; + } +} + +class RoomsWatchable extends Watchable { + 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; + } +} diff --git a/src/modules/common.ts b/src/modules/common.ts new file mode 100644 index 00000000000..50a8e53a333 --- /dev/null +++ b/src/modules/common.ts @@ -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; +} diff --git a/src/modules/models/Room.ts b/src/modules/models/Room.ts new file mode 100644 index 00000000000..32fed6496ad --- /dev/null +++ b/src/modules/models/Room.ts @@ -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; + + 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 { + 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); + } +} diff --git a/src/stores/room-list-v3/RoomListStoreV3.ts b/src/stores/room-list-v3/RoomListStoreV3.ts index f5fb48df07a..e0461c3a5f3 100644 --- a/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/src/stores/room-list-v3/RoomListStoreV3.ts @@ -373,4 +373,4 @@ export default class RoomListStoreV3 { } } -window.mxRoomListStoreV3 = RoomListStoreV3.instance; +window.getRoomListStoreV3 = () => RoomListStoreV3.instance; diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index d96ee1d045e..8f70d089dd5 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -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", diff --git a/test/unit-tests/modules/AccountDataApi-test.ts b/test/unit-tests/modules/AccountDataApi-test.ts new file mode 100644 index 00000000000..9697332c866 --- /dev/null +++ b/test/unit-tests/modules/AccountDataApi-test.ts @@ -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); + }); +}); diff --git a/test/unit-tests/modules/ActionsApi-test.ts b/test/unit-tests/modules/ActionsApi-test.ts new file mode 100644 index 00000000000..b1de69d80a0 --- /dev/null +++ b/test/unit-tests/modules/ActionsApi-test.ts @@ -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", + }), + ); + }); +}); diff --git a/test/unit-tests/modules/BuiltinsApi-test.ts b/test/unit-tests/modules/BuiltinsApi-test.ts deleted file mode 100644 index 38ddec0c560..00000000000 --- a/test/unit-tests/modules/BuiltinsApi-test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/test/unit-tests/modules/BuiltinsApi-test.tsx b/test/unit-tests/modules/BuiltinsApi-test.tsx new file mode 100644 index 00000000000..59363734a1e --- /dev/null +++ b/test/unit-tests/modules/BuiltinsApi-test.tsx @@ -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 ( +
+ Avatar, {room.roomId}, {size} +
+ ); + }; + 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 = () =>
hello world
; + 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"); + }); +}); diff --git a/test/unit-tests/modules/ClientApi-test.ts b/test/unit-tests/modules/ClientApi-test.ts new file mode 100644 index 00000000000..d64dad2790e --- /dev/null +++ b/test/unit-tests/modules/ClientApi-test.ts @@ -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"); + }); +}); diff --git a/test/unit-tests/modules/StoresApi-test.ts b/test/unit-tests/modules/StoresApi-test.ts new file mode 100644 index 00000000000..10d0cd6078d --- /dev/null +++ b/test/unit-tests/modules/StoresApi-test.ts @@ -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); + }); + }); + }); +}); diff --git a/test/unit-tests/modules/models/Room-test.ts b/test/unit-tests/modules/models/Room-test.ts new file mode 100644 index 00000000000..d149c8cdf05 --- /dev/null +++ b/test/unit-tests/modules/models/Room-test.ts @@ -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); + }); + }); +});