diff --git a/package-lock.json b/package-lock.json index 4d975fab..9ce3d61a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "license": "Apache-2.0", "dependencies": { "@emotion/react": "11.11.4", + "@floating-ui/react": "0.26.28", "@mattermost/compass-icons": "0.1.45", "auto-launch": "5.0.6", "bootstrap": "4.6.1", @@ -2568,10 +2569,39 @@ "@floating-ui/utils": "^0.2.0" } }, + "node_modules/@floating-ui/react": { + "version": "0.26.28", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz", + "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.8", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", + "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@floating-ui/utils": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz", - "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==" + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", + "license": "MIT" }, "node_modules/@formatjs/ecma402-abstract": { "version": "1.18.2", @@ -16229,6 +16259,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", + "license": "MIT" + }, "node_modules/tapable": { "version": "0.1.10", "resolved": "https://registry.npmjs.org/tapable/-/tapable-0.1.10.tgz", diff --git a/package.json b/package.json index 6a88b195..7b962599 100644 --- a/package.json +++ b/package.json @@ -160,6 +160,7 @@ }, "dependencies": { "@emotion/react": "11.11.4", + "@floating-ui/react": "0.26.28", "@mattermost/compass-icons": "0.1.45", "auto-launch": "5.0.6", "bootstrap": "4.6.1", diff --git a/src/main/developerMode.test.js b/src/main/developerMode.test.js index a713d94c..a7b21da3 100644 --- a/src/main/developerMode.test.js +++ b/src/main/developerMode.test.js @@ -15,6 +15,8 @@ jest.mock('electron', () => ({ }, })); +jest.mock('electron-is-dev', () => false); + describe('main/developerMode', () => { it('should toggle values correctly', () => { const developerMode = new DeveloperMode('file.json'); diff --git a/src/main/developerMode.ts b/src/main/developerMode.ts index b06f1113..fedaec74 100644 --- a/src/main/developerMode.ts +++ b/src/main/developerMode.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import {ipcMain} from 'electron'; +import isDev from 'electron-is-dev'; import {EventEmitter} from 'events'; import {DEVELOPER_MODE_UPDATED, IS_DEVELOPER_MODE_ENABLED, UPDATE_PATHS} from 'common/communication'; @@ -20,7 +21,7 @@ export class DeveloperMode extends EventEmitter { ipcMain.handle(IS_DEVELOPER_MODE_ENABLED, this.enabled); } - enabled = () => process.env.MM_DESKTOP_DEVELOPER_MODE === 'true'; + enabled = () => process.env.MM_DESKTOP_DEVELOPER_MODE === 'true' || isDev; toggle = (setting: keyof DeveloperSettings) => { if (!this.enabled()) { diff --git a/src/main/menus/app.test.js b/src/main/menus/app.test.js index 9b8cc92c..2a110905 100644 --- a/src/main/menus/app.test.js +++ b/src/main/menus/app.test.js @@ -22,6 +22,8 @@ jest.mock('electron-context-menu', () => { return () => jest.fn(); }); +jest.mock('electron-is-dev', () => false); + jest.mock('electron', () => { class NotificationMock { static isSupported = jest.fn(); diff --git a/src/renderer/components/DeveloperModeIndicator.tsx b/src/renderer/components/DeveloperModeIndicator.tsx index 68d1d0df..b9449c25 100644 --- a/src/renderer/components/DeveloperModeIndicator.tsx +++ b/src/renderer/components/DeveloperModeIndicator.tsx @@ -3,34 +3,33 @@ import classNames from 'classnames'; import React from 'react'; -// eslint-disable-next-line no-restricted-imports -import {OverlayTrigger, Tooltip} from 'react-bootstrap'; import {FormattedMessage} from 'react-intl'; import 'renderer/css/components/DeveloperModeIndicator.scss'; +import WithTooltip from './WithTooltip'; + export default function DeveloperModeIndicator({developerMode, darkMode}: {developerMode: boolean; darkMode: boolean}) { if (!developerMode) { return null; } return ( - - - + } + isVertical={false} + className='DeveloperModeIndicator__tooltip' >
-
+ ); } diff --git a/src/renderer/components/WithTooltip/index.tsx b/src/renderer/components/WithTooltip/index.tsx new file mode 100644 index 00000000..8285c8f2 --- /dev/null +++ b/src/renderer/components/WithTooltip/index.tsx @@ -0,0 +1,204 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Placement} from '@floating-ui/react'; +import { + useFloating, + autoUpdate, + offset, + useHover, + useFocus, + useDismiss, + useRole, + useInteractions, + arrow, + FloatingPortal, + useTransitionStyles, + FloatingArrow, + flip, + useMergeRefs, +} from '@floating-ui/react'; +import classNames from 'classnames'; +import React, {useRef, useState, useMemo, cloneElement, isValidElement} from 'react'; +import type {ReactElement, ReactNode} from 'react'; +import type {MessageDescriptor} from 'react-intl'; +import {defineMessage} from 'react-intl'; + +import {Constants} from 'renderer/constants'; + +import TooltipContent from './tooltip_content'; + +import './with_tooltip.scss'; + +/** + * Shortcut keys map to translations that can be used in the tooltip + * when shortcut definition is provided + */ +export const ShortcutKeys = { + alt: defineMessage({ + id: 'shortcuts.generic.alt', + defaultMessage: 'Alt', + }), + cmd: '⌘', + ctrl: defineMessage({ + id: 'shortcuts.generic.ctrl', + defaultMessage: 'Ctrl', + }), + option: '⌥', + shift: defineMessage({ + id: 'shortcuts.generic.shift', + defaultMessage: 'Shift', + }), +}; + +interface Props { + title: string | ReactNode | MessageDescriptor; + emoji?: string; + isEmojiLarge?: boolean; + hint?: string | ReactNode | MessageDescriptor; + + /** + * Whether the tooltip should be vertical or horizontal, by default it is vertical + * This doesn't always guarantee the tooltip will be vertical, it just determines the initial placement and fallback placements + */ + isVertical?: boolean; + + /** + * If closing of the tooltip should be delayed, + * Useful if tooltips contains links that need to be clicked + */ + delayClose?: boolean; + + /** + * Additional class name to be added to the tooltip container + */ + className?: string; + disabled?: boolean; + + /** + * @deprecated Do not use this except for special cases + * Callback when the tooltip appears + */ + onOpen?: () => void; + children: ReactElement; +} + +export default function WithTooltip({ + children, + title, + isEmojiLarge = false, + hint, + isVertical = true, + delayClose = false, + className, + onOpen, + disabled, +}: Props) { + const [open, setOpen] = useState(false); + + const arrowRef = useRef(null); + + function handleChange(open: boolean) { + setOpen(open); + + if (onOpen && open) { + onOpen(); + } + } + + const placements = useMemo<{initial: Placement; fallback: Placement[]}>(() => { + let initial: Placement; + let fallback: Placement[]; + if (isVertical) { + initial = 'top'; + fallback = ['bottom', 'right', 'left']; + } else { + initial = 'right'; + fallback = ['left', 'top', 'bottom']; + } + return {initial, fallback}; + }, [isVertical]); + + const {refs: {setReference, setFloating}, floatingStyles, context: floatingContext} = useFloating({ + open: disabled ? false : open, + onOpenChange: handleChange, + whileElementsMounted: autoUpdate, + placement: 'left', + middleware: [ + offset(Constants.OverlayArrow.OFFSET), + flip({ + fallbackPlacements: placements.fallback, + }), + arrow({ + element: arrowRef, + }), + ], + }); + + const {isMounted, styles: transitionStyles} = useTransitionStyles(floatingContext, TRANSITION_STYLE_PROPS); + + const hover = useHover(floatingContext, { + restMs: Constants.OverlaysTimings.CURSOR_REST_TIME_BEFORE_OPEN, + delay: { + open: Constants.OverlaysTimings.CURSOR_MOUSEOVER_TO_OPEN, + close: delayClose ? Constants.OverlaysTimings.CURSOR_MOUSEOUT_TO_CLOSE_WITH_DELAY : Constants.OverlaysTimings.CURSOR_MOUSEOUT_TO_CLOSE, + }, + }); + const focus = useFocus(floatingContext); + const dismiss = useDismiss(floatingContext); + const role = useRole(floatingContext, {role: 'tooltip'}); + + const {getReferenceProps, getFloatingProps} = useInteractions([hover, focus, dismiss, role]); + + if (!isValidElement(children)) { + // eslint-disable-next-line no-console + console.error('Children must be a valid React element for WithTooltip'); + } + + const mergedRefs = useMergeRefs([setReference, (children as any)?.ref]); + + const trigger = cloneElement( + children, + getReferenceProps({ + ref: mergedRefs, + ...children.props, + }), + ); + + return ( + <> + {trigger} + + {isMounted && ( + +
+ + +
+
+ )} + + ); +} + +const TRANSITION_STYLE_PROPS = { + duration: { + open: Constants.OverlaysTimings.FADE_IN_DURATION, + close: Constants.OverlaysTimings.FADE_OUT_DURATION, + }, + initial: Constants.OverlayTransitionStyles.START, +}; diff --git a/src/renderer/components/WithTooltip/tooltip_content.tsx b/src/renderer/components/WithTooltip/tooltip_content.tsx new file mode 100644 index 00000000..9d6df9ce --- /dev/null +++ b/src/renderer/components/WithTooltip/tooltip_content.tsx @@ -0,0 +1,32 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import classNames from 'classnames'; +import type {ReactNode} from 'react'; +import React, {memo} from 'react'; +import type {MessageDescriptor} from 'react-intl'; + +interface Props { + title: string | ReactNode | MessageDescriptor; + isEmojiLarge?: boolean; + hint?: string | ReactNode | MessageDescriptor; +} + +function TooltipContent(props: Props) { + return ( +
+ + {props.title} + + {props.hint && ( + {props.hint} + )} +
+ ); +} + +export default memo(TooltipContent); diff --git a/src/renderer/components/WithTooltip/with_tooltip.scss b/src/renderer/components/WithTooltip/with_tooltip.scss new file mode 100644 index 00000000..b41bce2c --- /dev/null +++ b/src/renderer/components/WithTooltip/with_tooltip.scss @@ -0,0 +1,68 @@ +@use '../../css/variables'; + +.tooltipContainer { + z-index: variables.$z-index-tooltip; + max-width: 220px; + padding: 4px 8px; + border-radius: 4px; + background: rgba(0, 0, 0, 1); + box-shadow: 0 6px 14px rgba(0, 0, 0, 0.12); + line-height: 18px; + pointer-events: none; + text-align: center; + word-break: break-word; + + > .tooltipContent { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-family: "Open Sans", sans-serif; + + > .tooltipContentTitleContainer { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 6px; + + &.isEmojiLarge { + flex-direction: column; + gap: 2px; + + > .tooltipContentEmoji { + padding-top: 1px; + } + } + + > .tooltipContentEmoji { + display: flex; + align-items: center; + justify-content: center; + } + + > .tooltipContentTitle { + color: #ffffff; + font-size: 12px; + font-weight: 600; + line-height: 15px; + } + } + + > .tooltipContentShortcut { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + padding: 4px 0; + gap: 2px; + } + + > .tooltipContentHint { + color: rgba(255, 255, 255, 0.64); + font-size: 11px; + font-weight: 600; + line-height: 16px; + } + } +} diff --git a/src/renderer/constants.ts b/src/renderer/constants.ts index 378bda3a..15a86c51 100644 --- a/src/renderer/constants.ts +++ b/src/renderer/constants.ts @@ -97,4 +97,28 @@ export const Constants = { flv: 'video', webm: 'video', }, + + /** + * This is the ID of the root portal container that is used to render modals and other components + * that need to be rendered outside of the main app container. + */ + RootHtmlPortalId: 'root-portal', + OverlaysTimings: { + CURSOR_REST_TIME_BEFORE_OPEN: 400, // in ms + CURSOR_MOUSEOVER_TO_OPEN: 400, // in ms + CURSOR_MOUSEOUT_TO_CLOSE: 0, + CURSOR_MOUSEOUT_TO_CLOSE_WITH_DELAY: 200, // in ms + FADE_IN_DURATION: 250, // in ms + FADE_OUT_DURATION: 150, // in ms + }, + OverlayTransitionStyles: { + START: { + opacity: 0, + }, + }, + OverlayArrow: { + WIDTH: 10, // in px + HEIGHT: 6, // in px + OFFSET: 8, // in px + }, }; diff --git a/src/renderer/css/_variables.scss b/src/renderer/css/_variables.scss new file mode 100644 index 00000000..e75a61d5 --- /dev/null +++ b/src/renderer/css/_variables.scss @@ -0,0 +1,2 @@ +// Since they can be used on any modal, menu or popover for now they are highest +$z-index-tooltip: 1350; \ No newline at end of file diff --git a/src/renderer/css/components/DeveloperModeIndicator.scss b/src/renderer/css/components/DeveloperModeIndicator.scss index 50fd421a..b4f4d6ae 100644 --- a/src/renderer/css/components/DeveloperModeIndicator.scss +++ b/src/renderer/css/components/DeveloperModeIndicator.scss @@ -47,8 +47,6 @@ } } -#DeveloperModeIndicator__tooltip { - > .tooltip-inner { - max-width: none; - } +.DeveloperModeIndicator__tooltip.tooltipContainer { + max-width: none; } diff --git a/src/renderer/index.html b/src/renderer/index.html index dcd28249..5cd5cf76 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -6,5 +6,6 @@
+