diff --git a/resources/qml/TimelineView.qml b/resources/qml/TimelineView.qml index 481561d22fc593e5a3505f25d7054ffecc00df83..2b6a8f267f83d1a91993fef357fb0310e810539f 100644 --- a/resources/qml/TimelineView.qml +++ b/resources/qml/TimelineView.qml @@ -79,6 +79,78 @@ Page { } + Component { + id: forwardCompleter + + Popup { + id: forwardMessagePopup + x: 400 + y: 400 + + property var mid + + onOpened: { + completerPopup.open(); + roomTextInput.forceActiveFocus(); + } + + background: Rectangle { + border.color: "#444" + } + + function setMessageEventId(mid_in) { + mid = mid_in; + } + + MatrixTextField { + id: roomTextInput + + width: 100 + + color: colors.text + onTextEdited: { + completerPopup.completer.searchString = text; + } + Keys.onPressed: { + if (event.key == Qt.Key_Up && completerPopup.opened) { + event.accepted = true; + completerPopup.up(); + } else if (event.key == Qt.Key_Down && completerPopup.opened) { + event.accepted = true; + completerPopup.down(); + } else if (event.matches(StandardKey.InsertParagraphSeparator)) { + completerPopup.finishCompletion(); + event.accepted = true; + } + } + } + + Completer { + id: completerPopup + + y: 50 + width: 100 + completerName: "room" + avatarHeight: 24 + avatarWidth: 24 + bottomToTop: false + closePolicy: Popup.NoAutoClose + } + + Connections { + onCompletionSelected: { + TimelineManager.timeline.forwardMessage(messageContextMenu.eventId, id); + forwardMessagePopup.close(); + } + onCountChanged: { + if (completerPopup.count > 0 && (completerPopup.currentIndex < 0 || completerPopup.currentIndex >= completerPopup.count)) + completerPopup.currentIndex = 0; + } + target: completerPopup + } + } + } + Shortcut { sequence: "Ctrl+K" onActivated: { @@ -133,6 +205,15 @@ Page { onTriggered: TimelineManager.timeline.readReceiptsAction(messageContextMenu.eventId) } + Platform.MenuItem { + text: qsTr("Forward") + onTriggered: { + var forwardMess = forwardCompleter.createObject(timelineRoot); + forwardMess.open(); + forwardMess.setMessageEventId(messageContextMenu.eventId) + } + } + Platform.MenuItem { text: qsTr("Mark as read") } diff --git a/src/timeline/InputBar.h b/src/timeline/InputBar.h index 9db16baefc66ca4e281f0af356f3376344526ead..d4fcfacf909aa4c887e598b1f6e9ee01f4737e7f 100644 --- a/src/timeline/InputBar.h +++ b/src/timeline/InputBar.h @@ -41,6 +41,14 @@ public: connect(&typingTimeout_, &QTimer::timeout, this, &InputBar::stopTyping); } + void image(const QString &filename, + const std::optional<mtx::crypto::EncryptedFile> &file, + const QString &url, + const QString &mime, + uint64_t dsize, + const QSize &dimensions, + const QString &blurhash); + public slots: QString text() const; QString previousText(); @@ -70,13 +78,6 @@ private: void emote(QString body, bool rainbowify); void notice(QString body, bool rainbowify); void command(QString name, QString args); - void image(const QString &filename, - const std::optional<mtx::crypto::EncryptedFile> &file, - const QString &url, - const QString &mime, - uint64_t dsize, - const QSize &dimensions, - const QString &blurhash); void file(const QString &filename, const std::optional<mtx::crypto::EncryptedFile> &encryptedFile, const QString &url, diff --git a/src/timeline/TimelineModel.cpp b/src/timeline/TimelineModel.cpp index 8e96cb3efc3e8a03369cff1e5b867d643d829f88..e3efe5ad32c129c460e4eece6749703b88aacaa3 100644 --- a/src/timeline/TimelineModel.cpp +++ b/src/timeline/TimelineModel.cpp @@ -822,6 +822,16 @@ TimelineModel::viewRawMessage(QString id) const Q_UNUSED(dialog); } +void +TimelineModel::forwardMessage(QString eventId, QString roomId) +{ + auto e = events.get(eventId.toStdString(), ""); + if (!e) + return; + + emit forwardToRoom(e, roomId, cache::isRoomEncrypted(room_id_.toStdString())); +} + void TimelineModel::viewDecryptedRawMessage(QString id) const { diff --git a/src/timeline/TimelineModel.h b/src/timeline/TimelineModel.h index 06da95c6f30cdd3a46eef424f4b6db3b3d5942fd..3e6f6f15c43d85319915948f96edf90d4bc363f1 100644 --- a/src/timeline/TimelineModel.h +++ b/src/timeline/TimelineModel.h @@ -219,6 +219,7 @@ public: Q_INVOKABLE QString formatPowerLevelEvent(QString id); Q_INVOKABLE void viewRawMessage(QString id) const; + Q_INVOKABLE void forwardMessage(QString eventId, QString roomId); Q_INVOKABLE void viewDecryptedRawMessage(QString id) const; Q_INVOKABLE void openUserProfile(QString userid, bool global = false); Q_INVOKABLE void openRoomSettings(); @@ -322,6 +323,9 @@ signals: void roomNameChanged(); void roomTopicChanged(); void roomAvatarUrlChanged(); + void forwardToRoom(mtx::events::collections::TimelineEvents *e, + QString roomId, + bool sentFromEncrypted); private: template<typename T> diff --git a/src/timeline/TimelineViewManager.cpp b/src/timeline/TimelineViewManager.cpp index f15b0b1492047be7528347409b39805d263ce29c..ae807f2d5eeea4a225b788e0e1c14aea8f46aa0b 100644 --- a/src/timeline/TimelineViewManager.cpp +++ b/src/timeline/TimelineViewManager.cpp @@ -4,6 +4,7 @@ #include "TimelineViewManager.h" +#include <QBuffer> #include <QDesktopServices> #include <QDropEvent> #include <QMetaType> @@ -25,14 +26,13 @@ #include "RoomsModel.h" #include "UserSettingsPage.h" #include "UsersModel.h" +#include "blurhash.hpp" #include "dialogs/ImageOverlay.h" #include "emoji/EmojiModel.h" #include "emoji/Provider.h" #include "ui/NhekoCursorShape.h" #include "ui/NhekoDropArea.h" -#include <iostream> //only for debugging - Q_DECLARE_METATYPE(mtx::events::collections::TimelineEvents) Q_DECLARE_METATYPE(std::vector<DeviceInfo>) @@ -332,6 +332,10 @@ TimelineViewManager::addRoom(const QString &room_id) &TimelineModel::newEncryptedImage, imgProvider, &MxcImageProvider::addEncryptionInfo); + connect(newRoom.data(), + &TimelineModel::forwardToRoom, + this, + &TimelineViewManager::forwardMessageToRoom); models.insert(room_id, std::move(newRoom)); } } @@ -614,3 +618,98 @@ TimelineViewManager::focusTimeline() { getWidget()->setFocus(); } + +void +TimelineViewManager::forwardMessageToRoom(mtx::events::collections::TimelineEvents *e, + QString roomId, + bool sentFromEncrypted) +{ + auto elem = *e; + auto room = models.find(roomId); + auto messageType = mtx::accessors::msg_type(elem); + + if (sentFromEncrypted && messageType == mtx::events::MessageType::Image) { + auto body = mtx::accessors::body(elem); + auto mimetype = mtx::accessors::mimetype(elem); + auto imageHeight = mtx::accessors::media_height(elem); + auto imageWidth = mtx::accessors::media_height(elem); + + QString mxcUrl = QString::fromStdString(mtx::accessors::url(elem)); + MxcImageProvider::download( + mxcUrl.remove("mxc://"), + QSize(imageWidth, imageHeight), + [this, roomId, body, mimetype](QString, QSize, QImage image, QString) { + QByteArray data = + QByteArray::fromRawData((const char *)image.bits(), image.byteCount()); + + auto payload = std::string(data.data(), data.size()); + std::optional<mtx::crypto::EncryptedFile> encryptedFile; + + QSize dimensions; + QString blurhash; + auto mimeClass = QString::fromStdString(mimetype).split("/")[0]; + + dimensions = image.size(); + if (image.height() > 200 && image.width() > 360) + image = image.scaled(360, 200, Qt::KeepAspectRatioByExpanding); + std::vector<unsigned char> data_; + for (int y = 0; y < image.height(); y++) { + for (int x = 0; x < image.width(); x++) { + auto p = image.pixel(x, y); + data_.push_back(static_cast<unsigned char>(qRed(p))); + data_.push_back(static_cast<unsigned char>(qGreen(p))); + data_.push_back(static_cast<unsigned char>(qBlue(p))); + } + } + blurhash = QString::fromStdString( + blurhash::encode(data_.data(), image.width(), image.height(), 4, 3)); + + http::client()->upload( + payload, + encryptedFile ? "application/octet-stream" : mimetype, + body, + [this, + roomId, + filename = body, + encryptedFile = std::move(encryptedFile), + mimeClass, + mimetype, + size = payload.size(), + dimensions, + blurhash](const mtx::responses::ContentURI &res, + mtx::http::RequestErr err) mutable { + if (err) { + nhlog::net()->warn("failed to upload media: {} {} ({})", + err->matrix_error.error, + to_string(err->matrix_error.errcode), + static_cast<int>(err->status_code)); + return; + } + + auto url = QString::fromStdString(res.content_uri); + if (encryptedFile) + encryptedFile->url = res.content_uri; + + auto r = models.find(roomId); + r.value()->input()->image(QString::fromStdString(filename), + encryptedFile, + url, + QString::fromStdString(mimetype), + size, + dimensions, + blurhash); + }); + }); + return; + }; + + std::visit( + [room](auto e) { + if constexpr (mtx::events::message_content_to_type<decltype(e.content)> == + mtx::events::EventType::RoomMessage) { + room.value()->sendMessageEvent(e.content, + mtx::events::EventType::RoomMessage); + } + }, + elem); +} \ No newline at end of file diff --git a/src/timeline/TimelineViewManager.h b/src/timeline/TimelineViewManager.h index 3b40514284153a73baf6e67493d1dc470b01ecff..40bee9907c43caf444e8d39eff8837a63d2eb5ed 100644 --- a/src/timeline/TimelineViewManager.h +++ b/src/timeline/TimelineViewManager.h @@ -16,6 +16,7 @@ #include "Cache.h" #include "CallManager.h" +#include "EventAccessors.h" #include "Logging.h" #include "TimelineModel.h" #include "Utils.h" @@ -146,6 +147,9 @@ public slots: void backToRooms() { emit showRoomList(); } QObject *completerFor(QString completerName, QString roomId = ""); + void forwardMessageToRoom(mtx::events::collections::TimelineEvents *e, + QString roomId, + bool sentFromEncrypted); private slots: void openImageOverlayInternal(QString eventId, QImage img);