Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit 6f21a15

Browse files
Johennesturt2livet3chguy
authored
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: element-hq/element-web#6532 Signed-off-by: Johannes Marbach <[email protected]> * Gracefully handle missing platform * Use public access modifier Co-authored-by: Travis Ralston <[email protected]> * Use exact inequality Co-authored-by: Travis Ralston <[email protected]> * Document getAbsoluteUrl * Appease the linter * Clarify performance impact in comment Co-authored-by: Travis Ralston <[email protected]> * 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 <[email protected]> * 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 <[email protected]> Co-authored-by: Michael Telatynski <[email protected]>
1 parent 530b51a commit 6f21a15

File tree

8 files changed

+223
-3
lines changed

8 files changed

+223
-3
lines changed

src/BasePlatform.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,14 @@ export default abstract class BasePlatform {
231231
}
232232
}
233233

234+
/**
235+
* Returns true if the platform requires URL previews in tooltips, otherwise false.
236+
* @returns {boolean} whether the platform requires URL previews in tooltips
237+
*/
238+
public needsUrlTooltips(): boolean {
239+
return false;
240+
}
241+
234242
/**
235243
* Returns a promise that resolves to a string representing the current version of the application.
236244
*/
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import React from 'react';
18+
19+
import TextWithTooltip from './TextWithTooltip';
20+
21+
interface IProps extends Omit<React.ComponentProps<typeof TextWithTooltip>, "tabIndex" | "onClick" > {}
22+
23+
export default class LinkWithTooltip extends React.Component<IProps> {
24+
constructor(props: IProps) {
25+
super(props);
26+
}
27+
28+
public render(): JSX.Element {
29+
const { children, tooltip, ...props } = this.props;
30+
31+
return (
32+
<TextWithTooltip
33+
// Disable focusing on the tooltip target to avoid double / nested focus. The contained anchor element
34+
// itself allows focusing which also triggers the tooltip.
35+
tabIndex={-1}
36+
tooltip={tooltip}
37+
onClick={e => (e.target as HTMLElement).blur()} // Force tooltip to hide on clickout
38+
{...props}
39+
>
40+
{ children }
41+
</TextWithTooltip>
42+
);
43+
}
44+
}

src/components/views/elements/TextWithTooltip.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,12 @@
1414
limitations under the License.
1515
*/
1616

17-
import React from 'react';
17+
import React, { HTMLAttributes } from 'react';
1818
import classNames from 'classnames';
1919

2020
import TooltipTarget from './TooltipTarget';
2121

22-
interface IProps {
22+
interface IProps extends HTMLAttributes<HTMLSpanElement> {
2323
class?: string;
2424
tooltipClass?: string;
2525
tooltip: React.ReactNode;

src/components/views/messages/EditHistoryMessage.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import * as HtmlUtils from '../../../HtmlUtils';
2222
import { editBodyDiffToHtml } from '../../../utils/MessageDiffUtils';
2323
import { formatTime } from '../../../DateUtils';
2424
import { pillifyLinks, unmountPills } from '../../../utils/pillify';
25+
import { tooltipifyLinks, unmountTooltips } from '../../../utils/tooltipify';
2526
import { _t } from '../../../languageHandler';
2627
import { MatrixClientPeg } from '../../../MatrixClientPeg';
2728
import Modal from '../../../Modal';
@@ -52,6 +53,7 @@ interface IState {
5253
export default class EditHistoryMessage extends React.PureComponent<IProps, IState> {
5354
private content = createRef<HTMLDivElement>();
5455
private pills: Element[] = [];
56+
private tooltips: Element[] = [];
5557

5658
constructor(props: IProps) {
5759
super(props);
@@ -93,12 +95,21 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
9395
}
9496
}
9597

98+
private tooltipifyLinks(): void {
99+
// not present for redacted events
100+
if (this.content.current) {
101+
tooltipifyLinks(this.content.current.children, this.pills, this.tooltips);
102+
}
103+
}
104+
96105
public componentDidMount(): void {
97106
this.pillifyLinks();
107+
this.tooltipifyLinks();
98108
}
99109

100110
public componentWillUnmount(): void {
101111
unmountPills(this.pills);
112+
unmountTooltips(this.tooltips);
102113
const event = this.props.mxEvent;
103114
if (event.localRedactionEvent()) {
104115
event.localRedactionEvent().off(MatrixEventEvent.Status, this.onAssociatedStatusChanged);
@@ -107,6 +118,7 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
107118

108119
public componentDidUpdate(): void {
109120
this.pillifyLinks();
121+
this.tooltipifyLinks();
110122
}
111123

112124
private renderActionBar(): JSX.Element {

src/components/views/messages/TextualBody.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import * as ContextMenu from '../../structures/ContextMenu';
2929
import { ChevronFace, toRightOf } from '../../structures/ContextMenu';
3030
import SettingsStore from "../../../settings/SettingsStore";
3131
import { pillifyLinks, unmountPills } from '../../../utils/pillify';
32+
import { tooltipifyLinks, unmountTooltips } from '../../../utils/tooltipify';
3233
import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
3334
import { isPermalinkHost, tryTransformPermalinkToLocalHref } from "../../../utils/permalinks/Permalinks";
3435
import { copyPlaintext } from "../../../utils/strings";
@@ -63,6 +64,7 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
6364

6465
private unmounted = false;
6566
private pills: Element[] = [];
67+
private tooltips: Element[] = [];
6668

6769
static contextType = RoomContext;
6870
public context!: React.ContextType<typeof RoomContext>;
@@ -91,6 +93,7 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
9193
// we should be pillify them here by doing the linkifying BEFORE the pillifying.
9294
pillifyLinks([this.contentRef.current], this.props.mxEvent, this.pills);
9395
HtmlUtils.linkifyElement(this.contentRef.current);
96+
tooltipifyLinks([this.contentRef.current], this.pills, this.tooltips);
9497
this.calculateUrlPreview();
9598

9699
if (this.props.mxEvent.getContent().format === "org.matrix.custom.html") {
@@ -283,6 +286,7 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
283286
componentWillUnmount() {
284287
this.unmounted = true;
285288
unmountPills(this.pills);
289+
unmountTooltips(this.tooltips);
286290
}
287291

288292
shouldComponentUpdate(nextProps, nextState) {

src/components/views/rooms/LinkPreviewWidget.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import Modal from "../../../Modal";
2525
import * as ImageUtils from "../../../ImageUtils";
2626
import { mediaFromMxc } from "../../../customisations/Media";
2727
import ImageView from '../elements/ImageView';
28+
import LinkWithTooltip from '../elements/LinkWithTooltip';
29+
import PlatformPeg from '../../../PlatformPeg';
2830

2931
interface IProps {
3032
link: string;
@@ -118,13 +120,20 @@ export default class LinkPreviewWidget extends React.Component<IProps> {
118120
// opaque string. This does not allow any HTML to be injected into the DOM.
119121
const description = AllHtmlEntities.decode(p["og:description"] || "");
120122

123+
const anchor = <a href={this.props.link} target="_blank" rel="noreferrer noopener">{ p["og:title"] }</a>;
124+
const needsTooltip = PlatformPeg.get()?.needsUrlTooltips() && this.props.link !== p["og:title"].trim();
125+
121126
return (
122127
<div className="mx_LinkPreviewWidget">
123128
<div className="mx_LinkPreviewWidget_wrapImageCaption">
124129
{ img }
125130
<div className="mx_LinkPreviewWidget_caption">
126131
<div className="mx_LinkPreviewWidget_title">
127-
<a href={this.props.link} target="_blank" rel="noreferrer noopener">{ p["og:title"] }</a>
132+
{ needsTooltip ? <LinkWithTooltip
133+
tooltip={new URL(this.props.link, window.location.href).toString()}
134+
>
135+
{ anchor }
136+
</LinkWithTooltip> : anchor }
128137
{ p["og:site_name"] && <span className="mx_LinkPreviewWidget_siteName">
129138
{ (" - " + p["og:site_name"]) }
130139
</span> }

src/utils/tooltipify.tsx

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import React from "react";
18+
import ReactDOM from 'react-dom';
19+
20+
import PlatformPeg from "../PlatformPeg";
21+
import LinkWithTooltip from "../components/views/elements/LinkWithTooltip";
22+
23+
/**
24+
* If the platform enabled needsUrlTooltips, recurses depth-first through a DOM tree, adding tooltip previews
25+
* for link elements. Otherwise, does nothing.
26+
*
27+
* @param {Element[]} rootNodes - a list of sibling DOM nodes to traverse to try
28+
* to add tooltips.
29+
* @param {Element[]} ignoredNodes: a list of nodes to not recurse into.
30+
* @param {Element[]} containers: an accumulator of the DOM nodes which contain
31+
* React components that have been mounted by this function. The initial caller
32+
* should pass in an empty array to seed the accumulator.
33+
*/
34+
export function tooltipifyLinks(rootNodes: ArrayLike<Element>, ignoredNodes: Element[], containers: Element[]) {
35+
if (!PlatformPeg.get()?.needsUrlTooltips()) {
36+
return;
37+
}
38+
39+
let node = rootNodes[0];
40+
41+
while (node) {
42+
let tooltipified = false;
43+
44+
if (ignoredNodes.indexOf(node) >= 0) {
45+
node = node.nextSibling as Element;
46+
continue;
47+
}
48+
49+
if (node.tagName === "A" && node.getAttribute("href")
50+
&& node.getAttribute("href") !== node.textContent.trim()
51+
) {
52+
const container = document.createElement("span");
53+
const href = node.getAttribute("href");
54+
55+
const tooltip = <LinkWithTooltip tooltip={new URL(href, window.location.href).toString()}>
56+
<span dangerouslySetInnerHTML={{ __html: node.outerHTML }} />
57+
</LinkWithTooltip>;
58+
59+
ReactDOM.render(tooltip, container);
60+
node.parentNode.replaceChild(container, node);
61+
containers.push(container);
62+
tooltipified = true;
63+
}
64+
65+
if (node.childNodes?.length && !tooltipified) {
66+
tooltipifyLinks(node.childNodes as NodeListOf<Element>, ignoredNodes, containers);
67+
}
68+
69+
node = node.nextSibling as Element;
70+
}
71+
}
72+
73+
/**
74+
* Unmount tooltip containers created by tooltipifyLinks.
75+
*
76+
* It's critical to call this after tooltipifyLinks, otherwise
77+
* tooltips will leak.
78+
*
79+
* @param {Element[]} containers - array of tooltip containers to unmount
80+
*/
81+
export function unmountTooltips(containers: Element[]) {
82+
for (const container of containers) {
83+
ReactDOM.unmountComponentAtNode(container);
84+
}
85+
}

test/utils/tooltipify-test.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import React from 'react';
18+
import { mount } from 'enzyme';
19+
20+
import { tooltipifyLinks } from '../../src/utils/tooltipify';
21+
import PlatformPeg from '../../src/PlatformPeg';
22+
import BasePlatform from '../../src/BasePlatform';
23+
24+
describe('tooltipify', () => {
25+
jest.spyOn(PlatformPeg, 'get')
26+
.mockReturnValue({ needsUrlTooltips: () => true } as unknown as BasePlatform);
27+
28+
it('does nothing for empty element', () => {
29+
const component = mount(<div />);
30+
const root = component.getDOMNode();
31+
const originalHtml = root.outerHTML;
32+
const containers: Element[] = [];
33+
tooltipifyLinks([root], [], containers);
34+
expect(containers).toHaveLength(0);
35+
expect(root.outerHTML).toEqual(originalHtml);
36+
});
37+
38+
it('wraps single anchor', () => {
39+
const component = mount(<div><a href="/foo">click</a></div>);
40+
const root = component.getDOMNode();
41+
const containers: Element[] = [];
42+
tooltipifyLinks([root], [], containers);
43+
expect(containers).toHaveLength(1);
44+
const anchor = root.querySelector(".mx_TextWithTooltip_target a");
45+
expect(anchor?.getAttribute("href")).toEqual("/foo");
46+
expect(anchor?.innerHTML).toEqual("click");
47+
});
48+
49+
it('ignores node', () => {
50+
const component = mount(<div><a href="/foo">click</a></div>);
51+
const root = component.getDOMNode();
52+
const originalHtml = root.outerHTML;
53+
const containers: Element[] = [];
54+
tooltipifyLinks([root], [root.children[0]], containers);
55+
expect(containers).toHaveLength(0);
56+
expect(root.outerHTML).toEqual(originalHtml);
57+
});
58+
});

0 commit comments

Comments
 (0)