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 @@
+
\ 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
@@ -319,6 +320,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
@@ -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 @@
icons/ui/search@2x.png
icons/ui/settings.png
icons/ui/settings@2x.png
+ icons/ui/sticky-note-solid.svg
icons/ui/smile.png
icons/ui/smile@2x.png
icons/ui/speech-bubbles-comment-option.png
@@ -150,8 +151,8 @@
qml/ForwardCompleter.qml
qml/TypingIndicator.qml
qml/RoomSettings.qml
- qml/emoji/EmojiButton.qml
qml/emoji/EmojiPicker.qml
+ qml/emoji/StickerPicker.qml
qml/UserProfile.qml
qml/delegates/MessageDelegate.qml
qml/delegates/TextMessage.qml
diff --git a/src/Cache.cpp b/src/Cache.cpp
index 7b6a6135..8c3d8c42 100644
--- a/src/Cache.cpp
+++ b/src/Cache.cpp
@@ -3382,6 +3382,13 @@ Cache::getChildRoomIds(const std::string &room_id)
return roomids;
}
+std::optional
+Cache::getAccountData(mtx::events::EventType type, const std::string &room_id)
+{
+ auto txn = ro_txn(env_);
+ return getAccountData(txn, type, room_id);
+}
+
std::optional
Cache::getAccountData(lmdb::txn &txn, mtx::events::EventType type, const std::string &room_id)
{
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 getAccountData(
+ mtx::events::EventType type,
+ const std::string &room_id = "");
+
//! Get a specific state event
template
std::optional> 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 accountPack;
+ if (accountpackV) {
+ auto tmp =
+ std::get_if>(
+ &*accountpackV);
+ if (tmp)
+ accountPack = tmp->content;
+ }
+ // mtx::events::msc2545::ImagePackRooms *enabledRoomPacks = nullptr;
+ // if (enabledRoomPacksV)
+ // enabledRoomPacks =
+ // std::get_if(&*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
+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
+
+#include
+
+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 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(row) >= images.size())
+ return {};
+ return images.at(static_cast(row)).image;
+ }
+
+private:
+ std::string room_id;
+
+ struct ImageDesc
+ {
+ QString shortcode;
+ QString packname;
+
+ mtx::events::msc2545::PackImage image;
+ };
+
+ std::vector 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"
@@ -501,6 +502,22 @@ InputBar::video(const QString &filename,
room->sendMessageEvent(video, mtx::events::EventType::RoomMessage);
}
+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)
{
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
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(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
void
TimelineModel::sendMessageEvent(const T &content, mtx::events::EventType eventType)
{
- mtx::events::RoomEvent msgCopy = {};
- msgCopy.content = content;
- msgCopy.type = eventType;
- emit newMessageToSend(msgCopy);
+ if constexpr (std::is_same_v) {
+ mtx::events::Sticker msgCopy = {};
+ msgCopy.content = content;
+ msgCopy.type = eventType;
+ emit newMessageToSend(msgCopy);
+ } else {
+ mtx::events::RoomEvent 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();
qRegisterMetaType();
qRegisterMetaType();
+ qRegisterMetaType();
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;
}