Files
utun2/tools/chatgui/src/inputbar.cpp
T

650 lines
30 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include <QCoreApplication>
#include "inputbar.h"
#include "emojipanel.h"
#include "audiorecorder.h"
#include "voicemessageencoder.h"
#include "media_blocks.h"
#include "debug_ui.h"
#include <QDebug>
#include <QTextEdit>
#include <QPushButton>
#include <QHBoxLayout>
#include <QVBoxLayout>
#include <QFileDialog>
#include <QMessageBox>
#include <QKeyEvent>
#include <QFontMetrics>
#include <QScrollBar>
#include <QApplication>
#include <QLabel>
#include <QResizeEvent>
#include <QDir>
#include <QProgressBar>
#include <QIcon>
#include <QTimer>
#include <QFileInfo>
#include <QDragEnterEvent>
#include <QDropEvent>
#include <QMimeData>
#include <QUrl>
#include <QToolTip>
#include <cmath>
extern "C" {
#include "video/video.h"
#include "media_async/attachment_send.h"
#include "mem.h"
}
static bool isImageFile(const QString& suffix) {
QString s = suffix.toLower();
return s == "jpg" || s == "jpeg" || s == "png" || s == "gif" ||
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);
btn->setIconSize(QSize(20, 20));
btn->setFixedSize(34, 34);
btn->setCursor(Qt::PointingHandCursor);
btn->setToolTip(tooltip);
btn->setStyleSheet(
"QPushButton { border: none; border-radius: 4px; background: transparent; }"
"QPushButton:hover { background: palette(light); }"
);
return btn;
}
static QWidget *wrapBottomBtn(QPushButton *btn, int pad) {
auto *w = new QWidget(btn->parentWidget());
auto *l = new QVBoxLayout(w);
l->setContentsMargins(0, 0, 0, 0);
l->setSpacing(0);
l->addWidget(btn);
l->addSpacing(pad);
return w;
}
QString InputBar::formatFileSize(qint64 bytes) {
if (bytes < 1024) return QCoreApplication::translate("ChatGui", "%1B").arg(bytes);
double kb = bytes / 1024.0;
if (kb < 1024.0) return QCoreApplication::translate("ChatGui", "%1KB").arg(kb, 0, 'f', kb < 10.0 ? 1 : 0);
double mb = kb / 1024.0;
if (mb < 1024.0) return QCoreApplication::translate("ChatGui", "%1MB").arg(mb, 0, 'f', mb < 10.0 ? 1 : 0);
double gb = mb / 1024.0;
return QCoreApplication::translate("ChatGui", "%1GB").arg(gb, 0, 'f', 1);
}
InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *parent)
: QWidget(parent)
, m_input(new QTextEdit(this))
, m_emojiBtn(nullptr)
, m_pttBtn(nullptr)
, m_emojiPanel(emojiPanel)
, m_recorder(recorder)
{
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);
auto *prevLay = new QHBoxLayout(m_attachPreview);
prevLay->setContentsMargins(0, 2, 0, 2);
prevLay->setSpacing(4);
m_attachPreviewLabel = new QLabel(m_attachPreview);
m_attachPreviewLabel->setStyleSheet("background: palette(light); border-radius: 6px; padding: 2px 8px;");
prevLay->addWidget(m_attachPreviewLabel, 1);
m_attachClearBtn = new QPushButton(QString::fromUtf8("\xE2\x9C\x95"), m_attachPreview);
m_attachClearBtn->setFixedSize(20, 20);
m_attachClearBtn->setCursor(Qt::PointingHandCursor);
m_attachClearBtn->setStyleSheet("QPushButton { border: none; background: transparent; color: palette(text); }"
"QPushButton:hover { color: #e74c3c; }");
connect(m_attachClearBtn, &QPushButton::clicked, this, &InputBar::clearPendingAttachment);
prevLay->addWidget(m_attachClearBtn);
m_attachPreview->hide();
mainLayout->addWidget(m_attachPreview);
m_preparationLabel = new QLabel(this);
m_preparationLabel->hide();
mainLayout->addWidget(m_preparationLabel);
auto *layout = new QHBoxLayout();
layout->setSpacing(4);
m_attachBtn = makeIconBtn(QIcon(":/icons/attach.png"),
QCoreApplication::translate("ChatGui", "Attach"), this);
connect(m_attachBtn, &QPushButton::clicked, this, &InputBar::onAttachClicked);
layout->addWidget(wrapBottomBtn(m_attachBtn, 3), 0, Qt::AlignBottom);
m_input->setAcceptRichText(false);
m_input->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_input->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_input->setTabChangesFocus(true);
m_input->setStyleSheet(
"QTextEdit { border: 1px solid palette(mid); border-radius: 6px;"
" padding: 4px 8px; background: palette(base); }"
);
m_input->installEventFilter(this);
QFontMetrics fm(m_input->font());
m_input->setFixedHeight(fm.lineSpacing() + 12);
layout->addWidget(m_input, 1);
m_emojiBtn = makeIconBtn(QIcon(":/icons/emoji.png"),
QCoreApplication::translate("ChatGui", "Emoji"), this);
layout->addWidget(wrapBottomBtn(m_emojiBtn, 3), 0, Qt::AlignBottom);
m_pttBtn = makeIconBtn(QIcon(":/icons/mic.png"),
QCoreApplication::translate("ChatGui", "Voice message (hold)"), this);
layout->addWidget(wrapBottomBtn(m_pttBtn, 3), 0, Qt::AlignBottom);
auto *sendBtn = makeIconBtn(QIcon(":/icons/send.png"),
QCoreApplication::translate("ChatGui", "Send"), this);
sendBtn->setStyleSheet(sendBtn->styleSheet() +
"QPushButton { border: 1px solid palette(highlight); }");
layout->addWidget(sendBtn, 0, Qt::AlignBottom);
connect(m_input, &QTextEdit::textChanged, this, &InputBar::adjustInputHeight);
connect(sendBtn, &QPushButton::clicked, this, &InputBar::onSendClicked);
/* PTT button: press to record, release to stop */
connect(m_pttBtn, &QPushButton::pressed, this, &InputBar::onPttPressed);
connect(m_pttBtn, &QPushButton::released, this, &InputBar::onPttReleased);
if (m_emojiPanel) {
connect(m_emojiBtn, &QPushButton::clicked, this, [this]() {
m_emojiPanel->setVisible(!m_emojiPanel->isVisible());
});
connect(m_emojiPanel, &EmojiPanel::emojiSelected, this, [this](const QString &emoji) {
m_input->insertPlainText(emoji);
m_input->setFocus();
});
}
/* Recording overlay — appears over the text input during recording */
m_recordOverlay = new QWidget(this);
m_recordOverlay->setStyleSheet("background: rgba(231, 76, 60, 0.88); border-radius: 6px;");
m_recordOverlay->hide();
auto* roLayout = new QHBoxLayout(m_recordOverlay);
roLayout->setContentsMargins(12, 0, 8, 0);
roLayout->setSpacing(6);
m_recordLabel = new QLabel(m_recordOverlay);
m_recordLabel->setStyleSheet("color: white; font-size: 14px; font-weight: bold; background: transparent;");
roLayout->addWidget(m_recordLabel);
m_levelBar = new QProgressBar(m_recordOverlay);
m_levelBar->setRange(0, 100);
m_levelBar->setValue(0);
m_levelBar->setTextVisible(false);
m_levelBar->setFixedSize(80, 14);
m_levelBar->setStyleSheet(
"QProgressBar { border: 1px solid rgba(255,255,255,0.2); border-radius: 3px; background: rgba(0,0,0,0.3); }"
"QProgressBar::chunk { background: qlineargradient(x1:0, y1:0, x2:1, y2:0,"
" stop:0 #27ae60, stop:0.6 #f1c40f, stop:1 #e74c3c); border-radius: 2px; }");
roLayout->addWidget(m_levelBar);
roLayout->addStretch();
m_cancelRecBtn = new QPushButton(QString::fromUtf8("\xE2\x9C\x95"), m_recordOverlay);
m_cancelRecBtn->setFixedSize(24, 24);
m_cancelRecBtn->setStyleSheet(
"QPushButton { border: none; color: white; font-size: 16px; background: transparent; }"
"QPushButton:hover { color: #fdd; }");
connect(m_cancelRecBtn, &QPushButton::clicked, this, [this]() {
m_pttActive = false;
if (m_recorder && m_recorder->isRecording()) m_recorder->stopRecording();
});
roLayout->addWidget(m_cancelRecBtn);
if (recorder) {
connect(recorder, &AudioRecorder::recordingStarted, this, &InputBar::showRecordOverlay);
connect(recorder, &AudioRecorder::recordingStopped, this, [this](int) { hideRecordOverlay(); });
connect(recorder, &AudioRecorder::durationChanged, this, &InputBar::updateRecordingUI);
}
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);
});
}
/* QWidget уничтожает дочерние viewport после полей InputBar: снимаем callbacks до их уничтожения. */
InputBar::~InputBar() {
GUI_DEBUG("InputBar: removing file drop targets count=%d", int(m_dropTargets.size()));
for (auto* target : m_dropTargets) {
target->removeEventFilter(this);
disconnect(target, nullptr, this, nullptr);
}
m_dropTargets.clear();
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(), m_attachmentRevision});
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.attachmentRevision == m_attachmentRevision) clearPendingAttachment();
if (!replySending() && !m_replyFailed && m_input->toPlainText().isEmpty() && m_pendingAttachSrc.isEmpty()) clearReply();
}
/* 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);
if (target != this) 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_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");
return true;
} else {
onSendClicked();
return true;
}
}
}
return QWidget::eventFilter(obj, event);
}
void InputBar::adjustInputHeight() {
m_input->document()->adjustSize();
int lineSpacing = m_input->fontMetrics().lineSpacing();
int docH = (int)m_input->document()->size().height() + 12;
int maxH = 12 * lineSpacing + 12;
int h = qBound(lineSpacing + 12, docH, maxH);
m_input->setFixedHeight(h);
bool needScroll = docH > maxH;
m_input->setVerticalScrollBarPolicy(needScroll ? Qt::ScrollBarAlwaysOn
: Qt::ScrollBarAlwaysOff);
if (needScroll) {
QTextCursor c = m_input->textCursor();
if (c.atEnd())
m_input->verticalScrollBar()->setValue(m_input->verticalScrollBar()->maximum());
else
m_input->ensureCursorVisible();
}
}
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);
bool hasAttach = !m_pendingAttachSrc.isEmpty();
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, 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); 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);
} else {
GUI_INFO("send: file=%s name=%s", qPrintable(m_pendingAttachSrc), qPrintable(m_pendingAttachName));
emit sendFileAttachment(m_channelIdForRecord, m_pendingAttachSrc, m_pendingAttachDest, m_pendingAttachName, reply, requestId);
}
if (reply.isEmpty()) clearPendingAttachment();
}
if (!text.isEmpty()) {
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();
}
}
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();
}
m_mediaDirBase = mediaDirBase;
m_channelIdForRecord = channelId;
showPreparation();
}
void InputBar::setDmMode(bool enabled) {
m_dmMode = enabled;
m_pttBtn->setEnabled(true);
m_attachBtn->setEnabled(true);
}
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() {
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; }");
}
void InputBar::onPttReleased() {
if (!m_pttActive) return;
m_pttActive = false;
m_recorder->stopRecording();
m_pttBtn->setStyleSheet(
"QPushButton { border: none; border-radius: 4px; background: transparent; }"
"QPushButton:hover { background: palette(light); }");
int durationMs = m_recorder->durationMs();
if (durationMs < 500) {
GUI_INFO("InputBar: voice message too short (%dms), discarded", durationMs);
return;
}
auto* request = (struct attachment_send_req*)u_calloc(1, sizeof(struct attachment_send_req));
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);
}
void InputBar::onAttachClicked() {
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 (!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;
}
bool asImage = !m_dmMode && isImageFile(fi.suffix());
bool asVideo = false;
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?"));
QPushButton *fileBtn = box.addButton(QCoreApplication::translate("ChatGui", "As a file"), QMessageBox::AcceptRole);
QPushButton *videoBtn = box.addButton(QCoreApplication::translate("ChatGui", "As a video"), QMessageBox::DestructiveRole);
box.exec();
asVideo = (box.clickedButton() == videoBtn);
if (!box.clickedButton() || (!asVideo && box.clickedButton() != fileBtn)) {
GUI_DEBUG("attachment selection cancelled: video mode source=%s", qPrintable(srcPath));
return false;
}
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 (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 {
destPath = QString("%1/%2_%3_%4.%5")
.arg(mediaPath, ts).arg(fi.completeBaseName()).arg(rnd, 4, 16, QChar('0'))
.arg(fi.suffix().isEmpty() ? "bin" : fi.suffix());
}
m_pendingAttachSrc = srcPath;
++m_attachmentRevision;
m_pendingAttachDest = destPath;
m_pendingAttachName = fi.fileName();
m_pendingAttachSize = fi.size();
m_pendingAttachAsVideo = asVideo;
m_pendingAttachAsImage = asImage;
QString labelText = QString::fromUtf8("\xF0\x9F\x93\x8E %1 (%2)")
.arg(fi.fileName(), formatFileSize(fi.size()));
m_attachPreviewLabel->setText(labelText);
m_attachPreview->show();
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() {
++m_attachmentRevision;
m_pendingAttachSrc.clear();
m_pendingAttachDest.clear();
m_pendingAttachName.clear();
m_pendingAttachSize = 0;
m_pendingAttachAsVideo = false;
m_pendingAttachAsImage = false;
m_attachPreview->hide();
}
void InputBar::updatePreparation(const QString& target, const QByteArray& job, int state) {
if (state == 0) { m_preparations[target].insert(job); m_preparationErrors.remove(target); }
else {
m_preparations[target].remove(job);
if (m_preparations[target].isEmpty()) m_preparations.remove(target);
if (state == 2) m_preparationErrors.insert(target);
}
showPreparation();
}
void InputBar::showPreparation() {
QString status;
if (!m_preparations.value(m_channelIdForRecord).isEmpty()) status = QCoreApplication::translate("ChatGui", "Preparing media…");
else if (m_preparationErrors.contains(m_channelIdForRecord)) status = QCoreApplication::translate("ChatGui", "Failed to prepare attachment");
m_preparationLabel->setText(status);
m_preparationLabel->setVisible(!status.isEmpty());
}
void InputBar::resizeEvent(QResizeEvent* event) {
QWidget::resizeEvent(event);
if (m_recordOverlay) {
QRect r = m_input->geometry();
int margin = 4;
m_recordOverlay->setGeometry(r.adjusted(margin, margin, -margin, -margin));
m_recordOverlay->raise();
}
}
void InputBar::showRecordOverlay() {
if (m_recordOverlay) {
m_recordOverlay->setGeometry(m_input->geometry().adjusted(4, 4, -4, -4));
m_recordOverlay->show();
m_recordOverlay->raise();
m_recordLabel->setText(QCoreApplication::translate("ChatGui", "🔴 Recording 0:00"));
if (m_levelBar) m_levelBar->setValue(0);
}
}
void InputBar::hideRecordOverlay() {
if (m_recordOverlay) m_recordOverlay->hide();
}
void InputBar::updateRecordingUI(int ms) {
if (m_recordLabel) {
int secs = ms / 1000;
m_recordLabel->setText(QCoreApplication::translate("ChatGui", "🔴 Recording %1:%2")
.arg(secs / 60).arg(secs % 60, 2, 10, QChar('0')));
}
if (m_levelBar && m_recorder) {
float peak = m_recorder->peakLevel();
float db = peak > 0.0001f ? 20.0f * log10f(peak) : -70.0f;
int pct = (int)((db + 70.0f) / 70.0f * 100.0f);
if (pct < 0) pct = 0; if (pct > 100) pct = 100;
m_levelBar->setValue(pct);
const char* color = pct < 60 ? "#27ae60" : pct < 85 ? "#f1c40f" : "#e74c3c";
m_levelBar->setStyleSheet(QString(
"QProgressBar { border: 1px solid rgba(255,255,255,0.2); border-radius: 3px; background: rgba(0,0,0,0.3); }"
"QProgressBar::chunk { background: %1; border-radius: 2px; }").arg(color));
}
}