+
= ({ app, room }) => {
{canModifyWidget && (
-
+ }
/>
)}
@@ -133,8 +124,6 @@ const AppRow: React.FC
= ({ app, room }) => {
title={pinTitle}
disabled={cannotPin}
/>
-
- {contextMenu}
);
};
diff --git a/src/components/views/right_panel/WidgetCard.tsx b/src/components/views/right_panel/WidgetCard.tsx
index 29a713fdd48..7ae9ae1b215 100644
--- a/src/components/views/right_panel/WidgetCard.tsx
+++ b/src/components/views/right_panel/WidgetCard.tsx
@@ -14,12 +14,11 @@ import BaseCard from "./BaseCard";
import WidgetUtils, { useWidgets } from "../../../utils/WidgetUtils";
import AppTile from "../elements/AppTile";
import { _t } from "../../../languageHandler";
-import { ChevronFace, ContextMenuButton, useContextMenu } from "../../structures/ContextMenu";
-import { WidgetContextMenu } from "../context_menus/WidgetContextMenu";
+import { ContextMenuButton, useContextMenu } from "../../structures/ContextMenu";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
-import UIStore from "../../../stores/UIStore";
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
import Heading from "../typography/Heading";
+import { WidgetContextMenu } from "../../../viewmodels/right-panel/WidgetContextMenuViewModel";
interface IProps {
room: Room;
@@ -46,34 +45,28 @@ const WidgetCard: React.FC
= ({ room, widgetId, onClose }) => {
// Don't render anything as we are about to transition
if (!app || !isRight) return null;
- let contextMenu: JSX.Element | undefined;
- if (menuDisplayed) {
- const rect = handle.current?.getBoundingClientRect();
- const rightMargin = rect ? rect.right : 0;
- const bottomMargin = rect ? rect.bottom : 0;
- contextMenu = (
-
- );
- }
+ const contextMenu: JSX.Element = (
+
+ }
+ onFinished={closeMenu}
+ app={app}
+ menuDisplayed={menuDisplayed}
+ />
+ );
const header = (
{WidgetUtils.getWidgetName(app)}
-
{contextMenu}
);
diff --git a/src/viewmodels/right-panel/WidgetContextMenuViewModel.tsx b/src/viewmodels/right-panel/WidgetContextMenuViewModel.tsx
new file mode 100644
index 00000000000..2ad6566bfcc
--- /dev/null
+++ b/src/viewmodels/right-panel/WidgetContextMenuViewModel.tsx
@@ -0,0 +1,299 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+import React, { useContext, useMemo, useEffect, type ReactElement, type ReactNode } from "react";
+import { logger } from "@sentry/browser";
+import { type Room, type MatrixClient } from "matrix-js-sdk/src/matrix";
+import { type IWidget, MatrixCapabilities } from "matrix-widget-api";
+import {
+ BaseViewModel,
+ type WidgetContextMenuSnapshot,
+ WidgetContextMenuView,
+ type WidgetContextMenuViewModel as WidgetContextMenuViewModelInterface,
+} from "@element-hq/web-shared-components";
+import { type ApprovalOpts, WidgetLifecycle } from "@matrix-org/react-sdk-module-api/lib/lifecycles/WidgetLifecycle";
+
+import ErrorDialog from "../../components/views/dialogs/ErrorDialog";
+import QuestionDialog from "../../components/views/dialogs/QuestionDialog";
+import MatrixClientContext from "../../contexts/MatrixClientContext";
+import { useScopedRoomContext } from "../../contexts/ScopedRoomContext";
+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";
+import WidgetUtils from "../../utils/WidgetUtils";
+import { WidgetType } from "../../widgets/WidgetType";
+import { ModuleRunner } from "../../modules/ModuleRunner";
+import { ElementWidget, type WidgetMessaging } from "../../stores/widgets/WidgetMessaging";
+import dis from "../../dispatcher/dispatcher";
+
+const checkRevokeButtonState = (
+ cli: MatrixClient,
+ roomId: string | undefined,
+ app: IWidget,
+ userWidget: boolean | undefined,
+): boolean => {
+ const opts: ApprovalOpts = { approved: undefined };
+ ModuleRunner.instance.invoke(WidgetLifecycle.PreLoadRequest, opts, new ElementWidget(app));
+ if (!opts.approved) {
+ const isAllowedWidget =
+ (isAppWidget(app) &&
+ app.eventId !== undefined &&
+ (SettingsStore.getValue("allowedWidgets", roomId)[app.eventId] ?? false)) ||
+ app.creatorUserId === cli?.getUserId();
+
+ const isLocalWidget = WidgetType.JITSI.matches(app.type);
+ return !userWidget && !isLocalWidget && isAllowedWidget;
+ }
+ return false;
+};
+
+export class WidgetContextMenuViewModel
+ extends BaseViewModel
+ implements WidgetContextMenuViewModelInterface
+{
+ private _app: IWidget;
+ private _roomId: string | undefined;
+ private _room: Room | undefined;
+ private _cli: MatrixClient;
+ private _widgetMessaging: WidgetMessaging | undefined;
+
+ public constructor(props: WidgetContextMenuViewModelProps) {
+ const { app, cli, room, roomId, userWidget, showUnpin, menuDisplayed, trigger, onDeleteClick } = props;
+ super(
+ props,
+ WidgetContextMenuViewModel.computeSnapshot(
+ app,
+ cli,
+ room,
+ userWidget,
+ showUnpin,
+ menuDisplayed,
+ trigger,
+ onDeleteClick,
+ ),
+ );
+ this._app = app;
+ this._roomId = roomId;
+ this._room = room;
+ this._cli = cli;
+ this._widgetMessaging = WidgetMessagingStore.instance.getMessagingForUid(WidgetUtils.getWidgetUid(props.app));
+ }
+
+ private static readonly computeSnapshot = (
+ app: IWidget,
+ cli: MatrixClient,
+ room: Room | undefined,
+ userWidget: boolean | undefined,
+ showUnpin: boolean | undefined,
+ menuDisplayed: boolean,
+ trigger: ReactNode,
+ onDeleteClick?: () => void,
+ ): WidgetContextMenuSnapshot => {
+ const showStreamAudioStreamButton = !!getConfigLivestreamUrl() && WidgetType.JITSI.matches(app.type);
+ const canModify = userWidget || WidgetUtils.canUserModifyWidgets(cli, room?.roomId);
+ const widgetMessaging = WidgetMessagingStore.instance.getMessagingForUid(WidgetUtils.getWidgetUid(app));
+ const showDeleteButton = !!onDeleteClick || canModify;
+
+ const showSnapshotButton =
+ SettingsStore.getValue("enableWidgetScreenshots") &&
+ !!widgetMessaging?.widgetApi?.hasCapability(MatrixCapabilities.Screenshots);
+
+ let showMoveButtons: [boolean, boolean] = [false, false];
+ if (showUnpin) {
+ const pinnedWidgets = room ? WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top) : [];
+ const widgetIndex = pinnedWidgets.findIndex((widget) => widget.id === app.id);
+ showMoveButtons = [widgetIndex > 0, widgetIndex < pinnedWidgets.length - 1];
+ }
+
+ const showEditButton = canModify && WidgetUtils.isManagedByManager(app);
+
+ const showRevokeButton = checkRevokeButtonState(cli, room?.roomId, app, userWidget);
+
+ return {
+ showStreamAudioStreamButton,
+ showEditButton,
+ showRevokeButton,
+ showDeleteButton,
+ showSnapshotButton,
+ showMoveButtons,
+ canModify,
+ isMenuOpened: menuDisplayed,
+ trigger,
+ };
+ };
+
+ public get onFinished(): () => void {
+ return () => this.props.onFinished!();
+ }
+
+ public get onRevokeClick(): () => void {
+ return () => {
+ const eventId = isAppWidget(this._app) ? this._app.eventId : undefined;
+ logger.info("Revoking permission for widget to load: " + eventId);
+ const current = SettingsStore.getValue("allowedWidgets", this._roomId);
+ if (eventId !== undefined) current[eventId] = false;
+ const level = SettingsStore.firstSupportedLevel("allowedWidgets");
+ if (!level) throw new Error("level must be defined");
+ SettingsStore.setValue("allowedWidgets", this._roomId ?? null, level, current).catch((err) => {
+ logger.error(err);
+ // We don't really need to do anything about this - the user will just hit the button again.
+ });
+ this.props.onFinished!();
+ };
+ }
+
+ public get onDeleteClick(): () => void {
+ return () => {
+ if (this.props.onDeleteClick) {
+ this.props.onDeleteClick();
+ } else if (this._roomId) {
+ // Show delete confirmation dialog
+ const { finished } = Modal.createDialog(QuestionDialog, {
+ title: _t("widget|context_menu|delete"),
+ description: _t("widget|context_menu|delete_warning"),
+ button: _t("widget|context_menu|delete"),
+ });
+
+ finished.then(([confirmed]) => {
+ if (!confirmed) return;
+ WidgetUtils.setRoomWidget(this._cli, this._roomId!, this._app.id);
+ });
+ }
+
+ this.props.onFinished!();
+ };
+ }
+
+ public get onSnapshotClick(): () => void {
+ return () => {
+ this._widgetMessaging?.widgetApi
+ ?.takeScreenshot()
+ .then((data) => {
+ dis.dispatch({
+ action: "picture_snapshot",
+ file: data.screenshot,
+ });
+ })
+ .catch((err) => {
+ logger.error("Failed to take screenshot: ", err);
+ });
+ this.props.onFinished!();
+ };
+ }
+
+ public get onStreamAudioClick(): () => Promise {
+ return async () => {
+ try {
+ if (this._roomId) {
+ await startJitsiAudioLivestream(this._cli, this._widgetMessaging!.widgetApi!, this._roomId!);
+ }
+ } catch (err: any) {
+ logger.error("Failed to start livestream", err);
+ // XXX: won't i18n well, but looks like widget api only support 'message'?
+ const message =
+ err instanceof Error ? err.message : _t("widget|error_unable_start_audio_stream_description");
+ Modal.createDialog(ErrorDialog, {
+ title: _t("widget|error_unable_start_audio_stream_title"),
+ description: message,
+ });
+ }
+ this.props.onFinished!();
+ };
+ }
+
+ public get onEditClick(): () => void {
+ return () => {
+ if (this.props.onEditClick) {
+ this.props.onEditClick();
+ } else if (this._room) {
+ WidgetUtils.editWidget(this._room, this._app);
+ }
+ this.props.onFinished!();
+ };
+ }
+
+ 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);
+ this.props.onFinished!();
+ };
+ }
+}
+
+interface WidgetContextMenuProps {
+ app: IWidget;
+ userWidget?: boolean;
+ showUnpin?: boolean;
+ menuDisplayed: boolean;
+ trigger: ReactNode;
+ // override delete handler
+ onDeleteClick?(): void;
+ // override edit handler
+ onEditClick?(): void;
+ onFinished(): void;
+}
+
+export type WidgetContextMenuViewModelProps = WidgetContextMenuProps & {
+ cli: MatrixClient;
+ room: Room | undefined;
+ roomId: string | undefined;
+};
+
+export function WidgetContextMenu(props: WidgetContextMenuProps): ReactElement {
+ const { app, userWidget, showUnpin, menuDisplayed, trigger, onEditClick, onDeleteClick, onFinished } = props;
+ const cli = useContext(MatrixClientContext);
+ const { room, roomId } = useScopedRoomContext("room", "roomId");
+
+ const vm = useMemo(
+ () =>
+ new WidgetContextMenuViewModel({
+ menuDisplayed,
+ room,
+ roomId,
+ cli,
+ app,
+ showUnpin,
+ userWidget,
+ trigger,
+ onEditClick,
+ onDeleteClick,
+ onFinished,
+ }),
+ [app, room, roomId, userWidget, showUnpin, menuDisplayed, cli, trigger, onEditClick, onDeleteClick, onFinished],
+ );
+
+ useEffect(() => {
+ return () => {
+ vm.dispose();
+ };
+ }, [vm]);
+
+ const {
+ showStreamAudioStreamButton,
+ showEditButton,
+ showRevokeButton,
+ showDeleteButton,
+ showSnapshotButton,
+ showMoveButtons,
+ } = vm.getSnapshot();
+
+ const hasContextMenuOptions =
+ showStreamAudioStreamButton ||
+ showEditButton ||
+ showRevokeButton ||
+ showDeleteButton ||
+ showSnapshotButton ||
+ showMoveButtons.some(Boolean);
+
+ return hasContextMenuOptions ? : <>>;
+}
diff --git a/test/unit-tests/components/views/elements/__snapshots__/AppTile-test.tsx.snap b/test/unit-tests/components/views/elements/__snapshots__/AppTile-test.tsx.snap
index c1330a4aab6..6b9a538a9f6 100644
--- a/test/unit-tests/components/views/elements/__snapshots__/AppTile-test.tsx.snap
+++ b/test/unit-tests/components/views/elements/__snapshots__/AppTile-test.tsx.snap
@@ -26,12 +26,15 @@ exports[`AppTile destroys non-persisted right panel widget on room change 1`] =
aria-haspopup="true"
aria-label="Options"
class="mx_AccessibleButton mx_BaseCard_header_title_button--option"
+ data-state="closed"
+ id="radix-_r_0_"
role="button"
tabindex="0"
+ type="button"
/>