|
|
|
|
@ -23,6 +23,11 @@
|
|
|
|
|
#include <QIcon> |
|
|
|
|
#include <QTimer> |
|
|
|
|
#include <QFileInfo> |
|
|
|
|
#include <QDragEnterEvent> |
|
|
|
|
#include <QDropEvent> |
|
|
|
|
#include <QMimeData> |
|
|
|
|
#include <QUrl> |
|
|
|
|
#include <QToolTip> |
|
|
|
|
#include <cmath> |
|
|
|
|
|
|
|
|
|
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<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) { |
|
|
|
|
auto *ke = static_cast<QKeyEvent *>(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() { |
|
|
|
|
|