From f18cce8a8f40f13e5355684274def074676d9045 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Thu, 21 Apr 2022 21:28:17 +0200 Subject: [PATCH 01/23] 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 --- src/BasePlatform.ts | 8 +++ src/HtmlUtils.tsx | 54 +++++++++++++++++++ .../views/messages/EditHistoryMessage.tsx | 9 ++++ src/components/views/messages/TextualBody.tsx | 1 + 4 files changed, 72 insertions(+) diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index b7f52d38952..ee7ba560ff4 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -222,6 +222,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 + */ + needsUrlTooltips(): boolean { + return false; + } + /** * Returns a promise that resolves to a string representing the current version of the application. */ diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index ac26eccc718..7cb22b034c3 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -18,6 +18,7 @@ limitations under the License. */ import React, { ReactNode } from 'react'; +import ReactDOM from 'react-dom'; import sanitizeHtml from 'sanitize-html'; import cheerio from 'cheerio'; import classNames from 'classnames'; @@ -35,6 +36,8 @@ import { getEmojiFromUnicode } from "./emoji"; import { mediaFromMxc } from "./customisations/Media"; import { ELEMENT_URL_PATTERN, options as linkifyMatrixOptions } from './linkify-matrix'; import { stripHTMLReply, stripPlainReply } from './utils/Reply'; +import TextWithTooltip from './components/views/elements/TextWithTooltip'; +import PlatformPeg from './PlatformPeg'; // Anything outside the basic multilingual plane will be a surrogate pair const SURROGATE_PAIR_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/; @@ -635,6 +638,57 @@ export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatri return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams); } +const getAbsoluteUrl = (() => { + let a: HTMLAnchorElement; + + return (url: string) => { + if (!a) { + a = document.createElement('a'); + } + a.href = url; + return a.href; + }; +})(); + +/** + * Recurses depth-first through a DOM tree, adding tooltip previews for link elements. + * + * @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. + */ +export function tooltipifyLinks(rootNodes: ArrayLike, ignoredNodes: 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 href = node.getAttribute("href"); + const tooltip = + + ; + ReactDOM.render(tooltip, node); + tooltipified = true; + } + + if (node.childNodes && node.childNodes.length && !tooltipified) { + tooltipifyLinks(node.childNodes as NodeListOf, ignoredNodes); + } + + node = node.nextSibling as Element; + } +} + /** * Returns if a node is a block element or not. * Only takes html nodes into account that are allowed in matrix messages. diff --git a/src/components/views/messages/EditHistoryMessage.tsx b/src/components/views/messages/EditHistoryMessage.tsx index 30922b62e5b..cfb07d2be13 100644 --- a/src/components/views/messages/EditHistoryMessage.tsx +++ b/src/components/views/messages/EditHistoryMessage.tsx @@ -93,8 +93,16 @@ export default class EditHistoryMessage extends React.PureComponent { // 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); + HtmlUtils.tooltipifyLinks([this.contentRef.current], this.pills); this.calculateUrlPreview(); if (this.props.mxEvent.getContent().format === "org.matrix.custom.html") { From cf07f54fac457a873c0b4b1b18d6016869a4842a Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Fri, 22 Apr 2022 21:19:50 +0200 Subject: [PATCH 02/23] Gracefully handle missing platform --- src/HtmlUtils.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 7cb22b034c3..9435721f3ac 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -658,7 +658,7 @@ const getAbsoluteUrl = (() => { * @param {Element[]} ignoredNodes: a list of nodes to not recurse into. */ export function tooltipifyLinks(rootNodes: ArrayLike, ignoredNodes: Element[]) { - if (!PlatformPeg.get().needsUrlTooltips()) { + if (!PlatformPeg.get()?.needsUrlTooltips()) { return; } From 5da4ab872be2a6ff12bb5a6582cbb538ae4ba053 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Mon, 2 May 2022 20:17:42 +0200 Subject: [PATCH 03/23] Use public access modifier Co-authored-by: Travis Ralston --- src/BasePlatform.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index ee7ba560ff4..f8f974fd632 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -226,7 +226,7 @@ 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 */ - needsUrlTooltips(): boolean { + public needsUrlTooltips(): boolean { return false; } From f90805f3a38f484bb47b84cfc07ca6b95c8e7e27 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Mon, 2 May 2022 20:18:02 +0200 Subject: [PATCH 04/23] Use exact inequality Co-authored-by: Travis Ralston --- src/HtmlUtils.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 9435721f3ac..1f3bff0df05 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -672,7 +672,7 @@ export function tooltipifyLinks(rootNodes: ArrayLike, ignoredNodes: Ele continue; } - if (node.tagName === "A" && node.getAttribute("href") && node.getAttribute("href") != node.textContent.trim()) { + if (node.tagName === "A" && node.getAttribute("href") && node.getAttribute("href") !== node.textContent.trim()) { const href = node.getAttribute("href"); const tooltip = From 1f2808b7fffc36826da8a78289acd316479117f5 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Mon, 2 May 2022 20:25:29 +0200 Subject: [PATCH 05/23] Document getAbsoluteUrl --- src/HtmlUtils.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 1f3bff0df05..f225e42f3de 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -638,7 +638,14 @@ export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatri return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams); } +/** + * Turns a given URL into an absolute one. + * @param {string} url The URL transform + * @returns {string} Absolute URL + */ const getAbsoluteUrl = (() => { + // Use a single cached anchor element stored in a closure to avoid having to recreate + // a new one on every call and increase performance let a: HTMLAnchorElement; return (url: string) => { From c5ae0807a1f983e23caac1c812190fa8e5bfc5cb Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Mon, 2 May 2022 20:34:34 +0200 Subject: [PATCH 06/23] Appease the linter --- src/HtmlUtils.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index f225e42f3de..4858958b412 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -679,7 +679,9 @@ export function tooltipifyLinks(rootNodes: ArrayLike, ignoredNodes: Ele continue; } - if (node.tagName === "A" && node.getAttribute("href") && node.getAttribute("href") !== node.textContent.trim()) { + if (node.tagName === "A" && node.getAttribute("href") + && node.getAttribute("href") !== node.textContent.trim() + ) { const href = node.getAttribute("href"); const tooltip = From 73dfb560e070a590616f310add14c4090d69f201 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Fri, 6 May 2022 08:41:55 +0200 Subject: [PATCH 07/23] Clarify performance impact in comment Co-authored-by: Travis Ralston --- src/HtmlUtils.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 4858958b412..36d1a2516bd 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -645,7 +645,7 @@ export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatri */ const getAbsoluteUrl = (() => { // Use a single cached anchor element stored in a closure to avoid having to recreate - // a new one on every call and increase performance + // a new one on every call, which would affect performance let a: HTMLAnchorElement; return (url: string) => { From e6946ac903e12d2f87a1051704436a989d3b8a6d Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Mon, 9 May 2022 20:41:20 +0200 Subject: [PATCH 08/23] Use URL instead of anchor element hack --- src/HtmlUtils.tsx | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index db73cfc8984..2488f52988b 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -642,25 +642,6 @@ export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatri return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams); } -/** - * Turns a given URL into an absolute one. - * @param {string} url The URL transform - * @returns {string} Absolute URL - */ -const getAbsoluteUrl = (() => { - // Use a single cached anchor element stored in a closure to avoid having to recreate - // a new one on every call, which would affect performance - let a: HTMLAnchorElement; - - return (url: string) => { - if (!a) { - a = document.createElement('a'); - } - a.href = url; - return a.href; - }; -})(); - /** * Recurses depth-first through a DOM tree, adding tooltip previews for link elements. * @@ -687,7 +668,7 @@ export function tooltipifyLinks(rootNodes: ArrayLike, ignoredNodes: Ele && node.getAttribute("href") !== node.textContent.trim() ) { const href = node.getAttribute("href"); - const tooltip = + const tooltip = ; ReactDOM.render(tooltip, node); From cd0574bb0f302b32da9cee05655c637bb5fcf112 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Wed, 11 May 2022 21:11:02 +0200 Subject: [PATCH 09/23] Wrap anchor in tooltip target and only allow focus on anchor --- src/HtmlUtils.tsx | 10 +++++++--- src/components/views/elements/TextWithTooltip.tsx | 4 ++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 2488f52988b..d8c82504e28 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -667,11 +667,15 @@ export function tooltipifyLinks(rootNodes: ArrayLike, ignoredNodes: Ele 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 = - + // Disable focusing on the tooltip target to avoid double / nested focus. The contained anchor element + // itself allows focusing which also triggers the tooltip. + const tooltip = + ; - ReactDOM.render(tooltip, node); + ReactDOM.render(tooltip, container); + node.parentNode.replaceChild(container, node); tooltipified = true; } 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; From 0b5d048700d762860ec08061802ad7226dffeb95 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Fri, 13 May 2022 20:01:05 +0200 Subject: [PATCH 10/23] Use optional chaining Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> --- src/HtmlUtils.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index e113166e078..a782f621a1d 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -679,7 +679,7 @@ export function tooltipifyLinks(rootNodes: ArrayLike, ignoredNodes: Ele tooltipified = true; } - if (node.childNodes && node.childNodes.length && !tooltipified) { + if (node.childNodes?.length && !tooltipified) { tooltipifyLinks(node.childNodes as NodeListOf, ignoredNodes); } From 49ee784f9f96a736e82fda42d3573495839dd7a6 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Fri, 13 May 2022 20:02:35 +0200 Subject: [PATCH 11/23] Use double quotes for consistency --- src/HtmlUtils.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index a782f621a1d..feea2414965 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -667,7 +667,7 @@ export function tooltipifyLinks(rootNodes: ArrayLike, ignoredNodes: Ele if (node.tagName === "A" && node.getAttribute("href") && node.getAttribute("href") !== node.textContent.trim() ) { - const container = document.createElement('span'); + const container = document.createElement("span"); const href = node.getAttribute("href"); // Disable focusing on the tooltip target to avoid double / nested focus. The contained anchor element // itself allows focusing which also triggers the tooltip. From 15615556ca648d9cee550abd6820e88635e5199e Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Fri, 13 May 2022 20:39:33 +0200 Subject: [PATCH 12/23] Accumulate and unmount tooltips and extract tooltipify.tsx --- src/HtmlUtils.tsx | 48 ----------- .../views/messages/EditHistoryMessage.tsx | 5 +- src/components/views/messages/TextualBody.tsx | 5 +- src/utils/tooltipify.tsx | 86 +++++++++++++++++++ 4 files changed, 94 insertions(+), 50 deletions(-) create mode 100644 src/utils/tooltipify.tsx diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index feea2414965..6b9724caab6 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -18,7 +18,6 @@ limitations under the License. */ import React, { ReactNode } from 'react'; -import ReactDOM from 'react-dom'; import sanitizeHtml from 'sanitize-html'; import cheerio from 'cheerio'; import classNames from 'classnames'; @@ -40,8 +39,6 @@ import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks" import { getEmojiFromUnicode } from "./emoji"; import { mediaFromMxc } from "./customisations/Media"; import { stripHTMLReply, stripPlainReply } from './utils/Reply'; -import TextWithTooltip from './components/views/elements/TextWithTooltip'; -import PlatformPeg from './PlatformPeg'; // Anything outside the basic multilingual plane will be a surrogate pair const SURROGATE_PAIR_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/; @@ -642,51 +639,6 @@ export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatri return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams); } -/** - * Recurses depth-first through a DOM tree, adding tooltip previews for link elements. - * - * @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. - */ -export function tooltipifyLinks(rootNodes: ArrayLike, ignoredNodes: 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"); - // Disable focusing on the tooltip target to avoid double / nested focus. The contained anchor element - // itself allows focusing which also triggers the tooltip. - const tooltip = - - ; - ReactDOM.render(tooltip, container); - node.parentNode.replaceChild(container, node); - tooltipified = true; - } - - if (node.childNodes?.length && !tooltipified) { - tooltipifyLinks(node.childNodes as NodeListOf, ignoredNodes); - } - - node = node.nextSibling as Element; - } -} - /** * Returns if a node is a block element or not. * Only takes html nodes into account that are allowed in matrix messages. diff --git a/src/components/views/messages/EditHistoryMessage.tsx b/src/components/views/messages/EditHistoryMessage.tsx index cfb07d2be13..3ca03b81451 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); @@ -96,7 +98,7 @@ export default class EditHistoryMessage extends React.PureComponent { private unmounted = false; private pills: Element[] = []; + private tooltips: Element[] = []; static contextType = RoomContext; public context!: React.ContextType; @@ -91,7 +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); - HtmlUtils.tooltipifyLinks([this.contentRef.current], this.pills); + tooltipifyLinks([this.contentRef.current], this.pills, this.tooltips); this.calculateUrlPreview(); if (this.props.mxEvent.getContent().format === "org.matrix.custom.html") { @@ -284,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/utils/tooltipify.tsx b/src/utils/tooltipify.tsx new file mode 100644 index 00000000000..919b89cca07 --- /dev/null +++ b/src/utils/tooltipify.tsx @@ -0,0 +1,86 @@ +/* +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 TextWithTooltip from "../components/views/elements/TextWithTooltip"; + +/** + * Recurses depth-first through a DOM tree, adding tooltip previews for link elements. + * + * @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"); + + // Disable focusing on the tooltip target to avoid double / nested focus. The contained anchor element + // itself allows focusing which also triggers the tooltip. + 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); + } +} From cfe46f25a3d04d78ce42c88edc416cbe78429e6c Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Fri, 13 May 2022 20:53:56 +0200 Subject: [PATCH 13/23] Fix indentation --- src/utils/tooltipify.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/tooltipify.tsx b/src/utils/tooltipify.tsx index 919b89cca07..ecdf3dabf43 100644 --- a/src/utils/tooltipify.tsx +++ b/src/utils/tooltipify.tsx @@ -30,7 +30,7 @@ import TextWithTooltip from "../components/views/elements/TextWithTooltip"; * 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[]) { +export function tooltipifyLinks(rootNodes: ArrayLike, ignoredNodes: Element[], containers: Element[]) { if (!PlatformPeg.get()?.needsUrlTooltips()) { return; } @@ -79,7 +79,7 @@ import TextWithTooltip from "../components/views/elements/TextWithTooltip"; * * @param {Element[]} containers - array of tooltip containers to unmount */ - export function unmountTooltips(containers: Element[]) { +export function unmountTooltips(containers: Element[]) { for (const container of containers) { ReactDOM.unmountComponentAtNode(container); } From d90964f8a2c1f8922c4676b47bab364ebacd0c72 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Fri, 13 May 2022 21:31:33 +0200 Subject: [PATCH 14/23] Blur tooltip target on click --- src/utils/tooltipify.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/utils/tooltipify.tsx b/src/utils/tooltipify.tsx index ecdf3dabf43..7e7eb7131bf 100644 --- a/src/utils/tooltipify.tsx +++ b/src/utils/tooltipify.tsx @@ -51,9 +51,13 @@ export function tooltipifyLinks(rootNodes: ArrayLike, ignoredNodes: Ele const container = document.createElement("span"); const href = node.getAttribute("href"); - // Disable focusing on the tooltip target to avoid double / nested focus. The contained anchor element - // itself allows focusing which also triggers the tooltip. - const tooltip = + const tooltip = (e.target as HTMLElement).blur() } // Force tooltip to hide on clickout + > ; From b8f68e0bdd0fc0bb52f5021dfa0b0e046ac55cd9 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Fri, 13 May 2022 21:36:49 +0200 Subject: [PATCH 15/23] Remove space --- src/utils/tooltipify.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/tooltipify.tsx b/src/utils/tooltipify.tsx index 7e7eb7131bf..6689adf8d77 100644 --- a/src/utils/tooltipify.tsx +++ b/src/utils/tooltipify.tsx @@ -56,7 +56,7 @@ export function tooltipifyLinks(rootNodes: ArrayLike, ignoredNodes: Ele // itself allows focusing which also triggers the tooltip. tabIndex={-1} tooltip={new URL(href, window.location.href).toString()} - onClick={e => (e.target as HTMLElement).blur() } // Force tooltip to hide on clickout + onClick={e => (e.target as HTMLElement).blur()} // Force tooltip to hide on clickout > ; From 7b6f64192d89243c9efecba092f9856f6a09e1db Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Tue, 17 May 2022 20:19:07 +0200 Subject: [PATCH 16/23] Mention platform flag in comment --- src/utils/tooltipify.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/utils/tooltipify.tsx b/src/utils/tooltipify.tsx index 6689adf8d77..8489f28ee0c 100644 --- a/src/utils/tooltipify.tsx +++ b/src/utils/tooltipify.tsx @@ -21,7 +21,8 @@ import PlatformPeg from "../PlatformPeg"; import TextWithTooltip from "../components/views/elements/TextWithTooltip"; /** - * Recurses depth-first through a DOM tree, adding tooltip previews for link elements. + * 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. From 1216b17424f06e079e9588afb8e879261f295014 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Tue, 17 May 2022 21:11:44 +0200 Subject: [PATCH 17/23] Add (simplistic) tests --- .../__snapshots__/tooltipify-test.tsx.snap | 32 +++++++++++ test/utils/tooltipify-test.tsx | 53 +++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 test/utils/__snapshots__/tooltipify-test.tsx.snap create mode 100644 test/utils/tooltipify-test.tsx diff --git a/test/utils/__snapshots__/tooltipify-test.tsx.snap b/test/utils/__snapshots__/tooltipify-test.tsx.snap new file mode 100644 index 00000000000..51543eaa398 --- /dev/null +++ b/test/utils/__snapshots__/tooltipify-test.tsx.snap @@ -0,0 +1,32 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`tooltipify does nothing for empty element 1`] = `
`; + +exports[`tooltipify ignores node 1`] = ` + +`; + +exports[`tooltipify wraps single anchor 1`] = ` +
+ +
+ + + click + + +
+
+
+`; diff --git a/test/utils/tooltipify-test.tsx b/test/utils/tooltipify-test.tsx new file mode 100644 index 00000000000..b7d2e3443e4 --- /dev/null +++ b/test/utils/tooltipify-test.tsx @@ -0,0 +1,53 @@ +/* +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(); + let containers: Element[] = []; + tooltipifyLinks([root], [], containers); + expect(containers).toHaveLength(0) + expect(root).toMatchSnapshot(); + }); + + it('wraps single anchor', () => { + const component = mount(); + const root = component.getDOMNode(); + let containers: Element[] = []; + tooltipifyLinks([root], [], containers); + expect(containers).toHaveLength(1) + expect(root).toMatchSnapshot(); + }); + + it('ignores node', () => { + const component = mount(); + const root = component.getDOMNode(); + let containers: Element[] = []; + tooltipifyLinks([root], [root.children[0]], containers); + expect(containers).toHaveLength(0) + expect(root).toMatchSnapshot(); + }); +}); From 9a711f1ed9651de7cee5d1ed10432bbdb62cf53a Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Tue, 17 May 2022 21:32:39 +0200 Subject: [PATCH 18/23] Fix lint errors --- test/utils/tooltipify-test.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/test/utils/tooltipify-test.tsx b/test/utils/tooltipify-test.tsx index b7d2e3443e4..56e8e028535 100644 --- a/test/utils/tooltipify-test.tsx +++ b/test/utils/tooltipify-test.tsx @@ -16,6 +16,7 @@ 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'; @@ -27,27 +28,27 @@ describe('tooltipify', () => { it('does nothing for empty element', () => { const component = mount(
); const root = component.getDOMNode(); - let containers: Element[] = []; + const containers: Element[] = []; tooltipifyLinks([root], [], containers); - expect(containers).toHaveLength(0) + expect(containers).toHaveLength(0); expect(root).toMatchSnapshot(); }); it('wraps single anchor', () => { const component = mount(); const root = component.getDOMNode(); - let containers: Element[] = []; + const containers: Element[] = []; tooltipifyLinks([root], [], containers); - expect(containers).toHaveLength(1) + expect(containers).toHaveLength(1); expect(root).toMatchSnapshot(); }); it('ignores node', () => { const component = mount(); const root = component.getDOMNode(); - let containers: Element[] = []; + const containers: Element[] = []; tooltipifyLinks([root], [root.children[0]], containers); - expect(containers).toHaveLength(0) + expect(containers).toHaveLength(0); expect(root).toMatchSnapshot(); }); }); From cdd181d1f26b921d4b6321a6a1298c87e320a7ed Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Wed, 18 May 2022 08:09:23 +0200 Subject: [PATCH 19/23] Fix lint errors ... for real --- test/utils/tooltipify-test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/utils/tooltipify-test.tsx b/test/utils/tooltipify-test.tsx index 56e8e028535..7754dbcdb19 100644 --- a/test/utils/tooltipify-test.tsx +++ b/test/utils/tooltipify-test.tsx @@ -26,7 +26,7 @@ describe('tooltipify', () => { .mockReturnValue({ needsUrlTooltips: () => true } as unknown as BasePlatform); it('does nothing for empty element', () => { - const component = mount(
); + const component = mount(
); const root = component.getDOMNode(); const containers: Element[] = []; tooltipifyLinks([root], [], containers); From 1e4cfacb57504a3586834e38f33e70e1eda0ed2b Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Wed, 18 May 2022 19:52:23 +0200 Subject: [PATCH 20/23] Replace snapshot tests with structural assertions --- .../__snapshots__/tooltipify-test.tsx.snap | 32 ------------------- test/utils/tooltipify-test.tsx | 10 ++++-- 2 files changed, 7 insertions(+), 35 deletions(-) delete mode 100644 test/utils/__snapshots__/tooltipify-test.tsx.snap diff --git a/test/utils/__snapshots__/tooltipify-test.tsx.snap b/test/utils/__snapshots__/tooltipify-test.tsx.snap deleted file mode 100644 index 51543eaa398..00000000000 --- a/test/utils/__snapshots__/tooltipify-test.tsx.snap +++ /dev/null @@ -1,32 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`tooltipify does nothing for empty element 1`] = `
`; - -exports[`tooltipify ignores node 1`] = ` - -`; - -exports[`tooltipify wraps single anchor 1`] = ` -
- -
- - - click - - -
-
-
-`; diff --git a/test/utils/tooltipify-test.tsx b/test/utils/tooltipify-test.tsx index 7754dbcdb19..bf87b96f5b3 100644 --- a/test/utils/tooltipify-test.tsx +++ b/test/utils/tooltipify-test.tsx @@ -28,10 +28,11 @@ describe('tooltipify', () => { 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).toMatchSnapshot(); + expect(root.outerHTML).toEqual(originalHtml); }); it('wraps single anchor', () => { @@ -40,15 +41,18 @@ describe('tooltipify', () => { const containers: Element[] = []; tooltipifyLinks([root], [], containers); expect(containers).toHaveLength(1); - expect(root).toMatchSnapshot(); + 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).toMatchSnapshot(); + expect(root.outerHTML).toEqual(originalHtml); }); }); From 837c56a91a01f35147e561d87e6e6a17b44b46ca Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Wed, 18 May 2022 19:54:56 +0200 Subject: [PATCH 21/23] Add missing semicolon --- test/utils/tooltipify-test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/utils/tooltipify-test.tsx b/test/utils/tooltipify-test.tsx index bf87b96f5b3..b94c829faf3 100644 --- a/test/utils/tooltipify-test.tsx +++ b/test/utils/tooltipify-test.tsx @@ -41,7 +41,7 @@ describe('tooltipify', () => { const containers: Element[] = []; tooltipifyLinks([root], [], containers); expect(containers).toHaveLength(1); - const anchor = root.querySelector(".mx_TextWithTooltip_target a") + const anchor = root.querySelector(".mx_TextWithTooltip_target a"); expect(anchor?.getAttribute("href")).toEqual("/foo"); expect(anchor?.innerHTML).toEqual("click"); }); From dc3a5d360280683fc66ffba284e0682cdd19768c Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Thu, 2 Jun 2022 20:44:29 +0200 Subject: [PATCH 22/23] Add tooltips in link previews --- .../views/elements/LinkWithTooltip.tsx | 44 +++++++++++++++++++ .../views/rooms/LinkPreviewWidget.tsx | 11 ++++- src/utils/tooltipify.tsx | 12 ++--- 3 files changed, 57 insertions(+), 10 deletions(-) create mode 100644 src/components/views/elements/LinkWithTooltip.tsx diff --git a/src/components/views/elements/LinkWithTooltip.tsx b/src/components/views/elements/LinkWithTooltip.tsx new file mode 100644 index 00000000000..60d841685a7 --- /dev/null +++ b/src/components/views/elements/LinkWithTooltip.tsx @@ -0,0 +1,44 @@ +/* + Copyright 2022 New Vector Ltd. + + 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/rooms/LinkPreviewWidget.tsx b/src/components/views/rooms/LinkPreviewWidget.tsx index d14c504dd8c..5d983d7e123 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,12 +120,19 @@ 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 index 8489f28ee0c..ec698aa198b 100644 --- a/src/utils/tooltipify.tsx +++ b/src/utils/tooltipify.tsx @@ -18,7 +18,7 @@ import React from "react"; import ReactDOM from 'react-dom'; import PlatformPeg from "../PlatformPeg"; -import TextWithTooltip from "../components/views/elements/TextWithTooltip"; +import LinkWithTooltip from "../components/views/elements/LinkWithTooltip"; /** * If the platform enabled needsUrlTooltips, recurses depth-first through a DOM tree, adding tooltip previews @@ -52,15 +52,9 @@ export function tooltipifyLinks(rootNodes: ArrayLike, ignoredNodes: Ele const container = document.createElement("span"); const href = node.getAttribute("href"); - const tooltip = (e.target as HTMLElement).blur()} // Force tooltip to hide on clickout - > + const tooltip = - ; + ; ReactDOM.render(tooltip, container); node.parentNode.replaceChild(container, node); From a76abc6725cb7e7fe0190805d90d519f96aa9b31 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Mon, 6 Jun 2022 09:41:04 +0200 Subject: [PATCH 23/23] Fix copyright --- src/components/views/elements/LinkWithTooltip.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/elements/LinkWithTooltip.tsx b/src/components/views/elements/LinkWithTooltip.tsx index 60d841685a7..b171df797f1 100644 --- a/src/components/views/elements/LinkWithTooltip.tsx +++ b/src/components/views/elements/LinkWithTooltip.tsx @@ -1,5 +1,5 @@ /* - Copyright 2022 New Vector Ltd. + 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.