Files
ThreadNet-Web/packages/shared-components/src/right-panel/WidgetContextMenu/WidgetContextMenuView.tsx
T
Michael TelatynskiandGitHub 77670eb369 Add lint rule to protect against this access on unbound methods (#32578)
* Add Actions to ViewModel utility types and specify `this: void` signature

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Add https://typescript-eslint.io/rules/unbound-method/ linter to shared-components

also fix stray lint config which doesn't apply to SC

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Add https://typescript-eslint.io/rules/unbound-method/ linter to element-web

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Fix genuine issues identified by the linter

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Specify this:void on i18napi

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update Module API

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Add comment for MapToVoidThis

Added utility type to map VM actions to unbound functions.

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2026-02-23 15:37:58 +00:00

198 lines
6.0 KiB
TypeScript

/*
* 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>
);
};