diff --git a/autotests/data/test-pollhandlerstart-sync.json b/autotests/data/test-pollhandlerstart-sync.json index eb8b52912..03ad23b79 100644 --- a/autotests/data/test-pollhandlerstart-sync.json +++ b/autotests/data/test-pollhandlerstart-sync.json @@ -8,14 +8,14 @@ "answers": [ { "id": "option1", - "org.matrix.msc1767.text": "option1" + "org.matrix.msc1767.text": "option1text" }, { "id": "option2", - "org.matrix.msc1767.text": "option2" + "org.matrix.msc1767.text": "option2text" } ], - "kind": "org.matrix.msc3381.poll.disclosed", + "kind": "org.matrix.msc3381.poll.undisclosed", "max_selections": 1, "question": { "body": "test", diff --git a/autotests/pollhandlertest.cpp b/autotests/pollhandlertest.cpp index df813b74d..ad01e8243 100644 --- a/autotests/pollhandlertest.cpp +++ b/autotests/pollhandlertest.cpp @@ -41,29 +41,32 @@ void PollHandlerTest::nullObject() auto pollHandler = PollHandler(); QCOMPARE(pollHandler.hasEnded(), false); - QCOMPARE(pollHandler.answerCount(), 0); + QCOMPARE(pollHandler.numAnswers(), 0); QCOMPARE(pollHandler.question(), QString()); - QCOMPARE(pollHandler.options(), QJsonArray()); - QCOMPARE(pollHandler.answers(), QJsonObject()); - QCOMPARE(pollHandler.counts(), QJsonObject()); - QCOMPARE(pollHandler.kind(), QString()); + QCOMPARE(pollHandler.kind(), PollKind::Disclosed); } void PollHandlerTest::poll() { auto startEvent = eventCast(room->messageEvents().at(0).get()); - auto pollHandler = PollHandler(room, startEvent); + auto pollHandler = PollHandler(room, startEvent->id()); - auto options = QJsonArray{QJsonObject{{"id"_L1, "option1"_L1}, {"org.matrix.msc1767.text"_L1, "option1"_L1}}, - QJsonObject{{"id"_L1, "option2"_L1}, {"org.matrix.msc1767.text"_L1, "option2"_L1}}}; + QList options = {EventContent::Answer{"option1"_L1, "option1"_L1}, EventContent::Answer{"option2"_L1, "option2"_L1}}; + const auto answer0 = pollHandler.answerAtRow(0); + const auto answer1 = pollHandler.answerAtRow(1); QCOMPARE(pollHandler.hasEnded(), false); - QCOMPARE(pollHandler.answerCount(), 0); + QCOMPARE(pollHandler.numAnswers(), 2); QCOMPARE(pollHandler.question(), u"test"_s); - QCOMPARE(pollHandler.options(), options); - QCOMPARE(pollHandler.answers(), QJsonObject()); - QCOMPARE(pollHandler.counts(), QJsonObject()); - QCOMPARE(pollHandler.kind(), u"org.matrix.msc3381.poll.disclosed"_s); + QCOMPARE(answer0.id, "option1"_L1); + QCOMPARE(answer1.id, "option2"_L1); + QCOMPARE(answer0.text, "option1text"_L1); + QCOMPARE(answer1.text, "option2text"_L1); + QCOMPARE(pollHandler.answerCountAtId(answer0.id), 0); + QCOMPARE(pollHandler.answerCountAtId(answer1.id), 0); + QCOMPARE(pollHandler.checkMemberSelectedId(connection->userId(), answer0.id), false); + QCOMPARE(pollHandler.checkMemberSelectedId(connection->userId(), answer1.id), false); + QCOMPARE(pollHandler.kind(), PollKind::Undisclosed); } QTEST_GUILESS_MAIN(PollHandlerTest) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 5c99970e2..b7cd010e9 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -196,6 +196,8 @@ add_library(neochat STATIC models/pinnedmessagemodel.h models/commonroomsmodel.cpp models/commonroomsmodel.h + models/pollanswermodel.cpp + models/pollanswermodel.h ) set_source_files_properties(qml/OsmLocationPlugin.qml PROPERTIES @@ -296,6 +298,7 @@ ecm_add_qml_module(neochat URI org.kde.neochat GENERATE_PLUGIN_SOURCE qml/HoverLinkIndicator.qml qml/AvatarNotification.qml qml/ReasonDialog.qml + qml/NewPollDialog.qml SOURCES messageattached.cpp messageattached.h diff --git a/src/chatbar/ChatBar.qml b/src/chatbar/ChatBar.qml index 02915e218..284930048 100644 --- a/src/chatbar/ChatBar.qml +++ b/src/chatbar/ChatBar.qml @@ -113,6 +113,20 @@ QQC2.Control { } tooltip: text }, + Kirigami.Action { + id: pollButton + icon.name: "amarok_playcount" + property bool isBusy: false + text: i18nc("@action:button", "Create a Poll") + displayHint: QQC2.AbstractButton.IconOnly + + onTriggered: { + newPollDialog.createObject(QQC2.Overlay.overlay, { + room: root.currentRoom + }).open(); + } + tooltip: text + }, Kirigami.Action { id: sendAction @@ -492,6 +506,11 @@ QQC2.Control { LocationChooser {} } + Component { + id: newPollDialog + NewPollDialog {} + } + CompletionMenu { id: completionMenu chatDocumentHandler: documentHandler diff --git a/src/events/pollevent.cpp b/src/events/pollevent.cpp index 73a71c00a..b8cbd61d9 100644 --- a/src/events/pollevent.cpp +++ b/src/events/pollevent.cpp @@ -2,22 +2,28 @@ // SPDX-License-Identifier: LGPL-2.0-or-later #include "pollevent.h" +#include using namespace Quotient; -PollStartEvent::PollStartEvent(const QJsonObject &obj) - : RoomEvent(obj) +PollKind::Kind PollStartEvent::kind() const { + return content().kind; } int PollStartEvent::maxSelections() const { - return contentJson()["org.matrix.msc3381.poll.start"_L1]["max_selections"_L1].toInt(); + return content().maxSelection > 0 ? content().maxSelection : 1; } QString PollStartEvent::question() const { - return contentJson()["org.matrix.msc3381.poll.start"_L1]["question"_L1]["body"_L1].toString(); + return content().question; +} + +QList PollStartEvent::answers() const +{ + return content().answers; } PollResponseEvent::PollResponseEvent(const QJsonObject &obj) @@ -25,14 +31,34 @@ PollResponseEvent::PollResponseEvent(const QJsonObject &obj) { } -PollEndEvent::PollEndEvent(const QJsonObject &obj) - : RoomEvent(obj) -{ -} - PollResponseEvent::PollResponseEvent(const QString &pollStartEventId, QStringList responses) : RoomEvent(basicJson(TypeId, {{"org.matrix.msc3381.poll.response"_L1, QJsonObject{{"answers"_L1, QJsonArray::fromStringList(responses)}}}, {"m.relates_to"_L1, QJsonObject{{"rel_type"_L1, "m.reference"_L1}, {"event_id"_L1, pollStartEventId}}}})) { } + +QStringList PollResponseEvent::selections() const +{ + const auto jsonSelections = contentPart("org.matrix.msc3381.poll.response"_L1)["answers"_L1].toArray(); + QStringList selections; + for (const auto &selection : jsonSelections) { + selections += selection.toString(); + } + return selections; +} + +std::optional PollResponseEvent::relatesTo() const +{ + return contentPart>(RelatesToKey); +} + +PollEndEvent::PollEndEvent(const QJsonObject &obj) + : RoomEvent(obj) +{ +} + +std::optional PollEndEvent::relatesTo() const +{ + return contentPart>(RelatesToKey); +} diff --git a/src/events/pollevent.h b/src/events/pollevent.h index b68824cec..ac99fd5ed 100644 --- a/src/events/pollevent.h +++ b/src/events/pollevent.h @@ -3,10 +3,148 @@ #pragma once +#include + +#include +#include #include +#include + +using namespace Qt::StringLiterals; + +/** + * @class PollKind + * + * This class is designed to define the PollKind enumeration. + */ +class PollKind : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("") + +public: + /** + * @brief Enum representing the available poll kinds. + */ + enum Kind { + Disclosed, /**< The poll results can been seen after the user votes. */ + Undisclosed, /**< The poll results can only been seen after the poll ends. */ + }; + Q_ENUM(Kind); + + /** + * @brief Return the string for the given Kind. + * + * @sa Kind + */ + static QString stringForKind(Kind kind) + { + switch (kind) { + case Undisclosed: + return "org.matrix.msc3381.poll.undisclosed"_L1; + default: + return "org.matrix.msc3381.poll.disclosed"_L1; + } + } + + /** + * @brief Return the Kind for the given string. + * + * @sa Kind + */ + static Kind kindForString(const QString &kindString) + { + if (kindString == "org.matrix.msc3381.poll.undisclosed"_L1) { + return Undisclosed; + } + return Disclosed; + } +}; namespace Quotient { +namespace EventContent +{ + +/** + * @brief An answer to the poll. + */ +struct Answer { + Q_GADGET + Q_PROPERTY(QString id MEMBER id CONSTANT) + Q_PROPERTY(QString text MEMBER text CONSTANT) + +public: + QString id; + QString text; + + int operator==(const Answer &right) const + { + return id == right.id && text == right.text; + } +}; + +/** + * @brief Struct representing the content of a poll event. + */ +struct PollStartContent { + PollKind::Kind kind; + int maxSelection; + QString question; + QList answers; +}; + +} // namespace EventContent + +template<> +inline EventContent::Answer fromJson(const QJsonObject &jo) +{ + return EventContent::Answer{fromJson(jo["id"_L1]), fromJson(jo["org.matrix.msc1767.text"_L1])}; +} + +template<> +inline auto toJson(const EventContent::Answer &c) +{ + QJsonObject jo; + addParam(jo, "id"_L1, c.id); + addParam(jo, "org.matrix.msc1767.text"_L1, c.text); + return jo; +} + +template<> +inline EventContent::PollStartContent fromJson(const QJsonObject &jo) +{ + return EventContent::PollStartContent{ + PollKind::kindForString(jo["org.matrix.msc3381.poll.start"_L1]["kind"_L1].toString()), + fromJson(jo["org.matrix.msc3381.poll.start"_L1]["max_selections"_L1]), + fromJson(jo["org.matrix.msc3381.poll.start"_L1]["question"_L1]["org.matrix.msc1767.text"_L1]), + fromJson>(jo["org.matrix.msc3381.poll.start"_L1]["answers"_L1]), + }; +} + +template<> +inline auto toJson(const EventContent::PollStartContent &c) +{ + QJsonObject innerJo; + addParam(innerJo, "kind"_L1, PollKind::stringForKind(c.kind)); + addParam(innerJo, "max_selections"_L1, c.maxSelection); + if (innerJo["max_selections"_L1].toInt() < 1) { + innerJo["max_selections"_L1] = 1; + } + innerJo.insert("question"_L1, QJsonObject{{"org.matrix.msc1767.text"_L1, c.question}}); + addParam(innerJo, "answers"_L1, c.answers); + + QJsonObject jo; + auto textString = c.question; + for (int i = 0; i < c.answers.length(); ++i) { + textString.append("\n%1. %2"_L1.arg(QString::number(i + 1), c.answers.at(i).text)); + } + addParam(jo, "org.matrix.msc1767.text"_L1, textString); + jo.insert("org.matrix.msc3381.poll.start"_L1, innerJo); + return jo; +} + /** * @class PollStartEvent * @@ -17,11 +155,16 @@ namespace Quotient * * @sa Quotient::RoomEvent */ -class PollStartEvent : public RoomEvent +class PollStartEvent : public EventTemplate { public: QUO_EVENT(PollStartEvent, "org.matrix.msc3381.poll.start"); - explicit PollStartEvent(const QJsonObject &obj); + using EventTemplate::EventTemplate; + + /** + * @brief The poll kind. + */ + PollKind::Kind kind() const; /** * @brief The maximum number of options a user can select in a poll. @@ -32,6 +175,11 @@ public: * @brief The question being asked in the poll. */ QString question() const; + + /** + * @brief The list of answers to the poll. + */ + QList answers() const; }; /** @@ -50,6 +198,16 @@ public: QUO_EVENT(PollResponseEvent, "org.matrix.msc3381.poll.response"); explicit PollResponseEvent(const QJsonObject &obj); explicit PollResponseEvent(const QString &pollStartEventId, QStringList responses); + + /** + * @brief The selected answers to the poll. + */ + QStringList selections() const; + + /** + * @brief The EventRelation pointing to the PollStartEvent. + */ + std::optional relatesTo() const; }; /** @@ -67,5 +225,10 @@ class PollEndEvent : public RoomEvent public: QUO_EVENT(PollEndEvent, "org.matrix.msc3381.poll.end"); explicit PollEndEvent(const QJsonObject &obj); + + /** + * @brief The EventRelation pointing to the PollStartEvent. + */ + std::optional relatesTo() const; }; } diff --git a/src/models/messagemodel.cpp b/src/models/messagemodel.cpp index 48bd140a4..99202e222 100644 --- a/src/models/messagemodel.cpp +++ b/src/models/messagemodel.cpp @@ -121,7 +121,7 @@ QVariant MessageModel::data(const QModelIndex &idx, int role) const } if (role == ContentModelRole) { - if (event->get().is()) { + if (event->get().is() || event->get().is()) { return QVariant::fromValue(m_room->contentModelForEvent(event->get().id())); } @@ -401,18 +401,12 @@ void MessageModel::refreshLastUserEvents(int baseTimelineRow) } } -void MessageModel::createEventObjects(const Quotient::RoomEvent *event, bool isPending) +void MessageModel::createEventObjects(const Quotient::RoomEvent *event) { if (event == nullptr) { return; } - // We only create the poll handler for event acknowledged by the server as we need - // an ID - if (!event->id().isEmpty() && event->is()) { - m_room->createPollHandler(eventCast(event)); - } - auto eventId = event->id(); auto senderId = event->senderId(); if (eventId.isEmpty()) { diff --git a/src/models/messagemodel.h b/src/models/messagemodel.h index 411fd9154..dc411aa84 100644 --- a/src/models/messagemodel.h +++ b/src/models/messagemodel.h @@ -129,7 +129,7 @@ Q_SIGNALS: * Any model inheriting from MessageModel needs to emit this signal for every * new event it adds. */ - void newEventAdded(const Quotient::RoomEvent *event, bool isPending = false); + void newEventAdded(const Quotient::RoomEvent *event); protected: QPointer m_room; @@ -154,5 +154,5 @@ private: QMap> m_readMarkerModels; - void createEventObjects(const Quotient::RoomEvent *event, bool isPending = false); + void createEventObjects(const Quotient::RoomEvent *event); }; diff --git a/src/models/pinnedmessagemodel.cpp b/src/models/pinnedmessagemodel.cpp index a47c16c8d..6a8c4de1d 100644 --- a/src/models/pinnedmessagemodel.cpp +++ b/src/models/pinnedmessagemodel.cpp @@ -58,7 +58,7 @@ void PinnedMessageModel::fill() connect(job, &BaseJob::success, this, [this, job] { beginInsertRows({}, m_pinnedEvents.size(), m_pinnedEvents.size()); m_pinnedEvents.push_back(std::move(fromJson>(job->jsonData()))); - Q_EMIT newEventAdded(m_pinnedEvents.back().get(), false); + Q_EMIT newEventAdded(m_pinnedEvents.back().get()); endInsertRows(); }); } diff --git a/src/models/pollanswermodel.cpp b/src/models/pollanswermodel.cpp new file mode 100644 index 000000000..4aa42aa32 --- /dev/null +++ b/src/models/pollanswermodel.cpp @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2025 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#include "pollanswermodel.h" + +#include "neochatroom.h" +#include "pollhandler.h" + +PollAnswerModel::PollAnswerModel(PollHandler *parent) + : QAbstractListModel(parent) +{ + Q_ASSERT(parent != nullptr); + + connect(parent, &PollHandler::selectionsChanged, this, [this]() { + dataChanged(index(0), index(rowCount() - 1), {CountRole, LocalChoiceRole}); + }); + connect(parent, &PollHandler::answersChanged, this, [this]() { + dataChanged(index(0), index(rowCount() - 1), {TextRole}); + }); +} + +QVariant PollAnswerModel::data(const QModelIndex &index, int role) const +{ + Q_ASSERT(checkIndex(index, QAbstractItemModel::CheckIndexOption::IndexIsValid)); + + const auto row = index.row(); + if (row < 0 || row >= rowCount()) { + return {}; + } + + const auto pollHandler = dynamic_cast(this->parent()); + if (pollHandler == nullptr) { + qWarning() << "PollAnswerModel created with nullptr parent."; + return 0; + } + + if (role == IdRole) { + return pollHandler->answerAtRow(row).id; + } + if (role == TextRole) { + return pollHandler->answerAtRow(row).text; + } + if (role == CountRole) { + return pollHandler->answerCountAtId(pollHandler->answerAtRow(row).id); + } + if (role == LocalChoiceRole) { + const auto room = pollHandler->room(); + if (room == nullptr) { + return {}; + } + return pollHandler->checkMemberSelectedId(room->localMember().id(), pollHandler->answerAtRow(row).id); + } + return {}; +} + +int PollAnswerModel::rowCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent); + const auto pollHandler = dynamic_cast(this->parent()); + if (pollHandler == nullptr) { + qWarning() << "PollAnswerModel created with nullptr parent."; + return 0; + } + + return pollHandler->numAnswers(); +} + +QHash PollAnswerModel::roleNames() const +{ + return { + {IdRole, "id"}, + {TextRole, "answerText"}, + {CountRole, "count"}, + {LocalChoiceRole, "localChoice"}, + }; +} diff --git a/src/models/pollanswermodel.h b/src/models/pollanswermodel.h new file mode 100644 index 000000000..50d3db015 --- /dev/null +++ b/src/models/pollanswermodel.h @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2025 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#pragma once + +#include +#include + +class PollHandler; + +/** + * @class PollAnswerModel + * + * This class defines the model for visualising a list of answer to a poll. + */ +class PollAnswerModel : public QAbstractListModel +{ + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("") + +public: + /** + * @brief Defines the model roles. + */ + enum Roles { + IdRole, /**< The ID of the answer. */ + TextRole, /**< The answer text. */ + CountRole, /**< The number of people who gave this answer. */ + LocalChoiceRole, /**< Whether this option was selected by the local user */ + }; + Q_ENUM(Roles) + + explicit PollAnswerModel(PollHandler *parent); + + /** + * @brief Get the given role value at the given index. + * + * @sa QAbstractItemModel::data + */ + QVariant data(const QModelIndex &index, int role) const override; + + /** + * @brief Number of rows in the model. + * + * @sa QAbstractItemModel::rowCount + */ + int rowCount(const QModelIndex &parent = {}) const override; + + /** + * @brief Returns a mapping from Role enum values to role names. + * + * @sa Roles, QAbstractItemModel::roleNames() + */ + QHash roleNames() const override; +}; diff --git a/src/models/timelinemessagemodel.cpp b/src/models/timelinemessagemodel.cpp index f1bc8e7f3..90b269457 100644 --- a/src/models/timelinemessagemodel.cpp +++ b/src/models/timelinemessagemodel.cpp @@ -2,6 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-only #include "timelinemessagemodel.h" +#include "events/pollevent.h" #include "messagemodel_logging.h" using namespace Quotient; @@ -36,7 +37,7 @@ void TimelineMessageModel::connectNewRoom() }); connect(m_room, &Room::addedMessages, this, [this](int lowest, int biggest) { if (m_initialized) { - for (int i = lowest; i == biggest; ++i) { + for (int i = lowest; i <= biggest; ++i) { const auto event = m_room->findInTimeline(i)->event(); Q_EMIT newEventAdded(event); } @@ -58,14 +59,14 @@ void TimelineMessageModel::connectNewRoom() #if Quotient_VERSION_MINOR > 9 || (Quotient_VERSION_MINOR == 9 && Quotient_VERSION_PATCH > 0) connect(m_room, &Room::pendingEventAdded, this, [this](const Quotient::RoomEvent *event) { m_initialized = true; - Q_EMIT newEventAdded(event, true); + Q_EMIT newEventAdded(event); beginInsertRows({}, 0, 0); endInsertRows(); }); #else connect(m_room, &Room::pendingEventAboutToAdd, this, [this](Quotient::RoomEvent *event) { m_initialized = true; - Q_EMIT newEventAdded(event, true); + Q_EMIT newEventAdded(event); beginInsertRows({}, 0, 0); }); connect(m_room, &Room::pendingEventAdded, this, &TimelineMessageModel::endInsertRows); @@ -111,9 +112,6 @@ void TimelineMessageModel::connectNewRoom() const auto eventIt = m_room->findInTimeline(eventId); if (eventIt != m_room->historyEdge()) { Q_EMIT newEventAdded(eventIt->event()); - if (eventIt->event()->is()) { - m_room->createPollHandler(eventCast(eventIt->event())); - } } refreshEventRoles(eventId, {Qt::DisplayRole}); }); diff --git a/src/neochatroom.cpp b/src/neochatroom.cpp index 058d4ad8a..64ffcf7d0 100644 --- a/src/neochatroom.cpp +++ b/src/neochatroom.cpp @@ -1438,24 +1438,37 @@ bool NeoChatRoom::canEncryptRoom() const static PollHandler *emptyPollHandler = new PollHandler; -PollHandler *NeoChatRoom::poll(const QString &eventId) const +PollHandler *NeoChatRoom::poll(const QString &eventId) { - if (auto pollHandler = m_polls[eventId]) { - return pollHandler; + const auto event = getEvent(eventId); + if (event.first == nullptr || event.second) { + return emptyPollHandler; } - return emptyPollHandler; + + if (m_polls.contains(eventId)) { + return m_polls[eventId]; + } + auto handler = new PollHandler(this, eventId); + m_polls.insert(eventId, handler); + return handler; } -void NeoChatRoom::createPollHandler(const Quotient::PollStartEvent *event) +void NeoChatRoom::postPoll(PollKind::Kind kind, const QString &question, const QList &answers) { - if (event == nullptr) { - return; - } - auto eventId = event->id(); - if (!m_polls.contains(eventId)) { - auto handler = new PollHandler(this, event); - m_polls.insert(eventId, handler); + QList answerStructs; + for (const auto &answer : answers) { + answerStructs += EventContent::Answer{ + QUuid::createUuid().toString().remove(QRegularExpression(u"{|}|-"_s)), + answer, + }; } + const auto content = EventContent::PollStartContent{ + .kind = kind, + .maxSelection = 1, + .question = question, + .answers = answerStructs, + }; + post(content); } bool NeoChatRoom::downloadTempFile(const QString &eventId) diff --git a/src/neochatroom.h b/src/neochatroom.h index 1ffa8aca1..0b5475f39 100644 --- a/src/neochatroom.h +++ b/src/neochatroom.h @@ -15,6 +15,7 @@ #include "enums/messagetype.h" #include "enums/pushrule.h" +#include "events/pollevent.h" #include "models/messagecontentmodel.h" #include "models/threadmodel.h" #include "neochatroommember.h" @@ -501,14 +502,9 @@ public: * * @sa PollHandler */ - PollHandler *poll(const QString &eventId) const; + PollHandler *poll(const QString &eventId); - /** - * @brief Create a PollHandler object for the given event. - * - * @sa PollHandler - */ - void createPollHandler(const Quotient::PollStartEvent *event); + Q_INVOKABLE void postPoll(PollKind::Kind kind, const QString &question, const QList &answers); /** * @brief Get the full Json data for a given room account data event. diff --git a/src/pollhandler.cpp b/src/pollhandler.cpp index 2fd75ce3b..b8f5b378b 100644 --- a/src/pollhandler.cpp +++ b/src/pollhandler.cpp @@ -3,7 +3,9 @@ #include "pollhandler.h" +#include "events/pollevent.h" #include "neochatroom.h" +#include "pollanswermodel.h" #include #include @@ -12,11 +14,14 @@ using namespace Quotient; -PollHandler::PollHandler(NeoChatRoom *room, const Quotient::PollStartEvent *pollStartEvent) +PollHandler::PollHandler(NeoChatRoom *room, const QString &pollStartId) : QObject(room) - , m_pollStartEvent(pollStartEvent) + , m_pollStartId(pollStartId) { - if (room != nullptr && m_pollStartEvent != nullptr) { + Q_ASSERT(room != nullptr); + Q_ASSERT(!pollStartId.isEmpty()); + + if (room != nullptr) { connect(room, &NeoChatRoom::aboutToAddNewMessages, this, &PollHandler::updatePoll); checkLoadRelations(); } @@ -26,28 +31,38 @@ void PollHandler::updatePoll(Quotient::RoomEventsRange events) { // This function will never be called if the PollHandler was not initialized with // a NeoChatRoom as parent and a PollStartEvent so no need to null check. - auto room = dynamic_cast(parent()); + const auto room = dynamic_cast(parent()); + auto pollStartEvent = eventCast(room->getEvent(m_pollStartId).first); + if (pollStartEvent == nullptr) { + return; + } + for (const auto &event : events) { if (event->is()) { + const auto endEvent = eventCast(event); + if (endEvent->relatesTo()->eventId != m_pollStartId) { + continue; + } + auto plEvent = room->currentState().get(); if (!plEvent) { continue; } auto userPl = plEvent->powerLevelForUser(event->senderId()); - if (event->senderId() == m_pollStartEvent->senderId() || userPl >= plEvent->redact()) { + if (event->senderId() == pollStartEvent->senderId() || userPl >= plEvent->redact()) { m_hasEnded = true; m_endedTimestamp = event->originTimestamp(); Q_EMIT hasEndedChanged(); } } if (event->is()) { - handleAnswer(event->contentJson(), event->senderId(), event->originTimestamp()); + handleResponse(eventCast(event)); } if (event->contentPart("m.relates_to"_L1).contains("rel_type"_L1) && event->contentPart("m.relates_to"_L1)["rel_type"_L1].toString() == "m.replace"_L1 - && event->contentPart("m.relates_to"_L1)["event_id"_L1].toString() == m_pollStartEvent->id()) { + && event->contentPart("m.relates_to"_L1)["event_id"_L1].toString() == pollStartEvent->id()) { Q_EMIT questionChanged(); - Q_EMIT optionsChanged(); + Q_EMIT answersChanged(); } } } @@ -57,91 +72,149 @@ void PollHandler::checkLoadRelations() // This function will never be called if the PollHandler was not initialized with // a NeoChatRoom as parent and a PollStartEvent so no need to null check. auto room = dynamic_cast(parent()); - m_maxVotes = m_pollStartEvent->maxSelections(); - auto job = room->connection()->callApi(room->id(), m_pollStartEvent->id()); - connect(job, &BaseJob::success, this, [this, job, room]() { + const auto pollStartEvent = room->getEvent(m_pollStartId).first; + if (pollStartEvent == nullptr) { + return; + } + + auto job = room->connection()->callApi(room->id(), pollStartEvent->id()); + connect(job, &BaseJob::success, this, [this, job, room, pollStartEvent]() { for (const auto &event : job->chunk()) { if (event->is()) { + const auto endEvent = eventCast(event); + if (endEvent->relatesTo()->eventId != m_pollStartId) { + continue; + } + auto plEvent = room->currentState().get(); if (!plEvent) { continue; } auto userPl = plEvent->powerLevelForUser(event->senderId()); - if (event->senderId() == m_pollStartEvent->senderId() || userPl >= plEvent->redact()) { + if (event->senderId() == pollStartEvent->senderId() || userPl >= plEvent->redact()) { m_hasEnded = true; m_endedTimestamp = event->originTimestamp(); Q_EMIT hasEndedChanged(); } } if (event->is()) { - handleAnswer(event->contentJson(), event->senderId(), event->originTimestamp()); + handleResponse(eventCast(event)); } } }); } -void PollHandler::handleAnswer(const QJsonObject &content, const QString &sender, QDateTime timestamp) +void PollHandler::handleResponse(const Quotient::PollResponseEvent *event) { - 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"_L1]["answers"_L1].toArray()) { - auto array = m_answers[sender].toArray(); - array.insert(0, answer); - m_answers[sender] = array; - i++; - if (i == m_maxVotes) { - break; - } + if (event == nullptr) { + return; + } + + if (event->relatesTo()->eventId != m_pollStartId) { + return; + } + + // If there is no origin timestamp it's pending and therefore must be newer. + if ((event->originTimestamp() > m_selectionTimestamps[event->senderId()] || event->id().isEmpty()) + && (!m_hasEnded || event->originTimestamp() < m_endedTimestamp)) { + m_selectionTimestamps[event->senderId()] = event->originTimestamp(); + + // This function will never be called if the PollHandler was not initialized with + // a NeoChatRoom as parent and a PollStartEvent so no need to null check. + auto room = dynamic_cast(parent()); + const auto pollStartEvent = eventCast(room->getEvent(m_pollStartId).first); + if (pollStartEvent == nullptr) { + return; } - for (const auto &key : m_answers.keys()) { - if (m_answers[key].toArray().isEmpty()) { - m_answers.remove(key); - } + + m_selections[event->senderId()] = event->selections().size() > 0 ? event->selections().first(pollStartEvent->maxSelections()) : event->selections(); + if (m_selections.contains(event->senderId()) && m_selections[event->senderId()].isEmpty()) { + m_selections.remove(event->senderId()); } } - Q_EMIT answersChanged(); + + Q_EMIT selectionsChanged(); +} + +NeoChatRoom *PollHandler::room() const +{ + return dynamic_cast(parent()); } QString PollHandler::question() const { - if (m_pollStartEvent == nullptr) { + auto room = dynamic_cast(parent()); + if (room == nullptr) { return {}; } - return m_pollStartEvent->contentPart("org.matrix.msc3381.poll.start"_L1)["question"_L1].toObject()["body"_L1].toString(); -} - -QJsonArray PollHandler::options() const -{ - if (m_pollStartEvent == nullptr) { + auto pollStartEvent = eventCast(room->getEvent(m_pollStartId).first); + if (pollStartEvent == nullptr) { return {}; } - return m_pollStartEvent->contentPart("org.matrix.msc3381.poll.start"_L1)["answers"_L1].toArray(); + return pollStartEvent->question(); } -QJsonObject PollHandler::answers() const +int PollHandler::numAnswers() const { - return m_answers; + auto room = dynamic_cast(parent()); + if (room == nullptr) { + return {}; + } + auto pollStartEvent = eventCast(room->getEvent(m_pollStartId).first); + if (pollStartEvent == nullptr) { + return {}; + } + return pollStartEvent->answers().length(); } -QJsonObject PollHandler::counts() const +Quotient::EventContent::Answer PollHandler::answerAtRow(int row) const { - QJsonObject counts; - for (const auto &answer : m_answers) { - for (const auto &id : answer.toArray()) { - counts[id.toString()] = counts[id.toString()].toInt() + 1; + auto room = dynamic_cast(parent()); + if (room == nullptr) { + return {}; + } + auto pollStartEvent = eventCast(room->getEvent(m_pollStartId).first); + if (pollStartEvent == nullptr) { + return {}; + } + return pollStartEvent->answers()[row]; +} + +int PollHandler::answerCountAtId(const QString &id) const +{ + int count = 0; + for (const auto &selection : m_selections) { + if (selection.contains(id)) { + count++; } } - return counts; + return count; } -QString PollHandler::kind() const +bool PollHandler::checkMemberSelectedId(const QString &memberId, const QString &id) const { - if (m_pollStartEvent == nullptr) { + return m_selections[memberId].contains(id); +} + +PollKind::Kind PollHandler::kind() const +{ + auto room = dynamic_cast(parent()); + if (room == nullptr) { return {}; } - return m_pollStartEvent->contentPart("org.matrix.msc3381.poll.start"_L1)["kind"_L1].toString(); + auto pollStartEvent = eventCast(room->getEvent(m_pollStartId).first); + if (pollStartEvent == nullptr) { + return {}; + } + return pollStartEvent->kind(); +} + +PollAnswerModel *PollHandler::answerModel() +{ + if (m_answerModel == nullptr) { + m_answerModel = new PollAnswerModel(this); + } + return m_answerModel; } void PollHandler::sendPollAnswer(const QString &eventId, const QString &answerId) @@ -153,23 +226,25 @@ void PollHandler::sendPollAnswer(const QString &eventId, const QString &answerId qWarning() << "PollHandler is empty, cannot send an answer."; return; } - QStringList ownAnswers; - for (const auto &answer : m_answers[room->localMember().id()].toArray()) { - ownAnswers += answer.toString(); + auto pollStartEvent = eventCast(room->getEvent(m_pollStartId).first); + if (pollStartEvent == nullptr) { + return; } + + QStringList ownAnswers = m_selections[room->localMember().id()]; 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) { + while (ownAnswers.size() >= pollStartEvent->maxSelections() && ownAnswers.size() > 0) { ownAnswers.pop_front(); } ownAnswers.insert(0, answerId); } const auto &response = room->post(eventId, ownAnswers); - handleAnswer(response->contentJson(), room->localMember().id(), QDateTime::currentDateTime()); + handleResponse(eventCast(response.event())); } bool PollHandler::hasEnded() const @@ -177,9 +252,4 @@ bool PollHandler::hasEnded() const return m_hasEnded; } -int PollHandler::answerCount() const -{ - return m_answers.size(); -} - #include "moc_pollhandler.cpp" diff --git a/src/pollhandler.h b/src/pollhandler.h index 3950cc383..d580e2078 100644 --- a/src/pollhandler.h +++ b/src/pollhandler.h @@ -12,6 +12,12 @@ #include #include "events/pollevent.h" +#include "models/pollanswermodel.h" + +namespace Quotient +{ +class PollResponseEvent; +} class NeoChatRoom; @@ -32,53 +38,56 @@ class PollHandler : public QObject QML_ELEMENT QML_UNCREATABLE("Use NeoChatRoom::poll") + /** + * @brief The kind of the poll. + */ + Q_PROPERTY(PollKind::Kind kind READ kind CONSTANT) + /** * @brief The question for the poll. */ Q_PROPERTY(QString question READ question NOTIFY questionChanged) - /** - * @brief The list of possible answers to the poll. - */ - Q_PROPERTY(QJsonArray options READ options NOTIFY optionsChanged) - - /** - * @brief The list of answer responses to the poll from users in the room. - */ - Q_PROPERTY(QJsonObject answers READ answers NOTIFY answersChanged) - - /** - * @brief The list number of votes for each answer in the poll. - */ - Q_PROPERTY(QJsonObject counts READ counts NOTIFY answersChanged) - /** * @brief Whether the poll has ended. */ Q_PROPERTY(bool hasEnded READ hasEnded NOTIFY hasEndedChanged) /** - * @brief The total number of answers to the poll. + * @brief The model to visualize the answers to this poll. */ - Q_PROPERTY(int answerCount READ answerCount NOTIFY answersChanged) - - /** - * @brief The kind of the poll. - */ - Q_PROPERTY(QString kind READ kind CONSTANT) + Q_PROPERTY(PollAnswerModel *answerModel READ answerModel CONSTANT) public: PollHandler() = default; - PollHandler(NeoChatRoom *room, const Quotient::PollStartEvent *pollStartEvent); + PollHandler(NeoChatRoom *room, const QString &pollStartId); - bool hasEnded() const; - int answerCount() const; + NeoChatRoom *room() const; + PollKind::Kind kind() const; QString question() const; - QJsonArray options() const; - QJsonObject answers() const; - QJsonObject counts() const; - QString kind() const; + bool hasEnded() const; + PollAnswerModel *answerModel(); + + /** + * @brief The total number of answer options. + */ + int numAnswers() const; + + /** + * @brief The answer at the given row. + */ + Quotient::EventContent::Answer answerAtRow(int row) const; + + /** + * @brief The number of responders who gave the answer ID. + */ + int answerCountAtId(const QString &id) const; + + /** + * @brief Check whether the given member has selected the given ID in their response. + */ + bool checkMemberSelectedId(const QString &memberId, const QString &id) const; /** * @brief Send an answer to the poll. @@ -87,20 +96,27 @@ public: Q_SIGNALS: void questionChanged(); - void optionsChanged(); - void answersChanged(); void hasEndedChanged(); + void answersChanged(); + + /** + * @brief Emitted when the selected answers to the poll change. + */ + void selectionsChanged(); + private: - const Quotient::PollStartEvent *m_pollStartEvent = nullptr; + QString m_pollStartId; void updatePoll(Quotient::RoomEventsRange events); void checkLoadRelations(); - void handleAnswer(const QJsonObject &object, const QString &sender, QDateTime timestamp); - QMap m_answerTimestamps; - QJsonObject m_answers; - int m_maxVotes = 1; + void handleResponse(const Quotient::PollResponseEvent *event); + QHash m_selectionTimestamps; + QHash> m_selections; + bool m_hasEnded = false; QDateTime m_endedTimestamp; + + QPointer m_answerModel; }; diff --git a/src/qml/NewPollDialog.qml b/src/qml/NewPollDialog.qml new file mode 100644 index 000000000..4ed8b17a5 --- /dev/null +++ b/src/qml/NewPollDialog.qml @@ -0,0 +1,156 @@ +// SPDX-FileCopyrightText: 2025 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +import QtQuick +import QtQuick.Controls as QQC2 +import QtQuick.Layouts + +import org.kde.kirigami as Kirigami + +import org.kde.kirigamiaddons.formcard as FormCard +import org.kde.kirigamiaddons.labs.components as KirigamiComponents +import org.kde.kirigamiaddons.delegates as Delegates + +import Quotient + +import org.kde.neochat + +Kirigami.Dialog { + id: root + + required property NeoChatRoom room + + standardButtons: Kirigami.Dialog.Cancel + + customFooterActions: [ + Kirigami.Action { + enabled: optionModel.allValuesSet && questionTextField.text.length > 0 + text: i18nc("@action:button", "Send") + icon.name: "document-send" + onTriggered: { + root.room.postPoll(pollTypeCombo.currentValue, questionTextField.text, optionModel.values()) + root.close() + } + } + ] + + width: Math.min(applicationWindow().width, Kirigami.Units.gridUnit * 24) + title: i18nc("@title: create new poll in the room", "Create Poll") + + contentItem: ColumnLayout { + spacing: 0 + + FormCard.FormComboBoxDelegate { + id: pollTypeCombo + + text: i18n("Poll type:") + currentIndex: 0 + textRole: "text" + valueRole: "value" + model: [ + { value: PollKind.Disclosed, text: i18n("Open poll") }, + { value: PollKind.Undisclosed, text: i18n("Closed poll") } + ] + } + FormCard.FormTextDelegate { + verticalPadding: 0 + text: pollTypeCombo.currentValue == 0 ? i18n("Voters can see the result as soon as they have voted") : i18n("Results are revealed only after the poll has closed") + } + FormCard.FormTextFieldDelegate { + id: questionTextField + label: i18n("Question:") + } + Repeater { + id: optionRepeater + + model: ListModel { + id: optionModel + + readonly property bool allValuesSet: { + for( var i = 0; i < optionModel.rowCount(); i++ ) { + if (optionModel.get(i).optionText.length <= 0) { + return false; + } + } + return true; + } + + ListElement { + optionText: "" + } + ListElement { + optionText: "" + } + + function values() { + let textValues = [] + for( var i = 0; i < optionModel.rowCount(); i++ ) { + textValues.push(optionModel.get(i).optionText); + } + return textValues; + } + } + delegate: FormCard.AbstractFormDelegate { + id: optionDelegate + + required property int index + required property string optionText + + contentItem: ColumnLayout { + QQC2.Label { + id: optionLabel + + Layout.fillWidth: true + text: i18nc("As in first answer option to the poll", "Option %1:", optionDelegate.index + 1) + elide: Text.ElideRight + wrapMode: Text.Wrap + Accessible.ignored: true + } + RowLayout { + Layout.fillWidth: true + + QQC2.TextField { + id: textField + Layout.fillWidth: true + Accessible.name: optionLabel.text + onTextChanged: { + optionModel.set(optionDelegate.index, {optionText: text}) + optionModel.allValuesSetChanged() + } + placeholderText: i18n("Enter option") + } + QQC2.ToolButton { + display: QQC2.AbstractButton.IconOnly + action: Kirigami.Action { + id: removeOptionAction + text: i18nc("@action:button", "Remove option") + icon.name: "edit-delete-remove" + onTriggered: optionModel.remove(optionDelegate.index) + } + QQC2.ToolTip { + text: removeOptionAction.text + delay: Kirigami.Units.toolTipDelay + } + } + } + } + + background: null + } + } + Delegates.RoundedItemDelegate { + Layout.fillWidth: true + + horizontalPadding: Kirigami.Units.largeSpacing * 2 + leftInset: Kirigami.Units.largeSpacing + Kirigami.Units.smallSpacing + rightInset: Kirigami.Units.largeSpacing + Kirigami.Units.smallSpacing + + highlighted: true + + icon.name: "list-add" + text: i18nc("@action:button", "Add option") + + onClicked: optionModel.append({optionText: ""}) + } + } +} diff --git a/src/timeline/PollComponent.qml b/src/timeline/PollComponent.qml index 19631d10e..c3665ba7f 100644 --- a/src/timeline/PollComponent.qml +++ b/src/timeline/PollComponent.qml @@ -8,6 +8,9 @@ import QtQuick.Layouts import org.kde.kirigami as Kirigami import org.kde.kirigamiaddons.formcard as FormCard + +import Quotient + import org.kde.neochat /** @@ -43,24 +46,31 @@ ColumnLayout { } Repeater { - model: root.pollHandler.options + model: root.pollHandler.answerModel delegate: FormCard.FormCheckDelegate { + id: answerDelegate + + required property string id + required property string answerText + required property int count + required property bool localChoice + Layout.fillWidth: true Layout.leftMargin: -Kirigami.Units.largeSpacing - Kirigami.Units.smallSpacing Layout.rightMargin: -Kirigami.Units.largeSpacing - Kirigami.Units.smallSpacing - checked: root.pollHandler.answers[root.Message.room.localMember.id] ? root.pollHandler.answers[root.Message.room.localMember.id].includes(modelData["id"]) : false - onClicked: root.pollHandler.sendPollAnswer(root.eventId, modelData["id"]) + checked: answerDelegate.localChoice + onClicked: root.pollHandler.sendPollAnswer(root.eventId, answerDelegate.id) enabled: !root.pollHandler.hasEnded - text: modelData["org.matrix.msc1767.text"] + text: answerDelegate.answerText topPadding: Kirigami.Units.smallSpacing bottomPadding: Kirigami.Units.smallSpacing trailing: Label { - visible: root.pollHandler.kind == "org.matrix.msc3381.poll.disclosed" || pollHandler.hasEnded + visible: root.pollHandler.kind == PollKind.Disclosed || pollHandler.hasEnded Layout.preferredWidth: contentWidth - text: root.pollHandler.counts[modelData["id"]] ?? "0" + text: answerDelegate.count } } }