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 <johannesm@element.io> * Gracefully handle missing platform * Use public access modifier Co-authored-by: Travis Ralston <travpc@gmail.com> * Use exact inequality Co-authored-by: Travis Ralston <travpc@gmail.com> * Document getAbsoluteUrl * Appease the linter * Clarify performance impact in comment Co-authored-by: Travis Ralston <travpc@gmail.com> * 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 <travpc@gmail.com> Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>pull/28788/head^2
parent
530b51a5ac
commit
6f21a155a4
|
@ -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.
|
* Returns a promise that resolves to a string representing the current version of the application.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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<React.ComponentProps<typeof TextWithTooltip>, "tabIndex" | "onClick" > {}
|
||||||
|
|
||||||
|
export default class LinkWithTooltip extends React.Component<IProps> {
|
||||||
|
constructor(props: IProps) {
|
||||||
|
super(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
public render(): JSX.Element {
|
||||||
|
const { children, tooltip, ...props } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextWithTooltip
|
||||||
|
// Disable focusing on the tooltip target to avoid double / nested focus. The contained anchor element
|
||||||
|
// itself allows focusing which also triggers the tooltip.
|
||||||
|
tabIndex={-1}
|
||||||
|
tooltip={tooltip}
|
||||||
|
onClick={e => (e.target as HTMLElement).blur()} // Force tooltip to hide on clickout
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{ children }
|
||||||
|
</TextWithTooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -14,12 +14,12 @@
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { HTMLAttributes } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import TooltipTarget from './TooltipTarget';
|
import TooltipTarget from './TooltipTarget';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps extends HTMLAttributes<HTMLSpanElement> {
|
||||||
class?: string;
|
class?: string;
|
||||||
tooltipClass?: string;
|
tooltipClass?: string;
|
||||||
tooltip: React.ReactNode;
|
tooltip: React.ReactNode;
|
||||||
|
|
|
@ -22,6 +22,7 @@ import * as HtmlUtils from '../../../HtmlUtils';
|
||||||
import { editBodyDiffToHtml } from '../../../utils/MessageDiffUtils';
|
import { editBodyDiffToHtml } from '../../../utils/MessageDiffUtils';
|
||||||
import { formatTime } from '../../../DateUtils';
|
import { formatTime } from '../../../DateUtils';
|
||||||
import { pillifyLinks, unmountPills } from '../../../utils/pillify';
|
import { pillifyLinks, unmountPills } from '../../../utils/pillify';
|
||||||
|
import { tooltipifyLinks, unmountTooltips } from '../../../utils/tooltipify';
|
||||||
import { _t } from '../../../languageHandler';
|
import { _t } from '../../../languageHandler';
|
||||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||||
import Modal from '../../../Modal';
|
import Modal from '../../../Modal';
|
||||||
|
@ -52,6 +53,7 @@ interface IState {
|
||||||
export default class EditHistoryMessage extends React.PureComponent<IProps, IState> {
|
export default class EditHistoryMessage extends React.PureComponent<IProps, IState> {
|
||||||
private content = createRef<HTMLDivElement>();
|
private content = createRef<HTMLDivElement>();
|
||||||
private pills: Element[] = [];
|
private pills: Element[] = [];
|
||||||
|
private tooltips: Element[] = [];
|
||||||
|
|
||||||
constructor(props: IProps) {
|
constructor(props: IProps) {
|
||||||
super(props);
|
super(props);
|
||||||
|
@ -93,12 +95,21 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private tooltipifyLinks(): void {
|
||||||
|
// not present for redacted events
|
||||||
|
if (this.content.current) {
|
||||||
|
tooltipifyLinks(this.content.current.children, this.pills, this.tooltips);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public componentDidMount(): void {
|
public componentDidMount(): void {
|
||||||
this.pillifyLinks();
|
this.pillifyLinks();
|
||||||
|
this.tooltipifyLinks();
|
||||||
}
|
}
|
||||||
|
|
||||||
public componentWillUnmount(): void {
|
public componentWillUnmount(): void {
|
||||||
unmountPills(this.pills);
|
unmountPills(this.pills);
|
||||||
|
unmountTooltips(this.tooltips);
|
||||||
const event = this.props.mxEvent;
|
const event = this.props.mxEvent;
|
||||||
if (event.localRedactionEvent()) {
|
if (event.localRedactionEvent()) {
|
||||||
event.localRedactionEvent().off(MatrixEventEvent.Status, this.onAssociatedStatusChanged);
|
event.localRedactionEvent().off(MatrixEventEvent.Status, this.onAssociatedStatusChanged);
|
||||||
|
@ -107,6 +118,7 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
|
||||||
|
|
||||||
public componentDidUpdate(): void {
|
public componentDidUpdate(): void {
|
||||||
this.pillifyLinks();
|
this.pillifyLinks();
|
||||||
|
this.tooltipifyLinks();
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderActionBar(): JSX.Element {
|
private renderActionBar(): JSX.Element {
|
||||||
|
|
|
@ -29,6 +29,7 @@ import * as ContextMenu from '../../structures/ContextMenu';
|
||||||
import { ChevronFace, toRightOf } from '../../structures/ContextMenu';
|
import { ChevronFace, toRightOf } from '../../structures/ContextMenu';
|
||||||
import SettingsStore from "../../../settings/SettingsStore";
|
import SettingsStore from "../../../settings/SettingsStore";
|
||||||
import { pillifyLinks, unmountPills } from '../../../utils/pillify';
|
import { pillifyLinks, unmountPills } from '../../../utils/pillify';
|
||||||
|
import { tooltipifyLinks, unmountTooltips } from '../../../utils/tooltipify';
|
||||||
import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
|
import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
|
||||||
import { isPermalinkHost, tryTransformPermalinkToLocalHref } from "../../../utils/permalinks/Permalinks";
|
import { isPermalinkHost, tryTransformPermalinkToLocalHref } from "../../../utils/permalinks/Permalinks";
|
||||||
import { copyPlaintext } from "../../../utils/strings";
|
import { copyPlaintext } from "../../../utils/strings";
|
||||||
|
@ -63,6 +64,7 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
|
||||||
|
|
||||||
private unmounted = false;
|
private unmounted = false;
|
||||||
private pills: Element[] = [];
|
private pills: Element[] = [];
|
||||||
|
private tooltips: Element[] = [];
|
||||||
|
|
||||||
static contextType = RoomContext;
|
static contextType = RoomContext;
|
||||||
public context!: React.ContextType<typeof RoomContext>;
|
public context!: React.ContextType<typeof RoomContext>;
|
||||||
|
@ -91,6 +93,7 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
|
||||||
// we should be pillify them here by doing the linkifying BEFORE the pillifying.
|
// we should be pillify them here by doing the linkifying BEFORE the pillifying.
|
||||||
pillifyLinks([this.contentRef.current], this.props.mxEvent, this.pills);
|
pillifyLinks([this.contentRef.current], this.props.mxEvent, this.pills);
|
||||||
HtmlUtils.linkifyElement(this.contentRef.current);
|
HtmlUtils.linkifyElement(this.contentRef.current);
|
||||||
|
tooltipifyLinks([this.contentRef.current], this.pills, this.tooltips);
|
||||||
this.calculateUrlPreview();
|
this.calculateUrlPreview();
|
||||||
|
|
||||||
if (this.props.mxEvent.getContent().format === "org.matrix.custom.html") {
|
if (this.props.mxEvent.getContent().format === "org.matrix.custom.html") {
|
||||||
|
@ -283,6 +286,7 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
this.unmounted = true;
|
this.unmounted = true;
|
||||||
unmountPills(this.pills);
|
unmountPills(this.pills);
|
||||||
|
unmountTooltips(this.tooltips);
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldComponentUpdate(nextProps, nextState) {
|
shouldComponentUpdate(nextProps, nextState) {
|
||||||
|
|
|
@ -25,6 +25,8 @@ import Modal from "../../../Modal";
|
||||||
import * as ImageUtils from "../../../ImageUtils";
|
import * as ImageUtils from "../../../ImageUtils";
|
||||||
import { mediaFromMxc } from "../../../customisations/Media";
|
import { mediaFromMxc } from "../../../customisations/Media";
|
||||||
import ImageView from '../elements/ImageView';
|
import ImageView from '../elements/ImageView';
|
||||||
|
import LinkWithTooltip from '../elements/LinkWithTooltip';
|
||||||
|
import PlatformPeg from '../../../PlatformPeg';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
link: string;
|
link: string;
|
||||||
|
@ -118,13 +120,20 @@ export default class LinkPreviewWidget extends React.Component<IProps> {
|
||||||
// opaque string. This does not allow any HTML to be injected into the DOM.
|
// opaque string. This does not allow any HTML to be injected into the DOM.
|
||||||
const description = AllHtmlEntities.decode(p["og:description"] || "");
|
const description = AllHtmlEntities.decode(p["og:description"] || "");
|
||||||
|
|
||||||
|
const anchor = <a href={this.props.link} target="_blank" rel="noreferrer noopener">{ p["og:title"] }</a>;
|
||||||
|
const needsTooltip = PlatformPeg.get()?.needsUrlTooltips() && this.props.link !== p["og:title"].trim();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx_LinkPreviewWidget">
|
<div className="mx_LinkPreviewWidget">
|
||||||
<div className="mx_LinkPreviewWidget_wrapImageCaption">
|
<div className="mx_LinkPreviewWidget_wrapImageCaption">
|
||||||
{ img }
|
{ img }
|
||||||
<div className="mx_LinkPreviewWidget_caption">
|
<div className="mx_LinkPreviewWidget_caption">
|
||||||
<div className="mx_LinkPreviewWidget_title">
|
<div className="mx_LinkPreviewWidget_title">
|
||||||
<a href={this.props.link} target="_blank" rel="noreferrer noopener">{ p["og:title"] }</a>
|
{ needsTooltip ? <LinkWithTooltip
|
||||||
|
tooltip={new URL(this.props.link, window.location.href).toString()}
|
||||||
|
>
|
||||||
|
{ anchor }
|
||||||
|
</LinkWithTooltip> : anchor }
|
||||||
{ p["og:site_name"] && <span className="mx_LinkPreviewWidget_siteName">
|
{ p["og:site_name"] && <span className="mx_LinkPreviewWidget_siteName">
|
||||||
{ (" - " + p["og:site_name"]) }
|
{ (" - " + p["og:site_name"]) }
|
||||||
</span> }
|
</span> }
|
||||||
|
|
|
@ -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<Element>, 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 = <LinkWithTooltip tooltip={new URL(href, window.location.href).toString()}>
|
||||||
|
<span dangerouslySetInnerHTML={{ __html: node.outerHTML }} />
|
||||||
|
</LinkWithTooltip>;
|
||||||
|
|
||||||
|
ReactDOM.render(tooltip, container);
|
||||||
|
node.parentNode.replaceChild(container, node);
|
||||||
|
containers.push(container);
|
||||||
|
tooltipified = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.childNodes?.length && !tooltipified) {
|
||||||
|
tooltipifyLinks(node.childNodes as NodeListOf<Element>, 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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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(<div />);
|
||||||
|
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(<div><a href="/foo">click</a></div>);
|
||||||
|
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(<div><a href="/foo">click</a></div>);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue