From 6f21a155a4009b5aa8ddfe1cc7b397561ae756de Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Wed, 6 Jul 2022 11:43:30 +0200 Subject: [PATCH] Add option to display tooltip on link hover (#8394) * Add option to display tooltip on link hover This makes it possible for platforms like Electron apps, which lack a built-in URL preview in the status bar, to enable tooltip previews of links. Relates to: vector-im/element-web#6532 Signed-off-by: Johannes Marbach * Gracefully handle missing platform * Use public access modifier Co-authored-by: Travis Ralston * Use exact inequality Co-authored-by: Travis Ralston * Document getAbsoluteUrl * Appease the linter * Clarify performance impact in comment Co-authored-by: Travis Ralston * Use URL instead of anchor element hack * Wrap anchor in tooltip target and only allow focus on anchor * Use optional chaining Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> * Use double quotes for consistency * Accumulate and unmount tooltips and extract tooltipify.tsx * Fix indentation * Blur tooltip target on click * Remove space * Mention platform flag in comment * Add (simplistic) tests * Fix lint errors * Fix lint errors ... for real * Replace snapshot tests with structural assertions * Add missing semicolon * Add tooltips in link previews * Fix copyright Co-authored-by: Travis Ralston Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> --- src/BasePlatform.ts | 8 ++ .../views/elements/LinkWithTooltip.tsx | 44 ++++++++++ .../views/elements/TextWithTooltip.tsx | 4 +- .../views/messages/EditHistoryMessage.tsx | 12 +++ src/components/views/messages/TextualBody.tsx | 4 + .../views/rooms/LinkPreviewWidget.tsx | 11 ++- src/utils/tooltipify.tsx | 85 +++++++++++++++++++ test/utils/tooltipify-test.tsx | 58 +++++++++++++ 8 files changed, 223 insertions(+), 3 deletions(-) create mode 100644 src/components/views/elements/LinkWithTooltip.tsx create mode 100644 src/utils/tooltipify.tsx create mode 100644 test/utils/tooltipify-test.tsx diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index 3c83229755f..232b44d7c96 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -231,6 +231,14 @@ export default abstract class BasePlatform { } } + /** + * Returns true if the platform requires URL previews in tooltips, otherwise false. + * @returns {boolean} whether the platform requires URL previews in tooltips + */ + public needsUrlTooltips(): boolean { + return false; + } + /** * Returns a promise that resolves to a string representing the current version of the application. */ diff --git a/src/components/views/elements/LinkWithTooltip.tsx b/src/components/views/elements/LinkWithTooltip.tsx new file mode 100644 index 00000000000..b171df797f1 --- /dev/null +++ b/src/components/views/elements/LinkWithTooltip.tsx @@ -0,0 +1,44 @@ +/* + Copyright 2022 The Matrix.org Foundation C.I.C. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import React from 'react'; + +import TextWithTooltip from './TextWithTooltip'; + +interface IProps extends Omit, "tabIndex" | "onClick" > {} + +export default class LinkWithTooltip extends React.Component { + constructor(props: IProps) { + super(props); + } + + public render(): JSX.Element { + const { children, tooltip, ...props } = this.props; + + return ( + (e.target as HTMLElement).blur()} // Force tooltip to hide on clickout + {...props} + > + { children } + + ); + } +} diff --git a/src/components/views/elements/TextWithTooltip.tsx b/src/components/views/elements/TextWithTooltip.tsx index c8fa5376b87..33eacbeebed 100644 --- a/src/components/views/elements/TextWithTooltip.tsx +++ b/src/components/views/elements/TextWithTooltip.tsx @@ -14,12 +14,12 @@ limitations under the License. */ -import React from 'react'; +import React, { HTMLAttributes } from 'react'; import classNames from 'classnames'; import TooltipTarget from './TooltipTarget'; -interface IProps { +interface IProps extends HTMLAttributes { class?: string; tooltipClass?: string; tooltip: React.ReactNode; diff --git a/src/components/views/messages/EditHistoryMessage.tsx b/src/components/views/messages/EditHistoryMessage.tsx index 51c39be9b24..e3c11c61a47 100644 --- a/src/components/views/messages/EditHistoryMessage.tsx +++ b/src/components/views/messages/EditHistoryMessage.tsx @@ -22,6 +22,7 @@ import * as HtmlUtils from '../../../HtmlUtils'; import { editBodyDiffToHtml } from '../../../utils/MessageDiffUtils'; import { formatTime } from '../../../DateUtils'; import { pillifyLinks, unmountPills } from '../../../utils/pillify'; +import { tooltipifyLinks, unmountTooltips } from '../../../utils/tooltipify'; import { _t } from '../../../languageHandler'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import Modal from '../../../Modal'; @@ -52,6 +53,7 @@ interface IState { export default class EditHistoryMessage extends React.PureComponent { private content = createRef(); private pills: Element[] = []; + private tooltips: Element[] = []; constructor(props: IProps) { super(props); @@ -93,12 +95,21 @@ export default class EditHistoryMessage extends React.PureComponent { private unmounted = false; private pills: Element[] = []; + private tooltips: Element[] = []; static contextType = RoomContext; public context!: React.ContextType; @@ -91,6 +93,7 @@ export default class TextualBody extends React.Component { // we should be pillify them here by doing the linkifying BEFORE the pillifying. pillifyLinks([this.contentRef.current], this.props.mxEvent, this.pills); HtmlUtils.linkifyElement(this.contentRef.current); + tooltipifyLinks([this.contentRef.current], this.pills, this.tooltips); this.calculateUrlPreview(); if (this.props.mxEvent.getContent().format === "org.matrix.custom.html") { @@ -283,6 +286,7 @@ export default class TextualBody extends React.Component { componentWillUnmount() { this.unmounted = true; unmountPills(this.pills); + unmountTooltips(this.tooltips); } shouldComponentUpdate(nextProps, nextState) { diff --git a/src/components/views/rooms/LinkPreviewWidget.tsx b/src/components/views/rooms/LinkPreviewWidget.tsx index cb28739f179..af0b8a022e6 100644 --- a/src/components/views/rooms/LinkPreviewWidget.tsx +++ b/src/components/views/rooms/LinkPreviewWidget.tsx @@ -25,6 +25,8 @@ import Modal from "../../../Modal"; import * as ImageUtils from "../../../ImageUtils"; import { mediaFromMxc } from "../../../customisations/Media"; import ImageView from '../elements/ImageView'; +import LinkWithTooltip from '../elements/LinkWithTooltip'; +import PlatformPeg from '../../../PlatformPeg'; interface IProps { link: string; @@ -118,13 +120,20 @@ export default class LinkPreviewWidget extends React.Component { // opaque string. This does not allow any HTML to be injected into the DOM. const description = AllHtmlEntities.decode(p["og:description"] || ""); + const anchor = { p["og:title"] }; + const needsTooltip = PlatformPeg.get()?.needsUrlTooltips() && this.props.link !== p["og:title"].trim(); + return (
{ img }
- { p["og:title"] } + { needsTooltip ? + { anchor } + : anchor } { p["og:site_name"] && { (" - " + p["og:site_name"]) } } diff --git a/src/utils/tooltipify.tsx b/src/utils/tooltipify.tsx new file mode 100644 index 00000000000..ec698aa198b --- /dev/null +++ b/src/utils/tooltipify.tsx @@ -0,0 +1,85 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import ReactDOM from 'react-dom'; + +import PlatformPeg from "../PlatformPeg"; +import LinkWithTooltip from "../components/views/elements/LinkWithTooltip"; + +/** + * If the platform enabled needsUrlTooltips, recurses depth-first through a DOM tree, adding tooltip previews + * for link elements. Otherwise, does nothing. + * + * @param {Element[]} rootNodes - a list of sibling DOM nodes to traverse to try + * to add tooltips. + * @param {Element[]} ignoredNodes: a list of nodes to not recurse into. + * @param {Element[]} containers: an accumulator of the DOM nodes which contain + * React components that have been mounted by this function. The initial caller + * should pass in an empty array to seed the accumulator. + */ +export function tooltipifyLinks(rootNodes: ArrayLike, ignoredNodes: Element[], containers: Element[]) { + if (!PlatformPeg.get()?.needsUrlTooltips()) { + return; + } + + let node = rootNodes[0]; + + while (node) { + let tooltipified = false; + + if (ignoredNodes.indexOf(node) >= 0) { + node = node.nextSibling as Element; + continue; + } + + if (node.tagName === "A" && node.getAttribute("href") + && node.getAttribute("href") !== node.textContent.trim() + ) { + const container = document.createElement("span"); + const href = node.getAttribute("href"); + + const tooltip = + + ; + + ReactDOM.render(tooltip, container); + node.parentNode.replaceChild(container, node); + containers.push(container); + tooltipified = true; + } + + if (node.childNodes?.length && !tooltipified) { + tooltipifyLinks(node.childNodes as NodeListOf, ignoredNodes, containers); + } + + node = node.nextSibling as Element; + } +} + +/** + * Unmount tooltip containers created by tooltipifyLinks. + * + * It's critical to call this after tooltipifyLinks, otherwise + * tooltips will leak. + * + * @param {Element[]} containers - array of tooltip containers to unmount + */ +export function unmountTooltips(containers: Element[]) { + for (const container of containers) { + ReactDOM.unmountComponentAtNode(container); + } +} diff --git a/test/utils/tooltipify-test.tsx b/test/utils/tooltipify-test.tsx new file mode 100644 index 00000000000..b94c829faf3 --- /dev/null +++ b/test/utils/tooltipify-test.tsx @@ -0,0 +1,58 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import { mount } from 'enzyme'; + +import { tooltipifyLinks } from '../../src/utils/tooltipify'; +import PlatformPeg from '../../src/PlatformPeg'; +import BasePlatform from '../../src/BasePlatform'; + +describe('tooltipify', () => { + jest.spyOn(PlatformPeg, 'get') + .mockReturnValue({ needsUrlTooltips: () => true } as unknown as BasePlatform); + + it('does nothing for empty element', () => { + const component = mount(
); + const root = component.getDOMNode(); + const originalHtml = root.outerHTML; + const containers: Element[] = []; + tooltipifyLinks([root], [], containers); + expect(containers).toHaveLength(0); + expect(root.outerHTML).toEqual(originalHtml); + }); + + it('wraps single anchor', () => { + const component = mount(); + const root = component.getDOMNode(); + const containers: Element[] = []; + tooltipifyLinks([root], [], containers); + expect(containers).toHaveLength(1); + const anchor = root.querySelector(".mx_TextWithTooltip_target a"); + expect(anchor?.getAttribute("href")).toEqual("/foo"); + expect(anchor?.innerHTML).toEqual("click"); + }); + + it('ignores node', () => { + const component = mount(); + const root = component.getDOMNode(); + const originalHtml = root.outerHTML; + const containers: Element[] = []; + tooltipifyLinks([root], [root.children[0]], containers); + expect(containers).toHaveLength(0); + expect(root.outerHTML).toEqual(originalHtml); + }); +});