summary refs log tree commit diff
diff options
context:
space:
mode:
authorNicolas Werner <nicolas.werner@hotmail.de>2021-07-15 20:37:52 +0200
committerNicolas Werner <nicolas.werner@hotmail.de>2021-07-19 01:28:07 +0200
commit8a1666bc889d963693b5dff8f0b4c7612319644a (patch)
tree80cdae27c4a88287fc08d707e638feecfd36d3da
parentMerge pull request #646 from Nheko-Reborn/historical-key-sharing (diff)
downloadnheko-8a1666bc889d963693b5dff8f0b4c7612319644a.tar.xz
Basic sticker support
-rw-r--r--CMakeLists.txt2
-rw-r--r--resources/icons/ui/sticky-note-solid.svg1
-rw-r--r--resources/qml/MatrixText.qml8
-rw-r--r--resources/qml/MessageInput.qml29
-rw-r--r--resources/qml/MessageView.qml10
-rw-r--r--resources/qml/emoji/EmojiButton.qml23
-rw-r--r--resources/qml/emoji/StickerPicker.qml174
-rw-r--r--resources/res.qrc3
-rw-r--r--src/Cache.cpp7
-rw-r--r--src/Cache_p.h6
-rw-r--r--src/ImagePackModel.cpp91
-rw-r--r--src/ImagePackModel.h52
-rw-r--r--src/timeline/InputBar.cpp17
-rw-r--r--src/timeline/InputBar.h2
-rw-r--r--src/timeline/TimelineModel.cpp9
-rw-r--r--src/timeline/TimelineModel.h15
-rw-r--r--src/timeline/TimelineViewManager.cpp7
17 files changed, 419 insertions, 37 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 78900535..6b26b2e5 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -355,6 +355,7 @@ set(SRC_FILES
 	src/Olm.cpp
 	src/RegisterPage.cpp
 	src/SSOHandler.cpp
+	src/ImagePackModel.cpp
 	src/TrayIcon.cpp
 	src/UserSettingsPage.cpp
 	src/UsersModel.cpp
@@ -559,6 +560,7 @@ qt5_wrap_cpp(MOC_HEADERS
 	src/MxcImageProvider.h
 	src/RegisterPage.h
 	src/SSOHandler.h
+	src/ImagePackModel.h
 	src/TrayIcon.h
 	src/UserSettingsPage.h
 	src/UsersModel.h
diff --git a/resources/icons/ui/sticky-note-solid.svg b/resources/icons/ui/sticky-note-solid.svg
new file mode 100644
index 00000000..bc36d474
--- /dev/null
+++ b/resources/icons/ui/sticky-note-solid.svg
@@ -0,0 +1 @@
+<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="sticky-note" class="svg-inline--fa fa-sticky-note fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M312 320h136V56c0-13.3-10.7-24-24-24H24C10.7 32 0 42.7 0 56v400c0 13.3 10.7 24 24 24h264V344c0-13.2 10.8-24 24-24zm129 55l-98 98c-4.5 4.5-10.6 7-17 7h-6V352h128v6.1c0 6.3-2.5 12.4-7 16.9z"></path></svg>
\ No newline at end of file
diff --git a/resources/qml/MatrixText.qml b/resources/qml/MatrixText.qml
index 9129b154..35e5f7e7 100644
--- a/resources/qml/MatrixText.qml
+++ b/resources/qml/MatrixText.qml
@@ -8,6 +8,7 @@ import im.nheko 1.0
 
 TextEdit {
     id: r
+
     textFormat: TextEdit.RichText
     readOnly: true
     focus: false
@@ -19,14 +20,13 @@ TextEdit {
     onLinkActivated: Nheko.openLink(link)
     ToolTip.visible: hoveredLink
     ToolTip.text: hoveredLink
+    Component.onCompleted: {
+        TimelineManager.fixImageRendering(r.textDocument, r);
+    }
 
     CursorShape {
         anchors.fill: parent
         cursorShape: hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor
     }
 
-    Component.onCompleted: {
-        TimelineManager.fixImageRendering(r.textDocument, r)
-    }
-
 }
diff --git a/resources/qml/MessageInput.qml b/resources/qml/MessageInput.qml
index 24f9b0e8..d4f7ca62 100644
--- a/resources/qml/MessageInput.qml
+++ b/resources/qml/MessageInput.qml
@@ -2,6 +2,7 @@
 //
 // SPDX-License-Identifier: GPL-3.0-or-later
 
+import "./emoji"
 import "./voip"
 import QtQuick 2.12
 import QtQuick.Controls 2.3
@@ -87,7 +88,7 @@ Rectangle {
             Layout.alignment: Qt.AlignBottom // | Qt.AlignHCenter
             Layout.maximumHeight: Window.height / 4
             Layout.minimumHeight: Settings.fontSize
-            implicitWidth: inputBar.width - 4 * (22 + 16) - 24
+            implicitWidth: inputBar.width - 5 * (22 + 16) - 24
 
             TextArea {
                 id: messageInput
@@ -320,6 +321,30 @@ Rectangle {
         }
 
         ImageButton {
+            id: stickerButton
+
+            Layout.alignment: Qt.AlignRight | Qt.AlignBottom
+            Layout.margins: 8
+            hoverEnabled: true
+            width: 22
+            height: 22
+            image: ":/icons/icons/ui/sticky-note-solid.svg"
+            ToolTip.visible: hovered
+            ToolTip.text: qsTr("Stickers")
+            onClicked: stickerPopup.visible ? stickerPopup.close() : stickerPopup.show(stickerButton, TimelineManager.completerFor("stickers", room.roomId()), function(row) {
+                room.input.sticker(stickerPopup.model.sourceModel, row);
+                TimelineManager.focusMessageInput();
+            })
+
+            StickerPicker {
+                id: stickerPopup
+
+                colors: Nheko.colors
+            }
+
+        }
+
+        ImageButton {
             id: emojiButton
 
             Layout.alignment: Qt.AlignRight | Qt.AlignBottom
@@ -330,7 +355,7 @@ Rectangle {
             image: ":/icons/icons/ui/smile.png"
             ToolTip.visible: hovered
             ToolTip.text: qsTr("Emoji")
-            onClicked: emojiPopup.visible ? emojiPopup.close() : emojiPopup.show(emojiButton, function(emoji) {
+            onClicked: emojiPopup.visible ? emojiPopup.close() : emojiPopup.show(function(emoji) {
                 messageInput.insert(messageInput.cursorPosition, emoji);
                 TimelineManager.focusMessageInput();
             })
diff --git a/resources/qml/MessageView.qml b/resources/qml/MessageView.qml
index 33dff122..4e605ad7 100644
--- a/resources/qml/MessageView.qml
+++ b/resources/qml/MessageView.qml
@@ -92,16 +92,20 @@ ScrollView {
                     }
                 }
 
-                EmojiButton {
+                ImageButton {
                     id: reactButton
 
                     visible: chat.model ? chat.model.permissions.canSend(MtxEvent.Reaction) : false
                     width: 16
                     hoverEnabled: true
+                    image: ":/icons/icons/ui/smile.png"
                     ToolTip.visible: hovered
                     ToolTip.text: qsTr("React")
-                    emojiPicker: emojiPopup
-                    event_id: row.model ? row.model.eventId : ""
+                    onClicked: emojiPopup.visible ? emojiPopup.close() : emojiPopup.show(emojiButton, function(emoji) {
+                        var event_id = row.model ? row.model.eventId : "";
+                        room.input.reaction(event_id, emoji);
+                        TimelineManager.focusMessageInput();
+                    })
                 }
 
                 ImageButton {
diff --git a/resources/qml/emoji/EmojiButton.qml b/resources/qml/emoji/EmojiButton.qml
deleted file mode 100644
index 5f4d23d3..00000000
--- a/resources/qml/emoji/EmojiButton.qml
+++ /dev/null
@@ -1,23 +0,0 @@
-// SPDX-FileCopyrightText: 2021 Nheko Contributors
-//
-// SPDX-License-Identifier: GPL-3.0-or-later
-
-import "../"
-import QtQuick 2.10
-import QtQuick.Controls 2.1
-import im.nheko 1.0
-import im.nheko.EmojiModel 1.0
-
-ImageButton {
-    id: emojiButton
-
-    property var colors: currentActivePalette
-    property var emojiPicker
-    property string event_id
-
-    image: ":/icons/icons/ui/smile.png"
-    onClicked: emojiPicker.visible ? emojiPicker.close() : emojiPicker.show(emojiButton, function(emoji) {
-        room.input.reaction(event_id, emoji);
-        TimelineManager.focusMessageInput();
-    })
-}
diff --git a/resources/qml/emoji/StickerPicker.qml b/resources/qml/emoji/StickerPicker.qml
new file mode 100644
index 00000000..3fe17ef2
--- /dev/null
+++ b/resources/qml/emoji/StickerPicker.qml
@@ -0,0 +1,174 @@
+// SPDX-FileCopyrightText: 2021 Nheko Contributors
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import "../"
+import QtGraphicalEffects 1.0
+import QtQuick 2.9
+import QtQuick.Controls 2.3
+import QtQuick.Layouts 1.3
+import im.nheko 1.0
+import im.nheko.EmojiModel 1.0
+
+Menu {
+    id: stickerPopup
+
+    property var callback
+    property var colors
+    property alias model: gridView.model
+    property var textArea
+    property real highlightHue: Nheko.colors.highlight.hslHue
+    property real highlightSat: Nheko.colors.highlight.hslSaturation
+    property real highlightLight: Nheko.colors.highlight.hslLightness
+    readonly property int stickerDim: 128
+    readonly property int stickerDimPad: 128 + Nheko.paddingSmall
+    readonly property int stickersPerRow: 3
+
+    function show(showAt, model_, callback) {
+        console.debug("Showing sticker picker");
+        model = model_;
+        stickerPopup.callback = callback;
+        popup(showAt ? showAt : null);
+    }
+
+    margins: 0
+    bottomPadding: 1
+    leftPadding: 1
+    rightPadding: 1
+    modal: true
+    focus: true
+    closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
+    //height: columnView.implicitHeight + 4
+    //width: columnView.implicitWidth
+    width: stickersPerRow * stickerDimPad + 20
+
+    Rectangle {
+        color: Nheko.colors.window
+        height: columnView.implicitHeight + 4
+        width: stickersPerRow * stickerDimPad + 20
+
+        ColumnLayout {
+            id: columnView
+
+            spacing: 0
+            anchors.leftMargin: 3
+            anchors.rightMargin: 3
+            anchors.bottom: parent.bottom
+            anchors.left: parent.left
+            anchors.right: parent.right
+            anchors.topMargin: 2
+
+            // Search field
+            TextField {
+                id: emojiSearch
+
+                Layout.topMargin: 3
+                Layout.preferredWidth: stickersPerRow * stickerDimPad + 20 - 6
+                palette: Nheko.colors
+                background: null
+                placeholderTextColor: Nheko.colors.buttonText
+                color: Nheko.colors.text
+                placeholderText: qsTr("Search")
+                selectByMouse: true
+                rightPadding: clearSearch.width
+                onTextChanged: searchTimer.restart()
+                onVisibleChanged: {
+                    if (visible)
+                        forceActiveFocus();
+
+                }
+
+                Timer {
+                    id: searchTimer
+
+                    interval: 350 // tweak as needed?
+                    onTriggered: stickerPopup.model.searchString = emojiSearch.text
+                }
+
+                ToolButton {
+                    id: clearSearch
+
+                    visible: emojiSearch.text !== ''
+                    icon.source: "image://colorimage/:/icons/icons/ui/round-remove-button.png?" + (clearSearch.hovered ? Nheko.colors.highlight : Nheko.colors.buttonText)
+                    focusPolicy: Qt.NoFocus
+                    onClicked: emojiSearch.clear()
+                    hoverEnabled: true
+                    background: null
+
+                    anchors {
+                        verticalCenter: parent.verticalCenter
+                        right: parent.right
+                    }
+                    // clear the default hover effects.
+
+                    Image {
+                        height: parent.height - 2 * Nheko.paddingSmall
+                        width: height
+                        source: "image://colorimage/:/icons/icons/ui/round-remove-button.png?" + (clearSearch.hovered ? Nheko.colors.highlight : Nheko.colors.buttonText)
+
+                        anchors {
+                            verticalCenter: parent.verticalCenter
+                            right: parent.right
+                            margins: Nheko.paddingSmall
+                        }
+
+                    }
+
+                }
+
+            }
+
+            // emoji grid
+            GridView {
+                id: gridView
+
+                Layout.preferredHeight: cellHeight * 3.5
+                Layout.preferredWidth: stickersPerRow * stickerDimPad + 20
+                Layout.leftMargin: 4
+                cellWidth: stickerDimPad
+                cellHeight: stickerDimPad
+                boundsBehavior: Flickable.StopAtBounds
+                clip: true
+                currentIndex: -1 // prevent sorting from stealing focus
+                cacheBuffer: 500
+
+                // Individual emoji
+                delegate: AbstractButton {
+                    width: stickerDim
+                    height: stickerDim
+                    hoverEnabled: true
+                    ToolTip.text: ":" + model.shortcode + ": - " + model.body
+                    ToolTip.visible: hovered
+                    // TODO: maybe add favorites at some point?
+                    onClicked: {
+                        console.debug("Picked " + model.shortcode);
+                        stickerPopup.close();
+                        callback(model.originalRow);
+                    }
+
+                    contentItem: Image {
+                        height: stickerDim
+                        width: stickerDim
+                        source: model.url.replace("mxc://", "image://MxcImage/")
+                        fillMode: Image.PreserveAspectFit
+                    }
+
+                    background: Rectangle {
+                        anchors.fill: parent
+                        color: hovered ? Nheko.colors.highlight : 'transparent'
+                        radius: 5
+                    }
+
+                }
+
+                ScrollBar.vertical: ScrollBar {
+                    id: emojiScroll
+                }
+
+            }
+
+        }
+
+    }
+
+}
diff --git a/resources/res.qrc b/resources/res.qrc
index f41835f9..e9479e57 100644
--- a/resources/res.qrc
+++ b/resources/res.qrc
@@ -26,6 +26,7 @@
         <file>icons/ui/search@2x.png</file>
         <file>icons/ui/settings.png</file>
         <file>icons/ui/settings@2x.png</file>
+        <file>icons/ui/sticky-note-solid.svg</file>
         <file>icons/ui/smile.png</file>
         <file>icons/ui/smile@2x.png</file>
         <file>icons/ui/speech-bubbles-comment-option.png</file>
@@ -150,8 +151,8 @@
         <file>qml/ForwardCompleter.qml</file>
         <file>qml/TypingIndicator.qml</file>
         <file>qml/RoomSettings.qml</file>
-        <file>qml/emoji/EmojiButton.qml</file>
         <file>qml/emoji/EmojiPicker.qml</file>
+        <file>qml/emoji/StickerPicker.qml</file>
         <file>qml/UserProfile.qml</file>
         <file>qml/delegates/MessageDelegate.qml</file>
         <file>qml/delegates/TextMessage.qml</file>
diff --git a/src/Cache.cpp b/src/Cache.cpp
index 7b6a6135..8c3d8c42 100644
--- a/src/Cache.cpp
+++ b/src/Cache.cpp
@@ -3383,6 +3383,13 @@ Cache::getChildRoomIds(const std::string &room_id)
 }
 
 std::optional<mtx::events::collections::RoomAccountDataEvents>
+Cache::getAccountData(mtx::events::EventType type, const std::string &room_id)
+{
+        auto txn = ro_txn(env_);
+        return getAccountData(txn, type, room_id);
+}
+
+std::optional<mtx::events::collections::RoomAccountDataEvents>
 Cache::getAccountData(lmdb::txn &txn, mtx::events::EventType type, const std::string &room_id)
 {
         try {
diff --git a/src/Cache_p.h b/src/Cache_p.h
index d1f6307d..3752f5e4 100644
--- a/src/Cache_p.h
+++ b/src/Cache_p.h
@@ -88,6 +88,12 @@ public:
         //! Retrieve if the room is a space
         bool getRoomIsSpace(lmdb::txn &txn, lmdb::dbi &statesdb);
 
+        //! retrieve a specific event from account data
+        //! pass empty room_id for global account data
+        std::optional<mtx::events::collections::RoomAccountDataEvents> getAccountData(
+          mtx::events::EventType type,
+          const std::string &room_id = "");
+
         //! Get a specific state event
         template<typename T>
         std::optional<mtx::events::StateEvent<T>> getStateEvent(const std::string &room_id,
diff --git a/src/ImagePackModel.cpp b/src/ImagePackModel.cpp
new file mode 100644
index 00000000..fb2599a5
--- /dev/null
+++ b/src/ImagePackModel.cpp
@@ -0,0 +1,91 @@
+// SPDX-FileCopyrightText: 2021 Nheko Contributors
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "ImagePackModel.h"
+
+#include "Cache_p.h"
+#include "CompletionModelRoles.h"
+
+ImagePackModel::ImagePackModel(const std::string &roomId, bool stickers, QObject *parent)
+  : QAbstractListModel(parent)
+  , room_id(roomId)
+{
+        auto accountpackV =
+          cache::client()->getAccountData(mtx::events::EventType::ImagePackInAccountData);
+        auto enabledRoomPacksV =
+          cache::client()->getAccountData(mtx::events::EventType::ImagePackRooms);
+
+        std::optional<mtx::events::msc2545::ImagePack> accountPack;
+        if (accountpackV) {
+                auto tmp =
+                  std::get_if<mtx::events::EphemeralEvent<mtx::events::msc2545::ImagePack>>(
+                    &*accountpackV);
+                if (tmp)
+                        accountPack = tmp->content;
+        }
+        // mtx::events::msc2545::ImagePackRooms *enabledRoomPacks = nullptr;
+        // if (enabledRoomPacksV)
+        //        enabledRoomPacks =
+        //          std::get_if<mtx::events::msc2545::ImagePackRooms>(&*enabledRoomPacksV);
+
+        if (accountPack && (!accountPack->pack || (stickers ? accountPack->pack->is_sticker()
+                                                            : accountPack->pack->is_emoji()))) {
+                QString packname;
+                if (accountPack->pack)
+                        packname = QString::fromStdString(accountPack->pack->display_name);
+
+                for (const auto &img : accountPack->images) {
+                        if (img.second.overrides_usage() &&
+                            (stickers ? !img.second.is_sticker() : !img.second.is_emoji()))
+                                continue;
+
+                        ImageDesc i{};
+                        i.shortcode = QString::fromStdString(img.first);
+                        i.packname  = packname;
+                        i.image     = img.second;
+                        images.push_back(std::move(i));
+                }
+        }
+}
+
+QHash<int, QByteArray>
+ImagePackModel::roleNames() const
+{
+        return {
+          {CompletionModel::CompletionRole, "completionRole"},
+          {CompletionModel::SearchRole, "searchRole"},
+          {CompletionModel::SearchRole2, "searchRole2"},
+          {Roles::Url, "url"},
+          {Roles::ShortCode, "shortcode"},
+          {Roles::Body, "body"},
+          {Roles::PackName, "packname"},
+          {Roles::OriginalRow, "originalRow"},
+        };
+}
+
+QVariant
+ImagePackModel::data(const QModelIndex &index, int role) const
+{
+        if (hasIndex(index.row(), index.column(), index.parent())) {
+                switch (role) {
+                case CompletionModel::CompletionRole:
+                        return QString::fromStdString(images[index.row()].image.url);
+                case Roles::Url:
+                        return QString::fromStdString(images[index.row()].image.url);
+                case CompletionModel::SearchRole:
+                case Roles::ShortCode:
+                        return images[index.row()].shortcode;
+                case CompletionModel::SearchRole2:
+                case Roles::Body:
+                        return QString::fromStdString(images[index.row()].image.body);
+                case Roles::PackName:
+                        return images[index.row()].packname;
+                case Roles::OriginalRow:
+                        return index.row();
+                default:
+                        return {};
+                }
+        }
+        return {};
+}
diff --git a/src/ImagePackModel.h b/src/ImagePackModel.h
new file mode 100644
index 00000000..10e71b8f
--- /dev/null
+++ b/src/ImagePackModel.h
@@ -0,0 +1,52 @@
+// SPDX-FileCopyrightText: 2021 Nheko Contributors
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include <QAbstractListModel>
+
+#include <mtx/events/mscs/image_packs.hpp>
+
+class ImagePackModel : public QAbstractListModel
+{
+        Q_OBJECT
+public:
+        enum Roles
+        {
+                Url = Qt::UserRole,
+                ShortCode,
+                Body,
+                PackName,
+                OriginalRow,
+        };
+
+        ImagePackModel(const std::string &roomId, bool stickers, QObject *parent = nullptr);
+        QHash<int, QByteArray> roleNames() const override;
+        int rowCount(const QModelIndex &parent = QModelIndex()) const override
+        {
+                (void)parent;
+                return (int)images.size();
+        }
+        QVariant data(const QModelIndex &index, int role) const override;
+
+        mtx::events::msc2545::PackImage imageAt(int row)
+        {
+                if (row < 0 || static_cast<size_t>(row) >= images.size())
+                        return {};
+                return images.at(static_cast<size_t>(row)).image;
+        }
+
+private:
+        std::string room_id;
+
+        struct ImageDesc
+        {
+                QString shortcode;
+                QString packname;
+
+                mtx::events::msc2545::PackImage image;
+        };
+
+        std::vector<ImageDesc> images;
+};
diff --git a/src/timeline/InputBar.cpp b/src/timeline/InputBar.cpp
index b0747a7c..0f210722 100644
--- a/src/timeline/InputBar.cpp
+++ b/src/timeline/InputBar.cpp
@@ -21,6 +21,7 @@
 #include "ChatPage.h"
 #include "CompletionProxyModel.h"
 #include "Config.h"
+#include "ImagePackModel.h"
 #include "Logging.h"
 #include "MainWindow.h"
 #include "MatrixClient.h"
@@ -502,6 +503,22 @@ InputBar::video(const QString &filename,
 }
 
 void
+InputBar::sticker(ImagePackModel *model, int row)
+{
+        if (!model || row < 0)
+                return;
+
+        auto img = model->imageAt(row);
+
+        mtx::events::msg::StickerImage sticker{};
+        sticker.info = img.info.value_or(mtx::common::ImageInfo{});
+        sticker.url  = img.url;
+        sticker.body = img.body;
+
+        room->sendMessageEvent(sticker, mtx::events::EventType::Sticker);
+}
+
+void
 InputBar::command(QString command, QString args)
 {
         if (command == "me") {
diff --git a/src/timeline/InputBar.h b/src/timeline/InputBar.h
index c9728379..acedceb7 100644
--- a/src/timeline/InputBar.h
+++ b/src/timeline/InputBar.h
@@ -12,6 +12,7 @@
 #include <mtx/responses/messages.hpp>
 
 class TimelineModel;
+class ImagePackModel;
 class QMimeData;
 class QDropEvent;
 class QStringList;
@@ -57,6 +58,7 @@ public slots:
                      MarkdownOverride useMarkdown = MarkdownOverride::NOT_SPECIFIED,
                      bool rainbowify              = false);
         void reaction(const QString &reactedEvent, const QString &reactionKey);
+        void sticker(ImagePackModel *model, int row);
 
 private slots:
         void startTyping();
diff --git a/src/timeline/TimelineModel.cpp b/src/timeline/TimelineModel.cpp
index 5832f56e..abfe28a9 100644
--- a/src/timeline/TimelineModel.cpp
+++ b/src/timeline/TimelineModel.cpp
@@ -1300,6 +1300,14 @@ struct SendMessageVisitor
                 sendRoomEvent<mtx::events::msg::KeyVerificationCancel,
                               mtx::events::EventType::KeyVerificationCancel>(msg);
         }
+        void operator()(mtx::events::Sticker msg)
+        {
+                msg.type = mtx::events::EventType::Sticker;
+                if (cache::isRoomEncrypted(model_->room_id_.toStdString())) {
+                        model_->sendEncryptedMessage(msg, mtx::events::EventType::Sticker);
+                } else
+                        emit model_->addPendingMessageToStore(msg);
+        }
 
         TimelineModel *model_;
 };
@@ -1309,6 +1317,7 @@ TimelineModel::addPendingMessage(mtx::events::collections::TimelineEvents event)
 {
         std::visit(
           [](auto &msg) {
+                  // gets overwritten for reactions and stickers in SendMessageVisitor
                   msg.type             = mtx::events::EventType::RoomMessage;
                   msg.event_id         = "m" + http::client()->generate_txn_id();
                   msg.sender           = http::client()->user_id().to_string();
diff --git a/src/timeline/TimelineModel.h b/src/timeline/TimelineModel.h
index b67234f2..0e2895d4 100644
--- a/src/timeline/TimelineModel.h
+++ b/src/timeline/TimelineModel.h
@@ -410,10 +410,17 @@ template<class T>
 void
 TimelineModel::sendMessageEvent(const T &content, mtx::events::EventType eventType)
 {
-        mtx::events::RoomEvent<T> msgCopy = {};
-        msgCopy.content                   = content;
-        msgCopy.type                      = eventType;
-        emit newMessageToSend(msgCopy);
+        if constexpr (std::is_same_v<T, mtx::events::msg::StickerImage>) {
+                mtx::events::Sticker msgCopy = {};
+                msgCopy.content              = content;
+                msgCopy.type                 = eventType;
+                emit newMessageToSend(msgCopy);
+        } else {
+                mtx::events::RoomEvent<T> msgCopy = {};
+                msgCopy.content                   = content;
+                msgCopy.type                      = eventType;
+                emit newMessageToSend(msgCopy);
+        }
         resetReply();
         resetEdit();
 }
diff --git a/src/timeline/TimelineViewManager.cpp b/src/timeline/TimelineViewManager.cpp
index b39ef615..ec1b3573 100644
--- a/src/timeline/TimelineViewManager.cpp
+++ b/src/timeline/TimelineViewManager.cpp
@@ -19,6 +19,7 @@
 #include "DelegateChooser.h"
 #include "DeviceVerificationFlow.h"
 #include "EventAccessors.h"
+#include "ImagePackModel.h"
 #include "Logging.h"
 #include "MainWindow.h"
 #include "MatrixClient.h"
@@ -144,6 +145,7 @@ TimelineViewManager::TimelineViewManager(CallManager *callManager, ChatPage *par
         qRegisterMetaType<mtx::events::msg::KeyVerificationReady>();
         qRegisterMetaType<mtx::events::msg::KeyVerificationRequest>();
         qRegisterMetaType<mtx::events::msg::KeyVerificationStart>();
+        qRegisterMetaType<ImagePackModel *>();
 
         qmlRegisterUncreatableMetaObject(qml_mtx_events::staticMetaObject,
                                          "im.nheko",
@@ -593,6 +595,11 @@ TimelineViewManager::completerFor(QString completerName, QString roomId)
                 auto proxy     = new CompletionProxyModel(roomModel);
                 roomModel->setParent(proxy);
                 return proxy;
+        } else if (completerName == "stickers") {
+                auto stickerModel = new ImagePackModel(roomId.toStdString(), true);
+                auto proxy        = new CompletionProxyModel(stickerModel);
+                stickerModel->setParent(proxy);
+                return proxy;
         }
         return nullptr;
 }