diff --git a/resources/qml/ActiveCallBar.qml b/resources/qml/ActiveCallBar.qml index 282cac81..cbe36e6d 100644 --- a/resources/qml/ActiveCallBar.qml +++ b/resources/qml/ActiveCallBar.qml @@ -12,8 +12,11 @@ Rectangle { MouseArea { anchors.fill: parent - onClicked: if (TimelineManager.onVideoCall) - stackLayout.currentIndex = stackLayout.currentIndex ? 0 : 1; + onClicked: { + if (TimelineManager.onVideoCall) + stackLayout.currentIndex = stackLayout.currentIndex ? 0 : 1; + + } } RowLayout { @@ -39,8 +42,7 @@ Rectangle { Image { Layout.preferredWidth: 24 Layout.preferredHeight: 24 - source: TimelineManager.onVideoCall ? - "qrc:/icons/icons/ui/video-call.png" : "qrc:/icons/icons/ui/place-call.png" + source: TimelineManager.onVideoCall ? "qrc:/icons/icons/ui/video-call.png" : "qrc:/icons/icons/ui/place-call.png" } Label { @@ -69,6 +71,7 @@ Rectangle { callTimer.startTime = Math.floor(d.getTime() / 1000); if (TimelineManager.onVideoCall) stackLayout.currentIndex = 1; + break; case WebRTCState.DISCONNECTED: callStateLabel.text = ""; diff --git a/resources/qml/MessageInput.qml b/resources/qml/MessageInput.qml index b76a44f3..a1220599 100644 --- a/resources/qml/MessageInput.qml +++ b/resources/qml/MessageInput.qml @@ -2,7 +2,6 @@ import QtQuick 2.9 import QtQuick.Controls 2.3 import QtQuick.Layouts 1.2 import QtQuick.Window 2.2 - import im.nheko 1.0 Rectangle { @@ -36,6 +35,20 @@ Rectangle { image: ":/icons/icons/ui/paper-clip-outline.png" Layout.topMargin: 8 Layout.bottomMargin: 8 + onClicked: TimelineManager.timeline.input.openFileSelection() + + Rectangle { + anchors.fill: parent + color: colors.window + visible: TimelineManager.timeline.input.uploading + + NhekoBusyIndicator { + anchors.fill: parent + running: parent.visible + } + + } + } ScrollView { @@ -52,27 +65,27 @@ Rectangle { placeholderTextColor: colors.buttonText color: colors.text wrapMode: TextEdit.Wrap - onTextChanged: TimelineManager.timeline.input.updateState(selectionStart, selectionEnd, cursorPosition, text) onCursorPositionChanged: TimelineManager.timeline.input.updateState(selectionStart, selectionEnd, cursorPosition, text) onSelectionStartChanged: TimelineManager.timeline.input.updateState(selectionStart, selectionEnd, cursorPosition, text) onSelectionEndChanged: TimelineManager.timeline.input.updateState(selectionStart, selectionEnd, cursorPosition, text) - - Connections { - target: TimelineManager.timeline.input - function onInsertText(text_) { textArea.insert(textArea.cursorPosition, text_); } - } - Keys.onPressed: { if (event.matches(StandardKey.Paste)) { - TimelineManager.timeline.input.paste(false) - event.accepted = true + TimelineManager.timeline.input.paste(false); + event.accepted = true; + } else if (event.matches(StandardKey.InsertParagraphSeparator)) { + TimelineManager.timeline.input.send(); + textArea.clear(); + event.accepted = true; } - else if (event.matches(StandardKey.InsertParagraphSeparator)) { - TimelineManager.timeline.input.send() - textArea.clear() - event.accepted = true + } + + Connections { + function onInsertText(text_) { + textArea.insert(textArea.cursorPosition, text_); } + + target: TimelineManager.timeline.input } MouseArea { @@ -110,6 +123,10 @@ Rectangle { Layout.topMargin: 8 Layout.bottomMargin: 8 Layout.rightMargin: 16 + onClicked: { + TimelineManager.timeline.input.send(); + textArea.clear(); + } } } diff --git a/resources/qml/NhekoBusyIndicator.qml b/resources/qml/NhekoBusyIndicator.qml new file mode 100644 index 00000000..8889989a --- /dev/null +++ b/resources/qml/NhekoBusyIndicator.qml @@ -0,0 +1,64 @@ +import QtQuick 2.9 +import QtQuick.Controls 2.3 +import QtQuick.Layouts 1.2 + +BusyIndicator { + id: control + + contentItem: Item { + implicitWidth: Math.min(parent.height, parent.width) + implicitHeight: implicitWidth + + Item { + id: item + + height: Math.min(parent.height, parent.width) + width: height + opacity: control.running ? 1 : 0 + + RotationAnimator { + target: item + running: control.visible && control.running + from: 0 + to: 360 + loops: Animation.Infinite + duration: 2000 + } + + Repeater { + id: repeater + + model: 6 + + Rectangle { + implicitWidth: radius * 2 + implicitHeight: radius * 2 + radius: item.height / 6 + color: colors.text + opacity: (index + 2) / (repeater.count + 2) + transform: [ + Translate { + y: -Math.min(item.width, item.height) * 0.5 + item.height / 6 + }, + Rotation { + angle: index / repeater.count * 360 + origin.x: item.height / 2 + origin.y: item.height / 2 + } + ] + } + + } + + Behavior on opacity { + OpacityAnimator { + duration: 250 + } + + } + + } + + } + +} diff --git a/resources/qml/TimelineView.qml b/resources/qml/TimelineView.qml index d85167af..5fce0846 100644 --- a/resources/qml/TimelineView.qml +++ b/resources/qml/TimelineView.qml @@ -192,13 +192,15 @@ Page { StackLayout { id: stackLayout + currentIndex: 0 Connections { - target: TimelineManager function onActiveTimelineChanged() { stackLayout.currentIndex = 0; } + + target: TimelineManager } MessageView { @@ -210,6 +212,7 @@ Page { source: TimelineManager.onVideoCall ? "VideoCall.qml" : "" onLoaded: TimelineManager.setVideoCallItem() } + } TypingIndicator { diff --git a/resources/qml/VideoCall.qml b/resources/qml/VideoCall.qml index 69fc1a2b..14408b6e 100644 --- a/resources/qml/VideoCall.qml +++ b/resources/qml/VideoCall.qml @@ -1,7 +1,6 @@ import QtQuick 2.9 - import org.freedesktop.gstreamer.GLVideoItem 1.0 GstGLVideoItem { - objectName: "videoCallItem" + objectName: "videoCallItem" } diff --git a/resources/res.qrc b/resources/res.qrc index efb9c907..02f31498 100644 --- a/resources/res.qrc +++ b/resources/res.qrc @@ -132,6 +132,7 @@ qml/Avatar.qml qml/ImageButton.qml qml/MatrixText.qml + qml/NhekoBusyIndicator.qml qml/StatusIndicator.qml qml/EncryptionIndicator.qml qml/Reactions.qml diff --git a/src/ChatPage.cpp b/src/ChatPage.cpp index 6e1ed8ca..e09041e7 100644 --- a/src/ChatPage.cpp +++ b/src/ChatPage.cpp @@ -268,126 +268,6 @@ ChatPage::ChatPage(QSharedPointer userSettings, QWidget *parent) this, SIGNAL(unreadMessages(int))); - connect( - text_input_, - &TextInputWidget::uploadMedia, - this, - [this](QSharedPointer dev, QString mimeClass, const QString &fn) { - if (!dev->open(QIODevice::ReadOnly)) { - emit uploadFailed( - QString("Error while reading media: %1").arg(dev->errorString())); - return; - } - - auto bin = dev->readAll(); - QMimeDatabase db; - QMimeType mime = db.mimeTypeForData(bin); - - auto payload = std::string(bin.data(), bin.size()); - std::optional encryptedFile; - if (cache::isRoomEncrypted(current_room_.toStdString())) { - mtx::crypto::BinaryBuf buf; - std::tie(buf, encryptedFile) = mtx::crypto::encrypt_file(payload); - payload = mtx::crypto::to_string(buf); - } - - QSize dimensions; - QString blurhash; - if (mimeClass == "image") { - QImage img = utils::readImage(&bin); - - 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.name().toStdString(), - QFileInfo(fn).fileName().toStdString(), - [this, - room_id = current_room_, - filename = fn, - encryptedFile, - mimeClass, - mime = mime.name(), - size = payload.size(), - dimensions, - blurhash](const mtx::responses::ContentURI &res, mtx::http::RequestErr err) { - if (err) { - emit uploadFailed( - 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)); - return; - } - - emit mediaUploaded(room_id, - filename, - encryptedFile, - QString::fromStdString(res.content_uri), - mimeClass, - mime, - size, - dimensions, - blurhash); - }); - }); - - connect(this, &ChatPage::uploadFailed, this, [this](const QString &msg) { - text_input_->hideUploadSpinner(); - emit showNotification(msg); - }); - connect(this, - &ChatPage::mediaUploaded, - this, - [this](QString roomid, - QString filename, - std::optional encryptedFile, - QString url, - QString mimeClass, - QString mime, - qint64 dsize, - QSize dimensions, - QString blurhash) { - text_input_->hideUploadSpinner(); - - if (encryptedFile) - encryptedFile->url = url.toStdString(); - - if (mimeClass == "image") - view_manager_->queueImageMessage(roomid, - filename, - encryptedFile, - url, - mime, - dsize, - dimensions, - blurhash); - else if (mimeClass == "audio") - view_manager_->queueAudioMessage( - roomid, filename, encryptedFile, url, mime, dsize); - else if (mimeClass == "video") - view_manager_->queueVideoMessage( - roomid, filename, encryptedFile, url, mime, dsize); - else - view_manager_->queueFileMessage( - roomid, filename, encryptedFile, url, mime, dsize); - }); - connect(text_input_, &TextInputWidget::callButtonPress, this, [this]() { if (callManager_->onActiveCall()) { callManager_->hangUp(); diff --git a/src/ChatPage.h b/src/ChatPage.h index 9a38fd6e..41c6f276 100644 --- a/src/ChatPage.h +++ b/src/ChatPage.h @@ -126,17 +126,6 @@ signals: void highlightedNotifsRetrieved(const mtx::responses::Notifications &, const QPoint widgetPos); - void uploadFailed(const QString &msg); - void mediaUploaded(const QString &roomid, - const QString &filename, - const std::optional &file, - const QString &url, - const QString &mimeClass, - const QString &mime, - qint64 dsize, - const QSize &dimensions, - const QString &blurhash); - void contentLoaded(); void closing(); void changeWindowTitle(const int); diff --git a/src/TextInputWidget.cpp b/src/TextInputWidget.cpp index dec7a574..232c0cad 100644 --- a/src/TextInputWidget.cpp +++ b/src/TextInputWidget.cpp @@ -88,10 +88,6 @@ FilteredTextEdit::FilteredTextEdit(QWidget *parent) typingTimer_->setSingleShot(true); connect(typingTimer_, &QTimer::timeout, this, &FilteredTextEdit::stopTyping); - connect(&previewDialog_, - &dialogs::PreviewUploadOverlay::confirmUpload, - this, - &FilteredTextEdit::uploadData); connect(this, &FilteredTextEdit::resultsRetrieved, this, &FilteredTextEdit::showResults); connect( @@ -355,81 +351,6 @@ FilteredTextEdit::keyPressEvent(QKeyEvent *event) } } -bool -FilteredTextEdit::canInsertFromMimeData(const QMimeData *source) const -{ - return (source->hasImage() || QTextEdit::canInsertFromMimeData(source)); -} - -void -FilteredTextEdit::insertFromMimeData(const QMimeData *source) -{ - qInfo() << "Got mime formats: \n" << source->formats(); - const auto formats = source->formats().filter("/"); - const auto image = formats.filter("image/", Qt::CaseInsensitive); - const auto audio = formats.filter("audio/", Qt::CaseInsensitive); - const auto video = formats.filter("video/", Qt::CaseInsensitive); - - if (!image.empty() && source->hasImage()) { - QImage img = qvariant_cast(source->imageData()); - previewDialog_.setPreview(img, image.front()); - } else if (!audio.empty()) { - showPreview(source, audio); - } else if (!video.empty()) { - showPreview(source, video); - } else if (source->hasUrls()) { - // Generic file path for any platform. - QString path; - for (auto &&u : source->urls()) { - if (u.isLocalFile()) { - path = u.toLocalFile(); - break; - } - } - - if (!path.isEmpty() && QFileInfo{path}.exists()) { - previewDialog_.setPreview(path); - } else { - qWarning() - << "Clipboard does not contain any valid file paths:" << source->urls(); - } - } else if (source->hasFormat("x-special/gnome-copied-files")) { - // Special case for X11 users. See "Notes for X11 Users" in source. - // Source: http://doc.qt.io/qt-5/qclipboard.html - - // This MIME type returns a string with multiple lines separated by '\n'. The first - // line is the command to perform with the clipboard (not useful to us). The - // following lines are the file URIs. - // - // Source: the nautilus source code in file 'src/nautilus-clipboard.c' in function - // nautilus_clipboard_get_uri_list_from_selection_data() - // https://github.com/GNOME/nautilus/blob/master/src/nautilus-clipboard.c - - auto data = source->data("x-special/gnome-copied-files").split('\n'); - if (data.size() < 2) { - qWarning() << "MIME format is malformed, cannot perform paste."; - return; - } - - QString path; - for (int i = 1; i < data.size(); ++i) { - QUrl url{data[i]}; - if (url.isLocalFile()) { - path = url.toLocalFile(); - break; - } - } - - if (!path.isEmpty()) { - previewDialog_.setPreview(path); - } else { - qWarning() << "Clipboard does not contain any valid file paths:" << data; - } - } else { - QTextEdit::insertFromMimeData(source); - } -} - void FilteredTextEdit::stopTyping() { @@ -494,28 +415,6 @@ FilteredTextEdit::textChanged() working_history_[history_index_] = toPlainText(); } -void -FilteredTextEdit::uploadData(const QByteArray data, - const QString &mediaType, - const QString &filename) -{ - QSharedPointer buffer{new QBuffer{this}}; - buffer->setData(data); - - emit startedUpload(); - - emit media(buffer, mediaType, filename); -} - -void -FilteredTextEdit::showPreview(const QMimeData *source, const QStringList &formats) -{ - // Retrieve data as MIME type. - auto const &mime = formats.first(); - QByteArray data = source->data(mime); - previewDialog_.setPreview(data, mime); -} - TextInputWidget::TextInputWidget(QWidget *parent) : QWidget(parent) { @@ -624,7 +523,6 @@ TextInputWidget::TextInputWidget(QWidget *parent) #endif connect(sendMessageBtn_, &FlatButton::clicked, input_, &FilteredTextEdit::submit); connect(sendFileBtn_, SIGNAL(clicked()), this, SLOT(openFileSelection())); - connect(input_, &FilteredTextEdit::media, this, &TextInputWidget::uploadMedia); connect(emojiBtn_, SIGNAL(emojiSelected(const QString &)), this, @@ -633,9 +531,6 @@ TextInputWidget::TextInputWidget(QWidget *parent) connect(input_, &FilteredTextEdit::startedTyping, this, &TextInputWidget::startedTyping); connect(input_, &FilteredTextEdit::stoppedTyping, this, &TextInputWidget::stoppedTyping); - - connect( - input_, &FilteredTextEdit::startedUpload, this, &TextInputWidget::showUploadSpinner); } void @@ -654,47 +549,6 @@ TextInputWidget::addSelectedEmoji(const QString &emoji) input_->show(); } -void -TextInputWidget::openFileSelection() -{ - const QString homeFolder = QStandardPaths::writableLocation(QStandardPaths::HomeLocation); - const auto fileName = - QFileDialog::getOpenFileName(this, tr("Select a file"), homeFolder, tr("All Files (*)")); - - if (fileName.isEmpty()) - return; - - QMimeDatabase db; - QMimeType mime = db.mimeTypeForFile(fileName, QMimeDatabase::MatchContent); - - const auto format = mime.name().split("/")[0]; - - QSharedPointer file{new QFile{fileName, this}}; - - emit uploadMedia(file, format, QFileInfo(fileName).fileName()); - - showUploadSpinner(); -} - -void -TextInputWidget::showUploadSpinner() -{ - topLayout_->removeWidget(sendFileBtn_); - sendFileBtn_->hide(); - - topLayout_->insertWidget(1, spinner_); - spinner_->start(); -} - -void -TextInputWidget::hideUploadSpinner() -{ - topLayout_->removeWidget(spinner_); - topLayout_->insertWidget(1, sendFileBtn_); - sendFileBtn_->show(); - spinner_->stop(); -} - void TextInputWidget::stopTyping() { diff --git a/src/TextInputWidget.h b/src/TextInputWidget.h index f9d84871..44419547 100644 --- a/src/TextInputWidget.h +++ b/src/TextInputWidget.h @@ -57,9 +57,6 @@ signals: void startedTyping(); void stoppedTyping(); void startedUpload(); - void message(QString msg); - void command(QString name, QString args); - void media(QSharedPointer data, QString mimeClass, const QString &filename); //! Trigger the suggestion popup. void showSuggestions(const QString &query); @@ -73,8 +70,6 @@ public slots: protected: void keyPressEvent(QKeyEvent *event) override; - bool canInsertFromMimeData(const QMimeData *source) const override; - void insertFromMimeData(const QMimeData *source) override; void focusOutEvent(QFocusEvent *event) override { suggestionsPopup_.hide(); @@ -131,9 +126,7 @@ private: void insertCompletion(QString completion); void textChanged(); - void uploadData(const QByteArray data, const QString &media, const QString &filename); void afterCompletion(int); - void showPreview(const QMimeData *source, const QStringList &formats); }; class TextInputWidget : public QWidget @@ -161,8 +154,6 @@ public: } public slots: - void openFileSelection(); - void hideUploadSpinner(); void focusLineEdit() { input_->setFocus(); } void changeCallButtonState(webrtc::State); @@ -172,9 +163,6 @@ private slots: signals: void heightChanged(int height); - void uploadMedia(const QSharedPointer data, - QString mimeClass, - const QString &filename); void callButtonPress(); void sendJoinRoomRequest(const QString &room); @@ -192,8 +180,6 @@ protected: void paintEvent(QPaintEvent *) override; private: - void showUploadSpinner(); - QHBoxLayout *topLayout_; FilteredTextEdit *input_; diff --git a/src/Utils.cpp b/src/Utils.cpp index 38dbba22..2896e773 100644 --- a/src/Utils.cpp +++ b/src/Utils.cpp @@ -677,9 +677,10 @@ utils::restoreCombobox(QComboBox *combo, const QString &value) } QImage -utils::readImage(QByteArray *data) +utils::readImage(const QByteArray *data) { - QBuffer buf(data); + QBuffer buf; + buf.setData(*data); QImageReader reader(&buf); reader.setAutoTransform(true); return reader.read(); diff --git a/src/Utils.h b/src/Utils.h index 5e7fb601..f59e8673 100644 --- a/src/Utils.h +++ b/src/Utils.h @@ -303,5 +303,5 @@ restoreCombobox(QComboBox *combo, const QString &value); //! Read image respecting exif orientation QImage -readImage(QByteArray *data); +readImage(const QByteArray *data); } diff --git a/src/dialogs/PreviewUploadOverlay.cpp b/src/dialogs/PreviewUploadOverlay.cpp index 20959b0a..e03993c7 100644 --- a/src/dialogs/PreviewUploadOverlay.cpp +++ b/src/dialogs/PreviewUploadOverlay.cpp @@ -60,7 +60,10 @@ PreviewUploadOverlay::PreviewUploadOverlay(QWidget *parent) emit confirmUpload(data_, mediaType_, fileName_.text()); close(); }); - connect(&cancel_, &QPushButton::clicked, this, &PreviewUploadOverlay::close); + connect(&cancel_, &QPushButton::clicked, this, [this]() { + emit aborted(); + close(); + }); } void diff --git a/src/dialogs/PreviewUploadOverlay.h b/src/dialogs/PreviewUploadOverlay.h index 11cd49bc..5139e3f2 100644 --- a/src/dialogs/PreviewUploadOverlay.h +++ b/src/dialogs/PreviewUploadOverlay.h @@ -40,6 +40,7 @@ public: signals: void confirmUpload(const QByteArray data, const QString &media, const QString &filename); + void aborted(); private: void init(); diff --git a/src/timeline/InputBar.cpp b/src/timeline/InputBar.cpp index dcd4a106..bd8f6414 100644 --- a/src/timeline/InputBar.cpp +++ b/src/timeline/InputBar.cpp @@ -1,18 +1,27 @@ #include "InputBar.h" #include +#include #include #include +#include +#include +#include #include +#include #include "Cache.h" #include "ChatPage.h" #include "Logging.h" #include "MatrixClient.h" +#include "Olm.h" #include "TimelineModel.h" #include "UserSettingsPage.h" #include "Utils.h" +#include "dialogs/PreviewUploadOverlay.h" + +#include "blurhash.hpp" static constexpr size_t INPUT_HISTORY_SIZE = 10; @@ -32,7 +41,66 @@ InputBar::paste(bool fromMouse) if (!md) return; - if (md->hasImage()) { + nhlog::ui()->debug("Got mime formats: {}", md->formats().join(", ").toStdString()); + const auto formats = md->formats().filter("/"); + const auto image = formats.filter("image/", Qt::CaseInsensitive); + const auto audio = formats.filter("audio/", Qt::CaseInsensitive); + const auto video = formats.filter("video/", Qt::CaseInsensitive); + + if (!image.empty() && md->hasImage()) { + showPreview(*md, "", image); + } else if (!audio.empty()) { + showPreview(*md, "", audio); + } else if (!video.empty()) { + showPreview(*md, "", video); + } else if (md->hasUrls()) { + // Generic file path for any platform. + QString path; + for (auto &&u : md->urls()) { + if (u.isLocalFile()) { + path = u.toLocalFile(); + break; + } + } + + if (!path.isEmpty() && QFileInfo{path}.exists()) { + showPreview(*md, path, formats); + } else { + nhlog::ui()->warn("Clipboard does not contain any valid file paths."); + } + } else if (md->hasFormat("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 + + // This MIME type returns a string with multiple lines separated by '\n'. The first + // line is the command to perform with the clipboard (not useful to us). The + // following lines are the file URIs. + // + // Source: the nautilus source code in file 'src/nautilus-clipboard.c' in function + // nautilus_clipboard_get_uri_list_from_selection_data() + // https://github.com/GNOME/nautilus/blob/master/src/nautilus-clipboard.c + + auto data = md->data("x-special/gnome-copied-files").split('\n'); + if (data.size() < 2) { + nhlog::ui()->warn("MIME format is malformed, cannot perform paste."); + return; + } + + QString path; + for (int i = 1; i < data.size(); ++i) { + QUrl url{data[i]}; + if (url.isLocalFile()) { + path = url.toLocalFile(); + break; + } + } + + 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 { @@ -78,6 +146,37 @@ InputBar::send() nhlog::ui()->debug("Send: {}", text.toStdString()); } +void +InputBar::openFileSelection() +{ + const QString homeFolder = QStandardPaths::writableLocation(QStandardPaths::HomeLocation); + const auto fileName = QFileDialog::getOpenFileName( + ChatPage::instance(), tr("Select a file"), homeFolder, tr("All Files (*)")); + + 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( + QString("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()}); +} + void InputBar::message(QString msg) { @@ -149,6 +248,112 @@ InputBar::emote(QString msg) room->sendMessageEvent(emote, mtx::events::EventType::RoomMessage); } +void +InputBar::image(const QString &filename, + const std::optional &file, + const QString &url, + const QString &mime, + uint64_t dsize, + const QSize &dimensions, + const QString &blurhash) +{ + mtx::events::msg::Image image; + image.info.mimetype = mime.toStdString(); + image.info.size = dsize; + image.info.blurhash = blurhash.toStdString(); + image.body = filename.toStdString(); + image.info.h = dimensions.height(); + image.info.w = dimensions.width(); + + if (file) + image.file = file; + else + image.url = url.toStdString(); + + if (!room->reply().isEmpty()) { + image.relates_to.in_reply_to.event_id = room->reply().toStdString(); + room->resetReply(); + } + + room->sendMessageEvent(image, mtx::events::EventType::RoomMessage); +} + +void +InputBar::file(const QString &filename, + const std::optional &encryptedFile, + const QString &url, + const QString &mime, + uint64_t dsize) +{ + mtx::events::msg::File file; + file.info.mimetype = mime.toStdString(); + file.info.size = dsize; + file.body = filename.toStdString(); + + if (encryptedFile) + file.file = encryptedFile; + else + file.url = url.toStdString(); + + if (!room->reply().isEmpty()) { + file.relates_to.in_reply_to.event_id = room->reply().toStdString(); + room->resetReply(); + } + + room->sendMessageEvent(file, mtx::events::EventType::RoomMessage); +} + +void +InputBar::audio(const QString &filename, + const std::optional &file, + const QString &url, + const QString &mime, + uint64_t dsize) +{ + mtx::events::msg::Audio audio; + audio.info.mimetype = mime.toStdString(); + audio.info.size = dsize; + audio.body = filename.toStdString(); + audio.url = url.toStdString(); + + if (file) + audio.file = file; + else + audio.url = url.toStdString(); + + if (!room->reply().isEmpty()) { + audio.relates_to.in_reply_to.event_id = room->reply().toStdString(); + room->resetReply(); + } + + room->sendMessageEvent(audio, mtx::events::EventType::RoomMessage); +} + +void +InputBar::video(const QString &filename, + const std::optional &file, + const QString &url, + const QString &mime, + uint64_t dsize) +{ + mtx::events::msg::Video video; + video.info.mimetype = mime.toStdString(); + video.info.size = dsize; + video.body = filename.toStdString(); + + if (file) + video.file = file; + else + video.url = url.toStdString(); + + if (!room->reply().isEmpty()) { + video.relates_to.in_reply_to.event_id = room->reply().toStdString(); + room->resetReply(); + } + + room->sendMessageEvent(video, mtx::events::EventType::RoomMessage); +} + void InputBar::command(QString command, QString args) { @@ -196,3 +401,113 @@ InputBar::command(QString command, QString args) cache::dropOutboundMegolmSession(room->roomId().toStdString()); } } + +void +InputBar::showPreview(const QMimeData &source, QString path, const QStringList &formats) +{ + dialogs::PreviewUploadOverlay *previewDialog_ = + new dialogs::PreviewUploadOverlay(ChatPage::instance()); + previewDialog_->setAttribute(Qt::WA_DeleteOnClose); + + if (source.hasImage()) + previewDialog_->setPreview(qvariant_cast(source.imageData()), + formats.front()); + else if (!path.isEmpty()) + previewDialog_->setPreview(path); + else if (!formats.isEmpty()) { + auto mime = formats.first(); + previewDialog_->setPreview(source.data(mime), mime); + } else { + setUploading(false); + previewDialog_->deleteLater(); + return; + } + + connect(previewDialog_, &dialogs::PreviewUploadOverlay::aborted, this, [this]() { + setUploading(false); + }); + + connect( + previewDialog_, + &dialogs::PreviewUploadOverlay::confirmUpload, + this, + [this](const QByteArray data, const QString &mime, const QString &fn) { + setUploading(true); + + 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.split("/")[0]; + if (mimeClass == "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 == "image") + image(filename, + encryptedFile, + url, + mime, + size, + dimensions, + blurhash); + else if (mimeClass == "audio") + audio(filename, encryptedFile, url, mime, size); + else if (mimeClass == "video") + video(filename, encryptedFile, url, mime, size); + else + file(filename, encryptedFile, url, mime, size); + + setUploading(false); + }); + }); +} diff --git a/src/timeline/InputBar.h b/src/timeline/InputBar.h index f3a38c2e..35e3f8a4 100644 --- a/src/timeline/InputBar.h +++ b/src/timeline/InputBar.h @@ -3,11 +3,17 @@ #include #include +#include +#include + class TimelineModel; +class QMimeData; +class QStringList; class InputBar : public QObject { Q_OBJECT + Q_PROPERTY(bool uploading READ uploading NOTIFY uploadingChanged) public: InputBar(TimelineModel *parent) @@ -19,18 +25,53 @@ public slots: void send(); void paste(bool fromMouse); void updateState(int selectionStart, int selectionEnd, int cursorPosition, QString text); + void openFileSelection(); + bool uploading() const { return uploading_; } signals: void insertText(QString text); + void uploadingChanged(bool value); private: void message(QString body); void emote(QString body); void command(QString name, QString args); + void image(const QString &filename, + const std::optional &file, + const QString &url, + const QString &mime, + uint64_t dsize, + const QSize &dimensions, + const QString &blurhash); + void file(const QString &filename, + const std::optional &encryptedFile, + const QString &url, + const QString &mime, + uint64_t dsize); + void audio(const QString &filename, + const std::optional &file, + const QString &url, + const QString &mime, + uint64_t dsize); + void video(const QString &filename, + const std::optional &file, + const QString &url, + const QString &mime, + uint64_t dsize); + + void showPreview(const QMimeData &source, QString path, const QStringList &formats); + void setUploading(bool value) + { + if (value != uploading_) { + uploading_ = value; + emit uploadingChanged(value); + } + } TimelineModel *room; QString text; std::deque history_; std::size_t history_index_ = 0; int selectionStart = 0, selectionEnd = 0, cursorPosition = 0; + bool uploading_ = false; }; diff --git a/src/timeline/TimelineModel.h b/src/timeline/TimelineModel.h index 58a1496c..16a2565e 100644 --- a/src/timeline/TimelineModel.h +++ b/src/timeline/TimelineModel.h @@ -150,7 +150,7 @@ class TimelineModel : public QAbstractListModel Q_PROPERTY(QString roomName READ roomName NOTIFY roomNameChanged) Q_PROPERTY(QString roomAvatarUrl READ roomAvatarUrl NOTIFY roomAvatarUrlChanged) Q_PROPERTY(QString roomTopic READ roomTopic NOTIFY roomTopicChanged) - Q_PROPERTY(InputBar *input READ input) + Q_PROPERTY(InputBar *input READ input CONSTANT) public: explicit TimelineModel(TimelineViewManager *manager,