From cf869770fb733a0dfea3acd4f7ee6123b30a382a Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 21:39:13 +0200 Subject: [PATCH] Add desktop poll creation, voting and live result dialogs --- tools/chatgui/CMakeLists.txt | 1 + tools/chatgui/src/messagefeedback.cpp | 194 ++++++++++++++++++++++++++ tools/chatgui/src/messagelist.cpp | 19 ++- 3 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 tools/chatgui/src/messagefeedback.cpp diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index 4e30fe4b..f543e779 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -144,6 +144,7 @@ add_executable(vibechat src/messagedelegate.cpp src/message_links.cpp src/messagelist.cpp + src/messagefeedback.cpp src/accountlist.cpp src/accountdelegate.cpp src/flagpainter.cpp diff --git a/tools/chatgui/src/messagefeedback.cpp b/tools/chatgui/src/messagefeedback.cpp new file mode 100644 index 00000000..dce87940 --- /dev/null +++ b/tools/chatgui/src/messagefeedback.cpp @@ -0,0 +1,194 @@ +#include "messagelist.h" +#include "messagedelegate.h" +#include "chatview.h" +#include "debug_ui.h" +#include "../db/db_manager.h" +#include "../transport/gui_bridge.h" +#include "chat/chat_poll.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include "../../../lib/mem.h" +} + +static QString feedbackText(const char* source) { return QCoreApplication::translate("ChatGui", source); } + +/* Итог для баббла и открытого диалога; строковые поля показываем как обычный текст. */ +static QString pollSummary(const QJsonObject& poll) { + QString text = poll.value("question").toString(); + for (const auto& value : poll.value("options").toArray()) { + const auto option = value.toObject(); + text += QStringLiteral("\n%1 — %2 (%3%)").arg(option.value("text").toString()) + .arg(option.value("count").toInt()).arg(option.value("percent").toDouble(), 0, 'f', 1); + } + text += QStringLiteral("\n%1: %2").arg(feedbackText("Participants")).arg(poll.value("voters").toInt()); + return text; +} + +/* Команды владеют только C-данными; QObject остаются в GUI-потоке. */ +void MessageList::sendFeedback(QJsonObject command, bool userAction) { + if (!gui_bridge_get_inst()) { GUI_ERROR("feedback: core unavailable"); return; } + if (!command.contains("ch")) command["ch"] = m_currentChannelId; + QByteArray json = QJsonDocument(command).toJson(QJsonDocument::Compact); + auto* req = static_cast(u_malloc(sizeof(chat_feedback_request) + json.size() + 1)); + if (!req) { GUI_ERROR("feedback: command allocation failed bytes=%d", json.size()); return; } + req->inst = gui_bridge_get_inst(); req->request_id = userAction ? ++m_feedbackSequence : 0; + memcpy(req + 1, json.constData(), json.size()); reinterpret_cast(req + 1)[json.size()] = 0; + gui_bridge_post_uasync_fn(chat_feedback_command_trampoline, req); +} + +void MessageList::setupFeedback(QStandardItem* item, const MessageRow& message, const QString& channel) { + item->setData(QString::fromLatin1(message.messageId.toHex()), MsgMessageKeyRole); + item->setData(message.timestamp, MsgRawTimestampRole); item->setData(channel, MsgChannelIdRole); + if (message.contentType == "poll") { + struct chat_poll poll; + QByteArray wire = QByteArray::fromBase64(message.data.mid(4), QByteArray::Base64UrlEncoding); + if (chat_poll_decode(reinterpret_cast(wire.constData()), wire.size(), &poll) == 0) + item->setData(QString::fromUtf8(poll.question), MsgTextRole); + else item->setData(feedbackText("Invalid poll"), MsgTextRole); + } + if (message.messageId.size() == 32) sendFeedback({{"cmd", "feedback"}, {"ch", channel}, + {"message_id", QString::fromLatin1(message.messageId.toHex())}}, false); +} + +void MessageList::onFeedbackChanged(const uint8_t* data, int len) { + if (!data || len < 34 || len != data[0] + 34) { GUI_ERROR("feedback: invalid change event bytes=%d", len); return; } + QString channel = QString::fromUtf8(reinterpret_cast(data + 1), data[0]); + if (channel != m_currentChannelId) return; + QString id = QString::fromLatin1(QByteArray(reinterpret_cast(data + 1 + data[0]), 32).toHex()); + sendFeedback({{"cmd", "feedback"}, {"message_id", id}}, false); +} + +void MessageList::onFeedbackResult(const uint8_t* data, int len) { + if (!data || len < 9) { GUI_ERROR("feedback: invalid result event bytes=%d", len); return; } + quint64 request; memcpy(&request, data, 8); + QJsonObject state = QJsonDocument::fromJson(QByteArray(reinterpret_cast(data + 9), len - 9)).object(); + if (!data[8]) { + GUI_ERROR("feedback: command failed request=%llu reason=%s", (unsigned long long)request, qPrintable(state.value("error").toString())); + if (request) { m_pendingPoll.clear(); QMessageBox::warning(this, feedbackText("Polls and reactions"), state.value("error").toString()); } + return; + } + if (state.value("ch").toString() != m_currentChannelId) return; + applyFeedback(state); +} + +void MessageList::applyFeedback(const QJsonObject& state) { + QString id = state.value("message_id").toString(); m_feedback[id] = state; + QVariantList reactions; + for (const auto& value : state.value("reactions").toArray()) reactions.append(value.toObject().toVariantMap()); + auto poll = state.value("poll").toObject(); + for (int row = 0; row < m_model->rowCount(); row++) { + auto* item = m_model->item(row); + if (item->data(MsgMessageKeyRole).toString() != id) continue; + item->setData(reactions, MsgReactionsRole); + if (!poll.isEmpty()) item->setData(pollSummary(poll), MsgTextRole); + } + if (m_pollDialog && m_pollDialogMessage == id) { + auto* results = m_pollDialog->findChild("pollResults"); + if (results) { + QString text = pollSummary(poll); + const auto options = poll.value("options").toArray(); + for (const auto& value : poll.value("answers").toArray()) { + auto row = value.toObject(), answer = row.value("answer").toObject(); QStringList parts; + for (const auto& choice : answer.value("choices").toArray()) + if (choice.toInt() >= 0 && choice.toInt() < options.size()) parts << options[choice.toInt()].toObject().value("text").toString(); + if (!answer.value("custom").toString().isEmpty()) parts << answer.value("custom").toString(); + text += QStringLiteral("\n%1: %2").arg(authorName(row.value("node_id").toString().toULongLong(nullptr, 0)), parts.join(", ")); + } + results->setPlainText(text); + } + } + if (m_pendingPoll == id && !poll.isEmpty()) { m_pendingPoll.clear(); showPoll(id); } +} + +void MessageList::createPoll() { + if (m_currentChannelId.isEmpty() || isDmChannel(m_currentChannelId)) return; + QDialog dialog(this); dialog.setWindowTitle(feedbackText("Create poll")); auto* layout = new QVBoxLayout(&dialog); + auto* question = new QPlainTextEdit(&dialog); question->setPlaceholderText(feedbackText("Question")); question->setMaximumHeight(70); + auto* options = new QPlainTextEdit(&dialog); options->setPlaceholderText(feedbackText("One option per line")); + auto* mode = new QComboBox(&dialog); mode->addItem(feedbackText("Single choice")); mode->addItem(feedbackText("Multiple choices")); + auto* custom = new QCheckBox(feedbackText("Allow a custom answer"), &dialog); + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dialog); + layout->addWidget(question); layout->addWidget(options); layout->addWidget(mode); layout->addWidget(custom); layout->addWidget(buttons); + connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); + connect(buttons, &QDialogButtonBox::accepted, &dialog, [&, this]() { + const QString title = question->toPlainText().trimmed(); QJsonArray values; QSet unique; + int bytes = 6 + title.toUtf8().size(); bool valid = !title.isEmpty() && title.toUtf8().size() <= CHAT_POLL_QUESTION_MAX; + for (const auto& line : options->toPlainText().split('\n')) { + QString text = line.trimmed(); if (text.isEmpty()) continue; + if (unique.contains(text) || text.toUtf8().size() > CHAT_POLL_OPTION_MAX) valid = false; + unique.insert(text); values.append(text); bytes += 2 + text.toUtf8().size(); + } + if (!valid || values.size() < 2 || values.size() > CHAT_POLL_OPTIONS_MAX || bytes > CHAT_POLL_WIRE_MAX) { + QMessageBox::warning(&dialog, feedbackText("Create poll"), feedbackText("Enter a question and distinct options within the size limit.")); return; + } + sendFeedback({{"cmd", "poll_create"}, {"question", title}, {"options", values}, + {"mode", mode->currentIndex() ? "multiple" : "single"}, {"allow_custom_answer", custom->isChecked()}}); + dialog.accept(); + }); + dialog.resize(420, 360); dialog.exec(); +} + +void MessageList::showPoll(const QString& messageId) { + if (m_pollDialog) { m_pollDialog->raise(); return; } + auto state = m_feedback.value(messageId), poll = state.value("poll").toObject(); + if (poll.isEmpty()) { m_pendingPoll = messageId; sendFeedback({{"cmd", "feedback"}, {"message_id", messageId}}); return; } + QDialog dialog(this); dialog.setWindowTitle(feedbackText("Vote / results")); + m_pollDialog = &dialog; m_pollDialogMessage = messageId; auto* layout = new QVBoxLayout(&dialog); + auto* title = new QLabel(poll.value("question").toString(), &dialog); title->setTextFormat(Qt::PlainText); title->setWordWrap(true); + layout->addWidget(title); + const bool multiple = poll.value("mode").toString() == "multiple"; + auto* group = new QButtonGroup(&dialog); group->setExclusive(!multiple); + QList choices; QSet selected; + for (const auto& value : poll.value("mine").toObject().value("choices").toArray()) selected.insert(value.toInt()); + auto values = poll.value("options").toArray(); + for (int i = 0; i < values.size(); i++) { + QString text = values[i].toObject().value("text").toString(); + QAbstractButton* choice = multiple ? static_cast(new QCheckBox(text, &dialog)) + : static_cast(new QRadioButton(text, &dialog)); + group->addButton(choice, i); choice->setChecked(selected.contains(i)); layout->addWidget(choice); choices.append(choice); + } + auto* own = new QPlainTextEdit(&dialog); own->setMaximumHeight(80); own->setPlaceholderText(feedbackText("Your answer")); + own->setPlainText(poll.value("mine").toObject().value("custom").toString()); + QRadioButton* other = nullptr; + if (poll.value("allow_custom_answer").toBool()) { + if (!multiple) { + other = new QRadioButton(feedbackText("Your answer"), &dialog); group->addButton(other); + layout->addWidget(other); connect(other, &QRadioButton::toggled, own, &QWidget::setEnabled); + other->setChecked(!own->toPlainText().isEmpty()); own->setEnabled(other->isChecked()); + } + layout->addWidget(own); + } else own->hide(); + auto* results = new QPlainTextEdit(&dialog); results->setObjectName("pollResults"); results->setReadOnly(true); + layout->addWidget(results); + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Save | QDialogButtonBox::Cancel, &dialog); + auto* withdraw = buttons->addButton(feedbackText("Withdraw vote"), QDialogButtonBox::ActionRole); + layout->addWidget(buttons); + connect(withdraw, &QPushButton::clicked, &dialog, [&, this]() { + sendFeedback({{"cmd", "vote"}, {"message_id", messageId}, {"choices", QJsonArray{}}, {"custom", ""}}); dialog.accept(); + }); + connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); + connect(buttons, &QDialogButtonBox::accepted, &dialog, [&, this]() { + QJsonArray ids; for (int i = 0; i < choices.size(); i++) if (choices[i]->isChecked()) ids.append(i); + QString text = poll.value("allow_custom_answer").toBool() && (multiple || (other && other->isChecked())) + ? own->toPlainText().trimmed() : QString(); + if (text.toUtf8().size() > CHAT_POLL_CUSTOM_MAX || (other && other->isChecked() && text.isEmpty())) { + QMessageBox::warning(&dialog, feedbackText("Your answer"), feedbackText("Enter a shorter nonempty answer.")); return; + } + sendFeedback({{"cmd", "vote"}, {"message_id", messageId}, {"choices", ids}, {"custom", text}}); dialog.accept(); + }); + applyFeedback(state); dialog.resize(450, 560); dialog.exec(); m_pollDialog = nullptr; m_pollDialogMessage.clear(); +} diff --git a/tools/chatgui/src/messagelist.cpp b/tools/chatgui/src/messagelist.cpp index 302d3871..6cebe3e2 100644 --- a/tools/chatgui/src/messagelist.cpp +++ b/tools/chatgui/src/messagelist.cpp @@ -41,6 +41,7 @@ static std::vector> s_icons; extern "C" { #include "../transport/gui_bridge.h" #include "chat/chat_core.h" +#include "chat/chat_poll.h" #include "dm/dm_core.h" #include "dm/dm_media.h" #include "video/video.h" @@ -374,6 +375,9 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent m_channelTitle->setStyleSheet("font-weight: bold; font-size: 14px;"); m_channelTitle->setText(QCoreApplication::translate("ChatGui", "Channel")); hl->addWidget(m_channelTitle, 1); + m_pollBtn = new QPushButton(QCoreApplication::translate("ChatGui", "Create poll"), header); + hl->addWidget(m_pollBtn); + connect(m_pollBtn, &QPushButton::clicked, this, &MessageList::createPoll); m_talkingLabel = new QLabel(header); m_talkingLabel->setStyleSheet("color: #27ae60; font-size: 12px;"); @@ -663,7 +667,9 @@ void MessageList::loadChannel(const QString& channelId) { m_model->clear(); m_avatarCache.clear(); + if (m_currentChannelId != channelId) { m_feedback.clear(); m_pendingPoll.clear(); } m_currentChannelId = channelId; + m_pollBtn->setVisible(!isDmChannel(channelId)); clearDmCallRoute(); m_callBtn->setVisible(isDmChannel(channelId)); m_inviteBtn->setVisible(isDmChannel(channelId)); @@ -739,6 +745,7 @@ void MessageList::loadChannel(const QString& channelId) { setVoiceMessageRoles(item, m.data, m.contentType, m_db ? m_db->dbPath() + "/media" : QString(), channelId, m.localAttrs); m_model->appendRow(item); + setupFeedback(item, m, channelId); } if (sameChannel) { @@ -780,12 +787,9 @@ void MessageList::refresh() { if (!m_db || !m_db->isOpen()) return; - qint64 maxId = 0; - for (int r = 0; r < m_model->rowCount(); r++) - if (m_model->item(r)->data(MsgIdRole).toLongLong() > maxId) - maxId = m_model->item(r)->data(MsgIdRole).toLongLong(); - - auto newMsgs = m_db->getMessagesSince(m_currentChannelId, maxId); + QSet present; + for (int row = 0; row < m_model->rowCount(); row++) present.insert(m_model->item(row)->data(MsgMessageKeyRole).toString()); + auto newMsgs = m_db->getMessages(m_currentChannelId, 200); if (newMsgs.isEmpty()) return; m_localNicks = m_db->getLocalNicknames(m_currentChannelId); @@ -794,6 +798,7 @@ void MessageList::refresh() { bool wasAtBottom = sb && (sb->value() >= sb->maximum()); for (const auto& m : newMsgs) { + if (present.contains(QString::fromLatin1(m.messageId.toHex()))) continue; AccountRow acc = m_db->getAccount(m.authorNodeId); QString author = authorName(m.authorNodeId); QString letter = acc.avatarLetter.isEmpty() ? author.mid(0, 1) : acc.avatarLetter; @@ -815,10 +820,12 @@ void MessageList::refresh() { setVoiceMessageRoles(item, m.data, m.contentType, m_db ? m_db->dbPath() + "/media" : QString(), m_currentChannelId, m.localAttrs); m_model->appendRow(item); + setupFeedback(item, m, m_currentChannelId); while (m_model->rowCount() > 200) m_model->removeRow(0); } + m_model->setSortRole(MsgRawTimestampRole); m_model->sort(0); if (wasAtBottom) m_view->scrollToBottom(); m_readDebounce->start(); }