From 0e782c4a93c23371e09f8cf32a4b53f87d491b4a Mon Sep 17 00:00:00 2001 From: Tobias Fella Date: Thu, 20 Oct 2022 01:22:49 +0200 Subject: [PATCH] Implement viewing and responding to polls --- src/CMakeLists.txt | 1 + src/controller.cpp | 16 +- src/main.cpp | 6 + src/messageeventmodel.cpp | 13 ++ src/messageeventmodel.h | 1 + src/neochatroom.cpp | 29 +++- src/neochatroom.h | 11 +- src/pollevent.cpp | 38 +++++ src/pollevent.h | 35 ++++ src/pollhandler.cpp | 165 +++++++++++++++++++ src/pollhandler.h | 57 +++++++ src/qml/Component/Timeline/EventDelegate.qml | 5 + src/qml/Component/Timeline/PollDelegate.qml | 52 ++++++ src/res.qrc | 1 + 14 files changed, 427 insertions(+), 3 deletions(-) create mode 100644 src/pollevent.cpp create mode 100644 src/pollevent.h create mode 100644 src/pollhandler.cpp create mode 100644 src/pollhandler.h create mode 100644 src/qml/Component/Timeline/PollDelegate.qml diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 7a02f9046..2ff51e32f 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -56,6 +56,7 @@ target_link_libraries(neochat-app PRIVATE if(Quotient_VERSION_MINOR GREATER 6) target_compile_definitions(neochat PUBLIC QUOTIENT_07) + target_sources(neochat PRIVATE pollevent.cpp pollhandler.cpp) else() target_sources(neochat PRIVATE neochataccountregistry.cpp) endif() diff --git a/src/controller.cpp b/src/controller.cpp index 9bce811a4..3368be4e0 100644 --- a/src/controller.cpp +++ b/src/controller.cpp @@ -149,6 +149,9 @@ void Controller::handleNotifications() { static bool initial = true; static QStringList oldNotifications; + if (!m_connection) { + return; + } auto job = m_connection->callApi(); connect(job, &BaseJob::success, this, [this, job]() { @@ -177,7 +180,18 @@ void Controller::handleNotifications() // The room might have been deleted (for example rejected invitation). auto sender = room->user(notification["event"].toObject()["sender"].toString()); - auto body = notification["event"].toObject()["content"].toObject()["body"].toString(); + QString body; + + if (notification["event"].toObject()["type"].toString() == "org.matrix.msc3381.poll.start") { + body = notification["event"] + .toObject()["content"] + .toObject()["org.matrix.msc3381.poll.start"] + .toObject()["question"] + .toObject()["body"] + .toString(); + } else { + body = notification["event"].toObject()["content"].toObject()["body"].toString(); + } if (notification["event"]["type"] == "m.room.encrypted") { #ifdef Quotient_E2EE_ENABLED diff --git a/src/main.cpp b/src/main.cpp index 1e8e5a35b..6e28395de 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -57,6 +57,9 @@ #include "neochatroom.h" #include "neochatuser.h" #include "notificationsmanager.h" +#ifdef QUOTIENT_07 +#include "pollhandler.h" +#endif #include "publicroomlistmodel.h" #include "roomlistmodel.h" #include "roommanager.h" @@ -210,6 +213,9 @@ int main(int argc, char *argv[]) qmlRegisterType("org.kde.neochat", 1, 0, "DevicesModel"); qmlRegisterType("org.kde.neochat", 1, 0, "LinkPreviewer"); qmlRegisterType("org.kde.neochat", 1, 0, "CompletionModel"); +#ifdef QUOTIENT_07 + qmlRegisterType("org.kde.neochat", 1, 0, "PollHandler"); +#endif qmlRegisterUncreatableType("org.kde.neochat", 1, 0, "RoomMessageEvent", "ENUM"); qmlRegisterUncreatableType("org.kde.neochat", 1, 0, "PushNotificationState", "ENUM"); qmlRegisterUncreatableType("org.kde.neochat", 1, 0, "NeoChatRoomType", "ENUM"); diff --git a/src/messageeventmodel.cpp b/src/messageeventmodel.cpp index fce6daeb2..4c8bf31aa 100644 --- a/src/messageeventmodel.cpp +++ b/src/messageeventmodel.cpp @@ -13,6 +13,9 @@ #include #include +#ifdef QUOTIENT_07 +#include "pollevent.h" +#endif #include "stickerevent.h" #include @@ -496,6 +499,15 @@ QVariant MessageEventModel::data(const QModelIndex &idx, int role) const if (is(evt)) { return DelegateType::Encrypted; } +#ifdef QUOTIENT_07 + if (is(evt)) { + if (evt.isRedacted()) { + return DelegateType::Message; + } + return DelegateType::Poll; + } +#endif + return DelegateType::Other; } @@ -532,6 +544,7 @@ QVariant MessageEventModel::data(const QModelIndex &idx, int role) const if (auto e = eventCast(&evt)) { return QVariant::fromValue(e->image().originalJson); } + return evt.contentJson(); } if (role == HighlightRole) { diff --git a/src/messageeventmodel.h b/src/messageeventmodel.h index 5212c082c..017ad171c 100644 --- a/src/messageeventmodel.h +++ b/src/messageeventmodel.h @@ -25,6 +25,7 @@ public: State, Encrypted, ReadMarker, + Poll, Other, }; Q_ENUM(DelegateType); diff --git a/src/neochatroom.cpp b/src/neochatroom.cpp index dbd92aa30..2df54cd39 100644 --- a/src/neochatroom.cpp +++ b/src/neochatroom.cpp @@ -37,6 +37,10 @@ #include "neochatconfig.h" #include "neochatuser.h" #include "notificationsmanager.h" +#ifdef QUOTIENT_07 +#include "pollevent.h" +#include "pollhandler.h" +#endif #include "stickerevent.h" #include "utils.h" @@ -182,7 +186,7 @@ void NeoChatRoom::sendTypingNotification(bool isTyping) connection()->callApi(BackgroundRequest, localUser()->id(), id(), isTyping, 10000); } -const RoomMessageEvent *NeoChatRoom::lastEvent(bool ignoreStateEvent) const +const RoomEvent *NeoChatRoom::lastEvent(bool ignoreStateEvent) const { for (auto timelineItem = messageEvents().rbegin(); timelineItem < messageEvents().rend(); timelineItem++) { const RoomEvent *event = timelineItem->get(); @@ -212,6 +216,11 @@ const RoomMessageEvent *NeoChatRoom::lastEvent(bool ignoreStateEvent) const if (auto lastEvent = eventCast(event)) { return lastEvent; } +#ifdef QUOTIENT_07 + if (auto lastEvent = eventCast(event)) { + return lastEvent; + } +#endif } return nullptr; } @@ -621,6 +630,11 @@ QString NeoChatRoom::eventToString(const RoomEvent &evt, Qt::TextFormat format, return e.stateKey().isEmpty() ? i18n("updated %1 state", e.matrixType()) : i18n("updated %1 state for %2", e.matrixType(), e.stateKey().toHtmlEscaped()); }, +#ifdef QUOTIENT_07 + [](const PollStartEvent &e) { + return e.question(); + }, +#endif i18n("Unknown event")); } @@ -1201,3 +1215,16 @@ bool NeoChatRoom::canEncryptRoom() const #endif return false; } + +#ifdef QUOTIENT_07 +PollHandler *NeoChatRoom::poll(const QString &eventId) +{ + if (!m_polls.contains(eventId)) { + auto handler = new PollHandler(this); + handler->setRoom(this); + handler->setPollStartEventId(eventId); + m_polls.insert(eventId, handler); + } + return m_polls[eventId]; +} +#endif diff --git a/src/neochatroom.h b/src/neochatroom.h index f54569916..5468f029d 100644 --- a/src/neochatroom.h +++ b/src/neochatroom.h @@ -5,11 +5,13 @@ #include +#include #include #include #include +class PollHandler; class NeoChatUser; class PushNotificationState : public QObject @@ -80,7 +82,7 @@ public: /// This function respect the showLeaveJoinEvent setting and discard /// other not interesting events. This function can return an empty pointer /// when the room is empty of RoomMessageEvent. - [[nodiscard]] const Quotient::RoomMessageEvent *lastEvent(bool ignoreStateEvent = false) const; + [[nodiscard]] const Quotient::RoomEvent *lastEvent(bool ignoreStateEvent = false) const; /// Convenient way to get the last event but in a string format. /// @@ -192,6 +194,10 @@ public: bool canEncryptRoom() const; +#ifdef QUOTIENT_07 + Q_INVOKABLE PollHandler *poll(const QString &eventId); +#endif + #ifndef QUOTIENT_07 Q_INVOKABLE QString htmlSafeMemberName(const QString &userId) const { @@ -223,6 +229,9 @@ private: QString m_chatBoxAttachmentPath; QVector m_mentions; QString m_savedText; +#ifdef QUOTIENT_07 + QCache m_polls; +#endif private Q_SLOTS: void countChanged(); diff --git a/src/pollevent.cpp b/src/pollevent.cpp new file mode 100644 index 000000000..901745721 --- /dev/null +++ b/src/pollevent.cpp @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2022 Tobias Fella +// SPDX-License-Identifier: LGPL-2.0-or-later + +#include "pollevent.h" + +using namespace Quotient; + +PollStartEvent::PollStartEvent(const QJsonObject &obj) + : RoomEvent(obj) +{ +} + +int PollStartEvent::maxSelections() const +{ + return contentJson()["org.matrix.msc3381.poll.start"]["max_selections"].toInt(); +} + +QString PollStartEvent::question() const +{ + return contentJson()["org.matrix.msc3381.poll.start"]["question"]["body"].toString(); +} + +PollResponseEvent::PollResponseEvent(const QJsonObject &obj) + : RoomEvent(obj) +{ +} + +PollEndEvent::PollEndEvent(const QJsonObject &obj) + : RoomEvent(obj) +{ +} + +PollResponseEvent::PollResponseEvent(const QString &pollStartEventId, QStringList responses) + : RoomEvent(basicJson(TypeId, + {{"org.matrix.msc3381.poll.response", QJsonObject{{"answers", QJsonArray::fromStringList(responses)}}}, + {"m.relates_to", QJsonObject{{"rel_type", "m.reference"}, {"event_id", pollStartEventId}}}})) +{ +} diff --git a/src/pollevent.h b/src/pollevent.h new file mode 100644 index 000000000..fb635a18a --- /dev/null +++ b/src/pollevent.h @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2022 Tobias Fella +// SPDX-License-Identifier: LGPL-2.0-or-later + +#pragma once + +#include +#include + +namespace Quotient +{ +class PollStartEvent : public RoomEvent +{ +public: + QUO_EVENT(PollStartEvent, "org.matrix.msc3381.poll.start"); + explicit PollStartEvent(const QJsonObject &obj); + + int maxSelections() const; + QString question() const; +}; + +class PollResponseEvent : public RoomEvent +{ +public: + QUO_EVENT(PollResponseEvent, "org.matrix.msc3381.poll.response"); + explicit PollResponseEvent(const QJsonObject &obj); + explicit PollResponseEvent(const QString &pollStartEventId, QStringList responses); +}; + +class PollEndEvent : public RoomEvent +{ +public: + QUO_EVENT(PollEndEvent, "org.matrix.msc3381.poll.end"); + explicit PollEndEvent(const QJsonObject &obj); +}; +} diff --git a/src/pollhandler.cpp b/src/pollhandler.cpp new file mode 100644 index 000000000..abe96e5d8 --- /dev/null +++ b/src/pollhandler.cpp @@ -0,0 +1,165 @@ +// SPDX-FileCopyrightText: 2022 Tobias Fella +// SPDX-License-Identifier: LGPL-2.0-or-later + +#include "pollhandler.h" +#include "neochatroom.h" +#include "pollevent.h" +#include +#include +#include +#include + +using namespace Quotient; + +PollHandler::PollHandler(QObject *parent) + : QObject(parent) +{ + connect(this, &PollHandler::roomChanged, this, &PollHandler::checkLoadRelations); + connect(this, &PollHandler::pollStartEventIdChanged, this, &PollHandler::checkLoadRelations); +} + +NeoChatRoom *PollHandler::room() const +{ + return m_room; +} + +void PollHandler::setRoom(NeoChatRoom *room) +{ + if (m_room == room) { + return; + } + if (m_room) { + disconnect(m_room, nullptr, this, nullptr); + } + connect(room, &NeoChatRoom::aboutToAddNewMessages, this, [this](Quotient::RoomEventsRange events) { + for (const auto &event : events) { + if (event->is()) { + auto pl = m_room->getCurrentState(); + auto userPl = pl->powerLevelForUser(event->senderId()); + if (event->senderId() == (*m_room->findInTimeline(m_pollStartEventId))->senderId() || userPl >= pl->redact()) { + m_hasEnded = true; + m_endedTimestamp = event->originTimestamp(); + Q_EMIT hasEndedChanged(); + } + } + if (event->is()) { + handleAnswer(event->contentJson(), event->senderId(), event->originTimestamp()); + } + } + }); + m_room = room; + Q_EMIT roomChanged(); +} + +QString PollHandler::pollStartEventId() const +{ + return m_pollStartEventId; +} + +void PollHandler::setPollStartEventId(const QString &eventId) +{ + if (eventId == m_pollStartEventId) { + return; + } + m_pollStartEventId = eventId; + Q_EMIT pollStartEventIdChanged(); +} + +void PollHandler::checkLoadRelations() +{ + if (!m_room || m_pollStartEventId.isEmpty()) { + return; + } + m_maxVotes = eventCast(&**m_room->findInTimeline(m_pollStartEventId))->maxSelections(); + auto job = m_room->connection()->callApi(m_room->id(), m_pollStartEventId); + connect(job, &BaseJob::success, this, [this, job]() { + for (const auto &event : job->chunk()) { + if (event->is()) { + auto pl = m_room->getCurrentState(); + auto userPl = pl->powerLevelForUser(event->senderId()); + if (event->senderId() == (*m_room->findInTimeline(m_pollStartEventId))->senderId() || userPl >= pl->redact()) { + m_hasEnded = true; + m_endedTimestamp = event->originTimestamp(); + Q_EMIT hasEndedChanged(); + } + } + if (event->is()) { + handleAnswer(event->contentJson(), event->senderId(), event->originTimestamp()); + } + } + }); +} + +void PollHandler::handleAnswer(const QJsonObject &content, const QString &sender, QDateTime timestamp) +{ + if (timestamp > m_answerTimestamps[sender] && (!m_hasEnded || timestamp < m_endedTimestamp)) { + m_answerTimestamps[sender] = timestamp; + m_answers[sender] = {}; + int i = 0; + for (const auto &answer : content["org.matrix.msc3381.poll.response"]["answers"].toArray()) { + auto array = m_answers[sender].toArray(); + array.insert(0, answer); + m_answers[sender] = array; + i++; + if (i == m_maxVotes) { + break; + } + } + for (const auto &key : m_answers.keys()) { + if (m_answers[key].toArray().isEmpty()) { + m_answers.remove(key); + } + } + } + Q_EMIT answersChanged(); +} + +QJsonObject PollHandler::answers() const +{ + return m_answers; +} + +QJsonObject PollHandler::counts() const +{ + QJsonObject counts; + for (const auto &answer : m_answers) { + for (const auto &id : answer.toArray()) { + counts[id.toString()] = counts[id.toString()].toInt() + 1; + } + } + return counts; +} + +void PollHandler::sendPollAnswer(const QString &eventId, const QString &answerId) +{ + Q_ASSERT(eventId.length() > 0); + Q_ASSERT(answerId.length() > 0); + QStringList ownAnswers; + for (const auto &answer : m_answers[m_room->localUser()->id()].toArray()) { + ownAnswers += answer.toString(); + } + if (ownAnswers.contains(answerId)) { + ownAnswers.erase(std::remove_if(ownAnswers.begin(), ownAnswers.end(), [answerId](const auto &it) { + return answerId == it; + })); + } else { + while (ownAnswers.size() >= m_maxVotes) { + ownAnswers.pop_front(); + } + ownAnswers.insert(0, answerId); + } + + auto response = new PollResponseEvent(eventId, ownAnswers); + handleAnswer(response->contentJson(), m_room->localUser()->id(), QDateTime::currentDateTime()); + m_room->postEvent(response); +} + +bool PollHandler::hasEnded() const +{ + return m_hasEnded; +} + +int PollHandler::answerCount() const +{ + return m_answers.size(); +} diff --git a/src/pollhandler.h b/src/pollhandler.h new file mode 100644 index 000000000..0c5e06072 --- /dev/null +++ b/src/pollhandler.h @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: 2022 Tobias Fella +// SPDX-License-Identifier: LGPL-2.0-or-later + +#pragma once + +#include +#include +#include + +class NeoChatRoom; + +class PollHandler : public QObject +{ + Q_OBJECT + + Q_PROPERTY(NeoChatRoom *room READ room WRITE setRoom NOTIFY roomChanged) + Q_PROPERTY(QString pollStartEventId READ pollStartEventId WRITE setPollStartEventId NOTIFY pollStartEventIdChanged) + Q_PROPERTY(QJsonObject answers READ answers NOTIFY answersChanged) + Q_PROPERTY(QJsonObject counts READ counts NOTIFY answersChanged) + Q_PROPERTY(bool hasEnded READ hasEnded NOTIFY hasEndedChanged) + Q_PROPERTY(int answerCount READ answerCount NOTIFY answersChanged) + +public: + PollHandler(QObject *parent = nullptr); + + NeoChatRoom *room() const; + void setRoom(NeoChatRoom *room); + + QString pollStartEventId() const; + void setPollStartEventId(const QString &eventId); + + bool hasEnded() const; + int answerCount() const; + + void checkLoadRelations(); + + QJsonObject answers() const; + QJsonObject counts() const; + Q_INVOKABLE void sendPollAnswer(const QString &eventId, const QString &answerId); + +Q_SIGNALS: + void roomChanged(); + void pollStartEventIdChanged(); + void answersChanged(); + void hasEndedChanged(); + +private: + NeoChatRoom *m_room = nullptr; + QString m_pollStartEventId; + + void handleAnswer(const QJsonObject &object, const QString &sender, QDateTime timestamp); + QMap m_answerTimestamps; + QJsonObject m_answers; + int m_maxVotes = 1; + bool m_hasEnded = false; + QDateTime m_endedTimestamp; +}; diff --git a/src/qml/Component/Timeline/EventDelegate.qml b/src/qml/Component/Timeline/EventDelegate.qml index 6410cccf9..6019a1278 100644 --- a/src/qml/Component/Timeline/EventDelegate.qml +++ b/src/qml/Component/Timeline/EventDelegate.qml @@ -70,6 +70,11 @@ DelegateChooser { delegate: ReadMarkerDelegate {} } + DelegateChoice { + roleValue: MessageEventModel.Poll + delegate: PollDelegate {} + } + DelegateChoice { roleValue: MessageEventModel.Other delegate: Item {} diff --git a/src/qml/Component/Timeline/PollDelegate.qml b/src/qml/Component/Timeline/PollDelegate.qml new file mode 100644 index 000000000..dc8fe8c02 --- /dev/null +++ b/src/qml/Component/Timeline/PollDelegate.qml @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2022 Tobias Fella +// SPDX-License-Identifier: GPL-2.0-or-later + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import Qt.labs.platform 1.1 + +import org.kde.kirigami 2.15 as Kirigami + +import org.kde.neochat 1.0 + +TimelineContainer { + id: pollDelegate + + readonly property var data: model + property PollHandler pollHandler: currentRoom.poll(model.eventId) + + innerObject: ColumnLayout { + Label { + id: questionLabel + text: pollDelegate.data.content["org.matrix.msc3381.poll.start"]["question"]["body"] + } + Repeater { + model: pollDelegate.data.content["org.matrix.msc3381.poll.start"]["answers"] + delegate: RowLayout { + width: pollDelegate.innerObject.width + CheckBox { + checked: pollDelegate.pollHandler.answers[currentRoom.localUser.id] ? pollDelegate.pollHandler.answers[currentRoom.localUser.id].includes(modelData["id"]) : false + onClicked: pollDelegate.pollHandler.sendPollAnswer(pollDelegate.data.eventId, modelData["id"]) + enabled: !pollDelegate.pollHandler.hasEnded + } + Label { + text: modelData["org.matrix.msc1767.text"] + } + Item { + Layout.fillWidth: true + } + Label { + visible: pollDelegate.data.content["org.matrix.msc3381.poll.start"]["kind"] == "org.matrix.msc3381.poll.disclosed" || pollHandler.hasEnded + Layout.preferredWidth: contentWidth + text: pollDelegate.pollHandler.counts[modelData["id"]] ?? "0" + } + } + } + Label { + visible: pollDelegate.data.content["org.matrix.msc3381.poll.start"]["kind"] == "org.matrix.msc3381.poll.disclosed" || pollDelegate.pollHandler.hasEnded + text: i18np("Based on votes by %1 user", "Based on votes by %1 users", pollDelegate.pollHandler.answerCount) + (pollDelegate.pollHandler.hasEnded ? (" " + i18nc("as in 'this vote has ended'", "(Ended)")) : "") + font.pointSize: questionLabel.font.pointSize * 0.8 + } + } +} diff --git a/src/res.qrc b/src/res.qrc index f226288f0..ddb5d62f6 100644 --- a/src/res.qrc +++ b/src/res.qrc @@ -43,6 +43,7 @@ qml/Component/Timeline/EventDelegate.qml qml/Component/Timeline/MessageDelegate.qml qml/Component/Timeline/ReadMarkerDelegate.qml + qml/Component/Timeline/PollDelegate.qml qml/Component/Timeline/MimeComponent.qml qml/Component/Login/LoginStep.qml qml/Component/Login/Login.qml