diff --git a/packages/shared-components/playwright/snapshots/rightpanel-widgetcontextmenuview--default-linux.png b/packages/shared-components/playwright/snapshots/rightpanel-widgetcontextmenuview--default-linux.png new file mode 100644 index 00000000000..bc0f8ee644b Binary files /dev/null and b/packages/shared-components/playwright/snapshots/rightpanel-widgetcontextmenuview--default-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/rightpanel-widgetcontextmenuview--only-basic-modification-linux.png b/packages/shared-components/playwright/snapshots/rightpanel-widgetcontextmenuview--only-basic-modification-linux.png new file mode 100644 index 00000000000..94984a2b16d Binary files /dev/null and b/packages/shared-components/playwright/snapshots/rightpanel-widgetcontextmenuview--only-basic-modification-linux.png differ diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index a806971a3f9..0f207c69374 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -21,6 +21,7 @@ export * from "./rich-list/RichItem"; export * from "./rich-list/RichList"; export * from "./utils/Box"; export * from "./utils/Flex"; +export * from "./right-panel/WidgetContextMenu"; // Utils export * from "./utils/i18n"; diff --git a/packages/shared-components/src/right-panel/WidgetContextMenu/WidgetContextMenuView.stories.tsx b/packages/shared-components/src/right-panel/WidgetContextMenu/WidgetContextMenuView.stories.tsx new file mode 100644 index 00000000000..54b17ab2610 --- /dev/null +++ b/packages/shared-components/src/right-panel/WidgetContextMenu/WidgetContextMenuView.stories.tsx @@ -0,0 +1,84 @@ +/* + * 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, { type JSX } from "react"; +import { fn } from "storybook/test"; +import { IconButton } from "@vector-im/compound-web"; +import TriggerIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal"; + +import type { Meta, StoryFn } from "@storybook/react-vite"; +import { useMockedViewModel } from "../../useMockedViewModel"; +import { + type WidgetContextMenuAction, + type WidgetContextMenuSnapshot, + WidgetContextMenuView, +} from "./WidgetContextMenuView"; + +type WidgetContextMenuViewModelProps = WidgetContextMenuSnapshot & WidgetContextMenuAction; + +const WidgetContextMenuViewWrapper = ({ + onStreamAudioClick, + onEditClick, + onSnapshotClick, + onDeleteClick, + onRevokeClick, + onFinished, + onMoveButton, + ...rest +}: WidgetContextMenuViewModelProps): JSX.Element => { + const vm = useMockedViewModel(rest, { + onStreamAudioClick, + onEditClick, + onSnapshotClick, + onDeleteClick, + onRevokeClick, + onFinished, + onMoveButton, + }); + return ; +}; + +export default { + title: "RightPanel/WidgetContextMenuView", + component: WidgetContextMenuViewWrapper, + tags: ["autodocs"], + args: { + showStreamAudioStreamButton: true, + showEditButton: true, + showRevokeButton: true, + showDeleteButton: true, + showSnapshotButton: true, + showMoveButtons: [true, true], + canModify: true, + widgetMessaging: undefined, + isMenuOpened: true, + trigger: ( + + + + ), + onStreamAudioClick: fn(), + onEditClick: fn(), + onSnapshotClick: fn(), + onDeleteClick: fn(), + onRevokeClick: fn(), + onFinished: fn(), + onMoveButton: fn(), + }, +} as Meta; + +const Template: StoryFn = (args) => ; + +export const Default = Template.bind({}); + +export const OnlyBasicModification = Template.bind({}); +OnlyBasicModification.args = { + showSnapshotButton: false, + showMoveButtons: [false, false], + showStreamAudioStreamButton: false, + showEditButton: false, +}; diff --git a/packages/shared-components/src/right-panel/WidgetContextMenu/WidgetContextMenuView.test.tsx b/packages/shared-components/src/right-panel/WidgetContextMenu/WidgetContextMenuView.test.tsx new file mode 100644 index 00000000000..51fd7b9e7e3 --- /dev/null +++ b/packages/shared-components/src/right-panel/WidgetContextMenu/WidgetContextMenuView.test.tsx @@ -0,0 +1,110 @@ +/* + * 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 from "react"; +import { screen, render } from "jest-matrix-react"; +import userEvent from "@testing-library/user-event"; +import { IconButton } from "@vector-im/compound-web"; +import { composeStories } from "@storybook/react-vite"; +import TriggerIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal"; + +import { + type WidgetContextMenuAction, + type WidgetContextMenuSnapshot, + WidgetContextMenuView, +} from "./WidgetContextMenuView"; +import * as stories from "./WidgetContextMenuView.stories.tsx"; +import { MockViewModel } from "../../viewmodel/MockViewModel.ts"; + +const { Default, OnlyBasicModification } = composeStories(stories); + +describe("", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it("renders widget contextmenu with all options", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders widget contextmenu without only basic modification", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + const onKeyDown = jest.fn(); + const togglePlay = jest.fn(); + const onSeekbarChange = jest.fn(); + + const onStreamAudioClick = jest.fn(); + const onEditClick = jest.fn(); + const onSnapshotClick = jest.fn(); + const onDeleteClick = jest.fn(); + const onRevokeClick = jest.fn(); + const onFinished = jest.fn(); + const onMoveButton = jest.fn(); + class WidgetContextMenuViewModel + extends MockViewModel + implements WidgetContextMenuAction + { + public onKeyDown = onKeyDown; + public togglePlay = togglePlay; + public onSeekbarChange = onSeekbarChange; + + public onStreamAudioClick = onStreamAudioClick; + public onEditClick = onEditClick; + public onSnapshotClick = onSnapshotClick; + public onDeleteClick = onDeleteClick; + public onRevokeClick = onRevokeClick; + public onFinished = onFinished; + public onMoveButton = onMoveButton; + } + + const defaultValue: WidgetContextMenuSnapshot = { + showStreamAudioStreamButton: true, + showEditButton: true, + showRevokeButton: true, + showDeleteButton: true, + showSnapshotButton: true, + showMoveButtons: [true, true], + canModify: true, + isMenuOpened: true, + trigger: ( + + + + ), + }; + + it("should attach vm methods", async () => { + const vm = new WidgetContextMenuViewModel(defaultValue); + + render(); + + await userEvent.click(screen.getByRole("menuitem", { name: "Start audio stream" })); + expect(onStreamAudioClick).toHaveBeenCalled(); + + await userEvent.click(screen.getByRole("menuitem", { name: "Edit" })); + expect(onEditClick).toHaveBeenCalled(); + + await userEvent.click(screen.getByRole("menuitem", { name: "Take a picture" })); + expect(onSnapshotClick).toHaveBeenCalled(); + + await userEvent.click(screen.getByRole("menuitem", { name: "Revoke permissions" })); + expect(onRevokeClick).toHaveBeenCalled(); + + await userEvent.click(screen.getByRole("menuitem", { name: "Remove for everyone" })); + expect(onDeleteClick).toHaveBeenCalled(); + + await userEvent.click(screen.getByRole("menuitem", { name: "Move left" })); + expect(onMoveButton).toHaveBeenCalledWith(-1); + + await userEvent.click(screen.getByRole("menuitem", { name: "Move right" })); + expect(onMoveButton).toHaveBeenCalledWith(1); + }); +}); diff --git a/packages/shared-components/src/right-panel/WidgetContextMenu/WidgetContextMenuView.tsx b/packages/shared-components/src/right-panel/WidgetContextMenu/WidgetContextMenuView.tsx new file mode 100644 index 00000000000..9c45ce484dc --- /dev/null +++ b/packages/shared-components/src/right-panel/WidgetContextMenu/WidgetContextMenuView.tsx @@ -0,0 +1,168 @@ +/* + * 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, { type ReactNode, type JSX } from "react"; +import { Menu, MenuItem } from "@vector-im/compound-web"; + +import { _t } from "../../utils/i18n.tsx"; +import { type ViewModel } from "../../viewmodel/ViewModel.ts"; +import { useViewModel } from "../../useViewModel.ts"; + +export interface WidgetContextMenuSnapshot { + /** + * Indicates if the audio stream button needs to be shown or not + * depending on the config value audio_stream_url and widget type jitsi + */ + showStreamAudioStreamButton: boolean; + /** + * Indicates if the edit button is shown depending the user permission to modify + */ + showEditButton: boolean; + /** + * Indicates if revoke widget button needs to be shown or not + */ + showRevokeButton: boolean; + /** + * Indicates if delete widget button needs to be shown or not + */ + showDeleteButton: boolean; + /** + * Show take screenshot button or not dependning on config value enableWidgetScreenshots + */ + showSnapshotButton: boolean; + /** + * show move widget position button + */ + showMoveButtons: [boolean, boolean]; + /** + * Indicates if user can modify the widget settings + */ + canModify: boolean; + /** + * Indicates if the widget menu is opened or not + */ + isMenuOpened: boolean; + /** + * A component that is displayed which trigger the menu to open or close + */ + trigger: ReactNode; +} + +export interface WidgetContextMenuAction { + /** + * Function triggered when stream audio is clicked + */ + onStreamAudioClick: () => Promise; + /** + * Function triggered when edit button is clicked + */ + onEditClick: () => void; + /** + * Function triggered when snapshot button is clicked + */ + onSnapshotClick: () => void; + /** + * Function triggered when delete button is clicked + */ + onDeleteClick: () => void; + /** + * Function triggered when revoke button is clicked + */ + onRevokeClick: () => void; + /** + * Called when the action is finished, to close the menu + */ + onFinished: () => void; + /** + * Button used to move up or down in the list the widget position + * @param direction 1 or -1 + */ + onMoveButton: (direction: number) => void; +} + +export type WidgetContextMenuViewModel = ViewModel & WidgetContextMenuAction; + +interface WidgetContextMenuViewProps { + vm: WidgetContextMenuViewModel; +} + +export const WidgetContextMenuView: React.FC = ({ vm }) => { + const { + showStreamAudioStreamButton, + showEditButton, + showSnapshotButton, + showDeleteButton, + showRevokeButton, + showMoveButtons, + isMenuOpened, + trigger, + } = useViewModel(vm); + + let streamAudioStreamButton: JSX.Element | undefined; + if (showStreamAudioStreamButton) { + streamAudioStreamButton = ( + + ); + } + + let editButton: JSX.Element | undefined; + if (showEditButton) { + editButton = ; + } + + let snapshotButton: JSX.Element | undefined; + if (showSnapshotButton) { + snapshotButton = ; + } + + let deleteButton: JSX.Element | undefined; + if (showDeleteButton) { + deleteButton = ( + + ); + } + + let revokeButton: JSX.Element | undefined; + if (showRevokeButton) { + revokeButton = ; + } + + const [showMoveLeftButton, showMoveRightButton] = showMoveButtons; + let moveLeftButton: JSX.Element | undefined; + if (showMoveLeftButton) { + moveLeftButton = vm.onMoveButton(-1)} label={_t("widget|context_menu|move_left")} />; + } + + let moveRightButton: JSX.Element | undefined; + if (showMoveRightButton) { + moveRightButton = vm.onMoveButton(1)} label={_t("widget|context_menu|move_right")} />; + } + + return ( + + {streamAudioStreamButton} + {editButton} + {revokeButton} + {deleteButton} + {snapshotButton} + {moveLeftButton} + {moveRightButton} + + ); +}; diff --git a/packages/shared-components/src/right-panel/WidgetContextMenu/__snapshots__/WidgetContextMenuView.test.tsx.snap b/packages/shared-components/src/right-panel/WidgetContextMenu/__snapshots__/WidgetContextMenuView.test.tsx.snap new file mode 100644 index 00000000000..f591665e7db --- /dev/null +++ b/packages/shared-components/src/right-panel/WidgetContextMenu/__snapshots__/WidgetContextMenuView.test.tsx.snap @@ -0,0 +1,79 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[` renders widget contextmenu with all options 1`] = ` + +`; + +exports[` renders widget contextmenu without only basic modification 1`] = ` + +`; diff --git a/packages/shared-components/src/right-panel/WidgetContextMenu/index.ts b/packages/shared-components/src/right-panel/WidgetContextMenu/index.ts new file mode 100644 index 00000000000..fadbd317e53 --- /dev/null +++ b/packages/shared-components/src/right-panel/WidgetContextMenu/index.ts @@ -0,0 +1,9 @@ +/* + * 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 type { WidgetContextMenuSnapshot, WidgetContextMenuViewModel } from "./WidgetContextMenuView"; +export { WidgetContextMenuView } from "./WidgetContextMenuView"; diff --git a/src/components/views/context_menus/WidgetContextMenu.tsx b/src/components/views/context_menus/WidgetContextMenu.tsx index ff74e25d38f..4ba300370f2 100644 --- a/src/components/views/context_menus/WidgetContextMenu.tsx +++ b/src/components/views/context_menus/WidgetContextMenu.tsx @@ -84,26 +84,6 @@ const showMoveButtons = (app: IWidget, room: Room | undefined, showUnpin: boolea return [widgetIndex > 0, widgetIndex < pinnedWidgets.length - 1]; }; -export const showContextMenu = ( - cli: MatrixClient, - room: Room | undefined, - app: IWidget, - userWidget: boolean, - showUnpin: boolean, - onDeleteClick: (() => void) | undefined, -): boolean => { - const canModify = userWidget || WidgetUtils.canUserModifyWidgets(cli, room?.roomId); - const widgetMessaging = WidgetMessagingStore.instance.getMessagingForUid(WidgetUtils.getWidgetUid(app)); - return ( - showStreamAudioStreamButton(app) || - showEditButton(app, canModify) || - showRevokeButton(cli, room?.roomId, app, userWidget) || - showDeleteButton(canModify, onDeleteClick) || - showSnapshotButton(widgetMessaging) || - showMoveButtons(app, room, showUnpin).some(Boolean) - ); -}; - export const WidgetContextMenu: React.FC = ({ onFinished, app, diff --git a/src/components/views/elements/AppTile.tsx b/src/components/views/elements/AppTile.tsx index c56a23e43dd..8ffa8023afe 100644 --- a/src/components/views/elements/AppTile.tsx +++ b/src/components/views/elements/AppTile.tsx @@ -39,11 +39,10 @@ import Spinner from "./Spinner"; import dis from "../../../dispatcher/dispatcher"; import ActiveWidgetStore from "../../../stores/ActiveWidgetStore"; import SettingsStore from "../../../settings/SettingsStore"; -import { aboveLeftOf, ContextMenuButton } from "../../structures/ContextMenu"; +import { ContextMenuButton } from "../../structures/ContextMenu"; import PersistedElement, { getPersistKey } from "./PersistedElement"; import { WidgetType } from "../../../widgets/WidgetType"; import { ElementWidget, WidgetMessaging, WidgetMessagingEvent } from "../../../stores/widgets/WidgetMessaging"; -import { showContextMenu, WidgetContextMenu } from "../context_menus/WidgetContextMenu"; import WidgetAvatar from "../avatars/WidgetAvatar"; import LegacyCallHandler from "../../../LegacyCallHandler"; import { type IApp, isAppWidget } from "../../../stores/WidgetStore"; @@ -61,6 +60,7 @@ import { ModuleRunner } from "../../../modules/ModuleRunner"; import { parseUrl } from "../../../utils/UrlUtils"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore.ts"; import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases.ts"; +import { WidgetContextMenu } from "../../../viewmodels/right-panel/WidgetContextMenuViewModel.tsx"; // Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin // because that would allow the iframe to programmatically remove the sandbox attribute, but @@ -132,7 +132,6 @@ interface IState { error: Error | null; menuDisplayed: boolean; requiresClient: boolean; - hasContextMenuOptions: boolean; } export default class AppTile extends React.Component { @@ -276,14 +275,6 @@ export default class AppTile extends React.Component { error: null, menuDisplayed: false, requiresClient: this.determineInitialRequiresClientState(), - hasContextMenuOptions: showContextMenu( - this.context, - this.props.room, - newProps.app, - newProps.userWidget, - !newProps.userWidget, - newProps.onDeleteClick, - ), }; } @@ -768,21 +759,6 @@ export default class AppTile extends React.Component { } appTileClasses = classNames(appTileClasses); - let contextMenu; - if (this.state.menuDisplayed) { - contextMenu = ( - - ); - } - const layoutButtons: ReactNode[] = []; if (this.props.showLayoutButtons) { const isMaximised = @@ -838,24 +814,31 @@ export default class AppTile extends React.Component { )} - {this.state.hasContextMenuOptions && ( - - - - )} + + + + } + app={this.props.app} + onFinished={this.closeContextMenu} + showUnpin={!this.props.userWidget} + userWidget={this.props.userWidget} + onEditClick={this.props.onEditClick} + onDeleteClick={this.props.onDeleteClick} + menuDisplayed={this.state.menuDisplayed} + /> )} {appTileBody} - - {contextMenu} ); } diff --git a/src/components/views/right_panel/ExtensionsCard.tsx b/src/components/views/right_panel/ExtensionsCard.tsx index 448917290a8..9b3cd938ed3 100644 --- a/src/components/views/right_panel/ExtensionsCard.tsx +++ b/src/components/views/right_panel/ExtensionsCard.tsx @@ -16,9 +16,7 @@ import ExtensionsIcon from "@vector-im/compound-design-tokens/assets/web/icons/e import BaseCard from "./BaseCard"; import WidgetUtils, { useWidgets } from "../../../utils/WidgetUtils"; import { _t } from "../../../languageHandler"; -import { ChevronFace, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu"; -import { WidgetContextMenu } from "../context_menus/WidgetContextMenu"; -import UIStore from "../../../stores/UIStore"; +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"; @@ -29,6 +27,7 @@ import { IntegrationManagers } from "../../../integrations/IntegrationManagers"; import EmptyState from "./EmptyState"; import { shouldShowComponent } from "../../../customisations/helpers/UIComponents.ts"; import { UIComponent } from "../../../settings/UIFeature.ts"; +import { WidgetContextMenu } from "../../../viewmodels/right-panel/WidgetContextMenuViewModel.tsx"; interface Props { room: Room; @@ -65,21 +64,6 @@ const AppRow: React.FC = ({ app, room }) => { }; const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); - let contextMenu; - if (menuDisplayed) { - const rect = handle.current?.getBoundingClientRect(); - const rightMargin = rect?.right ?? 0; - const topMargin = rect?.top ?? 0; - contextMenu = ( - - ); - } const cannotPin = !isPinned && !WidgetLayoutStore.instance.canAddToContainer(room, Container.Top); @@ -104,7 +88,7 @@ const AppRow: React.FC = ({ app, room }) => { }); return ( -
+
= ({ 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" />