) => {
+ const { room, app, userId, widgetPageTitle, widgetName, shouldEmptyWidgetCard } = useViewModel(vm);
+
+ const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
+
+ let contextMenu: JSX.Element | undefined;
+ if (menuDisplayed && app) {
+ const rect = handle.current?.getBoundingClientRect();
+ const rightMargin = rect ? rect.right : 0;
+ const bottomMargin = rect ? rect.bottom : 0;
+ contextMenu = (
+
+ );
+ }
+
+ const header = (
+
+
+ {widgetName}
+
+
+ {contextMenu}
+
+ );
+
+ if (shouldEmptyWidgetCard || !app) return null;
+
+ return (
+
+
+
+ );
+};
diff --git a/packages/shared-components/src/right-panel/WidgetCardView/index.ts b/packages/shared-components/src/right-panel/WidgetCardView/index.ts
new file mode 100644
index 00000000000..d44f3afd639
--- /dev/null
+++ b/packages/shared-components/src/right-panel/WidgetCardView/index.ts
@@ -0,0 +1,8 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+export { WidgetCardView } from "./WidgetCardView";
diff --git a/src/components/structures/RightPanel.tsx b/src/components/structures/RightPanel.tsx
index e006b1d92aa..3f28acedf03 100644
--- a/src/components/structures/RightPanel.tsx
+++ b/src/components/structures/RightPanel.tsx
@@ -16,7 +16,6 @@ import { RightPanelPhases } from "../../stores/right-panel/RightPanelStorePhases
import RightPanelStore from "../../stores/right-panel/RightPanelStore";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import RoomSummaryCardView from "../views/right_panel/RoomSummaryCardView";
-import WidgetCard from "../views/right_panel/WidgetCard";
import UserInfo from "../views/right_panel/UserInfo";
import ThirdPartyMemberInfo from "../views/rooms/ThirdPartyMemberInfo";
import FilePanel from "./FilePanel";
@@ -35,6 +34,7 @@ import { type XOR } from "../../@types/common";
import ExtensionsCard from "../views/right_panel/ExtensionsCard";
import MemberListView from "../views/rooms/MemberList/MemberListView";
import { _t } from "../../languageHandler";
+import { WidgetCard } from "../viewmodels/right_panel/WidgetCardViewModel";
interface BaseProps {
overwriteCard?: IRightPanelCard; // used to display a custom card and ignoring the RightPanelStore (used for UserView)
diff --git a/src/components/viewmodels/right_panel/WidgetCardViewModel.tsx b/src/components/viewmodels/right_panel/WidgetCardViewModel.tsx
new file mode 100644
index 00000000000..9470bedc3fa
--- /dev/null
+++ b/src/components/viewmodels/right_panel/WidgetCardViewModel.tsx
@@ -0,0 +1,101 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
+import React, { type JSX, useEffect, useMemo } from "react";
+
+import { BaseViewModel } from "../../../viewmodels/base/BaseViewModel";
+import {
+ type WidgetCardViewSnapshot,
+ type WidgetCardViewModel as WidgetCardViewModelInterface,
+ WidgetCardView,
+} from "../../../../packages/shared-components/src/right-panel/WidgetCardView/WidgetCardView";
+import WidgetUtils, { useWidgets } from "../../../utils/WidgetUtils";
+import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
+import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
+import { MatrixClientPeg } from "../../../MatrixClientPeg";
+import { type IApp } from "../../../utils/WidgetUtils-types";
+
+
+interface WidgetCardProps {
+ room: Room;
+ widgetId: string;
+ onClose(): void;
+}
+
+type WidgetCardViewModelProps = WidgetCardProps & {
+ apps: IApp[];
+}
+
+export class WidgetCardViewModel
+ extends BaseViewModel
+ implements WidgetCardViewModelInterface
+{
+ private cli: MatrixClient | null;
+
+ public static computeSnapshot = (
+ props: WidgetCardViewModelProps & { cli: MatrixClient | null },
+ ): WidgetCardViewSnapshot => {
+ const { room, widgetId } = props;
+
+ const app = props.apps.find((a) => a.id === widgetId);
+ const isRight = app && WidgetLayoutStore.instance.isInContainer(room, app, Container.Right);
+ let shouldEmptyWidgetCard = !isRight;
+
+ if (!app || !isRight) {
+ // stop showing this card
+ RightPanelStore.instance.popCard();
+ shouldEmptyWidgetCard = true;
+ }
+
+ return {
+ room,
+ app,
+ creatorUserId: app ? app.creatorUserId : undefined,
+ widgetPageTitle: WidgetUtils.getWidgetDataTitle(app),
+ userId: props.cli!.getSafeUserId(),
+ widgetName: WidgetUtils.getWidgetName(app),
+ shouldEmptyWidgetCard,
+ };
+ };
+
+ public constructor(props: WidgetCardViewModelProps) {
+ const cli = MatrixClientPeg?.get();
+ super(props, WidgetCardViewModel.computeSnapshot({ ...props, cli }));
+ this.cli = cli;
+ }
+
+ public onClose = (): void => {
+ this.snapshot.set({
+ room: this.props.room,
+ app: undefined,
+ creatorUserId: undefined,
+ widgetPageTitle: WidgetUtils.getWidgetDataTitle(),
+ userId: this.cli!.getSafeUserId(),
+ widgetName: WidgetUtils.getWidgetName(),
+ shouldEmptyWidgetCard: true,
+ });
+ this.props.onClose();
+ };
+}
+
+/**
+ * WidgetCard component that initializes the WidgetCardViewModel and renders the WidgetCardView.
+ */
+export function WidgetCard(props: WidgetCardProps): JSX.Element {
+ const { room, widgetId, onClose } = props;
+ const apps = useWidgets(props.room);
+ const vm = useMemo(() => new WidgetCardViewModel({ room, widgetId, apps, onClose }), [apps, room, widgetId, onClose]);
+
+ useEffect(() => {
+ return () => {
+ vm.dispose();
+ };
+ }, [vm]);
+
+ return ;
+}