From 38257a7f542f47d137c68905e6013b84ebde152f Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 8 Oct 2026 00:27:02 +0200 Subject: [PATCH] Connect reply sending, quote resolution and history navigation --- tools/chatgui/src/messagelist.cpp | 120 ++++++++++++++++++++++++++++-- tools/chatgui/src/messagelist.h | 5 ++ 2 files changed, 119 insertions(+), 6 deletions(-) diff --git a/tools/chatgui/src/messagelist.cpp b/tools/chatgui/src/messagelist.cpp index 0980981f..6f2704de 100644 --- a/tools/chatgui/src/messagelist.cpp +++ b/tools/chatgui/src/messagelist.cpp @@ -32,6 +32,7 @@ #include #include #include +#include #include #include #include @@ -48,6 +49,7 @@ extern "C" { #include "radio/radio.h" #include "../../lib/u_async.h" #include "../../lib/mem.h" +#include "../../lib/audio_compressor.h" #include "../../lib/debug_config.h" void chat_core_save_ui_state_trampoline(void* arg); } @@ -453,6 +455,12 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent }); connect(msgDelegate, &MessageDelegate::reactionRequested, this, &MessageList::requestReaction); connect(msgDelegate, &MessageDelegate::pollRequested, this, &MessageList::showPoll); + connect(msgDelegate, &MessageDelegate::replyRequested, this, &MessageList::jumpToMessage); + connect(m_view, &ChatView::latestRequested, this, [this]() { + if (m_historyAnchor.isEmpty()) return; + m_historyAnchor.clear(); loadChannel(m_currentChannelId); + QTimer::singleShot(0, m_view, &QListView::scrollToBottom); + }); msgDelegate->setDownloadCallback([](const QModelIndex& index) { QString target = index.data(MsgChannelIdRole).toString(); qint64 id = index.data(MsgIdRole).toLongLong(); @@ -501,9 +509,13 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent layout->addWidget(m_inputBar); m_inputBar->installFileDropTarget(m_view->viewport()); - connect(m_inputBar, &InputBar::sendMessage, this, [this](const QString &text) { + connect(m_inputBar, &InputBar::sendMessage, this, [this](const QString &text, const QByteArray& reply, quint64 request) { + if (!gui_bridge_get_inst() || !gui_bridge_is_uasync_ready()) { + GUI_ERROR("send: core unavailable"); m_inputBar->submissionResult(request, false); return; + } if (m_currentChannelId.isEmpty()) { qWarning("MessageList: no channel selected, message not sent"); + m_inputBar->submissionResult(request, false); return; } @@ -527,7 +539,10 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent struct chat_msg_submit* req = (struct chat_msg_submit*) u_malloc(sizeof(struct chat_msg_submit) + textData.size()); + if (!req) { GUI_ERROR("send: text request allocation failed"); m_inputBar->submissionResult(request, false); return; } memset(req, 0, sizeof(*req)); + if (reply.size() == 32) memcpy(req->reply_to, reply.constData(), 32); + req->request_id = request; req->inst = gui_bridge_get_inst(); snprintf(req->channel_id, sizeof(req->channel_id), "%s", m_currentChannelId.toUtf8().constData()); @@ -540,6 +555,11 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent }); connect(m_inputBar, &InputBar::submitAttachment, this, [this](const QString& target, struct attachment_send_req* req) { + if (!gui_bridge_get_inst() || !gui_bridge_is_uasync_ready()) { + GUI_ERROR("send: attachment core unavailable"); m_inputBar->submissionResult(req->request_id, false); + if (req->pcm_release) req->pcm_release(req->pcm_owner); else u_free((void*)req->pcm); + audio_compressor_destroy(req->compressor); u_free(req); return; + } req->inst = gui_bridge_get_inst(); req->is_dm = isDmChannel(target); QByteArray id = (req->is_dm ? dmConvId(target) : target).toUtf8(); snprintf(req->target, sizeof(req->target), "%s", id.constData()); @@ -547,8 +567,10 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent }); connect(m_inputBar, &InputBar::sendFileAttachment, this, [this](const QString& target, const QString& mediaSrc, const QString& mediaDest, - const QString& displayName) { - if (target.isEmpty()) return; + const QString& displayName, const QByteArray& reply, quint64 request) { + if (target.isEmpty() || !gui_bridge_get_inst() || !gui_bridge_is_uasync_ready()) { + GUI_ERROR("send: file target/core unavailable"); m_inputBar->submissionResult(request, false); return; + } if (isDmChannel(target)) { auto* req = (struct dm_file_req*)u_calloc(1, sizeof(struct dm_file_req)); if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_DM, "PM file request allocation failed"); return; } @@ -570,7 +592,10 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent struct chat_msg_submit* req = (struct chat_msg_submit*) u_malloc(sizeof(struct chat_msg_submit) + textData.size()); + if (!req) { GUI_ERROR("send: file request allocation failed"); m_inputBar->submissionResult(request, false); return; } memset(req, 0, sizeof(*req)); + if (reply.size() == 32) memcpy(req->reply_to, reply.constData(), 32); + req->request_id = request; req->inst = gui_bridge_get_inst(); snprintf(req->channel_id, sizeof(req->channel_id), "%s", target.toUtf8().constData()); @@ -586,8 +611,10 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent }); connect(m_inputBar, &InputBar::sendImageAttachment, this, [this](const QString& target, const QString& mediaSrc, const QString& mediaDest, - const QString& displayName) { - if (target.isEmpty()) return; + const QString& displayName, const QByteArray& reply, quint64 request) { + if (target.isEmpty() || !gui_bridge_get_inst() || !gui_bridge_is_uasync_ready()) { + GUI_ERROR("send: image target/core unavailable"); m_inputBar->submissionResult(request, false); return; + } QByteArray textData = displayName.toUtf8(); auto ts = QDateTime::currentMSecsSinceEpoch(); @@ -610,7 +637,10 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent struct chat_msg_submit* req = (struct chat_msg_submit*) u_malloc(sizeof(struct chat_msg_submit) + textData.size()); + if (!req) { GUI_ERROR("send: image request allocation failed"); m_inputBar->submissionResult(request, false); return; } memset(req, 0, sizeof(*req)); + if (reply.size() == 32) memcpy(req->reply_to, reply.constData(), 32); + req->request_id = request; req->inst = gui_bridge_get_inst(); snprintf(req->channel_id, sizeof(req->channel_id), "%s", target.toUtf8().constData()); @@ -646,6 +676,8 @@ void MessageList::loadChannel(const QString& channelId) { QWidget* prevFocus = QApplication::focusWidget(); bool sameChannel = (m_currentChannelId == channelId); + if (!sameChannel) m_historyAnchor.clear(); + m_view->setHistoricalWindow(!m_historyAnchor.isEmpty()); /* Save state of current channel before switching */ if (!m_currentChannelId.isEmpty()) { @@ -718,7 +750,7 @@ void MessageList::loadChannel(const QString& channelId) { m_localNicks = m_db->getLocalNicknames(channelId); - auto msgs = m_db->getMessages(channelId, 200); + auto msgs = m_db->getMessages(channelId, 200, m_historyAnchor); for (const auto& m : msgs) { AccountRow acc = m_db->getAccount(m.authorNodeId); @@ -748,6 +780,7 @@ void MessageList::loadChannel(const QString& channelId) { m_db ? m_db->dbPath() + "/media" : QString(), channelId, m.localAttrs); m_model->appendRow(item); setupFeedback(item, m, channelId); + updateReply(item, m.replyTo); } if (sameChannel) { @@ -789,6 +822,8 @@ void MessageList::refresh() { if (!m_db || !m_db->isOpen()) return; + refreshReplies(); + if (!m_historyAnchor.isEmpty()) return; 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); @@ -823,6 +858,7 @@ void MessageList::refresh() { m_db ? m_db->dbPath() + "/media" : QString(), m_currentChannelId, m.localAttrs); m_model->appendRow(item); setupFeedback(item, m, m_currentChannelId); + updateReply(item, m.replyTo); while (m_model->rowCount() > 200) m_model->removeRow(0); } @@ -1047,6 +1083,71 @@ void MessageList::prepareVideoThumbnail(const QString& path) { }); } +/* Цитаты разрешаются независимо от окна истории; поздняя синхронизация обновляет уже видимые ответы. */ +void MessageList::updateReply(QStandardItem* item, const QByteArray& original) { + item->setData(QString::fromLatin1(original.toHex()), MsgReplyToRole); + item->setData(!original.isEmpty(), MsgHasQuoteRole); + if (original.isEmpty() || !m_db) return; + const auto message = m_db->getMessageByKey(m_currentChannelId, original); + const QString author = message.messageId.isEmpty() ? QCoreApplication::translate("ChatGui", "Reply") : authorName(message.authorNodeId); + const QString preview = message.messageId.isEmpty() + ? QCoreApplication::translate("ChatGui", "Original message is not available yet") : DbManager::messagePreview(message); + if (item->data(MsgQuoteAuthorRole).toString() == author && item->data(MsgQuoteTextRole).toString() == preview) return; + item->setData(author, MsgQuoteAuthorRole); item->setData(preview, MsgQuoteTextRole); + GUI_DEBUG("reply: preview ch=%s original=%s resolved=%d", qPrintable(m_currentChannelId), original.toHex().constData(), + !message.messageId.isEmpty()); +} + +void MessageList::refreshReplies() { + for (int row = 0; row < m_model->rowCount(); ++row) { + auto* item = m_model->item(row); + const QByteArray key = QByteArray::fromHex(item->data(MsgReplyToRole).toString().toLatin1()); + if (!key.isEmpty()) updateReply(item, key); + } +} + +/* Перезагружаем только ограниченное окно вокруг цели; обычный refresh сохраняет открытый участок истории. */ +void MessageList::jumpToMessage(const QString& messageId) { + if (!m_db || isDmChannel(m_currentChannelId)) return; + const QByteArray key = QByteArray::fromHex(messageId.toLatin1()); + if (key.size() != 32) { GUI_ERROR("reply: invalid navigation key=%s", qPrintable(messageId)); return; } + bool present = false; + for (int row = 0; row < m_model->rowCount(); ++row) + if (m_model->item(row)->data(MsgMessageKeyRole).toString() == messageId) { present = true; break; } + if (!present) { + if (m_db->getMessageByKey(m_currentChannelId, key).messageId.isEmpty()) { + GUI_WARN("reply: navigation original unavailable ch=%s id=%s", qPrintable(m_currentChannelId), qPrintable(messageId)); + QToolTip::showText(QCursor::pos(), QCoreApplication::translate("ChatGui", "Original message is not available yet"), m_view); + return; + } + m_historyAnchor = key; + loadChannel(m_currentChannelId); + } + GUI_DEBUG("reply: navigate ch=%s id=%s loaded_window=%d", qPrintable(m_currentChannelId), qPrintable(messageId), !present); + const QString channel = m_currentChannelId; + QTimer::singleShot(0, this, [this, channel, messageId]() { + if (channel != m_currentChannelId) return; + for (int row = 0; row < m_model->rowCount(); ++row) { + const QModelIndex index = m_model->index(row, 0); + if (index.data(MsgMessageKeyRole).toString() != messageId) continue; + m_view->scrollTo(index, QAbstractItemView::PositionAtCenter); + m_model->setData(index, true, MsgHighlightRole); + QTimer::singleShot(1500, this, [this, index = QPersistentModelIndex(index)]() { + if (index.isValid()) m_model->setData(index, false, MsgHighlightRole); + }); + return; + } + GUI_WARN("reply: navigation target disappeared ch=%s id=%s", qPrintable(channel), qPrintable(messageId)); + }); +} + +void MessageList::onMessageSubmitted(const uint8_t* data, int len) { + if (!data || len != 9 || data[8] > 1) { GUI_ERROR("reply: invalid submission result bytes=%d", len); return; } + quint64 request = 0; + for (unsigned i = 0; i < 8; ++i) request |= quint64(data[i]) << (8 * i); + m_inputBar->submissionResult(request, data[8] != 0); +} + void MessageList::onMessageContextMenu(const QPoint& pos) { QModelIndex idx = m_view->indexAt(pos); if (!idx.isValid()) return; @@ -1064,6 +1165,13 @@ void MessageList::onMessageContextMenu(const QPoint& pos) { QMenu menu(m_view); QString messageId = idx.data(MsgMessageKeyRole).toString(); if (!isDm && messageId.size() == 64) { + const QString channel = idx.data(MsgChannelIdRole).toString(); + connect(menu.addAction(QCoreApplication::translate("ChatGui", "Reply")), &QAction::triggered, this, [this, channel, messageId]() { + if (channel != m_currentChannelId || !m_db) return; + const auto original = m_db->getMessageByKey(channel, QByteArray::fromHex(messageId.toLatin1())); + if (original.messageId.isEmpty()) { GUI_WARN("reply: selected original disappeared ch=%s", qPrintable(channel)); return; } + m_inputBar->setReply(original.messageId, authorName(original.authorNodeId), DbManager::messagePreview(original)); + }); if (ct == "poll") connect(menu.addAction(QCoreApplication::translate("ChatGui", "Vote / results")), &QAction::triggered, this, [this, messageId]() { showPoll(messageId); }); auto* reactions = menu.addMenu(QCoreApplication::translate("ChatGui", "React")); diff --git a/tools/chatgui/src/messagelist.h b/tools/chatgui/src/messagelist.h index fad3dc1e..f971f840 100644 --- a/tools/chatgui/src/messagelist.h +++ b/tools/chatgui/src/messagelist.h @@ -44,6 +44,7 @@ public: void onAttachmentPreparation(const uint8_t* data, int len); void onFeedbackChanged(const uint8_t* data, int len); void onFeedbackResult(const uint8_t* data, int len); + void onMessageSubmitted(const uint8_t* data, int len); void onRadioTalk(quint64 groupId, quint64 srcNodeId, bool on); /* индикация говорящего */ signals: @@ -85,6 +86,10 @@ private: QPointer m_pollDialog; QString m_pollDialogMessage, m_pendingPoll; quint64 m_feedbackSequence = 0; + QByteArray m_historyAnchor; + void updateReply(QStandardItem* item, const QByteArray& original); + void refreshReplies(); + void jumpToMessage(const QString& messageId); void sendFeedback(QJsonObject command, bool userAction = true); void setupFeedback(QStandardItem* item, const MessageRow& message, const QString& channel);