summary refs log tree commit diff
diff options
context:
space:
mode:
authorNicolas Werner <nicolas.werner@hotmail.de>2022-09-30 03:27:05 +0200
committerNicolas Werner <nicolas.werner@hotmail.de>2022-09-30 03:27:05 +0200
commit88cbac1695292e1dade325ee26ba5c52467d4b75 (patch)
tree275ef197ca81de7d08291f937cf4eca0451e4fcf
parentMake clazy happy (diff)
downloadnheko-88cbac1695292e1dade325ee26ba5c52467d4b75.tar.xz
Basic threading support
-rw-r--r--CMakeLists.txt2
-rw-r--r--io.github.NhekoReborn.Nheko.yaml2
-rw-r--r--resources/icons/ui/bookmark-disabled.svg1
-rw-r--r--resources/icons/ui/bookmark.svg1
-rw-r--r--resources/icons/ui/dismiss_thread.svg4
-rw-r--r--resources/icons/ui/needle.svg6
-rw-r--r--resources/icons/ui/new-thread.svg1
-rw-r--r--resources/icons/ui/thread.svg1
-rw-r--r--resources/qml/MessageInput.qml4
-rw-r--r--resources/qml/MessageView.qml44
-rw-r--r--resources/qml/ReplyPopup.qml23
-rw-r--r--resources/qml/TimelineRow.qml19
-rw-r--r--resources/res.qrc3
-rw-r--r--src/Cache.cpp26
-rw-r--r--src/Cache_p.h3
-rw-r--r--src/timeline/EventStore.cpp4
-rw-r--r--src/timeline/InputBar.cpp127
-rw-r--r--src/timeline/InputBar.h2
-rw-r--r--src/timeline/TimelineModel.cpp42
-rw-r--r--src/timeline/TimelineModel.h10
-rw-r--r--src/ui/MxcAnimatedImage.cpp47
-rw-r--r--src/ui/MxcAnimatedImage.h31
22 files changed, 240 insertions, 163 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt

index ba90835c..a1e0a41f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt
@@ -581,7 +581,7 @@ if(USE_BUNDLED_MTXCLIENT) FetchContent_Declare( MatrixClient GIT_REPOSITORY https://github.com/Nheko-Reborn/mtxclient.git - GIT_TAG b3740e26ad07a534b6092959c0da4d91b77a8528 + GIT_TAG 5ef4460c26acb02f24530db1c6058534b87014f6 ) set(BUILD_LIB_EXAMPLES OFF CACHE INTERNAL "") set(BUILD_LIB_TESTS OFF CACHE INTERNAL "") diff --git a/io.github.NhekoReborn.Nheko.yaml b/io.github.NhekoReborn.Nheko.yaml
index 1c358f88..eb2feb96 100644 --- a/io.github.NhekoReborn.Nheko.yaml +++ b/io.github.NhekoReborn.Nheko.yaml
@@ -173,7 +173,7 @@ modules: buildsystem: cmake-ninja name: mtxclient sources: - - commit: b3740e26ad07a534b6092959c0da4d91b77a8528 + - commit: 5ef4460c26acb02f24530db1c6058534b87014f6 #tag: v0.8.2 type: git url: https://github.com/Nheko-Reborn/mtxclient.git diff --git a/resources/icons/ui/bookmark-disabled.svg b/resources/icons/ui/bookmark-disabled.svg new file mode 100644
index 00000000..00d47430 --- /dev/null +++ b/resources/icons/ui/bookmark-disabled.svg
@@ -0,0 +1 @@ +<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M3.28 2.22a.75.75 0 1 0-1.06 1.06l2.788 2.788a3.3 3.3 0 0 0-.005.181v14.996a.75.75 0 0 0 1.188.609L12 17.673l5.812 4.18A.75.75 0 0 0 19 21.246v-1.183l1.718 1.718a.75.75 0 0 0 1.061-1.06L3.28 2.22Zm14.221 16.342v1.22L12.44 16.14a.75.75 0 0 0-.876 0l-5.061 3.642V7.563L17.5 18.562ZM17.501 6.25v8.07l1.5 1.5V6.25A3.25 3.25 0 0 0 15.751 3H8.253c-.595 0-1.153.16-1.633.438l1.133 1.133a1.75 1.75 0 0 1 .5-.072h7.498c.967 0 1.75.784 1.75 1.75Z" fill="#212121"/></svg> \ No newline at end of file diff --git a/resources/icons/ui/bookmark.svg b/resources/icons/ui/bookmark.svg new file mode 100644
index 00000000..59fec384 --- /dev/null +++ b/resources/icons/ui/bookmark.svg
@@ -0,0 +1 @@ +<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M6.19 21.854a.75.75 0 0 1-1.188-.61V6.25a3.25 3.25 0 0 1 3.25-3.25h7.499A3.25 3.25 0 0 1 19 6.249v14.996a.75.75 0 0 1-1.188.609l-5.811-4.181-5.812 4.18ZM17.5 6.249a1.75 1.75 0 0 0-1.75-1.75H8.253a1.75 1.75 0 0 0-1.75 1.75v13.532l5.062-3.64a.75.75 0 0 1 .876 0l5.06 3.64V6.25Z" fill="#212121"/></svg> \ No newline at end of file diff --git a/resources/icons/ui/dismiss_thread.svg b/resources/icons/ui/dismiss_thread.svg new file mode 100644
index 00000000..3285d31c --- /dev/null +++ b/resources/icons/ui/dismiss_thread.svg
@@ -0,0 +1,4 @@ +<svg width="96" height="96" version="1.1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> + <path d="M7 18h13a.75.75 0 0 1 .102 1.493L20 19.5H7a.75.75 0 0 1-.102-1.493L7 18Zm10-3a.75.75 0 0 1 .102 1.493L17 16.5H4a.75.75 0 0 1-.102-1.493L4 15h13Zm3-3a.75.75 0 0 1 .102 1.493L20 13.5H7a.75.75 0 0 1-.102-1.493L7 12h13ZM6 5a2.75 2.75 0 0 1 2.55 1.717.75.75 0 0 1-1.346.655l-.045-.091A1.25 1.25 0 1 0 6 9h11.5a.75.75 0 0 1 .102 1.493l-.102.007H6A2.75 2.75 0 0 1 6 5Zm14 1a.75.75 0 0 1 .102 1.493L20 7.5h-9a.75.75 0 0 1-.102-1.493L11 6h9Z" fill="#212121"/> + <path d="m3.2035 3.2035c0.29289-0.2929 0.76777-0.2929 1.0607 0 16.532 16.532 0 0 16.532 16.532 0.29295 0.29288 0.29295 0.76778 0 1.0606-0.29288 0.29288-0.7677 0.29288-1.0606 0-16.532-16.532-0.11015-0.11011-16.532-16.532-0.2929-0.29289-0.2929-0.76777 1e-7 -1.0607z" fill="#212121" stroke-width=".75"/> +</svg> diff --git a/resources/icons/ui/needle.svg b/resources/icons/ui/needle.svg new file mode 100644
index 00000000..a210c958 --- /dev/null +++ b/resources/icons/ui/needle.svg
@@ -0,0 +1,6 @@ +<svg width="32" height="32" version="1.1" viewBox="0 0 8.4667 8.4667" xmlns="http://www.w3.org/2000/svg"> + <g transform="rotate(17.066 .26297 6.1037)" stroke="#000" stroke-linejoin="round"> + <path d="m1.0583 0.26458c-0.79375 0-0.79375 0.79375-0.79375 0.79375l0.79375 6.35 0.79375-6.35s-9e-7 -0.79375-0.79375-0.79375zm0 0.26458a0.52917 0.52917 0 0 1 0.52917 0.52917 0.52917 0.52917 0 0 1-0.52917 0.52917 0.52917 0.52917 0 0 1-0.52917-0.52917 0.52917 0.52917 0 0 1 0.52917-0.52917z" fill-rule="evenodd" stroke-width=".026458"/> + <path d="m1.0583 1.0583s4.4594-1.5552 5.2917 0c0.60018 1.1215-2.242 1.9092-2.1167 3.175 0.049161 0.49648 1.0583 0.55943 1.0583 1.0583s-1.0583 1.0583-1.0583 1.0583" fill="none" stroke-linecap="round" stroke-width=".52917"/> + </g> +</svg> diff --git a/resources/icons/ui/new-thread.svg b/resources/icons/ui/new-thread.svg new file mode 100644
index 00000000..f79f2d99 --- /dev/null +++ b/resources/icons/ui/new-thread.svg
@@ -0,0 +1 @@ +<svg width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M17.5 12a5.5 5.5 0 1 1 0 11 5.5 5.5 0 0 1 0-11Zm0 2-.09.007a.5.5 0 0 0-.402.402L17 14.5V17L14.498 17l-.09.008a.5.5 0 0 0-.402.402l-.008.09.008.09a.5.5 0 0 0 .402.402l.09.008H17v2.503l.008.09a.5.5 0 0 0 .402.402l.09.008.09-.008a.5.5 0 0 0 .402-.402l.008-.09V18l2.504.001.09-.008a.5.5 0 0 0 .402-.402l.008-.09-.008-.09a.5.5 0 0 0-.403-.402l-.09-.008H18v-2.5l-.008-.09a.5.5 0 0 0-.402-.403L17.5 14Zm-6.481 4c.04.519.14 1.021.294 1.5H7a.75.75 0 0 1-.102-1.493L7 18h4.019Zm.479-3c-.198.475-.34.977-.422 1.5H4a.75.75 0 0 1-.102-1.493L4 15h7.498Zm2.537-3a6.534 6.534 0 0 0-1.659 1.5H7a.75.75 0 0 1-.102-1.493L7 12h7.035ZM6 5a2.75 2.75 0 0 1 2.55 1.717.75.75 0 0 1-1.346.655l-.045-.091A1.25 1.25 0 1 0 6 9h11.5a.75.75 0 0 1 .102 1.493l-.102.007H6A2.75 2.75 0 0 1 6 5Zm14 1a.75.75 0 0 1 .102 1.493L20 7.5h-9a.75.75 0 0 1-.102-1.493L11 6h9Z" fill="#212121"/></svg> \ No newline at end of file diff --git a/resources/icons/ui/thread.svg b/resources/icons/ui/thread.svg new file mode 100644
index 00000000..81eab2a0 --- /dev/null +++ b/resources/icons/ui/thread.svg
@@ -0,0 +1 @@ +<svg width="96" height="96" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M7 18h13a.75.75 0 0 1 .102 1.493L20 19.5H7a.75.75 0 0 1-.102-1.493L7 18Zm10-3a.75.75 0 0 1 .102 1.493L17 16.5H4a.75.75 0 0 1-.102-1.493L4 15h13Zm3-3a.75.75 0 0 1 .102 1.493L20 13.5H7a.75.75 0 0 1-.102-1.493L7 12h13ZM6 5a2.75 2.75 0 0 1 2.55 1.717.75.75 0 0 1-1.346.655l-.045-.091A1.25 1.25 0 1 0 6 9h11.5a.75.75 0 0 1 .102 1.493l-.102.007H6A2.75 2.75 0 0 1 6 5Zm14 1a.75.75 0 0 1 .102 1.493L20 7.5h-9a.75.75 0 0 1-.102-1.493L11 6h9Z" fill="#212121"/></svg> diff --git a/resources/qml/MessageInput.qml b/resources/qml/MessageInput.qml
index 6848f85c..6174a6c0 100644 --- a/resources/qml/MessageInput.qml +++ b/resources/qml/MessageInput.qml
@@ -378,6 +378,10 @@ Rectangle { messageInput.forceActiveFocus(); } + function onThreadChanged() { + messageInput.forceActiveFocus(); + } + ignoreUnknownSignals: true target: room } diff --git a/resources/qml/MessageView.qml b/resources/qml/MessageView.qml
index 2e1d9398..29e5ccf5 100644 --- a/resources/qml/MessageView.qml +++ b/resources/qml/MessageView.qml
@@ -116,9 +116,7 @@ Item { ToolTip.delay: Nheko.tooltipDelay ToolTip.text: qsTr("Edit") onClicked: { - if (row.model.isEditable) - chat.model.editAction(row.model.eventId); - + if (row.model.isEditable) chat.model.edit = row.model.eventId; } } @@ -140,6 +138,19 @@ Item { } ImageButton { + id: threadButton + + visible: chat.model ? chat.model.permissions.canSend(MtxEvent.TextMessage) : false + width: 16 + hoverEnabled: true + image: row.model.threadId ? ":/icons/icons/ui/thread.svg" : ":/icons/icons/ui/new-thread.svg" + ToolTip.visible: hovered + ToolTip.delay: Nheko.tooltipDelay + ToolTip.text: row.model.threadId ? qsTr("Reply in thread") : qsTr("New thread") + onClicked: chat.model.thread = (row.model.threadId || row.model.eventId) + } + + ImageButton { id: replyButton visible: chat.model ? chat.model.permissions.canSend(MtxEvent.TextMessage) : false @@ -149,7 +160,7 @@ Item { ToolTip.visible: hovered ToolTip.delay: Nheko.tooltipDelay ToolTip.text: qsTr("Reply") - onClicked: chat.model.replyAction(row.model.eventId) + onClicked: chat.model.reply = row.model.eventId } ImageButton { @@ -161,7 +172,7 @@ Item { ToolTip.visible: hovered ToolTip.delay: Nheko.tooltipDelay ToolTip.text: qsTr("Options") - onClicked: messageContextMenu.show(row.model.eventId, row.model.type, row.model.isSender, row.model.isEncrypted, row.model.isEditable, "", row.model.body, optionsButton) + onClicked: messageContextMenu.show(row.model.eventId, row.model.threadId, row.model.type, row.model.isSender, row.model.isEncrypted, row.model.isEditable, "", row.model.body, optionsButton) } } @@ -196,8 +207,10 @@ Item { room.input.declineUploads(); else if(chat.model.reply) chat.model.reply = undefined; - else + else if (chat.model.edit) chat.model.edit = undefined; + else + chat.model.thread = undefined TimelineManager.focusMessageInput(); } } @@ -383,6 +396,7 @@ Item { required property bool isStateEvent required property bool previousMessageIsStateEvent required property string replyTo + required property string threadId required property string userId required property string roomTopic required property string roomName @@ -448,6 +462,7 @@ Item { isEdited: wrapper.isEdited isStateEvent: wrapper.isStateEvent replyTo: wrapper.replyTo + threadId: wrapper.threadId userId: wrapper.userId userName: wrapper.userName roomTopic: wrapper.roomTopic @@ -554,6 +569,7 @@ Item { id: messageContextMenu property string eventId + property string threadId property string link property string text property int eventType @@ -561,8 +577,9 @@ Item { property bool isEditable property bool isSender - function show(eventId_, eventType_, isSender_, isEncrypted_, isEditable_, link_, text_, showAt_) { + function show(eventId_, threadId_, eventType_, isSender_, isEncrypted_, isEditable_, link_, text_, showAt_) { eventId = eventId_; + threadId = threadId_; eventType = eventType_; isEncrypted = isEncrypted_; isEditable = isEditable_; @@ -623,14 +640,21 @@ Item { Platform.MenuItem { visible: room ? room.permissions.canSend(MtxEvent.TextMessage) : false text: qsTr("Repl&y") - onTriggered: room.replyAction(messageContextMenu.eventId) + onTriggered: room.reply = (messageContextMenu.eventId) } Platform.MenuItem { visible: messageContextMenu.isEditable && (room ? room.permissions.canSend(MtxEvent.TextMessage) : false) enabled: visible text: qsTr("&Edit") - onTriggered: room.editAction(messageContextMenu.eventId) + onTriggered: room.edit = (messageContextMenu.eventId) + } + + Platform.MenuItem { + visible: (room ? room.permissions.canSend(MtxEvent.TextMessage) : false) + enabled: visible + text: qsTr("&Thread") + onTriggered: room.thread = (messageContextMenu.threadId || messageContextMenu.eventId) } Platform.MenuItem { @@ -641,7 +665,7 @@ Item { } Platform.MenuItem { - text: qsTr("Read receip&ts") + text: qsTr("&Read receipts") onTriggered: room.showReadReceipts(messageContextMenu.eventId) } diff --git a/resources/qml/ReplyPopup.qml b/resources/qml/ReplyPopup.qml
index 914f5d33..ec1de346 100644 --- a/resources/qml/ReplyPopup.qml +++ b/resources/qml/ReplyPopup.qml
@@ -13,9 +13,9 @@ Rectangle { id: replyPopup Layout.fillWidth: true - visible: room && (room.reply || room.edit) + visible: room && (room.reply || room.edit || room.thread) // Height of child, plus margins, plus border - implicitHeight: (room && room.reply ? replyPreview.height : closeEditButton.height) + Nheko.paddingSmall + implicitHeight: (room && room.reply ? replyPreview.height : Math.max(closeEditButton.height, closeThreadButton.height)) + Nheko.paddingSmall color: Nheko.colors.window z: 3 @@ -71,7 +71,7 @@ Rectangle { id: closeEditButton visible: room && room.edit - anchors.right: parent.right + anchors.right: closeThreadButton.left anchors.margins: 8 anchors.top: parent.top hoverEnabled: true @@ -83,4 +83,21 @@ Rectangle { onClicked: room.edit = undefined } + ImageButton { + id: closeThreadButton + + visible: room && room.thread + anchors.right: parent.right + anchors.margins: 8 + anchors.top: parent.top + hoverEnabled: true + buttonTextColor: TimelineManager.userColor(room.thread, Nheko.colors.base) + image: ":/icons/icons/ui/dismiss_thread.svg" + width: 22 + height: 22 + ToolTip.visible: closeThreadButton.hovered + ToolTip.text: qsTr("Cancel Thread") + onClicked: room.thread = undefined + } + } diff --git a/resources/qml/TimelineRow.qml b/resources/qml/TimelineRow.qml
index dc640099..173c3fb5 100644 --- a/resources/qml/TimelineRow.qml +++ b/resources/qml/TimelineRow.qml
@@ -33,6 +33,7 @@ AbstractButton { required property bool isEdited required property bool isStateEvent required property string replyTo + required property string threadId required property string userId required property string userName required property string roomTopic @@ -58,14 +59,14 @@ AbstractButton { // this looks better without margins TapHandler { acceptedButtons: Qt.RightButton - onSingleTapped: messageContextMenu.show(eventId, type, isSender, isEncrypted, isEditable, contentItem.child.hoveredLink, contentItem.child.copyText) + onSingleTapped: messageContextMenu.show(eventId, threadId, type, isSender, isEncrypted, isEditable, contentItem.child.hoveredLink, contentItem.child.copyText) gesturePolicy: TapHandler.ReleaseWithinBounds acceptedDevices: PointerDevice.Mouse | PointerDevice.Stylus | PointerDevice.TouchPad } } - onPressAndHold: messageContextMenu.show(eventId, type, isSender, isEncrypted, isEditable, contentItem.child.hoveredLink, contentItem.child.copyText) + onPressAndHold: messageContextMenu.show(eventId, threadId, type, isSender, isEncrypted, isEditable, contentItem.child.hoveredLink, contentItem.child.copyText) onDoubleClicked: chat.model.reply = eventId DragHandler { @@ -229,6 +230,20 @@ AbstractButton { anchors.verticalCenter: ts.verticalCenter } + ImageButton { + visible: threadId + Layout.alignment: Qt.AlignRight | Qt.AlignTop + height: parent.iconSize + width: parent.iconSize + image: ":/icons/icons/ui/thread.svg" + buttonTextColor: TimelineManager.userColor(threadId, Nheko.colors.base) + ToolTip.visible: hovered + ToolTip.delay: Nheko.tooltipDelay + ToolTip.text: qsTr("Part of a thread") + anchors.verticalCenter: ts.verticalCenter + onClicked: room.thread = threadId + } + Image { visible: isEdited || eventId == chat.model.edit Layout.alignment: Qt.AlignRight | Qt.AlignTop diff --git a/resources/res.qrc b/resources/res.qrc
index 7affe702..595dd5a7 100644 --- a/resources/res.qrc +++ b/resources/res.qrc
@@ -23,6 +23,9 @@ <file>icons/ui/microphone-mute.svg</file> <file>icons/ui/microphone-unmute.svg</file> <file>icons/ui/music.svg</file> + <file>icons/ui/thread.svg</file> + <file>icons/ui/new-thread.svg</file> + <file>icons/ui/dismiss_thread.svg</file> <file>icons/ui/options.svg</file> <file>icons/ui/pause-symbol.svg</file> <file>icons/ui/people.svg</file> diff --git a/src/Cache.cpp b/src/Cache.cpp
index 02b456db..b14f7414 100644 --- a/src/Cache.cpp +++ b/src/Cache.cpp
@@ -2229,7 +2229,7 @@ Cache::getTimelineMessages(lmdb::txn &txn, const std::string &room_id, uint64_t } std::optional<mtx::events::collections::TimelineEvent> -Cache::getEvent(const std::string &room_id, const std::string &event_id) +Cache::getEvent(const std::string &room_id, std::string_view event_id) { auto txn = ro_txn(env_); auto eventsDb = getEventsDb(txn, room_id); @@ -2555,30 +2555,6 @@ Cache::lastVisibleEvent(const std::string &room_id, std::string_view event_id) } } -std::optional<uint64_t> -Cache::getArrivalIndex(const std::string &room_id, std::string_view event_id) -{ - auto txn = ro_txn(env_); - - lmdb::dbi orderDb; - try { - orderDb = getEventToOrderDb(txn, room_id); - } catch (lmdb::runtime_error &e) { - nhlog::db()->error( - "Can't open db for room '{}', probably doesn't exist yet. ({})", room_id, e.what()); - return {}; - } - - std::string_view val; - - bool success = orderDb.get(txn, event_id, val); - if (!success) { - return {}; - } - - return lmdb::from_sv<uint64_t>(val); -} - std::optional<std::string> Cache::getTimelineEventId(const std::string &room_id, uint64_t index) { diff --git a/src/Cache_p.h b/src/Cache_p.h
index 839688f1..2d6df140 100644 --- a/src/Cache_p.h +++ b/src/Cache_p.h
@@ -195,7 +195,7 @@ public: bool forward = false); std::optional<mtx::events::collections::TimelineEvent> - getEvent(const std::string &room_id, const std::string &event_id); + getEvent(const std::string &room_id, std::string_view event_id); void storeEvent(const std::string &room_id, const std::string &event_id, const mtx::events::collections::TimelineEvent &event); @@ -216,7 +216,6 @@ public: std::optional<std::pair<uint64_t, std::string>> lastVisibleEvent(const std::string &room_id, std::string_view event_id); std::optional<std::string> getTimelineEventId(const std::string &room_id, uint64_t index); - std::optional<uint64_t> getArrivalIndex(const std::string &room_id, std::string_view event_id); std::string previousBatchToken(const std::string &room_id); uint64_t saveOldMessages(const std::string &room_id, const mtx::responses::Messages &res); diff --git a/src/timeline/EventStore.cpp b/src/timeline/EventStore.cpp
index 019cf78b..81f2eb9c 100644 --- a/src/timeline/EventStore.cpp +++ b/src/timeline/EventStore.cpp
@@ -547,8 +547,8 @@ EventStore::edits(const std::string &event_id) edits.end(), [this, c](const mtx::events::collections::TimelineEvents &a, const mtx::events::collections::TimelineEvents &b) { - return c->getArrivalIndex(this->room_id_, mtx::accessors::event_id(a)) < - c->getArrivalIndex(this->room_id_, mtx::accessors::event_id(b)); + return c->getEventIndex(this->room_id_, mtx::accessors::event_id(a)) < + c->getEventIndex(this->room_id_, mtx::accessors::event_id(b)); }); return edits; diff --git a/src/timeline/InputBar.cpp b/src/timeline/InputBar.cpp
index 4ac2708e..6854fce3 100644 --- a/src/timeline/InputBar.cpp +++ b/src/timeline/InputBar.cpp
@@ -23,9 +23,11 @@ #include <mtx/responses/media.hpp> #include "Cache.h" +#include "Cache_p.h" #include "ChatPage.h" #include "CombinedImagePackModel.h" #include "Config.h" +#include "EventAccessors.h" #include "Logging.h" #include "MatrixClient.h" #include "TimelineModel.h" @@ -37,6 +39,28 @@ static constexpr size_t INPUT_HISTORY_SIZE = 10; +std::string +threadFallbackEventId(const std::string &room_id, const std::string &thread_id) +{ + auto event_ids = cache::client()->relatedEvents(room_id, thread_id); + + std::map<uint64_t, std::string_view, std::greater<>> orderedEvents; + + for (const auto &e : event_ids) { + if (auto index = cache::client()->getTimelineIndex(room_id, e)) + orderedEvents.emplace(*index, e); + } + + for (const auto &[index, event_id] : orderedEvents) { + (void)index; + if (auto event = cache::client()->getEvent(room_id, event_id)) { + if (mtx::accessors::relations(event->data).thread() == thread_id) + return std::string(event_id); + } + } + return thread_id; +} + QUrl MediaUpload::thumbnailDataUrl() const { @@ -384,6 +408,31 @@ replaceMatrixToMarkdownLink(QString input) return input; } +mtx::common::Relations +InputBar::generateRelations() const +{ + mtx::common::Relations relations; + if (!room->thread().isEmpty()) { + relations.relations.push_back( + {mtx::common::RelationType::Thread, room->thread().toStdString()}); + if (room->reply().isEmpty()) + relations.relations.push_back( + {mtx::common::RelationType::InReplyTo, + threadFallbackEventId(room->roomId().toStdString(), room->thread().toStdString()), + std::nullopt, + true}); + } + if (!room->reply().isEmpty()) { + relations.relations.push_back( + {mtx::common::RelationType::InReplyTo, room->reply().toStdString()}); + } + if (!room->edit().isEmpty()) { + relations.relations.push_back( + {mtx::common::RelationType::Replace, room->edit().toStdString()}); + } + return relations; +} + void InputBar::message(const QString &msg, MarkdownOverride useMarkdown, bool rainbowify) { @@ -404,16 +453,8 @@ InputBar::message(const QString &msg, MarkdownOverride useMarkdown, bool rainbow text.format = "org.matrix.custom.html"; } - if (!room->edit().isEmpty()) { - if (!room->reply().isEmpty()) { - text.relations.relations.push_back( - {mtx::common::RelationType::InReplyTo, room->reply().toStdString()}); - } - - text.relations.relations.push_back( - {mtx::common::RelationType::Replace, room->edit().toStdString()}); - - } else if (!room->reply().isEmpty()) { + text.relations = generateRelations(); + if (!room->reply().isEmpty() && room->thread().isEmpty() && room->edit().isEmpty()) { auto related = room->relatedInfo(room->reply()); // Skip reply fallbacks to users who would cause a room ping with the fallback. @@ -448,9 +489,6 @@ InputBar::message(const QString &msg, MarkdownOverride useMarkdown, bool rainbow text.formatted_body = utils::getFormattedQuoteBody(related, msg.toHtmlEscaped()).toStdString(); } - - text.relations.relations.push_back( - {mtx::common::RelationType::InReplyTo, related.related_event}); } room->sendMessageEvent(text, mtx::events::EventType::RoomMessage); @@ -471,14 +509,7 @@ InputBar::emote(const QString &msg, bool rainbowify) emote.body = replaceMatrixToMarkdownLink(msg.trimmed()).toStdString(); } - if (!room->reply().isEmpty()) { - emote.relations.relations.push_back( - {mtx::common::RelationType::InReplyTo, room->reply().toStdString()}); - } - if (!room->edit().isEmpty()) { - emote.relations.relations.push_back( - {mtx::common::RelationType::Replace, room->edit().toStdString()}); - } + emote.relations = generateRelations(); room->sendMessageEvent(emote, mtx::events::EventType::RoomMessage); } @@ -498,14 +529,7 @@ InputBar::notice(const QString &msg, bool rainbowify) notice.body = replaceMatrixToMarkdownLink(msg.trimmed()).toStdString(); } - if (!room->reply().isEmpty()) { - notice.relations.relations.push_back( - {mtx::common::RelationType::InReplyTo, room->reply().toStdString()}); - } - if (!room->edit().isEmpty()) { - notice.relations.relations.push_back( - {mtx::common::RelationType::Replace, room->edit().toStdString()}); - } + notice.relations = generateRelations(); room->sendMessageEvent(notice, mtx::events::EventType::RoomMessage); } @@ -548,14 +572,7 @@ InputBar::image(const QString &filename, image.info.thumbnail_info.mimetype = "image/png"; } - if (!room->reply().isEmpty()) { - image.relations.relations.push_back( - {mtx::common::RelationType::InReplyTo, room->reply().toStdString()}); - } - if (!room->edit().isEmpty()) { - image.relations.relations.push_back( - {mtx::common::RelationType::Replace, room->edit().toStdString()}); - } + image.relations = generateRelations(); room->sendMessageEvent(image, mtx::events::EventType::RoomMessage); } @@ -577,14 +594,7 @@ InputBar::file(const QString &filename, else file.url = url.toStdString(); - if (!room->reply().isEmpty()) { - file.relations.relations.push_back( - {mtx::common::RelationType::InReplyTo, room->reply().toStdString()}); - } - if (!room->edit().isEmpty()) { - file.relations.relations.push_back( - {mtx::common::RelationType::Replace, room->edit().toStdString()}); - } + file.relations = generateRelations(); room->sendMessageEvent(file, mtx::events::EventType::RoomMessage); } @@ -611,14 +621,7 @@ InputBar::audio(const QString &filename, else audio.url = url.toStdString(); - if (!room->reply().isEmpty()) { - audio.relations.relations.push_back( - {mtx::common::RelationType::InReplyTo, room->reply().toStdString()}); - } - if (!room->edit().isEmpty()) { - audio.relations.relations.push_back( - {mtx::common::RelationType::Replace, room->edit().toStdString()}); - } + audio.relations = generateRelations(); room->sendMessageEvent(audio, mtx::events::EventType::RoomMessage); } @@ -667,14 +670,7 @@ InputBar::video(const QString &filename, video.info.thumbnail_info.mimetype = "image/png"; } - if (!room->reply().isEmpty()) { - video.relations.relations.push_back( - {mtx::common::RelationType::InReplyTo, room->reply().toStdString()}); - } - if (!room->edit().isEmpty()) { - video.relations.relations.push_back( - {mtx::common::RelationType::Replace, room->edit().toStdString()}); - } + video.relations = generateRelations(); room->sendMessageEvent(video, mtx::events::EventType::RoomMessage); } @@ -699,14 +695,7 @@ InputBar::sticker(CombinedImagePackModel *model, int row) sticker.info.thumbnail_info.h = sticker.info.h; sticker.info.thumbnail_info.w = sticker.info.w; - if (!room->reply().isEmpty()) { - sticker.relations.relations.push_back( - {mtx::common::RelationType::InReplyTo, room->reply().toStdString()}); - } - if (!room->edit().isEmpty()) { - sticker.relations.relations.push_back( - {mtx::common::RelationType::Replace, room->edit().toStdString()}); - } + sticker.relations = generateRelations(); room->sendMessageEvent(sticker, mtx::events::EventType::Sticker); } diff --git a/src/timeline/InputBar.h b/src/timeline/InputBar.h
index 7df556e8..d130fb8b 100644 --- a/src/timeline/InputBar.h +++ b/src/timeline/InputBar.h
@@ -266,6 +266,8 @@ private: const QSize &thumbnailDimensions, const QString &blurhash); + mtx::common::Relations generateRelations() const; + void startUploadFromPath(const QString &path); void startUploadFromMimeData(const QMimeData &source, const QString &format); void startUpload(std::unique_ptr<QIODevice> dev, const QString &orgPath, const QString &format); diff --git a/src/timeline/TimelineModel.cpp b/src/timeline/TimelineModel.cpp
index 34568385..01856a4d 100644 --- a/src/timeline/TimelineModel.cpp +++ b/src/timeline/TimelineModel.cpp
@@ -508,6 +508,7 @@ TimelineModel::roleNames() const {Trustlevel, "trustlevel"}, {EncryptionError, "encryptionError"}, {ReplyTo, "replyTo"}, + {ThreadId, "threadId"}, {Reactions, "reactions"}, {RoomId, "roomId"}, {RoomName, "roomName"}, @@ -725,8 +726,12 @@ TimelineModel::data(const mtx::events::collections::TimelineEvents &event, int r case EncryptionError: return events.decryptionError(event_id(event)); - case ReplyTo: - return QVariant(QString::fromStdString(relations(event).reply_to().value_or(""))); + case ReplyTo: { + const auto &rels = relations(event); + return QVariant(QString::fromStdString(rels.reply_to(!rels.thread()).value_or(""))); + } + case ThreadId: + return QVariant(QString::fromStdString(relations(event).thread().value_or(""))); case Reactions: { auto id = relations(event).replaces().value_or(event_id(event)); return QVariant::fromValue(events.reactions(id)); @@ -1206,12 +1211,6 @@ TimelineModel::openUserProfile(QString userid) } void -TimelineModel::replyAction(const QString &id) -{ - setReply(id); -} - -void TimelineModel::unpin(const QString &id) { auto pinned = @@ -1265,12 +1264,6 @@ TimelineModel::pin(const QString &id) }); } -void -TimelineModel::editAction(QString id) -{ - setEdit(id); -} - RelatedInfo TimelineModel::relatedInfo(const QString &id) { @@ -2673,6 +2666,26 @@ TimelineModel::formatMemberEvent(const QString &id) } void +TimelineModel::setThread(const QString &id) +{ + if (id.isEmpty()) { + resetThread(); + return; + } else if (id != thread_) { + thread_ = id; + emit threadChanged(thread_); + } +} +void +TimelineModel::resetThread() +{ + if (!thread_.isEmpty()) { + thread_.clear(); + emit threadChanged(thread_); + } +} + +void TimelineModel::setEdit(const QString &newEdit) { if (newEdit.isEmpty()) { @@ -2693,6 +2706,7 @@ TimelineModel::setEdit(const QString &newEdit) if (ev && mtx::accessors::sender(*ev) == http::client()->user_id().to_string()) { auto e = *ev; setReply(QString::fromStdString(mtx::accessors::relations(e).reply_to().value_or(""))); + setThread(QString::fromStdString(mtx::accessors::relations(e).thread().value_or(""))); auto msgType = mtx::accessors::msg_type(e); if (msgType == mtx::events::MessageType::Text || diff --git a/src/timeline/TimelineModel.h b/src/timeline/TimelineModel.h
index e16e79b0..43ea0b24 100644 --- a/src/timeline/TimelineModel.h +++ b/src/timeline/TimelineModel.h
@@ -180,6 +180,7 @@ class TimelineModel : public QAbstractListModel Q_PROPERTY(QString scrollTarget READ scrollTarget NOTIFY scrollTargetChanged) Q_PROPERTY(QString reply READ reply WRITE setReply NOTIFY replyChanged RESET resetReply) Q_PROPERTY(QString edit READ edit WRITE setEdit NOTIFY editChanged RESET resetEdit) + Q_PROPERTY(QString thread READ thread WRITE setThread NOTIFY threadChanged RESET resetThread) Q_PROPERTY( bool paginationInProgress READ paginationInProgress NOTIFY paginationInProgressChanged) Q_PROPERTY(QString roomId READ roomId CONSTANT) @@ -240,6 +241,7 @@ public: Trustlevel, EncryptionError, ReplyTo, + ThreadId, Reactions, RoomId, RoomName, @@ -281,8 +283,6 @@ public: Q_INVOKABLE void forwardMessage(const QString &eventId, QString roomId); Q_INVOKABLE void viewDecryptedRawMessage(const QString &id); Q_INVOKABLE void openUserProfile(QString userid); - Q_INVOKABLE void editAction(QString id); - Q_INVOKABLE void replyAction(const QString &id); Q_INVOKABLE void unpin(const QString &id); Q_INVOKABLE void pin(const QString &id); Q_INVOKABLE void showReadReceipts(QString id); @@ -383,6 +383,9 @@ public slots: QString edit() const { return edit_; } void setEdit(const QString &newEdit); void resetEdit(); + QString thread() const { return thread_; } + void setThread(const QString &newThread); + void resetThread(); void setDecryptDescription(bool decrypt) { decryptDescription = decrypt; } void clearTimeline() { events.clearTimeline(); } void resetState(); @@ -420,6 +423,7 @@ signals: void typingUsersChanged(std::vector<QString> users); void replyChanged(QString reply); void editChanged(QString reply); + void threadChanged(QString id); void openReadReceiptsDialog(ReadReceiptsProxy *rr); void showRawMessageDialog(QString rawMessage); void paginationInProgressChanged(const bool); @@ -466,7 +470,7 @@ private: mutable EventStore events; QString currentId, currentReadId; - QString reply_, edit_; + QString reply_, edit_, thread_; QString textBeforeEdit, replyBeforeEdit; std::vector<QString> typingUsers_; diff --git a/src/ui/MxcAnimatedImage.cpp b/src/ui/MxcAnimatedImage.cpp
index 8ecea7d9..90066fa0 100644 --- a/src/ui/MxcAnimatedImage.cpp +++ b/src/ui/MxcAnimatedImage.cpp
@@ -8,6 +8,7 @@ #include <QDir> #include <QFileInfo> #include <QMimeDatabase> +#include <QMovie> #include <QQuickWindow> #include <QSGImageNode> #include <QStandardPaths> @@ -34,8 +35,12 @@ MxcAnimatedImage::startDownload() QByteArray mimeType = QString::fromStdString(mtx::accessors::mimetype(*event)).toUtf8(); - static const auto formats = QMovie::supportedFormats(); - animatable_ = formats.contains(mimeType.split('/').back()); + static const auto movieFormats = QMovie::supportedFormats(); + QByteArray imageFormat; + const auto imageFormats = QImageReader::imageFormatsForMimeType(mimeType); + if (!imageFormats.isEmpty()) + imageFormat = imageFormats.front(); + animatable_ = movieFormats.contains(imageFormat); animatableChanged(); if (!animatable_) @@ -66,14 +71,14 @@ MxcAnimatedImage::startDownload() QPointer<MxcAnimatedImage> self = this; - auto processBuffer = [this, mimeType, encryptionInfo, self](QIODevice &device) { + auto processBuffer = [this, imageFormat, encryptionInfo, self](QIODevice &device) { if (!self) return; try { if (buffer.isOpen()) { - movie.stop(); - movie.setDevice(nullptr); + frameTimer.stop(); + movie->setDevice(nullptr); buffer.close(); } @@ -92,21 +97,22 @@ MxcAnimatedImage::startDownload() nhlog::net()->error("Failed to setup animated image buffer: {}", e.what()); } - QTimer::singleShot(0, this, [this, mimeType] { + QTimer::singleShot(0, this, [this, imageFormat] { nhlog::ui()->info( "Playing movie with size: {}, {}", buffer.bytesAvailable(), buffer.isOpen()); - movie.setFormat(mimeType); - movie.setDevice(&buffer); + movie->setFormat(imageFormat); + movie->setDevice(&buffer); if (height() != 0 && width() != 0) - movie.setScaledSize(this->size().toSize()); - if (buffer.bytesAvailable() < - 4LL * 1024 * 1024 * 1024) // cache images smaller than 4MB in RAM - movie.setCacheMode(QMovie::CacheAll); + movie->setScaledSize(this->size().toSize()); + + if (movie->supportsAnimation()) + frameTimer.setInterval(movie->nextImageDelay()); + if (play_) - movie.start(); + frameTimer.start(); else - movie.jumpToFrame(0); + movie->jumpToImage(0); emit loadedChanged(); update(); }); @@ -159,9 +165,9 @@ MxcAnimatedImage::geometryChanged(const QRectF &newGeometry, const QRectF &oldGe if (newGeometry.size() != oldGeometry.size()) { if (height() != 0 && width() != 0) { - QSizeF r = movie.scaledSize(); + QSizeF r = movie->scaledSize(); r.scale(newGeometry.size(), Qt::KeepAspectRatio); - movie.setScaledSize(r.toSize()); + movie->setScaledSize(r.toSize()); imageDirty = true; update(); } @@ -184,16 +190,15 @@ MxcAnimatedImage::updatePaintNode(QSGNode *oldNode, QQuickItem::UpdatePaintNodeD n->setFlags(QSGNode::OwnedByParent); } - auto img = movie.currentImage(); - n->setSourceRect(img.rect()); - if (!img.isNull()) - n->setTexture(window()->createTextureFromImage(std::move(img))); + n->setSourceRect(currentFrame.rect()); + if (!currentFrame.isNull()) + n->setTexture(window()->createTextureFromImage(currentFrame)); else { delete n; return nullptr; } - QSizeF r = img.size(); + QSizeF r = currentFrame.size(); r.scale(size(), Qt::KeepAspectRatio); n->setRect((width() - r.width()) / 2, (height() - r.height()) / 2, r.width(), r.height()); diff --git a/src/ui/MxcAnimatedImage.h b/src/ui/MxcAnimatedImage.h
index 8891e57e..2b067166 100644 --- a/src/ui/MxcAnimatedImage.h +++ b/src/ui/MxcAnimatedImage.h
@@ -6,7 +6,7 @@ #pragma once #include <QBuffer> -#include <QMovie> +#include <QImageReader> #include <QObject> #include <QQuickItem> @@ -24,10 +24,11 @@ class MxcAnimatedImage : public QQuickItem public: MxcAnimatedImage(QQuickItem *parent = nullptr) : QQuickItem(parent) + , movie(new QImageReader()) { connect(this, &MxcAnimatedImage::eventIdChanged, &MxcAnimatedImage::startDownload); connect(this, &MxcAnimatedImage::roomChanged, &MxcAnimatedImage::startDownload); - connect(&movie, &QMovie::frameChanged, this, &MxcAnimatedImage::newFrame); + connect(&frameTimer, &QTimer::timeout, this, &MxcAnimatedImage::newFrame); setFlag(QQuickItem::ItemHasContents); // setAcceptHoverEvents(true); } @@ -55,7 +56,10 @@ public: { if (play_ != newPlay) { play_ = newPlay; - movie.setPaused(!play_); + if (play_) + frameTimer.start(); + else + frameTimer.stop(); emit playChanged(); } } @@ -73,10 +77,16 @@ signals: private slots: void startDownload(); - void newFrame(int frame) + void newFrame() { - currentFrame = frame; - imageDirty = true; + if (movie->currentImageNumber() > 0 && !movie->canRead() && movie->imageCount() > 1) { + buffer.seek(0); + movie.reset(new QImageReader(movie->device(), movie->format())); + if (height() != 0 && width() != 0) + movie->setScaledSize(this->size().toSize()); + } + movie->read(&currentFrame); + imageDirty = true; update(); } @@ -86,8 +96,9 @@ private: QString filename_; bool animatable_ = false; QBuffer buffer; - QMovie movie; - int currentFrame = 0; - bool imageDirty = true; - bool play_ = true; + std::unique_ptr<QImageReader> movie; + bool imageDirty = true; + bool play_ = true; + QTimer frameTimer; + QImage currentFrame; };