summary refs log tree commit diff
path: root/resources/qml
diff options
context:
space:
mode:
authorJoseph Donofry <joedonofry@gmail.com>2019-12-07 21:39:47 -0500
committerJoseph Donofry <joedonofry@gmail.com>2019-12-07 21:39:47 -0500
commite79ae4ea0996f175ea607e22627a447a4de2a1dc (patch)
treea49d9a6936c22619d619f02e5a654c80f283f5c1 /resources/qml
parentMerge branch '0.7.0-dev' of ssh://github.com/Nheko-Reborn/nheko into 0.7.0-dev (diff)
parentSimplify scroll logic (diff)
downloadnheko-e79ae4ea0996f175ea607e22627a447a4de2a1dc.tar.xz
Merge branch '0.7.0-dev' of ssh://github.com/Nheko-Reborn/nheko into 0.7.0-dev
Diffstat (limited to 'resources/qml')
-rw-r--r--resources/qml/Avatar.qml51
-rw-r--r--resources/qml/EncryptionIndicator.qml24
-rw-r--r--resources/qml/ImageButton.qml29
-rw-r--r--resources/qml/MatrixText.qml33
-rw-r--r--resources/qml/StatusIndicator.qml38
-rw-r--r--resources/qml/TimelineRow.qml122
-rw-r--r--resources/qml/TimelineView.qml185
-rw-r--r--resources/qml/delegates/FileMessage.qml57
-rw-r--r--resources/qml/delegates/ImageMessage.qml23
-rw-r--r--resources/qml/delegates/MessageDelegate.qml55
-rw-r--r--resources/qml/delegates/NoticeMessage.qml8
-rw-r--r--resources/qml/delegates/Pill.qml14
-rw-r--r--resources/qml/delegates/Placeholder.qml7
-rw-r--r--resources/qml/delegates/PlayableMediaMessage.qml164
-rw-r--r--resources/qml/delegates/TextMessage.qml6
15 files changed, 816 insertions, 0 deletions
diff --git a/resources/qml/Avatar.qml b/resources/qml/Avatar.qml
new file mode 100644

index 00000000..a53f057b --- /dev/null +++ b/resources/qml/Avatar.qml
@@ -0,0 +1,51 @@ +import QtQuick 2.6 +import QtGraphicalEffects 1.0 +import Qt.labs.settings 1.0 + +Rectangle { + id: avatar + width: 48 + height: 48 + radius: settings.avatar_circles ? height/2 : 3 + + Settings { + id: settings + category: "user" + property bool avatar_circles: true + } + + property alias url: img.source + property string displayName + + Text { + anchors.fill: parent + text: String.fromCodePoint(displayName.codePointAt(0)) + color: colors.text + font.pixelSize: avatar.height/2 + verticalAlignment: Text.AlignVCenter + horizontalAlignment: Text.AlignHCenter + } + + Image { + id: img + anchors.fill: parent + asynchronous: true + fillMode: Image.PreserveAspectCrop + mipmap: true + smooth: false + + sourceSize.width: avatar.width + sourceSize.height: avatar.height + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + anchors.fill: parent + width: avatar.width + height: avatar.height + radius: settings.avatar_circles ? height/2 : 3 + } + } + } + color: colors.dark +} diff --git a/resources/qml/EncryptionIndicator.qml b/resources/qml/EncryptionIndicator.qml new file mode 100644
index 00000000..905cf934 --- /dev/null +++ b/resources/qml/EncryptionIndicator.qml
@@ -0,0 +1,24 @@ +import QtQuick 2.5 +import QtQuick.Controls 2.1 +import im.nheko 1.0 + +Rectangle { + id: indicator + color: "transparent" + width: 16 + height: 16 + ToolTip.visible: ma.containsMouse && indicator.visible + ToolTip.text: qsTr("Encrypted") + MouseArea{ + id: ma + anchors.fill: parent + hoverEnabled: true + } + + Image { + id: stateImg + anchors.fill: parent + source: "image://colorimage/:/icons/icons/ui/lock.png?"+colors.buttonText + } +} + diff --git a/resources/qml/ImageButton.qml b/resources/qml/ImageButton.qml new file mode 100644
index 00000000..dc576e18 --- /dev/null +++ b/resources/qml/ImageButton.qml
@@ -0,0 +1,29 @@ +import QtQuick 2.3 +import QtQuick.Controls 2.3 + +Button { + property string image: undefined + + id: button + + flat: true + + // disable background, because we don't want a border on hover + background: Item { + } + + Image { + id: buttonImg + // Workaround, can't get icon.source working for now... + anchors.fill: parent + source: "image://colorimage/" + image + "?" + (button.hovered ? colors.highlight : colors.buttonText) + } + + MouseArea + { + id: mouseArea + anchors.fill: parent + onPressed: mouse.accepted = false + cursorShape: Qt.PointingHandCursor + } +} diff --git a/resources/qml/MatrixText.qml b/resources/qml/MatrixText.qml new file mode 100644
index 00000000..46e74711 --- /dev/null +++ b/resources/qml/MatrixText.qml
@@ -0,0 +1,33 @@ +import QtQuick 2.5 +import QtQuick.Controls 2.3 + +TextEdit { + textFormat: TextEdit.RichText + readOnly: true + wrapMode: Text.Wrap + selectByMouse: true + color: colors.text + + onLinkActivated: { + if (/^https:\/\/matrix.to\/#\/(@.*)$/.test(link)) chat.model.openUserProfile(/^https:\/\/matrix.to\/#\/(@.*)$/.exec(link)[1]) + else if (/^https:\/\/matrix.to\/#\/(![^\/]*)$/.test(link)) timelineManager.setHistoryView(/^https:\/\/matrix.to\/#\/(!.*)$/.exec(link)[1]) + else if (/^https:\/\/matrix.to\/#\/(![^\/]*)\/(\$.*)$/.test(link)) { + var match = /^https:\/\/matrix.to\/#\/(![^\/]*)\/(\$.*)$/.exec(link) + timelineManager.setHistoryView(match[1]) + chat.positionViewAtIndex(chat.model.idToIndex(match[2]), ListView.Contain) + } + else Qt.openUrlExternally(link) + } + MouseArea + { + anchors.fill: parent + onPressed: mouse.accepted = false + cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor + } + + ToolTip { + visible: parent.hoveredLink + text: parent.hoveredLink + palette: colors + } +} diff --git a/resources/qml/StatusIndicator.qml b/resources/qml/StatusIndicator.qml new file mode 100644
index 00000000..91e8f769 --- /dev/null +++ b/resources/qml/StatusIndicator.qml
@@ -0,0 +1,38 @@ +import QtQuick 2.5 +import QtQuick.Controls 2.1 +import im.nheko 1.0 + +Rectangle { + id: indicator + property int state: 0 + color: "transparent" + width: 16 + height: 16 + ToolTip.visible: ma.containsMouse && state != MtxEvent.Empty + ToolTip.text: switch (state) { + case MtxEvent.Failed: return qsTr("Failed") + case MtxEvent.Sent: return qsTr("Sent") + case MtxEvent.Received: return qsTr("Received") + case MtxEvent.Read: return qsTr("Read") + default: return "" + } + MouseArea{ + id: ma + anchors.fill: parent + hoverEnabled: true + } + + Image { + id: stateImg + // Workaround, can't get icon.source working for now... + anchors.fill: parent + source: switch (indicator.state) { + case MtxEvent.Failed: return "image://colorimage/:/icons/icons/ui/remove-symbol.png?" + colors.buttonText + case MtxEvent.Sent: return "image://colorimage/:/icons/icons/ui/clock.png?" + colors.buttonText + case MtxEvent.Received: return "image://colorimage/:/icons/icons/ui/checkmark.png?" + colors.buttonText + case MtxEvent.Read: return "image://colorimage/:/icons/icons/ui/double-tick-indicator.png?" + colors.buttonText + default: return "" + } + } +} + diff --git a/resources/qml/TimelineRow.qml b/resources/qml/TimelineRow.qml new file mode 100644
index 00000000..2c2ed02a --- /dev/null +++ b/resources/qml/TimelineRow.qml
@@ -0,0 +1,122 @@ +import QtQuick 2.6 +import QtQuick.Controls 2.3 +import QtQuick.Layouts 1.2 +import QtQuick.Window 2.2 + +import im.nheko 1.0 + +import "./delegates" + +RowLayout { + property var view: chat + + anchors.leftMargin: avatarSize + 4 + anchors.left: parent.left + anchors.right: parent.right + + height: Math.max(contentItem.height, 16) + + Column { + Layout.fillWidth: true + Layout.alignment: Qt.AlignTop + + //property var replyTo: model.replyTo + + //Text { + // property int idx: timelineManager.timeline.idToIndex(replyTo) + // text: "" + (idx != -1 ? timelineManager.timeline.data(timelineManager.timeline.index(idx, 0), 2) : "nothing") + //} + MessageDelegate { + id: contentItem + + width: parent.width + height: childrenRect.height + } + } + + StatusIndicator { + state: model.state + Layout.alignment: Qt.AlignRight | Qt.AlignTop + Layout.preferredHeight: 16 + } + + EncryptionIndicator { + visible: model.isEncrypted + Layout.alignment: Qt.AlignRight | Qt.AlignTop + Layout.preferredHeight: 16 + } + + ImageButton { + Layout.alignment: Qt.AlignRight | Qt.AlignTop + Layout.preferredHeight: 16 + id: replyButton + + image: ":/icons/icons/ui/mail-reply.png" + ToolTip { + visible: replyButton.hovered + text: qsTr("Reply") + palette: colors + } + + onClicked: view.model.replyAction(model.id) + } + ImageButton { + Layout.alignment: Qt.AlignRight | Qt.AlignTop + Layout.preferredHeight: 16 + id: optionsButton + + image: ":/icons/icons/ui/vertical-ellipsis.png" + ToolTip { + visible: optionsButton.hovered + text: qsTr("Options") + palette: colors + } + + onClicked: contextMenu.open() + + Menu { + y: optionsButton.height + id: contextMenu + palette: colors + + MenuItem { + text: qsTr("Read receipts") + onTriggered: view.model.readReceiptsAction(model.id) + } + MenuItem { + text: qsTr("Mark as read") + } + MenuItem { + text: qsTr("View raw message") + onTriggered: view.model.viewRawMessage(model.id) + } + MenuItem { + text: qsTr("Redact message") + onTriggered: view.model.redactEvent(model.id) + } + MenuItem { + visible: model.type == MtxEvent.ImageMessage || model.type == MtxEvent.VideoMessage || model.type == MtxEvent.AudioMessage || model.type == MtxEvent.FileMessage || model.type == MtxEvent.Sticker + text: qsTr("Save as") + onTriggered: timelineManager.timeline.saveMedia(model.id) + } + } + } + + Text { + Layout.alignment: Qt.AlignRight | Qt.AlignTop + text: model.timestamp.toLocaleTimeString("HH:mm") + color: inactiveColors.text + + MouseArea{ + id: ma + anchors.fill: parent + hoverEnabled: true + } + + ToolTip { + visible: ma.containsMouse + text: Qt.formatDateTime(model.timestamp, Qt.DefaultLocaleLongDate) + palette: colors + } + } +} diff --git a/resources/qml/TimelineView.qml b/resources/qml/TimelineView.qml new file mode 100644
index 00000000..1a1900ad --- /dev/null +++ b/resources/qml/TimelineView.qml
@@ -0,0 +1,185 @@ +import QtQuick 2.9 +import QtQuick.Controls 2.1 +import QtQuick.Layouts 1.2 +import QtGraphicalEffects 1.0 +import QtQuick.Window 2.2 + +import im.nheko 1.0 + +import "./delegates" + +Item { + property var colors: currentActivePalette + property var systemInactive: SystemPalette { colorGroup: SystemPalette.Disabled } + property var inactiveColors: currentInactivePalette ? currentInactivePalette : systemInactive + property int avatarSize: 40 + + Rectangle { + anchors.fill: parent + color: colors.window + + Text { + visible: !timelineManager.timeline && !timelineManager.isInitialSync + anchors.centerIn: parent + text: qsTr("No room open") + font.pointSize: 24 + color: colors.windowText + } + + BusyIndicator { + anchors.centerIn: parent + running: timelineManager.isInitialSync + height: 200 + width: 200 + } + + ListView { + id: chat + + cacheBuffer: 2000 + + visible: timelineManager.timeline != null + anchors.fill: parent + + anchors.leftMargin: 4 + anchors.rightMargin: scrollbar.width + + model: timelineManager.timeline + + boundsBehavior: Flickable.StopAtBounds + + onVerticalOvershootChanged: contentY = contentY - verticalOvershoot + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + propagateComposedEvents: true + z: -1 + onWheel: { + if (wheel.angleDelta != 0) { + chat.contentY = chat.contentY - wheel.angleDelta.y + wheel.accepted = true + chat.forceLayout() + chat.updatePosition() + } + } + } + + onModelChanged: { + if (model) { + currentIndex = model.currentIndex + if (model.currentIndex == count - 1) { + positionViewAtEnd() + } else { + positionViewAtIndex(model.currentIndex, ListView.End) + } + } + } + + ScrollBar.vertical: ScrollBar { + id: scrollbar + parent: chat.parent + anchors.top: chat.top + anchors.left: chat.right + anchors.bottom: chat.bottom + onPressedChanged: if (!pressed) chat.updatePosition() + } + + property bool atBottom: false + onCountChanged: { + if (atBottom) { + var newIndex = count - 1 // last index + positionViewAtEnd() + currentIndex = newIndex + model.currentIndex = newIndex + } + + if (contentHeight < height && model) { + model.fetchHistory(); + } + } + + onAtYBeginningChanged: if (atYBeginning) { chat.model.currentIndex = 0; chat.currentIndex = 0; model.fetchHistory(); } + + function updatePosition() { + for (var y = chat.contentY + chat.height; y > chat.height; y -= 9) { + var i = chat.itemAt(100, y); + if (!i) continue; + if (!i.isFullyVisible()) continue; + chat.model.currentIndex = i.getIndex(); + chat.currentIndex = i.getIndex() + atBottom = i.getIndex() == count - 1; + break; + } + } + onMovementEnded: updatePosition() + + spacing: 4 + delegate: TimelineRow { + function isFullyVisible() { + return height > 1 && (y - chat.contentY - 1) + height < chat.height + } + function getIndex() { + return index; + } + } + + section { + property: "section" + delegate: Column { + topPadding: 4 + bottomPadding: 4 + spacing: 8 + + width: parent.width + height: (section.includes(" ") ? dateBubble.height + 8 + userName.height : userName.height) + 8 + + Label { + id: dateBubble + anchors.horizontalCenter: parent.horizontalCenter + visible: section.includes(" ") + text: chat.model.formatDateSeparator(new Date(Number(section.split(" ")[1]))) + color: colors.windowText + + height: contentHeight * 1.2 + width: contentWidth * 1.2 + horizontalAlignment: Text.AlignHCenter + background: Rectangle { + radius: parent.height / 2 + color: colors.dark + } + } + Row { + height: userName.height + spacing: 4 + Avatar { + width: avatarSize + height: avatarSize + url: chat.model.avatarUrl(section.split(" ")[0]).replace("mxc://", "image://MxcImage/") + displayName: chat.model.displayName(section.split(" ")[0]) + + MouseArea { + anchors.fill: parent + onClicked: chat.model.openUserProfile(section.split(" ")[0]) + cursorShape: Qt.PointingHandCursor + } + } + + Text { + id: userName + text: chat.model.escapeEmoji(chat.model.displayName(section.split(" ")[0])) + color: chat.model.userColor(section.split(" ")[0], colors.window) + textFormat: Text.RichText + + MouseArea { + anchors.fill: parent + onClicked: chat.model.openUserProfile(section.split(" ")[0]) + cursorShape: Qt.PointingHandCursor + } + } + } + } + } + } + } +} diff --git a/resources/qml/delegates/FileMessage.qml b/resources/qml/delegates/FileMessage.qml new file mode 100644
index 00000000..2c911c5e --- /dev/null +++ b/resources/qml/delegates/FileMessage.qml
@@ -0,0 +1,57 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.2 + +Rectangle { + radius: 10 + color: colors.dark + height: row.height + 24 + width: parent ? parent.width : undefined + + RowLayout { + id: row + + anchors.centerIn: parent + width: parent.width - 24 + + spacing: 15 + + Rectangle { + id: button + color: colors.light + radius: 22 + height: 44 + width: 44 + Image { + id: img + anchors.centerIn: parent + + source: "qrc:/icons/icons/ui/arrow-pointing-down.png" + fillMode: Image.Pad + + } + MouseArea { + anchors.fill: parent + onClicked: timelineManager.timeline.saveMedia(model.id) + cursorShape: Qt.PointingHandCursor + } + } + ColumnLayout { + id: col + + Text { + Layout.fillWidth: true + text: model.body + textFormat: Text.PlainText + elide: Text.ElideRight + color: colors.text + } + Text { + Layout.fillWidth: true + text: model.filesize + textFormat: Text.PlainText + elide: Text.ElideRight + color: colors.text + } + } + } +} diff --git a/resources/qml/delegates/ImageMessage.qml b/resources/qml/delegates/ImageMessage.qml new file mode 100644
index 00000000..1b6e5729 --- /dev/null +++ b/resources/qml/delegates/ImageMessage.qml
@@ -0,0 +1,23 @@ +import QtQuick 2.6 + +import im.nheko 1.0 + +Item { + width: Math.min(parent ? parent.width : undefined, model.width) + height: width * model.proportionalHeight + + Image { + id: img + anchors.fill: parent + + source: model.url.replace("mxc://", "image://MxcImage/") + asynchronous: true + fillMode: Image.PreserveAspectFit + + MouseArea { + enabled: model.type == MtxEvent.ImageMessage + anchors.fill: parent + onClicked: timelineManager.openImageOverlay(model.url, model.id) + } + } +} diff --git a/resources/qml/delegates/MessageDelegate.qml b/resources/qml/delegates/MessageDelegate.qml new file mode 100644
index 00000000..178dfd86 --- /dev/null +++ b/resources/qml/delegates/MessageDelegate.qml
@@ -0,0 +1,55 @@ +import QtQuick 2.6 +import im.nheko 1.0 + +DelegateChooser { + //role: "type" //< not supported in our custom implementation, have to use roleValue + roleValue: model.type + + DelegateChoice { + roleValue: MtxEvent.TextMessage + TextMessage {} + } + DelegateChoice { + roleValue: MtxEvent.NoticeMessage + NoticeMessage {} + } + DelegateChoice { + roleValue: MtxEvent.EmoteMessage + TextMessage {} + } + DelegateChoice { + roleValue: MtxEvent.ImageMessage + ImageMessage {} + } + DelegateChoice { + roleValue: MtxEvent.Sticker + ImageMessage {} + } + DelegateChoice { + roleValue: MtxEvent.FileMessage + FileMessage {} + } + DelegateChoice { + roleValue: MtxEvent.VideoMessage + PlayableMediaMessage {} + } + DelegateChoice { + roleValue: MtxEvent.AudioMessage + PlayableMediaMessage {} + } + DelegateChoice { + roleValue: MtxEvent.Redacted + Pill { + text: qsTr("redacted") + } + } + DelegateChoice { + roleValue: MtxEvent.Encryption + Pill { + text: qsTr("Encryption enabled") + } + } + DelegateChoice { + Placeholder {} + } +} diff --git a/resources/qml/delegates/NoticeMessage.qml b/resources/qml/delegates/NoticeMessage.qml new file mode 100644
index 00000000..a392eb5b --- /dev/null +++ b/resources/qml/delegates/NoticeMessage.qml
@@ -0,0 +1,8 @@ +import ".." + +MatrixText { + text: model.formattedBody + width: parent ? parent.width : undefined + font.italic: true + color: inactiveColors.text +} diff --git a/resources/qml/delegates/Pill.qml b/resources/qml/delegates/Pill.qml new file mode 100644
index 00000000..53a9684e --- /dev/null +++ b/resources/qml/delegates/Pill.qml
@@ -0,0 +1,14 @@ +import QtQuick 2.5 +import QtQuick.Controls 2.1 + +Label { + color: inactiveColors.text + horizontalAlignment: Text.AlignHCenter + + height: contentHeight * 1.2 + width: contentWidth * 1.2 + background: Rectangle { + radius: parent.height / 2 + color: colors.dark + } +} diff --git a/resources/qml/delegates/Placeholder.qml b/resources/qml/delegates/Placeholder.qml new file mode 100644
index 00000000..4c0e68c3 --- /dev/null +++ b/resources/qml/delegates/Placeholder.qml
@@ -0,0 +1,7 @@ +import ".." + +MatrixText { + text: qsTr("unimplemented event: ") + model.type + width: parent ? parent.width : undefined + color: inactiveColors.text +} diff --git a/resources/qml/delegates/PlayableMediaMessage.qml b/resources/qml/delegates/PlayableMediaMessage.qml new file mode 100644
index 00000000..d0d4d7cb --- /dev/null +++ b/resources/qml/delegates/PlayableMediaMessage.qml
@@ -0,0 +1,164 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.2 +import QtQuick.Controls 2.1 +import QtMultimedia 5.6 + +import im.nheko 1.0 + +Rectangle { + id: bg + radius: 10 + color: colors.dark + height: content.height + 24 + width: parent ? parent.width : undefined + + Column { + id: content + width: parent.width - 24 + anchors.centerIn: parent + + Rectangle { + id: videoContainer + visible: model.type == MtxEvent.VideoMessage + width: Math.min(parent.width, model.width ? model.width : 400) // some media has 0 as size... + height: width*model.proportionalHeight + Image { + anchors.fill: parent + source: model.thumbnailUrl.replace("mxc://", "image://MxcImage/") + asynchronous: true + fillMode: Image.PreserveAspectFit + + VideoOutput { + anchors.fill: parent + fillMode: VideoOutput.PreserveAspectFit + source: media + } + } + } + + RowLayout { + width: parent.width + Text { + id: positionText + text: "--:--:--" + color: colors.text + } + Slider { + Layout.fillWidth: true + id: progress + value: media.position + from: 0 + to: media.duration + + onMoved: media.seek(value) + //indeterminate: true + function updatePositionTexts() { + function formatTime(date) { + var hh = date.getUTCHours(); + var mm = date.getUTCMinutes(); + var ss = date.getSeconds(); + if (hh < 10) {hh = "0"+hh;} + if (mm < 10) {mm = "0"+mm;} + if (ss < 10) {ss = "0"+ss;} + return hh+":"+mm+":"+ss; + } + positionText.text = formatTime(new Date(media.position)) + durationText.text = formatTime(new Date(media.duration)) + } + onValueChanged: updatePositionTexts() + } + Text { + id: durationText + text: "--:--:--" + color: colors.text + } + } + + RowLayout { + width: parent.width + + spacing: 15 + + Rectangle { + id: button + color: colors.light + radius: 22 + height: 44 + width: 44 + Image { + id: img + anchors.centerIn: parent + + source: "qrc:/icons/icons/ui/arrow-pointing-down.png" + fillMode: Image.Pad + + } + MouseArea { + anchors.fill: parent + onClicked: { + switch (button.state) { + case "": timelineManager.timeline.cacheMedia(model.id); break; + case "stopped": + media.play(); console.log("play"); + button.state = "playing" + break + case "playing": + media.pause(); console.log("pause"); + button.state = "stopped" + break + } + } + cursorShape: Qt.PointingHandCursor + } + MediaPlayer { + id: media + onError: console.log(errorString) + onStatusChanged: if(status == MediaPlayer.Loaded) progress.updatePositionTexts() + onStopped: button.state = "stopped" + } + + Connections { + target: timelineManager.timeline + onMediaCached: { + if (mxcUrl == model.url) { + media.source = "file://" + cacheUrl + button.state = "stopped" + console.log("media loaded: " + mxcUrl + " at " + cacheUrl) + } + console.log("media cached: " + mxcUrl + " at " + cacheUrl) + } + } + + states: [ + State { + name: "stopped" + PropertyChanges { target: img; source: "qrc:/icons/icons/ui/play-sign.png" } + }, + State { + name: "playing" + PropertyChanges { target: img; source: "qrc:/icons/icons/ui/pause-symbol.png" } + } + ] + } + ColumnLayout { + id: col + + Text { + Layout.fillWidth: true + text: model.body + textFormat: Text.PlainText + elide: Text.ElideRight + color: colors.text + } + Text { + Layout.fillWidth: true + text: model.filesize + textFormat: Text.PlainText + elide: Text.ElideRight + color: colors.text + } + } + } + } +} + diff --git a/resources/qml/delegates/TextMessage.qml b/resources/qml/delegates/TextMessage.qml new file mode 100644
index 00000000..f984b32f --- /dev/null +++ b/resources/qml/delegates/TextMessage.qml
@@ -0,0 +1,6 @@ +import ".." + +MatrixText { + text: model.formattedBody.replace("<pre>", "<pre style='white-space: pre-wrap'>") + width: parent ? parent.width : undefined +}