diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index 725d53c0..b47b2c4c 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -530,7 +530,8 @@ target_link_libraries(test_avatars PRIVATE ${QT_LIBS} ${QT_TEST_LIB} utun pthrea add_test(NAME test_avatars COMMAND test_avatars) set_tests_properties(test_avatars PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 60) -add_executable(test_group_avatars tests/test_group_avatars.cpp src/avatars/groupavatarmanager.cpp src/channelsettingsdialog.cpp db/db_manager.cpp) +add_executable(test_group_avatars tests/test_group_avatars.cpp src/avatars/groupavatarmanager.cpp src/channelsettingsdialog.cpp + src/channellist.cpp src/channeldelegate.cpp db/db_manager.cpp) target_link_libraries(test_group_avatars PRIVATE ${QT_LIBS} ${QT_TEST_LIB} utun pthread) add_test(NAME test_group_avatars COMMAND test_group_avatars) set_tests_properties(test_group_avatars PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 30) diff --git a/tools/chatgui/src/channellist.cpp b/tools/chatgui/src/channellist.cpp index 7d24c390..a3b2d078 100644 --- a/tools/chatgui/src/channellist.cpp +++ b/tools/chatgui/src/channellist.cpp @@ -382,10 +382,15 @@ void ChannelList::loadChannels() { if (!selectedCid.isEmpty()) { selectChannel(selectedCid); - } else if (m_model->rowCount() > 0) { - QModelIndex idx = m_model->index(0, 0); - sel->select(idx, QItemSelectionModel::ClearAndSelect); - sel->setCurrentIndex(idx, QItemSelectionModel::NoUpdate); + } + if (!m_listView->currentIndex().isValid()) { + for (int row = 0; row < m_model->rowCount(); ++row) { + QModelIndex idx = m_model->index(row, 0); + if (idx.data(ChannelIsSectionRole).toBool()) continue; + sel->select(idx, QItemSelectionModel::ClearAndSelect); + sel->setCurrentIndex(idx, QItemSelectionModel::NoUpdate); + break; + } } } diff --git a/tools/chatgui/src/channelsettingsdialog.cpp b/tools/chatgui/src/channelsettingsdialog.cpp index b0999fe3..6711e891 100644 --- a/tools/chatgui/src/channelsettingsdialog.cpp +++ b/tools/chatgui/src/channelsettingsdialog.cpp @@ -4,6 +4,7 @@ #include "../db/db_manager.h" #include "../transport/gui_bridge.h" #include "../../lib/mem.h" +#include "../../lib/debug_config.h" extern "C" { #include "chat/chat_core.h" } @@ -12,6 +13,7 @@ extern "C" { #include #include #include +#include #include ChannelSettingsDialog::ChannelSettingsDialog(const QString& channelId, DbManager* db, QWidget* parent) @@ -82,6 +84,13 @@ ChannelSettingsDialog::ChannelSettingsDialog(const QString& channelId, DbManager auto* btnLayout = new QHBoxLayout(); btnLayout->setSpacing(8); + auto* deleteBtn = new QPushButton(QCoreApplication::translate("ChatGui", "Delete channel"), this); + 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()); + connect(deleteBtn, &QPushButton::clicked, this, &ChannelSettingsDialog::onDelete); + btnLayout->addWidget(deleteBtn); btnLayout->addStretch(); auto* cancelBtn = new QPushButton(QCoreApplication::translate("ChatGui", "Cancel"), this); @@ -114,6 +123,42 @@ ChannelSettingsDialog::ChannelSettingsDialog(const QString& channelId, DbManager } } +/* Как в Android: удаляются только локальные данные; ядро уведомит GUI по завершении. */ +void ChannelSettingsDialog::onDelete() { + 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; } + 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); + confirmation.setTextFormat(Qt::PlainText); + auto* remove = confirmation.addButton(QCoreApplication::translate("ChatGui", "Delete"), QMessageBox::DestructiveRole); + remove->setObjectName("confirmDeleteChannel"); + auto* cancel = confirmation.addButton(QMessageBox::Cancel); + confirmation.setDefaultButton(cancel); + confirmation.setEscapeButton(cancel); + confirmation.exec(); + if (confirmation.clickedButton() != remove) 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.")); + 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.")); + 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); + accept(); +} + void ChannelSettingsDialog::onSave() { auto saveKey = [&](const char* suffix, int val) { QString key = QStringLiteral("ch_%1_%2").arg(m_channelId, QString::fromUtf8(suffix)); diff --git a/tools/chatgui/src/channelsettingsdialog.h b/tools/chatgui/src/channelsettingsdialog.h index 17a00e21..60239b69 100644 --- a/tools/chatgui/src/channelsettingsdialog.h +++ b/tools/chatgui/src/channelsettingsdialog.h @@ -1,4 +1,4 @@ -/* Локальные настройки выбранного канала: autoplay голосовых и звук сообщений. */ +/* Локальные настройки канала: autoplay, звук и удаление с устройства после подтверждения. */ #pragma once #include @@ -14,6 +14,7 @@ public: private slots: void onSave(); + void onDelete(); private: QString m_channelId; diff --git a/tools/chatgui/src/mainwindow.cpp b/tools/chatgui/src/mainwindow.cpp index 32407377..928ec519 100644 --- a/tools/chatgui/src/mainwindow.cpp +++ b/tools/chatgui/src/mainwindow.cpp @@ -1355,6 +1355,23 @@ void MainWindow::onChannelPeersOnline(const char* ch_id, int ch_id_len, uint16_t } void MainWindow::reloadChannels() { + if (m_db && m_db->isOpen() && !m_currentChannelId.isEmpty() && !m_currentChannelId.startsWith(QStringLiteral("dm:"))) { + bool exists = false; + for (const auto& channel : m_db->getChannels()) if (channel.channelId == m_currentChannelId) { exists = true; break; } + if (!exists) { + DEBUG_INFO(DEBUG_CATEGORY_CHAT, "channel removed from GUI: channel=%s", qPrintable(m_currentChannelId)); + for (int i = m_autoPlayQueue.size() - 1; i >= 0; --i) if (m_autoPlayQueue[i].channelId == m_currentChannelId) { + m_autoPlayDownloading.remove(m_autoPlayQueue[i].msgId); + m_autoPlayQueue.removeAt(i); + } + if (m_pendingChannelSelect == m_currentChannelId) m_pendingChannelSelect.clear(); + m_messageList->loadChannel(QString()); + m_accountList->setChannel(QString()); + m_currentChannelId.clear(); + saveLastChannelId(QString()); + if (m_messageNotification) m_messageNotification->dismiss("channel deleted"); + } + } if (m_channelList) { m_channelList->loadChannels(); if (!m_pendingChannelSelect.isEmpty()) { diff --git a/tools/chatgui/src/messagelist.cpp b/tools/chatgui/src/messagelist.cpp index 2732a08d..3052fb40 100644 --- a/tools/chatgui/src/messagelist.cpp +++ b/tools/chatgui/src/messagelist.cpp @@ -886,7 +886,8 @@ void MessageList::loadChannel(const QString& channelId) { m_callBtn->setVisible(isDmChannel(channelId)); m_inviteBtn->setVisible(isDmChannel(channelId)); m_peerStatus->setVisible(isDmChannel(channelId)); - m_radioBtn->setVisible(!isDmChannel(channelId)); + m_radioBtn->setVisible(!channelId.isEmpty() && !isDmChannel(channelId)); + m_inputBar->setEnabled(!channelId.isEmpty()); m_inputBar->setRecordingContext(m_db ? m_db->dbPath() + "/media" : QString(), channelId); m_readDebounce->stop(); @@ -900,6 +901,10 @@ void MessageList::loadChannel(const QString& channelId) { if (m_channelTitle) m_channelTitle->setText(title); m_inputBar->setDmMode(isDmChannel(channelId)); + if (channelId.isEmpty()) { + DEBUG_INFO(DEBUG_CATEGORY_CHAT, "message list cleared: no channel selected"); + return; + } /* История и состояния PM поступают из ядра в одном снимке. */ if (isDmChannel(channelId)) { m_radioBtn->setEnabled(false); diff --git a/tools/chatgui/tests/test_group_avatars.cpp b/tools/chatgui/tests/test_group_avatars.cpp index f77e4022..9984cf16 100644 --- a/tools/chatgui/tests/test_group_avatars.cpp +++ b/tools/chatgui/tests/test_group_avatars.cpp @@ -1,25 +1,33 @@ #include "../src/avatars/groupavatarmanager.h" #include "../src/channelsettingsdialog.h" +#include "../src/channellist.h" +#include "../src/channeldelegate.h" #include "../db/db_manager.h" #include "../transport/gui_bridge.h" #include #include #include +#include +#include extern "C" { #include "utun_instance.h" #include "chat/chat_core.h" #include "chat/chat_event.h" #include "chat/group_avatar.h" +#include "dm/dm_core.h" #include "routing_layer/topo_node_sqlite.h" #include "mem.h" #include "debug_config.h" } static UTUN_INSTANCE* instance; +static QStringList deletedChannels; extern "C" int gui_bridge_is_uasync_ready(void) { return instance != nullptr; } extern "C" UTUN_INSTANCE* gui_bridge_get_inst(void) { return instance; } extern "C" void gui_bridge_post_uasync_fn(void (*fn)(void*), void* arg) { uasync_call_soon(instance->ua, arg, fn); } static void coreEvent(UTUN_INSTANCE*, int type, const uint8_t* data, int length) { if (type == CHAT_EVT_GROUP_AVATAR_RESULT && GroupAvatarManager::instance()) GroupAvatarManager::instance()->onResult(data, length); + if (type == CHAT_EVT_CHANNEL_DELETED && length > 1 && length == 1 + data[0]) + deletedChannels.append(QString::fromUtf8((const char*)data + 1, data[0])); } class GroupAvatarTest : public QObject { Q_OBJECT @@ -69,6 +77,57 @@ private slots: poll.stop(); chat_event_set_handler(instance, nullptr); db.setDb(nullptr); utun_instance_destroy(instance); instance = nullptr; uasync_destroy(ua, 0); } + + void channelDeletionRequiresConfirmationAndKeepsOtherChannels() { + debug_config_init(); debug_set_level(DEBUG_LEVEL_INFO); + UASYNC* ua = uasync_create(); QVERIFY(ua); + const char* config = "[global]\n" + "my_private_key=704f2e012c8fa8768130cb0f988a997dccb628372bc5ceccacc78dcbfec5916f\n" + "my_public_key=b3193173def895bd0fcea6f86af077c7d77216f10395275f627ac18242ec0f01\n" + "[chatserver]\ngroup_autoconnect=0\nmember_files_enabled=0\nstorage_autoload=0\n"; + instance = utun_instance_create_from_str(ua, config); QVERIFY(instance); + QCOMPARE(utun_core_start(instance), 0); QCOMPARE(chat_service_start(instance), 0); + chat_core_create_channel_auto(instance, "Delete me"); chat_core_create_channel_auto(instance, "Keep me"); + DbManager db; db.setDb(instance->topo_sqlite_db); db.setMyNodeId(instance->node_id); + QCOMPARE(db.getChannels().size(), 2); + const QString channel = db.getChannels()[0].channelId, survivor = db.getChannels()[1].channelId; + ChannelList list(&db); list.loadChannels(); list.selectChannel(channel); + auto* view = list.findChild(); QVERIFY(view); + const QByteArray sql = "UPDATE channels SET ed25519_privkey=NULL WHERE channel_id='" + channel.toLatin1() + "'"; + QCOMPARE(sqlite3_exec(instance->topo_sqlite_db, sql.constData(), nullptr, nullptr, nullptr), SQLITE_OK); + deletedChannels.clear(); chat_event_set_handler(instance, coreEvent); + QTimer poll; connect(&poll, &QTimer::timeout, this, [ua] { uasync_poll(ua, 0); }); poll.start(1); + ChannelSettingsDialog dialog(channel, &db); dialog.show(); + auto* remove = dialog.findChild("deleteChannel"); QVERIFY(remove && remove->isEnabled()); + bool confirmed = false; + QTimer::singleShot(0, &dialog, [&] { + auto* box = qobject_cast(QApplication::activeModalWidget()); QVERIFY(box); + QVERIFY(box->text().contains("Delete me")); + QCOMPARE(box->defaultButton(), box->button(QMessageBox::Cancel)); + box->button(QMessageBox::Cancel)->click(); + }); + QTest::mouseClick(remove, Qt::LeftButton); + QCoreApplication::processEvents(); + QCOMPARE(db.getChannels().size(), 2); QVERIFY(deletedChannels.isEmpty()); + QTimer::singleShot(0, &dialog, [&] { + auto* box = qobject_cast(QApplication::activeModalWidget()); QVERIFY(box); + auto* button = box->findChild("confirmDeleteChannel"); QVERIFY(button); + confirmed = true; button->click(); + }); + QTest::mouseClick(remove, Qt::LeftButton); QVERIFY(confirmed); + QCOMPARE(dialog.result(), int(QDialog::Accepted)); + QTRY_COMPARE(db.getChannels().size(), 1); + QCOMPARE(db.getChannels()[0].channelId, survivor); QCOMPARE(deletedChannels, QStringList({channel})); + list.loadChannels(); QCOMPARE(view->currentIndex().data(ChannelChannelIdRole).toString(), survivor); + const QByteArray peer = QByteArray::fromHex("ce8871f07fa056c636d297115f231b08c29cdf94e0d440fce83a07c34416d36a"); + QCOMPARE(dm_start(instance, 7, (const uint8_t*)peer.constData(), (const uint8_t*)peer.constData(), "PM", nullptr), 0); + chat_core_delete_channel(instance, survivor.toLatin1().constData()); + list.loadChannels(); + QVERIFY(!view->currentIndex().data(ChannelIsSectionRole).toBool()); + QVERIFY(view->currentIndex().data(ChannelChannelIdRole).toString().startsWith("dm:")); + poll.stop(); chat_event_set_handler(instance, nullptr); db.setDb(nullptr); + utun_instance_destroy(instance); instance = nullptr; uasync_destroy(ua, 0); + } }; QTEST_MAIN(GroupAvatarTest) #include "test_group_avatars.moc" diff --git a/tools/chatgui/tests/test_message_events.cpp b/tools/chatgui/tests/test_message_events.cpp index 22756478..73e5b873 100644 --- a/tools/chatgui/tests/test_message_events.cpp +++ b/tools/chatgui/tests/test_message_events.cpp @@ -15,6 +15,7 @@ struct ReceivedMessage { static QList received; static QList cpuLoads; static bool cpuOnGuiThread = true; +static QStringList changedChannels; static void cpu_received(int percent) { cpuLoads.append(percent); @@ -98,6 +99,27 @@ private slots: QCoreApplication::processEvents(); QCOMPARE(cpuLoads.size(), 6); } + + void deletionRefreshesChannelsOnlyAfterValidEvent() { + changedChannels.clear(); + gui_bridge_set_channel_updated_cb([](const char* channel, int length) { + QVERIFY(QThread::currentThread() == qApp->thread()); + changedChannels.append(QString::fromUtf8(channel, length)); + }); + std::thread worker([] { + const uint8_t valid[] = {3, '1', '2', '3'}; + gui_bridge_post(CHAT_EVT_CHANNEL_DELETED, valid, sizeof(valid)); + gui_bridge_post(CHAT_EVT_CHANNEL_DELETED, valid, sizeof(valid) - 1); + const uint8_t empty[] = {0}; + gui_bridge_post(CHAT_EVT_CHANNEL_DELETED, empty, sizeof(empty)); + gui_bridge_post(CHAT_EVT_CHANNEL_DELETED, nullptr, 0); + }); + worker.join(); + QTRY_COMPARE(changedChannels, QStringList({"123"})); + QCoreApplication::processEvents(); + QCOMPARE(changedChannels.size(), 1); + gui_bridge_set_channel_updated_cb(nullptr); + } }; QTEST_MAIN(MessageEventTest) diff --git a/tools/chatgui/translations/chatgui_en.ts b/tools/chatgui/translations/chatgui_en.ts index bc60cc4b..f4db3821 100644 --- a/tools/chatgui/translations/chatgui_en.ts +++ b/tools/chatgui/translations/chatgui_en.ts @@ -2619,5 +2619,21 @@ See log: See log: %2 + + Delete channel + Delete channel + + + Delete + Delete + + + Delete “%1” and all its local messages and files? Other members keep the channel. + Delete “%1” and all its local messages and files? Other members keep the channel. + + + Failed to delete channel. + Failed to delete channel. + diff --git a/tools/chatgui/translations/chatgui_ru.ts b/tools/chatgui/translations/chatgui_ru.ts index a27d7707..e1da02f5 100644 --- a/tools/chatgui/translations/chatgui_ru.ts +++ b/tools/chatgui/translations/chatgui_ru.ts @@ -2628,5 +2628,21 @@ See log: Подробности в логе: %2 + + Delete channel + Удалить канал + + + Delete + Удалить + + + Delete “%1” and all its local messages and files? Other members keep the channel. + Удалить «%1» и все его локальные сообщения и файлы? У других участников канал останется. + + + Failed to delete channel. + Не удалось удалить канал. + diff --git a/tools/chatgui/transport/gui_bridge.h b/tools/chatgui/transport/gui_bridge.h index 5aa300e3..1990de5b 100644 --- a/tools/chatgui/transport/gui_bridge.h +++ b/tools/chatgui/transport/gui_bridge.h @@ -92,7 +92,7 @@ void gui_bridge_set_connect_result_cb(gui_connect_result_fn cb); typedef void (*gui_msg_received_fn)(const char* ch_id, int ch_id_len, uint64_t author, int initial_sync); void gui_bridge_set_msg_received_cb(gui_msg_received_fn cb); -/* Callback для обновления канала (вызывается из GUI-потока) */ +/* Обновление списка после изменения или завершённого локального удаления канала; GUI-поток. */ typedef void (*gui_channel_updated_fn)(const char* ch_id, int ch_id_len); void gui_bridge_set_channel_updated_cb(gui_channel_updated_fn cb); diff --git a/tools/chatgui/transport/gui_bridge_impl.cpp b/tools/chatgui/transport/gui_bridge_impl.cpp index f8e082c1..afef5acc 100644 --- a/tools/chatgui/transport/gui_bridge_impl.cpp +++ b/tools/chatgui/transport/gui_bridge_impl.cpp @@ -196,6 +196,14 @@ void GuiBridgeReceiver::processPost(int eventType, QByteArray data) { DEBUG_WARN(DEBUG_CATEGORY_CONNECTION, "gui_bridge: NEW_PEER data too short %d", dlen); } break; + case CHAT_EVT_CHANNEL_DELETED: + if (dlen < 2 || !d[0] || dlen != 1 + d[0]) { + DEBUG_WARN(DEBUG_CATEGORY_CHAT, "gui_bridge: CHANNEL_DELETED invalid data length %d", dlen); + break; + } + DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "gui_bridge: CHANNEL_DELETED ch=%.*s", int(d[0]), (const char*)d + 1); + if (g_channel_updated_cb) g_channel_updated_cb((const char*)d + 1, d[0]); + break; case GUI_EVT_CHANNEL_UPDATED: if (dlen >= 2) { uint8_t chLen = d[0];