Use a component to display image link previews

This commit is contained in:
miruka 2019-09-03 03:04:57 -04:00
parent 3c4ca7d433
commit 5674d0c7b7
10 changed files with 147 additions and 100 deletions

View File

@ -11,6 +11,7 @@
- When qml syntax highlighting supports ES6 string interpolation, use them - When qml syntax highlighting supports ES6 string interpolation, use them
- Fixes - Fixes
- Scroll to begin/end
- `minutesBetween()` for 13:13:58 and 14:15:07 - `minutesBetween()` for 13:13:58 and 14:15:07
- `# > quote` doesn't color - `# > quote` doesn't color
- Pressing backspace in composer sometimes doesn't work - Pressing backspace in composer sometimes doesn't work
@ -29,6 +30,7 @@
- Verify big avatars aren't downloaded uselessly - Verify big avatars aren't downloaded uselessly
- UI - UI
- Esc in sidepane to focus chat again
- Set an explicit placeholder text color for text field/area - Set an explicit placeholder text color for text field/area
- Change typing bar background - Change typing bar background
- Show error if uploading avatar fails or file is corrupted - Show error if uploading avatar fails or file is corrupted
@ -38,8 +40,8 @@
- Message selection - Message selection
- Make scroll wheel usable - Make scroll wheel usable
- Copy to X11 selection - Copy to X11 selection
- Make events copiable - Link previews
- Images don't load correctly in TextEdit - Take the previews into account to calculate delegate min height
- Just use Shortcut onHeld instead of analyzing the current velocity - Just use Shortcut onHeld instead of analyzing the current velocity
in `smartVerticalFlick()` in `smartVerticalFlick()`

View File

@ -105,6 +105,7 @@ class UISettings(JSONConfigFile):
async def default_data(self) -> JsonData: async def default_data(self) -> JsonData:
return { return {
"alertOnMessageForMsec": 4000, "alertOnMessageForMsec": 4000,
"messageImageMaxThumbnailSize": 256,
"theme": "Default.qpl", "theme": "Default.qpl",
"writeAliases": {}, "writeAliases": {},
"keys": { "keys": {

View File

@ -1,7 +1,7 @@
import re import re
import mistune import mistune
from lxml.html import HtmlElement, etree # nosec from lxml.html import HtmlElement # nosec
import html_sanitizer.sanitizer as sanitizer import html_sanitizer.sanitizer as sanitizer
from html_sanitizer.sanitizer import Sanitizer from html_sanitizer.sanitizer import Sanitizer
@ -66,39 +66,22 @@ class HtmlFilter:
if outgoing: if outgoing:
return html 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( return re.sub(
r"<(p|br/?)>(\s*&gt;.*)(!?</?(?:br|p)/?>)", r"<(p|br/?)>(\s*&gt;.*)(!?</?(?:br|p)/?>)",
r'<\1><span class="quote">\2</span>\3', r'<\1><span class="quote">\2</span>\3',
text, html,
) )
def sanitize_settings(self, inline: bool = False) -> dict: def sanitize_settings(self, inline: bool = False) -> dict:
# https://matrix.org/docs/spec/client_server/latest#m-room-message-msgtypes # 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"} inline_tags = {"font", "a", "sup", "sub", "b", "i", "s", "u", "code"}
tags = inline_tags | { tags = inline_tags | {
"h1", "h2", "h3", "h4", "h5", "h6","blockquote", "h1", "h2", "h3", "h4", "h5", "h6","blockquote",
"p", "ul", "ol", "li", "hr", "br", "p", "ul", "ol", "li", "hr", "br",
"table", "thead", "tbody", "tr", "th", "td", "table", "thead", "tbody", "tr", "th", "td", "pre",
"pre", "img",
} }
inlines_attributes = { inlines_attributes = {
@ -107,7 +90,6 @@ class HtmlFilter:
"code": {"class"}, "code": {"class"},
} }
attributes = {**inlines_attributes, **{ attributes = {**inlines_attributes, **{
"img": {"width", "height", "alt", "title", "src"},
"ol": {"start"}, "ol": {"start"},
"hr": {"width"}, "hr": {"width"},
}} }}
@ -115,7 +97,7 @@ class HtmlFilter:
return { return {
"tags": inline_tags if inline else tags, "tags": inline_tags if inline else tags,
"attributes": inlines_attributes if inline else attributes, "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 { "separate": {"a"} if inline else {
"a", "p", "li", "table", "tr", "th", "td", "br", "hr", "a", "p", "li", "table", "tr", "th", "td", "br", "hr",
}, },
@ -139,6 +121,7 @@ class HtmlFilter:
sanitizer.tag_replacer("caption", "p"), sanitizer.tag_replacer("caption", "p"),
sanitizer.target_blank_noopener, sanitizer.target_blank_noopener,
self._process_span_font, self._process_span_font,
self._img_to_a,
], ],
"element_postprocessors": [], "element_postprocessors": [],
"is_mergeable": lambda e1, e2: e1.attrib == e2.attrib, "is_mergeable": lambda e1, e2: e1.attrib == e2.attrib,
@ -159,42 +142,13 @@ class HtmlFilter:
@staticmethod @staticmethod
def _wrap_img_in_a(el: HtmlElement) -> HtmlElement: def _img_to_a(el: HtmlElement) -> HtmlElement:
link = el.attrib.get("src", "") if el.tag == "img":
width = el.attrib.get("width", "256")
height = el.attrib.get("height", "256")
if el.getparent().tag == "a" or el.tag != "img":
return el
el.tag = "a" el.tag = "a"
el.attrib.clear() el.attrib["href"] = el.attrib.pop("src", "")
el.attrib["href"] = link el.text = el.attrib.pop("alt", None) or el.attrib["href"]
el.append(etree.Element("img", src=link, width=width, height=height))
return el 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 <a> already has an <img> 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() HTML_FILTER = HtmlFilter()

View File

@ -2,6 +2,9 @@ import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlparse
import lxml # nosec
import nio import nio
@ -145,6 +148,27 @@ class Event(ModelItem):
def event_type(self) -> str: def event_type(self) -> str:
return self.local_event_type or type(self.source).__name__ 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 @dataclass
class Device(ModelItem): class Device(ModelItem):

View File

@ -5,10 +5,10 @@ import QtQuick.Layouts 1.12
Button { Button {
id: button id: button
spacing: theme.spacing spacing: theme.spacing
leftPadding: spacing / (circle ? 1.5 : 1)
rightPadding: leftPadding
topPadding: spacing / (circle ? 1.75 : 1.5) topPadding: spacing / (circle ? 1.75 : 1.5)
bottomPadding: topPadding bottomPadding: topPadding
leftPadding: spacing / (circle ? 1.5 : 1)
rightPadding: leftPadding
iconItem.svgName: loading ? "hourglass" : icon.name iconItem.svgName: loading ? "hourglass" : icon.name
icon.color: theme.icons.colorize icon.color: theme.icons.colorize

View File

@ -3,6 +3,7 @@ import QtGraphicalEffects 1.12
Image { Image {
id: image id: image
autoTransform: true
asynchronous: true asynchronous: true
cache: true cache: true
fillMode: Image.PreserveAspectFit fillMode: Image.PreserveAspectFit

View File

@ -7,7 +7,6 @@ Row {
id: eventContent id: eventContent
spacing: theme.spacing / 2 spacing: theme.spacing / 2
readonly property string eventText: Utils.processedEventText(model) readonly property string eventText: Utils.processedEventText(model)
readonly property string eventTime: Utils.formatTime(model.date) readonly property string eventTime: Utils.formatTime(model.date)
readonly property int eventTimeSpaces: 2 readonly property int eventTimeSpaces: 2
@ -29,8 +28,8 @@ Row {
HoverHandler { id: hover } HoverHandler { id: hover }
Item { Item {
width: hideAvatar ? 0 : 48 width: hideAvatar ? 0 : 58
height: hideAvatar ? 0 : collapseAvatar ? 1 : smallAvatar ? 28 : 48 height: hideAvatar ? 0 : collapseAvatar ? 1 : smallAvatar ? 28 : 58
opacity: hideAvatar || collapseAvatar ? 0 : 1 opacity: hideAvatar || collapseAvatar ? 0 : 1
visible: width > 0 visible: width > 0
@ -39,8 +38,8 @@ Row {
userId: model.sender_id userId: model.sender_id
displayName: model.sender_name displayName: model.sender_name
avatarUrl: model.sender_avatar avatarUrl: model.sender_avatar
width: hideAvatar ? 0 : 48 width: hideAvatar ? 0 : 58
height: hideAvatar ? 0 : collapseAvatar ? 1 : 48 height: hideAvatar ? 0 : collapseAvatar ? 1 : 58
} }
} }
@ -55,15 +54,18 @@ Row {
theme.fontSize.normal * 0.5 * 75, // 600 with 16px font theme.fontSize.normal * 0.5 * 75, // 600 with 16px font
Math.max( Math.max(
nameLabel.visible ? nameLabel.implicitWidth : 0, nameLabel.visible ? nameLabel.implicitWidth : 0,
contentLabel.implicitWidth contentLabel.implicitWidth,
) )
) )
height: (nameLabel.visible ? nameLabel.height : 0) + height: childrenRect.height
contentLabel.implicitHeight
y: parent.height / 2 - height / 2 y: parent.height / 2 - height / 2
Column { Column {
anchors.fill: parent id: mainColumn
width: parent.width
spacing: theme.spacing / 1.75
topPadding: theme.spacing / 1.75
bottomPadding: topPadding
HSelectableLabel { HSelectableLabel {
id: nameLabel id: nameLabel
@ -71,6 +73,8 @@ Row {
visible: ! hideNameLine visible: ! hideNameLine
container: selectableLabelContainer container: selectableLabelContainer
selectable: ! unselectableNameLine selectable: ! unselectableNameLine
leftPadding: eventContent.spacing
rightPadding: leftPadding
// This is +0.1 and content is +0 instead of the opposite, // This is +0.1 and content is +0 instead of the opposite,
// because the eventList is reversed // because the eventList is reversed
@ -81,10 +85,6 @@ Row {
// elide: Text.ElideRight // elide: Text.ElideRight
horizontalAlignment: onRight ? Text.AlignRight : Text.AlignLeft horizontalAlignment: onRight ? Text.AlignRight : Text.AlignLeft
leftPadding: theme.spacing
rightPadding: leftPadding
topPadding: theme.spacing / 2
function selectAllTextPlus() { function selectAllTextPlus() {
contentLabel.selectAllTextPlus() contentLabel.selectAllTextPlus()
} }
@ -97,6 +97,10 @@ Row {
width: parent.width width: parent.width
container: selectableLabelContainer container: selectableLabelContainer
index: model.index index: model.index
leftPadding: eventContent.spacing
rightPadding: leftPadding
bottomPadding: previewLinksRepeater.count > 0 ?
mainColumn.bottomPadding : 0
text: theme.chat.message.styleInclude + text: theme.chat.message.styleInclude +
eventContent.eventText + eventContent.eventText +
@ -115,12 +119,6 @@ Row {
wrapMode: Text.Wrap wrapMode: Text.Wrap
textFormat: Text.RichText textFormat: Text.RichText
leftPadding: theme.spacing
rightPadding: leftPadding
topPadding: nameLabel.visible ? 0 : bottomPadding
bottomPadding: theme.spacing / 2
function selectAllText() { function selectAllText() {
// Select the message body without the date or name // Select the message body without the date or name
container.clearSelection() container.clearSelection()
@ -142,6 +140,22 @@ Row {
HoverHandler { id: contentHover } HoverHandler { id: contentHover }
} }
Repeater {
id: previewLinksRepeater
model: previewLinks
HLoader {
Component.onCompleted: {
if (modelData[0] == "image") {
setSource(
"EventImage.qml",
{ source: modelData[1] },
)
}
}
}
}
} }
} }
} }

View File

@ -1,9 +1,19 @@
import QtQuick 2.12 import QtQuick 2.12
import QtQuick.Layouts 1.12 import QtQuick.Layouts 1.12
import "../../Base" import "../../Base"
import "../../utils.js" as Utils
Column { Column {
id: eventDelegate 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 // Remember timeline goes from newest message at index 0 to oldest
property var previousItem: eventList.model.get(model.index + 1) property var previousItem: eventList.model.get(model.index + 1)
@ -38,14 +48,9 @@ Column {
readonly property bool unselectableNameLine: readonly property bool unselectableNameLine:
hideNameLine && ! (onRight && ! combine) hideNameLine && ! (onRight && ! combine)
width: eventList.width readonly property var previewLinks: model.preview_links
topPadding: property string hoveredImage: ""
model.event_type == "RoomCreateEvent" ? 0 :
dayBreak ? theme.spacing * 4 :
talkBreak ? theme.spacing * 6 :
combine ? theme.spacing / 2 :
theme.spacing * 2
Daybreak { Daybreak {
@ -71,14 +76,7 @@ Column {
acceptedButtons: Qt.RightButton acceptedButtons: Qt.RightButton
onTapped: { onTapped: {
contextMenu.link = eventContent.hoveredLink contextMenu.link = eventContent.hoveredLink
contextMenu.popup() contextMenu.image = eventDelegate.hoveredImage
}
}
TapHandler {
acceptedButtons: Qt.LeftButton | Qt.RightButton
onLongPressed: {
contextMenu.link = eventContent.hoveredLink
contextMenu.popup() contextMenu.popup()
} }
} }
@ -87,8 +85,17 @@ Column {
id: contextMenu id: contextMenu
property string link: "" 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 { HMenuItem {
id: copyLink id: copyLink
@ -101,12 +108,25 @@ Column {
HMenuItem { HMenuItem {
icon.name: "copy-text" icon.name: "copy-text"
text: qsTr("Copy text") text: qsTr("Copy text")
visible: enabled || ! copyLink.visible visible: enabled || (! copyLink.visible && ! copyImage.visible)
enabled: Boolean(selectableLabelContainer.joinedSelection) enabled: Boolean(selectableLabelContainer.joinedSelection)
onTriggered: onTriggered:
Utils.copyToClipboard(selectableLabelContainer.joinedSelection) 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 { HMenuItem {
icon.name: "settings" icon.name: "settings"
text: qsTr("Set as debug console target") text: qsTr("Set as debug console target")

View File

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

View File

@ -177,7 +177,7 @@ Rectangle {
} }
HNoticePage { HNoticePage {
text: qsTr("No messages visible yet.") text: qsTr("No messages visible yet")
visible: eventList.model.count < 1 visible: eventList.model.count < 1
anchors.fill: parent anchors.fill: parent