/* * Wire * Copyright (C) 2021 Wire Swiss GmbH * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. * */ import React, {ReactNode, useEffect, useMemo, useRef, useState} from 'react'; import cx from 'classnames'; import {createRoot, Root} from 'react-dom/client'; import {Availability} from '@wireapp/protocol-messaging'; import * as Icon from 'Components/Icon'; import {IgnoreOutsideClickWrapper} from 'Components/InputBar/util/clickHandlers'; import {useMessageActionsState} from 'Components/MessagesList/Message/ContentMessage/MessageActions/MessageActions.state'; import {isEnterKey, isEscapeKey, isKey, isOneOfKeys, isSpaceKey, KEY} from 'Util/KeyboardUtil'; import {useActiveWindowState} from '../hooks/useActiveWindow'; export interface ContextMenuEntry { availability?: Availability.Type; click?: (event?: MouseEvent) => void; icon?: React.ComponentType>; identifier?: string; isChecked?: boolean; isDisabled?: boolean; isSeparator?: boolean; label?: string; title?: string; } interface ContextMenuProps { defaultIdentifier?: string; entries: ContextMenuEntry[]; placeholder?: ReactNode; posX: number; posY: number; resetMenuStates?: () => void; } let container: HTMLDivElement | undefined; let previouslyFocused: HTMLElement; let reactRoot: Root; const cleanUp = () => { const {activeWindow} = useActiveWindowState.getState(); if (container) { reactRoot.unmount(); activeWindow.document.body.removeChild(container); container = undefined; } }; const getButtonId = (label: string): string => `btn-${label?.split(' ').join('-').toLowerCase()}`; const contextMenuClassName = 'ctx-menu'; const msgMenuIdentifier = 'message-options-menu'; const ContextMenu: React.FC = ({ entries, defaultIdentifier = `${contextMenuClassName}-item`, posX, posY, resetMenuStates, placeholder, }) => { const {activeWindow} = useActiveWindowState(); const [mainElement, setMainElement] = useState(); const placeholderElement = useRef(null); const [selected, setSelected] = useState(); const style = useMemo(() => { const left = mainElement && activeWindow.innerWidth - posX < mainElement.offsetWidth ? posX - mainElement.offsetWidth : posX; const top = Math.max( mainElement && activeWindow.innerHeight - posY < mainElement.offsetHeight ? posY - mainElement.offsetHeight : posY, 0, ); return { left, top, visibility: mainElement || placeholderElement ? 'unset' : 'hidden', }; }, [mainElement, placeholderElement]); useEffect(() => { if (selected) { // remove quotes from label const labelWithoutQuotes = selected?.label?.replaceAll('"', ''); // context menu options such as 10 seconds etc begings with digit which is an invalid querySelector // param append btn- to avoid such errors const selectedButton = activeWindow.document.querySelector( `#${getButtonId(labelWithoutQuotes!)}`, ) as HTMLButtonElement; selectedButton?.focus(); } }, [selected]); useEffect(() => { const onWheel = (event: MouseEvent) => event.preventDefault(); //after opening the menu first time, select the first option if (!selected) { setSelected(entries[0]); } const onKeyDown = (event: KeyboardEvent): void => { event.preventDefault(); if (isEscapeKey(event) || isKey(event, KEY.TAB)) { // escape/tab key press while the menu is open will close the menu and focus the trigerer cleanUp(); previouslyFocused.focus(); resetMsgMenuStates(); } if (isOneOfKeys(event, [KEY.ARROW_UP, KEY.ARROW_DOWN])) { if (!entries.includes(selected!)) { const index = isKey(event, KEY.ARROW_DOWN) ? 0 : entries.length - 1; setSelected(entries[index]); return; } const direction = isKey(event, KEY.ARROW_DOWN) ? 1 : -1; const nextIndex = (entries.indexOf(selected!) + direction + entries.length) % entries.length; setSelected(entries[nextIndex]); } if (isEnterKey(event) || isSpaceKey(event)) { if (selected) { cleanUp(); resetMsgMenuStates(); selected.click?.(); previouslyFocused.focus(); } } }; const onMouseDown = (event: MouseEvent): void => { const isOutsideClick = entries.length ? mainElement && !mainElement.contains(event.target as Node) : placeholderElement && !placeholderElement.current?.contains(event.target as Node); if (isOutsideClick) { cleanUp(); resetMsgMenuStates(isOutsideClick); } }; activeWindow.addEventListener('wheel', onWheel); activeWindow.addEventListener('keydown', onKeyDown); activeWindow.addEventListener('mousedown', onMouseDown); activeWindow.addEventListener('resize', cleanUp); return () => { activeWindow.removeEventListener('wheel', onWheel); activeWindow.removeEventListener('keydown', onKeyDown); activeWindow.removeEventListener('mousedown', onMouseDown); activeWindow.removeEventListener('resize', cleanUp); }; }, [mainElement, selected, activeWindow]); const {handleMenuOpen} = useMessageActionsState(); const resetMsgMenuStates = (isOutsideClick = false) => { if (defaultIdentifier === msgMenuIdentifier) { handleMenuOpen?.(false); if (isOutsideClick) { resetMenuStates?.(); } } }; return (
{entries.length ? (
    {entries.map((entry, index) => entry.isSeparator ? (
  • ) : (
  • ), )}
) : (
{placeholder}
)}
); }; export const showContextMenu = ({ entries, event, identifier, placeholder, resetMenuStates, }: { event: MouseEvent | React.MouseEvent; entries: ContextMenuEntry[]; identifier: string; resetMenuStates?: () => void; placeholder?: ReactNode; }) => { const {activeWindow} = useActiveWindowState.getState(); event.preventDefault(); event.stopPropagation(); previouslyFocused = activeWindow.document.activeElement as HTMLElement; cleanUp(); container = activeWindow.document.createElement('div'); activeWindow.document.body.appendChild(container); reactRoot = createRoot(container); reactRoot.render( , ); };