Skip to content

Commit 8b05a82

Browse files
committed
Create WidgetContextMenu component in shared-components
1 parent bb582fa commit 8b05a82

File tree

8 files changed

+551
-29
lines changed

8 files changed

+551
-29
lines changed

packages/shared-components/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export * from "./rich-list/RichItem";
1919
export * from "./rich-list/RichList";
2020
export * from "./utils/Box";
2121
export * from "./utils/Flex";
22+
export * from "./right-panel/WidgetContextMenu";
2223

2324
// Utils
2425
export * from "./utils/i18n";
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
Copyright 2024 New Vector Ltd.
3+
Copyright 2023 Mikhail Aheichyk
4+
Copyright 2023 Nordeck IT + Consulting GmbH.
5+
6+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
7+
Please see LICENSE files in the repository root for full details.
8+
*/
9+
10+
import React, { type JSX, type ComponentProps } from "react";
11+
import { screen, render } from "jest-matrix-react";
12+
import userEvent from "@testing-library/user-event";
13+
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
14+
import { MatrixWidgetType } from "matrix-widget-api";
15+
import {
16+
type ApprovalOpts,
17+
type WidgetInfo,
18+
WidgetLifecycle,
19+
} from "@matrix-org/react-sdk-module-api/lib/lifecycles/WidgetLifecycle";
20+
21+
import { WidgetContextMenu } from "../../../../../src/components/views/context_menus/WidgetContextMenu";
22+
import { type IApp } from "../../../../../src/stores/WidgetStore";
23+
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
24+
import WidgetUtils from "../../../../../src/utils/WidgetUtils";
25+
import { ModuleRunner } from "../../../../../src/modules/ModuleRunner";
26+
import SettingsStore from "../../../../../src/settings/SettingsStore";
27+
28+
describe("<WidgetContextMenu />", () => {
29+
const widgetId = "w1";
30+
const eventId = "e1";
31+
const roomId = "r1";
32+
const userId = "@user-id:server";
33+
34+
const app: IApp = {
35+
id: widgetId,
36+
eventId,
37+
roomId,
38+
type: MatrixWidgetType.Custom,
39+
url: "https://example.com",
40+
name: "Example 1",
41+
creatorUserId: userId,
42+
avatar_url: undefined,
43+
};
44+
45+
let mockClient: MatrixClient;
46+
47+
let onFinished: () => void;
48+
49+
beforeEach(() => {
50+
onFinished = jest.fn();
51+
jest.spyOn(WidgetUtils, "canUserModifyWidgets").mockReturnValue(true);
52+
53+
mockClient = {
54+
getUserId: jest.fn().mockReturnValue(userId),
55+
} as unknown as MatrixClient;
56+
});
57+
58+
afterEach(() => {
59+
jest.restoreAllMocks();
60+
});
61+
62+
function getComponent(props: Partial<ComponentProps<typeof WidgetContextMenu>> = {}): JSX.Element {
63+
return (
64+
<MatrixClientContext.Provider value={mockClient}>
65+
<WidgetContextMenu app={app} onFinished={onFinished} {...props} />
66+
</MatrixClientContext.Provider>
67+
);
68+
}
69+
70+
it("renders revoke button", async () => {
71+
const { rerender } = render(getComponent());
72+
73+
const revokeButton = screen.getByLabelText("Revoke permissions");
74+
expect(revokeButton).toBeInTheDocument();
75+
76+
jest.spyOn(ModuleRunner.instance, "invoke").mockImplementation((lifecycleEvent, opts, widgetInfo) => {
77+
if (lifecycleEvent === WidgetLifecycle.PreLoadRequest && (widgetInfo as WidgetInfo).id === widgetId) {
78+
(opts as ApprovalOpts).approved = true;
79+
}
80+
});
81+
82+
rerender(getComponent());
83+
expect(revokeButton).not.toBeInTheDocument();
84+
});
85+
86+
it("revokes permissions", async () => {
87+
render(getComponent());
88+
await userEvent.click(screen.getByLabelText("Revoke permissions"));
89+
expect(onFinished).toHaveBeenCalled();
90+
expect(SettingsStore.getValue("allowedWidgets", roomId)[eventId]).toBe(false);
91+
});
92+
});
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import React, { ReactNode, type JSX } from "react";
9+
import { type ClientWidgetApi } from "matrix-widget-api";
10+
import { Menu, MenuItem } from "@vector-im/compound-web";
11+
12+
import { _t } from "../../utils/i18n.tsx";
13+
import { type ViewModel } from "../../viewmodel/ViewModel.ts";
14+
import { useViewModel } from "../../useViewModel.ts";
15+
16+
export interface WidgetContextMenuSnapshot {
17+
showStreamAudioStreamButton: boolean;
18+
showEditButton: boolean;
19+
showRevokeButton: boolean;
20+
showDeleteButton: boolean;
21+
showSnapshotButton: boolean;
22+
showMoveButtons: [boolean, boolean];
23+
canModify: boolean;
24+
widgetMessaging: ClientWidgetApi | undefined;
25+
isMenuOpened: boolean;
26+
trigger: ReactNode;
27+
}
28+
29+
export interface WidgetContextMenuAction {
30+
onStreamAudioClick: () => Promise<void>;
31+
onEditClick: () => void;
32+
onSnapshotClick: () => void;
33+
onDeleteClick: () => void;
34+
onRevokeClick: () => void;
35+
onFinished: () => void;
36+
onMoveButton: (direction: number) => void;
37+
}
38+
39+
export type WidgetContextMenuViewModel = ViewModel<WidgetContextMenuSnapshot> & WidgetContextMenuAction;
40+
41+
interface WidgetContextMenuViewProps {
42+
vm: WidgetContextMenuViewModel;
43+
}
44+
45+
export const WidgetContextMenuView: React.FC<WidgetContextMenuViewProps> = ({
46+
vm
47+
}) => {
48+
49+
const {
50+
showStreamAudioStreamButton,
51+
showEditButton,
52+
showSnapshotButton,
53+
showDeleteButton,
54+
showRevokeButton,
55+
showMoveButtons,
56+
isMenuOpened,
57+
trigger,
58+
}= useViewModel(vm);
59+
60+
let streamAudioStreamButton: JSX.Element | undefined;
61+
if (showStreamAudioStreamButton) {
62+
streamAudioStreamButton = (
63+
<MenuItem
64+
onSelect={vm.onStreamAudioClick}
65+
label={_t("widget|context_menu|start_audio_stream")}
66+
/>
67+
);
68+
}
69+
70+
let editButton: JSX.Element | undefined;
71+
if (showEditButton) {
72+
editButton = <MenuItem onSelect={vm.onEditClick} label={_t("action|edit")} />;
73+
}
74+
75+
let snapshotButton: JSX.Element | undefined;
76+
if (showSnapshotButton) {
77+
snapshotButton = (
78+
<MenuItem onSelect={vm.onSnapshotClick} label={_t("widget|context_menu|screenshot")} />
79+
);
80+
}
81+
82+
let deleteButton: JSX.Element | undefined;
83+
if (showDeleteButton) {
84+
deleteButton = (
85+
<MenuItem
86+
onSelect={vm.onDeleteClick}
87+
// TODO label={userWidget ? _t("action|remove") : _t("widget|context_menu|remove")}
88+
label={_t("widget|context_menu|remove")}
89+
/>
90+
);
91+
}
92+
93+
let revokeButton: JSX.Element | undefined;
94+
if (showRevokeButton) {
95+
revokeButton = (
96+
<MenuItem onSelect={vm.onRevokeClick} label={_t("widget|context_menu|revoke")} />
97+
);
98+
}
99+
100+
const [showMoveLeftButton, showMoveRightButton] = showMoveButtons;
101+
let moveLeftButton: JSX.Element | undefined;
102+
if (showMoveLeftButton) {
103+
moveLeftButton = <MenuItem onSelect={() => vm.onMoveButton(-1)} label={_t("widget|context_menu|move_left")} />;
104+
}
105+
106+
let moveRightButton: JSX.Element | undefined;
107+
if (showMoveRightButton) {
108+
moveRightButton = <MenuItem onSelect={() => vm.onMoveButton(1)} label={_t("widget|context_menu|move_right")} />;
109+
}
110+
111+
return (
112+
<Menu
113+
title="Widget context menu"
114+
open={isMenuOpened}
115+
showTitle={false}
116+
side="right"
117+
align="start"
118+
trigger={trigger}
119+
onOpenChange={vm.onFinished}
120+
>
121+
{streamAudioStreamButton}
122+
{editButton}
123+
{revokeButton}
124+
{deleteButton}
125+
{snapshotButton}
126+
{moveLeftButton}
127+
{moveRightButton}
128+
</Menu>
129+
);
130+
};
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
export type { WidgetContextMenuSnapshot, WidgetContextMenuViewModel } from "./WidgetContextMenuView";
9+
export { WidgetContextMenuView } from "./WidgetContextMenuView";

src/components/views/elements/AppTile.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ import { aboveLeftOf, ContextMenuButton } from "../../structures/ContextMenu";
4242
import PersistedElement, { getPersistKey } from "./PersistedElement";
4343
import { WidgetType } from "../../../widgets/WidgetType";
4444
import { ElementWidget, StopGapWidget } from "../../../stores/widgets/StopGapWidget";
45-
import { showContextMenu, WidgetContextMenu } from "../context_menus/WidgetContextMenu";
4645
import WidgetAvatar from "../avatars/WidgetAvatar";
4746
import LegacyCallHandler from "../../../LegacyCallHandler";
4847
import { type IApp, isAppWidget } from "../../../stores/WidgetStore";
@@ -61,6 +60,7 @@ import { ModuleRunner } from "../../../modules/ModuleRunner";
6160
import { parseUrl } from "../../../utils/UrlUtils";
6261
import RightPanelStore from "../../../stores/right-panel/RightPanelStore.ts";
6362
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases.ts";
63+
import { WidgetContextMenu } from "../../../viewmodels/right-panel/WidgetContextMenuViewModel.tsx";
6464

6565
// Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin
6666
// because that would allow the iframe to programmatically remove the sandbox attribute, but
@@ -749,6 +749,15 @@ export default class AppTile extends React.Component<IProps, IState> {
749749
let contextMenu;
750750
if (this.state.menuDisplayed) {
751751
contextMenu = (
752+
// <WidgetContextMenu
753+
// {...aboveLeftOf(this.contextMenuButton.current.getBoundingClientRect())}
754+
// app={this.props.app}
755+
// onFinished={this.closeContextMenu}
756+
// showUnpin={!this.props.userWidget}
757+
// userWidget={this.props.userWidget}
758+
// onEditClick={this.props.onEditClick}
759+
// onDeleteClick={this.props.onDeleteClick}
760+
// />
752761
<WidgetContextMenu
753762
{...aboveLeftOf(this.contextMenuButton.current.getBoundingClientRect())}
754763
app={this.props.app}
@@ -757,6 +766,7 @@ export default class AppTile extends React.Component<IProps, IState> {
757766
userWidget={this.props.userWidget}
758767
onEditClick={this.props.onEditClick}
759768
onDeleteClick={this.props.onDeleteClick}
769+
menuDisplayed={this.state.menuDisplayed}
760770
/>
761771
);
762772
}

src/components/views/right_panel/ExtensionsCard.tsx

Lines changed: 16 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
66
Please see LICENSE files in the repository root for full details.
77
*/
88

9-
import React, { type JSX, useEffect, useMemo, useState } from "react";
9+
import React, { type JSX, useEffect, useMemo, useRef, useState } from "react";
1010
import { type Room } from "matrix-js-sdk/src/matrix";
1111
import classNames from "classnames";
1212
import { Button, Link, Separator, Text } from "@vector-im/compound-web";
@@ -17,7 +17,6 @@ import BaseCard from "./BaseCard";
1717
import WidgetUtils, { useWidgets } from "../../../utils/WidgetUtils";
1818
import { _t } from "../../../languageHandler";
1919
import { ChevronFace, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu";
20-
import { WidgetContextMenu } from "../context_menus/WidgetContextMenu";
2120
import UIStore from "../../../stores/UIStore";
2221
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
2322
import { type IApp } from "../../../stores/WidgetStore";
@@ -29,6 +28,7 @@ import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
2928
import EmptyState from "./EmptyState";
3029
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents.ts";
3130
import { UIComponent } from "../../../settings/UIFeature.ts";
31+
import { WidgetContextMenu } from "../../../viewmodels/right-panel/WidgetContextMenuViewModel.tsx";
3232

3333
interface Props {
3434
room: Room;
@@ -65,21 +65,7 @@ const AppRow: React.FC<IAppRowProps> = ({ app, room }) => {
6565
};
6666

6767
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLDivElement>();
68-
let contextMenu;
69-
if (menuDisplayed) {
70-
const rect = handle.current?.getBoundingClientRect();
71-
const rightMargin = rect?.right ?? 0;
72-
const topMargin = rect?.top ?? 0;
73-
contextMenu = (
74-
<WidgetContextMenu
75-
chevronFace={ChevronFace.None}
76-
right={UIStore.instance.windowWidth - rightMargin}
77-
bottom={UIStore.instance.windowHeight - topMargin}
78-
onFinished={closeMenu}
79-
app={app}
80-
/>
81-
);
82-
}
68+
8369

8470
const cannotPin = !isPinned && !WidgetLayoutStore.instance.canAddToContainer(room, Container.Top);
8571

@@ -104,7 +90,7 @@ const AppRow: React.FC<IAppRowProps> = ({ app, room }) => {
10490
});
10591

10692
return (
107-
<div className={classes} ref={handle}>
93+
<div className={classes}>
10894
<AccessibleButton
10995
className="mx_ExtensionsCard_icon_app"
11096
onClick={onOpenWidgetClick}
@@ -119,11 +105,17 @@ const AppRow: React.FC<IAppRowProps> = ({ app, room }) => {
119105
</AccessibleButton>
120106

121107
{canModifyWidget && (
122-
<ContextMenuTooltipButton
123-
className="mx_ExtensionsCard_app_options"
124-
isExpanded={menuDisplayed}
125-
onClick={openMenu}
126-
title={_t("common|options")}
108+
<WidgetContextMenu
109+
app={app}
110+
onFinished={closeMenu}
111+
menuDisplayed={menuDisplayed}
112+
trigger={
113+
<AccessibleButton
114+
className="mx_ExtensionsCard_app_options"
115+
onClick={openMenu}
116+
title={_t("common|options")}
117+
/>
118+
}
127119
/>
128120
)}
129121

@@ -134,7 +126,7 @@ const AppRow: React.FC<IAppRowProps> = ({ app, room }) => {
134126
disabled={cannotPin}
135127
/>
136128

137-
{contextMenu}
129+
{/* {contextMenu} */}
138130
</div>
139131
);
140132
};

0 commit comments

Comments
 (0)