Improve URL Previews

only show 2 by default with expand/collapse mechanism
show all hashes again, but dedup requests
clean up hide mechanism, instead of one `x` per preview have one per group
This commit is contained in:
Michael Telatynski 2021-07-07 18:04:30 +01:00
parent 30fa5419db
commit b9a539eaa2
7 changed files with 155 additions and 68 deletions

View file

@ -201,6 +201,7 @@
@import "./views/rooms/_GroupLayout.scss"; @import "./views/rooms/_GroupLayout.scss";
@import "./views/rooms/_IRCLayout.scss"; @import "./views/rooms/_IRCLayout.scss";
@import "./views/rooms/_JumpToBottomButton.scss"; @import "./views/rooms/_JumpToBottomButton.scss";
@import "./views/rooms/_LinkPreviewGroup.scss";
@import "./views/rooms/_LinkPreviewWidget.scss"; @import "./views/rooms/_LinkPreviewWidget.scss";
@import "./views/rooms/_MemberInfo.scss"; @import "./views/rooms/_MemberInfo.scss";
@import "./views/rooms/_MemberList.scss"; @import "./views/rooms/_MemberList.scss";

View file

@ -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;
}
}

View file

@ -51,22 +51,6 @@ limitations under the License.
word-wrap: break-word; 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;
}
.mx_MatrixChat_useCompactLayout { .mx_MatrixChat_useCompactLayout {
.mx_LinkPreviewWidget { .mx_LinkPreviewWidget {
margin-top: 6px; margin-top: 6px;

View file

@ -45,7 +45,7 @@ import Spoiler from "../elements/Spoiler";
import QuestionDialog from "../dialogs/QuestionDialog"; import QuestionDialog from "../dialogs/QuestionDialog";
import MessageEditHistoryDialog from "../dialogs/MessageEditHistoryDialog"; import MessageEditHistoryDialog from "../dialogs/MessageEditHistoryDialog";
import EditMessageComposer from '../rooms/EditMessageComposer'; import EditMessageComposer from '../rooms/EditMessageComposer';
import LinkPreviewWidget from '../rooms/LinkPreviewWidget'; import LinkPreviewGroup from '../rooms/LinkPreviewGroup';
interface IProps { interface IProps {
/* the MatrixEvent to show */ /* the MatrixEvent to show */
@ -294,15 +294,9 @@ 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 // pass only the first child which is the event tile otherwise this recurses on edited events
let links = this.findLinks([this.contentRef.current]); let links = this.findLinks([this.contentRef.current]);
if (links.length) { if (links.length) {
// de-dup the links (but preserve ordering) // de-duplicate the links using a set here maintains the order
const seen = new Set(); links = Array.from(new Set(links));
links = links.filter((link) => { this.setState({ links });
if (seen.has(link)) return false;
seen.add(link);
return true;
});
this.setState({ links: links });
// lazy-load the hidden state of the preview widget from localstorage // lazy-load the hidden state of the preview widget from localstorage
if (window.localStorage) { if (window.localStorage) {
@ -530,15 +524,12 @@ export default class TextualBody extends React.Component<IProps, IState> {
let widgets; let widgets;
if (this.state.links.length && !this.state.widgetHidden && this.props.showUrlPreview) { if (this.state.links.length && !this.state.widgetHidden && this.props.showUrlPreview) {
widgets = this.state.links.map((link)=>{ widgets = <LinkPreviewGroup
return <LinkPreviewWidget links={this.state.links}
key={link}
link={link}
mxEvent={this.props.mxEvent} mxEvent={this.props.mxEvent}
onCancelClick={this.onCancelClick} onCancelClick={this.onCancelClick}
onHeightChanged={this.props.onHeightChanged} onHeightChanged={this.props.onHeightChanged}
/>; />;
});
} }
switch (content.msgtype) { switch (content.msgtype) {

View file

@ -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;

View file

@ -1,6 +1,5 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -16,26 +15,33 @@ limitations under the License.
*/ */
import React, { createRef } from 'react'; import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import { AllHtmlEntities } from 'html-entities'; 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 { linkifyElement } from '../../../HtmlUtils';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import * as sdk from "../../../index";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import * as ImageUtils from "../../../ImageUtils"; import * as ImageUtils from "../../../ImageUtils";
import { _t } from "../../../languageHandler";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromMxc } from "../../../customisations/Media"; 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") @replaceableComponent("views.rooms.LinkPreviewWidget")
export default class LinkPreviewWidget extends React.Component { export default class LinkPreviewWidget extends React.Component<IProps, IState> {
static propTypes = { private unmounted = false;
link: PropTypes.string.isRequired, // the URL being previewed private readonly description = createRef<HTMLDivElement>();
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
};
constructor(props) { constructor(props) {
super(props); super(props);
@ -44,31 +50,25 @@ export default class LinkPreviewWidget extends React.Component {
preview: null, preview: null,
}; };
this.unmounted = false; MatrixClientPeg.get().getUrlPreview(this.props.link, this.props.mxEvent.getTs()).then((preview) => {
MatrixClientPeg.get().getUrlPreview(this.props.link, this.props.mxEvent.getTs()).then((res)=>{
if (this.unmounted) { if (this.unmounted) {
return; return;
} }
this.setState( this.setState({ preview }, this.props.onHeightChanged);
{ preview: res }, }, (error) => {
this.props.onHeightChanged,
);
}, (error)=>{
console.error("Failed to get URL preview: " + error); console.error("Failed to get URL preview: " + error);
}); });
this._description = createRef();
} }
componentDidMount() { componentDidMount() {
if (this._description.current) { if (this.description.current) {
linkifyElement(this._description.current); linkifyElement(this.description.current);
} }
} }
componentDidUpdate() { componentDidUpdate() {
if (this._description.current) { if (this.description.current) {
linkifyElement(this._description.current); linkifyElement(this.description.current);
} }
} }
@ -76,11 +76,10 @@ export default class LinkPreviewWidget extends React.Component {
this.unmounted = true; this.unmounted = true;
} }
onImageClick = ev => { private onImageClick = ev => {
const p = this.state.preview; const p = this.state.preview;
if (ev.button != 0 || ev.metaKey) return; if (ev.button != 0 || ev.metaKey) return;
ev.preventDefault(); ev.preventDefault();
const ImageView = sdk.getComponent("elements.ImageView");
let src = p["og:image"]; let src = p["og:image"];
if (src && src.startsWith("mxc://")) { if (src && src.startsWith("mxc://")) {
@ -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. // 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 AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return ( return (
<div className="mx_LinkPreviewWidget"> <div className="mx_LinkPreviewWidget">
{ img } { img }
<div className="mx_LinkPreviewWidget_caption"> <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_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_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 } { description }
</div> </div>
</div> </div>
<AccessibleButton className="mx_LinkPreviewWidget_cancel" onClick={this.props.onCancelClick} aria-label={_t("Close preview")}> { this.props.children }
<img className="mx_filterFlipColor" alt="" role="presentation"
src={require("../../../../res/img/cancel.svg")} width="18" height="18" />
</AccessibleButton>
</div> </div>
); );
} }

View file

@ -1508,6 +1508,8 @@
"Your message was sent": "Your message was sent", "Your message was sent": "Your message was sent",
"Failed to send": "Failed to send", "Failed to send": "Failed to send",
"Scroll to most recent messages": "Scroll to most recent messages", "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", "Close preview": "Close preview",
"and %(count)s others...|other": "and %(count)s others...", "and %(count)s others...|other": "and %(count)s others...",
"and %(count)s others...|one": "and one other...", "and %(count)s others...|one": "and one other...",