Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/shared-components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export * from "./rich-list/RichItem";
export * from "./rich-list/RichList";
export * from "./utils/Box";
export * from "./utils/Flex";
export * from "./right-panel/WidgetContextMenu";

// Utils
export * from "./utils/i18n";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/

import React, { type JSX } from "react";
import { fn } from "storybook/test";
import { IconButton } from "@vector-im/compound-web";
import TriggerIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal";

import type { Meta, StoryFn } from "@storybook/react-vite";
import { useMockedViewModel } from "../../useMockedViewModel";
import {
type WidgetContextMenuAction,
type WidgetContextMenuSnapshot,
WidgetContextMenuView,
} from "./WidgetContextMenuView";

type WidgetContextMenuViewModelProps = WidgetContextMenuSnapshot & WidgetContextMenuAction;

const WidgetContextMenuViewWrapper = ({
onStreamAudioClick,
onEditClick,
onSnapshotClick,
onDeleteClick,
onRevokeClick,
onFinished,
onMoveButton,
...rest
}: WidgetContextMenuViewModelProps): JSX.Element => {
const vm = useMockedViewModel(rest, {
onStreamAudioClick,
onEditClick,
onSnapshotClick,
onDeleteClick,
onRevokeClick,
onFinished,
onMoveButton,
});
return <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">
<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,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/

import React from "react";
import { screen, render } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { IconButton } from "@vector-im/compound-web";
import { composeStories } from "@storybook/react-vite";
import TriggerIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal";

import {
type WidgetContextMenuAction,
type WidgetContextMenuSnapshot,
WidgetContextMenuView,
} from "./WidgetContextMenuView";
import * as stories from "./WidgetContextMenuView.stories.tsx";
import { MockViewModel } from "../../viewmodel/MockViewModel.ts";

const { Default, OnlyBasicModification } = composeStories(stories);

describe("<WidgetContextMenuView />", () => {
afterEach(() => {
jest.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 = jest.fn();
const togglePlay = jest.fn();
const onSeekbarChange = jest.fn();

const onStreamAudioClick = jest.fn();
const onEditClick = jest.fn();
const onSnapshotClick = jest.fn();
const onDeleteClick = jest.fn();
const onRevokeClick = jest.fn();
const onFinished = jest.fn();
const onMoveButton = jest.fn();
class WidgetContextMenuViewModel
extends MockViewModel<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,
trigger: (
<IconButton size="24px">
<TriggerIcon />
</IconButton>
),
};

it("should attach vm methods", async () => {
const vm = new WidgetContextMenuViewModel(defaultValue);

render(<WidgetContextMenuView vm={vm} />);

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);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/

import React, { type ReactNode, type JSX } from "react";
import { Menu, MenuItem } from "@vector-im/compound-web";

import { _t } from "../../utils/i18n.tsx";
import { type ViewModel } from "../../viewmodel/ViewModel.ts";
import { useViewModel } from "../../useViewModel.ts";

export interface WidgetContextMenuSnapshot {
/**
* Indicates if the audio stream button needs to be shown or not
* depending on the config value audio_stream_url and widget type jitsi
*/
showStreamAudioStreamButton: boolean;
/**
* Indicates if the edit button is shown depending the user permission to modify
*/
showEditButton: boolean;
/**
* Indicates if revoke widget button needs to be shown or not
*/
showRevokeButton: boolean;
/**
* Indicates if delete widget button needs to be shown or not
*/
showDeleteButton: boolean;
/**
* Show take screenshot button or not dependning on config value enableWidgetScreenshots
*/
showSnapshotButton: boolean;
/**
* show move widget position button
*/
showMoveButtons: [boolean, boolean];
/**
* Indicates if user can modify the widget settings
*/
canModify: boolean;
/**
* Indicates if the widget menu is opened or not
*/
isMenuOpened: boolean;
/**
* A component that is displayed which trigger the menu to open or close
*/
trigger: ReactNode;
}

export interface WidgetContextMenuAction {
/**
* Function triggered when stream audio is clicked
*/
onStreamAudioClick: () => Promise<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;
}

export const WidgetContextMenuView: React.FC<WidgetContextMenuViewProps> = ({ vm }) => {
const {
showStreamAudioStreamButton,
showEditButton,
showSnapshotButton,
showDeleteButton,
showRevokeButton,
showMoveButtons,
isMenuOpened,
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}
// TODO label={userWidget ? _t("action|remove") : _t("widget|context_menu|remove")}
label={_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")} />;
}

return (
<Menu
title="Widget context menu"
open={isMenuOpened}
showTitle={false}
side="right"
align="start"
trigger={trigger}
onOpenChange={vm.onFinished}
>
{streamAudioStreamButton}
{editButton}
{revokeButton}
{deleteButton}
{snapshotButton}
{moveLeftButton}
{moveRightButton}
</Menu>
);
};
Loading
Loading