From 74bcf9140797c9aafdd5abdbf7df9f0ff48233fa Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 27 Oct 2026 11:19:00 +0100 Subject: [PATCH] Own channel settings requests and guard nested dialogs --- tools/chatgui/src/channelsettingsdialog.cpp | 136 +++++++++++++------- tools/chatgui/src/channelsettingsdialog.h | 24 +++- 2 files changed, 111 insertions(+), 49 deletions(-) diff --git a/tools/chatgui/src/channelsettingsdialog.cpp b/tools/chatgui/src/channelsettingsdialog.cpp index 6711e891..780e3e9e 100644 --- a/tools/chatgui/src/channelsettingsdialog.cpp +++ b/tools/chatgui/src/channelsettingsdialog.cpp @@ -14,6 +14,7 @@ extern "C" { #include #include #include +#include #include ChannelSettingsDialog::ChannelSettingsDialog(const QString& channelId, DbManager* db, QWidget* parent) @@ -21,6 +22,10 @@ ChannelSettingsDialog::ChannelSettingsDialog(const QString& channelId, DbManager , m_channelId(channelId) , m_db(db) { + const quint64 group = channelId.toULongLong(); + m_validChannel = group && QString::number(group) == channelId; + if (!m_validChannel) DEBUG_WARN(DEBUG_CATEGORY_CHAT, "channel settings: invalid channel ID bytes=%d", int(channelId.toUtf8().size())); + if (db) connect(db, &QObject::destroyed, this, &QDialog::reject); setWindowTitle(QCoreApplication::translate("ChatGui", "Channel Settings")); setMinimumWidth(340); setModal(true); @@ -38,7 +43,7 @@ ChannelSettingsDialog::ChannelSettingsDialog(const QString& channelId, DbManager chIdLabel->setWordWrap(true); layout->addWidget(chIdLabel); - if (auto* avatars = GroupAvatarManager::instance()) { + if (QPointer avatars = GroupAvatarManager::instance()) { auto* preview = new QLabel(this); preview->setFixedSize(64, 64); layout->addWidget(preview); auto* buttons = new QHBoxLayout; auto* upload = new QPushButton(QCoreApplication::translate("ChatGui", "Upload group avatar"), this); @@ -47,11 +52,13 @@ ChannelSettingsDialog::ChannelSettingsDialog(const QString& channelId, DbManager bool hasKey = false; if (db && db->isOpen()) for (const auto& row : db->getChannels()) if (row.channelId == channelId) hasKey = row.ed25519_privkey.size() == 32; - upload->setEnabled(hasKey); remove->setEnabled(hasKey); + upload->setEnabled(m_validChannel && hasKey); remove->setEnabled(m_validChannel && hasKey); + connect(avatars, &QObject::destroyed, upload, [upload, remove] { upload->setEnabled(false); remove->setEnabled(false); }); auto* info = new QLabel(QCoreApplication::translate("ChatGui", "Group avatar: up to 16 KiB. Changes apply immediately and require the group key."), this); info->setWordWrap(true); auto* result = new QLabel(this); result->setWordWrap(true); buttons->addWidget(upload); buttons->addWidget(remove); layout->addLayout(buttons); layout->addWidget(info); layout->addWidget(result); auto refresh = [avatars, preview, channelId] { + if (!avatars) return; const QImage image = avatars->image(channelId); if (image.isNull()) { preview->setPixmap(QPixmap()); preview->setText(QCoreApplication::translate("ChatGui", "No avatar")); } else preview->setPixmap(QPixmap::fromImage(image).scaled(64, 64, Qt::KeepAspectRatio, Qt::SmoothTransformation)); @@ -61,11 +68,19 @@ ChannelSettingsDialog::ChannelSettingsDialog(const QString& channelId, DbManager if (channel == channelId) { result->setText(message); refresh(); } }); connect(upload, &QPushButton::clicked, this, [this, avatars, channelId] { - const QString file = QFileDialog::getOpenFileName(this, QCoreApplication::translate("ChatGui", "Choose avatar"), {}, - QCoreApplication::translate("ChatGui", "Images (*.png *.jpg *.jpeg *.webp)")); - if (!file.isEmpty()) avatars->upload(channelId, file); + if (!avatars) return; + QPointer self(this); + QPointer chooser = new QFileDialog(this, QCoreApplication::translate("ChatGui", "Choose avatar")); + auto release = qScopeGuard([&] { delete chooser.data(); }); + chooser->setNameFilter(QCoreApplication::translate("ChatGui", "Images (*.png *.jpg *.jpeg *.webp)")); + chooser->setFileMode(QFileDialog::ExistingFile); + connect(avatars, &QObject::destroyed, chooser, &QDialog::reject); + if (m_db) connect(m_db, &QObject::destroyed, chooser, &QDialog::reject); + if (chooser->exec() != QDialog::Accepted || !self || !chooser || !avatars) return; + const auto files = chooser->selectedFiles(); + if (!files.isEmpty()) avatars->upload(channelId, files.first()); }); - connect(remove, &QPushButton::clicked, this, [avatars, channelId] { avatars->remove(channelId); }); refresh(); + connect(remove, &QPushButton::clicked, this, [avatars, channelId] { if (avatars) avatars->remove(channelId); }); refresh(); } layout->addSpacing(8); @@ -77,8 +92,11 @@ ChannelSettingsDialog::ChannelSettingsDialog(const QString& channelId, DbManager layout->addWidget(m_autoPlayVoice); m_soundOnMessage = new QCheckBox(QCoreApplication::translate("ChatGui", "Play sound on incoming message"), this); + m_soundOnMessage->setChecked(true); m_soundOnMessage->setToolTip(QCoreApplication::translate("ChatGui", "Play a notification sound when a message arrives in this channel.")); layout->addWidget(m_soundOnMessage); + m_status = new QLabel(this); m_status->setObjectName("channelSettingsStatus"); + m_status->setTextFormat(Qt::PlainText); m_status->setWordWrap(true); layout->addWidget(m_status); layout->addStretch(); @@ -88,7 +106,7 @@ ChannelSettingsDialog::ChannelSettingsDialog(const QString& channelId, DbManager deleteBtn->setObjectName("deleteChannel"); deleteBtn->setAutoDefault(false); deleteBtn->setStyleSheet("QPushButton { color: #EF5350; padding: 6px 12px; }"); - deleteBtn->setEnabled(m_db && m_db->isOpen() && channelId.toULongLong() != 0 && gui_bridge_is_uasync_ready()); + deleteBtn->setEnabled(m_validChannel && m_db && m_db->isOpen() && gui_bridge_is_uasync_ready()); connect(deleteBtn, &QPushButton::clicked, this, &ChannelSettingsDialog::onDelete); btnLayout->addWidget(deleteBtn); btnLayout->addStretch(); @@ -103,6 +121,7 @@ ChannelSettingsDialog::ChannelSettingsDialog(const QString& channelId, DbManager btnLayout->addWidget(cancelBtn); auto* saveBtn = new QPushButton(QCoreApplication::translate("ChatGui", "Save"), this); + saveBtn->setEnabled(m_validChannel && m_db && m_db->isOpen()); saveBtn->setMinimumWidth(90); saveBtn->setDefault(true); saveBtn->setStyleSheet( @@ -121,16 +140,30 @@ ChannelSettingsDialog::ChannelSettingsDialog(const QString& channelId, DbManager QString soundKey = QStringLiteral("ch_%1_sound_on_message").arg(channelId); m_soundOnMessage->setChecked(m_db->getUiStateInt(soundKey, 1) != 0); } + DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "channel settings opened: channel=%s valid=%d db=%d autoplay=%d sound=%d", + qPrintable(channelId), m_validChannel, bool(m_db && m_db->isOpen()), m_autoPlayVoice->isChecked(), m_soundOnMessage->isChecked()); } /* Как в Android: удаляются только локальные данные; ядро уведомит GUI по завершении. */ void ChannelSettingsDialog::onDelete() { + if (!m_validChannel || !m_db || !m_db->isOpen()) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT, "channel delete: unavailable channel=%s", qPrintable(m_channelId)); + m_status->setText(QCoreApplication::translate("ChatGui", "Channel is unavailable.")); return; + } QString name = m_channelId; - if (m_db && m_db->isOpen()) for (const auto& channel : m_db->getChannels()) - if (channel.channelId == m_channelId) { name = channel.name; break; } + bool found = false; + for (const auto& channel : m_db->getChannels()) + if (channel.channelId == m_channelId) { name = channel.name; found = true; break; } + if (!found) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT, "channel delete: channel removed before confirmation channel=%s", qPrintable(m_channelId)); + m_status->setText(QCoreApplication::translate("ChatGui", "Channel is unavailable.")); return; + } + QPointer self(this); QMessageBox confirmation(QMessageBox::Warning, QCoreApplication::translate("ChatGui", "Delete channel"), QCoreApplication::translate("ChatGui", "Delete “%1” and all its local messages and files? Other members keep the channel.") - .arg(name), QMessageBox::NoButton, this); + .arg(name), QMessageBox::NoButton); + connect(this, &QObject::destroyed, &confirmation, &QDialog::reject); + connect(m_db, &QObject::destroyed, &confirmation, &QDialog::reject); confirmation.setTextFormat(Qt::PlainText); auto* remove = confirmation.addButton(QCoreApplication::translate("ChatGui", "Delete"), QMessageBox::DestructiveRole); remove->setObjectName("confirmDeleteChannel"); @@ -138,57 +171,72 @@ void ChannelSettingsDialog::onDelete() { confirmation.setDefaultButton(cancel); confirmation.setEscapeButton(cancel); confirmation.exec(); - if (confirmation.clickedButton() != remove) return; + if (!self || confirmation.clickedButton() != remove) return; + found = false; + if (m_db && m_db->isOpen()) for (const auto& channel : m_db->getChannels()) + if (channel.channelId == m_channelId) { found = true; break; } + if (!found) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT, "channel delete: channel removed during confirmation channel=%s", qPrintable(m_channelId)); + m_status->setText(QCoreApplication::translate("ChatGui", "Channel is unavailable.")); return; + } auto* inst = gui_bridge_get_inst(); if (!inst || !gui_bridge_is_uasync_ready()) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "channel delete: core unavailable channel=%s", qPrintable(m_channelId)); - QMessageBox::warning(this, confirmation.windowTitle(), QCoreApplication::translate("ChatGui", "The core is not running.")); + m_status->setText(QCoreApplication::translate("ChatGui", "The core is not running.")); return; } const QByteArray channel = m_channelId.toUtf8(); auto* request = static_cast(u_calloc(1, sizeof(chat_delete_channel_req))); if (!request) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "channel delete: request allocation failed channel=%s", channel.constData()); - QMessageBox::warning(this, confirmation.windowTitle(), QCoreApplication::translate("ChatGui", "Failed to delete channel.")); + m_status->setText(QCoreApplication::translate("ChatGui", "Failed to delete channel.")); return; } request->inst = inst; snprintf(request->ch_id, sizeof(request->ch_id), "%s", channel.constData()); - DEBUG_INFO(DEBUG_CATEGORY_CHAT, "channel delete confirmed: channel=%s scope=local", channel.constData()); - gui_bridge_post_uasync_fn(chat_core_delete_channel_trampoline, request); + if (gui_bridge_post_uasync_owned_fn(chat_core_delete_channel_trampoline, request) < 0) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "channel delete: request rejected channel=%s", channel.constData()); + u_free(request); m_status->setText(QCoreApplication::translate("ChatGui", "Failed to delete channel.")); return; + } + DEBUG_INFO(DEBUG_CATEGORY_CHAT, "channel delete queued: channel=%s scope=local", channel.constData()); accept(); } void ChannelSettingsDialog::onSave() { - auto saveKey = [&](const char* suffix, int val) { - QString key = QStringLiteral("ch_%1_%2").arg(m_channelId, QString::fromUtf8(suffix)); - QByteArray keyBA = key.toUtf8(); - QByteArray valBA = QByteArray::number(val); - size_t klen = (size_t)keyBA.size(), vlen = (size_t)valBA.size(); - size_t total = klen + 1 + vlen + 1; - if (total <= 256) { - struct save_ui_state_arg* arg = (struct save_ui_state_arg*)u_malloc(sizeof(struct save_ui_state_arg)); - if (!arg) return; - arg->inst = gui_bridge_get_inst(); - memcpy(arg->data, keyBA.constData(), klen + 1); - memcpy(arg->data + klen + 1, valBA.constData(), vlen + 1); - gui_bridge_post_uasync_fn(chat_core_save_ui_state_trampoline, arg); - } - }; - saveKey("auto_play_voice", m_autoPlayVoice->isChecked() ? 1 : 0); - - /* sound_on_message: сохранить + разослать пирам (троттлинг db_sync на сервере) */ - { - int on = m_soundOnMessage->isChecked() ? 1 : 0; - QByteArray chIdBA = m_channelId.toUtf8(); - struct chat_sound_arg* arg = (struct chat_sound_arg*)u_malloc(sizeof(struct chat_sound_arg)); - if (arg) { - memset(arg, 0, sizeof(*arg)); - arg->inst = gui_bridge_get_inst(); - strncpy(arg->ch_id, chIdBA.constData(), sizeof(arg->ch_id) - 1); - arg->on = on; - gui_bridge_post_uasync_fn(chat_core_set_sound_on_message_trampoline, arg); - } + if (!m_validChannel || !m_db || !m_db->isOpen()) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT, "channel settings save: unavailable channel=%s", qPrintable(m_channelId)); + m_status->setText(QCoreApplication::translate("ChatGui", "Channel is unavailable.")); return; } + auto* inst = gui_bridge_get_inst(); + if (!inst || !gui_bridge_is_uasync_ready()) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT, "channel settings save: core unavailable channel=%s", qPrintable(m_channelId)); + m_status->setText(QCoreApplication::translate("ChatGui", "The core is not running.")); return; + } + auto* voice = static_cast(u_calloc(1, sizeof(save_ui_state_arg))); + auto* sound = static_cast(u_calloc(1, sizeof(chat_sound_arg))); + auto release = qScopeGuard([&] { u_free(voice); u_free(sound); }); + if (!voice || !sound) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "channel settings save: allocation failed channel=%s voice=%d sound=%d", + qPrintable(m_channelId), bool(voice), bool(sound)); + m_status->setText(QCoreApplication::translate("ChatGui", "Failed to save channel settings. Please try again.")); return; + } + voice->inst = sound->inst = inst; + const QByteArray key = QStringLiteral("ch_%1_auto_play_voice").arg(m_channelId).toUtf8(); + memcpy(voice->data, key.constData(), size_t(key.size())); + voice->data[key.size() + 1] = m_autoPlayVoice->isChecked() ? '1' : '0'; + const QByteArray channel = m_channelId.toUtf8(); + memcpy(sound->ch_id, channel.constData(), size_t(channel.size())); sound->on = m_soundOnMessage->isChecked(); + if (gui_bridge_post_uasync_owned_fn(chat_core_save_ui_state_trampoline, voice) < 0) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "channel settings save: autoplay request rejected channel=%s", channel.constData()); + m_status->setText(QCoreApplication::translate("ChatGui", "Failed to save channel settings. Please try again.")); return; + } + voice = nullptr; + if (gui_bridge_post_uasync_owned_fn(chat_core_set_sound_on_message_trampoline, sound) < 0) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "channel settings save: sound request rejected after autoplay accepted channel=%s", channel.constData()); + m_status->setText(QCoreApplication::translate("ChatGui", "Failed to save channel settings. Please try again.")); return; + } + sound = nullptr; + DEBUG_INFO(DEBUG_CATEGORY_CHAT, "channel settings queued: channel=%s autoplay=%d sound=%d", channel.constData(), + m_autoPlayVoice->isChecked(), m_soundOnMessage->isChecked()); accept(); } diff --git a/tools/chatgui/src/channelsettingsdialog.h b/tools/chatgui/src/channelsettingsdialog.h index 60239b69..b641e80a 100644 --- a/tools/chatgui/src/channelsettingsdialog.h +++ b/tools/chatgui/src/channelsettingsdialog.h @@ -1,24 +1,38 @@ -/* Локальные настройки канала: autoplay, звук и удаление с устройства после подтверждения. */ +/* Настройки одной группы, использовать в GUI-потоке: autoplay, звук, аватар и локальное удаление. + * DbManager заимствуется через QPointer; его уничтожение закрывает диалог с reject. + * Save отправляет две независимые owned-команды ядру; accept означает приём обеих в очередь, + * а не завершение SQL. Ошибки commit логируются ядром, повторное сохранение идемпотентно. + * При отказе очереди окно остаётся открытым для повторной попытки; ранее принятая команда может выполниться. + * Изменения аватара применяются сразу через GroupAvatarManager и не отменяются кнопкой Cancel. */ #pragma once #include #include #include +#include class DbManager; +class QLabel; class ChannelSettingsDialog : public QDialog { Q_OBJECT public: + /* channelId — каноническая десятичная строка ненулевого uint64 ID; parent владеет диалогом. + * Без открытой db/с допустимым ID только просмотр: Save/Delete недоступны. Дочерние поля принадлежат диалогу. */ explicit ChannelSettingsDialog(const QString& channelId, DbManager* db, QWidget* parent = nullptr); private slots: + /* Читает поля и передаёт autoplay/звук в поток ядра; отказы выделения/очереди показывает без закрытия. */ void onSave(); + /* Подтверждает локальное удаление (Cancel по умолчанию), заново проверяет наличие группы и очередь. + * Принятие команды закрывает окно; CHAT_EVT_CHANNEL_DELETED приходит контроллеру от ядра. */ void onDelete(); private: - QString m_channelId; - DbManager* m_db; - QCheckBox* m_autoPlayVoice; - QCheckBox* m_soundOnMessage; + QString m_channelId; /* Собственная копия ID целевой группы. */ + QPointer m_db; /* Не владеет QObject и SQLite. */ + bool m_validChannel = false; /* ID ненулевой, канонический и помещается в uint64. */ + QCheckBox* m_autoPlayVoice; /* Дочерний autoplay-флаг; по умолчанию false. */ + QCheckBox* m_soundOnMessage; /* Дочерний sound-флаг; по умолчанию true. */ + QLabel* m_status; /* Дочерняя строка ошибки подготовки/отправки команды. */ };