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
parent 86692ce0a7
commit 09bbf796dc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 553 additions and 225 deletions

View File

@ -42,7 +42,7 @@ import { Action } from "./dispatcher/actions";
import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from "./widgets/ManagedHybrid";
import SdkConfig from "./SdkConfig";
import { ensureDMExists } from "./createRoom";
import { Container, WidgetLayoutStore } from "./stores/widgets/WidgetLayoutStore";
import { WidgetLayoutStore } from "./stores/widgets/WidgetLayoutStore";
import IncomingLegacyCallToast, { getIncomingLegacyCallToastKey } from "./toasts/IncomingLegacyCallToast";
import ToastStore from "./stores/ToastStore";
import { type ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload";
@ -1027,7 +1027,7 @@ export default class LegacyCallHandler extends TypedEventEmitter<LegacyCallHandl
// If there already is a Jitsi widget, pin it
const room = client.getRoom(roomId);
if (isNotNull(room)) {
WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top);
WidgetLayoutStore.instance.moveToContainer(room, widget, "top");
}
return;
}

View File

@ -90,7 +90,7 @@ import { CallView } from "../views/voip/CallView";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import Notifier from "../../Notifier";
import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast";
import { Container, WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore";
import { WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore";
import { getKeyBindingsManager } from "../../KeyBindingsManager";
import { objectHasDiff } from "../../utils/objects";
import SpaceRoomView from "./SpaceRoomView";
@ -141,6 +141,7 @@ import { isRoomEncrypted } from "../../hooks/useIsEncrypted";
import { type RoomViewStore } from "../../stores/RoomViewStore.tsx";
import { RoomStatusBarViewModel } from "../../viewmodels/room/RoomStatusBar.ts";
import { EncryptionEventViewModel } from "../../viewmodels/event-tiles/EncryptionEventViewModel.ts";
import { ModuleApi } from "../../modules/Api.ts";
const DEBUG = false;
const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000;
@ -953,7 +954,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
// Otherwise (in case the user set hideWidgetDrawer by clicking the button) follow the parameter.
const isManuallyShown = hideWidgetDrawer ? hideWidgetDrawer === "false" : true;
const widgets = this.context.widgetLayoutStore.getContainerWidgets(room, Container.Top);
const widgets = this.context.widgetLayoutStore.getContainerWidgets(room, "top");
return isManuallyShown && widgets.length > 0;
}
@ -2727,6 +2728,12 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
this.state.mainSplitContentType === MainSplitContentType.Call ? "video_room" : "maximised_widget";
}
const extraButtons: JSX.Element[] = [];
for (const cb of ModuleApi.instance.extras.roomHeaderButtonsCallbacks) {
const b = cb(this.state.room.roomId);
if (b) extraButtons.push(b);
}
return (
<ScopedRoomContextProvider {...this.state} roomViewStore={this.roomViewStore}>
<div
@ -2754,7 +2761,8 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
{!this.props.hideHeader && (
<RoomHeader
room={this.state.room}
additionalButtons={this.state.viewRoomOpts.buttons}
legacyAdditionalButtons={this.state.viewRoomOpts.buttons}
extraButtons={<>{extraButtons}</>}
/>
)}
{mainSplitBody}

View File

@ -25,7 +25,7 @@ import QuestionDialog from "../dialogs/QuestionDialog";
import ErrorDialog from "../dialogs/ErrorDialog";
import { WidgetType } from "../../../widgets/WidgetType";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import { WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import { getConfigLivestreamUrl, startJitsiAudioLivestream } from "../../../Livestream";
import { ModuleRunner } from "../../../modules/ModuleRunner";
import { ElementWidget, type WidgetMessaging } from "../../../stores/widgets/WidgetMessaging";
@ -79,7 +79,7 @@ const showSnapshotButton = (widgetMessaging: WidgetMessaging | undefined): boole
const showMoveButtons = (app: IWidget, room: Room | undefined, showUnpin: boolean | undefined): [boolean, boolean] => {
if (!showUnpin) return [false, false];
const pinnedWidgets = room ? WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top) : [];
const pinnedWidgets = room ? WidgetLayoutStore.instance.getContainerWidgets(room, "top") : [];
const widgetIndex = pinnedWidgets.findIndex((widget) => widget.id === app.id);
return [widgetIndex > 0, widgetIndex < pinnedWidgets.length - 1];
};
@ -221,7 +221,7 @@ export const WidgetContextMenu: React.FC<IProps> = ({
if (showMoveLeftButton) {
const onClick = (): void => {
if (!room) throw new Error("room must be defined");
WidgetLayoutStore.instance.moveWithinContainer(room, Container.Top, app, -1);
WidgetLayoutStore.instance.moveWithinContainer(room, "top", app, -1);
onFinished();
};
@ -232,7 +232,7 @@ export const WidgetContextMenu: React.FC<IProps> = ({
if (showMoveRightButton) {
const onClick = (): void => {
if (!room) throw new Error("room must be defined");
WidgetLayoutStore.instance.moveWithinContainer(room, Container.Top, app, 1);
WidgetLayoutStore.instance.moveWithinContainer(room, "top", app, 1);
onFinished();
};

View File

@ -47,7 +47,7 @@ import { ElementWidget, WidgetMessaging, WidgetMessagingEvent } from "../../../s
import WidgetAvatar from "../avatars/WidgetAvatar";
import LegacyCallHandler from "../../../LegacyCallHandler";
import { type IApp, isAppWidget } from "../../../stores/WidgetStore";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import { WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import { OwnProfileStore } from "../../../stores/OwnProfileStore";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import WidgetUtils from "../../../utils/WidgetUtils";
@ -682,21 +682,17 @@ export default class AppTile extends React.Component<IProps, IState> {
private onToggleMaximisedClick = (): void => {
if (!this.props.room) return; // ignore action - it shouldn't even be visible
const targetContainer = WidgetLayoutStore.instance.isInContainer(
this.props.room,
this.props.app,
Container.Center,
)
? Container.Top
: Container.Center;
const targetContainer = WidgetLayoutStore.instance.isInContainer(this.props.room, this.props.app, "center")
? "top"
: "center";
WidgetLayoutStore.instance.moveToContainer(this.props.room, this.props.app, targetContainer);
if (targetContainer === Container.Top) this.closeChatCardIfNeeded();
if (targetContainer === "top") this.closeChatCardIfNeeded();
};
private onMinimiseClicked = (): void => {
if (!this.props.room) return; // ignore action - it shouldn't even be visible
WidgetLayoutStore.instance.moveToContainer(this.props.room, this.props.app, Container.Right);
WidgetLayoutStore.instance.moveToContainer(this.props.room, this.props.app, "right");
this.closeChatCardIfNeeded();
};
@ -822,8 +818,7 @@ export default class AppTile extends React.Component<IProps, IState> {
const layoutButtons: ReactNode[] = [];
if (this.props.showLayoutButtons) {
const isMaximised =
this.props.room &&
WidgetLayoutStore.instance.isInContainer(this.props.room, this.props.app, Container.Center);
this.props.room && WidgetLayoutStore.instance.isInContainer(this.props.room, this.props.app, "center");
layoutButtons.push(
<AccessibleButton

View File

@ -14,7 +14,7 @@ import { EventTileBubble } from "@element-hq/web-shared-components";
import { _t } from "../../../languageHandler";
import WidgetStore from "../../../stores/WidgetStore";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import { WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
interface IProps {
mxEvent: MatrixEvent;
@ -32,7 +32,7 @@ export default class MJitsiWidgetEvent extends React.PureComponent<IProps> {
const widget = WidgetStore.instance.getRoom(room.roomId, true).widgets.find((w) => w.id === widgetId);
let joinCopy: string | null = _t("timeline|m.widget|jitsi_join_top_prompt");
if (widget && WidgetLayoutStore.instance.isInContainer(room, widget, Container.Right)) {
if (widget && WidgetLayoutStore.instance.isInContainer(room, widget, "right")) {
joinCopy = _t("timeline|m.widget|jitsi_join_right_prompt");
} else if (!widget) {
joinCopy = null;

View File

@ -24,7 +24,7 @@ import { useContextMenu } from "../../structures/ContextMenu";
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
import { type IApp } from "../../../stores/WidgetStore";
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
import { Container, MAX_PINNED, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import { MAX_PINNED, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import AccessibleButton from "../elements/AccessibleButton";
import WidgetAvatar from "../avatars/WidgetAvatar";
import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
@ -58,18 +58,18 @@ const AppRow: React.FC<IAppRowProps> = ({ app, room }) => {
});
};
const isPinned = WidgetLayoutStore.instance.isInContainer(room, app, Container.Top);
const isPinned = WidgetLayoutStore.instance.isInContainer(room, app, "top");
const togglePin = isPinned
? () => {
WidgetLayoutStore.instance.moveToContainer(room, app, Container.Right);
WidgetLayoutStore.instance.moveToContainer(room, app, "right");
}
: () => {
WidgetLayoutStore.instance.moveToContainer(room, app, Container.Top);
WidgetLayoutStore.instance.moveToContainer(room, app, "top");
};
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLDivElement>();
const cannotPin = !isPinned && !WidgetLayoutStore.instance.canAddToContainer(room, Container.Top);
const cannotPin = !isPinned && !WidgetLayoutStore.instance.canAddToContainer(room, "top");
let pinTitle: string;
if (cannotPin) {
@ -78,7 +78,7 @@ const AppRow: React.FC<IAppRowProps> = ({ app, room }) => {
pinTitle = isPinned ? _t("action|unpin") : _t("action|pin");
}
const isMaximised = WidgetLayoutStore.instance.isInContainer(room, app, Container.Center);
const isMaximised = WidgetLayoutStore.instance.isInContainer(room, app, "center");
let openTitle = "";
if (isPinned) {

View File

@ -15,7 +15,7 @@ import WidgetUtils, { useWidgets } from "../../../utils/WidgetUtils";
import AppTile from "../elements/AppTile";
import { _t } from "../../../languageHandler";
import { ContextMenuButton, useContextMenu } from "../../structures/ContextMenu";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import { WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
import Heading from "../typography/Heading";
import { WidgetContextMenu } from "../../../viewmodels/right-panel/WidgetContextMenuViewModel";
@ -31,7 +31,7 @@ const WidgetCard: React.FC<IProps> = ({ room, widgetId, onClose }) => {
const apps = useWidgets(room);
const app = apps.find((a) => a.id === widgetId);
const isRight = app && WidgetLayoutStore.instance.isInContainer(room, app, Container.Right);
const isRight = app && WidgetLayoutStore.instance.isInContainer(room, app, "right");
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();

View File

@ -22,7 +22,7 @@ import type ResizeNotifier from "../../../utils/ResizeNotifier";
import ResizeHandle from "../elements/ResizeHandle";
import Resizer, { type IConfig } from "../../../resizer/resizer";
import PercentageDistributor from "../../../resizer/distributors/percentage";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import { WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import UIStore from "../../../stores/UIStore";
import { type ActionPayload } from "../../../dispatcher/payloads";
import Spinner from "../elements/Spinner";
@ -39,9 +39,9 @@ interface IProps {
interface IState {
apps: {
[Container.Top]: IWidget[];
[Container.Center]: IWidget[];
[Container.Right]?: IWidget[];
["top"]: IWidget[];
["center"]: IWidget[];
["right"]?: IWidget[];
};
resizingVertical: boolean; // true when changing the height of the apps drawer
resizingHorizontal: boolean; // true when changing the distribution of the width between widgets
@ -119,7 +119,7 @@ export default class AppsDrawer extends React.Component<IProps, IState> {
this.resizeContainer?.classList.remove("mx_AppsDrawer--resizing");
WidgetLayoutStore.instance.setResizerDistributions(
this.props.room,
Container.Top,
"top",
this.topApps()
.slice(1)
.map((_, i) => this.resizer.forHandleAt(i)!.size),
@ -152,7 +152,7 @@ export default class AppsDrawer extends React.Component<IProps, IState> {
if (prevProps.userId !== this.props.userId || prevProps.room !== this.props.room) {
// Room has changed, update apps
this.updateApps();
} else if (this.getAppsHash(this.topApps()) !== this.getAppsHash(prevState.apps[Container.Top])) {
} else if (this.getAppsHash(this.topApps()) !== this.getAppsHash(prevState.apps["top"])) {
this.loadResizerPreferences();
}
}
@ -166,7 +166,7 @@ export default class AppsDrawer extends React.Component<IProps, IState> {
};
private loadResizerPreferences = (): void => {
const distributions = WidgetLayoutStore.instance.getResizerDistributions(this.props.room, Container.Top);
const distributions = WidgetLayoutStore.instance.getResizerDistributions(this.props.room, "top");
if (this.state.apps && this.topApps().length - 1 === distributions.length) {
distributions.forEach((size, i) => {
const distributor = this.resizer.forHandleAt(i);
@ -206,11 +206,11 @@ export default class AppsDrawer extends React.Component<IProps, IState> {
};
private getApps = (): IState["apps"] => ({
[Container.Top]: WidgetLayoutStore.instance.getContainerWidgets(this.props.room, Container.Top),
[Container.Center]: WidgetLayoutStore.instance.getContainerWidgets(this.props.room, Container.Center),
["top"]: WidgetLayoutStore.instance.getContainerWidgets(this.props.room, "top"),
["center"]: WidgetLayoutStore.instance.getContainerWidgets(this.props.room, "center"),
});
private topApps = (): IWidget[] => this.state.apps[Container.Top];
private centerApps = (): IWidget[] => this.state.apps[Container.Center];
private topApps = (): IWidget[] => this.state.apps["top"];
private centerApps = (): IWidget[] => this.state.apps["center"];
private updateApps = (): void => {
if (this.unmounted) return;
@ -321,7 +321,7 @@ const PersistentVResizer: React.FC<IPersistentResizerProps> = ({
resizeNotifier,
children,
}) => {
let defaultHeight = WidgetLayoutStore.instance.getContainerHeight(room, Container.Top);
let defaultHeight = WidgetLayoutStore.instance.getContainerHeight(room, "top");
// Arbitrary defaults to avoid NaN problems. 100 px or 3/4 of the visible window.
if (!minHeight) minHeight = 100;
@ -352,7 +352,7 @@ const PersistentVResizer: React.FC<IPersistentResizerProps> = ({
let newHeight = defaultHeight! + d.height;
newHeight = percentageOf(newHeight, minHeight, maxHeight) * 100;
WidgetLayoutStore.instance.setContainerHeight(room, Container.Top, newHeight);
WidgetLayoutStore.instance.setContainerHeight(room, "top", newHeight);
resizeNotifier.stopResizing();
}}

View File

@ -60,10 +60,12 @@ import { useIsEncrypted } from "../../../../hooks/useIsEncrypted.ts";
function RoomHeaderButtons({
room,
additionalButtons,
legacyAdditionalButtons,
extraButtons,
}: {
room: Room;
additionalButtons?: ViewRoomOpts["buttons"];
legacyAdditionalButtons?: ViewRoomOpts["buttons"];
extraButtons?: JSX.Element;
}): JSX.Element {
const members = useRoomMembers(room, 2500);
const memberCount = useRoomMemberCount(room, { throttleWait: 2500, includeInvited: true });
@ -297,7 +299,9 @@ function RoomHeaderButtons({
roomContext.mainSplitContentType === MainSplitContentType.Call;
return (
<>
{additionalButtons?.map((props) => {
{extraButtons}
{legacyAdditionalButtons?.map((props) => {
const label = props.label();
return (
@ -427,11 +431,15 @@ function historyVisibilityIcon(historyVisibility: HistoryVisibility): JSX.Elemen
export default function RoomHeader({
room,
additionalButtons,
extraButtons,
legacyAdditionalButtons,
oobData,
}: {
room: Room | LocalRoom;
additionalButtons?: ViewRoomOpts["buttons"];
// Extra buttons added by a new element web module API module
extraButtons?: JSX.Element;
// DEPRECATED: Buttons added by a legacy react-sdk module API module.
legacyAdditionalButtons?: ViewRoomOpts["buttons"];
oobData?: IOOBData;
}): JSX.Element {
const client = useMatrixClientContext();
@ -530,7 +538,11 @@ export default function RoomHeader({
</button>
{/* If the room is local-only then we don't want to show any additional buttons, as it won't work */}
{room instanceof LocalRoom === false && (
<RoomHeaderButtons room={room} additionalButtons={additionalButtons} />
<RoomHeaderButtons
room={room}
legacyAdditionalButtons={legacyAdditionalButtons}
extraButtons={extraButtons}
/>
)}
</Flex>
{askToJoinEnabled && <RoomKnocksBar room={room} />}

View File

@ -22,7 +22,7 @@ import { useCall, useConnectionState, useParticipantCount } from "../useCall";
import { useRoomMemberCount } from "../useRoomMembers";
import { ConnectionState } from "../../models/Call";
import { placeCall } from "../../utils/room/placeCall";
import { Container, WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore";
import { WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore";
import { useRoomState } from "../useRoomState";
import { _t } from "../../languageHandler";
import { isManagedHybridWidget, isManagedHybridWidgetEnabled } from "../../widgets/ManagedHybrid";
@ -214,8 +214,8 @@ export const useRoomCall = (
widget = groupCall?.widget ?? jitsiWidget;
}
const updateWidgetState = useCallback((): void => {
setCanPinWidget(WidgetLayoutStore.instance.canAddToContainer(room, Container.Top));
setWidgetPinned(!!widget && WidgetLayoutStore.instance.isInContainer(room, widget, Container.Top));
setCanPinWidget(WidgetLayoutStore.instance.canAddToContainer(room, "top"));
setWidgetPinned(!!widget && WidgetLayoutStore.instance.isInContainer(room, widget, "top"));
}, [room, widget]);
useEventEmitter(WidgetLayoutStore.instance, WidgetLayoutStore.emissionForRoom(room), updateWidgetState);
useEffect(() => {
@ -266,7 +266,7 @@ export const useRoomCall = (
(evt: React.MouseEvent | undefined, callPlatformType: PlatformCallType): void => {
evt?.stopPropagation();
if (widget && promptPinWidget) {
WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top);
WidgetLayoutStore.instance.moveToContainer(room, widget, "top");
} else {
placeCall(room, CallType.Voice, callPlatformType, evt?.shiftKey || undefined, true);
}
@ -277,7 +277,7 @@ export const useRoomCall = (
(evt: React.MouseEvent | undefined, callPlatformType: PlatformCallType): void => {
evt?.stopPropagation();
if (widget && promptPinWidget) {
WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top);
WidgetLayoutStore.instance.moveToContainer(room, widget, "top");
} else {
// If we have pressed shift then always skip the lobby, otherwise `undefined` will defer
// to the defaults of the call implementation.

View File

@ -31,6 +31,7 @@ import { ElementWebBuiltinsApi } from "./BuiltinsApi.tsx";
import { ClientApi } from "./ClientApi.ts";
import { StoresApi } from "./StoresApi.ts";
import { WidgetLifecycleApi } from "./WidgetLifecycleApi.ts";
import { WidgetApi } from "./WidgetApi.ts";
import { CustomisationsApi } from "./customisationsApi.ts";
const legacyCustomisationsFactory = <T extends object>(baseCustomisations: T) => {
@ -89,6 +90,7 @@ export class ModuleApi implements Api {
public readonly extras = new ElementWebExtrasApi();
public readonly builtins = new ElementWebBuiltinsApi();
public readonly widgetLifecycle = new WidgetLifecycleApi();
public readonly widget = new WidgetApi();
public readonly rootNode = document.getElementById("matrixchat")!;
public readonly client = new ClientApi();
public readonly stores = new StoresApi();

View File

@ -1,4 +1,5 @@
/*
Copyright 2026 Element Creations Ltd.
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@ -6,7 +7,11 @@ Please see LICENSE files in the repository root for full details.
*/
import { useState } from "react";
import { type SpacePanelItemProps, type ExtrasApi } from "@element-hq/element-web-module-api";
import {
type SpacePanelItemProps,
type ExtrasApi,
type RoomHeaderButtonsCallback,
} from "@element-hq/element-web-module-api";
import { TypedEventEmitter } from "matrix-js-sdk/src/matrix";
import { useTypedEventEmitter } from "../hooks/useEventEmitter";
@ -26,6 +31,7 @@ interface EmittedEvents {
export class ElementWebExtrasApi extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents> implements ExtrasApi {
public spacePanelItems = new Map<string, SpacePanelItemProps>();
public visibleRoomBySpaceKey = new Map<string, () => string[]>();
public roomHeaderButtonsCallbacks: RoomHeaderButtonsCallback[] = [];
public setSpacePanelItem(spacekey: string, item: SpacePanelItemProps): void {
this.spacePanelItems.set(spacekey, item);
@ -35,6 +41,10 @@ export class ElementWebExtrasApi extends TypedEventEmitter<keyof EmittedEvents,
public getVisibleRoomBySpaceKey(spaceKey: string, cb: () => string[]): void {
this.visibleRoomBySpaceKey.set(spaceKey, cb);
}
public addRoomHeaderButtonCallback(cb: RoomHeaderButtonsCallback): void {
this.roomHeaderButtonsCallbacks.push(cb);
}
}
export function useModuleSpacePanelItems(api: ElementWebExtrasApi): ModuleSpacePanelItem[] {

View File

@ -0,0 +1,47 @@
/*
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 { type Container, type WidgetApi as WidgetApiInterface } from "@element-hq/element-web-module-api";
import { getHttpUriForMxc } from "matrix-js-sdk/src/matrix";
import type { IWidget } from "matrix-widget-api";
import WidgetStore, { isAppWidget } from "../stores/WidgetStore";
import { MatrixClientPeg } from "../MatrixClientPeg";
import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore";
/**
* Host-side implementation of the widget API.
* Allows modules to interact with widgets, including listing widgets in rooms.
*/
export class WidgetApi implements WidgetApiInterface {
public getWidgetsInRoom(roomId: string): IWidget[] {
return WidgetStore.instance.getApps(roomId);
}
public getAppAvatarUrl(app: IWidget, width?: number, height?: number, resizeMethod?: string): string | null {
if (!isAppWidget(app) || !app.avatar_url) return null;
return getHttpUriForMxc(
MatrixClientPeg.safeGet().getHomeserverUrl(),
app.avatar_url,
width,
height,
resizeMethod,
);
}
public isAppInContainer(app: IWidget, container: Container, roomId: string): boolean {
const room = MatrixClientPeg.safeGet().getRoom(roomId);
if (!room) return false;
return WidgetLayoutStore.instance.isInContainer(room, app, container);
}
public moveAppToContainer(app: IWidget, container: Container, roomId: string): void {
const room = MatrixClientPeg.safeGet().getRoom(roomId);
if (!room) return;
WidgetLayoutStore.instance.moveToContainer(room, app, container);
}
}

View File

@ -10,6 +10,7 @@ import { type Room, RoomStateEvent, type MatrixEvent } from "matrix-js-sdk/src/m
import { MapWithDefault, recursiveMapToObject } from "matrix-js-sdk/src/utils";
import { type IWidget } from "matrix-widget-api";
import { clamp, defaultNumber, sum } from "@element-hq/web-shared-components";
import { type Container } from "@element-hq/element-web-module-api";
import SettingsStore from "../../settings/SettingsStore";
import WidgetStore, { type IApp } from "../WidgetStore";
@ -19,16 +20,10 @@ import { ReadyWatchingStore } from "../ReadyWatchingStore";
import { SettingLevel } from "../../settings/SettingLevel";
import { arrayFastClone } from "../../utils/arrays";
import { UPDATE_EVENT } from "../AsyncStore";
import {
Container,
type IStoredLayout,
type ILayoutStateEvent,
WIDGET_LAYOUT_EVENT_TYPE,
type IWidgetLayouts,
} from "./types";
import { type IStoredLayout, type ILayoutStateEvent, WIDGET_LAYOUT_EVENT_TYPE, type IWidgetLayouts } from "./types";
export type { IStoredLayout, ILayoutStateEvent };
export { Container, WIDGET_LAYOUT_EVENT_TYPE };
export { type Container, WIDGET_LAYOUT_EVENT_TYPE };
export type ILayoutSettings = Partial<ILayoutStateEvent> & {
overrides?: string; // event ID for layout state event, if present
@ -173,8 +168,8 @@ export class WidgetLayoutStore extends ReadyWatchingStore {
const stateContainer = roomLayout?.widgets?.[widget.id]?.container;
const manualContainer = userLayout?.widgets?.[widget.id]?.container;
const isLegacyPinned = !!legacyPinned?.[widget.id];
const defaultContainer = WidgetType.JITSI.matches(widget.type) ? Container.Top : Container.Right;
if (manualContainer ? manualContainer === Container.Center : stateContainer === Container.Center) {
const defaultContainer = WidgetType.JITSI.matches(widget.type) ? "top" : "right";
if (manualContainer ? manualContainer === "center" : stateContainer === "center") {
if (centerWidgets.length) {
console.error("Tried to push a second widget into the center container");
} else {
@ -188,9 +183,9 @@ export class WidgetLayoutStore extends ReadyWatchingStore {
targetContainer = manualContainer ?? stateContainer!;
} else if (isLegacyPinned && !stateContainer) {
// Special legacy case
targetContainer = Container.Top;
targetContainer = "top";
}
(targetContainer === Container.Top ? topWidgets : rightWidgets).push(widget);
(targetContainer === "top" ? topWidgets : rightWidgets).push(widget);
}
// Trim to MAX_PINNED
@ -291,19 +286,19 @@ export class WidgetLayoutStore extends ReadyWatchingStore {
const newRoomContainers = new Map();
this.byRoom.set(room.roomId, newRoomContainers);
if (topWidgets.length) {
newRoomContainers.set(Container.Top, {
newRoomContainers.set("top", {
ordered: topWidgets,
distributions: widths,
height: maxHeight,
});
}
if (rightWidgets.length) {
newRoomContainers.set(Container.Right, {
newRoomContainers.set("right", {
ordered: rightWidgets,
});
}
if (centerWidgets.length) {
newRoomContainers.set(Container.Center, {
newRoomContainers.set("center", {
ordered: centerWidgets,
});
}
@ -325,11 +320,11 @@ export class WidgetLayoutStore extends ReadyWatchingStore {
public canAddToContainer(room: Room, container: Container): boolean {
switch (container) {
case Container.Top:
case "top":
return this.getContainerWidgets(room, container).length < MAX_PINNED;
case Container.Right:
case "right":
return this.getContainerWidgets(room, container).length < MAX_PINNED;
case Container.Center:
case "center":
return this.getContainerWidgets(room, container).length < 1;
}
}
@ -349,7 +344,7 @@ export class WidgetLayoutStore extends ReadyWatchingStore {
}
public setResizerDistributions(room: Room, container: Container, distributions: string[]): void {
if (container !== Container.Top) return; // ignore - not relevant
if (container !== "top") return; // ignore - not relevant
const numbers = distributions.map((d) => Number(Number(d.substring(0, d.length - 1)).toFixed(1)));
const widgets = this.getContainerWidgets(room, container);
@ -419,23 +414,23 @@ export class WidgetLayoutStore extends ReadyWatchingStore {
// Prepare other containers (potentially move widgets to obey the following rules)
const newLayout: Record<string, IStoredLayout> = {};
switch (toContainer) {
case Container.Right:
case "right":
// new "right" widget
break;
case Container.Center:
case "center":
// new "center" widget => all other widgets go into "right"
for (const w of this.getContainerWidgets(room, Container.Top)) {
newLayout[w.id] = { container: Container.Right };
for (const w of this.getContainerWidgets(room, "top")) {
newLayout[w.id] = { container: "right" };
}
for (const w of this.getContainerWidgets(room, Container.Center)) {
newLayout[w.id] = { container: Container.Right };
for (const w of this.getContainerWidgets(room, "center")) {
newLayout[w.id] = { container: "right" };
}
break;
case Container.Top:
case "top":
// new "top" widget => the center widget moves into "right"
if (this.hasMaximisedWidget(room)) {
const centerWidget = this.getContainerWidgets(room, Container.Center)[0];
newLayout[centerWidget.id] = { container: Container.Right };
const centerWidget = this.getContainerWidgets(room, "center")[0];
newLayout[centerWidget.id] = { container: "right" };
}
break;
}
@ -447,11 +442,11 @@ export class WidgetLayoutStore extends ReadyWatchingStore {
}
public hasMaximisedWidget(room: Room): boolean {
return this.getContainerWidgets(room, Container.Center).length > 0;
return this.getContainerWidgets(room, "center").length > 0;
}
public hasPinnedWidgets(room: Room): boolean {
return this.getContainerWidgets(room, Container.Top).length > 0;
return this.getContainerWidgets(room, "top").length > 0;
}
public canCopyLayoutToRoom(room: Room): boolean {
@ -464,7 +459,7 @@ export class WidgetLayoutStore extends ReadyWatchingStore {
const evContent: ILayoutStateEvent = { widgets: {} };
for (const [widget, container] of allWidgets) {
evContent.widgets[widget.id] = { container };
if (container === Container.Top) {
if (container === "top") {
const containerWidgets = this.getContainerWidgets(room, container);
const idx = containerWidgets.findIndex((w) => w.id === widget.id);
const widths = this.byRoom.get(room.roomId)?.get(container)?.distributions;

View File

@ -6,6 +6,8 @@ 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 { type Container } from "@element-hq/element-web-module-api";
export interface IStoredLayout {
// Where to store the widget. Required.
container: Container;
@ -42,15 +44,3 @@ export interface ILayoutStateEvent {
}
export const WIDGET_LAYOUT_EVENT_TYPE = "io.element.widgets.layout";
export enum Container {
// "Top" is the app drawer, and currently the only sensible value.
Top = "top",
// "Right" is the right panel, and the default for widgets. Setting
// this as a container on a widget is essentially like saying "no
// changes needed", though this may change in the future.
Right = "right",
Center = "center",
}

View File

@ -25,7 +25,6 @@ import { _t } from "../../languageHandler";
import { getConfigLivestreamUrl, startJitsiAudioLivestream } from "../../Livestream";
import Modal from "../../Modal";
import SettingsStore from "../../settings/SettingsStore";
import { Container } from "../../stores/widgets/types";
import { WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore";
import { WidgetMessagingStore } from "../../stores/widgets/WidgetMessagingStore";
import { isAppWidget } from "../../stores/WidgetStore";
@ -109,7 +108,7 @@ export class WidgetContextMenuViewModel
let showMoveButtons: [boolean, boolean] = [false, false];
if (showUnpin) {
const pinnedWidgets = room ? WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top) : [];
const pinnedWidgets = room ? WidgetLayoutStore.instance.getContainerWidgets(room, "top") : [];
const widgetIndex = pinnedWidgets.findIndex((widget) => widget.id === app.id);
showMoveButtons = [widgetIndex > 0, widgetIndex < pinnedWidgets.length - 1];
}
@ -225,7 +224,7 @@ export class WidgetContextMenuViewModel
public get onMoveButton(): (direction: number) => void {
return (direction: number) => {
if (!this._room) throw new Error("room must be defined");
WidgetLayoutStore.instance.moveWithinContainer(this._room, Container.Top, this._app, direction);
WidgetLayoutStore.instance.moveWithinContainer(this._room, "top", this._app, direction);
this.props.onFinished!();
};
}

View File

@ -20,7 +20,7 @@ import { CallStore, CallStoreEvent } from "../../stores/CallStore";
import { type Call } from "../../models/Call";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
import { Container, WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore";
import { WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore";
import PersistentApp from "../../components/views/elements/PersistentApp";
export interface Props {
@ -91,7 +91,7 @@ export class WidgetPipViewModel
metricsTrigger: "WebFloatingCallWindow",
});
} else if (this.viewingRoom) {
WidgetLayoutStore.instance.moveToContainer(this.props.room, this.widget, Container.Center);
WidgetLayoutStore.instance.moveToContainer(this.props.room, this.widget, "center");
} else {
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,

View File

@ -680,11 +680,13 @@ export function mkStubRoom(
maySendStateEvent: jest.fn().mockReturnValue(true),
maySendRedactionForEvent: jest.fn().mockReturnValue(true),
maySendEvent: jest.fn().mockReturnValue(true),
maySendMessage: jest.fn().mockReturnValue(true),
members: {},
getHistoryVisibility: jest.fn().mockReturnValue(HistoryVisibility.Shared),
getJoinRule: jest.fn().mockReturnValue(JoinRule.Invite),
on: jest.fn(),
off: jest.fn(),
removeListener: jest.fn(),
} as unknown as RoomState,
eventShouldLiveIn: jest.fn().mockReturnValue({ shouldLiveInRoom: true, shouldLiveInThread: false }),
fetchRoomThreads: jest.fn().mockReturnValue(Promise.resolve()),
@ -713,6 +715,7 @@ export function mkStubRoom(
isKicked: () => false,
}),
getMembers: jest.fn().mockReturnValue([]),
getEncryptionTargetMembers: jest.fn().mockReturnValue([]),
getMembersWithMembership: jest.fn().mockReturnValue([]),
getMxcAvatarUrl: () => "mxc://avatar.url/room.png",
getMyMembership: jest.fn().mockReturnValue(KnownMembership.Join),

View File

@ -43,7 +43,7 @@ import { Action } from "../../../../src/dispatcher/actions";
import { type ViewRoomPayload } from "../../../../src/dispatcher/payloads/ViewRoomPayload";
import { TestSdkContext } from "../../TestSdkContext";
import { RoomViewStore } from "../../../../src/stores/RoomViewStore";
import { Container, WidgetLayoutStore } from "../../../../src/stores/widgets/WidgetLayoutStore";
import { WidgetLayoutStore } from "../../../../src/stores/widgets/WidgetLayoutStore";
import WidgetStore from "../../../../src/stores/WidgetStore";
import { WidgetType } from "../../../../src/widgets/WidgetType";
import { SdkContextClass } from "../../../../src/contexts/SDKContext";
@ -234,7 +234,7 @@ describe("PipContainer", () => {
// The return button should maximize the widget
const moveSpy = jest.spyOn(WidgetLayoutStore.instance, "moveToContainer");
await user.click(await screen.findByRole("button", { name: "Back" }));
expect(moveSpy).toHaveBeenCalledWith(room, widget, Container.Center);
expect(moveSpy).toHaveBeenCalledWith(room, widget, "center");
expect(screen.queryByRole("button", { name: "Leave" })).toBeNull();
});

View File

@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details.
import React, { type JSX, type ComponentProps } from "react";
import { screen, render } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { type Room, type MatrixClient } from "matrix-js-sdk/src/matrix";
import { MatrixWidgetType } from "matrix-widget-api";
import {
type ApprovalOpts,
@ -24,6 +24,10 @@ import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext
import WidgetUtils from "../../../../../src/utils/WidgetUtils";
import { ModuleRunner } from "../../../../../src/modules/ModuleRunner";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import { WidgetLayoutStore } from "../../../../../src/stores/widgets/WidgetLayoutStore";
import { mkStubRoom } from "../../../../test-utils/test-utils.ts";
import { type RoomContextType } from "../../../../../src/contexts/RoomContext.ts";
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
describe("<WidgetContextMenu />", () => {
const widgetId = "w1";
@ -44,8 +48,12 @@ describe("<WidgetContextMenu />", () => {
let mockClient: MatrixClient;
let room: Room;
let onFinished: () => void;
let roomContext: RoomContextType;
beforeEach(() => {
onFinished = jest.fn();
jest.spyOn(WidgetUtils, "canUserModifyWidgets").mockReturnValue(true);
@ -53,6 +61,13 @@ describe("<WidgetContextMenu />", () => {
mockClient = {
getUserId: jest.fn().mockReturnValue(userId),
} as unknown as MatrixClient;
room = mkStubRoom(roomId, "Test Room", mockClient);
roomContext = {
room,
roomId,
} as unknown as RoomContextType;
});
afterEach(() => {
@ -62,7 +77,9 @@ describe("<WidgetContextMenu />", () => {
function getComponent(props: Partial<ComponentProps<typeof WidgetContextMenu>> = {}): JSX.Element {
return (
<MatrixClientContext.Provider value={mockClient}>
<WidgetContextMenu app={app} onFinished={onFinished} {...props} />
<ScopedRoomContextProvider {...roomContext}>
<WidgetContextMenu app={app} onFinished={onFinished} {...props} />
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>
);
}
@ -89,4 +106,69 @@ describe("<WidgetContextMenu />", () => {
expect(onFinished).toHaveBeenCalled();
expect(SettingsStore.getValue("allowedWidgets", roomId)[eventId]).toBe(false);
});
it("shows the move left button when the widget can be moved left", () => {
// Place our widget second so it can move left but not right.
jest.spyOn(WidgetLayoutStore.instance, "getContainerWidgets").mockReturnValue([
{ id: "someOtherWidget", type: "m.custom", creatorUserId: userId, url: "" },
{ id: widgetId, type: "m.custom", creatorUserId: userId, url: "" },
]);
render(getComponent({ showUnpin: true }));
expect(screen.getByLabelText("Move left")).toBeInTheDocument();
expect(screen.queryByLabelText("Move right")).not.toBeInTheDocument();
});
it("shows the move right button when the widget can be moved right", () => {
// Place our widget first so it can move right but not left.
jest.spyOn(WidgetLayoutStore.instance, "getContainerWidgets").mockReturnValue([
{ id: widgetId, type: "m.custom", creatorUserId: userId, url: "" },
{ id: "someOtherWidget", type: "m.custom", creatorUserId: userId, url: "" },
]);
render(getComponent({ showUnpin: true }));
expect(screen.getByLabelText("Move right")).toBeInTheDocument();
expect(screen.queryByLabelText("Move left")).not.toBeInTheDocument();
});
it("moves widget left when move left button is clicked", async () => {
// Place our widget second so move left is visible.
jest.spyOn(WidgetLayoutStore.instance, "getContainerWidgets").mockReturnValue([
{ id: "someOtherWidget", type: "m.custom", creatorUserId: userId, url: "" },
{ id: widgetId, type: "m.custom", creatorUserId: userId, url: "" },
]);
// Mock moveWithinContainer to verify it's called with the correct arguments.
const moveWithinContainerSpy = jest
.spyOn(WidgetLayoutStore.instance, "moveWithinContainer")
.mockImplementation();
render(getComponent({ showUnpin: true }));
await userEvent.click(screen.getByLabelText("Move left"));
expect(moveWithinContainerSpy).toHaveBeenCalledWith(room, "top", app, -1);
expect(onFinished).toHaveBeenCalled();
});
it("moves widget right when move right button is clicked", async () => {
// Place our widget first so move right is visible.
jest.spyOn(WidgetLayoutStore.instance, "getContainerWidgets").mockReturnValue([
{ id: widgetId, type: "m.custom", creatorUserId: userId, url: "" },
{ id: "someOtherWidget", type: "m.custom", creatorUserId: userId, url: "" },
]);
// Mock moveWithinContainer to verify it's called with the correct arguments.
const moveWithinContainerSpy = jest
.spyOn(WidgetLayoutStore.instance, "moveWithinContainer")
.mockImplementation();
render(getComponent({ showUnpin: true }));
await userEvent.click(screen.getByLabelText("Move right"));
expect(moveWithinContainerSpy).toHaveBeenCalledWith(room, "top", app, 1);
expect(onFinished).toHaveBeenCalled();
});
});

View File

@ -31,7 +31,7 @@ import RightPanelStore from "../../../../../src/stores/right-panel/RightPanelSto
import WidgetStore, { type IApp } from "../../../../../src/stores/WidgetStore";
import ActiveWidgetStore from "../../../../../src/stores/ActiveWidgetStore";
import AppTile from "../../../../../src/components/views/elements/AppTile";
import { Container, WidgetLayoutStore } from "../../../../../src/stores/widgets/WidgetLayoutStore";
import { type Container, WidgetLayoutStore } from "../../../../../src/stores/widgets/WidgetLayoutStore";
import AppsDrawer from "../../../../../src/components/views/rooms/AppsDrawer";
import { ElementWidgetCapabilities } from "../../../../../src/stores/widgets/ElementWidgetCapabilities";
import { ElementWidget, type WidgetMessaging } from "../../../../../src/stores/widgets/WidgetMessaging";
@ -303,7 +303,7 @@ describe("AppTile", () => {
return {
widgets: {
1: {
container: Container.Top,
container: "top",
},
},
};
@ -334,7 +334,7 @@ describe("AppTile", () => {
mockSettings.mockRestore();
act(() => {
// Move widget to center
WidgetLayoutStore.instance.moveToContainer(r1, app1, Container.Center);
WidgetLayoutStore.instance.moveToContainer(r1, app1, "center");
});
expect(renderResult.getByText("Example 1")).toBeInTheDocument();
@ -377,7 +377,7 @@ describe("AppTile", () => {
);
await waitForElementToBeRemoved(() => renderResult.queryByRole("progressbar"));
await userEvent.click(renderResult.getByLabelText("Minimise"));
expect(moveToContainerSpy).toHaveBeenCalledWith(r1, app1, Container.Right);
expect(moveToContainerSpy).toHaveBeenCalledWith(r1, app1, "right");
});
it("clicking 'maximise' should send the widget to the center", async () => {
@ -388,7 +388,7 @@ describe("AppTile", () => {
);
await waitForElementToBeRemoved(() => renderResult.queryByRole("progressbar"));
await userEvent.click(renderResult.getByLabelText("Maximise"));
expect(moveToContainerSpy).toHaveBeenCalledWith(r1, app1, Container.Center);
expect(moveToContainerSpy).toHaveBeenCalledWith(r1, app1, "center");
});
it("should render permission request", async () => {
@ -455,7 +455,7 @@ describe("AppTile", () => {
beforeEach(() => {
jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockImplementation(
(room: Room | null, widget: IWidget, container: Container) => {
return room === r1 && widget === app1 && container === Container.Center;
return room === r1 && widget === app1 && container === "center";
},
);
});
@ -472,7 +472,7 @@ describe("AppTile", () => {
);
await waitForElementToBeRemoved(() => renderResult.queryByRole("progressbar"));
await userEvent.click(renderResult.getByLabelText("Un-maximise"));
expect(moveToContainerSpy).toHaveBeenCalledWith(r1, app1, Container.Top);
expect(moveToContainerSpy).toHaveBeenCalledWith(r1, app1, "top");
});
});

View File

@ -53,7 +53,7 @@ import dispatcher from "../../../../../../src/dispatcher/dispatcher";
import { CallStore } from "../../../../../../src/stores/CallStore";
import { type Call } from "../../../../../../src/models/Call";
import * as ShieldUtils from "../../../../../../src/utils/ShieldUtils";
import { Container, WidgetLayoutStore } from "../../../../../../src/stores/widgets/WidgetLayoutStore";
import { WidgetLayoutStore } from "../../../../../../src/stores/widgets/WidgetLayoutStore";
import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext";
import { _t } from "../../../../../../src/languageHandler";
import WidgetStore, { type IApp } from "../../../../../../src/stores/WidgetStore";
@ -504,7 +504,7 @@ describe("RoomHeader", () => {
const videoButton = screen.getByRole("button", { name: "Video call" });
expect(videoButton).not.toHaveAttribute("aria-disabled", "true");
await user.click(videoButton);
expect(spy).toHaveBeenCalledWith(room, widget, Container.Top);
expect(spy).toHaveBeenCalledWith(room, widget, "top");
});
it("disables calling if there's a jitsi call", () => {
@ -869,7 +869,7 @@ describe("RoomHeader", () => {
});
});
it("renders additionalButtons", async () => {
it("renders legacy additionalButtons", async () => {
const additionalButtons: ViewRoomOpts["buttons"] = [
{
icon: () => <>test-icon</>,
@ -878,11 +878,11 @@ describe("RoomHeader", () => {
onClick: () => {},
},
];
render(<RoomHeader room={room} additionalButtons={additionalButtons} />, getWrapper());
render(<RoomHeader room={room} legacyAdditionalButtons={additionalButtons} />, getWrapper());
expect(screen.getByRole("button", { name: "test-label" })).toBeInTheDocument();
});
it("calls onClick-callback on additionalButtons", () => {
it("calls onClick-callback on legacyAdditionalButtons", () => {
const callback = jest.fn();
const additionalButtons: ViewRoomOpts["buttons"] = [
{
@ -893,7 +893,7 @@ describe("RoomHeader", () => {
},
];
render(<RoomHeader room={room} additionalButtons={additionalButtons} />, getWrapper());
render(<RoomHeader room={room} legacyAdditionalButtons={additionalButtons} />, getWrapper());
const button = screen.getByRole("button", { name: "test-label" });
const event = createEvent.click(button);

View File

@ -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();
});
});

View File

@ -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");
});
});

View File

@ -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");
});
});

View File

@ -10,7 +10,7 @@ import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";
import WidgetStore, { type IApp } from "../../../src/stores/WidgetStore";
import { Container, WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore";
import { WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore";
import { stubClient } from "../../test-utils";
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
import SettingsStore from "../../../src/settings/SettingsStore";
@ -74,14 +74,14 @@ describe("WidgetLayoutStore", () => {
it("all widgets should be in the right container by default", () => {
store.recalculateRoom(mockRoom);
expect(store.getContainerWidgets(mockRoom, Container.Right).length).toStrictEqual(mockApps.length);
expect(store.getContainerWidgets(mockRoom, "right").length).toStrictEqual(mockApps.length);
});
it("add widget to top container", async () => {
store.recalculateRoom(mockRoom);
store.moveToContainer(mockRoom, mockApps[0], Container.Top);
expect(store.getContainerWidgets(mockRoom, Container.Top)).toStrictEqual([mockApps[0]]);
expect(store.getContainerHeight(mockRoom, Container.Top)).toBeNull();
store.moveToContainer(mockRoom, mockApps[0], "top");
expect(store.getContainerWidgets(mockRoom, "top")).toStrictEqual([mockApps[0]]);
expect(store.getContainerHeight(mockRoom, "top")).toBeNull();
});
it("ordering of top container widgets should be consistent even if no index specified", async () => {
@ -97,71 +97,71 @@ describe("WidgetLayoutStore", () => {
};
store.recalculateRoom(mockRoom);
expect(store.getContainerWidgets(mockRoom, Container.Top)).toStrictEqual([mockApps[0], mockApps[1]]);
expect(store.getContainerWidgets(mockRoom, "top")).toStrictEqual([mockApps[0], mockApps[1]]);
});
it("add three widgets to top container", async () => {
store.recalculateRoom(mockRoom);
store.moveToContainer(mockRoom, mockApps[0], Container.Top);
store.moveToContainer(mockRoom, mockApps[1], Container.Top);
store.moveToContainer(mockRoom, mockApps[2], Container.Top);
expect(new Set(store.getContainerWidgets(mockRoom, Container.Top))).toEqual(
store.moveToContainer(mockRoom, mockApps[0], "top");
store.moveToContainer(mockRoom, mockApps[1], "top");
store.moveToContainer(mockRoom, mockApps[2], "top");
expect(new Set(store.getContainerWidgets(mockRoom, "top"))).toEqual(
new Set([mockApps[0], mockApps[1], mockApps[2]]),
);
});
it("cannot add more than three widgets to top container", async () => {
store.recalculateRoom(mockRoom);
store.moveToContainer(mockRoom, mockApps[0], Container.Top);
store.moveToContainer(mockRoom, mockApps[1], Container.Top);
store.moveToContainer(mockRoom, mockApps[2], Container.Top);
expect(store.canAddToContainer(mockRoom, Container.Top)).toEqual(false);
store.moveToContainer(mockRoom, mockApps[0], "top");
store.moveToContainer(mockRoom, mockApps[1], "top");
store.moveToContainer(mockRoom, mockApps[2], "top");
expect(store.canAddToContainer(mockRoom, "top")).toEqual(false);
});
it("remove pins when maximising (other widget)", async () => {
store.recalculateRoom(mockRoom);
store.moveToContainer(mockRoom, mockApps[0], Container.Top);
store.moveToContainer(mockRoom, mockApps[1], Container.Top);
store.moveToContainer(mockRoom, mockApps[2], Container.Top);
store.moveToContainer(mockRoom, mockApps[3], Container.Center);
expect(store.getContainerWidgets(mockRoom, Container.Top)).toEqual([]);
expect(new Set(store.getContainerWidgets(mockRoom, Container.Right))).toEqual(
store.moveToContainer(mockRoom, mockApps[0], "top");
store.moveToContainer(mockRoom, mockApps[1], "top");
store.moveToContainer(mockRoom, mockApps[2], "top");
store.moveToContainer(mockRoom, mockApps[3], "center");
expect(store.getContainerWidgets(mockRoom, "top")).toEqual([]);
expect(new Set(store.getContainerWidgets(mockRoom, "right"))).toEqual(
new Set([mockApps[0], mockApps[1], mockApps[2]]),
);
expect(store.getContainerWidgets(mockRoom, Container.Center)).toEqual([mockApps[3]]);
expect(store.getContainerWidgets(mockRoom, "center")).toEqual([mockApps[3]]);
});
it("remove pins when maximising (one of the pinned widgets)", async () => {
store.recalculateRoom(mockRoom);
store.moveToContainer(mockRoom, mockApps[0], Container.Top);
store.moveToContainer(mockRoom, mockApps[1], Container.Top);
store.moveToContainer(mockRoom, mockApps[2], Container.Top);
store.moveToContainer(mockRoom, mockApps[0], Container.Center);
expect(store.getContainerWidgets(mockRoom, Container.Top)).toEqual([]);
expect(store.getContainerWidgets(mockRoom, Container.Center)).toEqual([mockApps[0]]);
expect(new Set(store.getContainerWidgets(mockRoom, Container.Right))).toEqual(
store.moveToContainer(mockRoom, mockApps[0], "top");
store.moveToContainer(mockRoom, mockApps[1], "top");
store.moveToContainer(mockRoom, mockApps[2], "top");
store.moveToContainer(mockRoom, mockApps[0], "center");
expect(store.getContainerWidgets(mockRoom, "top")).toEqual([]);
expect(store.getContainerWidgets(mockRoom, "center")).toEqual([mockApps[0]]);
expect(new Set(store.getContainerWidgets(mockRoom, "right"))).toEqual(
new Set([mockApps[1], mockApps[2], mockApps[3]]),
);
});
it("remove maximised when pinning (other widget)", async () => {
store.recalculateRoom(mockRoom);
store.moveToContainer(mockRoom, mockApps[0], Container.Center);
store.moveToContainer(mockRoom, mockApps[1], Container.Top);
expect(store.getContainerWidgets(mockRoom, Container.Top)).toEqual([mockApps[1]]);
expect(store.getContainerWidgets(mockRoom, Container.Center)).toEqual([]);
expect(new Set(store.getContainerWidgets(mockRoom, Container.Right))).toEqual(
store.moveToContainer(mockRoom, mockApps[0], "center");
store.moveToContainer(mockRoom, mockApps[1], "top");
expect(store.getContainerWidgets(mockRoom, "top")).toEqual([mockApps[1]]);
expect(store.getContainerWidgets(mockRoom, "center")).toEqual([]);
expect(new Set(store.getContainerWidgets(mockRoom, "right"))).toEqual(
new Set([mockApps[2], mockApps[3], mockApps[0]]),
);
});
it("remove maximised when pinning (same widget)", async () => {
store.recalculateRoom(mockRoom);
store.moveToContainer(mockRoom, mockApps[0], Container.Center);
store.moveToContainer(mockRoom, mockApps[0], Container.Top);
expect(store.getContainerWidgets(mockRoom, Container.Top)).toEqual([mockApps[0]]);
expect(store.getContainerWidgets(mockRoom, Container.Center)).toEqual([]);
expect(new Set(store.getContainerWidgets(mockRoom, Container.Right))).toEqual(
store.moveToContainer(mockRoom, mockApps[0], "center");
store.moveToContainer(mockRoom, mockApps[0], "top");
expect(store.getContainerWidgets(mockRoom, "top")).toEqual([mockApps[0]]);
expect(store.getContainerWidgets(mockRoom, "center")).toEqual([]);
expect(new Set(store.getContainerWidgets(mockRoom, "right"))).toEqual(
new Set([mockApps[2], mockApps[3], mockApps[1]]),
);
});
@ -171,9 +171,9 @@ describe("WidgetLayoutStore", () => {
await store.start();
expect(roomUpdateListener).toHaveBeenCalled();
expect(store.getContainerWidgets(mockRoom, Container.Top)).toEqual([]);
expect(store.getContainerWidgets(mockRoom, Container.Center)).toEqual([]);
expect(store.getContainerWidgets(mockRoom, Container.Right)).toEqual([
expect(store.getContainerWidgets(mockRoom, "top")).toEqual([]);
expect(store.getContainerWidgets(mockRoom, "center")).toEqual([]);
expect(store.getContainerWidgets(mockRoom, "right")).toEqual([
mockApps[0],
mockApps[1],
mockApps[2],
@ -190,58 +190,50 @@ describe("WidgetLayoutStore", () => {
));
store.recalculateRoom(mockRoom);
expect(roomUpdateListener).toHaveBeenCalled();
expect(store.getContainerWidgets(mockRoom, Container.Top)).toEqual([]);
expect(store.getContainerWidgets(mockRoom, Container.Center)).toEqual([]);
expect(store.getContainerWidgets(mockRoom, Container.Right)).toEqual([]);
expect(store.getContainerWidgets(mockRoom, "top")).toEqual([]);
expect(store.getContainerWidgets(mockRoom, "center")).toEqual([]);
expect(store.getContainerWidgets(mockRoom, "right")).toEqual([]);
});
it("should clear the layout if the client is not viable", () => {
store.recalculateRoom(mockRoom);
defaultDispatcher.dispatch({ action: Action.ClientNotViable }, true);
expect(store.getContainerWidgets(mockRoom, Container.Top)).toEqual([]);
expect(store.getContainerWidgets(mockRoom, Container.Center)).toEqual([]);
expect(store.getContainerWidgets(mockRoom, Container.Right)).toEqual([]);
expect(store.getContainerWidgets(mockRoom, "top")).toEqual([]);
expect(store.getContainerWidgets(mockRoom, "center")).toEqual([]);
expect(store.getContainerWidgets(mockRoom, "right")).toEqual([]);
});
it("should return the expected resizer distributions", () => {
// this only works for top widgets
store.recalculateRoom(mockRoom);
store.moveToContainer(mockRoom, mockApps[0], Container.Top);
store.moveToContainer(mockRoom, mockApps[1], Container.Top);
expect(store.getResizerDistributions(mockRoom, Container.Top)).toEqual(["50.0%"]);
store.moveToContainer(mockRoom, mockApps[0], "top");
store.moveToContainer(mockRoom, mockApps[1], "top");
expect(store.getResizerDistributions(mockRoom, "top")).toEqual(["50.0%"]);
});
it("should set and return container height", () => {
store.recalculateRoom(mockRoom);
store.moveToContainer(mockRoom, mockApps[0], Container.Top);
store.moveToContainer(mockRoom, mockApps[1], Container.Top);
store.setContainerHeight(mockRoom, Container.Top, 23);
expect(store.getContainerHeight(mockRoom, Container.Top)).toBe(23);
store.moveToContainer(mockRoom, mockApps[0], "top");
store.moveToContainer(mockRoom, mockApps[1], "top");
store.setContainerHeight(mockRoom, "top", 23);
expect(store.getContainerHeight(mockRoom, "top")).toBe(23);
});
it("should move a widget within a container", () => {
store.recalculateRoom(mockRoom);
store.moveToContainer(mockRoom, mockApps[0], Container.Top);
store.moveToContainer(mockRoom, mockApps[1], Container.Top);
store.moveToContainer(mockRoom, mockApps[2], Container.Top);
expect(store.getContainerWidgets(mockRoom, Container.Top)).toStrictEqual([
mockApps[0],
mockApps[1],
mockApps[2],
]);
store.moveWithinContainer(mockRoom, Container.Top, mockApps[0], 1);
expect(store.getContainerWidgets(mockRoom, Container.Top)).toStrictEqual([
mockApps[1],
mockApps[0],
mockApps[2],
]);
store.moveToContainer(mockRoom, mockApps[0], "top");
store.moveToContainer(mockRoom, mockApps[1], "top");
store.moveToContainer(mockRoom, mockApps[2], "top");
expect(store.getContainerWidgets(mockRoom, "top")).toStrictEqual([mockApps[0], mockApps[1], mockApps[2]]);
store.moveWithinContainer(mockRoom, "top", mockApps[0], 1);
expect(store.getContainerWidgets(mockRoom, "top")).toStrictEqual([mockApps[1], mockApps[0], mockApps[2]]);
});
it("should copy the layout to the room", async () => {
await store.start();
store.recalculateRoom(mockRoom);
store.moveToContainer(mockRoom, mockApps[0], Container.Top);
store.moveToContainer(mockRoom, mockApps[0], "top");
store.copyLayoutToRoom(mockRoom);
expect(mocked(client.sendStateEvent).mock.calls).toMatchInlineSnapshot(`

View File

@ -16,7 +16,7 @@ import {
import { stubClient } from "../../test-utils";
import WidgetUtils from "../../../src/utils/WidgetUtils";
import { type IApp } from "../../../src/utils/WidgetUtils-types";
import { Container, WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore";
import { WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore";
import * as livestream from "../../../src/Livestream";
import Modal from "../../../src/Modal";
import SettingsStore from "../../../src/settings/SettingsStore";
@ -138,12 +138,7 @@ describe("WidgetContextMenuViewModel", () => {
const vm = new WidgetContextMenuViewModel(props);
vm.onMoveButton(1);
expect(WidgetLayoutStore.instance.moveWithinContainer).toHaveBeenCalledWith(
props.room,
Container.Top,
props.app,
1,
);
expect(WidgetLayoutStore.instance.moveWithinContainer).toHaveBeenCalledWith(props.room, "top", props.app, 1);
expect(props.onFinished).toHaveBeenCalled();
});

View File

@ -14,7 +14,7 @@ import { WidgetPipViewModel } from "../../../src/viewmodels/room/WidgetPipViewMo
import WidgetStore, { type IApp } from "../../../src/stores/WidgetStore";
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
import { Action } from "../../../src/dispatcher/actions";
import { Container, WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore";
import { WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore";
import { CallStore, CallStoreEvent } from "../../../src/stores/CallStore";
import { type Call } from "../../../src/models/Call";
@ -96,7 +96,7 @@ describe("WidgetPipViewModel", () => {
vm.setViewingRoom(true);
vm.onBackClick(createBackClickEvent());
expect(moveSpy).toHaveBeenCalledWith(room, widget, Container.Center);
expect(moveSpy).toHaveBeenCalledWith(room, widget, "center");
moveSpy.mockClear();
vm.setViewingRoom(false);

36
pnpm-lock.yaml generated
View File

@ -7,8 +7,8 @@ settings:
catalogs:
default:
'@element-hq/element-web-module-api':
specifier: 1.11.0
version: 1.11.0
specifier: 1.12.0
version: 1.12.0
'@element-hq/element-web-playwright-common':
specifier: 2.2.7
version: 2.2.7
@ -149,7 +149,7 @@ importers:
version: 7.28.6
'@element-hq/element-web-module-api':
specifier: 'catalog:'
version: 1.11.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)
version: 1.12.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)
'@element-hq/web-shared-components':
specifier: workspace:*
version: link:../../packages/shared-components
@ -278,7 +278,7 @@ importers:
version: 1.0.3
matrix-js-sdk:
specifier: github:matrix-org/matrix-js-sdk#develop
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/219eb617dc6d5f48c9424eae38b9863fd177f99c
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/d99363d288b8471e38892ec11fd9b37c062b85fe
matrix-widget-api:
specifier: ^1.17.0
version: 1.17.0
@ -411,7 +411,7 @@ importers:
version: 0.16.3
'@element-hq/element-web-playwright-common':
specifier: 'catalog:'
version: 2.2.7(@element-hq/element-web-module-api@1.11.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4))(@playwright/test@1.58.2)(playwright-core@1.58.2)
version: 2.2.7(@element-hq/element-web-module-api@1.12.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4))(@playwright/test@1.58.2)(playwright-core@1.58.2)
'@element-hq/element-web-playwright-common-local':
specifier: workspace:*
version: link:../../packages/playwright-common
@ -750,7 +750,7 @@ importers:
dependencies:
'@element-hq/element-web-module-api':
specifier: 'catalog:'
version: 1.11.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)
version: 1.12.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)
'@matrix-org/spec':
specifier: ^1.7.0
version: 1.16.0
@ -2016,8 +2016,8 @@ packages:
'@element-hq/element-call-embedded@0.16.3':
resolution: {integrity: sha512-OViKJonDaDNVBUW9WdV9mk78/Ruh34C7XsEgt3O8D9z+64C39elbIgllHSoH5S12IRlv9RYrrV37FZLo6QWsDQ==}
'@element-hq/element-web-module-api@1.11.0':
resolution: {integrity: sha512-RBkvt+Z32CGLkiPtYcQTryQBjG01ZrSVV98CS1cPz/kTeBtxEbVBpqDoOLdGvpmVe0dWo4DLaFcldw2iK39TPA==}
'@element-hq/element-web-module-api@1.12.0':
resolution: {integrity: sha512-fLhHFiL1UbRjolpgera3osHHxhSzfnDGTRhaDEv1UsrHRHwMu3hb/IcyXNqGhLXkJiuX8XoOH0aetaAUqQ0YQA==}
engines: {node: '>=20.0.0'}
peerDependencies:
'@matrix-org/react-sdk-module-api': '*'
@ -2669,8 +2669,8 @@ packages:
'@matrix-org/emojibase-bindings@1.5.0':
resolution: {integrity: sha512-+W9/ow2Z3iQa7ZOF698PBhwNcgGkn36B5Sr8VDPx8N8CH7+Uw+7TrtbtKPZVdgf4m/THmgmfX40jS5YDBsLaYg==}
'@matrix-org/matrix-sdk-crypto-wasm@17.1.0':
resolution: {integrity: sha512-yKPqBvKlHSqkt/UJh+Z+zLKQP8bd19OxokXYXh3VkKbW0+C44nPHsidSwd3SH+RxT+Ck2PDRwVcVXEnUft+/2g==}
'@matrix-org/matrix-sdk-crypto-wasm@18.0.0':
resolution: {integrity: sha512-88+n+dvxLI1cjS10UIlKXVYK7TGWbpAnnaDC9fow7ch/hCvdu3dFhJ3tS3/13N9s9+1QFXB4FFuommj+tHJPhQ==}
engines: {node: '>= 18'}
'@matrix-org/react-sdk-module-api@2.5.0':
@ -7819,8 +7819,8 @@ packages:
matrix-events-sdk@0.0.1:
resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==}
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/219eb617dc6d5f48c9424eae38b9863fd177f99c:
resolution: {tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/219eb617dc6d5f48c9424eae38b9863fd177f99c}
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/d99363d288b8471e38892ec11fd9b37c062b85fe:
resolution: {tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/d99363d288b8471e38892ec11fd9b37c062b85fe}
version: 41.1.0
engines: {node: '>=22.0.0'}
@ -11980,7 +11980,7 @@ snapshots:
'@element-hq/element-call-embedded@0.16.3': {}
'@element-hq/element-web-module-api@1.11.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)':
'@element-hq/element-web-module-api@1.12.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)':
dependencies:
'@types/react': 19.2.10
'@types/react-dom': 19.2.3(@types/react@19.2.10)
@ -11989,10 +11989,10 @@ snapshots:
'@matrix-org/react-sdk-module-api': 2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4)
matrix-web-i18n: 3.6.0
'@element-hq/element-web-playwright-common@2.2.7(@element-hq/element-web-module-api@1.11.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4))(@playwright/test@1.58.2)(playwright-core@1.58.2)':
'@element-hq/element-web-playwright-common@2.2.7(@element-hq/element-web-module-api@1.12.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4))(@playwright/test@1.58.2)(playwright-core@1.58.2)':
dependencies:
'@axe-core/playwright': 4.11.1(playwright-core@1.58.2)
'@element-hq/element-web-module-api': 1.11.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)
'@element-hq/element-web-module-api': 1.12.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)
'@playwright/test': 1.58.2
'@testcontainers/postgresql': 11.11.0
glob: 13.0.6
@ -12712,7 +12712,7 @@ snapshots:
emojibase: 17.0.0
emojibase-data: 17.0.0(emojibase@17.0.0)
'@matrix-org/matrix-sdk-crypto-wasm@17.1.0': {}
'@matrix-org/matrix-sdk-crypto-wasm@18.0.0': {}
'@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4)':
dependencies:
@ -18712,10 +18712,10 @@ snapshots:
matrix-events-sdk@0.0.1: {}
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/219eb617dc6d5f48c9424eae38b9863fd177f99c:
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/d99363d288b8471e38892ec11fd9b37c062b85fe:
dependencies:
'@babel/runtime': 7.28.6
'@matrix-org/matrix-sdk-crypto-wasm': 17.1.0
'@matrix-org/matrix-sdk-crypto-wasm': 18.0.0
another-json: 0.2.0
bs58: 6.0.0
content-type: 1.0.5

View File

@ -17,7 +17,7 @@ catalog:
"@playwright/test": 1.58.2
"playwright-core": 1.58.2
# Module API
"@element-hq/element-web-module-api": 1.11.0
"@element-hq/element-web-module-api": 1.12.0
# Compound
"@vector-im/compound-design-tokens": 6.10.1
"@vector-im/compound-web": 8.4.0