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/Avatar.qml b/resources/qml/Avatar.qml
index b1007469..ed065270 100644
--- a/resources/qml/Avatar.qml
+++ b/resources/qml/Avatar.qml
@@ -19,7 +19,7 @@ Rectangle {
verticalAlignment: Text.AlignVCenter
horizontalAlignment: Text.AlignHCenter
visible: img.status != Image.Ready
- color: colors.brightText
+ color: colors.text
}
Image {
@@ -43,5 +43,5 @@ Rectangle {
}
}
}
- color: colors.dark
+ color: colors.base
}
diff --git a/resources/qml/ImageButton.qml b/resources/qml/ImageButton.qml
index dc576e18..dd100503 100644
--- a/resources/qml/ImageButton.qml
+++ b/resources/qml/ImageButton.qml
@@ -1,17 +1,11 @@
import QtQuick 2.3
import QtQuick.Controls 2.3
-Button {
+AbstractButton {
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...
diff --git a/resources/qml/Reactions.qml b/resources/qml/Reactions.qml
new file mode 100644
index 00000000..cb15b723
--- /dev/null
+++ b/resources/qml/Reactions.qml
@@ -0,0 +1,76 @@
+import QtQuick 2.6
+import QtQuick.Controls 2.2
+
+Flow {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ spacing: 4
+
+ property alias reactions: repeater.model
+
+ Repeater {
+ id: repeater
+
+ AbstractButton {
+ id: reaction
+ text: model.key
+ hoverEnabled: true
+ implicitWidth: contentItem.childrenRect.width + contentItem.leftPadding*2
+ implicitHeight: contentItem.childrenRect.height
+
+ ToolTip.visible: hovered
+ ToolTip.text: model.users
+
+
+ contentItem: Row {
+ anchors.centerIn: parent
+ spacing: reactionText.implicitHeight/4
+ leftPadding: reactionText.implicitHeight / 2
+ rightPadding: reactionText.implicitHeight / 2
+
+ TextMetrics {
+ id: textMetrics
+ font.family: settings.emoji_font_family
+ elide: Text.ElideRight
+ elideWidth: 150
+ text: reaction.text
+ }
+
+ Text {
+ anchors.baseline: reactionCounter.baseline
+ id: reactionText
+ text: textMetrics.elidedText + (textMetrics.elidedText == textMetrics.text ? "" : "…")
+ font.family: settings.emoji_font_family
+ color: reaction.hovered ? colors.highlight : colors.text
+ maximumLineCount: 1
+ }
+
+ Rectangle {
+ id: divider
+ height: reactionCounter.implicitHeight * 1.4
+ width: 1
+ color: reaction.hovered ? colors.highlight : colors.text
+ }
+
+ Text {
+ anchors.verticalCenter: divider.verticalCenter
+ id: reactionCounter
+ text: model.counter
+ font: reaction.font
+ color: reaction.hovered ? colors.highlight : colors.text
+ }
+ }
+
+ background: Rectangle {
+ anchors.centerIn: parent
+ implicitWidth: reaction.implicitWidth
+ implicitHeight: reaction.implicitHeight
+ border.color: (reaction.hovered || model.selfReacted )? colors.highlight : colors.text
+ color: colors.base
+ border.width: 1
+ radius: reaction.height / 2.0
+ }
+ }
+ }
+}
+
diff --git a/resources/qml/TimelineRow.qml b/resources/qml/TimelineRow.qml
index 05c69112..22222ef3 100644
--- a/resources/qml/TimelineRow.qml
+++ b/resources/qml/TimelineRow.qml
@@ -52,6 +52,10 @@ MouseArea {
modelData: model
}
+
+ Reactions {
+ reactions: model.reactions
+ }
}
StatusIndicator {
diff --git a/resources/qml/TimelineView.qml b/resources/qml/TimelineView.qml
index 997f901e..eca646d1 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 {
@@ -133,6 +134,21 @@ Page {
sequence: StandardKey.MoveToNextPage
onActivated: { chat.contentY = chat.contentY + chat.height / 2; chat.returnToBounds(); }
}
+ Shortcut {
+ sequence: StandardKey.Cancel
+ onActivated: chat.model.reply = undefined
+ }
+ Shortcut {
+ sequence: "Alt+Up"
+ onActivated: chat.model.reply = chat.model.indexToId(chat.model.reply? chat.model.idToIndex(chat.model.reply) + 1 : 0)
+ }
+ Shortcut {
+ sequence: "Alt+Down"
+ onActivated: {
+ var idx = chat.model.reply? chat.model.idToIndex(chat.model.reply) - 1 : -1
+ chat.model.reply = idx >= 0 ? chat.model.indexToId(idx) : undefined
+ }
+ }
ScrollBar.vertical: ScrollBar {
id: scrollbar
@@ -210,7 +226,7 @@ Page {
anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined
visible: section.includes(" ")
text: chat.model.formatDateSeparator(modelData.timestamp)
- color: colors.brightText
+ color: colors.text
height: fontMetrics.height * 1.4
width: contentWidth * 1.2
@@ -218,7 +234,7 @@ Page {
horizontalAlignment: Text.AlignHCenter
background: Rectangle {
radius: parent.height / 2
- color: colors.dark
+ color: colors.base
}
}
Row {
diff --git a/resources/qml/delegates/Reply.qml b/resources/qml/delegates/Reply.qml
index 1b1be345..90013de9 100644
--- a/resources/qml/delegates/Reply.qml
+++ b/resources/qml/delegates/Reply.qml
@@ -15,7 +15,7 @@ Item {
MouseArea {
anchors.fill: parent
preventStealing: true
- onClicked: chat.positionViewAtIndex(chat.model.idToIndex(timelineManager.replyingEvent), ListView.Contain)
+ onClicked: chat.positionViewAtIndex(chat.model.idToIndex(modelData.id), ListView.Contain)
cursorShape: Qt.PointingHandCursor
}
diff --git a/resources/res.qrc b/resources/res.qrc
index c6daaa80..64a5b3cb 100644
--- a/resources/res.qrc
+++ b/resources/res.qrc
@@ -117,8 +117,9 @@
qml/MatrixText.qml
qml/StatusIndicator.qml
qml/EncryptionIndicator.qml
- qml/TimelineRow.qml
+ qml/Reactions.qml
qml/ScrollHelper.qml
+ qml/TimelineRow.qml
qml/delegates/MessageDelegate.qml
qml/delegates/TextMessage.qml
qml/delegates/NoticeMessage.qml
diff --git a/resources/styles/system.qss b/resources/styles/system.qss
index dd2a90ef..01951aff 100644
--- a/resources/styles/system.qss
+++ b/resources/styles/system.qss
@@ -98,15 +98,15 @@ UserMentionsWidget {
qproperty-highlightedTitleColor: palette(highlighted-text);
qproperty-highlightedSubtitleColor: palette(highlighted-text);
- qproperty-hoverTitleColor: palette(highlightedtext);
- qproperty-hoverSubtitleColor: palette(highlightedtext);
+ qproperty-hoverTitleColor: palette(dark);
+ qproperty-hoverSubtitleColor: palette(dark);
qproperty-btnColor: palette(dark);
qproperty-btnTextColor: palette(bright-text);
qproperty-timestampColor: palette(text);
qproperty-highlightedTimestampColor: palette(highlighted-text);
- qproperty-hoverTimestampColor: palette(highlighted-text);
+ qproperty-hoverTimestampColor: palette(dark);
qproperty-bubbleBgColor: palette(base);
qproperty-bubbleFgColor: palette(text);
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/UserSettingsPage.cpp b/src/UserSettingsPage.cpp
index 54bce52c..e19aa876 100644
--- a/src/UserSettingsPage.cpp
+++ b/src/UserSettingsPage.cpp
@@ -115,7 +115,7 @@ UserSettings::applyTheme()
/*mid*/ QColor(110, 110, 110),
/*text*/ QColor("#333"),
/*bright_text*/ QColor("#333"),
- /*base*/ QColor("white"),
+ /*base*/ QColor("#eee"),
/*window*/ QColor("white"));
lightActive.setColor(QPalette::Highlight, QColor("#38a3d8"));
lightActive.setColor(QPalette::ToolTipBase, lightActive.base().color());
diff --git a/src/timeline/ReactionsModel.cpp b/src/timeline/ReactionsModel.cpp
new file mode 100644
index 00000000..2e249819
--- /dev/null
+++ b/src/timeline/ReactionsModel.cpp
@@ -0,0 +1,98 @@
+#include "ReactionsModel.h"
+
+#include
+#include
+
+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;
+ bool first = true;
+ for (const auto &reaction : reactions[i].reactions) {
+ if (!first)
+ users += ", ";
+ else
+ first = false;
+ users += QString::fromStdString(
+ cache::displayName(room_id_, reaction.second.sender));
+ }
+ return users;
+ }
+ case SelfReacted:
+ for (const auto &reaction : reactions[i].reactions)
+ if (reaction.second.sender == http::client()->user_id().to_string())
+ return true;
+ return false;
+ default:
+ return {};
+ }
+}
+
+void
+ReactionsModel::addReaction(const std::string &room_id,
+ const mtx::events::RoomEvent &reaction)
+{
+ room_id_ = room_id;
+
+ int idx = 0;
+ for (auto &storedReactions : reactions) {
+ if (storedReactions.key == reaction.content.relates_to.key) {
+ storedReactions.reactions[reaction.event_id] = 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.event_id, 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) {
+ storedReactions.reactions.erase(reaction.event_id);
+
+ 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..5f61cd42
--- /dev/null
+++ b/src/timeline/ReactionsModel.h
@@ -0,0 +1,41 @@
+#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 std::string &room_id,
+ const mtx::events::RoomEvent &reaction);
+ void removeReaction(const mtx::events::RoomEvent &reaction);
+
+private:
+ struct KeyReaction
+ {
+ std::string key;
+ std::map> reactions;
+ };
+ std::string room_id_;
+ std::vector reactions;
+};
diff --git a/src/timeline/TimelineModel.cpp b/src/timeline/TimelineModel.cpp
index df2051e6..388a5842 100644
--- a/src/timeline/TimelineModel.cpp
+++ b/src/timeline/TimelineModel.cpp
@@ -42,6 +42,8 @@ struct RoomEventType
switch (e.type) {
case EventType::RoomKeyRequest:
return qml_mtx_events::EventType::KeyRequest;
+ case EventType::Reaction:
+ return qml_mtx_events::EventType::Reaction;
case EventType::RoomAliases:
return qml_mtx_events::EventType::Aliases;
case EventType::RoomAvatar:
@@ -223,6 +225,7 @@ TimelineModel::roleNames() const
{State, "state"},
{IsEncrypted, "isEncrypted"},
{ReplyTo, "replyTo"},
+ {Reactions, "reactions"},
{RoomId, "roomId"},
{RoomName, "roomName"},
{RoomTopic, "roomTopic"},
@@ -345,6 +348,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:
@@ -471,7 +479,6 @@ TimelineModel::fetchMore(const QModelIndex &)
mtx::errors::to_string(err->matrix_error.errcode),
err->matrix_error.error,
err->parse_error);
- emit oldMessagesRetrieved(std::move(res));
setPaginationInProgress(false);
return;
}
@@ -609,6 +616,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)
@@ -632,6 +651,18 @@ 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);
+ events.insert(id, e);
+ reactions[reactedTo].addReaction(room_id_.toStdString(), *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;
@@ -707,6 +738,11 @@ TimelineModel::addBackwardsEvents(const mtx::responses::Messages &msgs)
}
prev_batch_token_ = QString::fromStdString(msgs.end);
+
+ if (ids.empty() && !msgs.chunk.empty()) {
+ // no visible events fetched, prevent loading from stopping
+ fetchMore(QModelIndex());
+ }
}
QString
diff --git a/src/timeline/TimelineModel.h b/src/timeline/TimelineModel.h
index cc63eca2..a737aac7 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 &;
@@ -29,6 +30,8 @@ enum EventType
Unsupported,
/// m.room_key_request
KeyRequest,
+ /// m.reaction,
+ Reaction,
/// m.room.aliases
Aliases,
/// m.room.avatar
@@ -155,6 +158,7 @@ public:
State,
IsEncrypted,
ReplyTo,
+ Reactions,
RoomId,
RoomName,
RoomTopic,
@@ -271,6 +275,7 @@ private:
QSet read;
QList pending;
std::vector eventOrder;
+ std::map reactions;
QString room_id_;
QString prev_batch_token_;