Basic threading support

This commit is contained in:
Nicolas Werner 2022-09-30 03:27:05 +02:00
parent bffa0115d4
commit 88cbac1695
No known key found for this signature in database
GPG key ID: C8D75E610773F2D9
22 changed files with 240 additions and 163 deletions

View file

@ -581,7 +581,7 @@ if(USE_BUNDLED_MTXCLIENT)
FetchContent_Declare( FetchContent_Declare(
MatrixClient MatrixClient
GIT_REPOSITORY https://github.com/Nheko-Reborn/mtxclient.git 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_EXAMPLES OFF CACHE INTERNAL "")
set(BUILD_LIB_TESTS OFF CACHE INTERNAL "") set(BUILD_LIB_TESTS OFF CACHE INTERNAL "")

View file

@ -173,7 +173,7 @@ modules:
buildsystem: cmake-ninja buildsystem: cmake-ninja
name: mtxclient name: mtxclient
sources: sources:
- commit: b3740e26ad07a534b6092959c0da4d91b77a8528 - commit: 5ef4460c26acb02f24530db1c6058534b87014f6
#tag: v0.8.2 #tag: v0.8.2
type: git type: git
url: https://github.com/Nheko-Reborn/mtxclient.git url: https://github.com/Nheko-Reborn/mtxclient.git

View file

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

After

Width:  |  Height:  |  Size: 564 B

View file

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

After

Width:  |  Height:  |  Size: 403 B

View file

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

After

Width:  |  Height:  |  Size: 868 B

View file

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

After

Width:  |  Height:  |  Size: 765 B

View file

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

After

Width:  |  Height:  |  Size: 958 B

View file

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

After

Width:  |  Height:  |  Size: 561 B

View file

@ -378,6 +378,10 @@ Rectangle {
messageInput.forceActiveFocus(); messageInput.forceActiveFocus();
} }
function onThreadChanged() {
messageInput.forceActiveFocus();
}
ignoreUnknownSignals: true ignoreUnknownSignals: true
target: room target: room
} }

View file

@ -116,9 +116,7 @@ Item {
ToolTip.delay: Nheko.tooltipDelay ToolTip.delay: Nheko.tooltipDelay
ToolTip.text: qsTr("Edit") ToolTip.text: qsTr("Edit")
onClicked: { onClicked: {
if (row.model.isEditable) if (row.model.isEditable) chat.model.edit = row.model.eventId;
chat.model.editAction(row.model.eventId);
} }
} }
@ -139,6 +137,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 { ImageButton {
id: replyButton id: replyButton
@ -149,7 +160,7 @@ Item {
ToolTip.visible: hovered ToolTip.visible: hovered
ToolTip.delay: Nheko.tooltipDelay ToolTip.delay: Nheko.tooltipDelay
ToolTip.text: qsTr("Reply") ToolTip.text: qsTr("Reply")
onClicked: chat.model.replyAction(row.model.eventId) onClicked: chat.model.reply = row.model.eventId
} }
ImageButton { ImageButton {
@ -161,7 +172,7 @@ Item {
ToolTip.visible: hovered ToolTip.visible: hovered
ToolTip.delay: Nheko.tooltipDelay ToolTip.delay: Nheko.tooltipDelay
ToolTip.text: qsTr("Options") 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(); room.input.declineUploads();
else if(chat.model.reply) else if(chat.model.reply)
chat.model.reply = undefined; chat.model.reply = undefined;
else else if (chat.model.edit)
chat.model.edit = undefined; chat.model.edit = undefined;
else
chat.model.thread = undefined
TimelineManager.focusMessageInput(); TimelineManager.focusMessageInput();
} }
} }
@ -383,6 +396,7 @@ Item {
required property bool isStateEvent required property bool isStateEvent
required property bool previousMessageIsStateEvent required property bool previousMessageIsStateEvent
required property string replyTo required property string replyTo
required property string threadId
required property string userId required property string userId
required property string roomTopic required property string roomTopic
required property string roomName required property string roomName
@ -448,6 +462,7 @@ Item {
isEdited: wrapper.isEdited isEdited: wrapper.isEdited
isStateEvent: wrapper.isStateEvent isStateEvent: wrapper.isStateEvent
replyTo: wrapper.replyTo replyTo: wrapper.replyTo
threadId: wrapper.threadId
userId: wrapper.userId userId: wrapper.userId
userName: wrapper.userName userName: wrapper.userName
roomTopic: wrapper.roomTopic roomTopic: wrapper.roomTopic
@ -554,6 +569,7 @@ Item {
id: messageContextMenu id: messageContextMenu
property string eventId property string eventId
property string threadId
property string link property string link
property string text property string text
property int eventType property int eventType
@ -561,8 +577,9 @@ Item {
property bool isEditable property bool isEditable
property bool isSender 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_; eventId = eventId_;
threadId = threadId_;
eventType = eventType_; eventType = eventType_;
isEncrypted = isEncrypted_; isEncrypted = isEncrypted_;
isEditable = isEditable_; isEditable = isEditable_;
@ -623,14 +640,21 @@ Item {
Platform.MenuItem { Platform.MenuItem {
visible: room ? room.permissions.canSend(MtxEvent.TextMessage) : false visible: room ? room.permissions.canSend(MtxEvent.TextMessage) : false
text: qsTr("Repl&y") text: qsTr("Repl&y")
onTriggered: room.replyAction(messageContextMenu.eventId) onTriggered: room.reply = (messageContextMenu.eventId)
} }
Platform.MenuItem { Platform.MenuItem {
visible: messageContextMenu.isEditable && (room ? room.permissions.canSend(MtxEvent.TextMessage) : false) visible: messageContextMenu.isEditable && (room ? room.permissions.canSend(MtxEvent.TextMessage) : false)
enabled: visible enabled: visible
text: qsTr("&Edit") 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 { Platform.MenuItem {
@ -641,7 +665,7 @@ Item {
} }
Platform.MenuItem { Platform.MenuItem {
text: qsTr("Read receip&ts") text: qsTr("&Read receipts")
onTriggered: room.showReadReceipts(messageContextMenu.eventId) onTriggered: room.showReadReceipts(messageContextMenu.eventId)
} }

View file

@ -13,9 +13,9 @@ Rectangle {
id: replyPopup id: replyPopup
Layout.fillWidth: true Layout.fillWidth: true
visible: room && (room.reply || room.edit) visible: room && (room.reply || room.edit || room.thread)
// Height of child, plus margins, plus border // 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 color: Nheko.colors.window
z: 3 z: 3
@ -71,7 +71,7 @@ Rectangle {
id: closeEditButton id: closeEditButton
visible: room && room.edit visible: room && room.edit
anchors.right: parent.right anchors.right: closeThreadButton.left
anchors.margins: 8 anchors.margins: 8
anchors.top: parent.top anchors.top: parent.top
hoverEnabled: true hoverEnabled: true
@ -83,4 +83,21 @@ Rectangle {
onClicked: room.edit = undefined 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
}
} }

View file

@ -33,6 +33,7 @@ AbstractButton {
required property bool isEdited required property bool isEdited
required property bool isStateEvent required property bool isStateEvent
required property string replyTo required property string replyTo
required property string threadId
required property string userId required property string userId
required property string userName required property string userName
required property string roomTopic required property string roomTopic
@ -58,14 +59,14 @@ AbstractButton {
// this looks better without margins // this looks better without margins
TapHandler { TapHandler {
acceptedButtons: Qt.RightButton 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 gesturePolicy: TapHandler.ReleaseWithinBounds
acceptedDevices: PointerDevice.Mouse | PointerDevice.Stylus | PointerDevice.TouchPad 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 onDoubleClicked: chat.model.reply = eventId
DragHandler { DragHandler {
@ -229,6 +230,20 @@ AbstractButton {
anchors.verticalCenter: ts.verticalCenter 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 { Image {
visible: isEdited || eventId == chat.model.edit visible: isEdited || eventId == chat.model.edit
Layout.alignment: Qt.AlignRight | Qt.AlignTop Layout.alignment: Qt.AlignRight | Qt.AlignTop

View file

@ -23,6 +23,9 @@
<file>icons/ui/microphone-mute.svg</file> <file>icons/ui/microphone-mute.svg</file>
<file>icons/ui/microphone-unmute.svg</file> <file>icons/ui/microphone-unmute.svg</file>
<file>icons/ui/music.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/options.svg</file>
<file>icons/ui/pause-symbol.svg</file> <file>icons/ui/pause-symbol.svg</file>
<file>icons/ui/people.svg</file> <file>icons/ui/people.svg</file>

View file

@ -2229,7 +2229,7 @@ Cache::getTimelineMessages(lmdb::txn &txn, const std::string &room_id, uint64_t
} }
std::optional<mtx::events::collections::TimelineEvent> 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 txn = ro_txn(env_);
auto eventsDb = getEventsDb(txn, room_id); 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> std::optional<std::string>
Cache::getTimelineEventId(const std::string &room_id, uint64_t index) Cache::getTimelineEventId(const std::string &room_id, uint64_t index)
{ {

View file

@ -195,7 +195,7 @@ public:
bool forward = false); bool forward = false);
std::optional<mtx::events::collections::TimelineEvent> 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, void storeEvent(const std::string &room_id,
const std::string &event_id, const std::string &event_id,
const mtx::events::collections::TimelineEvent &event); const mtx::events::collections::TimelineEvent &event);
@ -216,7 +216,6 @@ public:
std::optional<std::pair<uint64_t, std::string>> std::optional<std::pair<uint64_t, std::string>>
lastVisibleEvent(const std::string &room_id, std::string_view event_id); 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<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); std::string previousBatchToken(const std::string &room_id);
uint64_t saveOldMessages(const std::string &room_id, const mtx::responses::Messages &res); uint64_t saveOldMessages(const std::string &room_id, const mtx::responses::Messages &res);

View file

@ -547,8 +547,8 @@ EventStore::edits(const std::string &event_id)
edits.end(), edits.end(),
[this, c](const mtx::events::collections::TimelineEvents &a, [this, c](const mtx::events::collections::TimelineEvents &a,
const mtx::events::collections::TimelineEvents &b) { const mtx::events::collections::TimelineEvents &b) {
return c->getArrivalIndex(this->room_id_, mtx::accessors::event_id(a)) < return c->getEventIndex(this->room_id_, mtx::accessors::event_id(a)) <
c->getArrivalIndex(this->room_id_, mtx::accessors::event_id(b)); c->getEventIndex(this->room_id_, mtx::accessors::event_id(b));
}); });
return edits; return edits;

View file

@ -23,9 +23,11 @@
#include <mtx/responses/media.hpp> #include <mtx/responses/media.hpp>
#include "Cache.h" #include "Cache.h"
#include "Cache_p.h"
#include "ChatPage.h" #include "ChatPage.h"
#include "CombinedImagePackModel.h" #include "CombinedImagePackModel.h"
#include "Config.h" #include "Config.h"
#include "EventAccessors.h"
#include "Logging.h" #include "Logging.h"
#include "MatrixClient.h" #include "MatrixClient.h"
#include "TimelineModel.h" #include "TimelineModel.h"
@ -37,6 +39,28 @@
static constexpr size_t INPUT_HISTORY_SIZE = 10; 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 QUrl
MediaUpload::thumbnailDataUrl() const MediaUpload::thumbnailDataUrl() const
{ {
@ -384,6 +408,31 @@ replaceMatrixToMarkdownLink(QString input)
return 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 void
InputBar::message(const QString &msg, MarkdownOverride useMarkdown, bool rainbowify) 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"; text.format = "org.matrix.custom.html";
} }
if (!room->edit().isEmpty()) { text.relations = generateRelations();
if (!room->reply().isEmpty()) { if (!room->reply().isEmpty() && room->thread().isEmpty() && room->edit().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()) {
auto related = room->relatedInfo(room->reply()); auto related = room->relatedInfo(room->reply());
// Skip reply fallbacks to users who would cause a room ping with the fallback. // 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 = text.formatted_body =
utils::getFormattedQuoteBody(related, msg.toHtmlEscaped()).toStdString(); 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); room->sendMessageEvent(text, mtx::events::EventType::RoomMessage);
@ -471,14 +509,7 @@ InputBar::emote(const QString &msg, bool rainbowify)
emote.body = replaceMatrixToMarkdownLink(msg.trimmed()).toStdString(); emote.body = replaceMatrixToMarkdownLink(msg.trimmed()).toStdString();
} }
if (!room->reply().isEmpty()) { emote.relations = generateRelations();
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()});
}
room->sendMessageEvent(emote, mtx::events::EventType::RoomMessage); room->sendMessageEvent(emote, mtx::events::EventType::RoomMessage);
} }
@ -498,14 +529,7 @@ InputBar::notice(const QString &msg, bool rainbowify)
notice.body = replaceMatrixToMarkdownLink(msg.trimmed()).toStdString(); notice.body = replaceMatrixToMarkdownLink(msg.trimmed()).toStdString();
} }
if (!room->reply().isEmpty()) { notice.relations = generateRelations();
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()});
}
room->sendMessageEvent(notice, mtx::events::EventType::RoomMessage); room->sendMessageEvent(notice, mtx::events::EventType::RoomMessage);
} }
@ -548,14 +572,7 @@ InputBar::image(const QString &filename,
image.info.thumbnail_info.mimetype = "image/png"; image.info.thumbnail_info.mimetype = "image/png";
} }
if (!room->reply().isEmpty()) { image.relations = generateRelations();
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()});
}
room->sendMessageEvent(image, mtx::events::EventType::RoomMessage); room->sendMessageEvent(image, mtx::events::EventType::RoomMessage);
} }
@ -577,14 +594,7 @@ InputBar::file(const QString &filename,
else else
file.url = url.toStdString(); file.url = url.toStdString();
if (!room->reply().isEmpty()) { file.relations = generateRelations();
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()});
}
room->sendMessageEvent(file, mtx::events::EventType::RoomMessage); room->sendMessageEvent(file, mtx::events::EventType::RoomMessage);
} }
@ -611,14 +621,7 @@ InputBar::audio(const QString &filename,
else else
audio.url = url.toStdString(); audio.url = url.toStdString();
if (!room->reply().isEmpty()) { audio.relations = generateRelations();
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()});
}
room->sendMessageEvent(audio, mtx::events::EventType::RoomMessage); room->sendMessageEvent(audio, mtx::events::EventType::RoomMessage);
} }
@ -667,14 +670,7 @@ InputBar::video(const QString &filename,
video.info.thumbnail_info.mimetype = "image/png"; video.info.thumbnail_info.mimetype = "image/png";
} }
if (!room->reply().isEmpty()) { video.relations = generateRelations();
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()});
}
room->sendMessageEvent(video, mtx::events::EventType::RoomMessage); 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.h = sticker.info.h;
sticker.info.thumbnail_info.w = sticker.info.w; sticker.info.thumbnail_info.w = sticker.info.w;
if (!room->reply().isEmpty()) { sticker.relations = generateRelations();
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()});
}
room->sendMessageEvent(sticker, mtx::events::EventType::Sticker); room->sendMessageEvent(sticker, mtx::events::EventType::Sticker);
} }

View file

@ -266,6 +266,8 @@ private:
const QSize &thumbnailDimensions, const QSize &thumbnailDimensions,
const QString &blurhash); const QString &blurhash);
mtx::common::Relations generateRelations() const;
void startUploadFromPath(const QString &path); void startUploadFromPath(const QString &path);
void startUploadFromMimeData(const QMimeData &source, const QString &format); void startUploadFromMimeData(const QMimeData &source, const QString &format);
void startUpload(std::unique_ptr<QIODevice> dev, const QString &orgPath, const QString &format); void startUpload(std::unique_ptr<QIODevice> dev, const QString &orgPath, const QString &format);

View file

@ -508,6 +508,7 @@ TimelineModel::roleNames() const
{Trustlevel, "trustlevel"}, {Trustlevel, "trustlevel"},
{EncryptionError, "encryptionError"}, {EncryptionError, "encryptionError"},
{ReplyTo, "replyTo"}, {ReplyTo, "replyTo"},
{ThreadId, "threadId"},
{Reactions, "reactions"}, {Reactions, "reactions"},
{RoomId, "roomId"}, {RoomId, "roomId"},
{RoomName, "roomName"}, {RoomName, "roomName"},
@ -725,8 +726,12 @@ TimelineModel::data(const mtx::events::collections::TimelineEvents &event, int r
case EncryptionError: case EncryptionError:
return events.decryptionError(event_id(event)); return events.decryptionError(event_id(event));
case ReplyTo: case ReplyTo: {
return QVariant(QString::fromStdString(relations(event).reply_to().value_or(""))); 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: { case Reactions: {
auto id = relations(event).replaces().value_or(event_id(event)); auto id = relations(event).replaces().value_or(event_id(event));
return QVariant::fromValue(events.reactions(id)); return QVariant::fromValue(events.reactions(id));
@ -1205,12 +1210,6 @@ TimelineModel::openUserProfile(QString userid)
emit manager_->openProfile(userProfile); emit manager_->openProfile(userProfile);
} }
void
TimelineModel::replyAction(const QString &id)
{
setReply(id);
}
void void
TimelineModel::unpin(const QString &id) TimelineModel::unpin(const QString &id)
{ {
@ -1265,12 +1264,6 @@ TimelineModel::pin(const QString &id)
}); });
} }
void
TimelineModel::editAction(QString id)
{
setEdit(id);
}
RelatedInfo RelatedInfo
TimelineModel::relatedInfo(const QString &id) TimelineModel::relatedInfo(const QString &id)
{ {
@ -2672,6 +2665,26 @@ TimelineModel::formatMemberEvent(const QString &id)
return rendered; return rendered;
} }
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 void
TimelineModel::setEdit(const QString &newEdit) TimelineModel::setEdit(const QString &newEdit)
{ {
@ -2693,6 +2706,7 @@ TimelineModel::setEdit(const QString &newEdit)
if (ev && mtx::accessors::sender(*ev) == http::client()->user_id().to_string()) { if (ev && mtx::accessors::sender(*ev) == http::client()->user_id().to_string()) {
auto e = *ev; auto e = *ev;
setReply(QString::fromStdString(mtx::accessors::relations(e).reply_to().value_or(""))); 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); auto msgType = mtx::accessors::msg_type(e);
if (msgType == mtx::events::MessageType::Text || if (msgType == mtx::events::MessageType::Text ||

View file

@ -180,6 +180,7 @@ class TimelineModel : public QAbstractListModel
Q_PROPERTY(QString scrollTarget READ scrollTarget NOTIFY scrollTargetChanged) Q_PROPERTY(QString scrollTarget READ scrollTarget NOTIFY scrollTargetChanged)
Q_PROPERTY(QString reply READ reply WRITE setReply NOTIFY replyChanged RESET resetReply) 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 edit READ edit WRITE setEdit NOTIFY editChanged RESET resetEdit)
Q_PROPERTY(QString thread READ thread WRITE setThread NOTIFY threadChanged RESET resetThread)
Q_PROPERTY( Q_PROPERTY(
bool paginationInProgress READ paginationInProgress NOTIFY paginationInProgressChanged) bool paginationInProgress READ paginationInProgress NOTIFY paginationInProgressChanged)
Q_PROPERTY(QString roomId READ roomId CONSTANT) Q_PROPERTY(QString roomId READ roomId CONSTANT)
@ -240,6 +241,7 @@ public:
Trustlevel, Trustlevel,
EncryptionError, EncryptionError,
ReplyTo, ReplyTo,
ThreadId,
Reactions, Reactions,
RoomId, RoomId,
RoomName, RoomName,
@ -281,8 +283,6 @@ public:
Q_INVOKABLE void forwardMessage(const QString &eventId, QString roomId); Q_INVOKABLE void forwardMessage(const QString &eventId, QString roomId);
Q_INVOKABLE void viewDecryptedRawMessage(const QString &id); Q_INVOKABLE void viewDecryptedRawMessage(const QString &id);
Q_INVOKABLE void openUserProfile(QString userid); 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 unpin(const QString &id);
Q_INVOKABLE void pin(const QString &id); Q_INVOKABLE void pin(const QString &id);
Q_INVOKABLE void showReadReceipts(QString id); Q_INVOKABLE void showReadReceipts(QString id);
@ -383,6 +383,9 @@ public slots:
QString edit() const { return edit_; } QString edit() const { return edit_; }
void setEdit(const QString &newEdit); void setEdit(const QString &newEdit);
void resetEdit(); void resetEdit();
QString thread() const { return thread_; }
void setThread(const QString &newThread);
void resetThread();
void setDecryptDescription(bool decrypt) { decryptDescription = decrypt; } void setDecryptDescription(bool decrypt) { decryptDescription = decrypt; }
void clearTimeline() { events.clearTimeline(); } void clearTimeline() { events.clearTimeline(); }
void resetState(); void resetState();
@ -420,6 +423,7 @@ signals:
void typingUsersChanged(std::vector<QString> users); void typingUsersChanged(std::vector<QString> users);
void replyChanged(QString reply); void replyChanged(QString reply);
void editChanged(QString reply); void editChanged(QString reply);
void threadChanged(QString id);
void openReadReceiptsDialog(ReadReceiptsProxy *rr); void openReadReceiptsDialog(ReadReceiptsProxy *rr);
void showRawMessageDialog(QString rawMessage); void showRawMessageDialog(QString rawMessage);
void paginationInProgressChanged(const bool); void paginationInProgressChanged(const bool);
@ -466,7 +470,7 @@ private:
mutable EventStore events; mutable EventStore events;
QString currentId, currentReadId; QString currentId, currentReadId;
QString reply_, edit_; QString reply_, edit_, thread_;
QString textBeforeEdit, replyBeforeEdit; QString textBeforeEdit, replyBeforeEdit;
std::vector<QString> typingUsers_; std::vector<QString> typingUsers_;

View file

@ -8,6 +8,7 @@
#include <QDir> #include <QDir>
#include <QFileInfo> #include <QFileInfo>
#include <QMimeDatabase> #include <QMimeDatabase>
#include <QMovie>
#include <QQuickWindow> #include <QQuickWindow>
#include <QSGImageNode> #include <QSGImageNode>
#include <QStandardPaths> #include <QStandardPaths>
@ -34,8 +35,12 @@ MxcAnimatedImage::startDownload()
QByteArray mimeType = QString::fromStdString(mtx::accessors::mimetype(*event)).toUtf8(); QByteArray mimeType = QString::fromStdString(mtx::accessors::mimetype(*event)).toUtf8();
static const auto formats = QMovie::supportedFormats(); static const auto movieFormats = QMovie::supportedFormats();
animatable_ = formats.contains(mimeType.split('/').back()); QByteArray imageFormat;
const auto imageFormats = QImageReader::imageFormatsForMimeType(mimeType);
if (!imageFormats.isEmpty())
imageFormat = imageFormats.front();
animatable_ = movieFormats.contains(imageFormat);
animatableChanged(); animatableChanged();
if (!animatable_) if (!animatable_)
@ -66,14 +71,14 @@ MxcAnimatedImage::startDownload()
QPointer<MxcAnimatedImage> self = this; QPointer<MxcAnimatedImage> self = this;
auto processBuffer = [this, mimeType, encryptionInfo, self](QIODevice &device) { auto processBuffer = [this, imageFormat, encryptionInfo, self](QIODevice &device) {
if (!self) if (!self)
return; return;
try { try {
if (buffer.isOpen()) { if (buffer.isOpen()) {
movie.stop(); frameTimer.stop();
movie.setDevice(nullptr); movie->setDevice(nullptr);
buffer.close(); buffer.close();
} }
@ -92,21 +97,22 @@ MxcAnimatedImage::startDownload()
nhlog::net()->error("Failed to setup animated image buffer: {}", e.what()); 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( nhlog::ui()->info(
"Playing movie with size: {}, {}", buffer.bytesAvailable(), buffer.isOpen()); "Playing movie with size: {}, {}", buffer.bytesAvailable(), buffer.isOpen());
movie.setFormat(mimeType); movie->setFormat(imageFormat);
movie.setDevice(&buffer); movie->setDevice(&buffer);
if (height() != 0 && width() != 0) if (height() != 0 && width() != 0)
movie.setScaledSize(this->size().toSize()); movie->setScaledSize(this->size().toSize());
if (buffer.bytesAvailable() <
4LL * 1024 * 1024 * 1024) // cache images smaller than 4MB in RAM if (movie->supportsAnimation())
movie.setCacheMode(QMovie::CacheAll); frameTimer.setInterval(movie->nextImageDelay());
if (play_) if (play_)
movie.start(); frameTimer.start();
else else
movie.jumpToFrame(0); movie->jumpToImage(0);
emit loadedChanged(); emit loadedChanged();
update(); update();
}); });
@ -159,9 +165,9 @@ MxcAnimatedImage::geometryChanged(const QRectF &newGeometry, const QRectF &oldGe
if (newGeometry.size() != oldGeometry.size()) { if (newGeometry.size() != oldGeometry.size()) {
if (height() != 0 && width() != 0) { if (height() != 0 && width() != 0) {
QSizeF r = movie.scaledSize(); QSizeF r = movie->scaledSize();
r.scale(newGeometry.size(), Qt::KeepAspectRatio); r.scale(newGeometry.size(), Qt::KeepAspectRatio);
movie.setScaledSize(r.toSize()); movie->setScaledSize(r.toSize());
imageDirty = true; imageDirty = true;
update(); update();
} }
@ -184,16 +190,15 @@ MxcAnimatedImage::updatePaintNode(QSGNode *oldNode, QQuickItem::UpdatePaintNodeD
n->setFlags(QSGNode::OwnedByParent); n->setFlags(QSGNode::OwnedByParent);
} }
auto img = movie.currentImage(); n->setSourceRect(currentFrame.rect());
n->setSourceRect(img.rect()); if (!currentFrame.isNull())
if (!img.isNull()) n->setTexture(window()->createTextureFromImage(currentFrame));
n->setTexture(window()->createTextureFromImage(std::move(img)));
else { else {
delete n; delete n;
return nullptr; return nullptr;
} }
QSizeF r = img.size(); QSizeF r = currentFrame.size();
r.scale(size(), Qt::KeepAspectRatio); r.scale(size(), Qt::KeepAspectRatio);
n->setRect((width() - r.width()) / 2, (height() - r.height()) / 2, r.width(), r.height()); n->setRect((width() - r.width()) / 2, (height() - r.height()) / 2, r.width(), r.height());

View file

@ -6,7 +6,7 @@
#pragma once #pragma once
#include <QBuffer> #include <QBuffer>
#include <QMovie> #include <QImageReader>
#include <QObject> #include <QObject>
#include <QQuickItem> #include <QQuickItem>
@ -24,10 +24,11 @@ class MxcAnimatedImage : public QQuickItem
public: public:
MxcAnimatedImage(QQuickItem *parent = nullptr) MxcAnimatedImage(QQuickItem *parent = nullptr)
: QQuickItem(parent) : QQuickItem(parent)
, movie(new QImageReader())
{ {
connect(this, &MxcAnimatedImage::eventIdChanged, &MxcAnimatedImage::startDownload); connect(this, &MxcAnimatedImage::eventIdChanged, &MxcAnimatedImage::startDownload);
connect(this, &MxcAnimatedImage::roomChanged, &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); setFlag(QQuickItem::ItemHasContents);
// setAcceptHoverEvents(true); // setAcceptHoverEvents(true);
} }
@ -55,7 +56,10 @@ public:
{ {
if (play_ != newPlay) { if (play_ != newPlay) {
play_ = newPlay; play_ = newPlay;
movie.setPaused(!play_); if (play_)
frameTimer.start();
else
frameTimer.stop();
emit playChanged(); emit playChanged();
} }
} }
@ -73,10 +77,16 @@ signals:
private slots: private slots:
void startDownload(); void startDownload();
void newFrame(int frame) void newFrame()
{ {
currentFrame = frame; if (movie->currentImageNumber() > 0 && !movie->canRead() && movie->imageCount() > 1) {
imageDirty = true; 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(); update();
} }
@ -86,8 +96,9 @@ private:
QString filename_; QString filename_;
bool animatable_ = false; bool animatable_ = false;
QBuffer buffer; QBuffer buffer;
QMovie movie; std::unique_ptr<QImageReader> movie;
int currentFrame = 0; bool imageDirty = true;
bool imageDirty = true; bool play_ = true;
bool play_ = true; QTimer frameTimer;
QImage currentFrame;
}; };