650 lines
30 KiB
C++
650 lines
30 KiB
C++
#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));
|
||
}
|
||
}
|