|
|
|
|
@ -91,6 +91,17 @@ InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *par
|
|
|
|
|
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); |
|
|
|
|
@ -225,6 +236,55 @@ InputBar::~InputBar() {
|
|
|
|
|
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()}); |
|
|
|
|
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.source == m_pendingAttachSrc) clearPendingAttachment(); |
|
|
|
|
if (!replySending() && !m_replyFailed) clearReply(); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/* Qt доставляет drop в viewport, а не в QTextEdit/QListView. Родитель composer принимает drop на кнопках. */ |
|
|
|
|
void InputBar::installFileDropTarget(QWidget* target) { |
|
|
|
|
if (!target || m_dropTargets.contains(target)) return; |
|
|
|
|
@ -295,6 +355,7 @@ bool InputBar::eventFilter(QObject *obj, QEvent *event) {
|
|
|
|
|
} |
|
|
|
|
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"); |
|
|
|
|
@ -330,6 +391,11 @@ void InputBar::adjustInputHeight() {
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
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); |
|
|
|
|
@ -338,37 +404,34 @@ void InputBar::onSendClicked() {
|
|
|
|
|
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); |
|
|
|
|
m_attachPreview->hide(); |
|
|
|
|
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); return; |
|
|
|
|
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); |
|
|
|
|
m_attachPreview->hide(); |
|
|
|
|
} else { |
|
|
|
|
GUI_INFO("send: file=%s name=%s", qPrintable(m_pendingAttachSrc), qPrintable(m_pendingAttachName)); |
|
|
|
|
emit sendFileAttachment(m_channelIdForRecord, m_pendingAttachSrc, m_pendingAttachDest, m_pendingAttachName); |
|
|
|
|
m_attachPreview->hide(); |
|
|
|
|
emit sendFileAttachment(m_channelIdForRecord, m_pendingAttachSrc, m_pendingAttachDest, m_pendingAttachName, reply, requestId); |
|
|
|
|
} |
|
|
|
|
m_pendingAttachSrc.clear(); |
|
|
|
|
m_pendingAttachDest.clear(); |
|
|
|
|
m_pendingAttachName.clear(); |
|
|
|
|
m_pendingAttachSize = 0; |
|
|
|
|
m_pendingAttachAsVideo = false; |
|
|
|
|
m_pendingAttachAsImage = false; |
|
|
|
|
if (reply.isEmpty()) clearPendingAttachment(); |
|
|
|
|
} |
|
|
|
|
if (!text.isEmpty()) { |
|
|
|
|
emit sendMessage(text); |
|
|
|
|
m_input->clear(); |
|
|
|
|
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(); |
|
|
|
|
} |
|
|
|
|
@ -376,6 +439,7 @@ void InputBar::onSendClicked() {
|
|
|
|
|
|
|
|
|
|
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(); |
|
|
|
|
@ -398,10 +462,12 @@ void InputBar::setMediaEnabled(bool enabled) {
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
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; }"); |
|
|
|
|
} |
|
|
|
|
@ -424,6 +490,8 @@ void InputBar::onPttReleased() {
|
|
|
|
|
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); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|