diff --git a/tools/chatgui/src/inputbar.cpp b/tools/chatgui/src/inputbar.cpp index 65e30bad..1265962d 100644 --- a/tools/chatgui/src/inputbar.cpp +++ b/tools/chatgui/src/inputbar.cpp @@ -91,6 +91,17 @@ InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *par auto *mainLayout = new QVBoxLayout(this); mainLayout->setContentsMargins(8, 4, 8, 0); mainLayout->setSpacing(2); + m_replyPreview = new QWidget(this); + auto* replyLayout = new QHBoxLayout(m_replyPreview); replyLayout->setContentsMargins(6, 4, 6, 4); + m_replyLabel = new QLabel(m_replyPreview); m_replyLabel->setTextFormat(Qt::PlainText); + m_replyLabel->setMinimumWidth(0); m_replyLabel->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + m_replyLabel->setStyleSheet("border-left: 2px solid palette(highlight); padding-left: 6px;"); + replyLayout->addWidget(m_replyLabel, 1); + auto* cancelReply = new QPushButton(QString::fromUtf8("✕"), m_replyPreview); + cancelReply->setToolTip(QCoreApplication::translate("ChatGui", "Cancel reply")); + cancelReply->setFixedSize(24, 24); replyLayout->addWidget(cancelReply); + connect(cancelReply, &QPushButton::clicked, this, &InputBar::clearReply); + mainLayout->addWidget(m_replyPreview); m_replyPreview->hide(); /* Attachment preview bar (hidden until file selected) */ m_attachPreview = new QWidget(this); @@ -225,6 +236,55 @@ InputBar::~InputBar() { delete m_dropHint.data(); } +/* Смена цели создаёт новую ревизию: поздний результат не очищает следующий ответ. */ +void InputBar::setReply(const QByteArray& messageId, const QString& author, const QString& preview) { + if (m_dmMode || messageId.size() != 32 || m_channelIdForRecord.isEmpty()) { + GUI_ERROR("reply: invalid composer target/key bytes=%d", int(messageId.size())); return; + } + ++m_replyGeneration; m_replyTo = messageId; m_replyFailed = false; + m_replyLabel->setText(author.simplified() + "\n" + preview.simplified()); + m_replyPreview->show(); m_input->setFocus(); + GUI_DEBUG("reply: selected ch=%s id=%s generation=%llu", qPrintable(m_channelIdForRecord), + messageId.toHex().constData(), (unsigned long long)m_replyGeneration); +} + +void InputBar::clearReply() { + if (!m_replyTo.isEmpty()) GUI_DEBUG("reply: cleared ch=%s generation=%llu", qPrintable(m_channelIdForRecord), + (unsigned long long)m_replyGeneration); + ++m_replyGeneration; m_replyTo.clear(); m_replyFailed = false; m_replyPreview->hide(); +} + +bool InputBar::replySending() const { + for (const auto& pending : m_pendingReplies) if (pending.generation == m_replyGeneration) return true; + return false; +} + +/* Каждая часть отправки (текст/вложение) имеет собственное подтверждение и снимок черновика. */ +quint64 InputBar::beginReplySend(const QByteArray& reply, quint64 generation, const QString& target, + const QString& text, const QString& source) { + if (reply.isEmpty()) return 0; + const quint64 request = ++m_submissionSequence; + m_pendingReplies.insert(request, {generation, target, text, source, m_input->document()->revision()}); + GUI_DEBUG("reply: submit ch=%s id=%s request=%llu", qPrintable(target), reply.toHex().constData(), (unsigned long long)request); + return request; +} + +void InputBar::submissionResult(quint64 request, bool success) { + if (!request || !m_pendingReplies.contains(request)) return; + const auto pending = m_pendingReplies.take(request); + GUI_DEBUG("reply: result ch=%s request=%llu success=%d", qPrintable(pending.target), (unsigned long long)request, success); + if (pending.generation != m_replyGeneration || pending.target != m_channelIdForRecord) return; + if (!success) { + m_replyFailed = true; + GUI_ERROR("reply: submission failed ch=%s request=%llu", qPrintable(pending.target), (unsigned long long)request); + emit attachmentRejected(QCoreApplication::translate("ChatGui", "Could not send reply. Please try again.")); + return; + } + if (!pending.text.isEmpty() && pending.textRevision == m_input->document()->revision()) m_input->clear(); + if (!pending.source.isEmpty() && pending.source == m_pendingAttachSrc) clearPendingAttachment(); + if (!replySending() && !m_replyFailed) clearReply(); +} + /* Qt доставляет drop в viewport, а не в QTextEdit/QListView. Родитель composer принимает drop на кнопках. */ void InputBar::installFileDropTarget(QWidget* target) { if (!target || m_dropTargets.contains(target)) return; @@ -295,6 +355,7 @@ bool InputBar::eventFilter(QObject *obj, QEvent *event) { } if (obj == m_input && event->type() == QEvent::KeyPress) { auto *ke = static_cast(event); + if (ke->key() == Qt::Key_Escape && !m_replyTo.isEmpty()) { clearReply(); return true; } if (ke->key() == Qt::Key_Return || ke->key() == Qt::Key_Enter) { if (ke->modifiers() & Qt::ControlModifier) { m_input->insertPlainText("\n"); @@ -330,6 +391,11 @@ void InputBar::adjustInputHeight() { } void InputBar::onSendClicked() { + if (replySending()) { + GUI_WARN("reply: submission still pending ch=%s", qPrintable(m_channelIdForRecord)); return; + } + m_replyFailed = false; + const QByteArray reply = m_replyTo; QString text = m_input->toPlainText(); while (!text.isEmpty() && (text.back() == ' ' || text.back() == '\t')) text.chop(1); @@ -338,37 +404,34 @@ void InputBar::onSendClicked() { if (!hasAttach && text.isEmpty()) return; if (hasAttach) { + const quint64 requestId = beginReplySend(reply, m_replyGeneration, m_channelIdForRecord, {}, m_pendingAttachSrc); if (m_pendingAttachAsImage) { GUI_INFO("send: image=%s name=%s", qPrintable(m_pendingAttachSrc), qPrintable(m_pendingAttachName)); - emit sendImageAttachment(m_channelIdForRecord, m_pendingAttachSrc, m_pendingAttachDest, m_pendingAttachName); - m_attachPreview->hide(); + emit sendImageAttachment(m_channelIdForRecord, m_pendingAttachSrc, m_pendingAttachDest, m_pendingAttachName, reply, requestId); } else if (m_pendingAttachAsVideo) { GUI_INFO("send: video=%s name=%s", qPrintable(m_pendingAttachSrc), qPrintable(m_pendingAttachName)); auto* request = (struct attachment_send_req*)u_calloc(1, sizeof(struct attachment_send_req)); QByteArray source = m_pendingAttachSrc.toUtf8(), name = (QFileInfo(m_pendingAttachName).completeBaseName() + ".mp4").toUtf8(); if (!request || source.size() >= (int)sizeof(request->source) || name.size() >= (int)sizeof(request->info.name)) { - DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "InputBar: video request allocation/path failed"); u_free(request); return; + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "InputBar: video request allocation/path failed"); + u_free(request); submissionResult(requestId, false); return; } request->info.kind = ATTACHMENT_VIDEO; request->transcode = 1; + if (reply.size() == 32) memcpy(request->reply_to, reply.constData(), 32); + request->request_id = requestId; snprintf(request->source, sizeof(request->source), "%s", source.constData()); snprintf(request->info.name, sizeof(request->info.name), "%s", name.constData()); emit submitAttachment(m_channelIdForRecord, request); - m_attachPreview->hide(); } else { GUI_INFO("send: file=%s name=%s", qPrintable(m_pendingAttachSrc), qPrintable(m_pendingAttachName)); - emit sendFileAttachment(m_channelIdForRecord, m_pendingAttachSrc, m_pendingAttachDest, m_pendingAttachName); - m_attachPreview->hide(); + emit sendFileAttachment(m_channelIdForRecord, m_pendingAttachSrc, m_pendingAttachDest, m_pendingAttachName, reply, requestId); } - m_pendingAttachSrc.clear(); - m_pendingAttachDest.clear(); - m_pendingAttachName.clear(); - m_pendingAttachSize = 0; - m_pendingAttachAsVideo = false; - m_pendingAttachAsImage = false; + if (reply.isEmpty()) clearPendingAttachment(); } if (!text.isEmpty()) { - emit sendMessage(text); - m_input->clear(); + const quint64 request = beginReplySend(reply, m_replyGeneration, m_channelIdForRecord, text); + emit sendMessage(text, reply, request); + if (reply.isEmpty()) m_input->clear(); if (m_emojiPanel) m_emojiPanel->hide(); m_input->setFocus(); } @@ -376,6 +439,7 @@ void InputBar::onSendClicked() { void InputBar::setRecordingContext(const QString& mediaDirBase, const QString& channelId) { if (m_channelIdForRecord != channelId || m_mediaDirBase != mediaDirBase) { + clearReply(); ++m_attachmentContext; clearPendingAttachment(); if (m_dropHint) m_dropHint->hide(); @@ -398,10 +462,12 @@ void InputBar::setMediaEnabled(bool enabled) { } void InputBar::onPttPressed() { + if (replySending()) { GUI_WARN("reply: recording blocked while submission pending"); return; } if (!m_recorder || !m_recorder->isInitialized()) return; m_recorder->startRecording(); m_pttActive = m_recorder->isRecording(); m_recordTarget = m_channelIdForRecord; + m_recordReply = m_replyTo; m_recordReplyGeneration = m_replyGeneration; m_replyFailed = false; m_pttBtn->setStyleSheet(m_pttBtn->styleSheet() + "QPushButton { background: #e74c3c; border-radius: 4px; }"); } @@ -424,6 +490,8 @@ void InputBar::onPttReleased() { if (!request) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "InputBar: voice request allocation failed"); return; } request->info.kind = ATTACHMENT_VOICE; request->preset = VoiceEncoder::preset(); if (!m_recorder->takeRecording(request)) { u_free(request); return; } + if (m_recordReply.size() == 32) memcpy(request->reply_to, m_recordReply.constData(), 32); + request->request_id = beginReplySend(m_recordReply, m_recordReplyGeneration, m_recordTarget); emit submitAttachment(m_recordTarget, request); } diff --git a/tools/chatgui/src/inputbar.h b/tools/chatgui/src/inputbar.h index 6a5bcd88..519cd3d4 100644 --- a/tools/chatgui/src/inputbar.h +++ b/tools/chatgui/src/inputbar.h @@ -32,14 +32,17 @@ public: void setDmMode(bool enabled); void setMediaEnabled(bool enabled); void updatePreparation(const QString& target, const QByteArray& job, int state); + void setReply(const QByteArray& messageId, const QString& author, const QString& preview); + void clearReply(); + void submissionResult(quint64 request, bool success); signals: - void sendMessage(const QString &text); + void sendMessage(const QString &text, const QByteArray& replyTo, quint64 request); void submitAttachment(const QString& target, struct attachment_send_req* request); void sendFileAttachment(const QString& target, const QString& mediaSrc, const QString& mediaDest, - const QString& displayName); + const QString& displayName, const QByteArray& replyTo, quint64 request); void sendImageAttachment(const QString& target, const QString& mediaSrc, const QString& mediaDest, - const QString& displayName); + const QString& displayName, const QByteArray& replyTo, quint64 request); void attachmentRejected(const QString& reason); protected: @@ -59,6 +62,20 @@ private slots: private: static QString formatFileSize(qint64 bytes); + struct PendingReply { + quint64 generation; + QString target, text, source; + int textRevision; + }; + quint64 beginReplySend(const QByteArray& reply, quint64 generation, const QString& target, + const QString& text = {}, const QString& source = {}); + bool replySending() const; + QWidget* m_replyPreview = nullptr; + QLabel* m_replyLabel = nullptr; + QByteArray m_replyTo, m_recordReply; + quint64 m_replyGeneration = 0, m_recordReplyGeneration = 0, m_submissionSequence = 0; + bool m_replyFailed = false; + QHash m_pendingReplies; QTextEdit *m_input; QPushButton *m_emojiBtn;