diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 68935afd3fc..c8e15a941fb 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -19,6 +19,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.tsx b/packages/shared-components/src/right-panel/WidgetContextMenu/WidgetContextMenuView.tsx new file mode 100644 index 00000000000..c7ccf091456 --- /dev/null +++ b/packages/shared-components/src/right-panel/WidgetContextMenu/WidgetContextMenuView.tsx @@ -0,0 +1,128 @@ +/* + * 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 { type ClientWidgetApi } from "matrix-widget-api"; +import { IconButton, 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 { + showStreamAudioStreamButton: boolean; + showEditButton: boolean; + showRevokeButton: boolean; + showDeleteButton: boolean; + showSnapshotButton: boolean; + showMoveButtons: [boolean, boolean]; + canModify: boolean; + widgetMessaging: ClientWidgetApi | undefined; + isMenuOpened: boolean; +} + +export interface WidgetContextMenuAction { + onStreamAudioClick: () => Promise; + onEditClick: () => void; + onSnapshotClick: () => void; + onDeleteClick: () => void; + onRevokeClick: () => void; + onFinished: () => void; + 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 + }= 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 ( + + } + onOpenChange={vm.onFinished} + > + {streamAudioStreamButton} + {editButton} + {revokeButton} + {deleteButton} + {snapshotButton} + {moveLeftButton} + {moveRightButton} + + ); +}; 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..648083e3b59 --- /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"; \ No newline at end of file diff --git a/src/viewmodels/right-panel/WidgetContextMenuViewModel.tsx b/src/viewmodels/right-panel/WidgetContextMenuViewModel.tsx new file mode 100644 index 00000000000..a99b695a933 --- /dev/null +++ b/src/viewmodels/right-panel/WidgetContextMenuViewModel.tsx @@ -0,0 +1,268 @@ +/* + * 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 { logger } from "@sentry/browser"; +import { type Room, type MatrixClient } from "matrix-js-sdk/src/matrix"; +import { type ClientWidgetApi, type IWidget, MatrixCapabilities } from "matrix-widget-api"; +import React, { useContext, useMemo, useEffect, type ReactElement } from "react"; +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 } from "../../stores/widgets/StopGapWidget"; +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 _onFinished: (() => void) | undefined; + private _onDeleteClick: (() => void) | undefined; + private _onEditClick: (() => void) | undefined; + + private _app: IWidget; + private _roomId: string | undefined; + private _room: Room | undefined; + private _cli: MatrixClient; + private _widgetMessaging: ClientWidgetApi | undefined; + + public constructor(props: WidgetContextMenuViewModelProps) { + const { app, cli, room, roomId, userWidget, showUnpin, menuDisplayed, onFinished, onDeleteClick, onEditClick } = + props; + super( + props, + WidgetContextMenuViewModel.computeSnapshot( + app, + cli, + room, + userWidget, + showUnpin, + menuDisplayed, + onDeleteClick, + ), + ); + this._app = app; + this._roomId = roomId; + this._room = room; + this._cli = cli; + this._onFinished = onFinished; + this._onDeleteClick = onDeleteClick; + this._onEditClick = onEditClick; + 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, + 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?.hasCapability(MatrixCapabilities.Screenshots); + + let showMoveButtons = [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, + widgetMessaging, + isMenuOpened: menuDisplayed, + }; + }; + + public onFinished(): (() => void) | undefined { + return this._onFinished; + } + + public onRevokeClick = (): void => { + 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._onFinished!(); + }; + + public onDeleteClick = (): void => { + if (this._onDeleteClick) { + this._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._onFinished!(); + }; + + public onSnapshotClick = (): void => { + this._widgetMessaging + ?.takeScreenshot() + .then((data) => { + dis.dispatch({ + action: "picture_snapshot", + file: data.screenshot, + }); + }) + .catch((err) => { + logger.error("Failed to take screenshot: ", err); + }); + this._onFinished!(); + }; + + public onStreamAudioClick = async (): Promise => { + try { + if (this._roomId) { + await startJitsiAudioLivestream(this._cli, this._widgetMessaging!, this._roomId!); + } + } catch (err) { + 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._onFinished!(); + }; + + public onEditClick = (): void => { + if (this._onEditClick) { + this._onEditClick(); + } else if (this._room) { + WidgetUtils.editWidget(this._room, this._app); + } + this._onFinished!(); + }; +} + +interface WidgetContextMenuProps { + app: IWidget; + userWidget?: boolean; + showUnpin?: boolean; + menuDisplayed: boolean; + // override delete handler + onDeleteClick?(): void; + // override edit handler + onEditClick?(): void; + onFinished?(): void; +} + +type WidgetContextMenuViewModelProps = WidgetContextMenuProps & { + cli: MatrixClient; + room: Room | undefined; + roomId: string | undefined; +}; + +export function WidgetContextMenu(props: WidgetContextMenuProps): ReactElement { + const { app, userWidget, showUnpin, menuDisplayed, 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, + onEditClick, + onDeleteClick, + onFinished, + }), + [app, room, roomId, userWidget, showUnpin, menuDisplayed, cli, onEditClick, onDeleteClick, onFinished], + ); + + useEffect(() => { + return () => { + vm.dispose(); + }; + }, [vm]); + + return ; +}