mirror of
https://github.com/element-hq/element-web
synced 2024-11-27 11:47:23 +03:00
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:
parent
30fa5419db
commit
b9a539eaa2
7 changed files with 155 additions and 68 deletions
|
@ -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";
|
||||||
|
|
38
res/css/views/rooms/_LinkPreviewGroup.scss
Normal file
38
res/css/views/rooms/_LinkPreviewGroup.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
76
src/components/views/rooms/LinkPreviewGroup.tsx
Normal file
76
src/components/views/rooms/LinkPreviewGroup.tsx
Normal 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;
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -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...",
|
||||||
|
|
Loading…
Reference in a new issue