Implement viewing and responding to polls

This commit is contained in:
Tobias Fella
2022-10-20 01:22:49 +02:00
parent 425f2a4b85
commit 0e782c4a93
14 changed files with 427 additions and 3 deletions

View File

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

View File

@@ -149,6 +149,9 @@ void Controller::handleNotifications()
{
static bool initial = true;
static QStringList oldNotifications;
if (!m_connection) {
return;
}
auto job = m_connection->callApi<GetNotificationsJob>();
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

View File

@@ -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<DevicesModel>("org.kde.neochat", 1, 0, "DevicesModel");
qmlRegisterType<LinkPreviewer>("org.kde.neochat", 1, 0, "LinkPreviewer");
qmlRegisterType<CompletionModel>("org.kde.neochat", 1, 0, "CompletionModel");
#ifdef QUOTIENT_07
qmlRegisterType<PollHandler>("org.kde.neochat", 1, 0, "PollHandler");
#endif
qmlRegisterUncreatableType<RoomMessageEvent>("org.kde.neochat", 1, 0, "RoomMessageEvent", "ENUM");
qmlRegisterUncreatableType<PushNotificationState>("org.kde.neochat", 1, 0, "PushNotificationState", "ENUM");
qmlRegisterUncreatableType<NeoChatRoomType>("org.kde.neochat", 1, 0, "NeoChatRoomType", "ENUM");

View File

@@ -13,6 +13,9 @@
#include <events/simplestateevents.h>
#include <user.h>
#ifdef QUOTIENT_07
#include "pollevent.h"
#endif
#include "stickerevent.h"
#include <QDebug>
@@ -496,6 +499,15 @@ QVariant MessageEventModel::data(const QModelIndex &idx, int role) const
if (is<const EncryptedEvent>(evt)) {
return DelegateType::Encrypted;
}
#ifdef QUOTIENT_07
if (is<PollStartEvent>(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<const StickerEvent>(&evt)) {
return QVariant::fromValue(e->image().originalJson);
}
return evt.contentJson();
}
if (role == HighlightRole) {

View File

@@ -25,6 +25,7 @@ public:
State,
Encrypted,
ReadMarker,
Poll,
Other,
};
Q_ENUM(DelegateType);

View File

@@ -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<SetTypingJob>(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<const RoomMessageEvent>(event)) {
return lastEvent;
}
#ifdef QUOTIENT_07
if (auto lastEvent = eventCast<const PollStartEvent>(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

View File

@@ -5,11 +5,13 @@
#include <room.h>
#include <QCache>
#include <QObject>
#include <QTextCursor>
#include <qcoro/task.h>
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<Mention> m_mentions;
QString m_savedText;
#ifdef QUOTIENT_07
QCache<QString, PollHandler> m_polls;
#endif
private Q_SLOTS:
void countChanged();

38
src/pollevent.cpp Normal file
View File

@@ -0,0 +1,38 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <fella@posteo.de>
// 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}}}}))
{
}

35
src/pollevent.h Normal file
View File

@@ -0,0 +1,35 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: LGPL-2.0-or-later
#pragma once
#include <events/eventcontent.h>
#include <events/roomevent.h>
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);
};
}

165
src/pollhandler.cpp Normal file
View File

@@ -0,0 +1,165 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: LGPL-2.0-or-later
#include "pollhandler.h"
#include "neochatroom.h"
#include "pollevent.h"
#include <algorithm>
#include <csapi/relations.h>
#include <events/roompowerlevelsevent.h>
#include <user.h>
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<PollEndEvent>()) {
auto pl = m_room->getCurrentState<RoomPowerLevelsEvent>();
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<PollResponseEvent>()) {
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<const PollStartEvent>(&**m_room->findInTimeline(m_pollStartEventId))->maxSelections();
auto job = m_room->connection()->callApi<GetRelatingEventsJob>(m_room->id(), m_pollStartEventId);
connect(job, &BaseJob::success, this, [this, job]() {
for (const auto &event : job->chunk()) {
if (event->is<PollEndEvent>()) {
auto pl = m_room->getCurrentState<RoomPowerLevelsEvent>();
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<PollResponseEvent>()) {
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();
}

57
src/pollhandler.h Normal file
View File

@@ -0,0 +1,57 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: LGPL-2.0-or-later
#pragma once
#include <QJsonObject>
#include <QObject>
#include <QPair>
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<QString, QDateTime> m_answerTimestamps;
QJsonObject m_answers;
int m_maxVotes = 1;
bool m_hasEnded = false;
QDateTime m_endedTimestamp;
};

View File

@@ -70,6 +70,11 @@ DelegateChooser {
delegate: ReadMarkerDelegate {}
}
DelegateChoice {
roleValue: MessageEventModel.Poll
delegate: PollDelegate {}
}
DelegateChoice {
roleValue: MessageEventModel.Other
delegate: Item {}

View File

@@ -0,0 +1,52 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <fella@posteo.de>
// 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
}
}
}

View File

@@ -43,6 +43,7 @@
<file alias="EventDelegate.qml">qml/Component/Timeline/EventDelegate.qml</file>
<file alias="MessageDelegate.qml">qml/Component/Timeline/MessageDelegate.qml</file>
<file alias="ReadMarkerDelegate.qml">qml/Component/Timeline/ReadMarkerDelegate.qml</file>
<file alias="PollDelegate.qml">qml/Component/Timeline/PollDelegate.qml</file>
<file alias="MimeComponent.qml">qml/Component/Timeline/MimeComponent.qml</file>
<file alias="LoginStep.qml">qml/Component/Login/LoginStep.qml</file>
<file alias="Login.qml">qml/Component/Login/Login.qml</file>