diff --git a/res/css/_components.scss b/res/css/_components.scss index 389be11c60..7360c61c25 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -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"; diff --git a/res/css/views/rooms/_LinkPreviewGroup.scss b/res/css/views/rooms/_LinkPreviewGroup.scss new file mode 100644 index 0000000000..ed341904fd --- /dev/null +++ b/res/css/views/rooms/_LinkPreviewGroup.scss @@ -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; + } +} diff --git a/res/css/views/rooms/_LinkPreviewWidget.scss b/res/css/views/rooms/_LinkPreviewWidget.scss index 022cf3ed28..e1628e19a6 100644 --- a/res/css/views/rooms/_LinkPreviewWidget.scss +++ b/res/css/views/rooms/_LinkPreviewWidget.scss @@ -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 { @@ -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 { diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.js index a2086451cd..3e01954a21 100644 --- a/src/components/views/context_menus/MessageContextMenu.js +++ b/src/components/views/context_menus/MessageContextMenu.js @@ -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 = ( { // 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 @@ -530,15 +524,12 @@ export default class TextualBody extends React.Component { let widgets; if (this.state.links.length && !this.state.widgetHidden && this.props.showUrlPreview) { - widgets = this.state.links.map((link)=>{ - return ; - }); + widgets = ; } switch (content.msgtype) { diff --git a/src/components/views/rooms/LinkPreviewGroup.tsx b/src/components/views/rooms/LinkPreviewGroup.tsx new file mode 100644 index 0000000000..ff6fd4afd2 --- /dev/null +++ b/src/components/views/rooms/LinkPreviewGroup.tsx @@ -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 = ({ 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 = + { expanded + ? _t("Collapse") + : _t("Show %(count)s other previews", { count: links.length - shownLinks.length }) } + ; + } + + return
+ { shownLinks.map((link, i) => ( + + { i === 0 ? ( + + + + ): undefined } + + )) } + { toggleButton } +
; +}; + +export default LinkPreviewGroup; diff --git a/src/components/views/rooms/LinkPreviewWidget.js b/src/components/views/rooms/LinkPreviewWidget.tsx similarity index 73% rename from src/components/views/rooms/LinkPreviewWidget.js rename to src/components/views/rooms/LinkPreviewWidget.tsx index 360ca41d55..db13021b32 100644 --- a/src/components/views/rooms/LinkPreviewWidget.js +++ b/src/components/views/rooms/LinkPreviewWidget.tsx @@ -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. @@ -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 { + private unmounted = false; + private readonly description = createRef(); constructor(props) { super(props); @@ -44,31 +50,25 @@ 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); } } @@ -76,11 +76,10 @@ export default class LinkPreviewWidget extends React.Component { 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://")) { @@ -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 (
{ img }
{ p["og:site_name"] ? (" - " + p["og:site_name"]) : null }
-
+
{ description }
- - - + { this.props.children }
); } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index bbf6954435..7d4252545b 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -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...", diff --git a/test/components/views/messages/TextualBody-test.js b/test/components/views/messages/TextualBody-test.js index c9418fc557..c6a3f3c779 100644 --- a/test/components/views/messages/TextualBody-test.js +++ b/test/components/views/messages/TextualBody-test.js @@ -302,7 +302,7 @@ describe("", () => { event: true, }); - const wrapper = mount(); + const wrapper = mount( {}} />); expect(wrapper.text()).toBe(ev.getContent().body); let widgets = wrapper.find("LinkPreviewWidget");