diff --git a/autotests/reactionmodeltest.cpp b/autotests/reactionmodeltest.cpp index 6b157b3cd..d0065a52e 100644 --- a/autotests/reactionmodeltest.cpp +++ b/autotests/reactionmodeltest.cpp @@ -20,11 +20,11 @@ class ReactionModelTest : public QObject private: Connection *connection = nullptr; TestUtils::TestRoom *room = nullptr; + MessageContentModel *parentModel; private Q_SLOTS: void initTestCase(); - void nullModel(); void basicReaction(); void newReaction(); }; @@ -33,20 +33,13 @@ void ReactionModelTest::initTestCase() { connection = Connection::makeMockConnection(u"@bob:kde.org"_s); room = new TestUtils::TestRoom(connection, u"#myroom:kde.org"_s, u"test-reactionmodel-sync.json"_s); -} - -void ReactionModelTest::nullModel() -{ - auto model = ReactionModel(nullptr, nullptr); - - QCOMPARE(model.rowCount(), 0); - QCOMPARE(model.data(model.index(0), ReactionModel::TextContentRole), QVariant()); + parentModel = new MessageContentModel(room, "123456"_L1); } void ReactionModelTest::basicReaction() { auto event = eventCast(room->messageEvents().at(0).get()); - auto model = ReactionModel(event, room); + auto model = ReactionModel(parentModel, event->id(), room); QCOMPARE(model.rowCount(), 1); QCOMPARE(model.data(model.index(0), ReactionModel::TextContentRole), u"👍"_s); @@ -58,7 +51,7 @@ void ReactionModelTest::basicReaction() void ReactionModelTest::newReaction() { auto event = eventCast(room->messageEvents().at(0).get()); - auto model = new ReactionModel(event, room); + auto model = new ReactionModel(parentModel, event->id(), room); QCOMPARE(model->rowCount(), 1); QCOMPARE(model->data(model->index(0), ReactionModel::ToolTipRole), u"Alice Margatroid reacted with 👍"_s); diff --git a/src/enums/messagecomponenttype.h b/src/enums/messagecomponenttype.h index 7f4d82203..138de5f66 100644 --- a/src/enums/messagecomponenttype.h +++ b/src/enums/messagecomponenttype.h @@ -50,6 +50,7 @@ public: LiveLocation, /**< The initial event of a shared live location (i.e., the place where this is supposed to be shown in the timeline). */ Encrypted, /**< An encrypted message that cannot be decrypted. */ Reply, /**< A component to show a replied-to message. */ + Reaction, /**< A component to show the reactions to this message. */ LinkPreview, /**< A preview of a URL in the message. */ LinkPreviewLoad, /**< A loading dialog for a link preview. */ ChatBar, /**< A text edit for editing a message. */ diff --git a/src/models/messagecontentmodel.cpp b/src/models/messagecontentmodel.cpp index 60eaaa75b..32c265a65 100644 --- a/src/models/messagecontentmodel.cpp +++ b/src/models/messagecontentmodel.cpp @@ -3,6 +3,7 @@ #include "messagecontentmodel.h" #include "eventhandler.h" +#include "messagecomponenttype.h" #include "neochatconfig.h" #include @@ -27,6 +28,7 @@ #include "chatbarcache.h" #include "filetype.h" #include "linkpreviewer.h" +#include "models/reactionmodel.h" #include "neochatconnection.h" #include "neochatroom.h" #include "texthandler.h" @@ -152,12 +154,18 @@ void MessageContentModel::initializeModel() updateReplyModel(); resetModel(); }); + connect(m_room, &Room::updatedEvent, this, [this](const QString &eventId) { + if (eventId == m_eventId) { + updateReactionModel(); + } + }); initializeEvent(); if (m_currentState == Available || m_currentState == Pending) { updateReplyModel(); } resetModel(); + updateReactionModel(); } void MessageContentModel::initializeEvent() @@ -340,6 +348,10 @@ QVariant MessageContentModel::data(const QModelIndex &index, int role) const if (role == ReplyContentModelRole) { return QVariant::fromValue(m_replyModel); } + if (role == ReactionModelRole) { + return QVariant::fromValue(m_reactionModel); + ; + } if (role == ThreadRootRole) { auto roomMessageEvent = eventCast(event.first); #if Quotient_VERSION_MINOR > 9 || (Quotient_VERSION_MINOR == 9 && Quotient_VERSION_PATCH > 1) @@ -400,6 +412,7 @@ QHash MessageContentModel::roleNamesStatic() roles[MessageContentModel::ReplyEventIdRole] = "replyEventId"; roles[MessageContentModel::ReplyAuthorRole] = "replyAuthor"; roles[MessageContentModel::ReplyContentModelRole] = "replyContentModel"; + roles[MessageContentModel::ReactionModelRole] = "reactionModel"; roles[MessageContentModel::ThreadRootRole] = "threadRoot"; roles[MessageContentModel::LinkPreviewerRole] = "linkPreviewer"; roles[MessageContentModel::ChatBarCacheRole] = "chatBarCache"; @@ -480,6 +493,10 @@ QList MessageContentModel::messageContentComponents(bool isEdi newComponents = addLinkPreviews(newComponents); } + if ((m_reactionModel && m_reactionModel->rowCount() > 0)) { + newComponents += MessageComponent{MessageComponentType::Reaction, QString(), {}}; + } + #if Quotient_VERSION_MINOR > 9 || (Quotient_VERSION_MINOR == 9 && Quotient_VERSION_PATCH > 1) if (roomMessageEvent && (roomMessageEvent->isThreaded() || m_room->threads().contains(roomMessageEvent->id())) && roomMessageEvent->id() == roomMessageEvent->threadRootEventId()) { @@ -731,4 +748,25 @@ void MessageContentModel::updateItineraryModel() } } +void MessageContentModel::updateReactionModel() +{ + if (m_reactionModel != nullptr && m_reactionModel->rowCount() > 0) { + return; + } + + if (m_reactionModel == nullptr) { + m_reactionModel = new ReactionModel(this, m_eventId, m_room); + connect(m_reactionModel, &ReactionModel::reactionsUpdated, this, &MessageContentModel::updateReactionModel); + } + + if (m_reactionModel->rowCount() <= 0) { + m_reactionModel->disconnect(this); + delete m_reactionModel; + m_reactionModel = nullptr; + return; + } + + resetContent(); +} + #include "moc_messagecontentmodel.cpp" diff --git a/src/models/messagecontentmodel.h b/src/models/messagecontentmodel.h index 6eb645126..84508edf9 100644 --- a/src/models/messagecontentmodel.h +++ b/src/models/messagecontentmodel.h @@ -7,11 +7,11 @@ #include #include -#include #include "enums/messagecomponenttype.h" #include "itinerarymodel.h" #include "messagecomponent.h" +#include "models/reactionmodel.h" #include "neochatroommember.h" /** @@ -57,6 +57,8 @@ public: ReplyAuthorRole, /**< The author of the event that was replied to. */ ReplyContentModelRole, /**< The MessageContentModel for the reply event. */ + ReactionModelRole, /**< Reaction model for this event. */ + ThreadRootRole, /**< The thread root event ID for the event. */ LinkPreviewerRole, /**< The link preview details. */ @@ -125,6 +127,7 @@ private: QPointer m_replyModel; void updateReplyModel(); + ReactionModel *m_reactionModel = nullptr; ItineraryModel *m_itineraryModel = nullptr; QList componentsForType(MessageComponentType::Type type); @@ -135,4 +138,6 @@ private: void updateItineraryModel(); bool m_emptyItinerary = false; + + void updateReactionModel(); }; diff --git a/src/models/messagemodel.cpp b/src/models/messagemodel.cpp index 223caaa80..afd968a4e 100644 --- a/src/models/messagemodel.cpp +++ b/src/models/messagemodel.cpp @@ -258,18 +258,6 @@ QVariant MessageModel::data(const QModelIndex &idx, int role) const return m_readMarkerModels.contains(event.value().get().id()); } - if (role == ReactionRole) { - if (m_reactionModels.contains(event.value().get().id())) { - return QVariant::fromValue(m_reactionModels[event.value().get().id()].data()); - } else { - return QVariantList(); - } - } - - if (role == ShowReactionsRole) { - return m_reactionModels.contains(event.value().get().id()); - } - if (role == VerifiedRole) { if (event.value().get().originalEvent()) { auto encrypted = dynamic_cast(event.value().get().originalEvent()); @@ -323,8 +311,6 @@ QHash MessageModel::roleNames() const roles[ShowSectionRole] = "showSection"; roles[ReadMarkersRole] = "readMarkers"; roles[ShowReadMarkersRole] = "showReadMarkers"; - roles[ReactionRole] = "reaction"; - roles[ShowReactionsRole] = "showReactions"; roles[VerifiedRole] = "verified"; roles[AuthorDisplayNameRole] = "authorDisplayName"; roles[IsRedactedRole] = "isRedacted"; @@ -454,31 +440,6 @@ void MessageModel::createEventObjects(const Quotient::RoomEvent *event, bool isP } } } - - if (const auto roomEvent = eventCast(event)) { - // ReactionModel handles updates to add and remove reactions, we only need to - // handle adding and removing whole models here. - if (m_reactionModels.contains(eventId)) { - // If a model already exists but now has no reactions remove it - if (m_reactionModels[eventId]->rowCount() <= 0) { - m_reactionModels.remove(eventId); - if (!resetting) { - refreshEventRoles(eventId, {ReactionRole, ShowReactionsRole}); - } - } - } else { - if (m_room->relatedEvents(*event, Quotient::EventRelation::AnnotationType).count() > 0) { - // If a model doesn't exist and there are reactions add it. - auto reactionModel = QSharedPointer(new ReactionModel(roomEvent, m_room)); - if (reactionModel->rowCount() > 0) { - m_reactionModels[eventId] = reactionModel; - if (!resetting) { - refreshEventRoles(eventId, {ReactionRole, ShowReactionsRole}); - } - } - } - } - } } void MessageModel::clearModel() @@ -504,7 +465,6 @@ void MessageModel::clearModel() void MessageModel::clearEventObjects() { - m_reactionModels.clear(); m_readMarkerModels.clear(); } diff --git a/src/models/messagemodel.h b/src/models/messagemodel.h index 88d6881ec..411fd9154 100644 --- a/src/models/messagemodel.h +++ b/src/models/messagemodel.h @@ -77,8 +77,6 @@ public: ReadMarkersRole, /**< The first 5 other users at the event for read marker tracking. */ ShowReadMarkersRole, /**< Whether there are any other user read markers to be shown. */ - ReactionRole, /**< List model for this event. */ - ShowReactionsRole, /**< Whether there are any reactions to be shown. */ VerifiedRole, /**< Whether an encrypted message is sent in a verified session. */ AuthorDisplayNameRole, /**< The displayname for the event's sender; for name change events, the old displayname. */ @@ -155,7 +153,6 @@ private: bool movingEvent = false; QMap> m_readMarkerModels; - QMap> m_reactionModels; void createEventObjects(const Quotient::RoomEvent *event, bool isPending = false); }; diff --git a/src/models/reactionmodel.cpp b/src/models/reactionmodel.cpp index a11033a69..7fee8393d 100644 --- a/src/models/reactionmodel.cpp +++ b/src/models/reactionmodel.cpp @@ -9,22 +9,27 @@ #include +#include "neochatroom.h" + using namespace Qt::StringLiterals; -ReactionModel::ReactionModel(const Quotient::RoomMessageEvent *event, NeoChatRoom *room) - : QAbstractListModel(nullptr) +ReactionModel::ReactionModel(MessageContentModel *parent, const QString &eventId, NeoChatRoom *room) + : QAbstractListModel(parent) , m_room(room) - , m_event(event) + , m_eventId(eventId) { - if (m_event != nullptr && m_room != nullptr) { - connect(m_room, &NeoChatRoom::updatedEvent, this, [this](const QString &eventId) { - if (m_event && m_event->id() == eventId) { - updateReactions(); - } - }); + Q_ASSERT(parent); + Q_ASSERT(parent != nullptr); + Q_ASSERT(!eventId.isEmpty()); + Q_ASSERT(room != nullptr); - updateReactions(); - } + connect(m_room, &NeoChatRoom::updatedEvent, this, [this](const QString &eventId) { + if (m_eventId == eventId) { + updateReactions(); + } + }); + + updateReactions(); } QVariant ReactionModel::data(const QModelIndex &index, int role) const @@ -99,12 +104,16 @@ int ReactionModel::rowCount(const QModelIndex &parent) const void ReactionModel::updateReactions() { + if (m_room == nullptr) { + return; + } + beginResetModel(); m_reactions.clear(); m_shortcodes.clear(); - const auto &annotations = m_room->relatedEvents(*m_event, Quotient::EventRelation::AnnotationType); + const auto &annotations = m_room->relatedEvents(m_eventId, Quotient::EventRelation::AnnotationType); if (annotations.isEmpty()) { endResetModel(); return; diff --git a/src/models/reactionmodel.h b/src/models/reactionmodel.h index df9cb57e1..f65a28b87 100644 --- a/src/models/reactionmodel.h +++ b/src/models/reactionmodel.h @@ -3,11 +3,20 @@ #pragma once -#include "neochatroom.h" #include +#include + #include #include +namespace Quotient +{ +class RoomMessageEvent; +} + +class MessageContentModel; +class NeoChatRoom; + /** * @class ReactionModel * @@ -38,7 +47,7 @@ public: HasLocalMember, /**< Whether the local member is in the list of authors. */ }; - explicit ReactionModel(const Quotient::RoomMessageEvent *event, NeoChatRoom *room); + explicit ReactionModel(MessageContentModel *parent, const QString &eventId, NeoChatRoom *room); /** * @brief Get the given role value at the given index. @@ -61,9 +70,15 @@ public: */ [[nodiscard]] QHash roleNames() const override; +Q_SIGNALS: + /** + * @brief The reactions in the model have been updated. + */ + void reactionsUpdated(); + private: QPointer m_room; - const Quotient::RoomMessageEvent *m_event; + QString m_eventId; QList m_reactions; QMap m_shortcodes; diff --git a/src/models/threadmodel.h b/src/models/threadmodel.h index 81997ff28..8ea460707 100644 --- a/src/models/threadmodel.h +++ b/src/models/threadmodel.h @@ -19,7 +19,6 @@ #include "messagecontentmodel.h" class NeoChatRoom; -class ReactionModel; /** * @class ThreadFetchModel @@ -172,8 +171,6 @@ private: ThreadFetchModel *m_threadFetchModel; ThreadChatBarModel *m_threadChatBarModel; - QMap> m_reactionModels; - QPointer m_currentJob = nullptr; std::optional m_nextBatch = QString(); bool m_addingPending = false; diff --git a/src/models/timelinemessagemodel.h b/src/models/timelinemessagemodel.h index c62532088..9fa87f593 100644 --- a/src/models/timelinemessagemodel.h +++ b/src/models/timelinemessagemodel.h @@ -32,42 +32,6 @@ class TimelineMessageModel : public MessageModel QML_ELEMENT public: - /** - * @brief Defines the model roles. - */ - enum EventRoles { - DelegateTypeRole = Qt::UserRole + 1, /**< The delegate type of the message. */ - EventIdRole, /**< The matrix event ID of the event. */ - TimeRole, /**< The timestamp for when the event was sent (as a QDateTime). */ - SectionRole, /**< The date of the event as a string. */ - AuthorRole, /**< The author of the event. */ - HighlightRole, /**< Whether the event should be highlighted. */ - SpecialMarksRole, /**< Whether the event is hidden or not. */ - ProgressInfoRole, /**< Progress info when downloading files. */ - GenericDisplayRole, /**< A generic string based upon the message type. */ - MediaInfoRole, /**< The media info for the event. */ - - ContentModelRole, /**< The MessageContentModel for the event. */ - - IsThreadedRole, /**< Whether the message is in a thread. */ - ThreadRootRole, /**< The Matrix ID of the thread root message, if any . */ - - ShowSectionRole, /**< Whether the section header should be shown. */ - - ReadMarkersRole, /**< The first 5 other users at the event for read marker tracking. */ - ShowReadMarkersRole, /**< Whether there are any other user read markers to be shown. */ - ReactionRole, /**< List model for this event. */ - ShowReactionsRole, /**< Whether there are any reactions to be shown. */ - - VerifiedRole, /**< Whether an encrypted message is sent in a verified session. */ - AuthorDisplayNameRole, /**< The displayname for the event's sender; for name change events, the old displayname. */ - IsRedactedRole, /**< Whether an event has been deleted. */ - IsPendingRole, /**< Whether an event is waiting to be accepted by the server. */ - IsEditableRole, /**< Whether the event can be edited by the user. */ - LastRole, // Keep this last - }; - Q_ENUM(EventRoles) - explicit TimelineMessageModel(QObject *parent = nullptr); /** diff --git a/src/timeline/BaseMessageComponentChooser.qml b/src/timeline/BaseMessageComponentChooser.qml index 195d4c8cc..e7de8b435 100644 --- a/src/timeline/BaseMessageComponentChooser.qml +++ b/src/timeline/BaseMessageComponentChooser.qml @@ -193,6 +193,14 @@ DelegateChooser { } } + DelegateChoice { + roleValue: MessageComponentType.Reaction + delegate: ReactionComponent { + room: root.room + maxContentWidth: root.maxContentWidth + } + } + DelegateChoice { roleValue: MessageComponentType.LinkPreview delegate: LinkPreviewComponent { diff --git a/src/timeline/CMakeLists.txt b/src/timeline/CMakeLists.txt index 1c1fa8077..a395174c9 100644 --- a/src/timeline/CMakeLists.txt +++ b/src/timeline/CMakeLists.txt @@ -17,7 +17,6 @@ ecm_add_qml_module(timeline GENERATE_PLUGIN_SOURCE TimelineEndDelegate.qml Bubble.qml AvatarFlow.qml - ReactionDelegate.qml SectionDelegate.qml BaseMessageComponentChooser.qml MessageComponentChooser.qml @@ -47,6 +46,7 @@ ecm_add_qml_module(timeline GENERATE_PLUGIN_SOURCE PdfPreviewComponent.qml PollComponent.qml QuoteComponent.qml + ReactionComponent.qml ReplyAuthorComponent.qml ReplyButtonComponent.qml ReplyComponent.qml diff --git a/src/timeline/MessageDelegate.qml b/src/timeline/MessageDelegate.qml index 3b2190d35..c573cd6c2 100644 --- a/src/timeline/MessageDelegate.qml +++ b/src/timeline/MessageDelegate.qml @@ -14,7 +14,7 @@ import org.kde.neochat /** * @brief The base delegate for all messages in the timeline. * - * This supports a message bubble plus sender avatar for each message as well as reactions + * This supports a message bubble plus sender avatar for each message * and read markers. A date section can be show for when the message is on a different * day to the previous one. * @@ -72,16 +72,6 @@ TimelineDelegate { */ required property bool showSection - /** - * @brief A model with the reactions to the message in. - */ - required property var reaction - - /** - * @brief Whether the reaction component should be shown. - */ - required property bool showReactions - /** * @brief A model with the first 5 other user read markers for this message. */ @@ -337,18 +327,6 @@ TimelineDelegate { onLongPressed: _private.showMessageMenu() } } - - ReactionDelegate { - Layout.maximumWidth: root.width - Kirigami.Units.largeSpacing * 2 - Layout.alignment: _private.showUserMessageOnRight ? Qt.AlignRight : Qt.AlignLeft - Layout.leftMargin: _private.showUserMessageOnRight ? 0 : bubble.x + bubble.anchors.leftMargin - Layout.rightMargin: _private.showUserMessageOnRight ? Kirigami.Units.largeSpacing : 0 - - visible: root.showReactions - model: root.reaction - - onReactionClicked: reaction => root.room.toggleReaction(root.eventId, reaction) - } AvatarFlow { Layout.alignment: Qt.AlignRight Layout.rightMargin: Kirigami.Units.largeSpacing diff --git a/src/timeline/ReactionDelegate.qml b/src/timeline/ReactionComponent.qml similarity index 51% rename from src/timeline/ReactionDelegate.qml rename to src/timeline/ReactionComponent.qml index 16901e231..97f8ebfd1 100644 --- a/src/timeline/ReactionDelegate.qml +++ b/src/timeline/ReactionComponent.qml @@ -4,6 +4,7 @@ import QtQuick import QtQuick.Controls as QQC2 +import QtQuick.Layouts import org.kde.kirigami as Kirigami @@ -13,22 +14,36 @@ Flow { id: root /** - * @brief The reaction model to get the reactions from. + * @brief The NeoChatRoom the delegate is being displayed in. */ - property alias model: reactionRepeater.model + required property NeoChatRoom room /** - * @brief The given reaction has been clicked. - * - * Thrown when one of the reaction buttons in the flow is clicked. + * @brief The matrix ID of the message event. */ - signal reactionClicked(string reaction) + required property string eventId + + /** + * @brief The reaction model to get the reactions from. + */ + required property ReactionModel reactionModel + + /** + * @brief The maximum width that the bubble's content can be. + */ + property real maxContentWidth: -1 + + Layout.fillWidth: true + Layout.fillHeight: true + Layout.maximumWidth: root.maxContentWidth spacing: Kirigami.Units.smallSpacing Repeater { id: reactionRepeater + model: root.reactionModel + delegate: QQC2.AbstractButton { id: reactionDelegate @@ -54,9 +69,9 @@ Flow { padding: Kirigami.Units.smallSpacing background: Kirigami.ShadowedRectangle { - color: reactionDelegate.hasLocalMember ? Kirigami.Theme.positiveBackgroundColor : Kirigami.Theme.backgroundColor Kirigami.Theme.inherit: false - Kirigami.Theme.colorSet: NeoChatConfig.compactLayout ? Kirigami.Theme.Window : Kirigami.Theme.View + Kirigami.Theme.colorSet: NeoChatConfig.compactLayout ? Kirigami.Theme.View : Kirigami.Theme.Window + color: reactionDelegate.hasLocalMember ? Kirigami.Theme.positiveBackgroundColor : Kirigami.Theme.backgroundColor radius: height / 2 shadow { size: Kirigami.Units.smallSpacing @@ -64,7 +79,7 @@ Flow { } } - onClicked: reactionClicked(reactionDelegate.reaction) + onClicked: root.room.toggleReaction(root.eventId, reactionDelegate.reaction) hoverEnabled: true @@ -73,4 +88,53 @@ Flow { QQC2.ToolTip.text: reactionDelegate.toolTip } } + + QQC2.AbstractButton { + id: reactButton + width: Math.round(Kirigami.Units.gridUnit * 1.5) + height: Math.round(Kirigami.Units.gridUnit * 1.5) + + text: i18nc("@button", "React") + + contentItem: Kirigami.Icon { + source: "list-add" + } + + padding: Kirigami.Units.smallSpacing + + background: Rectangle { + color: Kirigami.Theme.backgroundColor + radius: height / 2 + border { + width: reactButton.hovered ? 1 : 0 + color: Kirigami.ColorUtils.linearInterpolation(Kirigami.Theme.backgroundColor, Kirigami.Theme.textColor, Kirigami.Theme.frameContrast) + } + } + + onClicked: { + var dialog = emojiDialog.createObject(reactButton); + dialog.chosen.connect(emoji => { + root.room.toggleReaction(root.eventId, emoji); + if (!Kirigami.Settings.isMobile) { + root.focusChatBar(); + } + }); + dialog.open(); + } + + hoverEnabled: true + + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.text: reactButton.text + } + + Component { + id: emojiDialog + + EmojiDialog { + currentRoom: root.room + showQuickReaction: true + } + } }