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.
|
||||
*/
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { HTMLAttributes } from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import TooltipTarget from './TooltipTarget';
|
||||
|
||||
interface IProps {
|
||||
interface IProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
class?: string;
|
||||
tooltipClass?: string;
|
||||
tooltip: React.ReactNode;
|
||||
|
|
|
@ -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<IProps, IState> {
|
||||
private content = createRef<HTMLDivElement>();
|
||||
private pills: Element[] = [];
|
||||
private tooltips: Element[] = [];
|
||||
|
||||
constructor(props: IProps) {
|
||||
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 {
|
||||
this.pillifyLinks();
|
||||
this.tooltipifyLinks();
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
unmountPills(this.pills);
|
||||
unmountTooltips(this.tooltips);
|
||||
const event = this.props.mxEvent;
|
||||
if (event.localRedactionEvent()) {
|
||||
event.localRedactionEvent().off(MatrixEventEvent.Status, this.onAssociatedStatusChanged);
|
||||
|
@ -107,6 +118,7 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
|
|||
|
||||
public componentDidUpdate(): void {
|
||||
this.pillifyLinks();
|
||||
this.tooltipifyLinks();
|
||||
}
|
||||
|
||||
private renderActionBar(): JSX.Element {
|
||||
|
|
|
@ -29,6 +29,7 @@ import * as ContextMenu from '../../structures/ContextMenu';
|
|||
import { ChevronFace, toRightOf } from '../../structures/ContextMenu';
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { pillifyLinks, unmountPills } from '../../../utils/pillify';
|
||||
import { tooltipifyLinks, unmountTooltips } from '../../../utils/tooltipify';
|
||||
import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
|
||||
import { isPermalinkHost, tryTransformPermalinkToLocalHref } from "../../../utils/permalinks/Permalinks";
|
||||
import { copyPlaintext } from "../../../utils/strings";
|
||||
|
@ -63,6 +64,7 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
|
|||
|
||||
private unmounted = false;
|
||||
private pills: Element[] = [];
|
||||
private tooltips: Element[] = [];
|
||||
|
||||
static contextType = 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.
|
||||
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<IBodyProps, IState> {
|
|||
componentWillUnmount() {
|
||||
this.unmounted = true;
|
||||
unmountPills(this.pills);
|
||||
unmountTooltips(this.tooltips);
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
|
|
|
@ -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<IProps> {
|
|||
// opaque string. This does not allow any HTML to be injected into the DOM.
|
||||
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 (
|
||||
<div className="mx_LinkPreviewWidget">
|
||||
<div className="mx_LinkPreviewWidget_wrapImageCaption">
|
||||
{ img }
|
||||
<div className="mx_LinkPreviewWidget_caption">
|
||||
<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> }
|
||||
|
|
|
@ -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