diff --git a/tools/chatgui/src/inputbar.cpp b/tools/chatgui/src/inputbar.cpp index 79f5d2f5..e39af4d0 100644 --- a/tools/chatgui/src/inputbar.cpp +++ b/tools/chatgui/src/inputbar.cpp @@ -32,7 +32,14 @@ #include #include #include +#include +#include +#include +#include +#include +#include #include +#include extern "C" { #include "video/video.h" @@ -42,6 +49,105 @@ extern "C" { quint64 InputBar::s_submissionSequence = 0; +namespace { +constexpr qint64 clipboardMaxBytes = 128 * 1024 * 1024; +constexpr qint64 clipboardMaxPixels = 32 * 1024 * 1024; +const QList clipboardImageFormats = {"image/png", "image/jpeg", "image/bmp", "image/webp"}; + +bool hasClipboardImage(const QMimeData* source) { + if (!source) return false; + if (source->hasImage()) return true; + for (const auto& format : clipboardImageFormats) if (source->hasFormat(QString::fromLatin1(format))) return true; + return false; +} + +bool validClipboardSize(const QSize& size) { + return size.width() > 0 && size.height() > 0 && size.width() <= 16384 && size.height() <= 16384 && + qint64(size.width()) * size.height() <= clipboardMaxPixels; +} + +/* Общая точка вставки для клавиатуры и контекстного меню; обычный текст обрабатывает QTextEdit. */ +class ComposerTextEdit final : public QTextEdit { +public: + ComposerTextEdit(QWidget* parent, std::function pasteImage) + : QTextEdit(parent), m_pasteImage(std::move(pasteImage)) {} +protected: + bool canInsertFromMimeData(const QMimeData* source) const override { + return hasClipboardImage(source) || QTextEdit::canInsertFromMimeData(source); + } + void insertFromMimeData(const QMimeData* source) override { + if (hasClipboardImage(source)) { + if (isReadOnly()) { GUI_WARN("clipboard image: insertion rejected in read-only composer"); return; } + m_pasteImage(source); + } else QTextEdit::insertFromMimeData(source); + } +private: + std::function m_pasteImage; +}; +} + +/* Не QObject: результат worker и GUI совместно держат путь, последний владелец удаляет только свой PNG. */ +struct ClipboardPng { + QString path, name; + QImage thumbnail; + QSize dimensions; + qint64 bytes = 0; + ~ClipboardPng() { + if (path.isEmpty()) return; + QFile file(path); + if (!file.remove()) GUI_WARN("clipboard image: cleanup failed path=%s error=%s", qPrintable(path), qPrintable(file.errorString())); + else GUI_DEBUG("clipboard image: temporary PNG removed path=%s", qPrintable(path)); + } +}; + +/* Возврат происходит после destructor QTemporaryFile: его native handle уже закрыт, в том числе в Windows. */ +static std::shared_ptr encodeClipboardPng(QImage image, const QByteArray& encoded, + const QString& name, QString& error) { + if (image.isNull()) { + QBuffer buffer; buffer.setData(encoded); buffer.open(QIODevice::ReadOnly); + QImageReader reader(&buffer); reader.setAutoTransform(true); + if (!reader.canRead()) error = reader.errorString(); + else if (!validClipboardSize(reader.size())) error = QStringLiteral("invalid dimensions or more than 32 megapixels"); + else { image = reader.read(); if (image.isNull()) error = reader.errorString(); } + } + if (error.isEmpty() && (!validClipboardSize(image.size()) || image.sizeInBytes() > clipboardMaxBytes)) + error = QStringLiteral("image exceeds 16384 pixels per side, 32 megapixels or 128 MiB"); + if (!error.isEmpty()) return {}; + image.setDevicePixelRatio(1); + auto result = std::make_shared(); + QTemporaryFile file(QDir::tempPath() + "/vibechat-clipboard-XXXXXX.png"); + if (!file.open()) { error = file.errorString(); return {}; } + result->path = file.fileName(); file.setAutoRemove(false); + QImageWriter writer(&file, "png"); + if (!writer.write(image)) { error = writer.errorString(); return {}; } + if (!file.flush()) { error = file.errorString(); return {}; } + result->name = name; result->dimensions = image.size(); result->bytes = file.size(); + result->thumbnail = image.scaled(80, 60, Qt::KeepAspectRatio, Qt::SmoothTransformation); + return result; +} + +/* Независимый source для worker: удаление/замена черновика не влияет на запрос, а отказ не мешает повтору. */ +static bool copyClipboardPng(const QString& source, struct attachment_send_req* request, QString& error) { + ClipboardPng temporary; + QTemporaryFile output(QDir::tempPath() + "/vibechat-clipboard-send-XXXXXX.png"); + if (!output.open()) { error = output.errorString(); return false; } + temporary.path = output.fileName(); output.setAutoRemove(false); + const QByteArray path = temporary.path.toUtf8(); + if (path.size() >= int(sizeof(request->source))) { error = QStringLiteral("temporary source path is too long"); return false; } + QFile input(source); + if (!input.open(QIODevice::ReadOnly)) { error = input.errorString(); return false; } + while (!input.atEnd()) { + const QByteArray bytes = input.read(65536); + if (input.error() != QFileDevice::NoError) { error = input.errorString(); return false; } + if (output.write(bytes) != bytes.size()) { error = output.errorString(); return false; } + } + if (!output.flush()) { error = output.errorString(); return false; } + snprintf(request->source, sizeof(request->source), "%s", path.constData()); + request->temporary_source = 1; + temporary.path.clear(); + return true; +} + static bool isImageFile(const QString& suffix) { QString s = suffix.toLower(); return s == "jpg" || s == "jpeg" || s == "png" || s == "gif" || @@ -88,12 +194,13 @@ QString InputBar::formatFileSize(qint64 bytes) { InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *parent) : QWidget(parent) - , m_input(new QTextEdit(this)) + , m_input(new ComposerTextEdit(this, [this](const QMimeData* source) { pasteImage(source); })) , m_emojiBtn(nullptr) , m_pttBtn(nullptr) , m_emojiPanel(emojiPanel) , m_recorder(recorder) { + m_clipboardPool.setMaxThreadCount(1); auto *mainLayout = new QVBoxLayout(this); mainLayout->setContentsMargins(8, 4, 8, 0); mainLayout->setSpacing(2); @@ -114,7 +221,12 @@ InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *par auto *prevLay = new QHBoxLayout(m_attachPreview); prevLay->setContentsMargins(0, 2, 0, 2); prevLay->setSpacing(4); + m_attachThumbnail = new QLabel(m_attachPreview); + m_attachThumbnail->setObjectName("clipboardImageThumbnail"); + m_attachThumbnail->hide(); + prevLay->addWidget(m_attachThumbnail); m_attachPreviewLabel = new QLabel(m_attachPreview); + m_attachPreviewLabel->setTextFormat(Qt::PlainText); m_attachPreviewLabel->setStyleSheet("background: palette(light); border-radius: 6px; padding: 2px 8px;"); prevLay->addWidget(m_attachPreviewLabel, 1); m_encryptCheck = new QCheckBox(QCoreApplication::translate("ChatGui", "Encrypt attachment"), m_attachPreview); @@ -137,6 +249,7 @@ InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *par mainLayout->addWidget(m_attachPreview); m_preparationLabel = new QLabel(this); + m_preparationLabel->setObjectName("mediaPreparationStatus"); m_preparationLabel->hide(); mainLayout->addWidget(m_preparationLabel); @@ -244,6 +357,9 @@ InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *par /* QWidget уничтожает дочерние viewport после полей InputBar: снимаем callbacks до их уничтожения. */ InputBar::~InputBar() { + cancelClipboardPreparation(); + m_clipboardPool.waitForDone(); + m_clipboardDraft.reset(); GUI_DEBUG("InputBar: removing file drop targets count=%d", int(m_dropTargets.size())); for (auto* target : m_dropTargets) { target->removeEventFilter(this); @@ -281,7 +397,7 @@ bool InputBar::submissionPending() const { /* Канальный текст и каждая часть ответа имеют собственное подтверждение и снимок черновика. */ quint64 InputBar::beginSubmission(const QByteArray& reply, quint64 generation, const QString& target, const QString& text, const QString& source) { - if (reply.isEmpty() && text.isEmpty() && (source.isEmpty() || m_dmMode)) return 0; + if (reply.isEmpty() && text.isEmpty() && (source.isEmpty() || (m_dmMode && !m_clipboardDraft))) return 0; const quint64 request = ++s_submissionSequence; m_pendingSubmissions.insert(request, {generation, target, text, source, m_input->document()->revision(), m_attachmentRevision}); GUI_DEBUG("send: pending ch=%s reply=%s request=%llu", qPrintable(target), reply.toHex().constData(), (unsigned long long)request); @@ -319,7 +435,8 @@ void InputBar::submissionResult(quint64 request, bool success) { } if (!pending.text.isEmpty() && pending.textRevision == m_input->document()->revision()) m_input->clear(); if (!pending.source.isEmpty() && pending.attachmentRevision == m_attachmentRevision) clearPendingAttachment(); - if (!submissionPending() && !m_submissionFailed && m_input->toPlainText().isEmpty() && m_pendingAttachSrc.isEmpty()) clearReply(); + if (!submissionPending() && !m_submissionFailed && !m_clipboardPreparing && + m_input->toPlainText().isEmpty() && m_pendingAttachSrc.isEmpty()) clearReply(); } /* Черновик и выбранный ответ восстанавливаются после сохранения/отмены. Вложения остаются в composer. */ @@ -327,6 +444,7 @@ void InputBar::editMessage(const QByteArray& id, quint64 version, const QString& if (m_dmMode || id.size() != 32 || !version || m_editRequest || submissionPending() || m_pttActive) { GUI_WARN("edit: composer busy/invalid target=%s", qPrintable(m_channelIdForRecord)); return; } + cancelClipboardPreparation(); if (!m_editId.isEmpty()) cancelEdit(); m_savedText = m_input->toPlainText(); m_savedReply = m_replyTo; m_savedReplyLabel = m_replyLabel->text(); clearReply(); m_editId = id; m_editVersion = version; @@ -369,6 +487,78 @@ void InputBar::rejectAttachment(const QString& reason, const QString& source) { emit attachmentRejected(reason); } +void InputBar::cancelClipboardPreparation() { + ++m_clipboardGeneration; + m_clipboardPool.clear(); + m_clipboardPreparing = false; + showPreparation(); +} + +void InputBar::pasteImage(const QMimeData* source) { + if (!canAttachFile()) { + rejectAttachment(QCoreApplication::translate("ChatGui", "Images cannot be pasted in the current composer state.")); return; + } + const quint64 context = m_attachmentContext, generation = ++m_clipboardGeneration; + const QString target = m_channelIdForRecord; + m_clipboardPool.clear(); m_clipboardPreparing = true; showPreparation(); + GUI_DEBUG("clipboard image: snapshot target=%s generation=%llu formats=%s", qPrintable(target), + (unsigned long long)generation, qPrintable(source->formats().join(","))); + QImage image; + QByteArray representation = "Qt image"; + if (source->hasImage()) { + const QVariant data = source->imageData(); + image = qvariant_cast(data); + if (image.isNull() && data.canConvert()) image = qvariant_cast(data).toImage(); + } + QByteArray encoded; + if (image.isNull()) for (const auto& format : clipboardImageFormats) { + if (!source->hasFormat(QString::fromLatin1(format))) continue; + encoded = source->data(QString::fromLatin1(format)); + if (!encoded.isEmpty()) { representation = format; break; } + } + if (context != m_attachmentContext || generation != m_clipboardGeneration) { + GUI_DEBUG("clipboard image: snapshot superseded target=%s generation=%llu", qPrintable(target), + (unsigned long long)generation); return; + } + if ((!image.isNull() && (!validClipboardSize(image.size()) || image.sizeInBytes() > clipboardMaxBytes)) || + encoded.size() > clipboardMaxBytes || (image.isNull() && encoded.isEmpty())) { + m_clipboardPreparing = false; showPreparation(); + GUI_WARN("clipboard image: invalid snapshot target=%s dimensions=%dx%d image_bytes=%lld encoded_bytes=%lld", + qPrintable(target), image.width(), image.height(), (long long)image.sizeInBytes(), (long long)encoded.size()); + rejectAttachment(QCoreApplication::translate("ChatGui", "Cannot paste image: invalid data or more than 32 megapixels / 128 MiB.")); + return; + } + const QString name = "clipboard-" + QDateTime::currentDateTime().toString("yyyyMMdd-HHmmss-zzz") + ".png"; + ++m_attachmentRevision; + GUI_INFO("clipboard image: preparing PNG target=%s generation=%llu representation=%s dimensions=%dx%d encoded_bytes=%lld", + qPrintable(target), (unsigned long long)generation, representation.constData(), image.width(), image.height(), + (long long)encoded.size()); + m_clipboardPool.start(QRunnable::create([this, image, encoded, name, target, context, generation] { + QString error; + const auto result = encodeClipboardPng(image, encoded, name, error); + if (!result) GUI_ERROR("clipboard image: PNG preparation failed target=%s reason=%s", qPrintable(target), qPrintable(error)); + if (!QMetaObject::invokeMethod(this, [this, result, error, target, context, generation] { + if (context != m_attachmentContext || generation != m_clipboardGeneration) { + GUI_DEBUG("clipboard image: stale PNG discarded target=%s generation=%llu", qPrintable(target), + (unsigned long long)generation); return; + } + m_clipboardPreparing = false; showPreparation(); + if (!result) { + rejectAttachment(QCoreApplication::translate("ChatGui", "Could not prepare clipboard image: %1").arg(error)); return; + } + if (!prepareAttachment(result->path, context)) return; + m_clipboardDraft = result; + m_pendingAttachName = result->name; + m_attachPreviewLabel->setText(QString("%1 (%2, %3×%4)").arg(result->name, formatFileSize(result->bytes)) + .arg(result->dimensions.width()).arg(result->dimensions.height())); + m_attachThumbnail->setPixmap(QPixmap::fromImage(result->thumbnail)); m_attachThumbnail->show(); + GUI_INFO("clipboard image: preview ready target=%s source=%s dimensions=%dx%d bytes=%lld", + qPrintable(target), qPrintable(result->path), result->dimensions.width(), result->dimensions.height(), + (long long)result->bytes); + }, Qt::QueuedConnection)) GUI_ERROR("clipboard image: cannot queue PNG result target=%s", qPrintable(target)); + })); +} + bool InputBar::eventFilter(QObject *obj, QEvent *event) { if (m_dropTargets.contains(obj)) { if (event->type() == QEvent::DragLeave) { @@ -464,6 +654,9 @@ void InputBar::onSendClicked() { m_editRequest = ++s_submissionSequence; m_input->setReadOnly(true); emit saveEdit(m_channelIdForRecord, m_editId, m_editVersion, text, m_editRequest); return; } + if (m_clipboardPreparing) { + GUI_WARN("send: clipboard PNG still preparing target=%s", qPrintable(m_channelIdForRecord)); return; + } if (submissionPending()) { GUI_WARN("send: submission still pending ch=%s", qPrintable(m_channelIdForRecord)); return; } @@ -486,15 +679,17 @@ void InputBar::onSendClicked() { const bool typedImage = m_pendingAttachAsImage && QStringList{"jpg", "jpeg", "png"}.contains(QFileInfo(attachmentSource).suffix().toLower()); if (typedImage) { - QImageReader reader(attachmentSource); reader.setAutoTransform(true); - const QByteArray format = reader.format().toLower(); - QSize size = reader.size(); - if (reader.transformation() & QImageIOHandler::TransformationRotate90) size.transpose(); + QByteArray format; QSize size; QString readError; bool readable; + { // Reader должен закрыть исходник до синхронного submissionResult, который может удалить PNG черновика. + QImageReader reader(attachmentSource); reader.setAutoTransform(true); + format = reader.format().toLower(); size = reader.size(); readable = reader.canRead(); readError = reader.errorString(); + if (reader.transformation() & QImageIOHandler::TransformationRotate90) size.transpose(); + } const QByteArray source = attachmentSource.toUtf8(), name = attachmentName.toUtf8(); - if (!reader.canRead() || (format != "jpeg" && format != "png") || size.width() <= 0 || size.height() <= 0 || + if (!readable || (format != "jpeg" && format != "png") || size.width() <= 0 || size.height() <= 0 || size.width() > 16384 || size.height() > 16384 || source.size() >= 1024 || name.size() >= 256) { GUI_ERROR("image rejected: source=%s format=%s dimensions=%dx%d error=%s", qPrintable(attachmentSource), - format.constData(), size.width(), size.height(), qPrintable(reader.errorString())); + format.constData(), size.width(), size.height(), qPrintable(readError)); submissionResult(requestId, false); rejectAttachment(QCoreApplication::translate("ChatGui", "Cannot read the image or its dimensions are too large."), attachmentSource); return; @@ -508,6 +703,15 @@ void InputBar::onSendClicked() { snprintf(request->info.name, sizeof(request->info.name), "%s", name.constData()); if (reply.size() == 32) memcpy(request->reply_to, reply.constData(), 32); request->request_id = requestId; + if (m_clipboardDraft) { + QString error; + if (!copyClipboardPng(attachmentSource, request, error)) { + GUI_ERROR("clipboard image: send snapshot failed source=%s reason=%s", qPrintable(attachmentSource), qPrintable(error)); + attachment_send_discard(request); submissionResult(requestId, false); return; + } + GUI_DEBUG("clipboard image: send owns snapshot target=%s draft=%s source=%s request=%llu", qPrintable(target), + qPrintable(attachmentSource), request->source, (unsigned long long)requestId); + } GUI_INFO("image queued: target=%s format=%s dimensions=%dx%d encrypted=%d", qPrintable(target), format.constData(), size.width(), size.height(), encrypted); emit submitAttachment(target, request); @@ -615,13 +819,14 @@ void InputBar::setMediaEnabled(bool enabled) { if (m_encryptCheck) m_encryptCheck->setEnabled(enabled); if (m_pttBtn) m_pttBtn->setEnabled(enabled && m_editId.isEmpty()); if (m_attachBtn) m_attachBtn->setEnabled(enabled && m_editId.isEmpty()); + if (!enabled) cancelClipboardPreparation(); if (!enabled && m_dropHint) m_dropHint->hide(); } void InputBar::onPttPressed() { if (!m_editId.isEmpty()) { GUI_WARN("voice: blocked while editing"); return; } if (!isEnabled() || m_channelIdForRecord.isEmpty()) { GUI_WARN("voice: composer unavailable"); return; } - if (submissionPending()) { GUI_WARN("send: recording blocked while submission pending"); return; } + if (submissionPending() || m_clipboardPreparing) { GUI_WARN("send: recording blocked while submission/PNG pending"); return; } if (!m_recorder || !m_recorder->isInitialized()) return; m_recorder->startRecording(); m_pttActive = m_recorder->isRecording(); @@ -724,6 +929,9 @@ bool InputBar::prepareAttachment(const QString& srcPath, quint64 context) { } m_encryptCheck->setChecked(false); + cancelClipboardPreparation(); + m_clipboardDraft.reset(); + m_attachThumbnail->clear(); m_attachThumbnail->hide(); m_pendingAttachSrc = srcPath; ++m_attachmentRevision; m_pendingAttachDest = destPath; @@ -744,6 +952,9 @@ bool InputBar::prepareAttachment(const QString& srcPath, quint64 context) { } void InputBar::clearPendingAttachment() { + cancelClipboardPreparation(); + m_clipboardDraft.reset(); + m_attachThumbnail->clear(); m_attachThumbnail->hide(); m_encryptCheck->setChecked(false); ++m_attachmentRevision; m_pendingAttachSrc.clear(); @@ -772,7 +983,8 @@ void InputBar::updatePreparation(const QString& target, const QByteArray& job, i void InputBar::showPreparation() { QString status; - if (!m_preparations.value(m_channelIdForRecord).isEmpty()) status = QCoreApplication::translate("ChatGui", "Preparing media…"); + if (m_clipboardPreparing || !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()); diff --git a/tools/chatgui/src/inputbar.h b/tools/chatgui/src/inputbar.h index 6a56d699..72e63857 100644 --- a/tools/chatgui/src/inputbar.h +++ b/tools/chatgui/src/inputbar.h @@ -1,7 +1,7 @@ /* GUI-composer канала/PM: текст, emoji, ответ/редактирование, голосовая запись и preview одного вложения. * Сам не отправляет данные в сеть: владелец обрабатывает сигналы в GUI и передаёт работу ядру. * Для submitAttachment нужен ровно один обработчик, принимающий request вместе с PCM/compressor даже при отказе. - * Канальный текст/файлы/ответы/правки ждут submissionResult; PM-вложения очищаются сразу после сигнала отправки. + * Канальный текст/файлы/ответы/правки и PNG из буфера в PM ждут submissionResult; остальные PM-вложения очищаются сразу. * emojiPanel/recorder заимствованы, могут быть nullptr и должны жить во время использования; остальные виджеты — дочерние. */ #pragma once @@ -9,6 +9,8 @@ #include #include #include +#include +#include class QTextEdit; class QPushButton; @@ -18,7 +20,9 @@ class QProgressBar; class QTimer; class EmojiPanel; class AudioRecorder; +class QMimeData; struct attachment_send_req; +struct ClipboardPng; class InputBar : public QWidget { Q_OBJECT @@ -27,10 +31,10 @@ public: * Для PTT recorder должен быть init; его жизненным циклом и остановкой при закрытии владеет вызывающий. */ explicit InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *parent = nullptr); - ~InputBar() override; // Снимает drop-фильтры/callbacks, удаляет drag-подсказку; не уничтожает заимствованные объекты. + ~InputBar() override; // Ждёт PNG-worker, удаляет свой временный PNG, снимает drop-фильтры; заимствованные объекты не уничтожает. /* mediaDirBase — каталог media; channelId — канал либо dm:, dmGeneration — поколение PM (для канала 0). - * Изменение контекста отменяет edit/reply/preview и отложенный drop; обычный текст сохраняется. + * Изменение контекста отменяет edit/reply/preview, PNG-worker и отложенный drop; обычный текст сохраняется. * Запись уже фиксирует прежний target/generation; удаление текущего поколения PM отдельно очищает его черновик. */ void setRecordingContext(const QString& mediaDirBase, const QString& channelId, quint64 dmGeneration = 0); /* Очистить текст/reply/preview и остановить запись только при совпадении target/generation. @@ -52,7 +56,7 @@ public: * PM/пустая цель/неверный ключ отклоняются; edit сначала отменяется. Новый ответ имеет собственное поколение. */ void setReply(const QByteArray& messageId, const QString& author, const QString& preview); void clearReply(); // Снять ответ и сменить поколение; текст/preview вложения не очищаются, старые результаты не применяются. - /* Подтвердить канальный текст/файл, правку либо одну часть ответа по request; 0/неизвестный ID игнорируется. + /* Подтвердить канальный текст/файл, PNG из буфера в PM, правку либо одну часть ответа по request. * Ошибка сохраняет черновик и показывает отказ. Успех очищает лишь неизменённую ревизию текста/вложения; * ответ снимается после успеха всех частей и очистки черновика. Старое поколение/другая цель не затрагиваются. */ void submissionResult(quint64 request, bool success); @@ -90,7 +94,7 @@ private slots: void onPttPressed(); // Запускает recorder и фиксирует target/generation/reply только при успешном старте. void onPttReleased(); // Stop; запись от 500 мс передаётся через takeRecording/submitAttachment, меньшая отбрасывается. void onAttachClicked(); // QFileDialog → та же подготовка preview, что у drop; контекст проверяется после модального выбора. - void clearPendingAttachment(); // Снять preview/encrypt, увеличить ревизию; исходный файл не удаляется. + void clearPendingAttachment(); // Снять preview/encrypt и подготовку PNG; удалить только собственный временный исходник. void updateRecordingUI(int ms); // Длительность и RMS recorder (-70..0 dB → 0..100), чтение peakLevel потребляет метрику. void showRecordOverlay(); // Показать overlay и начальную длительность/уровень по recordingStarted. void hideRecordOverlay(); // Скрыть по recordingStopped; не выполняет отправку записи. @@ -104,7 +108,7 @@ private: quint64 attachmentRevision; // Ревизия preview для такой же защиты выбранного файла. }; /* Запомнить канальный текст/файл либо часть ответа; ID общий для всех composer, чтобы late result не попал новой форме. - * Вложение без source/reply и файл PM не требуют подтверждения: вернуть 0 и ничего не запоминать. */ + * Вложение без source/reply и обычный файл PM не требуют подтверждения: вернуть 0 и ничего не запоминать. */ quint64 beginSubmission(const QByteArray& reply, quint64 generation, const QString& target, const QString& text = {}, const QString& source = {}); bool submissionPending() const; // Есть ожидающая часть текущего контекста/выбора ответа. @@ -133,7 +137,8 @@ private: QPushButton *m_cancelRecBtn = nullptr; // Stop без отправки: снимает m_pttActive до остановки recorder. QProgressBar *m_levelBar = nullptr; // RMS последнего обработанного блока recorder, 0..100. QWidget *m_attachPreview = nullptr; // Preview выбранного файла, скрыт до успешной подготовки выбора. - QLabel *m_attachPreviewLabel = nullptr; // Имя/размер файла; его содержимое здесь не читается. + QLabel *m_attachPreviewLabel = nullptr; // Plain text: имя/размер файла, для PNG из буфера также размеры в пикселях. + QLabel *m_attachThumbnail = nullptr; // Миниатюра PNG из буфера; скрыта для обычного файла. QPushButton *m_attachClearBtn = nullptr; // Снять preview без удаления исходника. bool m_pttActive = false; // Composer начал запись, которую release ещё может отправить. bool m_dmMode = false; // PM: общий формат JPEG/PNG/voice/video, групповое шифрование скрыто. @@ -142,17 +147,23 @@ private: QString m_mediaDirBase; // База планируемых путей вложений; файлы PM без transcode остаются по исходному пути. QString m_channelIdForRecord; // Текущий target composer: канал либо dm:. QString m_recordTarget; // Target, зафиксированный при старте PTT, независимо от дальнейшего выбора канала. - QString m_pendingAttachSrc; // Локальный source preview, не копируется/удаляется composer. + QString m_pendingAttachSrc; // Локальный source preview; только PNG из буфера принадлежит composer. QString m_pendingAttachDest; // Планируемый mediaDirBase/target/имя либо исходный путь PM. QString m_pendingAttachName; // Исходное имя для отображения/метаданных; видео при отправке получает суффикс mp4. qint64 m_pendingAttachSize = 0; // Размер на момент выбора, bytes. bool m_pendingAttachAsVideo = false; // Пользователь выбрал подготовку/transcode видео. - bool m_pendingAttachAsImage = false; // Расширение картинки в канале; PM не использует этот режим. + bool m_pendingAttachAsImage = false; // Картинка в канале; в PM только JPEG/PNG. + std::shared_ptr m_clipboardDraft; // Собственный PNG черновика; каждой отправке принадлежит отдельная копия. + QThreadPool m_clipboardPool; // Один PNG-worker; destructor ждёт завершения, queued-результат владеет своим файлом. + quint64 m_clipboardGeneration = 0; // Номер вставки/отмены; устаревший результат не меняет preview. + bool m_clipboardPreparing = false; // Пока последний PNG кодируется, отправка заблокирована, старый preview сохраняется. QLabel* m_preparationLabel = nullptr; // Статус подготовки текущего target. QHash> m_preparations; // Target → UUID активных задач ядра. QSet m_preparationErrors; // Target с последней ошибкой, снимается при новом start или forget. QSet m_discardedPreparations; /* Завершения задач удалённой истории, ещё ожидаемые из ядра. */ void showPreparation(); // Обновить индикатор для текущей цели по кешу задач/ошибок. + void pasteImage(const QMimeData* source); // Синхронно забрать изображение/байты буфера, проверить лимиты, запустить PNG-worker. + void cancelClipboardPreparation(); // Сменить поколение, снять очередь PNG и индикатор; активный worker закончит без применения. bool canAttachFile() const; // Enabled, выбран target, скрепка разрешена, нет edit/PTT/записи recorder. /* Проверить source/context, выбрать режим видео и подготовить preview; true — выбор принят, false — отмена/отказ. * Старый preview заменяется лишь после успеха; source остаётся неизменным, для групп/видео создаётся каталог назначения. */ diff --git a/tools/chatgui/tests/test_file_drop.cpp b/tools/chatgui/tests/test_file_drop.cpp index 16b74633..070b0e89 100644 --- a/tools/chatgui/tests/test_file_drop.cpp +++ b/tools/chatgui/tests/test_file_drop.cpp @@ -32,6 +32,31 @@ extern "C" { #include #include #include +#include +#include +#include +#include +#include +#include +#include + +static QSet clipboardPaths() { + QSet paths; + const QDir directory(QDir::tempPath()); + for (const auto& name : directory.entryList({"vibechat-clipboard-*.png"}, QDir::Files)) paths.insert(directory.filePath(name)); + return paths; +} + +static void copyImage(const QImage& image, bool encoded = false) { + auto* mime = new QMimeData; + if (encoded) { + QByteArray bytes; QBuffer buffer(&bytes); buffer.open(QIODevice::WriteOnly); + if (!image.save(&buffer, "PNG")) qFatal("Cannot create clipboard PNG fixture"); + mime->setData("image/png", bytes); + } else mime->setImageData(image); + mime->setText("image alternate text"); mime->setHtml("image alternate text"); + QApplication::clipboard()->setMimeData(mime); +} static QString makeFile(const QTemporaryDir& dir, const QString& name, const QByteArray& contents = "test") { const QString path = dir.filePath(name); @@ -57,6 +82,199 @@ static bool dropUrls(QWidget* target, const QList& urls, bool process = tr class FileDropTest : public QObject { Q_OBJECT private slots: + void cleanup() { QApplication::clipboard()->clear(); } + + void clipboardImageUsesPngPipeline_data() { + QTest::addColumn("dm"); QTest::addColumn("encrypted"); QTest::addColumn("method"); + QTest::newRow("group-ctrl-v") << false << false << QString("ctrl"); + QTest::newRow("pm-ctrl-v") << true << false << QString("ctrl"); + QTest::newRow("encrypted-shift-insert") << false << true << QString("shift"); + QTest::newRow("group-context-menu-png") << false << false << QString("menu"); + } + void clipboardImageUsesPngPipeline() { + QFETCH(bool, dm); QFETCH(bool, encrypted); QFETCH(QString, method); + const auto before = clipboardPaths(); QTemporaryDir directory; QVERIFY(directory.isValid()); + InputBar bar(nullptr, nullptr); bar.setRecordingContext(directory.path(), dm ? "dm:456" : "123", dm ? 7 : 0); + bar.setDmMode(dm); bar.show(); + auto* editor = bar.findChild(); QVERIFY(editor); + auto* thumbnail = bar.findChild("clipboardImageThumbnail"); QVERIFY(thumbnail); + QSignalSpy errors(&bar, &InputBar::attachmentRejected), texts(&bar, &InputBar::sendMessage); + attachment_send_req* pending = nullptr; + const auto discard = qScopeGuard([&] { attachment_send_discard(pending); }); + connect(&bar, &InputBar::submitAttachment, &bar, [&](const QString& target, attachment_send_req* request) { + QCOMPARE(target, dm ? QString("dm:456") : QString("123")); QVERIFY(!pending); pending = request; + }); + QImage original(120, 80, QImage::Format_ARGB32); original.fill(qRgba(17, 61, 95, 127)); + editor->setPlainText("existing draft"); copyImage(original, method == "menu"); + if (method == "ctrl") QTest::keyClick(editor, Qt::Key_V, Qt::ControlModifier); + else if (method == "shift") QTest::keyClick(editor, Qt::Key_Insert, Qt::ShiftModifier); + else { + std::unique_ptr menu(editor->createStandardContextMenu()); QAction* paste = nullptr; + for (auto* action : menu->actions()) if (action->text().remove('&').startsWith("Paste")) paste = action; + QVERIFY(paste); QVERIFY(paste->isEnabled()); paste->trigger(); + } + QCOMPARE(editor->toPlainText(), QString("existing draft")); QVERIFY(!pending); QCOMPARE(texts.size(), 0); + QTest::keyClick(editor, Qt::Key_Return); QVERIFY(!pending); QCOMPARE(texts.size(), 0); // PNG ещё кодируется. + QApplication::clipboard()->setText("clipboard has changed"); + QTRY_VERIFY(!thumbnail->isHidden()); + QCOMPARE(errors.size(), 0); QCOMPARE(editor->toPlainText(), QString("existing draft")); + const auto drafts = clipboardPaths() - before; QCOMPARE(drafts.size(), 1); const QString draft = *drafts.begin(); + QImage decoded(draft); QCOMPARE(decoded.size(), original.size()); QCOMPARE(decoded.pixelColor(0, 0), original.pixelColor(0, 0)); + editor->clear(); const QByteArray reply(32, 'r'); if (!dm) bar.setReply(reply, "Member", "Original"); + if (encrypted) bar.findChild()->setChecked(true); + QTest::keyClick(editor, Qt::Key_Return); QVERIFY(pending); QVERIFY(pending->request_id); QVERIFY(pending->temporary_source); + QCOMPARE(pending->info.kind, ATTACHMENT_PNG); QCOMPARE(pending->info.width, 120); QCOMPARE(pending->info.height, 80); + QCOMPARE(pending->encrypted, int(encrypted)); QCOMPARE(pending->dm_generation, dm ? UINT64_C(7) : UINT64_C(0)); + QVERIFY(QString::fromUtf8(pending->info.name).startsWith("clipboard-")); + QVERIFY(QString::fromUtf8(pending->info.name).endsWith(".png")); + if (!dm) QCOMPARE(QByteArray(reinterpret_cast(pending->reply_to), 32), reply); + const QString source = QString::fromUtf8(pending->source); QVERIFY(source != draft); + QCOMPARE(QImage(source).pixelColor(0, 0), original.pixelColor(0, 0)); + bar.submissionResult(pending->request_id, true); QVERIFY(thumbnail->isHidden()); QVERIFY(!QFile::exists(draft)); + QVERIFY(QFile::exists(source)); // Worker владеет отдельным source после очистки preview. + attachment_send_discard(pending); pending = nullptr; QVERIFY(!QFile::exists(source)); + QCOMPARE(clipboardPaths(), before); + } + + void clipboardFailureKeepsDraftAndRetries_data() { + QTest::addColumn("dm"); QTest::newRow("group") << false; QTest::newRow("pm") << true; + } + void clipboardFailureKeepsDraftAndRetries() { + QFETCH(bool, dm); const auto before = clipboardPaths(); QTemporaryDir directory; + InputBar bar(nullptr, nullptr); bar.setRecordingContext(directory.path(), dm ? "dm:456" : "123", dm ? 7 : 0); + bar.setDmMode(dm); bar.show(); auto* editor = bar.findChild(); QVERIFY(editor); + auto* thumbnail = bar.findChild("clipboardImageThumbnail"); QVERIFY(thumbnail); + attachment_send_req* pending = nullptr; + const auto discard = qScopeGuard([&] { attachment_send_discard(pending); }); + connect(&bar, &InputBar::submitAttachment, &bar, [&](const QString&, attachment_send_req* request) { pending = request; }); + QImage image(32, 24, QImage::Format_RGB32); image.fill(Qt::green); copyImage(image); editor->paste(); + QTRY_VERIFY(!thumbnail->isHidden()); + const auto drafts = clipboardPaths() - before; QCOMPARE(drafts.size(), 1); const QString draft = *drafts.begin(); + QTest::keyClick(editor, Qt::Key_Return); QVERIFY(pending); const auto first = pending->request_id; QVERIFY(first); + QTest::keyClick(editor, Qt::Key_Return); QCOMPARE(pending->request_id, first); + attachment_send_discard(pending); pending = nullptr; + QSignalSpy errors(&bar, &InputBar::attachmentRejected); bar.submissionResult(first, false); + QCOMPARE(errors.size(), 1); QVERIFY(!thumbnail->isHidden()); QVERIFY(QFile::exists(draft)); + QTest::keyClick(editor, Qt::Key_Return); QVERIFY(pending); QVERIFY(pending->request_id != first); + bar.submissionResult(pending->request_id, true); QVERIFY(thumbnail->isHidden()); QVERIFY(!QFile::exists(draft)); + attachment_send_discard(pending); pending = nullptr; QCOMPARE(clipboardPaths(), before); + } + + void clipboardOldSuccessPreservesNewPaste() { + const auto before = clipboardPaths(); QTemporaryDir directory; + InputBar bar(nullptr, nullptr); bar.setRecordingContext(directory.path(), "123"); bar.show(); + auto* editor = bar.findChild(); auto* thumbnail = bar.findChild("clipboardImageThumbnail"); + QVERIFY(editor); QVERIFY(thumbnail); + attachment_send_req* pending = nullptr; + const auto discard = qScopeGuard([&] { attachment_send_discard(pending); }); + connect(&bar, &InputBar::submitAttachment, &bar, [&](const QString&, attachment_send_req* request) { pending = request; }); + QImage image(32, 24, QImage::Format_RGB32); image.fill(Qt::red); copyImage(image); editor->paste(); + QTRY_VERIFY(!thumbnail->isHidden()); QTest::keyClick(editor, Qt::Key_Return); QVERIFY(pending); + const auto oldDrafts = clipboardPaths() - before; QCOMPARE(oldDrafts.size(), 2); + image.fill(Qt::blue); copyImage(image); editor->paste(); + bar.submissionResult(pending->request_id, true); // Успех A не должен отменить кодирование B. + const QString sendSource = QString::fromUtf8(pending->source); + auto* status = bar.findChild("mediaPreparationStatus"); QVERIFY(status); QTRY_VERIFY(status->isHidden()); + const auto newDrafts = clipboardPaths() - before - QSet{sendSource}; QCOMPARE(newDrafts.size(), 1); + QCOMPARE(QImage(*newDrafts.begin()).pixelColor(0, 0), QColor(Qt::blue)); + QVERIFY(!thumbnail->isHidden()); QVERIFY(QFile::exists(sendSource)); + attachment_send_discard(pending); pending = nullptr; + bar.setRecordingContext(directory.path(), "456"); QCOMPARE(clipboardPaths(), before); + } + + void clipboardWorkIsSupersededAndCleaned_data() { + QTest::addColumn("action"); + for (const char* action : {"replace", "change-chat", "clear", "destroy", "edit", "disable", "file"}) + QTest::newRow(action) << QString(action); + } + void clipboardWorkIsSupersededAndCleaned() { + QFETCH(QString, action); const auto before = clipboardPaths(); QTemporaryDir directory; + { + InputBar bar(nullptr, nullptr); bar.setRecordingContext(directory.path(), "123"); bar.show(); + auto* editor = bar.findChild(); QVERIFY(editor); + QImage image(120, 80, QImage::Format_RGB32); image.fill(Qt::red); copyImage(image); editor->paste(); + if (action != "destroy") { + if (action == "change-chat") bar.setRecordingContext(directory.path(), "456"); + if (action == "clear") QVERIFY(QMetaObject::invokeMethod(&bar, "clearPendingAttachment", Qt::DirectConnection)); + if (action == "edit") { bar.editMessage(QByteArray(32, 'e'), 1, "edit"); bar.cancelEdit(); } + if (action == "disable") { bar.setMediaEnabled(false); bar.setMediaEnabled(true); } + if (action == "file") QVERIFY(dropUrls(editor->viewport(), {QUrl::fromLocalFile(makeFile(directory, "file.txt"))})); + image = QImage(60, 40, QImage::Format_RGB32); image.fill(Qt::blue); copyImage(image); editor->paste(); + auto* thumbnail = bar.findChild("clipboardImageThumbnail"); QVERIFY(thumbnail); QTRY_VERIFY(!thumbnail->isHidden()); + const auto drafts = clipboardPaths() - before; QCOMPARE(drafts.size(), 1); + const QImage decoded(*drafts.begin()); QCOMPARE(decoded.size(), image.size()); QCOMPARE(decoded.pixelColor(0, 0), QColor(Qt::blue)); + } + } + QCOMPARE(clipboardPaths(), before); + } + + void clipboardInvalidImagePreservesDraft_data() { + QTest::addColumn("kind"); + for (const char* kind : {"corrupt-png", "too-wide", "too-many-pixels"}) QTest::newRow(kind) << QString(kind); + } + void clipboardInvalidImagePreservesDraft() { + QFETCH(QString, kind); const auto before = clipboardPaths(); QTemporaryDir directory; + InputBar bar(nullptr, nullptr); bar.setRecordingContext(directory.path(), "123"); bar.show(); + auto* editor = bar.findChild(); auto* thumbnail = bar.findChild("clipboardImageThumbnail"); + QVERIFY(editor); QVERIFY(thumbnail); + QImage original(32, 24, QImage::Format_RGB32); original.fill(Qt::green); copyImage(original); editor->paste(); + QTRY_VERIFY(!thumbnail->isHidden()); const auto drafts = clipboardPaths() - before; QCOMPARE(drafts.size(), 1); + QSignalSpy errors(&bar, &InputBar::attachmentRejected); + if (kind == "corrupt-png") { + auto* mime = new QMimeData; mime->setData("image/png", "invalid PNG"); QApplication::clipboard()->setMimeData(mime); + } else copyImage(QImage(kind == "too-wide" ? 16385 : 8192, kind == "too-wide" ? 1 : 4097, QImage::Format_Mono)); + editor->paste(); QTRY_COMPARE(errors.size(), 1); + QCOMPARE(clipboardPaths() - before, drafts); QVERIFY(!thumbnail->isHidden()); + QCOMPARE(QImage(*drafts.begin()).pixelColor(0, 0), QColor(Qt::green)); + bar.setRecordingContext(directory.path(), "456"); QCOMPARE(clipboardPaths(), before); + } + + void clipboardTextAndUnavailableComposer_data() { + QTest::addColumn("mode"); + for (const char* mode : {"text", "disabled-media", "no-target", "edit"}) QTest::newRow(mode) << QString(mode); + } + void clipboardTextAndUnavailableComposer() { + QFETCH(QString, mode); const auto before = clipboardPaths(); QTemporaryDir directory; + InputBar bar(nullptr, nullptr); bar.setRecordingContext(directory.path(), "123"); + auto* editor = bar.findChild(); QVERIFY(editor); QSignalSpy errors(&bar, &InputBar::attachmentRejected); + if (mode == "text") { + QApplication::clipboard()->setText("обычный текст\nnext line"); editor->paste(); + QCOMPARE(editor->toPlainText(), QString("обычный текст\nnext line")); QCOMPARE(errors.size(), 0); + } else { + if (mode == "disabled-media") bar.setMediaEnabled(false); + if (mode == "no-target") bar.setRecordingContext(directory.path(), ""); + if (mode == "edit") bar.editMessage(QByteArray(32, 'e'), 1, "edit"); + const QString text = editor->toPlainText(); + QImage image(32, 24, QImage::Format_RGB32); image.fill(Qt::red); copyImage(image); editor->paste(); + QCOMPARE(errors.size(), 1); QCOMPARE(editor->toPlainText(), text); + } + QCOMPARE(clipboardPaths(), before); + } + + void externalClipboardImage() { + const QString platform = QGuiApplication::platformName(); + const bool wayland = platform.startsWith("wayland"); + if (!wayland && platform != "xcb") QSKIP("Run with QT_QPA_PLATFORM=xcb or wayland to test an external clipboard owner."); + const QString program = QStandardPaths::findExecutable(wayland ? "wl-copy" : "xclip"); + if (program.isEmpty()) QSKIP("Install wl-copy/xclip to test an external PNG clipboard owner."); + const auto before = clipboardPaths(); QTemporaryDir directory; + InputBar bar(nullptr, nullptr); bar.setRecordingContext(directory.path(), "123"); bar.show(); bar.activateWindow(); + QVERIFY(QTest::qWaitForWindowActive(&bar)); + QImage original(120, 80, QImage::Format_ARGB32); original.fill(qRgba(41, 81, 121, 127)); + QByteArray bytes; QBuffer buffer(&bytes); QVERIFY(buffer.open(QIODevice::WriteOnly)); QVERIFY(original.save(&buffer, "PNG")); + QProcess owner; + const auto stop = qScopeGuard([&] { owner.kill(); owner.waitForFinished(1000); }); + owner.start(program, wayland ? QStringList{"--foreground", "--type", "image/png"} + : QStringList{"-selection", "clipboard", "-target", "image/png", "-in", "-quiet"}); + QVERIFY(owner.waitForStarted(1000)); QCOMPARE(owner.write(bytes), qint64(bytes.size())); owner.closeWriteChannel(); + QTRY_VERIFY(QApplication::clipboard()->mimeData()->hasFormat("image/png")); + auto* editor = bar.findChild(); QVERIFY(editor); editor->paste(); + auto* thumbnail = bar.findChild("clipboardImageThumbnail"); QVERIFY(thumbnail); QTRY_VERIFY(!thumbnail->isHidden()); + const auto drafts = clipboardPaths() - before; QCOMPARE(drafts.size(), 1); + QCOMPARE(QImage(*drafts.begin()).pixelColor(0, 0), original.pixelColor(0, 0)); + bar.setRecordingContext(directory.path(), "456"); QCOMPARE(clipboardPaths(), before); + } + void ordinaryTextWaitsForResultAndCanRetry() { InputBar bar(nullptr, nullptr); bar.setRecordingContext("/tmp", "123"); auto* editor = bar.findChild(); QVERIFY(editor); diff --git a/tools/chatgui/tests/test_group_edits.cpp b/tools/chatgui/tests/test_group_edits.cpp index 0eab1c15..36e640e0 100644 --- a/tools/chatgui/tests/test_group_edits.cpp +++ b/tools/chatgui/tests/test_group_edits.cpp @@ -19,6 +19,10 @@ #include #include #include +#include +#include +#include +#include #include #include #include @@ -26,6 +30,8 @@ extern "C" { #include "utun_instance.h" #include "chat/chat_core.h" #include "chat/chat_event.h" +#include "dm/dm_core.h" +#include "dm/dm_crypto.h" #include "debug_config.h" #include "mem.h" } @@ -78,18 +84,19 @@ struct SendCore { void openHistory() { history = new MessageList(&db); sendHistory = history; history->loadChannel(channel); } - bool start(bool reply = false) { + bool start(bool reply = false, const QString& dbPath = {}) { debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); debug_set_category_level(DEBUG_CATEGORY_CHAT, DEBUG_LEVEL_DEBUG); debug_set_category_level(DEBUG_CATEGORY_GENERAL, DEBUG_LEVEL_DEBUG); gui_bridge_init(); - instance = ua ? utun_instance_create_from_str(ua, - "[global]\nmy_private_key=704f2e012c8fa8768130cb0f988a997dccb628372bc5ceccacc78dcbfec5916f\n" + const QByteArray config = "[global]\ndb_path=" + dbPath.toUtf8() + + "\nmy_private_key=704f2e012c8fa8768130cb0f988a997dccb628372bc5ceccacc78dcbfec5916f\n" "my_public_key=b3193173def895bd0fcea6f86af077c7d77216f10395275f627ac18242ec0f01\n" "tun_enabled=no\n[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\nmember_files_enabled=0\n" - "local_invites_enabled=0\nlocal_broadcasts_enabled=0\n[ntp]\nenabled=no\n") : nullptr; + "local_invites_enabled=0\nlocal_broadcasts_enabled=0\n[ntp]\nenabled=no\n"; + instance = ua ? utun_instance_create_from_str(ua, config.constData()) : nullptr; if (!instance || utun_core_start(instance) || chat_service_start(instance)) return false; const auto id = chat_core_create_channel_auto(instance, "Send tests"); if (!id) return false; - channel = QString::number(id); db.setDb(instance->topo_sqlite_db); db.setMyNodeId(instance->node_id); + channel = QString::number(id); db.setDb(instance->topo_sqlite_db); db.setMyNodeId(instance->node_id); db.setDbPath(dbPath); if (reply) { chat_msg_submit request{}; request.inst = instance; strcpy(request.content_type, "text/plain"); snprintf(request.channel_id, sizeof(request.channel_id), "%s", qPrintable(channel)); @@ -119,6 +126,55 @@ static int inCore(std::function operation) { class GroupEditTest : public QObject { Q_OBJECT private slots: + void clipboardPngCommitsThroughCore_data() { + QTest::addColumn("dm"); QTest::newRow("group-reply") << false; QTest::newRow("pm") << true; + } + void clipboardPngCommitsThroughCore() { + QFETCH(bool, dm); QTemporaryDir directory; QVERIFY(directory.isValid()); SendCore core; + QVERIFY(core.start(!dm, directory.path())); QString conversation; + if (dm) { + SC_MYKEYS peer{}; uint8_t ed[32]; QCOMPARE(sc_generate_keypair(&peer), 0); + QCOMPARE(sc_derive_ed25519_pubkey(peer.private_key, ed), 0); + const auto peerId = sc_derive_node_id_from_pubkey(peer.public_key); + QCOMPARE(dm_start(core.instance, peerId, peer.public_key, ed, "Clipboard peer", nullptr), 0); + conversation = QString::number(dm_derive_conv_id(core.instance->node_id, peerId)); + core.history->loadChannel("dm:" + conversation); + } + core.history->show(); + auto* thumbnail = core.input()->findChild("clipboardImageThumbnail"); QVERIFY(thumbnail); + QSignalSpy errors(core.input(), &InputBar::attachmentRejected); + const auto before = QDir(QDir::tempPath()).entryList({"vibechat-clipboard-*.png"}, QDir::Files); + const auto clear = qScopeGuard([] { QApplication::clipboard()->clear(); }); + QImage image(120, 80, QImage::Format_ARGB32); image.fill(qRgba(17, 61, 95, 127)); + QApplication::clipboard()->setImage(image); core.editor()->paste(); QTRY_VERIFY(!thumbnail->isHidden()); + gui_bridge_set_inst(nullptr); QVERIFY(core.send()); QCOMPARE(errors.size(), 1); QVERIFY(!thumbnail->isHidden()); + core.attach(); QVERIFY(core.send()); + QTRY_VERIFY_WITH_TIMEOUT((core.pump(), thumbnail->isHidden()), 5000); + QCOMPARE(errors.size(), 1); + QCOMPARE(QDir(QDir::tempPath()).entryList({"vibechat-clipboard-*.png"}, QDir::Files), before); + if (dm) { + char json[8192]; size_t length = 0; + QCOMPARE(dm_list_messages_json(core.instance, qPrintable(conversation), 20, 0, json, sizeof(json), &length), 0); + const QByteArray messages(json, int(length)); + QVERIFY(messages.contains("\"content_type\":\"image/png\"")); + QVERIFY(messages.contains("\"width\":120,\"height\":80")); QVERIFY(messages.contains("clipboard-")); + } else { + const auto messages = core.db.getMessages(core.channel, 10); QCOMPARE(messages.size(), 2); + bool found = false; + for (const auto& message : messages) if (message.contentType == "image/png") { + found = true; QCOMPARE(message.replyTo, core.original); + const int separator = message.data.indexOf('|'); QVERIFY(separator > 1); QVERIFY(message.data.startsWith('*')); + QVERIFY(QByteArray::fromBase64(message.data.mid(1, separator - 1)).startsWith("clipboard-")); + QVERIFY(message.data.endsWith("|w=120|h=80")); + } + QVERIFY(found); + const QDir media(directory.filePath("media/" + core.channel)); + const auto files = media.entryList({"*.png"}, QDir::Files); QCOMPARE(files.size(), 1); + const QImage decoded(media.filePath(files.front())); + QCOMPARE(decoded.size(), image.size()); QCOMPARE(decoded.pixelColor(0, 0), image.pixelColor(0, 0)); + } + } + void channelSend_data() { QTest::addColumn("reply"); QTest::addColumn("failure"); QStringList failures{"none", "unavailable", "stopped", "cancelled", "sql"}; diff --git a/tools/chatgui/translations/chatgui_en.ts b/tools/chatgui/translations/chatgui_en.ts index f4513288..56d801de 100644 --- a/tools/chatgui/translations/chatgui_en.ts +++ b/tools/chatgui/translations/chatgui_en.ts @@ -2815,5 +2815,17 @@ See log: Camera connection error: %1 Camera connection error: %1 + + Images cannot be pasted in the current composer state. + Images cannot be pasted in the current composer state. + + + Cannot paste image: invalid data or more than 32 megapixels / 128 MiB. + Cannot paste image: invalid data or more than 32 megapixels / 128 MiB. + + + Could not prepare clipboard image: %1 + Could not prepare clipboard image: %1 + diff --git a/tools/chatgui/translations/chatgui_ru.ts b/tools/chatgui/translations/chatgui_ru.ts index cd9804b6..f198d3b5 100644 --- a/tools/chatgui/translations/chatgui_ru.ts +++ b/tools/chatgui/translations/chatgui_ru.ts @@ -2824,5 +2824,17 @@ See log: Camera connection error: %1 Ошибка подключения к камере: %1 + + Images cannot be pasted in the current composer state. + Сейчас нельзя вставить изображение в сообщение. + + + Cannot paste image: invalid data or more than 32 megapixels / 128 MiB. + Не удалось вставить изображение: повреждённые данные либо превышен предел 32 Мп / 128 МиБ. + + + Could not prepare clipboard image: %1 + Не удалось подготовить изображение из буфера: %1 +