MVVM WidgetContextMenu component to shared component (#31190)
* Create WidgetContextMenu component in shared-components * Modify WidgetMenuContext call (apptile, extensioncard, widgetcard), test and stories * Correctly use new widgetcontextmenu component * WidgetContextMenuViewModel unit test * Lint and add comments * Finalize widgetcontextmenuviewmodel test * fix lint errors * Fix test error * Update playwright screenshots * add userWidget in widgetcontexstmenu props * Fix some a11y issues on playwright * fix linter error widget card * Use new i18n way for share component widget context menu * Add i18n context provider for widget context menu * chore: lint and update snapshot widgetcontextmenu
This commit is contained in:
BIN
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 8.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -5,6 +5,7 @@
|
|||||||
"action": {
|
"action": {
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"dismiss": "Dismiss",
|
"dismiss": "Dismiss",
|
||||||
|
"edit": "Edit",
|
||||||
"explore_rooms": "Explore rooms",
|
"explore_rooms": "Explore rooms",
|
||||||
"invite": "Invite",
|
"invite": "Invite",
|
||||||
"new_conversation": "New conversation",
|
"new_conversation": "New conversation",
|
||||||
@@ -13,6 +14,7 @@
|
|||||||
"open_menu": "Open menu",
|
"open_menu": "Open menu",
|
||||||
"pause": "Pause",
|
"pause": "Pause",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
|
"remove": "Remove",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"start_chat": "Start chat"
|
"start_chat": "Start chat"
|
||||||
@@ -83,5 +85,15 @@
|
|||||||
"error_downloading_audio": "Error downloading audio",
|
"error_downloading_audio": "Error downloading audio",
|
||||||
"unnamed_audio": "Unnamed audio"
|
"unnamed_audio": "Unnamed audio"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"widget": {
|
||||||
|
"context_menu": {
|
||||||
|
"move_left": "Move left",
|
||||||
|
"move_right": "Move right",
|
||||||
|
"remove": "Remove for everyone",
|
||||||
|
"revoke": "Revoke permissions",
|
||||||
|
"screenshot": "Take a picture",
|
||||||
|
"start_audio_stream": "Start audio stream"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export * from "./room-list/RoomListHeaderView";
|
|||||||
export * from "./room-list/RoomListSearchView";
|
export * from "./room-list/RoomListSearchView";
|
||||||
export * from "./utils/Box";
|
export * from "./utils/Box";
|
||||||
export * from "./utils/Flex";
|
export * from "./utils/Flex";
|
||||||
|
export * from "./right-panel/WidgetContextMenu";
|
||||||
export * from "./utils/VirtualizedList";
|
export * from "./utils/VirtualizedList";
|
||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
|
|||||||
+84
@@ -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 {
|
||||||
|
type WidgetContextMenuAction,
|
||||||
|
type WidgetContextMenuSnapshot,
|
||||||
|
WidgetContextMenuView,
|
||||||
|
} from "./WidgetContextMenuView";
|
||||||
|
import { useMockedViewModel } from "../../viewmodel/useMockedViewModel";
|
||||||
|
|
||||||
|
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 <WidgetContextMenuView vm={vm} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
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: (
|
||||||
|
<IconButton size="24px" aria-label="context menu trigger button" inert={true} tabIndex={-1}>
|
||||||
|
<TriggerIcon />
|
||||||
|
</IconButton>
|
||||||
|
),
|
||||||
|
onStreamAudioClick: fn(),
|
||||||
|
onEditClick: fn(),
|
||||||
|
onSnapshotClick: fn(),
|
||||||
|
onDeleteClick: fn(),
|
||||||
|
onRevokeClick: fn(),
|
||||||
|
onFinished: fn(),
|
||||||
|
onMoveButton: fn(),
|
||||||
|
},
|
||||||
|
} as Meta<typeof WidgetContextMenuViewWrapper>;
|
||||||
|
|
||||||
|
const Template: StoryFn<typeof WidgetContextMenuViewWrapper> = (args) => <WidgetContextMenuViewWrapper {...args} />;
|
||||||
|
|
||||||
|
export const Default = Template.bind({});
|
||||||
|
|
||||||
|
export const OnlyBasicModification = Template.bind({});
|
||||||
|
OnlyBasicModification.args = {
|
||||||
|
showSnapshotButton: false,
|
||||||
|
showMoveButtons: [false, false],
|
||||||
|
showStreamAudioStreamButton: false,
|
||||||
|
showEditButton: false,
|
||||||
|
};
|
||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
/*
|
||||||
|
* 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 "@test-utils";
|
||||||
|
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 { describe, vi, expect, it, afterEach } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type WidgetContextMenuAction,
|
||||||
|
type WidgetContextMenuSnapshot,
|
||||||
|
WidgetContextMenuView,
|
||||||
|
} from "./WidgetContextMenuView";
|
||||||
|
import * as stories from "./WidgetContextMenuView.stories.tsx";
|
||||||
|
import { MockViewModel } from "../../viewmodel/MockViewModel.ts";
|
||||||
|
import { I18nApi } from "../../utils/I18nApi.ts";
|
||||||
|
import { I18nContext } from "../../utils/i18nContext.ts";
|
||||||
|
|
||||||
|
const { Default, OnlyBasicModification } = composeStories(stories);
|
||||||
|
|
||||||
|
describe("<WidgetContextMenuView />", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders widget contextmenu with all options", () => {
|
||||||
|
const { container } = render(<Default />);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders widget contextmenu without only basic modification", () => {
|
||||||
|
const { container } = render(<OnlyBasicModification />);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
const onKeyDown = vi.fn();
|
||||||
|
const togglePlay = vi.fn();
|
||||||
|
const onSeekbarChange = vi.fn();
|
||||||
|
|
||||||
|
const onStreamAudioClick = vi.fn();
|
||||||
|
const onEditClick = vi.fn();
|
||||||
|
const onSnapshotClick = vi.fn();
|
||||||
|
const onDeleteClick = vi.fn();
|
||||||
|
const onRevokeClick = vi.fn();
|
||||||
|
const onFinished = vi.fn();
|
||||||
|
const onMoveButton = vi.fn();
|
||||||
|
class WidgetContextMenuViewModel
|
||||||
|
extends MockViewModel<WidgetContextMenuSnapshot>
|
||||||
|
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,
|
||||||
|
userWidget: false,
|
||||||
|
trigger: (
|
||||||
|
<IconButton size="24px" aria-label="context menu trigger button">
|
||||||
|
<TriggerIcon />
|
||||||
|
</IconButton>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should attach vm methods", async () => {
|
||||||
|
const vm = new WidgetContextMenuViewModel(defaultValue);
|
||||||
|
|
||||||
|
render(<WidgetContextMenuView vm={vm} />, {
|
||||||
|
wrapper: ({ children }) => <I18nContext.Provider value={new I18nApi()}>{children}</I18nContext.Provider>,
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+197
@@ -0,0 +1,197 @@
|
|||||||
|
/*
|
||||||
|
* 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 { IconButton, Menu, MenuItem } from "@vector-im/compound-web";
|
||||||
|
import TriggerIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal";
|
||||||
|
|
||||||
|
import { type ViewModel } from "../../viewmodel/ViewModel.ts";
|
||||||
|
import { useI18n } from "../../utils/i18nContext.ts";
|
||||||
|
import { useViewModel } from "../../viewmodel/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;
|
||||||
|
/**
|
||||||
|
* If it's an instance of a user widget
|
||||||
|
*/
|
||||||
|
userWidget: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WidgetContextMenuAction {
|
||||||
|
/**
|
||||||
|
* Function triggered when stream audio is clicked
|
||||||
|
*/
|
||||||
|
onStreamAudioClick: () => Promise<void>;
|
||||||
|
/**
|
||||||
|
* 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<WidgetContextMenuSnapshot> & WidgetContextMenuAction;
|
||||||
|
|
||||||
|
interface WidgetContextMenuViewProps {
|
||||||
|
vm: WidgetContextMenuViewModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A context menu component used to display the correct items that needs to be displayed for a widget item menu
|
||||||
|
*/
|
||||||
|
export const WidgetContextMenuView: React.FC<WidgetContextMenuViewProps> = ({ vm }) => {
|
||||||
|
const { translate: _t } = useI18n();
|
||||||
|
|
||||||
|
const {
|
||||||
|
showStreamAudioStreamButton,
|
||||||
|
showEditButton,
|
||||||
|
showSnapshotButton,
|
||||||
|
showDeleteButton,
|
||||||
|
showRevokeButton,
|
||||||
|
showMoveButtons,
|
||||||
|
isMenuOpened,
|
||||||
|
userWidget,
|
||||||
|
trigger,
|
||||||
|
} = useViewModel(vm);
|
||||||
|
|
||||||
|
let streamAudioStreamButton: JSX.Element | undefined;
|
||||||
|
if (showStreamAudioStreamButton) {
|
||||||
|
streamAudioStreamButton = (
|
||||||
|
<MenuItem onSelect={vm.onStreamAudioClick} label={_t("widget|context_menu|start_audio_stream")} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let editButton: JSX.Element | undefined;
|
||||||
|
if (showEditButton) {
|
||||||
|
editButton = <MenuItem onSelect={vm.onEditClick} label={_t("action|edit")} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshotButton: JSX.Element | undefined;
|
||||||
|
if (showSnapshotButton) {
|
||||||
|
snapshotButton = <MenuItem onSelect={vm.onSnapshotClick} label={_t("widget|context_menu|screenshot")} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
let deleteButton: JSX.Element | undefined;
|
||||||
|
if (showDeleteButton) {
|
||||||
|
deleteButton = (
|
||||||
|
<MenuItem
|
||||||
|
onSelect={vm.onDeleteClick}
|
||||||
|
label={userWidget ? _t("action|remove") : _t("widget|context_menu|remove")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let revokeButton: JSX.Element | undefined;
|
||||||
|
if (showRevokeButton) {
|
||||||
|
revokeButton = <MenuItem onSelect={vm.onRevokeClick} label={_t("widget|context_menu|revoke")} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [showMoveLeftButton, showMoveRightButton] = showMoveButtons;
|
||||||
|
let moveLeftButton: JSX.Element | undefined;
|
||||||
|
if (showMoveLeftButton) {
|
||||||
|
moveLeftButton = <MenuItem onSelect={() => vm.onMoveButton(-1)} label={_t("widget|context_menu|move_left")} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
let moveRightButton: JSX.Element | undefined;
|
||||||
|
if (showMoveRightButton) {
|
||||||
|
moveRightButton = <MenuItem onSelect={() => vm.onMoveButton(1)} label={_t("widget|context_menu|move_right")} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only render menu items when the menu is open to prevent focusable elements in aria-hidden container
|
||||||
|
const renderMenuItems = (): React.ReactNode => {
|
||||||
|
if (!isMenuOpened) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{streamAudioStreamButton}
|
||||||
|
{editButton}
|
||||||
|
{revokeButton}
|
||||||
|
{deleteButton}
|
||||||
|
{snapshotButton}
|
||||||
|
{moveLeftButton}
|
||||||
|
{moveRightButton}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default trigger icon if no valid trigger element was passed
|
||||||
|
const wrappedTrigger = React.isValidElement(trigger) ? (
|
||||||
|
trigger
|
||||||
|
) : (
|
||||||
|
<IconButton size="24px" aria-label="context menu trigger button" inert={true} tabIndex={-1}>
|
||||||
|
<TriggerIcon />
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu
|
||||||
|
title="Widget context menu"
|
||||||
|
open={isMenuOpened}
|
||||||
|
showTitle={false}
|
||||||
|
side="right"
|
||||||
|
align="start"
|
||||||
|
trigger={wrappedTrigger}
|
||||||
|
onOpenChange={vm.onFinished}
|
||||||
|
>
|
||||||
|
{renderMenuItems()}
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`<WidgetContextMenuView /> > renders widget contextmenu with all options 1`] = `
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
data-aria-hidden="true"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-controls="radix-_r_1_"
|
||||||
|
aria-disabled="false"
|
||||||
|
aria-expanded="true"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-label="context menu trigger button"
|
||||||
|
class="_icon-button_1215g_8"
|
||||||
|
data-kind="primary"
|
||||||
|
data-state="open"
|
||||||
|
id="radix-_r_0_"
|
||||||
|
inert=""
|
||||||
|
role="button"
|
||||||
|
style="--cpd-icon-button-size: 24px;"
|
||||||
|
tabindex="-1"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_indicator-icon_147l5_17"
|
||||||
|
style="--cpd-icon-button-size: 100%;"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`<WidgetContextMenuView /> > renders widget contextmenu without only basic modification 1`] = `
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
data-aria-hidden="true"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-controls="radix-_r_b_"
|
||||||
|
aria-disabled="false"
|
||||||
|
aria-expanded="true"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-label="context menu trigger button"
|
||||||
|
class="_icon-button_1215g_8"
|
||||||
|
data-kind="primary"
|
||||||
|
data-state="open"
|
||||||
|
id="radix-_r_a_"
|
||||||
|
inert=""
|
||||||
|
role="button"
|
||||||
|
style="--cpd-icon-button-size: 24px;"
|
||||||
|
tabindex="-1"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_indicator-icon_147l5_17"
|
||||||
|
style="--cpd-icon-button-size: 100%;"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="currentColor"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
@@ -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";
|
||||||
@@ -84,26 +84,6 @@ const showMoveButtons = (app: IWidget, room: Room | undefined, showUnpin: boolea
|
|||||||
return [widgetIndex > 0, widgetIndex < pinnedWidgets.length - 1];
|
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<IProps> = ({
|
export const WidgetContextMenu: React.FC<IProps> = ({
|
||||||
onFinished,
|
onFinished,
|
||||||
app,
|
app,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
CollapseIcon,
|
CollapseIcon,
|
||||||
PopOutIcon,
|
PopOutIcon,
|
||||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
|
import { I18nContext } from "@element-hq/web-shared-components";
|
||||||
|
|
||||||
import AccessibleButton from "./AccessibleButton";
|
import AccessibleButton from "./AccessibleButton";
|
||||||
import { _t } from "../../../languageHandler";
|
import { _t } from "../../../languageHandler";
|
||||||
@@ -39,11 +40,10 @@ import Spinner from "./Spinner";
|
|||||||
import dis from "../../../dispatcher/dispatcher";
|
import dis from "../../../dispatcher/dispatcher";
|
||||||
import ActiveWidgetStore from "../../../stores/ActiveWidgetStore";
|
import ActiveWidgetStore from "../../../stores/ActiveWidgetStore";
|
||||||
import SettingsStore from "../../../settings/SettingsStore";
|
import SettingsStore from "../../../settings/SettingsStore";
|
||||||
import { aboveLeftOf, ContextMenuButton } from "../../structures/ContextMenu";
|
import { ContextMenuButton } from "../../structures/ContextMenu";
|
||||||
import PersistedElement, { getPersistKey } from "./PersistedElement";
|
import PersistedElement, { getPersistKey } from "./PersistedElement";
|
||||||
import { WidgetType } from "../../../widgets/WidgetType";
|
import { WidgetType } from "../../../widgets/WidgetType";
|
||||||
import { ElementWidget, WidgetMessaging, WidgetMessagingEvent } from "../../../stores/widgets/WidgetMessaging";
|
import { ElementWidget, WidgetMessaging, WidgetMessagingEvent } from "../../../stores/widgets/WidgetMessaging";
|
||||||
import { showContextMenu, WidgetContextMenu } from "../context_menus/WidgetContextMenu";
|
|
||||||
import WidgetAvatar from "../avatars/WidgetAvatar";
|
import WidgetAvatar from "../avatars/WidgetAvatar";
|
||||||
import LegacyCallHandler from "../../../LegacyCallHandler";
|
import LegacyCallHandler from "../../../LegacyCallHandler";
|
||||||
import { type IApp, isAppWidget } from "../../../stores/WidgetStore";
|
import { type IApp, isAppWidget } from "../../../stores/WidgetStore";
|
||||||
@@ -61,6 +61,7 @@ import { ModuleRunner } from "../../../modules/ModuleRunner";
|
|||||||
import { parseUrl } from "../../../utils/UrlUtils";
|
import { parseUrl } from "../../../utils/UrlUtils";
|
||||||
import RightPanelStore from "../../../stores/right-panel/RightPanelStore.ts";
|
import RightPanelStore from "../../../stores/right-panel/RightPanelStore.ts";
|
||||||
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases.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
|
// 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
|
// because that would allow the iframe to programmatically remove the sandbox attribute, but
|
||||||
@@ -132,7 +133,6 @@ interface IState {
|
|||||||
error: Error | null;
|
error: Error | null;
|
||||||
menuDisplayed: boolean;
|
menuDisplayed: boolean;
|
||||||
requiresClient: boolean;
|
requiresClient: boolean;
|
||||||
hasContextMenuOptions: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class AppTile extends React.Component<IProps, IState> {
|
export default class AppTile extends React.Component<IProps, IState> {
|
||||||
@@ -276,14 +276,6 @@ export default class AppTile extends React.Component<IProps, IState> {
|
|||||||
error: null,
|
error: null,
|
||||||
menuDisplayed: false,
|
menuDisplayed: false,
|
||||||
requiresClient: this.determineInitialRequiresClientState(),
|
requiresClient: this.determineInitialRequiresClientState(),
|
||||||
hasContextMenuOptions: showContextMenu(
|
|
||||||
this.context,
|
|
||||||
this.props.room,
|
|
||||||
newProps.app,
|
|
||||||
newProps.userWidget,
|
|
||||||
!newProps.userWidget,
|
|
||||||
newProps.onDeleteClick,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -768,21 +760,6 @@ export default class AppTile extends React.Component<IProps, IState> {
|
|||||||
}
|
}
|
||||||
appTileClasses = classNames(appTileClasses);
|
appTileClasses = classNames(appTileClasses);
|
||||||
|
|
||||||
let contextMenu;
|
|
||||||
if (this.state.menuDisplayed) {
|
|
||||||
contextMenu = (
|
|
||||||
<WidgetContextMenu
|
|
||||||
{...aboveLeftOf(this.contextMenuButton.current.getBoundingClientRect())}
|
|
||||||
app={this.props.app}
|
|
||||||
onFinished={this.closeContextMenu}
|
|
||||||
showUnpin={!this.props.userWidget}
|
|
||||||
userWidget={this.props.userWidget}
|
|
||||||
onEditClick={this.props.onEditClick}
|
|
||||||
onDeleteClick={this.props.onDeleteClick}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const layoutButtons: ReactNode[] = [];
|
const layoutButtons: ReactNode[] = [];
|
||||||
if (this.props.showLayoutButtons) {
|
if (this.props.showLayoutButtons) {
|
||||||
const isMaximised =
|
const isMaximised =
|
||||||
@@ -838,24 +815,33 @@ export default class AppTile extends React.Component<IProps, IState> {
|
|||||||
<PopOutIcon className="mx_Icon mx_Icon_12" />
|
<PopOutIcon className="mx_Icon mx_Icon_12" />
|
||||||
</AccessibleButton>
|
</AccessibleButton>
|
||||||
)}
|
)}
|
||||||
{this.state.hasContextMenuOptions && (
|
<I18nContext.Provider value={window.mxModuleApi.i18n}>
|
||||||
<ContextMenuButton
|
<WidgetContextMenu
|
||||||
className="mx_AppTileMenuBar_widgets_button"
|
trigger={
|
||||||
label={_t("common|options")}
|
<ContextMenuButton
|
||||||
isExpanded={this.state.menuDisplayed}
|
className="mx_AppTileMenuBar_widgets_button"
|
||||||
ref={this.contextMenuButton}
|
label={_t("common|options")}
|
||||||
onClick={this.onContextMenuClick}
|
isExpanded={this.state.menuDisplayed}
|
||||||
>
|
ref={this.contextMenuButton}
|
||||||
<OverflowHorizontalIcon className="mx_Icon mx_Icon_12" />
|
onClick={this.onContextMenuClick}
|
||||||
</ContextMenuButton>
|
>
|
||||||
)}
|
<OverflowHorizontalIcon className="mx_Icon mx_Icon_12" />
|
||||||
|
</ContextMenuButton>
|
||||||
|
}
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</I18nContext.Provider>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{appTileBody}
|
{appTileBody}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{contextMenu}
|
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,9 +20,7 @@ import {
|
|||||||
import BaseCard from "./BaseCard";
|
import BaseCard from "./BaseCard";
|
||||||
import WidgetUtils, { useWidgets } from "../../../utils/WidgetUtils";
|
import WidgetUtils, { useWidgets } from "../../../utils/WidgetUtils";
|
||||||
import { _t } from "../../../languageHandler";
|
import { _t } from "../../../languageHandler";
|
||||||
import { ChevronFace, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu";
|
import { useContextMenu } from "../../structures/ContextMenu";
|
||||||
import { WidgetContextMenu } from "../context_menus/WidgetContextMenu";
|
|
||||||
import UIStore from "../../../stores/UIStore";
|
|
||||||
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
|
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
|
||||||
import { type IApp } from "../../../stores/WidgetStore";
|
import { type IApp } from "../../../stores/WidgetStore";
|
||||||
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
|
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
|
||||||
@@ -33,6 +31,7 @@ import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
|
|||||||
import EmptyState from "./EmptyState";
|
import EmptyState from "./EmptyState";
|
||||||
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents.ts";
|
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents.ts";
|
||||||
import { UIComponent } from "../../../settings/UIFeature.ts";
|
import { UIComponent } from "../../../settings/UIFeature.ts";
|
||||||
|
import { WidgetContextMenu } from "../../../viewmodels/right-panel/WidgetContextMenuViewModel.tsx";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -69,21 +68,6 @@ const AppRow: React.FC<IAppRowProps> = ({ app, room }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLDivElement>();
|
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLDivElement>();
|
||||||
let contextMenu;
|
|
||||||
if (menuDisplayed) {
|
|
||||||
const rect = handle.current?.getBoundingClientRect();
|
|
||||||
const rightMargin = rect?.right ?? 0;
|
|
||||||
const topMargin = rect?.top ?? 0;
|
|
||||||
contextMenu = (
|
|
||||||
<WidgetContextMenu
|
|
||||||
chevronFace={ChevronFace.None}
|
|
||||||
right={UIStore.instance.windowWidth - rightMargin}
|
|
||||||
bottom={UIStore.instance.windowHeight - topMargin}
|
|
||||||
onFinished={closeMenu}
|
|
||||||
app={app}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cannotPin = !isPinned && !WidgetLayoutStore.instance.canAddToContainer(room, Container.Top);
|
const cannotPin = !isPinned && !WidgetLayoutStore.instance.canAddToContainer(room, Container.Top);
|
||||||
|
|
||||||
@@ -108,7 +92,7 @@ const AppRow: React.FC<IAppRowProps> = ({ app, room }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes} ref={handle}>
|
<div className={classes}>
|
||||||
<AccessibleButton
|
<AccessibleButton
|
||||||
className="mx_ExtensionsCard_icon_app"
|
className="mx_ExtensionsCard_icon_app"
|
||||||
onClick={onOpenWidgetClick}
|
onClick={onOpenWidgetClick}
|
||||||
@@ -123,14 +107,21 @@ const AppRow: React.FC<IAppRowProps> = ({ app, room }) => {
|
|||||||
</AccessibleButton>
|
</AccessibleButton>
|
||||||
|
|
||||||
{canModifyWidget && (
|
{canModifyWidget && (
|
||||||
<ContextMenuTooltipButton
|
<WidgetContextMenu
|
||||||
className="mx_ExtensionsCard_app_options"
|
app={app}
|
||||||
isExpanded={menuDisplayed}
|
onFinished={closeMenu}
|
||||||
onClick={openMenu}
|
menuDisplayed={menuDisplayed}
|
||||||
title={_t("common|options")}
|
trigger={
|
||||||
>
|
<AccessibleButton
|
||||||
<OverflowHorizontalIcon />
|
ref={handle}
|
||||||
</ContextMenuTooltipButton>
|
className="mx_ExtensionsCard_app_options"
|
||||||
|
onClick={openMenu}
|
||||||
|
title={_t("common|options")}
|
||||||
|
>
|
||||||
|
<OverflowHorizontalIcon />
|
||||||
|
</AccessibleButton>
|
||||||
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AccessibleButton
|
<AccessibleButton
|
||||||
@@ -141,8 +132,6 @@ const AppRow: React.FC<IAppRowProps> = ({ app, room }) => {
|
|||||||
>
|
>
|
||||||
<PinSolidIcon />
|
<PinSolidIcon />
|
||||||
</AccessibleButton>
|
</AccessibleButton>
|
||||||
|
|
||||||
{contextMenu}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,19 +8,17 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
|
|
||||||
import React, { type JSX, useContext, useEffect } from "react";
|
import React, { type JSX, useContext, useEffect } from "react";
|
||||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||||
import { OverflowHorizontalIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
|
||||||
|
|
||||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||||
import BaseCard from "./BaseCard";
|
import BaseCard from "./BaseCard";
|
||||||
import WidgetUtils, { useWidgets } from "../../../utils/WidgetUtils";
|
import WidgetUtils, { useWidgets } from "../../../utils/WidgetUtils";
|
||||||
import AppTile from "../elements/AppTile";
|
import AppTile from "../elements/AppTile";
|
||||||
import { _t } from "../../../languageHandler";
|
import { _t } from "../../../languageHandler";
|
||||||
import { ChevronFace, ContextMenuButton, useContextMenu } from "../../structures/ContextMenu";
|
import { ContextMenuButton, useContextMenu } from "../../structures/ContextMenu";
|
||||||
import { WidgetContextMenu } from "../context_menus/WidgetContextMenu";
|
|
||||||
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
|
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
|
||||||
import UIStore from "../../../stores/UIStore";
|
|
||||||
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
|
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
|
||||||
import Heading from "../typography/Heading";
|
import Heading from "../typography/Heading";
|
||||||
|
import { WidgetContextMenu } from "../../../viewmodels/right-panel/WidgetContextMenuViewModel";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -47,36 +45,28 @@ const WidgetCard: React.FC<IProps> = ({ room, widgetId, onClose }) => {
|
|||||||
// Don't render anything as we are about to transition
|
// Don't render anything as we are about to transition
|
||||||
if (!app || !isRight) return null;
|
if (!app || !isRight) return null;
|
||||||
|
|
||||||
let contextMenu: JSX.Element | undefined;
|
const contextMenu: JSX.Element = (
|
||||||
if (menuDisplayed) {
|
<WidgetContextMenu
|
||||||
const rect = handle.current?.getBoundingClientRect();
|
trigger={
|
||||||
const rightMargin = rect ? rect.right : 0;
|
<ContextMenuButton
|
||||||
const bottomMargin = rect ? rect.bottom : 0;
|
className="mx_BaseCard_header_title_button--option"
|
||||||
contextMenu = (
|
ref={handle}
|
||||||
<WidgetContextMenu
|
onClick={openMenu}
|
||||||
chevronFace={ChevronFace.None}
|
isExpanded={menuDisplayed}
|
||||||
right={UIStore.instance.windowWidth - rightMargin - 12}
|
label={_t("common|options")}
|
||||||
top={bottomMargin + 12}
|
/>
|
||||||
onFinished={closeMenu}
|
}
|
||||||
app={app}
|
onFinished={closeMenu}
|
||||||
/>
|
app={app}
|
||||||
);
|
menuDisplayed={menuDisplayed}
|
||||||
}
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
const header = (
|
const header = (
|
||||||
<div className="mx_BaseCard_header_title">
|
<div className="mx_BaseCard_header_title">
|
||||||
<Heading size="4" className="mx_BaseCard_header_title_heading" as="h1">
|
<Heading size="4" className="mx_BaseCard_header_title_heading" as="h1">
|
||||||
{WidgetUtils.getWidgetName(app)}
|
{WidgetUtils.getWidgetName(app)}
|
||||||
</Heading>
|
</Heading>
|
||||||
<ContextMenuButton
|
|
||||||
className="mx_BaseCard_header_title_button--option"
|
|
||||||
ref={handle}
|
|
||||||
onClick={openMenu}
|
|
||||||
isExpanded={menuDisplayed}
|
|
||||||
label={_t("common|options")}
|
|
||||||
>
|
|
||||||
<OverflowHorizontalIcon />
|
|
||||||
</ContextMenuButton>
|
|
||||||
{contextMenu}
|
{contextMenu}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
/*
|
||||||
|
* 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<WidgetContextMenuSnapshot, WidgetContextMenuViewModelProps>
|
||||||
|
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,
|
||||||
|
userWidget: !!userWidget,
|
||||||
|
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<void> {
|
||||||
|
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 ? <WidgetContextMenuView vm={vm} /> : <></>;
|
||||||
|
}
|
||||||
@@ -26,24 +26,15 @@ exports[`AppTile destroys non-persisted right panel widget on room change 1`] =
|
|||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
aria-label="Options"
|
aria-label="Options"
|
||||||
class="mx_AccessibleButton mx_BaseCard_header_title_button--option"
|
class="mx_AccessibleButton mx_BaseCard_header_title_button--option"
|
||||||
|
data-state="closed"
|
||||||
|
id="radix-_r_0_"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
type="button"
|
||||||
<svg
|
/>
|
||||||
fill="currentColor"
|
|
||||||
height="1em"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="1em"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
aria-labelledby="_r_0_"
|
aria-labelledby="_r_2_"
|
||||||
class="_icon-button_1215g_8"
|
class="_icon-button_1215g_8"
|
||||||
data-kind="secondary"
|
data-kind="secondary"
|
||||||
data-testid="base-card-close-button"
|
data-testid="base-card-close-button"
|
||||||
@@ -185,8 +176,11 @@ exports[`AppTile for a pinned widget should render 1`] = `
|
|||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
aria-label="Options"
|
aria-label="Options"
|
||||||
class="mx_AccessibleButton mx_AppTileMenuBar_widgets_button"
|
class="mx_AccessibleButton mx_AppTileMenuBar_widgets_button"
|
||||||
|
data-state="closed"
|
||||||
|
id="radix-_r_1k_"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="mx_Icon mx_Icon_12"
|
class="mx_Icon mx_Icon_12"
|
||||||
@@ -298,8 +292,11 @@ exports[`AppTile for a pinned widget should render permission request 1`] = `
|
|||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
aria-label="Options"
|
aria-label="Options"
|
||||||
class="mx_AccessibleButton mx_AppTileMenuBar_widgets_button"
|
class="mx_AccessibleButton mx_AppTileMenuBar_widgets_button"
|
||||||
|
data-state="closed"
|
||||||
|
id="radix-_r_30_"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="mx_Icon mx_Icon_12"
|
class="mx_Icon mx_Icon_12"
|
||||||
@@ -352,8 +349,8 @@ exports[`AppTile for a pinned widget should render permission request 1`] = `
|
|||||||
<span>
|
<span>
|
||||||
Using this widget may share data
|
Using this widget may share data
|
||||||
<div
|
<div
|
||||||
aria-describedby="_r_2f_"
|
aria-describedby="_r_33_"
|
||||||
aria-labelledby="_r_2e_"
|
aria-labelledby="_r_32_"
|
||||||
class="mx_TextWithTooltip_target mx_TextWithTooltip_target--helpIcon"
|
class="mx_TextWithTooltip_target mx_TextWithTooltip_target--helpIcon"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -490,8 +487,11 @@ exports[`AppTile preserves non-persisted widget on container move 1`] = `
|
|||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
aria-label="Options"
|
aria-label="Options"
|
||||||
class="mx_AccessibleButton mx_AppTileMenuBar_widgets_button"
|
class="mx_AccessibleButton mx_AppTileMenuBar_widgets_button"
|
||||||
|
data-state="closed"
|
||||||
|
id="radix-_r_10_"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="mx_Icon mx_Icon_12"
|
class="mx_Icon mx_Icon_12"
|
||||||
|
|||||||
+1
-23
@@ -288,26 +288,4 @@ exports[`<ExtensionsCard /> should render widgets 1`] = `
|
|||||||
</DocumentFragment>
|
</DocumentFragment>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`<ExtensionsCard /> should show context menu on widget row 1`] = `
|
exports[`<ExtensionsCard /> should show context menu on widget row 1`] = `null`;
|
||||||
<ul
|
|
||||||
class="mx_IconizedContextMenu"
|
|
||||||
role="none"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="mx_IconizedContextMenu_optionList mx_IconizedContextMenu_optionList_notFirst"
|
|
||||||
>
|
|
||||||
<li
|
|
||||||
aria-label="Remove for everyone"
|
|
||||||
class="mx_AccessibleButton mx_IconizedContextMenu_item"
|
|
||||||
role="menuitem"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="mx_IconizedContextMenu_label"
|
|
||||||
>
|
|
||||||
Remove for everyone
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</div>
|
|
||||||
</ul>
|
|
||||||
`;
|
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
/*
|
||||||
|
* 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 { MatrixWidgetType } from "matrix-widget-api";
|
||||||
|
import { type MatrixClient, Room } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
|
import {
|
||||||
|
WidgetContextMenuViewModel,
|
||||||
|
type WidgetContextMenuViewModelProps,
|
||||||
|
} from "../../../src/viewmodels/right-panel/WidgetContextMenuViewModel";
|
||||||
|
import { stubClient } from "../../test-utils";
|
||||||
|
import WidgetUtils from "../../../src/utils/WidgetUtils";
|
||||||
|
import { type IApp } from "../../../src/utils/WidgetUtils-types";
|
||||||
|
import { Container, WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore";
|
||||||
|
import * as livestream from "../../../src/Livestream";
|
||||||
|
import Modal from "../../../src/Modal";
|
||||||
|
import SettingsStore from "../../../src/settings/SettingsStore";
|
||||||
|
import { SettingLevel } from "../../../src/settings/SettingLevel";
|
||||||
|
import * as widgetStore from "../../../src/stores/WidgetStore";
|
||||||
|
import { WidgetMessagingStore } from "../../../src/stores/widgets/WidgetMessagingStore";
|
||||||
|
import { type WidgetMessaging } from "../../../src/stores/widgets/WidgetMessaging";
|
||||||
|
|
||||||
|
describe("WidgetContextMenuViewModel", () => {
|
||||||
|
const widgetId = "w1";
|
||||||
|
const eventId = "e1";
|
||||||
|
const roomId = "r1";
|
||||||
|
const userId = "@user-id:server";
|
||||||
|
|
||||||
|
const app: IApp = {
|
||||||
|
id: widgetId,
|
||||||
|
eventId,
|
||||||
|
roomId,
|
||||||
|
type: MatrixWidgetType.Custom,
|
||||||
|
url: "https://example.com",
|
||||||
|
name: "Example 1",
|
||||||
|
creatorUserId: userId,
|
||||||
|
avatar_url: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
let client: MatrixClient;
|
||||||
|
const defaultProps: WidgetContextMenuViewModelProps = {
|
||||||
|
menuDisplayed: true,
|
||||||
|
room: undefined,
|
||||||
|
roomId,
|
||||||
|
cli: stubClient(),
|
||||||
|
app,
|
||||||
|
showUnpin: true,
|
||||||
|
userWidget: true,
|
||||||
|
trigger: <></>,
|
||||||
|
onEditClick: jest.fn(),
|
||||||
|
onDeleteClick: jest.fn(),
|
||||||
|
onFinished: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(WidgetUtils, "canUserModifyWidgets").mockReturnValue(true);
|
||||||
|
jest.spyOn(WidgetUtils, "isManagedByManager").mockReturnValue(true);
|
||||||
|
jest.spyOn(WidgetUtils, "editWidget").mockReturnValue();
|
||||||
|
const mockMessaging = {
|
||||||
|
on: () => {},
|
||||||
|
off: () => {},
|
||||||
|
stop: () => {},
|
||||||
|
widgetApi: {
|
||||||
|
hasCapability: jest.fn(),
|
||||||
|
},
|
||||||
|
} as unknown as WidgetMessaging;
|
||||||
|
jest.spyOn(WidgetMessagingStore.instance, "getMessagingForUid").mockReturnValue(mockMessaging);
|
||||||
|
client = stubClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return the snapshot", () => {
|
||||||
|
const vm = new WidgetContextMenuViewModel(defaultProps);
|
||||||
|
expect(vm.getSnapshot()).toMatchObject({
|
||||||
|
showStreamAudioStreamButton: false, // because widget type is custom and not jitsi
|
||||||
|
showEditButton: true, // because default mock return true on canUserModifyWidgets and isManagedByManager
|
||||||
|
showRevokeButton: false,
|
||||||
|
showDeleteButton: true,
|
||||||
|
showSnapshotButton: false, // because no default value for sdkconfig "enableWidgetScreenshots"
|
||||||
|
showMoveButtons: [false, false],
|
||||||
|
canModify: true,
|
||||||
|
isMenuOpened: true,
|
||||||
|
trigger: <></>,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call edit widget no custom edit function passed and room exist", () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
room: new Room(roomId, client, userId),
|
||||||
|
onEditClick: undefined,
|
||||||
|
};
|
||||||
|
const vm = new WidgetContextMenuViewModel(props);
|
||||||
|
vm.onEditClick();
|
||||||
|
expect(WidgetUtils.editWidget).toHaveBeenCalled();
|
||||||
|
expect(props.onFinished).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call custom onEditClick if passed as props and room exist", () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
room: new Room(roomId, client, userId),
|
||||||
|
};
|
||||||
|
const vm = new WidgetContextMenuViewModel(props);
|
||||||
|
vm.onEditClick();
|
||||||
|
|
||||||
|
expect(props.onEditClick).toHaveBeenCalled();
|
||||||
|
expect(props.onFinished).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should just call finish if no custom onEditClick is passed as props and does not room exist", () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
room: undefined,
|
||||||
|
onEditClick: undefined,
|
||||||
|
};
|
||||||
|
const vm = new WidgetContextMenuViewModel(props);
|
||||||
|
vm.onEditClick();
|
||||||
|
|
||||||
|
expect(WidgetUtils.editWidget).not.toHaveBeenCalled();
|
||||||
|
expect(props.onFinished).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should move widget position when onmovebutton is called", () => {
|
||||||
|
jest.spyOn(WidgetLayoutStore.instance, "moveWithinContainer").mockReturnValue();
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
room: new Room(roomId, client, userId),
|
||||||
|
};
|
||||||
|
const vm = new WidgetContextMenuViewModel(props);
|
||||||
|
vm.onMoveButton(1);
|
||||||
|
|
||||||
|
expect(WidgetLayoutStore.instance.moveWithinContainer).toHaveBeenCalledWith(
|
||||||
|
props.room,
|
||||||
|
Container.Top,
|
||||||
|
props.app,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(props.onFinished).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error when onmovebutton is called and no room is given", () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
room: undefined,
|
||||||
|
};
|
||||||
|
const vm = new WidgetContextMenuViewModel(props);
|
||||||
|
|
||||||
|
expect(() => vm.onMoveButton(1)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should startJitsiAudioLivestream when onStreamAudioClick button is clicked", async () => {
|
||||||
|
jest.spyOn(livestream, "startJitsiAudioLivestream").mockImplementation(jest.fn());
|
||||||
|
jest.spyOn(livestream, "getConfigLivestreamUrl").mockReturnValue("https://url");
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
room: new Room(roomId, client, userId),
|
||||||
|
};
|
||||||
|
const vm = new WidgetContextMenuViewModel(props);
|
||||||
|
vm.onStreamAudioClick();
|
||||||
|
await expect(livestream.startJitsiAudioLivestream).toHaveBeenCalled();
|
||||||
|
expect(props.onFinished).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show modal when startJitsiAudioLivestream is on error and onStreamAudioClick button is clicked", async () => {
|
||||||
|
jest.spyOn(livestream, "startJitsiAudioLivestream").mockImplementation(() => {
|
||||||
|
console.log("failllllled");
|
||||||
|
throw new Error("Failed");
|
||||||
|
});
|
||||||
|
jest.spyOn(livestream, "getConfigLivestreamUrl").mockReturnValue("https://url");
|
||||||
|
jest.spyOn(Modal, "createDialog").mockReturnValue({
|
||||||
|
finished: Promise.resolve([true, true, false]),
|
||||||
|
close: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
room: new Room(roomId, client, userId),
|
||||||
|
};
|
||||||
|
const vm = new WidgetContextMenuViewModel(props);
|
||||||
|
await vm.onStreamAudioClick();
|
||||||
|
expect(Modal.createDialog).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw when no room is given and onStreamAudioClick button is clicked", async () => {
|
||||||
|
jest.spyOn(livestream, "startJitsiAudioLivestream").mockImplementation(jest.fn());
|
||||||
|
jest.spyOn(livestream, "getConfigLivestreamUrl").mockReturnValue("https://url");
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
room: new Room(roomId, client, userId),
|
||||||
|
};
|
||||||
|
const vm = new WidgetContextMenuViewModel(props);
|
||||||
|
await vm.onStreamAudioClick();
|
||||||
|
// nothing happened
|
||||||
|
expect(props.onFinished).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call custom delete function when it is given in props", () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
};
|
||||||
|
const vm = new WidgetContextMenuViewModel(props);
|
||||||
|
vm.onDeleteClick();
|
||||||
|
expect(props.onDeleteClick).toHaveBeenCalled();
|
||||||
|
expect(props.onFinished).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display modal when no custom function is provided and a room is given", () => {
|
||||||
|
jest.spyOn(Modal, "createDialog").mockReturnValue({
|
||||||
|
finished: Promise.resolve([true, true, false]),
|
||||||
|
close: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
room: new Room(roomId, client, userId),
|
||||||
|
onDeleteClick: undefined,
|
||||||
|
};
|
||||||
|
const vm = new WidgetContextMenuViewModel(props);
|
||||||
|
|
||||||
|
vm.onDeleteClick();
|
||||||
|
|
||||||
|
expect(Modal.createDialog).toHaveBeenCalled();
|
||||||
|
expect(props.onFinished).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should do nothing when onDeleteClick and no custom function and no room is provided", () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
room: undefined,
|
||||||
|
onDeleteClick: undefined,
|
||||||
|
};
|
||||||
|
const vm = new WidgetContextMenuViewModel(props);
|
||||||
|
|
||||||
|
vm.onDeleteClick();
|
||||||
|
|
||||||
|
expect(props.onFinished).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set new level for allowedwidget when onrevoke button is clicked", () => {
|
||||||
|
const current = { [eventId]: true };
|
||||||
|
jest.spyOn(SettingsStore, "getValue").mockReturnValue(current);
|
||||||
|
jest.spyOn(SettingsStore, "firstSupportedLevel").mockReturnValue(SettingLevel.DEFAULT);
|
||||||
|
jest.spyOn(SettingsStore, "setValue").mockResolvedValue();
|
||||||
|
jest.spyOn(widgetStore, "isAppWidget").mockReturnValue(true);
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
room: new Room(roomId, client, userId),
|
||||||
|
};
|
||||||
|
const vm = new WidgetContextMenuViewModel(props);
|
||||||
|
|
||||||
|
vm.onRevokeClick();
|
||||||
|
|
||||||
|
expect(SettingsStore.setValue).toHaveBeenCalledWith(
|
||||||
|
"allowedWidgets",
|
||||||
|
props.roomId,
|
||||||
|
SettingLevel.DEFAULT,
|
||||||
|
current,
|
||||||
|
);
|
||||||
|
|
||||||
|
const current2 = { [eventId]: false };
|
||||||
|
jest.spyOn(SettingsStore, "getValue").mockReturnValue(current2);
|
||||||
|
jest.spyOn(SettingsStore, "firstSupportedLevel").mockReturnValue(SettingLevel.DEFAULT);
|
||||||
|
jest.spyOn(SettingsStore, "setValue").mockResolvedValue();
|
||||||
|
jest.spyOn(widgetStore, "isAppWidget").mockReturnValue(false);
|
||||||
|
|
||||||
|
vm.onRevokeClick();
|
||||||
|
|
||||||
|
expect(SettingsStore.setValue).toHaveBeenCalledWith(
|
||||||
|
"allowedWidgets",
|
||||||
|
props.roomId,
|
||||||
|
SettingLevel.DEFAULT,
|
||||||
|
current2,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw an error when first supported level is not set", () => {
|
||||||
|
jest.spyOn(SettingsStore, "firstSupportedLevel").mockReturnValue(null);
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
room: undefined,
|
||||||
|
onDeleteClick: undefined,
|
||||||
|
};
|
||||||
|
const vm = new WidgetContextMenuViewModel(props);
|
||||||
|
|
||||||
|
expect(() => vm.onRevokeClick()).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user