From 0279ed84a9fceeacb510788a12c7d9e37e48da00 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 18:58:33 +0200 Subject: [PATCH] chatgui: stage dropped files through the attachment composer --- tools/chatgui/src/inputbar.cpp | 150 ++++++++++++++++++++++++------ tools/chatgui/src/inputbar.h | 10 ++ tools/chatgui/src/messagelist.cpp | 1 + 3 files changed, 134 insertions(+), 27 deletions(-) diff --git a/tools/chatgui/src/inputbar.cpp b/tools/chatgui/src/inputbar.cpp index 30bc3773..834019d8 100644 --- a/tools/chatgui/src/inputbar.cpp +++ b/tools/chatgui/src/inputbar.cpp @@ -23,6 +23,11 @@ #include #include #include +#include +#include +#include +#include +#include #include extern "C" { @@ -37,6 +42,10 @@ static bool isImageFile(const QString& suffix) { s == "bmp" || s == "webp"; } +static bool isVideoFile(const QString& suffix) { + return QStringList{"mp4", "mov", "mkv", "avi", "webm", "m4v"}.contains(suffix.toLower()); +} + static QPushButton *makeIconBtn(const QIcon& icon, const QString &tooltip, QWidget *parent) { auto *btn = new QPushButton(parent); btn->setIcon(icon); @@ -198,9 +207,81 @@ InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *par } mainLayout->addLayout(layout); + installFileDropTarget(this); + installFileDropTarget(m_input->viewport()); + connect(this, &InputBar::attachmentRejected, this, [this](const QString& reason) { + QToolTip::showText(mapToGlobal(QPoint(8, 0)), reason, this); + }); +} + +/* Qt доставляет drop в viewport, а не в QTextEdit/QListView. Родитель composer принимает drop на кнопках. */ +void InputBar::installFileDropTarget(QWidget* target) { + if (!target || m_dropTargets.contains(target)) return; + target->setAcceptDrops(true); + target->installEventFilter(this); + m_dropTargets.insert(target); + connect(target, &QObject::destroyed, this, [this, target] { m_dropTargets.remove(target); }); + GUI_DEBUG("InputBar: file drop target installed class=%s", target->metaObject()->className()); +} + +bool InputBar::canAttachFile() const { + return !m_channelIdForRecord.isEmpty() && m_attachBtn->isEnabled() && + !m_pttActive && (!m_recorder || !m_recorder->isRecording()); +} + +void InputBar::rejectAttachment(const QString& reason, const QString& source) { + GUI_WARN("attachment rejected: target=%s source=%s reason=%s", qPrintable(m_channelIdForRecord), + qPrintable(source), qPrintable(reason)); + emit attachmentRejected(reason); } bool InputBar::eventFilter(QObject *obj, QEvent *event) { + if (m_dropTargets.contains(obj)) { + if (event->type() == QEvent::DragLeave) { + if (m_dropHint) m_dropHint->hide(); + } else if (event->type() == QEvent::DragEnter || event->type() == QEvent::DragMove || event->type() == QEvent::Drop) { + auto* drop = static_cast(event); + if (!drop->mimeData()->hasUrls()) return QWidget::eventFilter(obj, event); + if (!canAttachFile() || !(drop->possibleActions() & Qt::CopyAction)) { + if (m_dropHint) m_dropHint->hide(); + drop->ignore(); + if (event->type() == QEvent::DragEnter) + GUI_DEBUG("attachment drag unavailable: target=%s enabled=%d recording=%d actions=%d", + qPrintable(m_channelIdForRecord), m_attachBtn->isEnabled(), m_pttActive, int(drop->possibleActions())); + return true; + } + const auto urls = drop->mimeData()->urls(); + QString error; + if (urls.size() != 1) error = QCoreApplication::translate("ChatGui", "Drop one file at a time."); + else if (!urls.front().isLocalFile()) error = QCoreApplication::translate("ChatGui", "Only local files can be attached."); + if (event->type() != QEvent::Drop) { + auto* target = qobject_cast(obj); + if (!m_dropHint) { + m_dropHint = new QLabel(target); + m_dropHint->setAttribute(Qt::WA_TransparentForMouseEvents); + m_dropHint->setAlignment(Qt::AlignCenter); + m_dropHint->setWordWrap(true); + m_dropHint->setStyleSheet("QLabel { border: 2px dashed palette(highlight); color: palette(text);" + " background: rgba(40, 110, 160, 80); }"); + } + if (m_dropHint->parentWidget() != target) m_dropHint->setParent(target); + m_dropHint->setGeometry(target->rect().adjusted(2, 2, -2, -2)); + m_dropHint->setText(error.isEmpty() ? QCoreApplication::translate("ChatGui", "Drop a file to attach") : error); + m_dropHint->show(); m_dropHint->raise(); + drop->setDropAction(Qt::CopyAction); drop->accept(); + return true; + } + if (m_dropHint) m_dropHint->hide(); + if (!error.isEmpty()) { rejectAttachment(error); drop->ignore(); return true; } + const QString source = urls.front().toLocalFile(); + const quint64 context = m_attachmentContext; + GUI_INFO("attachment dropped: target=%s source=%s", qPrintable(m_channelIdForRecord), qPrintable(source)); + // Завершаем native drag до возможного модального выбора режима видео; контекст проверяется повторно. + QTimer::singleShot(0, this, [this, source, context] { prepareAttachment(source, context); }); + drop->setDropAction(Qt::CopyAction); drop->accept(); + return true; + } + } if (obj == m_input && event->type() == QEvent::KeyPress) { auto *ke = static_cast(event); if (ke->key() == Qt::Key_Return || ke->key() == Qt::Key_Enter) { @@ -283,7 +364,11 @@ void InputBar::onSendClicked() { } void InputBar::setRecordingContext(const QString& mediaDirBase, const QString& channelId) { - if (m_channelIdForRecord != channelId) clearPendingAttachment(); + if (m_channelIdForRecord != channelId || m_mediaDirBase != mediaDirBase) { + ++m_attachmentContext; + clearPendingAttachment(); + if (m_dropHint) m_dropHint->hide(); + } m_mediaDirBase = mediaDirBase; m_channelIdForRecord = channelId; showPreparation(); @@ -298,6 +383,7 @@ void InputBar::setDmMode(bool enabled) { void InputBar::setMediaEnabled(bool enabled) { if (m_pttBtn) m_pttBtn->setEnabled(enabled); if (m_attachBtn) m_attachBtn->setEnabled(enabled); + if (!enabled && m_dropHint) m_dropHint->hide(); } void InputBar::onPttPressed() { @@ -331,35 +417,30 @@ void InputBar::onPttReleased() { } void InputBar::onAttachClicked() { - if (m_channelIdForRecord.isEmpty()) return; + if (!canAttachFile()) return; + const quint64 context = m_attachmentContext; QString srcPath = QFileDialog::getOpenFileName(this, QCoreApplication::translate("ChatGui", "Attach file")); if (srcPath.isEmpty()) return; + prepareAttachment(srcPath, context); +} +/* Выбор через скрепку и drop одинаково проверяют файл и заменяют прежний preview только после успеха. */ +bool InputBar::prepareAttachment(const QString& srcPath, quint64 context) { + if (context != m_attachmentContext || !canAttachFile()) { + rejectAttachment(QCoreApplication::translate("ChatGui", "Attachment cancelled: the chat changed or attachments are unavailable."), srcPath); + return false; + } QFileInfo fi(srcPath); - if (m_dmMode && !QStringList{"mp4", "mov", "mkv", "avi", "webm", "m4v"}.contains(fi.suffix().toLower())) { - m_pendingAttachSrc = srcPath; - m_pendingAttachDest = srcPath; - m_pendingAttachName = fi.fileName(); - m_pendingAttachSize = fi.size(); - m_pendingAttachAsVideo = m_pendingAttachAsImage = false; - m_attachPreviewLabel->setText(QString::fromUtf8("📎 %1 (%2)").arg(fi.fileName(), formatFileSize(fi.size()))); - m_attachPreview->show(); - GUI_INFO("InputBar: PM attachment selected name=%s bytes=%lld", qPrintable(fi.fileName()), (long long)fi.size()); - return; + if (!fi.isFile() || !fi.isReadable()) { + rejectAttachment(QCoreApplication::translate("ChatGui", "Select an existing, readable file."), srcPath); return false; + } + if (fi.size() == 0 && (!m_dmMode || isVideoFile(fi.suffix()))) { + rejectAttachment(QCoreApplication::translate("ChatGui", "Cannot attach an empty file."), srcPath); return false; } - if (fi.size() == 0) { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "InputBar::onAttachClicked: empty file"); return; } - - QString mediaPath = m_mediaDirBase + "/" + m_channelIdForRecord; - VoiceEncoder::ensureMediaDir(mediaPath); - QDir().mkpath(mediaPath); - - QDateTime now = QDateTime::currentDateTime(); - QString ts = now.toString("yyyyMMdd-HHmmss"); - int rnd = rand() & 0xFFFF; bool asImage = !m_dmMode && isImageFile(fi.suffix()); bool asVideo = false; - if (!asImage && QStringList{"mp4", "mov", "mkv", "avi", "webm", "m4v"}.contains(fi.suffix().toLower())) { + if (!asImage && isVideoFile(fi.suffix())) { QMessageBox box(this); box.setWindowTitle(QCoreApplication::translate("ChatGui", "Send video")); box.setText(QCoreApplication::translate("ChatGui", "The file contains video. How would you like to send it?")); @@ -368,14 +449,27 @@ void InputBar::onAttachClicked() { box.exec(); asVideo = (box.clickedButton() == videoBtn); if (!box.clickedButton() || (!asVideo && box.clickedButton() != fileBtn)) { - return; /* закрыт без выбора */ + GUI_DEBUG("attachment selection cancelled: video mode source=%s", qPrintable(srcPath)); + return false; } - GUI_INFO("InputBar::onAttachClicked: video detected, mode=%s", + GUI_INFO("InputBar: video detected, mode=%s", asVideo ? "video" : "file"); } + if (context != m_attachmentContext || !canAttachFile()) { + rejectAttachment(QCoreApplication::translate("ChatGui", "Attachment cancelled: the chat changed or attachments are unavailable."), srcPath); + return false; + } + const QString mediaPath = m_mediaDirBase + "/" + m_channelIdForRecord; + if ((!m_dmMode || asVideo) && !QDir().mkpath(mediaPath)) { + GUI_ERROR("attachment media directory unavailable: target=%s path=%s", qPrintable(m_channelIdForRecord), qPrintable(mediaPath)); + rejectAttachment(QCoreApplication::translate("ChatGui", "Cannot create the attachment directory."), srcPath); return false; + } + const QString ts = QDateTime::currentDateTime().toString("yyyyMMdd-HHmmss"); + const int rnd = rand() & 0xFFFF; QString destPath; - if (asVideo) { + if (m_dmMode && !asVideo) destPath = srcPath; + else if (asVideo) { destPath = QString("%1/%2_%3_%4.mp4") .arg(mediaPath, ts).arg(fi.completeBaseName()).arg(rnd, 4, 16, QChar('0')); } else { @@ -396,8 +490,10 @@ void InputBar::onAttachClicked() { m_attachPreviewLabel->setText(labelText); m_attachPreview->show(); - GUI_INFO("InputBar::onAttachClicked: pending %s size=%lld video=%d image=%d", - qPrintable(srcPath), (long long)fi.size(), asVideo ? 1 : 0, asImage ? 1 : 0); + GUI_INFO("attachment preview ready: target=%s source=%s bytes=%lld video=%d image=%d", + qPrintable(m_channelIdForRecord), qPrintable(srcPath), (long long)fi.size(), asVideo ? 1 : 0, asImage ? 1 : 0); + m_input->setFocus(); + return true; } void InputBar::clearPendingAttachment() { diff --git a/tools/chatgui/src/inputbar.h b/tools/chatgui/src/inputbar.h index 86e9f684..4ab1a548 100644 --- a/tools/chatgui/src/inputbar.h +++ b/tools/chatgui/src/inputbar.h @@ -6,6 +6,7 @@ #include #include #include +#include class QTextEdit; class QPushButton; @@ -23,6 +24,8 @@ public: QWidget *parent = nullptr); void setRecordingContext(const QString& mediaDirBase, const QString& channelId); + /* Один обработчик URL-drop для composer и viewport истории; отправка остаётся по кнопке/Enter. */ + void installFileDropTarget(QWidget* target); /* Общий composer для канала и PM. */ void setDmMode(bool enabled); @@ -36,6 +39,7 @@ signals: const QString& displayName); void sendImageAttachment(const QString& target, const QString& mediaSrc, const QString& mediaDest, const QString& displayName); + void attachmentRejected(const QString& reason); protected: bool eventFilter(QObject *obj, QEvent *event) override; @@ -83,4 +87,10 @@ private: QHash> m_preparations; QSet m_preparationErrors; void showPreparation(); + bool canAttachFile() const; + bool prepareAttachment(const QString& source, quint64 context); + void rejectAttachment(const QString& reason, const QString& source = QString()); + QSet m_dropTargets; + QPointer m_dropHint; + quint64 m_attachmentContext = 0; }; diff --git a/tools/chatgui/src/messagelist.cpp b/tools/chatgui/src/messagelist.cpp index df9f88ab..7204ce83 100644 --- a/tools/chatgui/src/messagelist.cpp +++ b/tools/chatgui/src/messagelist.cpp @@ -489,6 +489,7 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent m_inputBar = new InputBar(m_emojiPanel, recorder, this); layout->addWidget(m_inputBar); + m_inputBar->installFileDropTarget(m_view->viewport()); connect(m_inputBar, &InputBar::sendMessage, this, [this](const QString &text) { if (m_currentChannelId.isEmpty()) {