diff --git a/src/chat/chat_attachment.c b/src/chat/chat_attachment.c index 07fbd342..395a03d4 100644 --- a/src/chat/chat_attachment.c +++ b/src/chat/chat_attachment.c @@ -6,6 +6,7 @@ #include "chat_feedback.h" #include "member_sync.h" #include "../utun_instance.h" +#include "../transport_layer/etcp_connections.h" #include "../routing_layer/etcp_router.h" #include "../routing_layer/topo_node_sqlite.h" #include "../media_delivery/media_index.h" diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index 61eeddda..0b5febcf 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -266,6 +266,7 @@ add_executable(vibechat WIN32 src/messagedelegate.cpp src/message_links.cpp src/messagelist.cpp + src/attachment_key_ui.cpp src/messagefeedback.cpp src/accountlist.cpp src/accountdelegate.cpp diff --git a/tools/chatgui/src/attachment_key_ui.cpp b/tools/chatgui/src/attachment_key_ui.cpp new file mode 100644 index 00000000..36435c25 --- /dev/null +++ b/tools/chatgui/src/attachment_key_ui.cpp @@ -0,0 +1,119 @@ +/* Локальная очередь запросов автора. Чтение — GUI, решения — через uasync. */ +#include "messagelist.h" +#include "debug_ui.h" +#include "../db/db_manager.h" +#include "../transport/gui_bridge.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +extern "C" { +#include "chat/chat_attachment.h" +#include "lib/mem.h" +} + +/* Счётчик и список строятся из durable таблицы, включая уже принятые решения. */ +void MessageList::refreshKeyRequests() { + const bool group = !m_currentChannelId.isEmpty() && !isDmChannel(m_currentChannelId); + m_keyRequestsButton->setVisible(group); + if (!group || !m_db || !m_db->isOpen()) return; + const QByteArray channel = m_currentChannelId.toUtf8(); + sqlite3_stmt* st = m_db->prepareOrNull("SELECT count(*) FROM attachment_key_requests WHERE channel=? AND incoming=1 AND state=0"); + int count = 0; + if (st) { + sqlite3_bind_text(st, 1, channel.constData(), -1, SQLITE_STATIC); + if (sqlite3_step(st) == SQLITE_ROW) count = sqlite3_column_int(st, 0); + sqlite3_finalize(st); + } + QString title = QCoreApplication::translate("ChatGui", "Decryption requests"); + m_keyRequestsButton->setText(count ? title + QStringLiteral(" · %1").arg(count) : title); + m_keyRequestsButton->setAccessibleName(m_keyRequestsButton->text()); + if (!m_keyRequestsDialog || !m_keyRequestsList) return; + m_keyRequestsList->clear(); + st = m_db->prepareOrNull("SELECT r.message_id,r.peer,r.state,r.created_at,a.info FROM attachment_key_requests r" + " JOIN attachment_keys a ON a.channel=r.channel AND a.message_id=r.message_id" + " WHERE r.channel=? AND r.incoming=1 ORDER BY r.state<>0,r.created_at DESC,r.peer LIMIT 500"); + if (!st) return; + sqlite3_bind_text(st, 1, channel.constData(), -1, SQLITE_STATIC); + int rc; + while ((rc = sqlite3_step(st)) == SQLITE_ROW) { + const QByteArray message = DbManager::colBlob(st, 0); + const quint64 peer = quint64(sqlite3_column_int64(st, 1)); + const int state = sqlite3_column_int(st, 2); + struct attachment_info info = {}; + const bool valid = !attachment_decode((const uint8_t*)sqlite3_column_blob(st, 4), sqlite3_column_bytes(st, 4), &info); + auto* row = new QTreeWidgetItem(m_keyRequestsList); + row->setText(0, authorName(peer)); + row->setToolTip(0, QStringLiteral("%1").arg(peer, 16, 16, QChar('0'))); + row->setText(1, valid ? QString::fromUtf8(info.name) : QCoreApplication::translate("ChatGui", "Encrypted attachment")); + row->setData(1, Qt::UserRole, message.toHex()); + row->setText(2, QDateTime::fromSecsSinceEpoch(sqlite3_column_int64(st, 3)).toString("dd.MM HH:mm")); + if (state == 0) { + auto* actions = new QWidget(m_keyRequestsList); + auto* layout = new QHBoxLayout(actions); layout->setContentsMargins(0, 0, 0, 0); + for (bool approve : {true, false}) { + auto* button = new QPushButton(QCoreApplication::translate("ChatGui", approve ? "Send key" : "Decline"), actions); + layout->addWidget(button); + connect(button, &QPushButton::clicked, this, [this, channel, message, peer, approve, actions]() { + if (!gui_bridge_get_inst() || !gui_bridge_is_uasync_ready() || message.size() != 32) return; + auto* request = (struct chat_attachment_decision*)u_calloc(1, sizeof(struct chat_attachment_decision)); + if (!request) { GUI_ERROR("attachment keys: decision allocation failed"); return; } + request->inst = gui_bridge_get_inst(); request->peer = peer; request->approve = approve; + snprintf(request->channel, sizeof(request->channel), "%s", channel.constData()); + memcpy(request->message, message.constData(), 32); + actions->setEnabled(false); + gui_bridge_post_uasync_fn(chat_attachment_decision_trampoline, request); + }); + } + m_keyRequestsList->setItemWidget(row, 3, actions); + } else row->setText(3, QCoreApplication::translate("ChatGui", state == 4 ? "Key delivered" : state == 3 ? "Declined" : "Waiting to deliver key")); + } + if (rc != SQLITE_DONE) GUI_ERROR("attachment keys: request list read failed rc=%d", rc); + sqlite3_finalize(st); +} + +/* Обычное немодальное окно: каждый запрос подтверждается своей кнопкой. */ +void MessageList::showKeyRequests() { + if (isDmChannel(m_currentChannelId) || m_currentChannelId.isEmpty()) return; + if (m_keyRequestsDialog) { m_keyRequestsDialog->raise(); m_keyRequestsDialog->activateWindow(); return; } + auto* dialog = new QDialog(this); dialog->setAttribute(Qt::WA_DeleteOnClose); + dialog->setObjectName("attachmentKeyRequestsDialog"); + dialog->setWindowTitle(QCoreApplication::translate("ChatGui", "Decryption requests")); + dialog->resize(820, 400); m_keyRequestsDialog = dialog; + auto* layout = new QVBoxLayout(dialog); + auto* label = new QLabel(QCoreApplication::translate("ChatGui", "Send a key to let this participant open the attachment."), dialog); + label->setWordWrap(true); layout->addWidget(label); + m_keyRequestsList = new QTreeWidget(dialog); m_keyRequestsList->setObjectName("attachmentKeyRequestsList"); + m_keyRequestsList->setHeaderLabels({QCoreApplication::translate("ChatGui", "Participant"), QCoreApplication::translate("ChatGui", "Attachment"), + QCoreApplication::translate("ChatGui", "Requested"), QCoreApplication::translate("ChatGui", "Decision")}); + m_keyRequestsList->setRootIsDecorated(false); m_keyRequestsList->setAlternatingRowColors(true); + m_keyRequestsList->header()->setSectionResizeMode(QHeaderView::ResizeToContents); + m_keyRequestsList->header()->setSectionResizeMode(1, QHeaderView::Stretch); + connect(m_keyRequestsList, &QTreeWidget::itemDoubleClicked, this, [this](QTreeWidgetItem* row, int) { + jumpToMessage(QString::fromLatin1(row->data(1, Qt::UserRole).toByteArray())); + }); + layout->addWidget(m_keyRequestsList); + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Close, dialog); + connect(buttons, &QDialogButtonBox::rejected, dialog, &QDialog::close); layout->addWidget(buttons); + connect(dialog, &QDialog::finished, this, [this]() { m_keyRequestsList = nullptr; m_keyRequestsDialog = nullptr; }); + refreshKeyRequests(); dialog->show(); +} + +/* Секретов в событии нет: UI перечитывает готовые локальные данные после commit. */ +void MessageList::onAttachmentKeysChanged(const uint8_t* data, int len) { + if (!data || len < 2 || data[0] > 31 || (len != data[0] + 1 && len != data[0] + 2)) { + GUI_ERROR("attachment keys: invalid UI event bytes=%d", len); return; + } + const QString channel = QString::fromUtf8((const char*)data + 1, data[0]); + if (channel == m_currentChannelId) { refresh(); refreshKeyRequests(); } + if (len == data[0] + 2 && !data[len - 1]) QMessageBox::warning(this, + QCoreApplication::translate("ChatGui", "Decryption requests"), + QCoreApplication::translate("ChatGui", "Could not save the decision. Check membership and try again.")); +}