Poll Updates and Send Polls

Fix showing polls and update the events and PollHandler to make them easier to work with.

Add a PollAnswerModel to visualise poll answers.

Enable sending polls.
This commit is contained in:
James Graham
2025-03-22 16:32:08 +00:00
parent 55d68af499
commit 37d77f579a
18 changed files with 764 additions and 165 deletions

View File

@@ -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",

View File

@@ -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<const PollStartEvent>(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<Quotient::EventContent::Answer> 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)

View File

@@ -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

View File

@@ -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

View File

@@ -2,22 +2,28 @@
// SPDX-License-Identifier: LGPL-2.0-or-later
#include "pollevent.h"
#include <Quotient/converters.h>
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<EventContent::Answer> 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<QJsonObject>("org.matrix.msc3381.poll.response"_L1)["answers"_L1].toArray();
QStringList selections;
for (const auto &selection : jsonSelections) {
selections += selection.toString();
}
return selections;
}
std::optional<EventRelation> PollResponseEvent::relatesTo() const
{
return contentPart<std::optional<EventRelation>>(RelatesToKey);
}
PollEndEvent::PollEndEvent(const QJsonObject &obj)
: RoomEvent(obj)
{
}
std::optional<EventRelation> PollEndEvent::relatesTo() const
{
return contentPart<std::optional<EventRelation>>(RelatesToKey);
}

View File

@@ -3,10 +3,148 @@
#pragma once
#include <QQmlEngine>
#include <Quotient/converters.h>
#include <Quotient/events/eventrelation.h>
#include <Quotient/events/roomevent.h>
#include <Quotient/quotient_common.h>
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<EventContent::Answer> answers;
};
} // namespace EventContent
template<>
inline EventContent::Answer fromJson(const QJsonObject &jo)
{
return EventContent::Answer{fromJson<QString>(jo["id"_L1]), fromJson<QString>(jo["org.matrix.msc1767.text"_L1])};
}
template<>
inline auto toJson(const EventContent::Answer &c)
{
QJsonObject jo;
addParam<IfNotEmpty>(jo, "id"_L1, c.id);
addParam<IfNotEmpty>(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<int>(jo["org.matrix.msc3381.poll.start"_L1]["max_selections"_L1]),
fromJson<QString>(jo["org.matrix.msc3381.poll.start"_L1]["question"_L1]["org.matrix.msc1767.text"_L1]),
fromJson<QList<EventContent::Answer>>(jo["org.matrix.msc3381.poll.start"_L1]["answers"_L1]),
};
}
template<>
inline auto toJson(const EventContent::PollStartContent &c)
{
QJsonObject innerJo;
addParam<IfNotEmpty>(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<IfNotEmpty>(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<IfNotEmpty>(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<PollStartEvent, RoomEvent, EventContent::PollStartContent>
{
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<EventContent::Answer> 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<EventRelation> 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<EventRelation> relatesTo() const;
};
}

View File

@@ -121,7 +121,7 @@ QVariant MessageModel::data(const QModelIndex &idx, int role) const
}
if (role == ContentModelRole) {
if (event->get().is<EncryptedEvent>()) {
if (event->get().is<EncryptedEvent>() || event->get().is<PollStartEvent>()) {
return QVariant::fromValue<MessageContentModel *>(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<PollStartEvent>()) {
m_room->createPollHandler(eventCast<const PollStartEvent>(event));
}
auto eventId = event->id();
auto senderId = event->senderId();
if (eventId.isEmpty()) {

View File

@@ -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<NeoChatRoom> m_room;
@@ -154,5 +154,5 @@ private:
QMap<QString, QSharedPointer<ReadMarkerModel>> m_readMarkerModels;
void createEventObjects(const Quotient::RoomEvent *event, bool isPending = false);
void createEventObjects(const Quotient::RoomEvent *event);
};

View File

@@ -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<event_ptr_tt<RoomEvent>>(job->jsonData())));
Q_EMIT newEventAdded(m_pinnedEvents.back().get(), false);
Q_EMIT newEventAdded(m_pinnedEvents.back().get());
endInsertRows();
});
}

View File

@@ -0,0 +1,76 @@
// SPDX-FileCopyrightText: 2025 James Graham <james.h.graham@protonmail.com>
// 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<PollHandler *>(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<PollHandler *>(this->parent());
if (pollHandler == nullptr) {
qWarning() << "PollAnswerModel created with nullptr parent.";
return 0;
}
return pollHandler->numAnswers();
}
QHash<int, QByteArray> PollAnswerModel::roleNames() const
{
return {
{IdRole, "id"},
{TextRole, "answerText"},
{CountRole, "count"},
{LocalChoiceRole, "localChoice"},
};
}

View File

@@ -0,0 +1,56 @@
// SPDX-FileCopyrightText: 2025 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#pragma once
#include <QAbstractListModel>
#include <QQmlEngine>
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<int, QByteArray> roleNames() const override;
};

View File

@@ -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<PollStartEvent>()) {
m_room->createPollHandler(eventCast<const PollStartEvent>(eventIt->event()));
}
}
refreshEventRoles(eventId, {Qt::DisplayRole});
});

View File

@@ -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;
}
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<QString> &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<EventContent::Answer> 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<PollStartEvent>(content);
}
bool NeoChatRoom::downloadTempFile(const QString &eventId)

View File

@@ -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<QString> &answers);
/**
* @brief Get the full Json data for a given room account data event.

View File

@@ -3,7 +3,9 @@
#include "pollhandler.h"
#include "events/pollevent.h"
#include "neochatroom.h"
#include "pollanswermodel.h"
#include <Quotient/csapi/relations.h>
#include <Quotient/events/roompowerlevelsevent.h>
@@ -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<NeoChatRoom *>(parent());
const auto room = dynamic_cast<NeoChatRoom *>(parent());
auto pollStartEvent = eventCast<const PollStartEvent>(room->getEvent(m_pollStartId).first);
if (pollStartEvent == nullptr) {
return;
}
for (const auto &event : events) {
if (event->is<PollEndEvent>()) {
const auto endEvent = eventCast<const PollEndEvent>(event);
if (endEvent->relatesTo()->eventId != m_pollStartId) {
continue;
}
auto plEvent = room->currentState().get<RoomPowerLevelsEvent>();
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<PollResponseEvent>()) {
handleAnswer(event->contentJson(), event->senderId(), event->originTimestamp());
handleResponse(eventCast<const PollResponseEvent>(event));
}
if (event->contentPart<QJsonObject>("m.relates_to"_L1).contains("rel_type"_L1)
&& event->contentPart<QJsonObject>("m.relates_to"_L1)["rel_type"_L1].toString() == "m.replace"_L1
&& event->contentPart<QJsonObject>("m.relates_to"_L1)["event_id"_L1].toString() == m_pollStartEvent->id()) {
&& event->contentPart<QJsonObject>("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<NeoChatRoom *>(parent());
m_maxVotes = m_pollStartEvent->maxSelections();
auto job = room->connection()->callApi<GetRelatingEventsJob>(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<GetRelatingEventsJob>(room->id(), pollStartEvent->id());
connect(job, &BaseJob::success, this, [this, job, room, pollStartEvent]() {
for (const auto &event : job->chunk()) {
if (event->is<PollEndEvent>()) {
const auto endEvent = eventCast<const PollEndEvent>(event);
if (endEvent->relatesTo()->eventId != m_pollStartId) {
continue;
}
auto plEvent = room->currentState().get<RoomPowerLevelsEvent>();
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<PollResponseEvent>()) {
handleAnswer(event->contentJson(), event->senderId(), event->originTimestamp());
handleResponse(eventCast<const PollResponseEvent>(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<NeoChatRoom *>(parent());
const auto pollStartEvent = eventCast<const PollStartEvent>(room->getEvent(m_pollStartId).first);
if (pollStartEvent == nullptr) {
return;
}
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());
}
}
for (const auto &key : m_answers.keys()) {
if (m_answers[key].toArray().isEmpty()) {
m_answers.remove(key);
}
}
}
Q_EMIT answersChanged();
Q_EMIT selectionsChanged();
}
NeoChatRoom *PollHandler::room() const
{
return dynamic_cast<NeoChatRoom *>(parent());
}
QString PollHandler::question() const
{
if (m_pollStartEvent == nullptr) {
auto room = dynamic_cast<NeoChatRoom *>(parent());
if (room == nullptr) {
return {};
}
return m_pollStartEvent->contentPart<QJsonObject>("org.matrix.msc3381.poll.start"_L1)["question"_L1].toObject()["body"_L1].toString();
}
QJsonArray PollHandler::options() const
{
if (m_pollStartEvent == nullptr) {
auto pollStartEvent = eventCast<const PollStartEvent>(room->getEvent(m_pollStartId).first);
if (pollStartEvent == nullptr) {
return {};
}
return m_pollStartEvent->contentPart<QJsonObject>("org.matrix.msc3381.poll.start"_L1)["answers"_L1].toArray();
return pollStartEvent->question();
}
QJsonObject PollHandler::answers() const
int PollHandler::numAnswers() 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;
}
QString PollHandler::kind() const
{
if (m_pollStartEvent == nullptr) {
auto room = dynamic_cast<NeoChatRoom *>(parent());
if (room == nullptr) {
return {};
}
return m_pollStartEvent->contentPart<QJsonObject>("org.matrix.msc3381.poll.start"_L1)["kind"_L1].toString();
auto pollStartEvent = eventCast<const PollStartEvent>(room->getEvent(m_pollStartId).first);
if (pollStartEvent == nullptr) {
return {};
}
return pollStartEvent->answers().length();
}
Quotient::EventContent::Answer PollHandler::answerAtRow(int row) const
{
auto room = dynamic_cast<NeoChatRoom *>(parent());
if (room == nullptr) {
return {};
}
auto pollStartEvent = eventCast<const PollStartEvent>(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 count;
}
bool PollHandler::checkMemberSelectedId(const QString &memberId, const QString &id) const
{
return m_selections[memberId].contains(id);
}
PollKind::Kind PollHandler::kind() const
{
auto room = dynamic_cast<NeoChatRoom *>(parent());
if (room == nullptr) {
return {};
}
auto pollStartEvent = eventCast<const PollStartEvent>(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<const PollStartEvent>(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<PollResponseEvent>(eventId, ownAnswers);
handleAnswer(response->contentJson(), room->localMember().id(), QDateTime::currentDateTime());
handleResponse(eventCast<const PollResponseEvent>(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"

View File

@@ -12,6 +12,12 @@
#include <Quotient/events/roomevent.h>
#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<QString, QDateTime> m_answerTimestamps;
QJsonObject m_answers;
int m_maxVotes = 1;
void handleResponse(const Quotient::PollResponseEvent *event);
QHash<QString, QDateTime> m_selectionTimestamps;
QHash<QString, QList<QString>> m_selections;
bool m_hasEnded = false;
QDateTime m_endedTimestamp;
QPointer<PollAnswerModel> m_answerModel;
};

156
src/qml/NewPollDialog.qml Normal file
View File

@@ -0,0 +1,156 @@
// SPDX-FileCopyrightText: 2025 James Graham <james.h.graham@protonmail.com>
// 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: ""})
}
}
}

View File

@@ -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
}
}
}