diff --git a/CMakeLists.txt b/CMakeLists.txt index 210340af..10a49dce 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -249,6 +249,7 @@ set(SRC_FILES src/emoji/Provider.cpp # Timeline + src/timeline/ReactionsModel.cpp src/timeline/TimelineViewManager.cpp src/timeline/TimelineModel.cpp src/timeline/DelegateChooser.cpp @@ -335,7 +336,7 @@ if(USE_BUNDLED_MTXCLIENT) FetchContent_Declare( MatrixClient GIT_REPOSITORY https://github.com/Nheko-Reborn/mtxclient.git - GIT_TAG v0.3.0 + GIT_TAG 1893cd6171c40c250ca64d388c082789452340a8 ) FetchContent_MakeAvailable(MatrixClient) else() @@ -451,6 +452,7 @@ qt5_wrap_cpp(MOC_HEADERS src/emoji/PickButton.h # Timeline + src/timeline/ReactionsModel.h src/timeline/TimelineViewManager.h src/timeline/TimelineModel.h src/timeline/DelegateChooser.h diff --git a/io.github.NhekoReborn.Nheko.json b/io.github.NhekoReborn.Nheko.json index 00e9430f..fe3a4a25 100644 --- a/io.github.NhekoReborn.Nheko.json +++ b/io.github.NhekoReborn.Nheko.json @@ -146,9 +146,9 @@ "name": "mtxclient", "sources": [ { - "sha256": "0c2930b5861d93bab9a6515adca74ebaa78984119705d9b4372a9deb275dd30c", + "sha256": "a8c0239b7157fe8eadae8b06cd6c4e3531dcc61fc5a7f52dbb3c85106f70e3a5", "type": "archive", - "url": "https://github.com/Nheko-Reborn/mtxclient/archive/v0.3.0.tar.gz" + "url": "https://github.com/Nheko-Reborn/mtxclient/archive/1893cd6171c40c250ca64d388c082789452340a8.tar.gz" } ] }, diff --git a/resources/qml/Reactions.qml b/resources/qml/Reactions.qml index 86f0071b..c15f0b3d 100644 --- a/resources/qml/Reactions.qml +++ b/resources/qml/Reactions.qml @@ -5,18 +5,14 @@ Flow { anchors.left: parent.left anchors.right: parent.right spacing: 4 + + property alias reactions: repeater.model + Repeater { - model: ListModel { - id: nameModel - ListElement { key: "😊"; count: 5; reactedBySelf: true; users: "Nico, RedSky, AAA, BBB, CCC" } - ListElement { key: "🤠"; count: 6; reactedBySelf: false; users: "Nico, AAA, BBB, CCC" } - ListElement { key: "💘"; count: 1; reactedBySelf: true; users: "Nico" } - ListElement { key: "🙈"; count: 7; reactedBySelf: false; users: "Nico, RedSky, AAA, BBB, CCC, DDD" } - ListElement { key: "👻"; count: 6; reactedBySelf: false; users: "Nico, RedSky, BBB, CCC" } - } + id: repeater + Button { id: reaction - //border.width: 1 text: model.key hoverEnabled: true implicitWidth: contentItem.childrenRect.width + contentItem.padding*2 @@ -33,7 +29,7 @@ Flow { Text { id: reactionText text: reaction.text - font: reaction.font + font.family: settings.emoji_font_family opacity: enabled ? 1.0 : 0.3 color: reaction.hovered ? colors.highlight : colors.buttonText horizontalAlignment: Text.AlignHCenter @@ -48,7 +44,7 @@ Flow { } Text { - text: model.count + text: model.counter font: reaction.font opacity: enabled ? 1.0 : 0.3 color: reaction.hovered ? colors.highlight : colors.buttonText @@ -63,7 +59,7 @@ Flow { implicitWidth: reaction.implicitWidth implicitHeight: reaction.implicitHeight opacity: enabled ? 1 : 0.3 - border.color: (reaction.hovered || model.reactedBySelf )? colors.highlight : colors.buttonText + border.color: (reaction.hovered || model.selfReacted )? colors.highlight : colors.buttonText color: colors.dark border.width: 1 radius: reaction.height / 2.0 diff --git a/resources/qml/TimelineRow.qml b/resources/qml/TimelineRow.qml index f3262fbd..22222ef3 100644 --- a/resources/qml/TimelineRow.qml +++ b/resources/qml/TimelineRow.qml @@ -54,6 +54,7 @@ MouseArea { } Reactions { + reactions: model.reactions } } diff --git a/resources/qml/TimelineView.qml b/resources/qml/TimelineView.qml index 997f901e..28d282a1 100644 --- a/resources/qml/TimelineView.qml +++ b/resources/qml/TimelineView.qml @@ -25,6 +25,7 @@ Page { id: settings category: "user" property bool avatar_circles: true + property string emoji_font_family: "default" } Settings { diff --git a/src/Olm.cpp b/src/Olm.cpp index c8e4c13c..8ea39566 100644 --- a/src/Olm.cpp +++ b/src/Olm.cpp @@ -164,8 +164,8 @@ encrypt_group_message(const std::string &room_id, const std::string &device_id, using namespace mtx::events; // relations shouldn't be encrypted... - mtx::common::RelatesTo relation; - if (body["content"].count("m.relates_to") != 0) { + mtx::common::ReplyRelatesTo relation; + if (body["content"]["m.relates_to"].contains("m.in_reply_to")) { relation = body["content"]["m.relates_to"]; body["content"].erase("m.relates_to"); } diff --git a/src/timeline/ReactionsModel.cpp b/src/timeline/ReactionsModel.cpp new file mode 100644 index 00000000..fd061b14 --- /dev/null +++ b/src/timeline/ReactionsModel.cpp @@ -0,0 +1,98 @@ +#include "ReactionsModel.h" + +#include + +#include "Logging.h" + +QHash +ReactionsModel::roleNames() const +{ + return { + {Key, "key"}, + {Count, "counter"}, + {Users, "users"}, + {SelfReacted, "selfReacted"}, + }; +} + +int +ReactionsModel::rowCount(const QModelIndex &) const +{ + return static_cast(reactions.size()); +} + +QVariant +ReactionsModel::data(const QModelIndex &index, int role) const +{ + const int i = index.row(); + if (i < 0 || i >= static_cast(reactions.size())) + return {}; + + switch (role) { + case Key: + return QString::fromStdString(reactions[i].key); + case Count: + return static_cast(reactions[i].reactions.size()); + case Users: { + QString users; + for (size_t r = 0; r < reactions[i].reactions.size(); r++) { + if (r != 0) + users += ", "; + users += QString::fromStdString(reactions[i].reactions[r].sender); + } + return users; + } + case SelfReacted: + for (const auto &reaction : reactions[i].reactions) + if (reaction.sender == http::client()->user_id().to_string()) + return true; + return false; + default: + return {}; + } +} + +void +ReactionsModel::addReaction(const mtx::events::RoomEvent &reaction) +{ + int idx = 0; + for (auto &storedReactions : reactions) { + if (storedReactions.key == reaction.content.relates_to.key) { + storedReactions.reactions.push_back(reaction); + emit dataChanged(index(idx, 0), index(idx, 0)); + return; + } + idx++; + } + + beginInsertRows(QModelIndex(), idx, idx); + reactions.push_back(KeyReaction{reaction.content.relates_to.key, {reaction}}); + endInsertRows(); +} + +void +ReactionsModel::removeReaction(const mtx::events::RoomEvent &reaction) +{ + int idx = 0; + for (auto &storedReactions : reactions) { + if (storedReactions.key == reaction.content.relates_to.key) { + for (auto it = begin(storedReactions.reactions); + it != end(storedReactions.reactions); + ++it) { + if (it->event_id == reaction.event_id) { + storedReactions.reactions.erase(it); + break; + } + } + + if (storedReactions.reactions.size() == 0) { + beginRemoveRows(QModelIndex(), idx, idx); + reactions.erase(reactions.begin() + idx); + endRemoveRows(); + } else + emit dataChanged(index(idx, 0), index(idx, 0)); + return; + } + idx++; + } +} diff --git a/src/timeline/ReactionsModel.h b/src/timeline/ReactionsModel.h new file mode 100644 index 00000000..ba71f2b7 --- /dev/null +++ b/src/timeline/ReactionsModel.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include + +#include +#include + +#include + +class ReactionsModel : public QAbstractListModel +{ + Q_OBJECT +public: + explicit ReactionsModel(QObject *parent = nullptr) { Q_UNUSED(parent); } + enum Roles + { + Key, + Count, + Users, + SelfReacted, + }; + + QHash roleNames() const override; + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + +public slots: + void addReaction(const mtx::events::RoomEvent &reaction); + void removeReaction(const mtx::events::RoomEvent &reaction); + +private: + struct KeyReaction + { + std::string key; + std::vector> reactions; + }; + std::vector reactions; +}; diff --git a/src/timeline/TimelineModel.cpp b/src/timeline/TimelineModel.cpp index 340bae39..0555d2ba 100644 --- a/src/timeline/TimelineModel.cpp +++ b/src/timeline/TimelineModel.cpp @@ -223,6 +223,7 @@ TimelineModel::roleNames() const {State, "state"}, {IsEncrypted, "isEncrypted"}, {ReplyTo, "replyTo"}, + {Reactions, "reactions"}, {RoomId, "roomId"}, {RoomName, "roomName"}, {RoomTopic, "roomTopic"}, @@ -337,6 +338,11 @@ TimelineModel::data(const QString &id, int role) const } case ReplyTo: return QVariant(QString::fromStdString(in_reply_to_event(event))); + case Reactions: + if (reactions.count(id)) + return QVariant::fromValue((QObject *)&reactions.at(id)); + else + return {}; case RoomId: return QVariant(QString::fromStdString(room_id(event))); case RoomName: @@ -574,6 +580,18 @@ TimelineModel::internalAddEvents( QString redacts = QString::fromStdString(redaction->redacts); auto redacted = std::find(eventOrder.begin(), eventOrder.end(), redacts); + auto event = events.value(redacts); + if (auto reaction = + std::get_if>( + &event)) { + QString reactedTo = + QString::fromStdString(reaction->content.relates_to.event_id); + reactions[reactedTo].removeReaction(*reaction); + int idx = idToIndex(reactedTo); + if (idx >= 0) + emit dataChanged(index(idx, 0), index(idx, 0)); + } + if (redacted != eventOrder.end()) { auto redactedEvent = std::visit( [](const auto &ev) @@ -597,6 +615,17 @@ TimelineModel::internalAddEvents( continue; // don't insert redaction into timeline } + if (auto reaction = + std::get_if>(&e)) { + QString reactedTo = + QString::fromStdString(reaction->content.relates_to.event_id); + reactions[reactedTo].addReaction(*reaction); + int idx = idToIndex(reactedTo); + if (idx >= 0) + emit dataChanged(index(idx, 0), index(idx, 0)); + continue; // don't insert reaction into timeline + } + if (auto event = std::get_if>(&e)) { auto e_ = decryptEvent(*event).event; diff --git a/src/timeline/TimelineModel.h b/src/timeline/TimelineModel.h index cc63eca2..ecb64693 100644 --- a/src/timeline/TimelineModel.h +++ b/src/timeline/TimelineModel.h @@ -9,6 +9,7 @@ #include #include "CacheCryptoStructs.h" +#include "ReactionsModel.h" namespace mtx::http { using RequestErr = const std::optional &; @@ -155,6 +156,7 @@ public: State, IsEncrypted, ReplyTo, + Reactions, RoomId, RoomName, RoomTopic, @@ -271,6 +273,7 @@ private: QSet read; QList pending; std::vector eventOrder; + std::map reactions; QString room_id_; QString prev_batch_token_;