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*>.*)(!??(?:br|p)/?>)",
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