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 @@
+