diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 87620174a..d67b4f1c4 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -54,6 +54,7 @@ add_library(neochat STATIC events/imagepackevent.cpp events/joinrulesevent.cpp events/stickerevent.cpp + models/reactionmodel.cpp ) ecm_qt_declare_logging_category(neochat diff --git a/src/main.cpp b/src/main.cpp index 358a83642..397c8a285 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -59,6 +59,7 @@ #include "models/messageeventmodel.h" #include "models/messagefiltermodel.h" #include "models/publicroomlistmodel.h" +#include "models/reactionmodel.h" #include "models/roomlistmodel.h" #include "models/searchmodel.h" #include "models/serverlistmodel.h" @@ -230,6 +231,7 @@ int main(int argc, char *argv[]) qmlRegisterType("org.kde.neochat", 1, 0, "WebShortcutModel"); qmlRegisterType("org.kde.neochat", 1, 0, "UserListModel"); qmlRegisterType("org.kde.neochat", 1, 0, "MessageEventModel"); + qmlRegisterType("org.kde.neochat", 1, 0, "ReactionModel"); qmlRegisterType("org.kde.neochat", 1, 0, "CollapseStateProxyModel"); qmlRegisterType("org.kde.neochat", 1, 0, "MessageFilterModel"); qmlRegisterType("org.kde.neochat", 1, 0, "UserFilterModel"); diff --git a/src/models/messageeventmodel.cpp b/src/models/messageeventmodel.cpp index 13caa3381..a9b54d9e2 100644 --- a/src/models/messageeventmodel.cpp +++ b/src/models/messageeventmodel.cpp @@ -27,6 +27,7 @@ #include #include +#include "models/reactionmodel.h" #include "neochatuser.h" #include "texthandler.h" @@ -61,6 +62,7 @@ QHash MessageEventModel::roleNames() const roles[ReadMarkersStringRole] = "readMarkersString"; roles[ShowReadMarkersRole] = "showReadMarkers"; roles[ReactionRole] = "reaction"; + roles[ShowReactionsRole] = "showReactions"; roles[SourceRole] = "source"; roles[MimeTypeRole] = "mimeType"; roles[FormattedBodyRole] = "formattedBody"; @@ -106,6 +108,8 @@ void MessageEventModel::setRoom(NeoChatRoom *room) if (m_currentRoom) { m_currentRoom->disconnect(this); m_linkPreviewers.clear(); + qDeleteAll(m_reactionModels); + m_reactionModels.clear(); } m_currentRoom = room; @@ -116,6 +120,7 @@ void MessageEventModel::setRoom(NeoChatRoom *room) for (auto event = m_currentRoom->messageEvents().begin(); event != m_currentRoom->messageEvents().end(); ++event) { if (auto e = &*event->viewAs()) { createLinkPreviewerForEvent(e); + createReactionModelForEvent(e); } } @@ -134,6 +139,7 @@ void MessageEventModel::setRoom(NeoChatRoom *room) const RoomMessageEvent *message = dynamic_cast(event.get()); if (message != nullptr) { createLinkPreviewerForEvent(message); + createReactionModelForEvent(message); if (NeoChatConfig::self()->showFancyEffects()) { QString planBody = message->plainBody(); @@ -173,6 +179,7 @@ void MessageEventModel::setRoom(NeoChatRoom *room) RoomMessageEvent *message = dynamic_cast(event.get()); if (message) { createLinkPreviewerForEvent(message); + createReactionModelForEvent(message); } } if (rowCount() > 0) { @@ -244,7 +251,11 @@ void MessageEventModel::setRoom(NeoChatRoom *room) if (eventId.isEmpty()) { // How did we get here? return; } - refreshEventRoles(eventId, {ReactionRole, Qt::DisplayRole}); + const auto eventIt = m_currentRoom->findInTimeline(eventId); + if (eventIt != m_currentRoom->historyEdge()) { + createReactionModelForEvent(static_cast(&**eventIt)); + } + refreshEventRoles(eventId, {ReactionRole, ShowReactionsRole, Qt::DisplayRole}); }); connect(m_currentRoom, &Room::changed, this, [this]() { for (auto it = m_currentRoom->messageEvents().rbegin(); it != m_currentRoom->messageEvents().rend(); ++it) { @@ -932,38 +943,17 @@ QVariant MessageEventModel::data(const QModelIndex &idx, int role) const } if (role == ReactionRole) { - const auto &annotations = m_currentRoom->relatedEvents(evt, EventRelation::Annotation()); - if (annotations.isEmpty()) { - return {}; - }; - QMap> reactions = {}; - for (const auto &a : annotations) { - if (a->isRedacted()) { // Just in case? - continue; - } - if (auto e = eventCast(a)) { - reactions[e->relation().key].append(static_cast(m_currentRoom->user(e->senderId()))); - } + if (m_reactionModels.contains(evt.id())) { + return QVariant::fromValue(m_reactionModels[evt.id()]); + } else { + return QVariantList(); } - - if (reactions.isEmpty()) { - return {}; - } - - QVariantList res = {}; - auto i = reactions.constBegin(); - while (i != reactions.constEnd()) { - QVariantList authors; - for (auto author : i.value()) { - authors.append(userInContext(author, m_currentRoom)); - } - bool hasLocalUser = i.value().contains(static_cast(m_currentRoom->localUser())); - res.append(QVariantMap{{"reaction", i.key()}, {"count", i.value().count()}, {"authors", authors}, {"hasLocalUser", hasLocalUser}}); - ++i; - } - - return res; } + + if (role == ShowReactionsRole) { + return m_reactionModels.contains(evt.id()); + } + if (role == AuthorIdRole) { return evt.senderId(); } @@ -1278,3 +1268,60 @@ void MessageEventModel::createLinkPreviewerForEvent(const Quotient::RoomMessageE } } } + +void MessageEventModel::createReactionModelForEvent(const Quotient::RoomMessageEvent *event) +{ + if (event == nullptr) { + return; + } + auto eventId = event->id(); + const auto &annotations = m_currentRoom->relatedEvents(eventId, EventRelation::Annotation()); + if (annotations.isEmpty()) { + if (m_reactionModels.contains(eventId)) { + delete m_reactionModels[eventId]; + m_reactionModels.remove(eventId); + } + return; + }; + + QMap> reactions = {}; + for (const auto &a : annotations) { + if (a->isRedacted()) { // Just in case? + continue; + } + if (const auto &e = eventCast(a)) { + reactions[e->relation().key].append(static_cast(m_currentRoom->user(e->senderId()))); + } + } + + if (reactions.isEmpty()) { + if (m_reactionModels.contains(eventId)) { + delete m_reactionModels[eventId]; + m_reactionModels.remove(eventId); + } + return; + } + + QList res; + auto i = reactions.constBegin(); + while (i != reactions.constEnd()) { + QVariantList authors; + for (const auto &author : i.value()) { + authors.append(userInContext(author, m_currentRoom)); + } + + res.append(ReactionModel::Reaction{i.key(), authors}); + ++i; + } + + if (m_reactionModels.contains(eventId)) { + m_reactionModels[eventId]->setReactions(res); + } else if (res.size() > 0) { + m_reactionModels[eventId] = new ReactionModel(this, res, static_cast(m_currentRoom->localUser())); + } else { + if (m_reactionModels.contains(eventId)) { + delete m_reactionModels[eventId]; + m_reactionModels.remove(eventId); + } + } +} diff --git a/src/models/messageeventmodel.h b/src/models/messageeventmodel.h index 20d249e19..12a982ed7 100644 --- a/src/models/messageeventmodel.h +++ b/src/models/messageeventmodel.h @@ -8,6 +8,8 @@ #include "linkpreviewer.h" #include "neochatroom.h" +class ReactionModel; + /** * @class MessageEventModel * @@ -90,7 +92,8 @@ public: ExcessReadMarkersRole, /**< The number of other users at the event after the first 5. */ ReadMarkersStringRole, /**< String with the display name and mxID of the users at the event. */ ShowReadMarkersRole, /**< Whether there are any other user read markers to be shown. */ - ReactionRole, /**< List of reactions to this event. */ + ReactionRole, /**< List model for this event. */ + ShowReactionsRole, /**< Whether there are any reactions to be shown. */ SourceRole, /**< The full message source JSON. */ // For debugging @@ -186,6 +189,7 @@ private: bool movingEvent = false; QMap m_linkPreviewers; + QMap m_reactionModels; [[nodiscard]] int timelineBaseIndex() const; [[nodiscard]] QDateTime makeMessageTimestamp(const Quotient::Room::rev_iter_t &baseIt) const; @@ -203,6 +207,7 @@ private: QVariantMap getMediaInfoForEvent(const Quotient::RoomEvent &event) const; QVariantMap getMediaInfoFromFileInfo(const Quotient::EventContent::FileInfo *fileInfo, const QString &eventId, bool isThumbnail = false) const; void createLinkPreviewerForEvent(const Quotient::RoomMessageEvent *event); + void createReactionModelForEvent(const Quotient::RoomMessageEvent *event); std::vector> m_extraEvents; // Hack to ensure that we don't call endInsertRows when we haven't called beginInsertRows diff --git a/src/models/reactionmodel.cpp b/src/models/reactionmodel.cpp new file mode 100644 index 000000000..86af00d0e --- /dev/null +++ b/src/models/reactionmodel.cpp @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: 2023 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#include "reactionmodel.h" + +#include + +#include + +#include "neochatuser.h" + +ReactionModel::ReactionModel(QObject *parent, QList reactions, NeoChatUser *localUser) + : QAbstractListModel(parent) + , m_localUser(localUser) +{ + setReactions(reactions); +} + +QVariant ReactionModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid()) { + return {}; + } + + if (index.row() >= rowCount()) { + qDebug() << "ReactionModel, something's wrong: index.row() >= rowCount()"; + return {}; + } + + const auto &reaction = m_reactions.at(index.row()); + + if (role == TextRole) { + if (reaction.authors.count() > 1) { + return reaction.reaction + QStringLiteral(" %1").arg(reaction.authors.count()); + } else { + return reaction.reaction; + } + } + + if (role == ReactionRole) { + return reaction.reaction; + } + + if (role == ToolTipRole) { + QString text; + + for (int i = 0; i < reaction.authors.count() && i < 3; i++) { + if (i != 0) { + if (i < reaction.authors.count() - 1) { + text += QStringLiteral(", "); + } else { + text += i18nc("Separate the usernames of users", " and "); + } + } + text += reaction.authors.at(i).toMap()["displayName"].toString(); + } + + if (reaction.authors.count() > 3) { + text += i18ncp("%1 is the number of other users", " and %1 other", " and %1 others", reaction.authors.count() - 3); + } + + text = i18ncp("%2 is the users who reacted and %3 the emoji that was given", + "%2 reacted with %3", + "%2 reacted with %3", + reaction.authors.count(), + text, + reaction.reaction); + return text; + } + + if (role == AuthorsRole) { + return reaction.authors; + } + + if (role == HasLocalUser) { + for (auto author : reaction.authors) { + if (author.toMap()["id"] == m_localUser->id()) { + return true; + } + } + return false; + } + + return {}; +} + +int ReactionModel::rowCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent) + return m_reactions.count(); +} + +void ReactionModel::setReactions(QList reactions) +{ + beginResetModel(); + m_reactions.clear(); + m_reactions = reactions; + endResetModel(); +} + +QHash ReactionModel::roleNames() const +{ + return { + {TextRole, "text"}, + {ReactionRole, "reaction"}, + {ToolTipRole, "toolTip"}, + {AuthorsRole, "authors"}, + {HasLocalUser, "hasLocalUser"}, + }; +} diff --git a/src/models/reactionmodel.h b/src/models/reactionmodel.h new file mode 100644 index 000000000..acc602dc3 --- /dev/null +++ b/src/models/reactionmodel.h @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: 2023 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#pragma once + +#include + +class NeoChatUser; + +/** + * @class ReactionModel + * + * This class defines the model for visualising a list of reactions to an event. + */ +class ReactionModel : public QAbstractListModel +{ + Q_OBJECT + +public: + /** + * @brief Definition of an reaction. + */ + struct Reaction { + QString reaction; /**< The reaction emoji. */ + QVariantList authors; /**< The list of authors who sent the given reaction. */ + }; + + /** + * @brief Defines the model roles. + */ + enum Roles { + TextRole = Qt::DisplayRole, /**< The text to show in the reaction. */ + ReactionRole, /**< The reaction emoji. */ + ToolTipRole, /**< The tool tip to show for the reaction. */ + AuthorsRole, /**< The list of authors who sent the given reaction. */ + HasLocalUser, /**< Whether the local user is in the list of authors. */ + }; + + ReactionModel(QObject *parent = nullptr, QList reactions = {}, NeoChatUser *localUser = nullptr); + + /** + * @brief Get the given role value at the given index. + * + * @sa QAbstractItemModel::data + */ + [[nodiscard]] QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + + /** + * @brief Number of rows in the model. + * + * @sa QAbstractItemModel::rowCount + */ + [[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override; + + /** + * @brief Returns a mapping from Role enum values to role names. + * + * @sa Roles, QAbstractItemModel::roleNames() + */ + [[nodiscard]] QHash roleNames() const override; + + /** + * @brief Set the reactions data in the model. + */ + void setReactions(QList reactions); + +private: + QList m_reactions; + + NeoChatUser *m_localUser; +}; +Q_DECLARE_METATYPE(ReactionModel *) diff --git a/src/qml/Component/Timeline/ReactionDelegate.qml b/src/qml/Component/Timeline/ReactionDelegate.qml index f0875913f..8d8d503bf 100644 --- a/src/qml/Component/Timeline/ReactionDelegate.qml +++ b/src/qml/Component/Timeline/ReactionDelegate.qml @@ -4,67 +4,64 @@ import QtQuick 2.15 import QtQuick.Controls 2.15 as QQC2 -import QtQuick.Layouts 1.15 import org.kde.kirigami 2.15 as Kirigami Flow { + id: root + + /** + * @brief The reaction model to get the reactions from. + */ + property alias model: reactionRepeater.model + + /** + * @brief The given reaction has been clicked. + * + * Thrown when one of the reaction buttons in the flow is clicked. + */ + signal reactionClicked(string reaction) + spacing: Kirigami.Units.smallSpacing Repeater { - model: reaction ?? null + id: reactionRepeater + model: root.model delegate: QQC2.AbstractButton { - width: Math.max(implicitWidth, height) + width: Math.max(reactionTextMetrics.advanceWidth + Kirigami.Units.smallSpacing * 4, height) contentItem: QQC2.Label { + id: reactionLabel horizontalAlignment: Text.AlignHCenter - text: modelData.reaction + " " + modelData.count + verticalAlignment: Text.AlignVCenter + text: model.text + + TextMetrics { + id: reactionTextMetrics + text: reactionLabel.text + } } padding: Kirigami.Units.smallSpacing background: Kirigami.ShadowedRectangle { - color: checked ? Kirigami.Theme.positiveBackgroundColor : Kirigami.Theme.backgroundColor + color: model.hasLocalUser ? Kirigami.Theme.positiveBackgroundColor : Kirigami.Theme.backgroundColor Kirigami.Theme.inherit: false Kirigami.Theme.colorSet: Kirigami.Theme.View radius: height / 2 shadow.size: Kirigami.Units.smallSpacing - shadow.color: !model.isHighlighted ? Qt.rgba(0.0, 0.0, 0.0, 0.10) : Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.10) + shadow.color: !model.hasLocalUser ? Qt.rgba(0.0, 0.0, 0.0, 0.10) : Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.10) border.color: Kirigami.ColorUtils.tintWithAlpha(color, Kirigami.Theme.textColor, 0.15) border.width: 1 } - checkable: true - - checked: modelData.hasLocalUser - - onToggled: currentRoom.toggleReaction(eventId, modelData.reaction) + onClicked: reactionClicked(model.reaction) hoverEnabled: true QQC2.ToolTip.visible: hovered - QQC2.ToolTip.text: { - var text = ""; - - for (var i = 0; i < modelData.authors.length && i < 3; i++) { - if (i !== 0) { - if (i < modelData.authors.length - 1) { - text += ", " - } else { - text += i18nc("Separate the usernames of users", " and ") - } - } - text += currentRoom.htmlSafeMemberName(modelData.authors[i].id) - } - if (modelData.authors.length > 3) { - text += i18ncp("%1 is the number of other users", " and %1 other", " and %1 others", modelData.authors.length - 3) - } - - text = i18ncp("%2 is the users who reacted and %3 the emoji that was given", "%2 reacted with %3", "%2 reacted with %3", modelData.authors.length, text, modelData.reaction) - - return text - } + QQC2.ToolTip.text: model.toolTip } } } diff --git a/src/qml/Component/Timeline/TimelineContainer.qml b/src/qml/Component/Timeline/TimelineContainer.qml index 288d2152c..3138bf3b6 100644 --- a/src/qml/Component/Timeline/TimelineContainer.qml +++ b/src/qml/Component/Timeline/TimelineContainer.qml @@ -334,7 +334,10 @@ ColumnLayout { Layout.leftMargin: showUserMessageOnRight ? 0 : bubble.x + bubble.anchors.leftMargin Layout.rightMargin: showUserMessageOnRight ? Kirigami.Units.largeSpacing : 0 - visible: delegateType !== MessageEventModel.State && delegateType !== MessageEventModel.Notice && reaction != undefined && reaction.length > 0 + visible: showReactions + model: reaction + + onReactionClicked: (reaction) => currentRoom.toggleReaction(eventId, reaction) } AvatarFlow { Layout.alignment: Qt.AlignRight