Add support for Widget & Room Header Buttons module APIs (#32734)

* Add support for Widget & Room Header Buttons module APIs

To support https://github.com/element-hq/element-modules/pull/217

* Update for new api

* Test addRoomHeaderButtonCallback

* Extra mock api

* Test for widgetapi

* Convert enum

* Convert other enum usage

* Add tests for widget context menu move buttons

Which have just changed because of the enum

* Add tests for moving the widgets

* Fix copyright

Co-authored-by: Florian Duros <florianduros@element.io>

* Update module API

* A little import/export

---------

Co-authored-by: Florian Duros <florianduros@element.io>
This commit is contained in:
David Baker
2026-03-13 13:44:18 +00:00
committed by GitHub
co-authored by Florian Duros
parent 86692ce0a7
commit 09bbf796dc
30 changed files with 553 additions and 225 deletions
@@ -0,0 +1,77 @@
/*
Copyright 2026 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, { act } from "react";
import { render, type RenderOptions } from "jest-matrix-react";
import { type MatrixClient, PendingEventOrdering, Room } from "matrix-js-sdk/src/matrix";
import { EventEmitter } from "events";
import { stubClient } from "../../test-utils";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import { SDKContext, SdkContextClass } from "../../../src/contexts/SDKContext";
import { ScopedRoomContextProvider } from "../../../src/contexts/ScopedRoomContext";
import RoomContext, { type RoomContextType } from "../../../src/contexts/RoomContext";
import MatrixClientContext from "../../../src/contexts/MatrixClientContext";
import { RoomView } from "../../../src/components/structures/RoomView";
import { ModuleApi } from "../../../src/modules/Api";
describe("ExtrasApi", () => {
let client: MatrixClient;
let sdkContext: SdkContextClass;
let room: Room;
let roomContext: RoomContextType;
beforeEach(() => {
client = stubClient();
room = new Room("!test:room", client, "@alice:example.org", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
sdkContext = new SdkContextClass();
sdkContext.client = client;
jest.spyOn(sdkContext.roomViewStore, "getRoomId").mockReturnValue(room.roomId);
const mockRoomViewStore = new (class extends EventEmitter {
isViewingCall = jest.fn().mockReturnValue(false);
})();
roomContext = {
...RoomContext,
roomId: "!test:room",
roomViewStore: mockRoomViewStore,
} as unknown as RoomContextType;
DMRoomMap.setShared({
getUserIdForRoomId: jest.fn(),
getRoomIds: jest.fn().mockReturnValue(new Set()),
} as unknown as DMRoomMap);
});
function getWrapper(): RenderOptions {
return {
wrapper: ({ children }) => (
<SDKContext.Provider value={sdkContext}>
<ScopedRoomContextProvider {...roomContext}>
<MatrixClientContext.Provider value={client}>{children}</MatrixClientContext.Provider>
</ScopedRoomContextProvider>
</SDKContext.Provider>
),
};
}
it("addRoomHeaderButtonCallback stores and uses the provided callback", () => {
const callback = jest.fn();
ModuleApi.instance.extras.addRoomHeaderButtonCallback(callback);
render(<RoomView />, getWrapper());
act(() => {
sdkContext.roomViewStore.emit("update");
});
expect(callback).toHaveBeenCalled();
});
});
@@ -23,7 +23,7 @@ import { registerMockModule } from "./MockModule";
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
import { Action } from "../../../src/dispatcher/actions";
import WidgetStore, { type IApp } from "../../../src/stores/WidgetStore";
import { Container, WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore";
import { WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore";
import * as navigator from "../../../src/utils/permalinks/navigator.ts";
describe("ProxiedApiModule", () => {
@@ -319,18 +319,18 @@ describe("ProxiedApiModule", () => {
it("should return false if there is no room", () => {
client.getRoom = jest.fn().mockReturnValue(null);
expect(api.isAppInContainer(app, Container.Top, roomId)).toBe(false);
expect(api.isAppInContainer(app, "top", roomId)).toBe(false);
expect(WidgetLayoutStore.instance.isInContainer).not.toHaveBeenCalled();
});
it("should return false if the app is not in the container", () => {
jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(false);
expect(api.isAppInContainer(app, Container.Top, roomId)).toBe(false);
expect(api.isAppInContainer(app, "top", roomId)).toBe(false);
});
it("should return true if the app is in the container", () => {
jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(true);
expect(api.isAppInContainer(app, Container.Top, roomId)).toBe(true);
expect(api.isAppInContainer(app, "top", roomId)).toBe(true);
});
});
@@ -350,7 +350,7 @@ describe("ProxiedApiModule", () => {
it("should not move if there is no room", () => {
client.getRoom = jest.fn().mockReturnValue(null);
api.moveAppToContainer(app, Container.Top, roomId);
api.moveAppToContainer(app, "top", roomId);
expect(WidgetLayoutStore.instance.moveToContainer).not.toHaveBeenCalled();
});
@@ -358,8 +358,8 @@ describe("ProxiedApiModule", () => {
const room = mkRoom(client, roomId);
client.getRoom = jest.fn().mockReturnValue(room);
api.moveAppToContainer(app, Container.Top, roomId);
expect(WidgetLayoutStore.instance.moveToContainer).toHaveBeenCalledWith(room, app, Container.Top);
api.moveAppToContainer(app, "top", roomId);
expect(WidgetLayoutStore.instance.moveToContainer).toHaveBeenCalledWith(room, app, "top");
});
});
@@ -0,0 +1,121 @@
/*
Copyright 2026 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 { mocked } from "jest-mock";
import type { IWidget } from "matrix-widget-api";
import type { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import { WidgetApi } from "../../../src/modules/WidgetApi";
import WidgetStore from "../../../src/stores/WidgetStore";
import { WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore";
import { stubClient } from "../../test-utils";
describe("WidgetApi", () => {
let client: MatrixClient;
let api: WidgetApi;
const mkWidget = (overrides: Partial<IWidget> = {}): IWidget => ({
id: "widget-id",
creatorUserId: "@alice:example.org",
type: "m.custom",
url: "https://example.org/widget",
...overrides,
});
beforeEach(() => {
client = stubClient();
api = new WidgetApi();
});
afterEach(() => {
jest.restoreAllMocks();
});
it("getWidgetsInRoom returns widgets from WidgetStore", () => {
const widgets = [{ id: "w1" }, { id: "w2" }] as unknown as IWidget[];
const getAppsSpy = jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue(widgets as any);
expect(api.getWidgetsInRoom("!room:example.org")).toBe(widgets);
expect(getAppsSpy).toHaveBeenCalledWith("!room:example.org");
});
it("getAppAvatarUrl returns the http avatar URL for a widget if it has one", () => {
const app = {
...mkWidget(),
roomId: "!room:example.org",
avatar_url: "mxc://example.org/avatar",
} as unknown as IWidget;
mocked(client.getHomeserverUrl).mockReturnValue("https://hs.example.org");
const avatarUrl = api.getAppAvatarUrl(app, 32, 32, "scale");
expect(avatarUrl).toContain("https://hs.example.org/_matrix/media/");
expect(avatarUrl).toContain("/thumbnail/example.org/avatar");
expect(avatarUrl).toContain("width=32");
expect(avatarUrl).toContain("height=32");
expect(avatarUrl).toContain("method=scale");
});
it("getAppAvatarUrl returns null when app is not an app widget", () => {
const nonAppWidget = {
...mkWidget(),
avatar_url: "mxc://example.org/avatar",
};
expect(api.getAppAvatarUrl(nonAppWidget)).toBeNull();
});
it("getAppAvatarUrl returns null when app has no avatar URL", () => {
const appWithoutAvatar = {
...mkWidget(),
roomId: "!room:example.org",
} as unknown as IWidget;
expect(api.getAppAvatarUrl(appWithoutAvatar)).toBeNull();
});
it("isAppInContainer returns false when room is not found", () => {
const isInContainerSpy = jest.spyOn(WidgetLayoutStore.instance, "isInContainer");
const app = mkWidget();
mocked(client.getRoom).mockReturnValue(null);
expect(api.isAppInContainer(app, "top", "!missing:example.org")).toBe(false);
expect(isInContainerSpy).not.toHaveBeenCalled();
});
it("isAppInContainer delegates to WidgetLayoutStore when room exists", () => {
const room = { roomId: "!room:example.org" } as Room;
mocked(client.getRoom).mockReturnValue(room);
const isInContainerSpy = jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(true);
const app = mkWidget();
expect(api.isAppInContainer(app, "top", room.roomId)).toBe(true);
expect(isInContainerSpy).toHaveBeenCalledWith(room, app, "top");
});
it("moveAppToContainer does nothing when room is not found", () => {
const moveToContainerSpy = jest.spyOn(WidgetLayoutStore.instance, "moveToContainer");
const app = mkWidget();
mocked(client.getRoom).mockReturnValue(null);
api.moveAppToContainer(app, "right", "!missing:example.org");
expect(moveToContainerSpy).not.toHaveBeenCalled();
});
it("moveAppToContainer delegates to WidgetLayoutStore when room exists", () => {
const room = { roomId: "!room:example.org" } as Room;
mocked(client.getRoom).mockReturnValue(room);
const moveToContainerSpy = jest.spyOn(WidgetLayoutStore.instance, "moveToContainer").mockImplementation();
const app = mkWidget();
api.moveAppToContainer(app, "right", room.roomId);
expect(moveToContainerSpy).toHaveBeenCalledWith(room, app, "right");
});
});