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

Improve URL previews #6326

Merged
merged 7 commits into from
Jul 8, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions res/css/_components.scss
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@
@import "./views/rooms/_GroupLayout.scss";
@import "./views/rooms/_IRCLayout.scss";
@import "./views/rooms/_JumpToBottomButton.scss";
@import "./views/rooms/_LinkPreviewGroup.scss";
@import "./views/rooms/_LinkPreviewWidget.scss";
@import "./views/rooms/_MemberInfo.scss";
@import "./views/rooms/_MemberList.scss";
Expand Down
38 changes: 38 additions & 0 deletions res/css/views/rooms/_LinkPreviewGroup.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
Copyright 2021 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.
*/

.mx_LinkPreviewGroup {
.mx_LinkPreviewGroup_hide {
cursor: pointer;
width: 18px;
height: 18px;

img {
flex: 0 0 40px;
visibility: hidden;
}
}

&:hover .mx_LinkPreviewGroup_hide img,
.mx_LinkPreviewGroup_hide.focus-visible:focus img {
visibility: visible;
}

> .mx_AccessibleButton {
color: $accent-color;
text-align: center;
}
}
25 changes: 8 additions & 17 deletions res/css/views/rooms/_LinkPreviewWidget.scss
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,16 @@ limitations under the License.
.mx_LinkPreviewWidget_caption {
margin-left: 15px;
flex: 1 1 auto;
overflow-x: hidden; // cause it to wrap rather than clip
}

.mx_LinkPreviewWidget_title {
display: inline;
font-weight: bold;
white-space: normal;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}

.mx_LinkPreviewWidget_siteName {
Expand All @@ -49,22 +53,9 @@ limitations under the License.
margin-top: 8px;
white-space: normal;
word-wrap: break-word;
}

.mx_LinkPreviewWidget_cancel {
cursor: pointer;
width: 18px;
height: 18px;

img {
flex: 0 0 40px;
visibility: hidden;
}
}

.mx_LinkPreviewWidget:hover .mx_LinkPreviewWidget_cancel img,
.mx_LinkPreviewWidget_cancel.focus-visible:focus img {
visibility: visible;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}

.mx_MatrixChat_useCompactLayout {
Expand Down
1 change: 0 additions & 1 deletion src/components/views/context_menus/MessageContextMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,6 @@ export default class MessageContextMenu extends React.Component {
if (this.props.permalinkCreator) {
permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId());
}
// XXX: if we use room ID, we should also include a server where the event can be found (other than in the domain of the event ID)
const permalinkButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconPermalink"
Expand Down
27 changes: 9 additions & 18 deletions src/components/views/messages/TextualBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import Spoiler from "../elements/Spoiler";
import QuestionDialog from "../dialogs/QuestionDialog";
import MessageEditHistoryDialog from "../dialogs/MessageEditHistoryDialog";
import EditMessageComposer from '../rooms/EditMessageComposer';
import LinkPreviewWidget from '../rooms/LinkPreviewWidget';
import LinkPreviewGroup from '../rooms/LinkPreviewGroup';

interface IProps {
/* the MatrixEvent to show */
Expand Down Expand Up @@ -294,14 +294,8 @@ export default class TextualBody extends React.Component<IProps, IState> {
// pass only the first child which is the event tile otherwise this recurses on edited events
let links = this.findLinks([this.contentRef.current]);
if (links.length) {
// de-duplicate the links after stripping hashes as they don't affect the preview
// using a set here maintains the order
links = Array.from(new Set(links.map(link => {
const url = new URL(link);
url.hash = "";
return url.toString();
})));

// de-duplicate the links using a set here maintains the order
links = Array.from(new Set(links));
this.setState({ links });

// lazy-load the hidden state of the preview widget from localstorage
Expand Down Expand Up @@ -530,15 +524,12 @@ export default class TextualBody extends React.Component<IProps, IState> {

let widgets;
if (this.state.links.length && !this.state.widgetHidden && this.props.showUrlPreview) {
widgets = this.state.links.map((link)=>{
return <LinkPreviewWidget
key={link}
link={link}
mxEvent={this.props.mxEvent}
onCancelClick={this.onCancelClick}
onHeightChanged={this.props.onHeightChanged}
/>;
});
widgets = <LinkPreviewGroup
links={this.state.links}
mxEvent={this.props.mxEvent}
onCancelClick={this.onCancelClick}
onHeightChanged={this.props.onHeightChanged}
/>;
}

switch (content.msgtype) {
Expand Down
76 changes: 76 additions & 0 deletions src/components/views/rooms/LinkPreviewGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
Copyright 2021 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, { useEffect } from "react";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";

import { useStateToggle } from "../../../hooks/useStateToggle";
import LinkPreviewWidget from "./LinkPreviewWidget";
import AccessibleButton from "../elements/AccessibleButton";
import { _t } from "../../../languageHandler";

const INITIAL_NUM_PREVIEWS = 2;

interface IProps {
links: string[]; // the URLs to be previewed
mxEvent: MatrixEvent; // the Event associated with the preview
onCancelClick?(): void; // called when the preview's cancel ('hide') button is clicked
onHeightChanged?(): void; // called when the preview's contents has loaded
}

const LinkPreviewGroup: React.FC<IProps> = ({ links, mxEvent, onCancelClick, onHeightChanged }) => {
const [expanded, toggleExpanded] = useStateToggle();
useEffect(() => {
onHeightChanged();
}, [onHeightChanged, expanded]);

const shownLinks = expanded ? links : links.slice(0, INITIAL_NUM_PREVIEWS);

let toggleButton;
if (links.length > INITIAL_NUM_PREVIEWS) {
toggleButton = <AccessibleButton onClick={toggleExpanded}>
{ expanded
? _t("Collapse")
: _t("Show %(count)s other previews", { count: links.length - shownLinks.length }) }
</AccessibleButton>;
}

return <div className="mx_LinkPreviewGroup">
{ shownLinks.map((link, i) => (
<LinkPreviewWidget key={link} link={link} mxEvent={mxEvent} onHeightChanged={onHeightChanged}>
{ i === 0 ? (
<AccessibleButton
className="mx_LinkPreviewGroup_hide"
onClick={onCancelClick}
aria-label={_t("Close preview")}
>
<img
className="mx_filterFlipColor"
alt=""
role="presentation"
src={require("../../../../res/img/cancel.svg")}
width="18"
height="18"
/>
</AccessibleButton>
): undefined }
</LinkPreviewWidget>
)) }
{ toggleButton }
</div>;
};

export default LinkPreviewGroup;
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2016 - 2021 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.
Expand All @@ -16,26 +15,33 @@ limitations under the License.
*/

import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import { AllHtmlEntities } from 'html-entities';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { IPreviewUrlResponse } from 'matrix-js-sdk/src/client';

import { linkifyElement } from '../../../HtmlUtils';
import SettingsStore from "../../../settings/SettingsStore";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import * as sdk from "../../../index";
import Modal from "../../../Modal";
import * as ImageUtils from "../../../ImageUtils";
import { _t } from "../../../languageHandler";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromMxc } from "../../../customisations/Media";
import ImageView from '../elements/ImageView';

interface IProps {
link: string; // the URL being previewed
mxEvent: MatrixEvent; // the Event associated with the preview
onHeightChanged(): void; // called when the preview's contents has loaded
}

interface IState {
preview?: IPreviewUrlResponse;
}

@replaceableComponent("views.rooms.LinkPreviewWidget")
export default class LinkPreviewWidget extends React.Component {
static propTypes = {
link: PropTypes.string.isRequired, // the URL being previewed
mxEvent: PropTypes.object.isRequired, // the Event associated with the preview
onCancelClick: PropTypes.func, // called when the preview's cancel ('hide') button is clicked
onHeightChanged: PropTypes.func, // called when the preview's contents has loaded
};
export default class LinkPreviewWidget extends React.Component<IProps, IState> {
private unmounted = false;
private readonly description = createRef<HTMLDivElement>();

constructor(props) {
super(props);
Expand All @@ -44,43 +50,36 @@ export default class LinkPreviewWidget extends React.Component {
preview: null,
};

this.unmounted = false;
MatrixClientPeg.get().getUrlPreview(this.props.link, this.props.mxEvent.getTs()).then((res)=>{
MatrixClientPeg.get().getUrlPreview(this.props.link, this.props.mxEvent.getTs()).then((preview) => {
if (this.unmounted) {
return;
}
this.setState(
{ preview: res },
this.props.onHeightChanged,
);
}, (error)=>{
this.setState({ preview }, this.props.onHeightChanged);
}, (error) => {
console.error("Failed to get URL preview: " + error);
});

this._description = createRef();
}

componentDidMount() {
if (this._description.current) {
linkifyElement(this._description.current);
if (this.description.current) {
linkifyElement(this.description.current);
}
}

componentDidUpdate() {
if (this._description.current) {
linkifyElement(this._description.current);
if (this.description.current) {
linkifyElement(this.description.current);
}
}

componentWillUnmount() {
this.unmounted = true;
}

onImageClick = ev => {
private onImageClick = ev => {
const p = this.state.preview;
if (ev.button != 0 || ev.metaKey) return;
ev.preventDefault();
const ImageView = sdk.getComponent("elements.ImageView");

let src = p["og:image"];
if (src && src.startsWith("mxc://")) {
Expand Down Expand Up @@ -136,21 +135,17 @@ 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 AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<div className="mx_LinkPreviewWidget">
{ 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></div>
<div className="mx_LinkPreviewWidget_siteName">{ p["og:site_name"] ? (" - " + p["og:site_name"]) : null }</div>
<div className="mx_LinkPreviewWidget_description" ref={this._description}>
<div className="mx_LinkPreviewWidget_description" ref={this.description}>
{ description }
</div>
</div>
<AccessibleButton className="mx_LinkPreviewWidget_cancel" onClick={this.props.onCancelClick} aria-label={_t("Close preview")}>
<img className="mx_filterFlipColor" alt="" role="presentation"
src={require("../../../../res/img/cancel.svg")} width="18" height="18" />
</AccessibleButton>
{ this.props.children }
</div>
);
}
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1508,6 +1508,8 @@
"Your message was sent": "Your message was sent",
"Failed to send": "Failed to send",
"Scroll to most recent messages": "Scroll to most recent messages",
"Show %(count)s other previews|other": "Show %(count)s other previews",
"Show %(count)s other previews|one": "Show %(count)s other preview",
"Close preview": "Close preview",
"and %(count)s others...|other": "and %(count)s others...",
"and %(count)s others...|one": "and one other...",
Expand Down
2 changes: 1 addition & 1 deletion test/components/views/messages/TextualBody-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ describe("<TextualBody />", () => {
event: true,
});

const wrapper = mount(<TextualBody mxEvent={ev} showUrlPreview={true} />);
const wrapper = mount(<TextualBody mxEvent={ev} showUrlPreview={true} onHeightChanged={() => {}} />);
expect(wrapper.text()).toBe(ev.getContent().body);

let widgets = wrapper.find("LinkPreviewWidget");
Expand Down