From 5674d0c7b7585a544e2a2cb49946856b8af271e5 Mon Sep 17 00:00:00 2001 From: miruka Date: Tue, 3 Sep 2019 03:04:57 -0400 Subject: [PATCH] Use a component to display image link previews --- TODO.md | 6 ++- src/python/config_files.py | 1 + src/python/html_filter.py | 68 ++++--------------------- src/python/models/items.py | 24 +++++++++ src/qml/Base/HButton.qml | 4 +- src/qml/Base/HImage.qml | 1 + src/qml/Chat/Timeline/EventContent.qml | 52 ++++++++++++------- src/qml/Chat/Timeline/EventDelegate.qml | 58 ++++++++++++++------- src/qml/Chat/Timeline/EventImage.qml | 31 +++++++++++ src/qml/Chat/Timeline/EventList.qml | 2 +- 10 files changed, 147 insertions(+), 100 deletions(-) create mode 100644 src/qml/Chat/Timeline/EventImage.qml diff --git a/TODO.md b/TODO.md index 1f315d50..ef8e555d 100644 --- a/TODO.md +++ b/TODO.md @@ -11,6 +11,7 @@ - When qml syntax highlighting supports ES6 string interpolation, use them - Fixes + - Scroll to begin/end - `minutesBetween()` for 13:13:58 and 14:15:07 - `# > quote` doesn't color - Pressing backspace in composer sometimes doesn't work @@ -29,6 +30,7 @@ - Verify big avatars aren't downloaded uselessly - UI + - Esc in sidepane to focus chat again - Set an explicit placeholder text color for text field/area - Change typing bar background - Show error if uploading avatar fails or file is corrupted @@ -38,8 +40,8 @@ - Message selection - Make scroll wheel usable - Copy to X11 selection - - Make events copiable - - Images don't load correctly in TextEdit + - Link previews + - Take the previews into account to calculate delegate min height - Just use Shortcut onHeld instead of analyzing the current velocity in `smartVerticalFlick()` diff --git a/src/python/config_files.py b/src/python/config_files.py index bff0dbb3..ee0c9e88 100644 --- a/src/python/config_files.py +++ b/src/python/config_files.py @@ -105,6 +105,7 @@ class UISettings(JSONConfigFile): async def default_data(self) -> JsonData: return { "alertOnMessageForMsec": 4000, + "messageImageMaxThumbnailSize": 256, "theme": "Default.qpl", "writeAliases": {}, "keys": { diff --git a/src/python/html_filter.py b/src/python/html_filter.py index 846fd09c..e582bed3 100644 --- a/src/python/html_filter.py +++ b/src/python/html_filter.py @@ -1,7 +1,7 @@ import re import mistune -from lxml.html import HtmlElement, etree # nosec +from lxml.html import HtmlElement # nosec import html_sanitizer.sanitizer as sanitizer from html_sanitizer.sanitizer import Sanitizer @@ -66,39 +66,22 @@ class HtmlFilter: if outgoing: return html - tree = etree.fromstring(html, parser=etree.HTMLParser()) - - if tree is None: - return "" - - for el in tree.iter("img"): - el = self._wrap_img_in_a(el) - - for el in tree.iter("a"): - el = self._append_img_to_a(el) - - result = b"".join((etree.tostring(el, encoding="utf-8") - for el in tree[0].iterchildren())) - - text = str(result, "utf-8").strip("\n") - return re.sub( r"<(p|br/?)>(\s*>.*)(!?)", r'<\1>\2\3', - text, + html, ) def sanitize_settings(self, inline: bool = False) -> dict: # https://matrix.org/docs/spec/client_server/latest#m-room-message-msgtypes - # TODO: mx-reply, audio, video, the new hidden thing + # TODO: mx-reply and the new hidden thing inline_tags = {"font", "a", "sup", "sub", "b", "i", "s", "u", "code"} tags = inline_tags | { "h1", "h2", "h3", "h4", "h5", "h6","blockquote", "p", "ul", "ol", "li", "hr", "br", - "table", "thead", "tbody", "tr", "th", "td", - "pre", "img", + "table", "thead", "tbody", "tr", "th", "td", "pre", } inlines_attributes = { @@ -107,7 +90,6 @@ class HtmlFilter: "code": {"class"}, } attributes = {**inlines_attributes, **{ - "img": {"width", "height", "alt", "title", "src"}, "ol": {"start"}, "hr": {"width"}, }} @@ -115,7 +97,7 @@ class HtmlFilter: return { "tags": inline_tags if inline else tags, "attributes": inlines_attributes if inline else attributes, - "empty": {} if inline else {"hr", "br", "img"}, + "empty": {} if inline else {"hr", "br"}, "separate": {"a"} if inline else { "a", "p", "li", "table", "tr", "th", "td", "br", "hr", }, @@ -139,6 +121,7 @@ class HtmlFilter: sanitizer.tag_replacer("caption", "p"), sanitizer.target_blank_noopener, self._process_span_font, + self._img_to_a, ], "element_postprocessors": [], "is_mergeable": lambda e1, e2: e1.attrib == e2.attrib, @@ -159,42 +142,13 @@ class HtmlFilter: @staticmethod - def _wrap_img_in_a(el: HtmlElement) -> HtmlElement: - link = el.attrib.get("src", "") - width = el.attrib.get("width", "256") - height = el.attrib.get("height", "256") + def _img_to_a(el: HtmlElement) -> HtmlElement: + if el.tag == "img": + el.tag = "a" + el.attrib["href"] = el.attrib.pop("src", "") + el.text = el.attrib.pop("alt", None) or el.attrib["href"] - if el.getparent().tag == "a" or el.tag != "img": - return el - - el.tag = "a" - el.attrib.clear() - el.attrib["href"] = link - el.append(etree.Element("img", src=link, width=width, height=height)) return el - def _append_img_to_a(self, el: HtmlElement) -> HtmlElement: - link = el.attrib.get("href", "") - - if not (el.tag == "a" and self._is_image_path(link)): - return el - - for _ in el.iter("img"): # if the already has an child - return el - - el.append(etree.Element("br")) - el.append(etree.Element("img", src=link, width="256", height="256")) - return el - - - @staticmethod - def _is_image_path(link: str) -> bool: - return bool(re.match( - r"(https?|s?ftp)://.+/.+\.(jpg|jpeg|png|gif|bmp|webp|tiff|svg)$", - link, - re.IGNORECASE, - )) - - HTML_FILTER = HtmlFilter() diff --git a/src/python/models/items.py b/src/python/models/items.py index 7bf95981..7eedef4a 100644 --- a/src/python/models/items.py +++ b/src/python/models/items.py @@ -2,6 +2,9 @@ import re from dataclasses import dataclass, field from datetime import datetime from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import urlparse + +import lxml # nosec import nio @@ -145,6 +148,27 @@ class Event(ModelItem): def event_type(self) -> str: return self.local_event_type or type(self.source).__name__ + @property + def preview_links(self) -> List[Tuple[str, str]]: + if not self.content.strip(): + return [] + + return [ + (self._get_preview_type(link[0], link[2]), link[2]) + for link in lxml.html.iterlinks(self.content) + ] + + @staticmethod + def _get_preview_type(el: lxml.html.HtmlElement, link: str) -> str: + path = urlparse(link).path.lower() + + for ext in ("jpg", "jpeg", "png", "gif", "bmp", "webp", "tiff", "svg"): + if el.tag == "img" or path.endswith(ext): + return "image" + + return "page" + + @dataclass class Device(ModelItem): diff --git a/src/qml/Base/HButton.qml b/src/qml/Base/HButton.qml index 20225ad5..8d7b5265 100644 --- a/src/qml/Base/HButton.qml +++ b/src/qml/Base/HButton.qml @@ -5,10 +5,10 @@ import QtQuick.Layouts 1.12 Button { id: button spacing: theme.spacing - leftPadding: spacing / (circle ? 1.5 : 1) - rightPadding: leftPadding topPadding: spacing / (circle ? 1.75 : 1.5) bottomPadding: topPadding + leftPadding: spacing / (circle ? 1.5 : 1) + rightPadding: leftPadding iconItem.svgName: loading ? "hourglass" : icon.name icon.color: theme.icons.colorize diff --git a/src/qml/Base/HImage.qml b/src/qml/Base/HImage.qml index 9c920c09..c73639e7 100644 --- a/src/qml/Base/HImage.qml +++ b/src/qml/Base/HImage.qml @@ -3,6 +3,7 @@ import QtGraphicalEffects 1.12 Image { id: image + autoTransform: true asynchronous: true cache: true fillMode: Image.PreserveAspectFit diff --git a/src/qml/Chat/Timeline/EventContent.qml b/src/qml/Chat/Timeline/EventContent.qml index 120ff81d..e6e54563 100644 --- a/src/qml/Chat/Timeline/EventContent.qml +++ b/src/qml/Chat/Timeline/EventContent.qml @@ -7,7 +7,6 @@ Row { id: eventContent spacing: theme.spacing / 2 - readonly property string eventText: Utils.processedEventText(model) readonly property string eventTime: Utils.formatTime(model.date) readonly property int eventTimeSpaces: 2 @@ -29,8 +28,8 @@ Row { HoverHandler { id: hover } Item { - width: hideAvatar ? 0 : 48 - height: hideAvatar ? 0 : collapseAvatar ? 1 : smallAvatar ? 28 : 48 + width: hideAvatar ? 0 : 58 + height: hideAvatar ? 0 : collapseAvatar ? 1 : smallAvatar ? 28 : 58 opacity: hideAvatar || collapseAvatar ? 0 : 1 visible: width > 0 @@ -39,8 +38,8 @@ Row { userId: model.sender_id displayName: model.sender_name avatarUrl: model.sender_avatar - width: hideAvatar ? 0 : 48 - height: hideAvatar ? 0 : collapseAvatar ? 1 : 48 + width: hideAvatar ? 0 : 58 + height: hideAvatar ? 0 : collapseAvatar ? 1 : 58 } } @@ -55,15 +54,18 @@ Row { theme.fontSize.normal * 0.5 * 75, // 600 with 16px font Math.max( nameLabel.visible ? nameLabel.implicitWidth : 0, - contentLabel.implicitWidth + contentLabel.implicitWidth, ) ) - height: (nameLabel.visible ? nameLabel.height : 0) + - contentLabel.implicitHeight + height: childrenRect.height y: parent.height / 2 - height / 2 Column { - anchors.fill: parent + id: mainColumn + width: parent.width + spacing: theme.spacing / 1.75 + topPadding: theme.spacing / 1.75 + bottomPadding: topPadding HSelectableLabel { id: nameLabel @@ -71,6 +73,8 @@ Row { visible: ! hideNameLine container: selectableLabelContainer selectable: ! unselectableNameLine + leftPadding: eventContent.spacing + rightPadding: leftPadding // This is +0.1 and content is +0 instead of the opposite, // because the eventList is reversed @@ -81,10 +85,6 @@ Row { // elide: Text.ElideRight horizontalAlignment: onRight ? Text.AlignRight : Text.AlignLeft - leftPadding: theme.spacing - rightPadding: leftPadding - topPadding: theme.spacing / 2 - function selectAllTextPlus() { contentLabel.selectAllTextPlus() } @@ -97,6 +97,10 @@ Row { width: parent.width container: selectableLabelContainer index: model.index + leftPadding: eventContent.spacing + rightPadding: leftPadding + bottomPadding: previewLinksRepeater.count > 0 ? + mainColumn.bottomPadding : 0 text: theme.chat.message.styleInclude + eventContent.eventText + @@ -115,12 +119,6 @@ Row { wrapMode: Text.Wrap textFormat: Text.RichText - leftPadding: theme.spacing - rightPadding: leftPadding - topPadding: nameLabel.visible ? 0 : bottomPadding - bottomPadding: theme.spacing / 2 - - function selectAllText() { // Select the message body without the date or name container.clearSelection() @@ -142,6 +140,22 @@ Row { HoverHandler { id: contentHover } } + + Repeater { + id: previewLinksRepeater + model: previewLinks + + HLoader { + Component.onCompleted: { + if (modelData[0] == "image") { + setSource( + "EventImage.qml", + { source: modelData[1] }, + ) + } + } + } + } } } } diff --git a/src/qml/Chat/Timeline/EventDelegate.qml b/src/qml/Chat/Timeline/EventDelegate.qml index af0b0f43..5259de7b 100644 --- a/src/qml/Chat/Timeline/EventDelegate.qml +++ b/src/qml/Chat/Timeline/EventDelegate.qml @@ -1,9 +1,19 @@ import QtQuick 2.12 import QtQuick.Layouts 1.12 import "../../Base" +import "../../utils.js" as Utils Column { id: eventDelegate + width: eventList.width + + topPadding: + model.event_type == "RoomCreateEvent" ? 0 : + dayBreak ? theme.spacing * 4 : + talkBreak ? theme.spacing * 6 : + combine ? theme.spacing / 2 : + theme.spacing * 2 + // Remember timeline goes from newest message at index 0 to oldest property var previousItem: eventList.model.get(model.index + 1) @@ -38,14 +48,9 @@ Column { readonly property bool unselectableNameLine: hideNameLine && ! (onRight && ! combine) - width: eventList.width + readonly property var previewLinks: model.preview_links - topPadding: - model.event_type == "RoomCreateEvent" ? 0 : - dayBreak ? theme.spacing * 4 : - talkBreak ? theme.spacing * 6 : - combine ? theme.spacing / 2 : - theme.spacing * 2 + property string hoveredImage: "" Daybreak { @@ -70,15 +75,8 @@ Column { TapHandler { acceptedButtons: Qt.RightButton onTapped: { - contextMenu.link = eventContent.hoveredLink - contextMenu.popup() - } - } - - TapHandler { - acceptedButtons: Qt.LeftButton | Qt.RightButton - onLongPressed: { - contextMenu.link = eventContent.hoveredLink + contextMenu.link = eventContent.hoveredLink + contextMenu.image = eventDelegate.hoveredImage contextMenu.popup() } } @@ -87,8 +85,17 @@ Column { id: contextMenu property string link: "" + property string image: "" - onClosed: link = "" + onClosed: { link = ""; image = "" } + + HMenuItem { + id: copyImage + icon.name: "copy-link" + text: qsTr("Copy image address") + visible: Boolean(contextMenu.image) + onTriggered: Utils.copyToClipboard(contextMenu.image) + } HMenuItem { id: copyLink @@ -101,12 +108,25 @@ Column { HMenuItem { icon.name: "copy-text" text: qsTr("Copy text") - visible: enabled || ! copyLink.visible + visible: enabled || (! copyLink.visible && ! copyImage.visible) enabled: Boolean(selectableLabelContainer.joinedSelection) onTriggered: Utils.copyToClipboard(selectableLabelContainer.joinedSelection) } + HMenuItem { + icon.name: "settings" + text: qsTr("Print event item") + visible: debugMode + onTriggered: print(JSON.stringify(Utils.getItem( + modelSources[[ + "Event", chatPage.userId, chatPage.roomId + ]], + "client_id", + model.client_id + ), null, 4)) + } + HMenuItem { icon.name: "settings" text: qsTr("Set as debug console target") @@ -114,6 +134,6 @@ Column { onTriggered: { mainUI.debugConsole.target = [eventDelegate, eventContent] } - } + } } } diff --git a/src/qml/Chat/Timeline/EventImage.qml b/src/qml/Chat/Timeline/EventImage.qml new file mode 100644 index 00000000..76c1c85e --- /dev/null +++ b/src/qml/Chat/Timeline/EventImage.qml @@ -0,0 +1,31 @@ +import QtQuick 2.12 +import "../../Base" + +HImage { + id: image + x: eventContent.spacing + sourceSize.width: maxDimension + sourceSize.height: maxDimension + width: Math.min( + mainColumn.width - eventContent.spacing * 2, + implicitWidth, + maxDimension, + ) + + property int maxDimension: window.settings.messageImageMaxThumbnailSize + + TapHandler { + onTapped: Qt.openUrlExternally(image.source) + } + + HoverHandler { + id: hover + onHoveredChanged: eventDelegate.hoveredImage = hovered ? image.source : "" + } + + MouseArea { + anchors.fill: image + acceptedButtons: Qt.NoButton + cursorShape: Qt.PointingHandCursor + } +} diff --git a/src/qml/Chat/Timeline/EventList.qml b/src/qml/Chat/Timeline/EventList.qml index af187180..9c008aec 100644 --- a/src/qml/Chat/Timeline/EventList.qml +++ b/src/qml/Chat/Timeline/EventList.qml @@ -177,7 +177,7 @@ Rectangle { } HNoticePage { - text: qsTr("No messages visible yet.") + text: qsTr("No messages visible yet") visible: eventList.model.count < 1 anchors.fill: parent