diff --git a/e2e-tests/cypress/tests/integration/channels/plugins/link_tooltip_spec.js b/e2e-tests/cypress/tests/integration/channels/plugins/link_tooltip_spec.js index 19b80998e9..827dc75976 100644 --- a/e2e-tests/cypress/tests/integration/channels/plugins/link_tooltip_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/plugins/link_tooltip_spec.js @@ -39,23 +39,20 @@ describe('Link tooltips', () => { cy.apiInitSetup({loginAfter: true}).then(({team, channel}) => { cy.visit(`/${team.name}/channels/${channel.name}`); }); - cy.postMessage('www.test.com'); }); it('MM-T3422 fade in and out with an animation', () => { - cy.get('a[href*="www.test.com"] span').as('link'); - cy.contains('This is a custom tooltip from the Demo Plugin').parents('.tooltip-container').as('tooltip-container'); + const url = 'www.test.com'; + cy.postMessage(url); + cy.uiWaitUntilMessagePostedIncludes(url); - // # Mouse over the link - cy.get('@link').trigger('mouseover'); + // # Hover over the plugin link + cy.findByText(url).should('exist').focus(); // * Check tooltip has appeared - cy.get('@tooltip-container').should('have.class', 'visible'); + cy.findByText('This is a custom tooltip from the Demo Plugin').should('be.visible'); - // # Mouse out the link - cy.get('@link').trigger('mouseout'); - - // * Check tooltip has disappeared - cy.get('@tooltip-container').should('not.have.class', 'visible'); + // # Close the tooltip + cy.get('body').type('{esc}'); }); }); diff --git a/webapp/channels/src/components/link_tooltip/__snapshots__/link_tooltip.test.tsx.snap b/webapp/channels/src/components/link_tooltip/__snapshots__/link_tooltip.test.tsx.snap deleted file mode 100644 index acb38018af..0000000000 --- a/webapp/channels/src/components/link_tooltip/__snapshots__/link_tooltip.test.tsx.snap +++ /dev/null @@ -1,80 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`components/link_tooltip/link_tooltip should match snapshot 1`] = ` - -
- -
- - test title - -
-`; - -exports[`components/link_tooltip/link_tooltip should match snapshot with uncommon link structure 1`] = ` - -
- -
- - - - foo - - - and - - - bar - - - -
-`; diff --git a/webapp/channels/src/components/link_tooltip/link_tooltip.scss b/webapp/channels/src/components/link_tooltip/link_tooltip.scss deleted file mode 100644 index b8ae719662..0000000000 --- a/webapp/channels/src/components/link_tooltip/link_tooltip.scss +++ /dev/null @@ -1,12 +0,0 @@ -@use 'utils/variables'; - -.tooltip-container { - opacity: 0; - transition: opacity variables.$transition-quick ease-in; - visibility: hidden; - - &.visible { - opacity: 1; - visibility: visible; - } -} diff --git a/webapp/channels/src/components/link_tooltip/link_tooltip.test.tsx b/webapp/channels/src/components/link_tooltip/link_tooltip.test.tsx deleted file mode 100644 index ad7fff3141..0000000000 --- a/webapp/channels/src/components/link_tooltip/link_tooltip.test.tsx +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {shallow} from 'enzyme'; -import React from 'react'; -import type {ReactPortal} from 'react'; -import ReactDOM from 'react-dom'; - -import LinkTooltip from 'components/link_tooltip/link_tooltip'; - -describe('components/link_tooltip/link_tooltip', () => { - test('should match snapshot', () => { - ReactDOM.createPortal = (node) => node as ReactPortal; - const wrapper = shallow( - - {'test title'} - , - ); - - expect(wrapper).toMatchSnapshot(); - expect(wrapper.find('span').text()).toBe('test title'); - }); - - test('should match snapshot with uncommon link structure', () => { - ReactDOM.createPortal = (node) => node as ReactPortal; - const wrapper = shallow( - - - {'foo'} - - {' and '} - - {'bar'} - - , - ); - - expect(wrapper).toMatchSnapshot(); - expect(wrapper.find('span').at(0).text()).toBe('foo and bar'); - }); -}); diff --git a/webapp/channels/src/components/link_tooltip/link_tooltip.tsx b/webapp/channels/src/components/link_tooltip/link_tooltip.tsx deleted file mode 100644 index 5c32b9ebbc..0000000000 --- a/webapp/channels/src/components/link_tooltip/link_tooltip.tsx +++ /dev/null @@ -1,138 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import classNames from 'classnames'; -import Popper from 'popper.js'; -import React from 'react'; -import type {RefObject, CSSProperties} from 'react'; -import ReactDOM from 'react-dom'; - -import Pluggable from 'plugins/pluggable'; -import {Constants} from 'utils/constants'; - -import './link_tooltip.scss'; - -const tooltipContainerStyles: CSSProperties = { - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - zIndex: 1070, - position: 'absolute', - top: -1000, - left: -1000, -}; - -type Props = { - href: string; - attributes: {[attribute: string]: string}; - children?: React.ReactNode; -} - -type State = { - show: boolean; -} - -export default class LinkTooltip extends React.PureComponent { - private tooltipContainerRef: RefObject; - private hideTimeout: number; - private showTimeout: number; - private popper?: Popper; - - public constructor(props: Props) { - super(props); - - this.tooltipContainerRef = React.createRef(); - this.showTimeout = -1; - this.hideTimeout = -1; - - this.state = { - show: false, - }; - } - - public showTooltip = (e: React.MouseEvent): void => { - //clear the hideTimeout in the case when the cursor is moved from a tooltipContainer child to the link - window.clearTimeout(this.hideTimeout); - - if (!this.state.show) { - const target = e.currentTarget; - const tooltipContainer = this.tooltipContainerRef.current; - - //clear the old this.showTimeout if there is any before overriding - window.clearTimeout(this.showTimeout); - - this.showTimeout = window.setTimeout(() => { - this.setState({show: true}); - - if (!tooltipContainer) { - return; - } - - const addChildEventListeners = (node: Node) => { - node.addEventListener('mouseover', () => clearTimeout(this.hideTimeout)); - (node as HTMLElement).addEventListener('mouseleave', (event) => { - if (event.relatedTarget !== null) { - this.hideTooltip(); - } - }); - }; - tooltipContainer.childNodes.forEach(addChildEventListeners); - - this.popper = new Popper(target, tooltipContainer, { - placement: 'bottom', - modifiers: { - preventOverflow: {enabled: false}, - hide: {enabled: false}, - }, - }); - }, Constants.OVERLAY_TIME_DELAY); - } - }; - - public hideTooltip = (): void => { - //clear the old this.hideTimeout if there is any before overriding - window.clearTimeout(this.hideTimeout); - - this.hideTimeout = window.setTimeout(() => { - this.setState({show: false}); - - //prevent executing the showTimeout after the hideTooltip - clearTimeout(this.showTimeout); - }, Constants.OVERLAY_TIME_DELAY_SMALL); - }; - - public render() { - const {href, children, attributes} = this.props; - - const dataAttributes = { - 'data-hashtag': attributes['data-hashtag'], - 'data-link': attributes['data-link'], - 'data-channel-mention': attributes['data-channel-mention'], - }; - return ( - <> - {ReactDOM.createPortal( -
- -
, - document.getElementById('root') as HTMLElement, - )} - - {children} - - - ); - } -} diff --git a/webapp/channels/src/components/plugin_link_tooltip/index.tsx b/webapp/channels/src/components/plugin_link_tooltip/index.tsx new file mode 100644 index 0000000000..92396274d6 --- /dev/null +++ b/webapp/channels/src/components/plugin_link_tooltip/index.tsx @@ -0,0 +1,112 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import { + useFloating, + autoUpdate, + safePolygon, + useHover, + useDismiss, + useInteractions, + FloatingPortal, + autoPlacement, + inline, + useTransitionStyles, + FloatingOverlay, + FloatingFocusManager, + useFocus, +} from '@floating-ui/react'; +import React, {useState} from 'react'; +import type {AnchorHTMLAttributes, ReactElement} from 'react'; + +import Pluggable from 'plugins/pluggable'; +import {RootHtmlPortalId, OverlaysTimings, OverlayTransitionStyles} from 'utils/constants'; + +import './plugin_link_tooltip.scss'; + +interface Props { + nodeAttributes: AnchorHTMLAttributes; + children: ReactElement; +} + +/** + * A key drawback of this component is that it gets attached to all links in the app if any installed plugin + * supports link previews. Ideally plugins should have provided a regex matcher upfront, allowing us to + * conditionally render the component only when needed. + */ +export default function PluginLinkTooltip(props: Props) { + const [isOpen, setOpen] = useState(false); + + const {refs: {setReference, setFloating}, floatingStyles, context: floatingContext} = useFloating({ + open: isOpen, + onOpenChange: setOpen, + whileElementsMounted: autoUpdate, + middleware: [ + inline(), + autoPlacement({ + allowedPlacements: ['top', 'bottom'], + }), + ], + }); + + const {isMounted, styles: transitionStyles} = useTransitionStyles(floatingContext, TRANSITION_STYLE_PROPS); + + const hoverInteractions = useHover(floatingContext, HOVER_PROPS); + const focusInteractions = useFocus(floatingContext); + const dismissInteraction = useDismiss(floatingContext); + + const {getReferenceProps, getFloatingProps} = useInteractions([ + hoverInteractions, + focusInteractions, + dismissInteraction, + ]); + + return ( + <> + + {props.children} + + + {isMounted && ( + + + +
+ +
+
+
+
+ )} + + ); +} + +const TRANSITION_STYLE_PROPS = { + duration: { + open: OverlaysTimings.FADE_IN_DURATION, + close: OverlaysTimings.FADE_OUT_DURATION, + }, + initial: OverlayTransitionStyles.START, +}; + +const HOVER_PROPS = { + restMs: OverlaysTimings.CURSOR_REST_TIME_BEFORE_OPEN, + move: false, + handleClose: safePolygon({ + requireIntent: false, + blockPointerEvents: true, + }), +}; diff --git a/webapp/channels/src/components/plugin_link_tooltip/plugin_link_tooltip.scss b/webapp/channels/src/components/plugin_link_tooltip/plugin_link_tooltip.scss new file mode 100644 index 0000000000..164584dcc7 --- /dev/null +++ b/webapp/channels/src/components/plugin_link_tooltip/plugin_link_tooltip.scss @@ -0,0 +1,20 @@ +@use 'utils/variables'; + +.plugin-link-tooltip-floating-overlay { + z-index: variables.$z-index-popover; +} + +// This is as per UX guidelines what the container of the +// plugin link tooltip should be, not included currently +// as it will be a breaking change for the plugin's implemented +//link tooltips with styling +.plugin-link-tooltip-container { + border: 1px solid rgba(var(--center-channel-color-rgb), 0.16); + border-radius: 4px; + background: var(--center-channel-bg); + box-shadow: var(--elevation-4); + + .plugin-link-tooltip-arrow { + fill: var(--center-channel-bg); + } +} diff --git a/webapp/channels/src/utils/__snapshots__/message_html_to_component.test.tsx.snap b/webapp/channels/src/utils/__snapshots__/message_html_to_component.test.tsx.snap index 2f3c35e49d..af71c5e7a7 100644 --- a/webapp/channels/src/utils/__snapshots__/message_html_to_component.test.tsx.snap +++ b/webapp/channels/src/utils/__snapshots__/message_html_to_component.test.tsx.snap @@ -144,26 +144,18 @@ Array [ exports[`messageHtmlToComponent link with enabled a tooltip plugin 1`] = `

lorem ipsum - - - www.dolor.com - - + } + > + www.dolor.com + sit amet

`; diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 9e5c8f02b6..f8b75dfaec 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -2009,8 +2009,6 @@ export const Constants = { COMMAND_SUGGESTION_CHANNEL: 'channel', COMMAND_SUGGESTION_USER: 'user', }, - OVERLAY_TIME_DELAY_SMALL: 100, - OVERLAY_TIME_DELAY: 400, PERMALINK_FADEOUT: 5000, DEFAULT_MAX_USERS_PER_TEAM: 50, DEFAULT_MAX_CHANNELS_PER_TEAM: 2000, diff --git a/webapp/channels/src/utils/message_html_to_component.tsx b/webapp/channels/src/utils/message_html_to_component.tsx index daecfd1ed3..15eb7fb702 100644 --- a/webapp/channels/src/utils/message_html_to_component.tsx +++ b/webapp/channels/src/utils/message_html_to_component.tsx @@ -10,8 +10,8 @@ import AtSumOfMembersMention from 'components/at_sum_members_mention'; import CodeBlock from 'components/code_block/code_block'; import LatexBlock from 'components/latex_block'; import LatexInline from 'components/latex_inline'; -import LinkTooltip from 'components/link_tooltip/link_tooltip'; import MarkdownImage from 'components/markdown_image'; +import PluginLinkTooltip from 'components/plugin_link_tooltip'; import PostEmoji from 'components/post_emoji'; import PostEditedIndicator from 'components/post_view/post_edited_indicator'; @@ -107,18 +107,14 @@ export function messageHtmlToComponent(html: string, options: Options = {}) { ]; if (options.hasPluginTooltips) { - const hrefAttrib = 'href'; processingInstructions.push({ - replaceChildren: true, - shouldProcessNode: (node: any) => node.type === 'tag' && node.name === 'a' && node.attribs[hrefAttrib], + replaceChildren: false, + shouldProcessNode: (node: any) => node.type === 'tag' && node.name === 'a' && node.attribs.href, processNode: (node: any, children: any) => { return ( - + {children} - + ); }, });