diff --git a/CMakeLists.txt b/CMakeLists.txt index 027bc44e..550a3aa4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -311,7 +311,6 @@ set(SRC_FILES # Dialogs src/dialogs/CreateRoom.cpp src/dialogs/FallbackAuth.cpp - src/dialogs/PreviewUploadOverlay.cpp src/dialogs/ReCaptcha.cpp # Emoji @@ -509,7 +508,6 @@ qt5_wrap_cpp(MOC_HEADERS # Dialogs src/dialogs/CreateRoom.h src/dialogs/FallbackAuth.h - src/dialogs/PreviewUploadOverlay.h src/dialogs/ReCaptcha.h # Emoji diff --git a/resources/icons/ui/image.svg b/resources/icons/ui/image.svg new file mode 100644 index 00000000..ca73a76e --- /dev/null +++ b/resources/icons/ui/image.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/ui/music.svg b/resources/icons/ui/music.svg new file mode 100644 index 00000000..5f72b736 --- /dev/null +++ b/resources/icons/ui/music.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/ui/video-file.svg b/resources/icons/ui/video-file.svg new file mode 100644 index 00000000..08c0a6bb --- /dev/null +++ b/resources/icons/ui/video-file.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/ui/zip.svg b/resources/icons/ui/zip.svg new file mode 100644 index 00000000..e22534d6 --- /dev/null +++ b/resources/icons/ui/zip.svg @@ -0,0 +1 @@ + diff --git a/resources/qml/MessageView.qml b/resources/qml/MessageView.qml index 4fce9a75..bbe61ee9 100644 --- a/resources/qml/MessageView.qml +++ b/resources/qml/MessageView.qml @@ -376,6 +376,7 @@ Item { required property string filesize required property string url required property string thumbnailUrl + required property string duration required property bool isOnlyEmoji required property bool isSender required property bool isEncrypted @@ -492,6 +493,7 @@ Item { filesize: wrapper.filesize url: wrapper.url thumbnailUrl: wrapper.thumbnailUrl + duration: wrapper.duration isOnlyEmoji: wrapper.isOnlyEmoji isSender: wrapper.isSender isEncrypted: wrapper.isEncrypted diff --git a/resources/qml/TimelineRow.qml b/resources/qml/TimelineRow.qml index bb6514d1..032821ba 100644 --- a/resources/qml/TimelineRow.qml +++ b/resources/qml/TimelineRow.qml @@ -41,6 +41,7 @@ Item { required property var reactions required property int trustlevel required property int encryptionError + required property int duration required property var timestamp required property int status required property int relatedEventCacheBuster @@ -128,6 +129,7 @@ Item { userId: r.relatedEventCacheBuster, fromModel(Room.UserId) ?? "" userName: r.relatedEventCacheBuster, fromModel(Room.UserName) ?? "" thumbnailUrl: r.relatedEventCacheBuster, fromModel(Room.ThumbnailUrl) ?? "" + duration: r.relatedEventCacheBuster, fromModel(Room.Duration) ?? "" roomTopic: r.relatedEventCacheBuster, fromModel(Room.RoomTopic) ?? "" roomName: r.relatedEventCacheBuster, fromModel(Room.RoomName) ?? "" callType: r.relatedEventCacheBuster, fromModel(Room.CallType) ?? "" @@ -154,6 +156,7 @@ Item { typeString: r.typeString ?? "" url: r.url thumbnailUrl: r.thumbnailUrl + duration: r.duration originalWidth: r.originalWidth isOnlyEmoji: r.isOnlyEmoji isStateEvent: r.isStateEvent diff --git a/resources/qml/TimelineView.qml b/resources/qml/TimelineView.qml index 1933baeb..c4820077 100644 --- a/resources/qml/TimelineView.qml +++ b/resources/qml/TimelineView.qml @@ -124,6 +124,10 @@ Item { color: Nheko.theme.separator } + + UploadBox { + } + NotificationWarning { } diff --git a/resources/qml/UploadBox.qml b/resources/qml/UploadBox.qml new file mode 100644 index 00000000..ba00f205 --- /dev/null +++ b/resources/qml/UploadBox.qml @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: 2022 Nheko Contributors +// +// SPDX-License-Identifier: GPL-3.0-or-later + +import "./components" +import "./ui" + +import QtQuick 2.9 +import QtQuick.Controls 2.5 +import QtQuick.Layouts 1.3 +import im.nheko 1.0 + +Page { + id: uploadPopup + visible: room && room.input.uploads.length > 0 + Layout.preferredHeight: 200 + clip: true + + Layout.fillWidth: true + + padding: Nheko.paddingMedium + + contentItem: ListView { + id: uploadsList + anchors.horizontalCenter: parent.horizontalCenter + boundsBehavior: Flickable.StopAtBounds + + ScrollBar.horizontal: ScrollBar { + id: scr + } + + orientation: ListView.Horizontal + width: Math.min(contentWidth, parent.availableWidth) + model: room ? room.input.uploads : undefined + spacing: Nheko.paddingMedium + + delegate: Pane { + padding: Nheko.paddingSmall + height: uploadPopup.availableHeight - buttons.height - (scr.visible? scr.height : 0) + width: uploadPopup.availableHeight - buttons.height + + background: Rectangle { + color: Nheko.colors.window + radius: Nheko.paddingMedium + } + contentItem: ColumnLayout { + Image { + Layout.fillHeight: true + Layout.fillWidth: true + + sourceSize.height: height + sourceSize.width: width + fillMode: Image.PreserveAspectFit + smooth: true + mipmap: true + + property string typeStr: switch(modelData.mediaType) { + case MediaUpload.Video: return "video-file"; + case MediaUpload.Audio: return "music"; + case MediaUpload.Image: return "image"; + default: return "zip"; + } + source: (modelData.thumbnail != "") ? modelData.thumbnail : ("image://colorimage/:/icons/icons/ui/"+typeStr+".svg?" + Nheko.colors.buttonText) + } + MatrixTextField { + Layout.fillWidth: true + text: modelData.filename + onTextEdited: modelData.filename = text + } + } + } + } + + footer: DialogButtonBox { + id: buttons + + standardButtons: DialogButtonBox.Cancel + Button { + text: qsTr("Upload %n file(s)", "", (room ? room.input.uploads.length : 0)) + DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole + } + onAccepted: room.input.acceptUploads() + onRejected: room.input.declineUploads() + } + + background: Rectangle { + color: Nheko.colors.base + } +} diff --git a/resources/qml/delegates/MessageDelegate.qml b/resources/qml/delegates/MessageDelegate.qml index 08b2098e..0e211ded 100644 --- a/resources/qml/delegates/MessageDelegate.qml +++ b/resources/qml/delegates/MessageDelegate.qml @@ -18,6 +18,7 @@ Item { required property int type required property string typeString required property int originalWidth + required property int duration required property string blurhash required property string body required property string formattedBody @@ -161,6 +162,7 @@ Item { url: d.url body: d.body filesize: d.filesize + duration: d.duration metadataWidth: d.metadataWidth } @@ -178,6 +180,7 @@ Item { url: d.url body: d.body filesize: d.filesize + duration: d.duration metadataWidth: d.metadataWidth } diff --git a/resources/qml/delegates/PlayableMediaMessage.qml b/resources/qml/delegates/PlayableMediaMessage.qml index 5d7beaad..4828843c 100644 --- a/resources/qml/delegates/PlayableMediaMessage.qml +++ b/resources/qml/delegates/PlayableMediaMessage.qml @@ -17,6 +17,7 @@ Item { required property double proportionalHeight required property int type required property int originalWidth + required property int duration required property string thumbnailUrl required property string eventId required property string url @@ -57,7 +58,7 @@ Item { Image { anchors.fill: parent - source: thumbnailUrl.replace("mxc://", "image://MxcImage/") + "?scale" + source: thumbnailUrl ? thumbnailUrl.replace("mxc://", "image://MxcImage/") + "?scale" : "" asynchronous: true fillMode: Image.PreserveAspectFit @@ -85,7 +86,7 @@ Item { anchors.bottom: fileInfoLabel.top playingVideo: type == MtxEvent.VideoMessage positionValue: mxcmedia.position - duration: mxcmedia.duration + duration: mediaLoaded ? mxcmedia.duration : content.duration mediaLoaded: mxcmedia.loaded mediaState: mxcmedia.state onPositionChanged: mxcmedia.position = position diff --git a/resources/qml/delegates/Reply.qml b/resources/qml/delegates/Reply.qml index 513b7c0b..27fb4e07 100644 --- a/resources/qml/delegates/Reply.qml +++ b/resources/qml/delegates/Reply.qml @@ -34,6 +34,7 @@ Item { property string roomTopic property string roomName property string callType + property int duration property int encryptionError property int relatedEventCacheBuster property int maxWidth @@ -112,6 +113,7 @@ Item { typeString: r.typeString ?? "" url: r.url thumbnailUrl: r.thumbnailUrl + duration: r.duration originalWidth: r.originalWidth isOnlyEmoji: r.isOnlyEmoji isStateEvent: r.isStateEvent diff --git a/resources/qml/ui/media/MediaControls.qml b/resources/qml/ui/media/MediaControls.qml index 1844af73..d73957ee 100644 --- a/resources/qml/ui/media/MediaControls.qml +++ b/resources/qml/ui/media/MediaControls.qml @@ -214,7 +214,7 @@ Rectangle { Label { Layout.alignment: Qt.AlignRight - text: (!control.mediaLoaded) ? "-- / --" : (durationToString(control.positionValue) + " / " + durationToString(control.duration)) + text: (!control.mediaLoaded ? "-- " : durationToString(control.positionValue)) + " / " + durationToString(control.duration) color: Nheko.colors.text } diff --git a/resources/res.qrc b/resources/res.qrc index a383f805..3b762d20 100644 --- a/resources/res.qrc +++ b/resources/res.qrc @@ -46,6 +46,10 @@ icons/ui/volume-off-indicator.svg icons/ui/volume-up.svg icons/ui/world.svg + icons/ui/music.svg + icons/ui/image.svg + icons/ui/zip.svg + icons/ui/video-file.svg icons/emoji-categories/activity.svg icons/emoji-categories/flags.svg icons/emoji-categories/foods.svg @@ -95,6 +99,7 @@ qml/MatrixText.qml qml/MatrixTextField.qml qml/ToggleButton.qml + qml/UploadBox.qml qml/MessageInput.qml qml/MessageView.qml qml/NhekoBusyIndicator.qml diff --git a/src/EventAccessors.cpp b/src/EventAccessors.cpp index 935ff73a..e4dfe92e 100644 --- a/src/EventAccessors.cpp +++ b/src/EventAccessors.cpp @@ -139,6 +139,19 @@ struct EventFile } }; +struct EventThumbnailFile +{ + template + using file_t = decltype(Content::info.thumbnail_file); + template + std::optional operator()(const mtx::events::Event &e) + { + if constexpr (is_detected::value) + return e.content.info.thumbnail_file; + return std::nullopt; + } +}; + struct EventUrl { template @@ -163,12 +176,28 @@ struct EventThumbnailUrl std::string operator()(const mtx::events::Event &e) { if constexpr (is_detected::value) { + if (auto file = EventThumbnailFile{}(e)) + return file->url; return e.content.info.thumbnail_url; } return ""; } }; +struct EventDuration +{ + template + using thumbnail_url_t = decltype(Content::info.duration); + template + uint64_t operator()(const mtx::events::Event &e) + { + if constexpr (is_detected::value) { + return e.content.info.duration; + } + return 0; + } +}; + struct EventBlurhash { template @@ -410,6 +439,12 @@ mtx::accessors::file(const mtx::events::collections::TimelineEvents &event) return std::visit(EventFile{}, event); } +std::optional +mtx::accessors::thumbnail_file(const mtx::events::collections::TimelineEvents &event) +{ + return std::visit(EventThumbnailFile{}, event); +} + std::string mtx::accessors::url(const mtx::events::collections::TimelineEvents &event) { @@ -420,6 +455,11 @@ mtx::accessors::thumbnail_url(const mtx::events::collections::TimelineEvents &ev { return std::visit(EventThumbnailUrl{}, event); } +uint64_t +mtx::accessors::duration(const mtx::events::collections::TimelineEvents &event) +{ + return std::visit(EventDuration{}, event); +} std::string mtx::accessors::blurhash(const mtx::events::collections::TimelineEvents &event) { diff --git a/src/EventAccessors.h b/src/EventAccessors.h index e46d4786..9d8a34e7 100644 --- a/src/EventAccessors.h +++ b/src/EventAccessors.h @@ -78,11 +78,15 @@ formattedBodyWithFallback(const mtx::events::collections::TimelineEvents &event) std::optional file(const mtx::events::collections::TimelineEvents &event); +std::optional +thumbnail_file(const mtx::events::collections::TimelineEvents &event); std::string url(const mtx::events::collections::TimelineEvents &event); std::string thumbnail_url(const mtx::events::collections::TimelineEvents &event); +uint64_t +duration(const mtx::events::collections::TimelineEvents &event); std::string blurhash(const mtx::events::collections::TimelineEvents &event); std::string diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index 73e556f7..c4af7f0c 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -251,6 +251,8 @@ MainWindow::registerQmlTypes() qmlRegisterType("im.nheko.EmojiModel", 1, 0, "EmojiModel"); qmlRegisterUncreatableType( "im.nheko.EmojiModel", 1, 0, "Emoji", QStringLiteral("Used by emoji models")); + qmlRegisterUncreatableType( + "im.nheko", 1, 0, "MediaUpload", QStringLiteral("MediaUploads can not be created in Qml")); qmlRegisterUncreatableMetaObject(emoji::staticMetaObject, "im.nheko.EmojiModel", 1, diff --git a/src/dialogs/PreviewUploadOverlay.cpp b/src/dialogs/PreviewUploadOverlay.cpp deleted file mode 100644 index a00358d9..00000000 --- a/src/dialogs/PreviewUploadOverlay.cpp +++ /dev/null @@ -1,223 +0,0 @@ -// SPDX-FileCopyrightText: 2017 Konstantinos Sideris -// SPDX-FileCopyrightText: 2021 Nheko Contributors -// SPDX-FileCopyrightText: 2022 Nheko Contributors -// -// SPDX-License-Identifier: GPL-3.0-or-later - -#include -#include -#include -#include -#include -#include - -#include "dialogs/PreviewUploadOverlay.h" - -#include "Config.h" -#include "Logging.h" -#include "MainWindow.h" -#include "Utils.h" - -using namespace dialogs; - -constexpr const char *DEFAULT = "Upload %1?"; -constexpr const char *ERR_MSG = "Failed to load image type '%1'. Continue upload?"; - -PreviewUploadOverlay::PreviewUploadOverlay(QWidget *parent) - : QWidget{parent} - , titleLabel_{this} - , fileName_{this} - , upload_{tr("Upload"), this} - , cancel_{tr("Cancel"), this} -{ - auto hlayout = new QHBoxLayout; - hlayout->setContentsMargins(0, 0, 0, 0); - hlayout->addStretch(1); - hlayout->addWidget(&cancel_); - hlayout->addWidget(&upload_); - - auto vlayout = new QVBoxLayout{this}; - vlayout->addWidget(&titleLabel_); - vlayout->addWidget(&infoLabel_); - vlayout->addWidget(&fileName_); - vlayout->addLayout(hlayout); - vlayout->setSpacing(conf::modals::WIDGET_SPACING); - vlayout->setContentsMargins(conf::modals::WIDGET_MARGIN, - conf::modals::WIDGET_MARGIN, - conf::modals::WIDGET_MARGIN, - conf::modals::WIDGET_MARGIN); - - upload_.setDefault(true); - connect(&upload_, &QPushButton::clicked, this, [this]() { - emit confirmUpload(data_, mediaType_, fileName_.text()); - close(); - }); - - connect(&fileName_, &QLineEdit::returnPressed, this, [this]() { - emit confirmUpload(data_, mediaType_, fileName_.text()); - close(); - }); - - connect(&cancel_, &QPushButton::clicked, this, [this]() { - emit aborted(); - close(); - }); -} - -void -PreviewUploadOverlay::init() -{ - QSize winsize; - QPoint center; - - auto window = MainWindow::instance(); - if (window) { - winsize = window->frameGeometry().size(); - center = window->frameGeometry().center(); - } else { - nhlog::ui()->warn("unable to retrieve MainWindow's size"); - } - - fileName_.setText(QFileInfo{filePath_}.fileName()); - - setAutoFillBackground(true); - setWindowFlags(Qt::Tool | Qt::WindowStaysOnTopHint); - setWindowModality(Qt::WindowModal); - - QFont font; - font.setPointSizeF(font.pointSizeF() * conf::modals::LABEL_MEDIUM_SIZE_RATIO); - - titleLabel_.setFont(font); - titleLabel_.setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); - titleLabel_.setAlignment(Qt::AlignCenter); - infoLabel_.setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Maximum); - fileName_.setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); - fileName_.setAlignment(Qt::AlignCenter); - upload_.setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); - cancel_.setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); - - if (isImage_) { - infoLabel_.setAlignment(Qt::AlignCenter); - - const auto maxWidth = winsize.width() * 0.8; - const auto maxHeight = winsize.height() * 0.8; - - // Scale image preview to fit into the application window. - infoLabel_.setPixmap(utils::scaleDown(maxWidth, maxHeight, image_)); - move(center.x() - (width() * 0.5), center.y() - (height() * 0.5)); - } else { - infoLabel_.setAlignment(Qt::AlignLeft); - } - infoLabel_.setScaledContents(false); - - show(); -} - -void -PreviewUploadOverlay::setLabels(const QString &type, const QString &mime, uint64_t upload_size) -{ - if (mediaType_.split('/')[0] == QLatin1String("image")) { - if (!image_.loadFromData(data_)) { - titleLabel_.setText(QString{tr(ERR_MSG)}.arg(type)); - } else { - titleLabel_.setText(QString{tr(DEFAULT)}.arg(mediaType_)); - } - isImage_ = true; - } else { - auto const info = QString{tr("Media type: %1\n" - "Media size: %2\n")} - .arg(mime, utils::humanReadableFileSize(upload_size)); - - titleLabel_.setText(QString{tr(DEFAULT)}.arg(QStringLiteral("file"))); - infoLabel_.setText(info); - } -} - -void -PreviewUploadOverlay::setPreview(const QImage &src, const QString &mime) -{ - nhlog::ui()->info( - "Pasting image with size: {}x{}, format: {}", src.height(), src.width(), mime.toStdString()); - - auto const &split = mime.split('/'); - auto const &type = split[1]; - - QBuffer buffer(&data_); - buffer.open(QIODevice::WriteOnly); - if (src.save(&buffer, type.toStdString().c_str())) - titleLabel_.setText(QString{tr(DEFAULT)}.arg(QStringLiteral("image"))); - else - titleLabel_.setText(QString{tr(ERR_MSG)}.arg(type)); - - mediaType_ = mime; - filePath_ = "clipboard." + type; - image_.convertFromImage(src); - isImage_ = true; - - titleLabel_.setText(QString{tr(DEFAULT)}.arg(QStringLiteral("image"))); - init(); -} - -void -PreviewUploadOverlay::setPreview(const QByteArray data, const QString &mime) -{ - nhlog::ui()->info("Pasting {} bytes of data, mimetype {}", data.size(), mime.toStdString()); - - auto const &split = mime.split('/'); - auto const &type = split[1]; - - data_ = data; - mediaType_ = mime; - filePath_ = "clipboard." + type; - isImage_ = false; - - if (mime == QLatin1String("image/svg+xml")) { - isImage_ = true; - image_.loadFromData(data_, mediaType_.toStdString().c_str()); - } - - setLabels(type, mime, data_.size()); - init(); -} - -void -PreviewUploadOverlay::setPreview(const QString &path) -{ - QFile file{path}; - - if (!file.open(QIODevice::ReadOnly)) { - nhlog::ui()->warn( - "Failed to open file ({}): {}", path.toStdString(), file.errorString().toStdString()); - close(); - return; - } - - QMimeDatabase db; - auto mime = db.mimeTypeForFileNameAndData(path, &file); - - if ((data_ = file.readAll()).isEmpty()) { - nhlog::ui()->warn("Failed to read media: {}", file.errorString().toStdString()); - close(); - return; - } - - auto const &split = mime.name().split('/'); - - mediaType_ = mime.name(); - filePath_ = file.fileName(); - isImage_ = false; - - setLabels(split[1], mime.name(), data_.size()); - init(); -} - -void -PreviewUploadOverlay::keyPressEvent(QKeyEvent *event) -{ - if (event->matches(QKeySequence::Cancel)) { - emit aborted(); - close(); - } else { - QWidget::keyPressEvent(event); - } -} diff --git a/src/dialogs/PreviewUploadOverlay.h b/src/dialogs/PreviewUploadOverlay.h deleted file mode 100644 index aa72d4d2..00000000 --- a/src/dialogs/PreviewUploadOverlay.h +++ /dev/null @@ -1,53 +0,0 @@ -// SPDX-FileCopyrightText: 2017 Konstantinos Sideris -// SPDX-FileCopyrightText: 2021 Nheko Contributors -// SPDX-FileCopyrightText: 2022 Nheko Contributors -// -// SPDX-License-Identifier: GPL-3.0-or-later - -#pragma once - -#include -#include -#include -#include -#include -#include - -class QMimeData; - -namespace dialogs { - -class PreviewUploadOverlay : public QWidget -{ - Q_OBJECT -public: - PreviewUploadOverlay(QWidget *parent = nullptr); - - void setPreview(const QImage &src, const QString &mime); - void setPreview(const QByteArray data, const QString &mime); - void setPreview(const QString &path); - void keyPressEvent(QKeyEvent *event); - -signals: - void confirmUpload(const QByteArray data, const QString &media, const QString &filename); - void aborted(); - -private: - void init(); - void setLabels(const QString &type, const QString &mime, uint64_t upload_size); - - bool isImage_; - QPixmap image_; - - QByteArray data_; - QString filePath_; - QString mediaType_; - - QLabel titleLabel_; - QLabel infoLabel_; - QLineEdit fileName_; - - QPushButton upload_; - QPushButton cancel_; -}; -} // dialogs diff --git a/src/timeline/InputBar.cpp b/src/timeline/InputBar.cpp index 349ce7af..e1223021 100644 --- a/src/timeline/InputBar.cpp +++ b/src/timeline/InputBar.cpp @@ -5,16 +5,18 @@ #include "InputBar.h" +#include #include #include #include #include #include +#include +#include #include #include #include #include -#include #include #include @@ -31,12 +33,97 @@ #include "TimelineViewManager.h" #include "UserSettingsPage.h" #include "Utils.h" -#include "dialogs/PreviewUploadOverlay.h" #include "blurhash.hpp" static constexpr size_t INPUT_HISTORY_SIZE = 10; +QUrl +MediaUpload::thumbnailDataUrl() const +{ + if (thumbnail_.isNull()) + return {}; + + QByteArray byteArray; + QBuffer buffer(&byteArray); + buffer.open(QIODevice::WriteOnly); + thumbnail_.save(&buffer, "PNG"); + QString base64 = QString::fromUtf8(byteArray.toBase64()); + return QString("data:image/png;base64,") + base64; +} + +bool +InputVideoSurface::present(const QVideoFrame &frame) +{ + QImage::Format format = QImage::Format_Invalid; + + switch (frame.pixelFormat()) { + case QVideoFrame::Format_ARGB32: + format = QImage::Format_ARGB32; + break; + case QVideoFrame::Format_ARGB32_Premultiplied: + format = QImage::Format_ARGB32_Premultiplied; + break; + case QVideoFrame::Format_RGB24: + format = QImage::Format_RGB888; + break; + case QVideoFrame::Format_BGR24: + format = QImage::Format_BGR888; + break; + case QVideoFrame::Format_RGB32: + format = QImage::Format_RGB32; + break; + case QVideoFrame::Format_RGB565: + format = QImage::Format_RGB16; + break; + case QVideoFrame::Format_RGB555: + format = QImage::Format_RGB555; + break; + default: + format = QImage::Format_Invalid; + } + + if (format == QImage::Format_Invalid) { + emit newImage({}); + return false; + } else { + QVideoFrame frametodraw(frame); + + if (!frametodraw.map(QAbstractVideoBuffer::ReadOnly)) { + emit newImage({}); + return false; + } + + // this is a shallow operation. it just refer the frame buffer + QImage image(frametodraw.bits(), + frametodraw.width(), + frametodraw.height(), + frametodraw.bytesPerLine(), + format); + + emit newImage(std::move(image)); + return true; + } +} + +QList +InputVideoSurface::supportedPixelFormats(QAbstractVideoBuffer::HandleType type) const +{ + if (type == QAbstractVideoBuffer::NoHandle) { + return { + QVideoFrame::Format_ARGB32, + QVideoFrame::Format_ARGB32_Premultiplied, + QVideoFrame::Format_RGB24, + QVideoFrame::Format_BGR24, + QVideoFrame::Format_RGB32, + QVideoFrame::Format_RGB565, + QVideoFrame::Format_RGB555, + }; + } else { + return {}; + } +} + void InputBar::paste(bool fromMouse) { @@ -67,29 +154,23 @@ InputBar::insertMimeData(const QMimeData *md) if (md->hasImage()) { if (formats.contains(QStringLiteral("image/svg+xml"), Qt::CaseInsensitive)) { - showPreview(*md, QLatin1String(""), QStringList(QStringLiteral("image/svg+xml"))); + startUploadFromMimeData(*md, QStringLiteral("image/svg+xml")); + } else if (formats.contains(QStringLiteral("image/png"), Qt::CaseInsensitive)) { + startUploadFromMimeData(*md, QStringLiteral("image/png")); } else { - showPreview(*md, QLatin1String(""), image); + startUploadFromMimeData(*md, image.first()); } } else if (!audio.empty()) { - showPreview(*md, QLatin1String(""), audio); + startUploadFromMimeData(*md, audio.first()); } else if (!video.empty()) { - showPreview(*md, QLatin1String(""), video); + startUploadFromMimeData(*md, video.first()); } else if (md->hasUrls()) { // Generic file path for any platform. - QString path; for (auto &&u : md->urls()) { if (u.isLocalFile()) { - path = u.toLocalFile(); - break; + startUploadFromPath(u.toLocalFile()); } } - - if (!path.isEmpty() && QFileInfo::exists(path)) { - showPreview(*md, path, formats); - } else { - nhlog::ui()->warn("Clipboard does not contain any valid file paths."); - } } else if (md->hasFormat(QStringLiteral("x-special/gnome-copied-files"))) { // Special case for X11 users. See "Notes for X11 Users" in md. // Source: http://doc.qt.io/qt-5/qclipboard.html @@ -108,21 +189,12 @@ InputBar::insertMimeData(const QMimeData *md) return; } - QString path; for (int i = 1; i < data.size(); ++i) { QUrl url{data[i]}; if (url.isLocalFile()) { - path = url.toLocalFile(); - break; + startUploadFromPath(url.toLocalFile()); } } - - if (!path.isEmpty()) { - showPreview(*md, path, formats); - } else { - nhlog::ui()->warn("Clipboard does not contain any valid file paths: {}", - data.join(", ").toStdString()); - } } else if (md->hasText()) { emit insertText(md->text()); } else { @@ -275,25 +347,7 @@ InputBar::openFileSelection() if (fileName.isEmpty()) return; - QMimeDatabase db; - QMimeType mime = db.mimeTypeForFile(fileName, QMimeDatabase::MatchContent); - - QFile file{fileName}; - - if (!file.open(QIODevice::ReadOnly)) { - emit ChatPage::instance()->showNotification( - QStringLiteral("Error while reading media: %1").arg(file.errorString())); - return; - } - - setUploading(true); - - auto bin = file.readAll(); - - QMimeData data; - data.setData(mime.name(), bin); - - showPreview(data, fileName, QStringList{mime.name()}); + startUploadFromPath(fileName); } void @@ -424,6 +478,10 @@ InputBar::image(const QString &filename, const QString &mime, uint64_t dsize, const QSize &dimensions, + const std::optional &thumbnailEncryptedFile, + const QString &thumbnailUrl, + uint64_t thumbnailSize, + const QSize &thumbnailDimensions, const QString &blurhash) { mtx::events::msg::Image image; @@ -439,6 +497,18 @@ InputBar::image(const QString &filename, else image.url = url.toStdString(); + if (!thumbnailUrl.isEmpty()) { + if (thumbnailEncryptedFile) + image.info.thumbnail_file = thumbnailEncryptedFile; + else + image.info.thumbnail_url = thumbnailUrl.toStdString(); + + image.info.thumbnail_info.h = thumbnailDimensions.height(); + image.info.thumbnail_info.w = thumbnailDimensions.width(); + image.info.thumbnail_info.size = thumbnailSize; + image.info.thumbnail_info.mimetype = "image/png"; + } + if (!room->reply().isEmpty()) { image.relations.relations.push_back( {mtx::common::RelationType::InReplyTo, room->reply().toStdString()}); @@ -485,7 +555,8 @@ InputBar::audio(const QString &filename, const std::optional &file, const QString &url, const QString &mime, - uint64_t dsize) + uint64_t dsize, + uint64_t duration) { mtx::events::msg::Audio audio; audio.info.mimetype = mime.toStdString(); @@ -493,6 +564,9 @@ InputBar::audio(const QString &filename, audio.body = filename.toStdString(); audio.url = url.toStdString(); + if (duration > 0) + audio.info.duration = duration; + if (file) audio.file = file; else @@ -515,18 +589,45 @@ InputBar::video(const QString &filename, const std::optional &file, const QString &url, const QString &mime, - uint64_t dsize) + uint64_t dsize, + uint64_t duration, + const QSize &dimensions, + const std::optional &thumbnailEncryptedFile, + const QString &thumbnailUrl, + uint64_t thumbnailSize, + const QSize &thumbnailDimensions, + const QString &blurhash) { mtx::events::msg::Video video; video.info.mimetype = mime.toStdString(); video.info.size = dsize; + video.info.blurhash = blurhash.toStdString(); video.body = filename.toStdString(); + if (duration > 0) + video.info.duration = duration; + if (dimensions.isValid()) { + video.info.h = dimensions.height(); + video.info.w = dimensions.width(); + } + if (file) video.file = file; else video.url = url.toStdString(); + if (!thumbnailUrl.isEmpty()) { + if (thumbnailEncryptedFile) + video.info.thumbnail_file = thumbnailEncryptedFile; + else + video.info.thumbnail_url = thumbnailUrl.toStdString(); + + video.info.thumbnail_info.h = thumbnailDimensions.height(); + video.info.thumbnail_info.w = thumbnailDimensions.width(); + video.info.thumbnail_info.size = thumbnailSize; + video.info.thumbnail_info.mimetype = "image/png"; + } + if (!room->reply().isEmpty()) { video.relations.relations.push_back( {mtx::common::RelationType::InReplyTo, room->reply().toStdString()}); @@ -661,125 +762,351 @@ InputBar::command(const QString &command, QString args) } } -void -InputBar::showPreview(const QMimeData &source, const QString &path, const QStringList &formats) +MediaUpload::MediaUpload(std::unique_ptr source_, + QString mimetype, + QString originalFilename, + bool encrypt, + QObject *parent) + : QObject(parent) + , source(std::move(source_)) + , mimetype_(std::move(mimetype)) + , originalFilename_(QFileInfo(originalFilename).fileName()) + , encrypt_(encrypt) { - auto *previewDialog_ = new dialogs::PreviewUploadOverlay(nullptr); - previewDialog_->setAttribute(Qt::WA_DeleteOnClose); + mimeClass_ = mimetype_.left(mimetype_.indexOf(u'/')); - // Force SVG to _not_ be handled as an image, but as raw data - if (source.hasImage() && - (formats.empty() || formats.front() != QLatin1String("image/svg+xml"))) { - if (!formats.empty() && formats.front().startsWith(QLatin1String("image/"))) { - // known format, keep as-is - previewDialog_->setPreview(qvariant_cast(source.imageData()), formats.front()); - } else { - // unknown image format, default to image/png - previewDialog_->setPreview(qvariant_cast(source.imageData()), - QStringLiteral("image/png")); - } - } else if (!path.isEmpty()) - previewDialog_->setPreview(path); - else if (!formats.isEmpty()) { - const auto &mime = formats.first(); - previewDialog_->setPreview(source.data(mime), mime); - } else { - setUploading(false); - previewDialog_->deleteLater(); + if (!source->isOpen()) + source->open(QIODevice::ReadOnly); + + data = source->readAll(); + source->reset(); + + if (!data.size()) { + nhlog::ui()->warn("Attempted to upload zero-byte file?! Mimetype {}, filename {}", + mimetype_.toStdString(), + originalFilename_.toStdString()); + emit uploadFailed(this); return; } - connect(previewDialog_, &dialogs::PreviewUploadOverlay::aborted, this, [this]() { - setUploading(false); - }); + nhlog::ui()->debug("Mime: {}", mimetype_.toStdString()); + if (mimeClass_ == u"image") { + QImage img = utils::readImage(data); + setThumbnail(img.scaled( + std::min(800, img.width()), std::min(800, img.height()), Qt::KeepAspectRatioByExpanding)); - connect( - previewDialog_, - &dialogs::PreviewUploadOverlay::confirmUpload, - this, - [this](const QByteArray &data, const QString &mime, const QString &fn) { - if (!data.size()) { - nhlog::ui()->warn("Attempted to upload zero-byte file?! Mimetype {}, filename {}", - mime.toStdString(), - fn.toStdString()); + dimensions_ = img.size(); + if (img.height() > 200 && img.width() > 360) + img = img.scaled(360, 200, Qt::KeepAspectRatioByExpanding); + std::vector data_; + for (int y = 0; y < img.height(); y++) { + for (int x = 0; x < img.width(); x++) { + auto p = img.pixel(x, y); + data_.push_back(static_cast(qRed(p))); + data_.push_back(static_cast(qGreen(p))); + data_.push_back(static_cast(qBlue(p))); + } + } + blurhash_ = + QString::fromStdString(blurhash::encode(data_.data(), img.width(), img.height(), 4, 3)); + } else if (mimeClass_ == u"video" || mimeClass_ == u"audio") { + auto mediaPlayer = new QMediaPlayer( + this, + mimeClass_ == u"video" ? QFlags{QMediaPlayer::StreamPlayback, QMediaPlayer::VideoSurface} + : QFlags{QMediaPlayer::StreamPlayback}); + mediaPlayer->setMuted(true); + + if (mimeClass_ == u"video") { + auto newSurface = new InputVideoSurface(this); + connect( + newSurface, &InputVideoSurface::newImage, this, [this, mediaPlayer](QImage img) { + mediaPlayer->stop(); + + auto orientation = mediaPlayer->metaData(QMediaMetaData::Orientation).toInt(); + if (orientation == 90 || orientation == 270 || orientation == 180) { + img = + img.transformed(QTransform().rotate(orientation), Qt::SmoothTransformation); + } + + nhlog::ui()->debug("Got image {}x{}", img.width(), img.height()); + + this->setThumbnail(img); + + if (!dimensions_.isValid()) + this->dimensions_ = img.size(); + + if (img.height() > 200 && img.width() > 360) + img = img.scaled(360, 200, Qt::KeepAspectRatioByExpanding); + std::vector data_; + for (int y = 0; y < img.height(); y++) { + for (int x = 0; x < img.width(); x++) { + auto p = img.pixel(x, y); + data_.push_back(static_cast(qRed(p))); + data_.push_back(static_cast(qGreen(p))); + data_.push_back(static_cast(qBlue(p))); + } + } + blurhash_ = QString::fromStdString( + blurhash::encode(data_.data(), img.width(), img.height(), 4, 3)); + }); + mediaPlayer->setVideoOutput(newSurface); + } + + connect(mediaPlayer, + qOverload(&QMediaPlayer::error), + this, + [mediaPlayer](QMediaPlayer::Error error) { + nhlog::ui()->debug("Media player error {} and errorStr {}", + error, + mediaPlayer->errorString().toStdString()); + }); + connect(mediaPlayer, + &QMediaPlayer::mediaStatusChanged, + [mediaPlayer](QMediaPlayer::MediaStatus status) { + nhlog::ui()->debug( + "Media player status {} and error {}", status, mediaPlayer->error()); + }); + connect(mediaPlayer, + qOverload(&QMediaPlayer::metaDataChanged), + [this, mediaPlayer](QString t, QVariant) { + nhlog::ui()->debug("Got metadata {}", t.toStdString()); + + if (mediaPlayer->duration() > 0) + this->duration_ = mediaPlayer->duration(); + + dimensions_ = mediaPlayer->metaData(QMediaMetaData::Resolution).toSize(); + auto orientation = mediaPlayer->metaData(QMediaMetaData::Orientation).toInt(); + if (orientation == 90 || orientation == 270) { + dimensions_.transpose(); + } + }); + connect(mediaPlayer, &QMediaPlayer::durationChanged, [this, mediaPlayer](qint64 duration) { + if (duration > 0) { + this->duration_ = mediaPlayer->duration(); + if (mimeClass_ == u"audio") + mediaPlayer->stop(); + } + nhlog::ui()->debug("Duration changed {}", duration); + }); + mediaPlayer->setMedia(QMediaContent(originalFilename_), source.get()); + + mediaPlayer->play(); + } +} + +void +MediaUpload::startUpload() +{ + if (!thumbnail_.isNull() && thumbnailUrl_.isEmpty()) { + QByteArray ba; + QBuffer buffer(&ba); + buffer.open(QIODevice::WriteOnly); + thumbnail_.save(&buffer, "PNG"); + auto payload = std::string(ba.data(), ba.size()); + if (encrypt_) { + mtx::crypto::BinaryBuf buf; + std::tie(buf, thumbnailEncryptedFile) = mtx::crypto::encrypt_file(std::move(payload)); + payload = mtx::crypto::to_string(buf); + } + thumbnailSize_ = payload.size(); + + http::client()->upload( + payload, + encryptedFile ? "application/octet-stream" : "image/png", + "", + [this](const mtx::responses::ContentURI &res, mtx::http::RequestErr err) mutable { + if (err) { + emit ChatPage::instance()->showNotification( + tr("Failed to upload media. Please try again.")); + nhlog::net()->warn("failed to upload media: {} {} ({})", + err->matrix_error.error, + to_string(err->matrix_error.errcode), + static_cast(err->status_code)); + thumbnail_ = QImage(); + startUpload(); + return; + } + + thumbnailUrl_ = QString::fromStdString(res.content_uri); + if (thumbnailEncryptedFile) + thumbnailEncryptedFile->url = res.content_uri; + + startUpload(); + }); + return; + } + + auto payload = std::string(data.data(), data.size()); + if (encrypt_) { + mtx::crypto::BinaryBuf buf; + std::tie(buf, encryptedFile) = mtx::crypto::encrypt_file(std::move(payload)); + payload = mtx::crypto::to_string(buf); + } + size_ = payload.size(); + + http::client()->upload( + payload, + encryptedFile ? "application/octet-stream" : mimetype_.toStdString(), + encrypt_ ? "" : originalFilename_.toStdString(), + [this](const mtx::responses::ContentURI &res, mtx::http::RequestErr err) mutable { + if (err) { + emit ChatPage::instance()->showNotification( + tr("Failed to upload media. Please try again.")); + nhlog::net()->warn("failed to upload media: {} {} ({})", + err->matrix_error.error, + to_string(err->matrix_error.errcode), + static_cast(err->status_code)); + emit uploadFailed(this); return; } - setUploading(true); - setText(QLatin1String("")); + auto url = QString::fromStdString(res.content_uri); + if (encryptedFile) + encryptedFile->url = res.content_uri; - auto payload = std::string(data.data(), data.size()); - std::optional encryptedFile; - if (cache::isRoomEncrypted(room->roomId().toStdString())) { - mtx::crypto::BinaryBuf buf; - std::tie(buf, encryptedFile) = mtx::crypto::encrypt_file(payload); - payload = mtx::crypto::to_string(buf); - } - - QSize dimensions; - QString blurhash; - auto mimeClass = mime.left(mime.indexOf(u'/')); - nhlog::ui()->debug("Mime: {}", mime.toStdString()); - if (mimeClass == u"image") { - QImage img = utils::readImage(data); - - dimensions = img.size(); - if (img.height() > 200 && img.width() > 360) - img = img.scaled(360, 200, Qt::KeepAspectRatioByExpanding); - std::vector data_; - for (int y = 0; y < img.height(); y++) { - for (int x = 0; x < img.width(); x++) { - auto p = img.pixel(x, y); - data_.push_back(static_cast(qRed(p))); - data_.push_back(static_cast(qGreen(p))); - data_.push_back(static_cast(qBlue(p))); - } - } - blurhash = QString::fromStdString( - blurhash::encode(data_.data(), img.width(), img.height(), 4, 3)); - } - - http::client()->upload( - payload, - encryptedFile ? "application/octet-stream" : mime.toStdString(), - QFileInfo(fn).fileName().toStdString(), - [this, - filename = fn, - encryptedFile = std::move(encryptedFile), - mimeClass, - mime, - size = payload.size(), - dimensions, - blurhash](const mtx::responses::ContentURI &res, mtx::http::RequestErr err) mutable { - if (err) { - emit ChatPage::instance()->showNotification( - tr("Failed to upload media. Please try again.")); - nhlog::net()->warn("failed to upload media: {} {} ({})", - err->matrix_error.error, - to_string(err->matrix_error.errcode), - static_cast(err->status_code)); - setUploading(false); - return; - } - - auto url = QString::fromStdString(res.content_uri); - if (encryptedFile) - encryptedFile->url = res.content_uri; - - if (mimeClass == u"image") - image(filename, encryptedFile, url, mime, size, dimensions, blurhash); - else if (mimeClass == u"audio") - audio(filename, encryptedFile, url, mime, size); - else if (mimeClass == u"video") - video(filename, encryptedFile, url, mime, size); - else - file(filename, encryptedFile, url, mime, size); - - setUploading(false); - }); + emit uploadComplete(this, std::move(url)); }); } +void +InputBar::finalizeUpload(MediaUpload *upload, QString url) +{ + auto mime = upload->mimetype(); + auto filename = upload->filename(); + auto mimeClass = upload->mimeClass(); + auto size = upload->size(); + auto encryptedFile = upload->encryptedFile_(); + if (mimeClass == u"image") + image(filename, + encryptedFile, + url, + mime, + size, + upload->dimensions(), + upload->thumbnailEncryptedFile_(), + upload->thumbnailUrl(), + upload->thumbnailSize(), + upload->thumbnailImg().size(), + upload->blurhash()); + else if (mimeClass == u"audio") + audio(filename, encryptedFile, url, mime, size, upload->duration()); + else if (mimeClass == u"video") + video(filename, + encryptedFile, + url, + mime, + size, + upload->duration(), + upload->dimensions(), + upload->thumbnailEncryptedFile_(), + upload->thumbnailUrl(), + upload->thumbnailSize(), + upload->thumbnailImg().size(), + upload->blurhash()); + else + file(filename, encryptedFile, url, mime, size); + + removeRunUpload(upload); +} + +void +InputBar::removeRunUpload(MediaUpload *upload) +{ + auto it = std::find_if(runningUploads.begin(), + runningUploads.end(), + [upload](const UploadHandle &h) { return h.get() == upload; }); + if (it != runningUploads.end()) + runningUploads.erase(it); + + if (runningUploads.empty()) + setUploading(false); + else + runningUploads.front()->startUpload(); +} + +void +InputBar::startUploadFromPath(const QString &path) +{ + if (path.isEmpty()) + return; + + auto file = std::make_unique(path); + + if (!file->open(QIODevice::ReadOnly)) { + nhlog::ui()->warn( + "Failed to open file ({}): {}", path.toStdString(), file->errorString().toStdString()); + return; + } + + QMimeDatabase db; + auto mime = db.mimeTypeForFileNameAndData(path, file.get()); + + startUpload(std::move(file), path, mime.name()); +} + +void +InputBar::startUploadFromMimeData(const QMimeData &source, const QString &format) +{ + auto file = std::make_unique(); + file->setData(source.data(format)); + + if (!file->open(QIODevice::ReadOnly)) { + nhlog::ui()->warn("Failed to open buffer: {}", file->errorString().toStdString()); + return; + } + + startUpload(std::move(file), {}, format); +} +void +InputBar::startUpload(std::unique_ptr dev, const QString &orgPath, const QString &format) +{ + auto upload = + UploadHandle(new MediaUpload(std::move(dev), format, orgPath, room->isEncrypted(), this)); + connect(upload.get(), &MediaUpload::uploadComplete, this, &InputBar::finalizeUpload); + + unconfirmedUploads.push_back(std::move(upload)); + + nhlog::ui()->debug("Uploads {}", unconfirmedUploads.size()); + emit uploadsChanged(); +} + +void +InputBar::acceptUploads() +{ + if (unconfirmedUploads.empty()) + return; + + bool wasntRunning = runningUploads.empty(); + runningUploads.insert(runningUploads.end(), + std::make_move_iterator(unconfirmedUploads.begin()), + std::make_move_iterator(unconfirmedUploads.end())); + unconfirmedUploads.clear(); + emit uploadsChanged(); + + if (wasntRunning) { + setUploading(true); + runningUploads.front()->startUpload(); + } +} + +void +InputBar::declineUploads() +{ + unconfirmedUploads.clear(); + emit uploadsChanged(); +} + +QVariantList +InputBar::uploads() const +{ + QVariantList l; + l.reserve((int)unconfirmedUploads.size()); + + for (auto &e : unconfirmedUploads) + l.push_back(QVariant::fromValue(e.get())); + return l; +} + void InputBar::startTyping() { diff --git a/src/timeline/InputBar.h b/src/timeline/InputBar.h index 20c3d17e..28a4bcf6 100644 --- a/src/timeline/InputBar.h +++ b/src/timeline/InputBar.h @@ -5,10 +5,17 @@ #pragma once +#include +#include +#include #include +#include #include #include +#include +#include #include +#include #include #include @@ -25,12 +32,139 @@ enum class MarkdownOverride OFF, }; +class InputVideoSurface : public QAbstractVideoSurface +{ + Q_OBJECT + +public: + InputVideoSurface(QObject *parent) + : QAbstractVideoSurface(parent) + {} + + bool present(const QVideoFrame &frame) override; + + QList + supportedPixelFormats(QAbstractVideoBuffer::HandleType type) const override; + +signals: + void newImage(QImage img); +}; + +class MediaUpload : public QObject +{ + Q_OBJECT + Q_PROPERTY(int mediaType READ type NOTIFY mediaTypeChanged) + // https://stackoverflow.com/questions/33422265/pass-qimage-to-qml/68554646#68554646 + Q_PROPERTY(QUrl thumbnail READ thumbnailDataUrl NOTIFY thumbnailChanged) + // Q_PROPERTY(QString humanSize READ humanSize NOTIFY huSizeChanged) + Q_PROPERTY(QString filename READ filename WRITE setFilename NOTIFY filenameChanged) + + // thumbnail video + // https://stackoverflow.com/questions/26229633/display-on-screen-using-qabstractvideosurface + +public: + enum MediaType + { + File, + Image, + Video, + Audio, + }; + Q_ENUM(MediaType) + + explicit MediaUpload(std::unique_ptr data, + QString mimetype, + QString originalFilename, + bool encrypt, + QObject *parent = nullptr); + + [[nodiscard]] int type() const + { + if (mimeClass_ == u"video") + return MediaType::Video; + else if (mimeClass_ == u"audio") + return MediaType::Audio; + else if (mimeClass_ == u"image") + return MediaType::Image; + else + return MediaType::File; + } + [[nodiscard]] QString url() const { return url_; } + [[nodiscard]] QString mimetype() const { return mimetype_; } + [[nodiscard]] QString mimeClass() const { return mimeClass_; } + [[nodiscard]] QString filename() const { return originalFilename_; } + [[nodiscard]] QString blurhash() const { return blurhash_; } + [[nodiscard]] uint64_t size() const { return size_; } + [[nodiscard]] uint64_t duration() const { return duration_; } + [[nodiscard]] std::optional encryptedFile_() + { + return encryptedFile; + } + [[nodiscard]] std::optional thumbnailEncryptedFile_() + { + return thumbnailEncryptedFile; + } + [[nodiscard]] QSize dimensions() const { return dimensions_; } + + QImage thumbnailImg() const { return thumbnail_; } + QString thumbnailUrl() const { return thumbnailUrl_; } + QUrl thumbnailDataUrl() const; + [[nodiscard]] uint64_t thumbnailSize() const { return thumbnailSize_; } + + void setFilename(QString fn) + { + if (fn != originalFilename_) { + originalFilename_ = std::move(fn); + emit filenameChanged(); + } + } + +signals: + void uploadComplete(MediaUpload *self, QString url); + void uploadFailed(MediaUpload *self); + void filenameChanged(); + void thumbnailChanged(); + void mediaTypeChanged(); + +public slots: + void startUpload(); + +private slots: + void setThumbnail(QImage img) + { + this->thumbnail_ = std::move(img); + emit thumbnailChanged(); + } + +public: + // void uploadThumbnail(QImage img); + + std::unique_ptr source; + QByteArray data; + QString mimetype_; + QString mimeClass_; + QString originalFilename_; + QString blurhash_; + QString thumbnailUrl_; + QString url_; + std::optional encryptedFile, thumbnailEncryptedFile; + + QImage thumbnail_; + + QSize dimensions_; + uint64_t size_ = 0; + uint64_t thumbnailSize_ = 0; + uint64_t duration_ = 0; + bool encrypt_; +}; + class InputBar : public QObject { Q_OBJECT Q_PROPERTY(bool uploading READ uploading NOTIFY uploadingChanged) Q_PROPERTY(bool containsAtRoom READ containsAtRoom NOTIFY containsAtRoomChanged) Q_PROPERTY(QString text READ text NOTIFY textChanged) + Q_PROPERTY(QVariantList uploads READ uploads NOTIFY uploadsChanged) public: explicit InputBar(TimelineModel *parent) @@ -45,6 +179,8 @@ public: connect(&typingTimeout_, &QTimer::timeout, this, &InputBar::stopTyping); } + QVariantList uploads() const; + public slots: [[nodiscard]] QString text() const; QString previousText(); @@ -65,15 +201,22 @@ public slots: void reaction(const QString &reactedEvent, const QString &reactionKey); void sticker(CombinedImagePackModel *model, int row); + void acceptUploads(); + void declineUploads(); + private slots: void startTyping(); void stopTyping(); + void finalizeUpload(MediaUpload *upload, QString url); + void removeRunUpload(MediaUpload *upload); + signals: void insertText(QString text); void textChanged(QString newText); void uploadingChanged(bool value); void containsAtRoomChanged(); + void uploadsChanged(); private: void emote(const QString &body, bool rainbowify); @@ -85,6 +228,10 @@ private: const QString &mime, uint64_t dsize, const QSize &dimensions, + const std::optional &thumbnailEncryptedFile, + const QString &thumbnailUrl, + uint64_t thumbnailSize, + const QSize &thumbnailDimensions, const QString &blurhash); void file(const QString &filename, const std::optional &encryptedFile, @@ -95,14 +242,24 @@ private: const std::optional &file, const QString &url, const QString &mime, - uint64_t dsize); + uint64_t dsize, + uint64_t duration); void video(const QString &filename, const std::optional &file, const QString &url, const QString &mime, - uint64_t dsize); + uint64_t dsize, + uint64_t duration, + const QSize &dimensions, + const std::optional &thumbnailEncryptedFile, + const QString &thumbnailUrl, + uint64_t thumbnailSize, + const QSize &thumbnailDimensions, + const QString &blurhash); - void showPreview(const QMimeData &source, const QString &path, const QStringList &formats); + void startUploadFromPath(const QString &path); + void startUploadFromMimeData(const QMimeData &source, const QString &format); + void startUpload(std::unique_ptr dev, const QString &orgPath, const QString &format); void setUploading(bool value) { if (value != uploading_) { @@ -121,4 +278,16 @@ private: int selectionStart = 0, selectionEnd = 0, cursorPosition = 0; bool uploading_ = false; bool containsAtRoom_ = false; + + struct DeleteLaterDeleter + { + void operator()(QObject *p) + { + if (p) + p->deleteLater(); + } + }; + using UploadHandle = std::unique_ptr; + std::vector unconfirmedUploads; + std::vector runningUploads; }; diff --git a/src/timeline/TimelineModel.cpp b/src/timeline/TimelineModel.cpp index 8e6c7235..28d8f0bb 100644 --- a/src/timeline/TimelineModel.cpp +++ b/src/timeline/TimelineModel.cpp @@ -474,6 +474,7 @@ TimelineModel::roleNames() const {Timestamp, "timestamp"}, {Url, "url"}, {ThumbnailUrl, "thumbnailUrl"}, + {Duration, "duration"}, {Blurhash, "blurhash"}, {Filename, "filename"}, {Filesize, "filesize"}, @@ -627,6 +628,8 @@ TimelineModel::data(const mtx::events::collections::TimelineEvents &event, int r return QVariant(QString::fromStdString(url(event))); case ThumbnailUrl: return QVariant(QString::fromStdString(thumbnail_url(event))); + case Duration: + return QVariant(static_cast(duration(event))); case Blurhash: return QVariant(QString::fromStdString(blurhash(event))); case Filename: @@ -739,6 +742,7 @@ TimelineModel::data(const mtx::events::collections::TimelineEvents &event, int r m.insert(names[Timestamp], data(event, static_cast(Timestamp))); m.insert(names[Url], data(event, static_cast(Url))); m.insert(names[ThumbnailUrl], data(event, static_cast(ThumbnailUrl))); + m.insert(names[Duration], data(event, static_cast(Duration))); m.insert(names[Blurhash], data(event, static_cast(Blurhash))); m.insert(names[Filename], data(event, static_cast(Filename))); m.insert(names[Filesize], data(event, static_cast(Filesize))); @@ -1363,6 +1367,10 @@ struct SendMessageVisitor if (encInfo) emit model_->newEncryptedImage(encInfo.value()); + encInfo = mtx::accessors::thumbnail_file(msg); + if (encInfo) + emit model_->newEncryptedImage(encInfo.value()); + model_->sendEncryptedMessage(msg, Event); } else { msg.type = Event; diff --git a/src/timeline/TimelineModel.h b/src/timeline/TimelineModel.h index f47203f0..7e21a394 100644 --- a/src/timeline/TimelineModel.h +++ b/src/timeline/TimelineModel.h @@ -215,6 +215,7 @@ public: Timestamp, Url, ThumbnailUrl, + Duration, Blurhash, Filename, Filesize,