diff --git a/apps/web/src/LegacyCallHandler.tsx b/apps/web/src/LegacyCallHandler.tsx index 9e4544c37d..81fb0093d0 100644 --- a/apps/web/src/LegacyCallHandler.tsx +++ b/apps/web/src/LegacyCallHandler.tsx @@ -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 { // 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 { 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 (
{ {!this.props.hideHeader && ( {extraButtons}} /> )} {mainSplitBody} diff --git a/apps/web/src/components/views/context_menus/WidgetContextMenu.tsx b/apps/web/src/components/views/context_menus/WidgetContextMenu.tsx index f974741cf5..d0a168f691 100644 --- a/apps/web/src/components/views/context_menus/WidgetContextMenu.tsx +++ b/apps/web/src/components/views/context_menus/WidgetContextMenu.tsx @@ -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 = ({ 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 = ({ 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(); }; diff --git a/apps/web/src/components/views/elements/AppTile.tsx b/apps/web/src/components/views/elements/AppTile.tsx index 3442bac1c3..c35a77489f 100644 --- a/apps/web/src/components/views/elements/AppTile.tsx +++ b/apps/web/src/components/views/elements/AppTile.tsx @@ -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 { 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 { 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( { 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; diff --git a/apps/web/src/components/views/right_panel/ExtensionsCard.tsx b/apps/web/src/components/views/right_panel/ExtensionsCard.tsx index 4ee41642e4..25aabadd55 100644 --- a/apps/web/src/components/views/right_panel/ExtensionsCard.tsx +++ b/apps/web/src/components/views/right_panel/ExtensionsCard.tsx @@ -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 = ({ 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(); - 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 = ({ 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) { diff --git a/apps/web/src/components/views/right_panel/WidgetCard.tsx b/apps/web/src/components/views/right_panel/WidgetCard.tsx index 30d9f38b22..b9c7c23957 100644 --- a/apps/web/src/components/views/right_panel/WidgetCard.tsx +++ b/apps/web/src/components/views/right_panel/WidgetCard.tsx @@ -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 = ({ 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(); diff --git a/apps/web/src/components/views/rooms/AppsDrawer.tsx b/apps/web/src/components/views/rooms/AppsDrawer.tsx index b4b62e9763..051eb20047 100644 --- a/apps/web/src/components/views/rooms/AppsDrawer.tsx +++ b/apps/web/src/components/views/rooms/AppsDrawer.tsx @@ -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 { 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 { 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 { }; 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 { }; 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 = ({ 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 = ({ 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(); }} diff --git a/apps/web/src/components/views/rooms/RoomHeader/RoomHeader.tsx b/apps/web/src/components/views/rooms/RoomHeader/RoomHeader.tsx index b6c0086800..7197e42be4 100644 --- a/apps/web/src/components/views/rooms/RoomHeader/RoomHeader.tsx +++ b/apps/web/src/components/views/rooms/RoomHeader/RoomHeader.tsx @@ -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({ {/* 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 && ( - + )} {askToJoinEnabled && } diff --git a/apps/web/src/hooks/room/useRoomCall.tsx b/apps/web/src/hooks/room/useRoomCall.tsx index 7c214bd019..6737395cb2 100644 --- a/apps/web/src/hooks/room/useRoomCall.tsx +++ b/apps/web/src/hooks/room/useRoomCall.tsx @@ -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. diff --git a/apps/web/src/modules/Api.ts b/apps/web/src/modules/Api.ts index 3a38c3268f..bb3c7497d5 100644 --- a/apps/web/src/modules/Api.ts +++ b/apps/web/src/modules/Api.ts @@ -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 = (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(); diff --git a/apps/web/src/modules/ExtrasApi.ts b/apps/web/src/modules/ExtrasApi.ts index 420b17130a..c82925889c 100644 --- a/apps/web/src/modules/ExtrasApi.ts +++ b/apps/web/src/modules/ExtrasApi.ts @@ -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 implements ExtrasApi { public spacePanelItems = new Map(); public visibleRoomBySpaceKey = new Map 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 string[]): void { this.visibleRoomBySpaceKey.set(spaceKey, cb); } + + public addRoomHeaderButtonCallback(cb: RoomHeaderButtonsCallback): void { + this.roomHeaderButtonsCallbacks.push(cb); + } } export function useModuleSpacePanelItems(api: ElementWebExtrasApi): ModuleSpacePanelItem[] { diff --git a/apps/web/src/modules/WidgetApi.ts b/apps/web/src/modules/WidgetApi.ts new file mode 100644 index 0000000000..3951d3f65b --- /dev/null +++ b/apps/web/src/modules/WidgetApi.ts @@ -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); + } +} diff --git a/apps/web/src/stores/widgets/WidgetLayoutStore.ts b/apps/web/src/stores/widgets/WidgetLayoutStore.ts index 345db83cfc..0beeba830b 100644 --- a/apps/web/src/stores/widgets/WidgetLayoutStore.ts +++ b/apps/web/src/stores/widgets/WidgetLayoutStore.ts @@ -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 & { 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 = {}; 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; diff --git a/apps/web/src/stores/widgets/types.ts b/apps/web/src/stores/widgets/types.ts index b91edd5c35..cc264c6dce 100644 --- a/apps/web/src/stores/widgets/types.ts +++ b/apps/web/src/stores/widgets/types.ts @@ -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", -} diff --git a/apps/web/src/viewmodels/right-panel/WidgetContextMenuViewModel.tsx b/apps/web/src/viewmodels/right-panel/WidgetContextMenuViewModel.tsx index 6bb482fafa..092c78bbe4 100644 --- a/apps/web/src/viewmodels/right-panel/WidgetContextMenuViewModel.tsx +++ b/apps/web/src/viewmodels/right-panel/WidgetContextMenuViewModel.tsx @@ -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!(); }; } diff --git a/apps/web/src/viewmodels/room/WidgetPipViewModel.tsx b/apps/web/src/viewmodels/room/WidgetPipViewModel.tsx index e3deb31c4d..80eb04b9b9 100644 --- a/apps/web/src/viewmodels/room/WidgetPipViewModel.tsx +++ b/apps/web/src/viewmodels/room/WidgetPipViewModel.tsx @@ -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({ action: Action.ViewRoom, diff --git a/apps/web/test/test-utils/test-utils.ts b/apps/web/test/test-utils/test-utils.ts index 54969a4774..dce7257b83 100644 --- a/apps/web/test/test-utils/test-utils.ts +++ b/apps/web/test/test-utils/test-utils.ts @@ -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), diff --git a/apps/web/test/unit-tests/components/structures/PipContainer-test.tsx b/apps/web/test/unit-tests/components/structures/PipContainer-test.tsx index be30707f2e..c1b8275981 100644 --- a/apps/web/test/unit-tests/components/structures/PipContainer-test.tsx +++ b/apps/web/test/unit-tests/components/structures/PipContainer-test.tsx @@ -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(); }); diff --git a/apps/web/test/unit-tests/components/views/context_menus/WidgetContextMenu-test.tsx b/apps/web/test/unit-tests/components/views/context_menus/WidgetContextMenu-test.tsx index 648e5bff64..9fd96c5cfe 100644 --- a/apps/web/test/unit-tests/components/views/context_menus/WidgetContextMenu-test.tsx +++ b/apps/web/test/unit-tests/components/views/context_menus/WidgetContextMenu-test.tsx @@ -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("", () => { const widgetId = "w1"; @@ -44,8 +48,12 @@ describe("", () => { 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("", () => { 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("", () => { function getComponent(props: Partial> = {}): JSX.Element { return ( - + + + ); } @@ -89,4 +106,69 @@ describe("", () => { 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(); + }); }); diff --git a/apps/web/test/unit-tests/components/views/elements/AppTile-test.tsx b/apps/web/test/unit-tests/components/views/elements/AppTile-test.tsx index 73242b2014..44f5addcc7 100644 --- a/apps/web/test/unit-tests/components/views/elements/AppTile-test.tsx +++ b/apps/web/test/unit-tests/components/views/elements/AppTile-test.tsx @@ -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"); }); }); diff --git a/apps/web/test/unit-tests/components/views/rooms/RoomHeader/RoomHeader-test.tsx b/apps/web/test/unit-tests/components/views/rooms/RoomHeader/RoomHeader-test.tsx index 7de539ed54..08bb4de1b8 100644 --- a/apps/web/test/unit-tests/components/views/rooms/RoomHeader/RoomHeader-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/RoomHeader/RoomHeader-test.tsx @@ -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(, getWrapper()); + render(, 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(, getWrapper()); + render(, getWrapper()); const button = screen.getByRole("button", { name: "test-label" }); const event = createEvent.click(button); diff --git a/apps/web/test/unit-tests/modules/ExtrasApi-test.tsx b/apps/web/test/unit-tests/modules/ExtrasApi-test.tsx new file mode 100644 index 0000000000..241b518faf --- /dev/null +++ b/apps/web/test/unit-tests/modules/ExtrasApi-test.tsx @@ -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 }) => ( + + + {children} + + + ), + }; + } + + it("addRoomHeaderButtonCallback stores and uses the provided callback", () => { + const callback = jest.fn(); + ModuleApi.instance.extras.addRoomHeaderButtonCallback(callback); + + render(, getWrapper()); + + act(() => { + sdkContext.roomViewStore.emit("update"); + }); + + expect(callback).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/test/unit-tests/modules/ProxiedModuleApi-test.tsx b/apps/web/test/unit-tests/modules/ProxiedModuleApi-test.tsx index bb21f35397..20e0641694 100644 --- a/apps/web/test/unit-tests/modules/ProxiedModuleApi-test.tsx +++ b/apps/web/test/unit-tests/modules/ProxiedModuleApi-test.tsx @@ -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"); }); }); diff --git a/apps/web/test/unit-tests/modules/WidgetApi-test.ts b/apps/web/test/unit-tests/modules/WidgetApi-test.ts new file mode 100644 index 0000000000..d37ad6a2b1 --- /dev/null +++ b/apps/web/test/unit-tests/modules/WidgetApi-test.ts @@ -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 => ({ + 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"); + }); +}); diff --git a/apps/web/test/unit-tests/stores/WidgetLayoutStore-test.ts b/apps/web/test/unit-tests/stores/WidgetLayoutStore-test.ts index 982c65e210..b774f1414d 100644 --- a/apps/web/test/unit-tests/stores/WidgetLayoutStore-test.ts +++ b/apps/web/test/unit-tests/stores/WidgetLayoutStore-test.ts @@ -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(` diff --git a/apps/web/test/viewmodels/right-panel/WidgetContextMenuViewModel-test.tsx b/apps/web/test/viewmodels/right-panel/WidgetContextMenuViewModel-test.tsx index 559be7ed56..1d9cf070fa 100644 --- a/apps/web/test/viewmodels/right-panel/WidgetContextMenuViewModel-test.tsx +++ b/apps/web/test/viewmodels/right-panel/WidgetContextMenuViewModel-test.tsx @@ -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(); }); diff --git a/apps/web/test/viewmodels/room/WidgetPip-test.ts b/apps/web/test/viewmodels/room/WidgetPip-test.ts index 701b630467..60823c076e 100644 --- a/apps/web/test/viewmodels/room/WidgetPip-test.ts +++ b/apps/web/test/viewmodels/room/WidgetPip-test.ts @@ -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); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf355623f1..34082769f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1d5548742f..faa474ca61 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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