chatgui: stage dropped files through the attachment composer
This commit is contained in:
1 parent
7b1143b9d4
commit
0279ed84a9
3 files changed
+134
-27
No files matched your search
+123
-27
@@ -23,6 +23,11 @@
|
|||||||
#include <QIcon>
|
#include <QIcon>
|
||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
|
#include <QDragEnterEvent>
|
||||||
|
#include <QDropEvent>
|
||||||
|
#include <QMimeData>
|
||||||
|
#include <QUrl>
|
||||||
|
#include <QToolTip>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
|
||||||
extern "C" {
|
extern "C" {
|
||||||
@@ -37,6 +42,10 @@ static bool isImageFile(const QString& suffix) {
|
|||||||
s == "bmp" || s == "webp";
|
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) {
|
static QPushButton *makeIconBtn(const QIcon& icon, const QString &tooltip, QWidget *parent) {
|
||||||
auto *btn = new QPushButton(parent);
|
auto *btn = new QPushButton(parent);
|
||||||
btn->setIcon(icon);
|
btn->setIcon(icon);
|
||||||
@@ -198,9 +207,81 @@ InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *par
|
|||||||
}
|
}
|
||||||
|
|
||||||
mainLayout->addLayout(layout);
|
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) {
|
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<QDropEvent*>(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<QWidget*>(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) {
|
if (obj == m_input && event->type() == QEvent::KeyPress) {
|
||||||
auto *ke = static_cast<QKeyEvent *>(event);
|
auto *ke = static_cast<QKeyEvent *>(event);
|
||||||
if (ke->key() == Qt::Key_Return || ke->key() == Qt::Key_Enter) {
|
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) {
|
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_mediaDirBase = mediaDirBase;
|
||||||
m_channelIdForRecord = channelId;
|
m_channelIdForRecord = channelId;
|
||||||
showPreparation();
|
showPreparation();
|
||||||
@@ -298,6 +383,7 @@ void InputBar::setDmMode(bool enabled) {
|
|||||||
void InputBar::setMediaEnabled(bool enabled) {
|
void InputBar::setMediaEnabled(bool enabled) {
|
||||||
if (m_pttBtn) m_pttBtn->setEnabled(enabled);
|
if (m_pttBtn) m_pttBtn->setEnabled(enabled);
|
||||||
if (m_attachBtn) m_attachBtn->setEnabled(enabled);
|
if (m_attachBtn) m_attachBtn->setEnabled(enabled);
|
||||||
|
if (!enabled && m_dropHint) m_dropHint->hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
void InputBar::onPttPressed() {
|
void InputBar::onPttPressed() {
|
||||||
@@ -331,35 +417,30 @@ void InputBar::onPttReleased() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void InputBar::onAttachClicked() {
|
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"));
|
QString srcPath = QFileDialog::getOpenFileName(this, QCoreApplication::translate("ChatGui", "Attach file"));
|
||||||
if (srcPath.isEmpty()) return;
|
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);
|
QFileInfo fi(srcPath);
|
||||||
if (m_dmMode && !QStringList{"mp4", "mov", "mkv", "avi", "webm", "m4v"}.contains(fi.suffix().toLower())) {
|
if (!fi.isFile() || !fi.isReadable()) {
|
||||||
m_pendingAttachSrc = srcPath;
|
rejectAttachment(QCoreApplication::translate("ChatGui", "Select an existing, readable file."), srcPath); return false;
|
||||||
m_pendingAttachDest = srcPath;
|
}
|
||||||
m_pendingAttachName = fi.fileName();
|
if (fi.size() == 0 && (!m_dmMode || isVideoFile(fi.suffix()))) {
|
||||||
m_pendingAttachSize = fi.size();
|
rejectAttachment(QCoreApplication::translate("ChatGui", "Cannot attach an empty file."), srcPath); return false;
|
||||||
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.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 asImage = !m_dmMode && isImageFile(fi.suffix());
|
||||||
bool asVideo = false;
|
bool asVideo = false;
|
||||||
if (!asImage && QStringList{"mp4", "mov", "mkv", "avi", "webm", "m4v"}.contains(fi.suffix().toLower())) {
|
if (!asImage && isVideoFile(fi.suffix())) {
|
||||||
QMessageBox box(this);
|
QMessageBox box(this);
|
||||||
box.setWindowTitle(QCoreApplication::translate("ChatGui", "Send video"));
|
box.setWindowTitle(QCoreApplication::translate("ChatGui", "Send video"));
|
||||||
box.setText(QCoreApplication::translate("ChatGui", "The file contains video. How would you like to send it?"));
|
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();
|
box.exec();
|
||||||
asVideo = (box.clickedButton() == videoBtn);
|
asVideo = (box.clickedButton() == videoBtn);
|
||||||
if (!box.clickedButton() || (!asVideo && box.clickedButton() != fileBtn)) {
|
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");
|
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;
|
QString destPath;
|
||||||
if (asVideo) {
|
if (m_dmMode && !asVideo) destPath = srcPath;
|
||||||
|
else if (asVideo) {
|
||||||
destPath = QString("%1/%2_%3_%4.mp4")
|
destPath = QString("%1/%2_%3_%4.mp4")
|
||||||
.arg(mediaPath, ts).arg(fi.completeBaseName()).arg(rnd, 4, 16, QChar('0'));
|
.arg(mediaPath, ts).arg(fi.completeBaseName()).arg(rnd, 4, 16, QChar('0'));
|
||||||
} else {
|
} else {
|
||||||
@@ -396,8 +490,10 @@ void InputBar::onAttachClicked() {
|
|||||||
m_attachPreviewLabel->setText(labelText);
|
m_attachPreviewLabel->setText(labelText);
|
||||||
m_attachPreview->show();
|
m_attachPreview->show();
|
||||||
|
|
||||||
GUI_INFO("InputBar::onAttachClicked: pending %s size=%lld video=%d image=%d",
|
GUI_INFO("attachment preview ready: target=%s source=%s bytes=%lld video=%d image=%d",
|
||||||
qPrintable(srcPath), (long long)fi.size(), asVideo ? 1 : 0, asImage ? 1 : 0);
|
qPrintable(m_channelIdForRecord), qPrintable(srcPath), (long long)fi.size(), asVideo ? 1 : 0, asImage ? 1 : 0);
|
||||||
|
m_input->setFocus();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void InputBar::clearPendingAttachment() {
|
void InputBar::clearPendingAttachment() {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
#include <QHash>
|
#include <QHash>
|
||||||
#include <QSet>
|
#include <QSet>
|
||||||
|
#include <QPointer>
|
||||||
|
|
||||||
class QTextEdit;
|
class QTextEdit;
|
||||||
class QPushButton;
|
class QPushButton;
|
||||||
@@ -23,6 +24,8 @@ public:
|
|||||||
QWidget *parent = nullptr);
|
QWidget *parent = nullptr);
|
||||||
|
|
||||||
void setRecordingContext(const QString& mediaDirBase, const QString& channelId);
|
void setRecordingContext(const QString& mediaDirBase, const QString& channelId);
|
||||||
|
/* Один обработчик URL-drop для composer и viewport истории; отправка остаётся по кнопке/Enter. */
|
||||||
|
void installFileDropTarget(QWidget* target);
|
||||||
|
|
||||||
/* Общий composer для канала и PM. */
|
/* Общий composer для канала и PM. */
|
||||||
void setDmMode(bool enabled);
|
void setDmMode(bool enabled);
|
||||||
@@ -36,6 +39,7 @@ signals:
|
|||||||
const QString& displayName);
|
const QString& displayName);
|
||||||
void sendImageAttachment(const QString& target, const QString& mediaSrc, const QString& mediaDest,
|
void sendImageAttachment(const QString& target, const QString& mediaSrc, const QString& mediaDest,
|
||||||
const QString& displayName);
|
const QString& displayName);
|
||||||
|
void attachmentRejected(const QString& reason);
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
bool eventFilter(QObject *obj, QEvent *event) override;
|
bool eventFilter(QObject *obj, QEvent *event) override;
|
||||||
@@ -83,4 +87,10 @@ private:
|
|||||||
QHash<QString, QSet<QByteArray>> m_preparations;
|
QHash<QString, QSet<QByteArray>> m_preparations;
|
||||||
QSet<QString> m_preparationErrors;
|
QSet<QString> m_preparationErrors;
|
||||||
void showPreparation();
|
void showPreparation();
|
||||||
|
bool canAttachFile() const;
|
||||||
|
bool prepareAttachment(const QString& source, quint64 context);
|
||||||
|
void rejectAttachment(const QString& reason, const QString& source = QString());
|
||||||
|
QSet<QObject*> m_dropTargets;
|
||||||
|
QPointer<QLabel> m_dropHint;
|
||||||
|
quint64 m_attachmentContext = 0;
|
||||||
};
|
};
|
||||||
@@ -489,6 +489,7 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent
|
|||||||
|
|
||||||
m_inputBar = new InputBar(m_emojiPanel, recorder, this);
|
m_inputBar = new InputBar(m_emojiPanel, recorder, this);
|
||||||
layout->addWidget(m_inputBar);
|
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) {
|
||||||
if (m_currentChannelId.isEmpty()) {
|
if (m_currentChannelId.isEmpty()) {
|
||||||
|
|||||||
Reference in new issue
Block a user