Compare commits

...

1 Commits

Author SHA1 Message Date
Tobias Fella
68a917e385 Comletely redo emoticon handling 2024-11-22 14:52:12 +01:00
30 changed files with 243 additions and 5536 deletions

View File

@@ -11,7 +11,6 @@
#include <qnamespace.h>
#include "enums/messagecomponenttype.h"
#include "models/customemojimodel.h"
#include "neochatconnection.h"
#include "testutils.h"
@@ -77,7 +76,6 @@ void TextHandlerTest::initTestCase()
QJsonObject{{"body"_ls, "Test custom emoji"_ls},
{"url"_ls, "mxc://example.org/test"_ls},
{"usage"_ls, QJsonArray{"emoticon"_ls}}}}}}});
CustomEmojiModel::instance().setConnection(static_cast<NeoChatConnection *>(connection));
room = new TestUtils::TestRoom(connection, QStringLiteral("#myroom:kde.org"), QLatin1String("test-texthandler-sync.json"));
}

View File

@@ -10,12 +10,6 @@ endif()
add_library(neochat STATIC
controller.cpp
controller.h
models/emojimodel.cpp
models/emojimodel.h
emojitones.cpp
emojitones.h
models/customemojimodel.cpp
models/customemojimodel.h
clipboard.cpp
clipboard.h
models/messageeventmodel.cpp
@@ -26,8 +20,6 @@ add_library(neochat STATIC
models/roomlistmodel.h
models/sortfilterspacelistmodel.cpp
models/sortfilterspacelistmodel.h
models/accountemoticonmodel.cpp
models/accountemoticonmodel.h
spacehierarchycache.cpp
spacehierarchycache.h
roommanager.cpp
@@ -50,8 +42,6 @@ add_library(neochat STATIC
models/userdirectorylistmodel.h
models/pushrulemodel.cpp
models/pushrulemodel.h
models/emoticonfiltermodel.cpp
models/emoticonfiltermodel.h
notificationsmanager.cpp
notificationsmanager.h
models/sortfilterroomlistmodel.cpp
@@ -101,10 +91,6 @@ add_library(neochat STATIC
texthandler.h
logger.cpp
logger.h
models/stickermodel.cpp
models/stickermodel.h
models/imagepacksmodel.cpp
models/imagepacksmodel.h
events/imagepackevent.cpp
events/imagepackevent.h
events/joinrulesevent.cpp
@@ -282,6 +268,9 @@ ecm_add_qml_module(neochat URI org.kde.neochat GENERATE_PLUGIN_SOURCE
qml/ConfirmLeaveDialog.qml
qml/CodeMaximizeComponent.qml
qml/EditStateDialog.qml
qml/EmojiPickerTypeHeader.qml
qml/EmojiPickerPackHeader.qml
qml/QuickReaction.qml
qml/ConsentDialog.qml
qml/AskDirectChatConfirmation.qml
qml/HoverLinkIndicator.qml

View File

@@ -519,7 +519,6 @@ QQC2.Control {
y: -implicitHeight
modal: false
includeCustom: true
closeOnChosen: false
currentRoom: root.currentRoom

View File

@@ -5,59 +5,30 @@ import QtQuick
import QtQuick.Controls as QQC2
import org.kde.kirigami as Kirigami
QQC2.ItemDelegate {
QQC2.Button {
id: root
property string name
property string emoji
required property string toolTip
property bool showTones: false
property bool isImage: false
QQC2.ToolTip.text: root.name
QQC2.ToolTip.visible: hovered && root.name !== ""
QQC2.ToolTip.text: toolTip
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
leftInset: Kirigami.Units.smallSpacing
topInset: Kirigami.Units.smallSpacing
rightInset: Kirigami.Units.smallSpacing
bottomInset: Kirigami.Units.smallSpacing
contentItem: Item {
Kirigami.Heading {
anchors.fill: parent
visible: !root.emoji.startsWith("mxc") && !root.isImage
text: root.emoji
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
font.family: "emoji"
flat: true
Kirigami.Icon {
width: Kirigami.Units.gridUnit * 0.5
height: Kirigami.Units.gridUnit * 0.5
source: "arrow-down-symbolic"
anchors.bottom: parent.bottom
anchors.right: parent.right
visible: root.showTones
}
}
Image {
anchors.fill: parent
visible: root.emoji.startsWith("mxc") || root.isImage
source: visible ? root.emoji : ""
fillMode: Image.PreserveAspectFit
sourceSize.width: width
sourceSize.height: height
}
}
contentItem: Kirigami.Heading {
text: root.text
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
background: Rectangle {
color: root.checked ? Kirigami.Theme.highlightColor : Kirigami.Theme.backgroundColor
radius: Kirigami.Units.cornerRadius
Rectangle {
radius: Kirigami.Units.cornerRadius
anchors.fill: parent
color: Kirigami.Theme.highlightColor
opacity: root.hovered && !root.pressed ? 0.2 : 0
Kirigami.Icon {
width: Kirigami.Units.gridUnit * 0.5
height: Kirigami.Units.gridUnit * 0.5
source: "arrow-down-symbolic"
anchors.bottom: parent.bottom
anchors.right: parent.right
visible: root.showTones
}
}
}

View File

@@ -16,9 +16,7 @@ QQC2.Popup {
*/
property NeoChatRoom currentRoom
property bool includeCustom: false
property bool closeOnChosen: true
property bool showQuickReaction: false
signal chosen(string emoji)
@@ -64,15 +62,15 @@ QQC2.Popup {
padding: 2
implicitHeight: Kirigami.Units.gridUnit * 20 + 2 * padding
width: Math.min(contentItem.categoryIconSize * 11 + 2 * padding, applicationWindow().width)
width: Math.min(contentItem.implicitWidth + 2 * padding, applicationWindow().width)
contentItem: EmojiPicker {
id: emojiPicker
height: 400
currentRoom: root.currentRoom
includeCustom: root.includeCustom
showQuickReaction: root.showQuickReaction
onChosen: emoji => {
root.chosen(emoji);
ImageContentManager.emojiUsed(emoji)
if (root.closeOnChosen) {
root.close();
}

View File

@@ -1,20 +1,17 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella
// SPDX-FileCopyrightText: 2024 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick
import QtQuick.Controls as QQC2
import org.kde.kirigami as Kirigami
import org.kde.neochat
import org.kde.textaddons.emoticons
QQC2.ScrollView {
id: root
property alias model: emojis.model
property alias count: emojis.count
required property int targetIconSize
readonly property int emojisPerRow: emojis.width / targetIconSize
required property bool withCustom
readonly property var searchCategory: withCustom ? EmojiModel.Search : EmojiModel.SearchNoCustom
readonly property int emojisPerRow: emojis.width / Kirigami.Units.iconSizes.large
required property QtObject header
property bool stickers: false
@@ -25,6 +22,8 @@ QQC2.ScrollView {
emojis.forceActiveFocus();
}
width: Kirigami.Units.gridUnit * 24
GridView {
id: emojis
@@ -41,7 +40,9 @@ QQC2.ScrollView {
onModelChanged: currentIndex = -1
cellWidth: emojis.width / root.emojisPerRow
cellHeight: root.targetIconSize
cellHeight: Kirigami.Units.iconSizes.large
model: EmojiModelManager.emojiModel
KeyNavigation.up: root.header
@@ -49,50 +50,49 @@ QQC2.ScrollView {
delegate: EmojiDelegate {
id: emojiDelegate
checked: emojis.currentIndex === model.index
emoji: !!modelData ? modelData.unicode : model.url
name: !!modelData ? modelData.shortName : model.body
required property string unicode
required property string identifier
required property int index
text: emojiDelegate.unicode
toolTip: emojiDelegate.identifier
checked: emojis.currentIndex === emojiDelegate.index
width: emojis.cellWidth
height: emojis.cellHeight
isImage: root.stickers
Keys.onEnterPressed: clicked()
Keys.onReturnPressed: clicked()
onClicked: {
if (root.stickers) {
root.stickerChosen(model.index);
}
root.chosen(modelData.isCustom ? modelData.shortName : modelData.unicode);
EmojiModel.emojiUsed(modelData);
}
Keys.onSpacePressed: pressAndHold()
onPressAndHold: {
if (EmojiModel.tones(modelData.shortName).length === 0) {
return;
}
let tones = tonesPopupComponent.createObject(emojiDelegate, {
shortName: modelData.shortName,
unicode: modelData.unicode,
categoryIconSize: root.targetIconSize
});
tones.open();
tones.forceActiveFocus();
}
showTones: !!modelData && EmojiModel.tones(modelData.shortName).length > 0
// onClicked: {
// if (root.stickers) {
// root.stickerChosen(model.index);
// }
// root.chosen(modelData.isCustom ? modelData.shortName : modelData.unicode);
// EmojiModel.emojiUsed(modelData);
// }
// Keys.onSpacePressed: pressAndHold()
// onPressAndHold: {
// if (!showTones) {
// return;
// }
// let tones = Qt.createComponent("org.kde.neochat", "EmojiTonesPicker").createObject(emojiDelegate, {
// shortName: modelData.shortName,
// unicode: modelData.unicode,
// categoryIconSize: root.targetIconSize,
// onChosen: root.chosen(emoji => root.chosen(emoji))
// });
// tones.open();
// tones.forceActiveFocus();
// }
// showTones: model.hasTones
}
Kirigami.PlaceholderMessage {
anchors.centerIn: parent
icon.name: root.stickers ? "stickers" : "preferences-desktop-emoticons"
text: root.stickers ? i18n("No stickers") : i18n("No emojis")
text: root.stickers ? i18nc("@info", "No stickers") : i18nc("@info", "No emojis")
visible: emojis.count === 0
}
}
Component {
id: tonesPopupComponent
EmojiTonesPicker {
onChosen: root.chosen(emoji)
}
}
}

View File

@@ -1,4 +1,4 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <tobias.fella@kde.org>
// SPDX-FileCopyrightText: 2022-2023 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick
@@ -6,6 +6,7 @@ import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.neochat
import org.kde.textaddons.emoticons
ColumnLayout {
id: root
@@ -13,87 +14,29 @@ ColumnLayout {
/**
* @brief The current room that user is viewing.
*/
property NeoChatRoom currentRoom
property bool includeCustom: false
property bool showQuickReaction: false
readonly property var currentEmojiModel: {
if (includeCustom) {
EmojiModel.categoriesWithCustom;
} else {
EmojiModel.categories;
}
}
readonly property int categoryIconSize: Math.round(Kirigami.Units.gridUnit * 2.5)
readonly property var currentCategory: currentEmojiModel[categories.currentIndex].category
readonly property alias categoryCount: categories.count
property int selectedType: 0
required property NeoChatRoom currentRoom
signal chosen(string emoji)
spacing: 0
onActiveFocusChanged: if (activeFocus) {
searchField.forceActiveFocus();
}
spacing: 0
EmojiPickerTypeHeader {
id: emoticonPickerTypeHeader
Kirigami.NavigationTabBar {
id: types
Layout.fillWidth: true
Kirigami.Theme.colorSet: Kirigami.Theme.View
background: null
actions: [
Kirigami.Action {
id: emojis
icon.name: "smiley"
text: i18n("Emojis")
checked: true
onTriggered: root.selectedType = 0
},
Kirigami.Action {
id: stickers
icon.name: "stickers"
text: i18n("Stickers")
onTriggered: root.selectedType = 1
}
]
onSelectedTypeChanged: emoticonPickerCategoryHeader.currentIndex = 0
}
QQC2.ScrollView {
EmojiPickerPackHeader {
id: emoticonPickerCategoryHeader
Layout.fillWidth: true
Layout.preferredHeight: root.categoryIconSize + QQC2.ScrollBar.horizontal.height
QQC2.ScrollBar.horizontal.height: QQC2.ScrollBar.horizontal.visible ? QQC2.ScrollBar.horizontal.implicitHeight : 0
visible: categories.count !== 0
ListView {
id: categories
clip: true
focus: true
orientation: ListView.Horizontal
Keys.onReturnPressed: if (emojiGrid.count > 0) {
emojiGrid.focus = true;
}
Keys.onEnterPressed: if (emojiGrid.count > 0) {
emojiGrid.focus = true;
}
KeyNavigation.down: emojiGrid.count > 0 ? emojiGrid : categories
KeyNavigation.tab: emojiGrid.count > 0 ? emojiGrid : categories
keyNavigationEnabled: true
keyNavigationWraps: true
Keys.forwardTo: searchField
interactive: width !== contentWidth
model: root.selectedType === 0 ? root.currentEmojiModel : stickerPackModel
Component.onCompleted: categories.forceActiveFocus()
delegate: root.selectedType === 0 ? emojiDelegate : stickerDelegate
}
model: UnicodeEmoticonManager.categories
}
Kirigami.Separator {
@@ -105,119 +48,34 @@ ColumnLayout {
id: searchField
Layout.margins: Kirigami.Units.smallSpacing
Layout.fillWidth: true
visible: selectedType === 0
/**
* The focus is manged by the parent and we don't want to use the standard
* shortcut as it could block other SearchFields from using it.
*/
focusSequence: ""
}
EmojiGrid {
id: emojiGrid
targetIconSize: root.currentCategory === EmojiModel.Custom ? Kirigami.Units.gridUnit * 3 : root.categoryIconSize // Custom emojis are bigger
model: root.selectedType === 1 ? emoticonFilterModel : searchField.text.length === 0 ? EmojiModel.emojis(root.currentCategory) : (root.includeCustom ? EmojiModel.filterModel(searchField.text, false) : EmojiModel.filterModelNoCustom(searchField.text, false))
Layout.fillWidth: true
Layout.fillHeight: true
withCustom: root.includeCustom
onChosen: unicode => root.chosen(unicode)
header: categories
header: emoticonPickerCategoryHeader
Keys.forwardTo: searchField
stickers: root.selectedType === 1
stickers: emoticonPickerTypeHeader.selectedType === EmojiPickerTypeHeader.EmoticonType.Sticker
onStickerChosen: stickerModel.postSticker(emoticonFilterModel.mapToSource(emoticonFilterModel.index(index, 0)).row)
}
Kirigami.Separator {
visible: showQuickReaction
Layout.fillWidth: true
Layout.preferredHeight: 1
}
QQC2.ScrollView {
visible: showQuickReaction
QuickReaction {
id: quickReaction
onChosen: root.chosen(text)
Layout.fillWidth: true
Layout.preferredHeight: root.categoryIconSize + QQC2.ScrollBar.horizontal.height
QQC2.ScrollBar.horizontal.height: QQC2.ScrollBar.horizontal.visible ? QQC2.ScrollBar.horizontal.implicitHeight : 0
ListView {
id: quickReactions
Layout.fillWidth: true
model: ["👍", "👎", "😄", "🎉", "😕", "❤", "🚀", "👀"]
delegate: EmojiDelegate {
emoji: modelData
height: root.categoryIconSize
width: height
onClicked: root.chosen(modelData)
}
orientation: Qt.Horizontal
}
}
ImagePacksModel {
id: stickerPackModel
room: root.currentRoom
showStickers: true
showEmoticons: false
}
StickerModel {
id: stickerModel
model: stickerPackModel
packIndex: 0
room: root.currentRoom
}
EmoticonFilterModel {
id: emoticonFilterModel
sourceModel: stickerModel
showStickers: true
}
Component {
id: emojiDelegate
Kirigami.NavigationTabButton {
width: root.categoryIconSize
height: width
checked: categories.currentIndex === model.index
text: modelData ? modelData.emoji : ""
QQC2.ToolTip.text: modelData ? modelData.name : ""
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
QQC2.ToolTip.visible: hovered
onClicked: {
categories.currentIndex = index;
categories.focus = true;
}
}
}
Component {
id: stickerDelegate
Kirigami.NavigationTabButton {
width: root.categoryIconSize
height: width
checked: stickerModel.packIndex === model.index
padding: Kirigami.Units.largeSpacing
contentItem: Image {
source: model.avatarUrl
fillMode: Image.PreserveAspectFit
sourceSize.width: width
sourceSize.height: height
}
QQC2.ToolTip.text: model.name
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
QQC2.ToolTip.visible: hovered && !!model.name
onClicked: stickerModel.packIndex = model.index
}
}
function clearSearchField() {
searchField.text = "";
searchField.text = ""
}
}

View File

@@ -1,4 +1,4 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <tobias.fella@kde.org>
// SPDX-FileCopyrightText: 2024 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick
@@ -54,8 +54,8 @@ QQC2.Popup {
delegate: EmojiDelegate {
id: emojiDelegate
checked: tonesList.currentIndex === model.index
emoji: modelData.unicode
name: modelData.shortName
text: modelData.unicode
toolTip: modelData.shortName
width: root.categoryIconSize
height: width

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +0,0 @@
// SPDX-FileCopyrightText: None
// SPDX-License-Identifier: LGPL-2.0-or-later
#include "emojitones.h"
#include "models/emojimodel.h"
QMultiHash<QString, QVariant> EmojiTones::_tones = {
#include "emojitones_data.h"
};

View File

@@ -1,21 +0,0 @@
// SPDX-FileCopyrightText: None
// SPDX-License-Identifier: LGPL-2.0-or-later
#pragma once
#include <QVariant>
/**
* @class EmojiTones
*
* This class provides a _tones variable with the available emoji tones to EmojiModel.
*
* @sa EmojiModel
*/
class EmojiTones
{
private:
static QMultiHash<QString, QVariant> _tones;
friend class EmojiModel;
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,222 +0,0 @@
// SPDX-FileCopyrightText: 2021-2023 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: LGPL-2.0-or-later
#include "accountemoticonmodel.h"
#include <QImage>
#include <QMimeDatabase>
#include <Quotient/csapi/content-repo.h>
#include <Quotient/events/eventcontent.h>
#include <qcoro/qcorosignal.h>
#include "neochatconnection.h"
using namespace Quotient;
AccountEmoticonModel::AccountEmoticonModel(QObject *parent)
: QAbstractListModel(parent)
{
}
int AccountEmoticonModel::rowCount(const QModelIndex &index) const
{
Q_UNUSED(index);
if (!m_images) {
return 0;
}
return m_images->images.size();
}
QVariant AccountEmoticonModel::data(const QModelIndex &index, int role) const
{
if (m_connection == nullptr) {
return {};
}
const auto &row = index.row();
const auto &image = m_images->images[row];
if (role == UrlRole) {
return m_connection->makeMediaUrl(image.url).toString();
}
if (role == BodyRole) {
if (image.body) {
return *image.body;
}
}
if (role == ShortCodeRole) {
return image.shortcode;
}
if (role == IsStickerRole) {
if (image.usage) {
return image.usage->isEmpty() || image.usage->contains("sticker"_ls);
}
if (m_images->pack && m_images->pack->usage) {
return m_images->pack->usage->isEmpty() || m_images->pack->usage->contains("sticker"_ls);
}
return true;
}
if (role == IsEmojiRole) {
if (image.usage) {
return image.usage->isEmpty() || image.usage->contains("emoticon"_ls);
}
if (m_images->pack && m_images->pack->usage) {
return m_images->pack->usage->isEmpty() || m_images->pack->usage->contains("emoticon"_ls);
}
return true;
}
return {};
}
QHash<int, QByteArray> AccountEmoticonModel::roleNames() const
{
return {
{AccountEmoticonModel::UrlRole, "url"},
{AccountEmoticonModel::BodyRole, "body"},
{AccountEmoticonModel::ShortCodeRole, "shortcode"},
{AccountEmoticonModel::IsStickerRole, "isSticker"},
{AccountEmoticonModel::IsEmojiRole, "isEmoji"},
};
}
NeoChatConnection *AccountEmoticonModel::connection() const
{
return m_connection;
}
void AccountEmoticonModel::setConnection(NeoChatConnection *connection)
{
if (m_connection) {
disconnect(m_connection, nullptr, this, nullptr);
}
m_connection = connection;
Q_EMIT connectionChanged();
connect(m_connection, &Connection::accountDataChanged, this, [this](QString type) {
if (type == QStringLiteral("im.ponies.user_emotes")) {
reloadEmoticons();
}
});
reloadEmoticons();
}
void AccountEmoticonModel::reloadEmoticons()
{
if (m_connection == nullptr) {
return;
}
QJsonObject json;
if (m_connection->hasAccountData("im.ponies.user_emotes"_ls)) {
json = m_connection->accountData("im.ponies.user_emotes"_ls)->contentJson();
}
const auto &content = ImagePackEventContent(json);
beginResetModel();
m_images = content;
endResetModel();
}
void AccountEmoticonModel::deleteEmoticon(int index)
{
if (m_connection == nullptr) {
return;
}
QJsonObject data;
m_images->images.removeAt(index);
m_images->fillJson(&data);
m_connection->setAccountData("im.ponies.user_emotes"_ls, data);
}
void AccountEmoticonModel::setEmoticonBody(int index, const QString &text)
{
if (m_connection == nullptr) {
return;
}
m_images->images[index].body = text;
QJsonObject data;
m_images->fillJson(&data);
m_connection->setAccountData("im.ponies.user_emotes"_ls, data);
}
void AccountEmoticonModel::setEmoticonShortcode(int index, const QString &shortcode)
{
if (m_connection == nullptr) {
return;
}
m_images->images[index].shortcode = shortcode;
QJsonObject data;
m_images->fillJson(&data);
m_connection->setAccountData("im.ponies.user_emotes"_ls, data);
}
void AccountEmoticonModel::setEmoticonImage(int index, const QUrl &source)
{
if (m_connection == nullptr) {
return;
}
doSetEmoticonImage(index, source);
}
QCoro::Task<void> AccountEmoticonModel::doSetEmoticonImage(int index, QUrl source)
{
auto job = m_connection->uploadFile(source.isLocalFile() ? source.toLocalFile() : source.toString());
co_await qCoro(job.get(), &BaseJob::finished);
if (job->error() != BaseJob::NoError) {
co_return;
}
m_images->images[index].url = job->contentUri();
auto mime = QMimeDatabase().mimeTypeForUrl(source);
source.setScheme("file"_ls);
QFileInfo fileInfo(source.isLocalFile() ? source.toLocalFile() : source.toString());
EventContent::ImageInfo info;
if (mime.name().startsWith("image/"_ls)) {
QImage image(source.toLocalFile());
info = EventContent::ImageInfo(source, fileInfo.size(), mime, image.size(), fileInfo.fileName());
}
m_images->images[index].info = info;
QJsonObject data;
m_images->fillJson(&data);
m_connection->setAccountData("im.ponies.user_emotes"_ls, data);
}
QCoro::Task<void> AccountEmoticonModel::doAddEmoticon(QUrl source, QString shortcode, QString description, QString type)
{
auto job = m_connection->uploadFile(source.isLocalFile() ? source.toLocalFile() : source.toString());
co_await qCoro(job.get(), &BaseJob::finished);
if (job->error() != BaseJob::NoError) {
co_return;
}
auto mime = QMimeDatabase().mimeTypeForUrl(source);
source.setScheme("file"_ls);
QFileInfo fileInfo(source.isLocalFile() ? source.toLocalFile() : source.toString());
EventContent::ImageInfo info;
if (mime.name().startsWith("image/"_ls)) {
QImage image(source.toLocalFile());
info = EventContent::ImageInfo(source, fileInfo.size(), mime, image.size(), fileInfo.fileName());
}
m_images->images.append(ImagePackEventContent::ImagePackImage{
shortcode,
job->contentUri(),
description,
info,
QStringList{type},
});
QJsonObject data;
m_images->fillJson(&data);
m_connection->setAccountData("im.ponies.user_emotes"_ls, data);
}
void AccountEmoticonModel::addEmoticon(const QUrl &source, const QString &shortcode, const QString &description, const QString &type)
{
if (m_connection == nullptr) {
return;
}
doAddEmoticon(source, shortcode, description, type);
}
#include "moc_accountemoticonmodel.cpp"

View File

@@ -1,104 +0,0 @@
// SPDX-FileCopyrightText: 2023 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: LGPL-2.0-or-later
#pragma once
#include "events/imagepackevent.h"
#include <QAbstractListModel>
#include <QCoroTask>
#include <QList>
#include <QObject>
#include <QPointer>
#include <QQmlEngine>
class NeoChatConnection;
/**
* @class AccountEmoticonModel
*
* This class defines the model for visualising the account stickers and emojis.
*
* This is based upon the im.ponies.user_emotes spec (MSC2545).
*/
class AccountEmoticonModel : public QAbstractListModel
{
Q_OBJECT
QML_ELEMENT
/**
* @brief The connection to get emoticons from.
*/
Q_PROPERTY(NeoChatConnection *connection READ connection WRITE setConnection NOTIFY connectionChanged)
public:
enum Roles {
UrlRole = Qt::UserRole + 1, /**< The URL for the emoticon. */
ShortCodeRole, /**< The shortcode for the emoticon. */
BodyRole, //**< A textual description of the emoticon */
IsStickerRole, //**< Whether this emoticon is a sticker */
IsEmojiRole, //**< Whether this emoticon is an emoji */
};
explicit AccountEmoticonModel(QObject *parent = nullptr);
/**
* @brief Number of rows in the model.
*
* @sa QAbstractItemModel::rowCount
*/
[[nodiscard]] int rowCount(const QModelIndex &index) const override;
/**
* @brief Get the given role value at the given index.
*
* @sa QAbstractItemModel::data
*/
[[nodiscard]] QVariant data(const QModelIndex &index, int role) const override;
/**
* @brief Returns a mapping from Role enum values to role names.
*
* @sa Roles, QAbstractItemModel::roleNames()
*/
[[nodiscard]] QHash<int, QByteArray> roleNames() const override;
[[nodiscard]] NeoChatConnection *connection() const;
void setConnection(NeoChatConnection *connection);
/**
* @brief Deletes the emoticon at the given index.
*/
Q_INVOKABLE void deleteEmoticon(int index);
/**
* @brief Changes the description for the emoticon at the given index.
*/
Q_INVOKABLE void setEmoticonBody(int index, const QString &text);
/**
* @brief Changes the shortcode for the emoticon at the given index.
*/
Q_INVOKABLE void setEmoticonShortcode(int index, const QString &shortCode);
/**
* @brief Changes the image for the emoticon at the given index.
*/
Q_INVOKABLE void setEmoticonImage(int index, const QUrl &source);
/**
* @brief Add an emoticon with the given parameters.
*/
Q_INVOKABLE void addEmoticon(const QUrl &source, const QString &shortcode, const QString &description, const QString &type);
Q_SIGNALS:
void connectionChanged();
private:
std::optional<Quotient::ImagePackEventContent> m_images;
QPointer<NeoChatConnection> m_connection;
QCoro::Task<void> doSetEmoticonImage(int index, QUrl source);
QCoro::Task<void> doAddEmoticon(QUrl source, QString shortcode, QString description, QString type);
void reloadEmoticons();
};

View File

@@ -6,8 +6,7 @@
#include "actionsmodel.h"
#include "completionproxymodel.h"
#include "customemojimodel.h"
#include "emojimodel.h"
// #include "emojimodel.h"
#include "neochatroom.h"
#include "roommanager.h"
#include "userlistmodel.h"
@@ -16,11 +15,13 @@ CompletionModel::CompletionModel(QObject *parent)
: QAbstractListModel(parent)
, m_filterModel(new CompletionProxyModel())
, m_userListModel(RoomManager::instance().userListModel())
, m_emojiModel(new QConcatenateTablesProxyModel(this))
//, m_emojiModel(new QConcatenateTablesProxyModel(this))
{
connect(this, &CompletionModel::textChanged, this, &CompletionModel::updateCompletion);
m_emojiModel->addSourceModel(&CustomEmojiModel::instance());
m_emojiModel->addSourceModel(&EmojiModel::instance());
connect(this, &CompletionModel::roomChanged, this, [this]() {
m_userListModel->setRoom(m_room);
});
// TODO m_emojiModel->addSourceModel(&EmojiModel::instance());
}
QString CompletionModel::text() const
@@ -88,20 +89,20 @@ QVariant CompletionModel::data(const QModelIndex &index, int role) const
return m_filterModel->data(filterIndex, RoomListModel::AvatarRole).toString();
}
}
if (m_autoCompletionType == Emoji) {
if (role == DisplayNameRole) {
return m_filterModel->data(filterIndex, CustomEmojiModel::DisplayRole);
}
if (role == IconNameRole) {
return m_filterModel->data(filterIndex, CustomEmojiModel::MxcUrl);
}
if (role == ReplacedTextRole) {
return m_filterModel->data(filterIndex, CustomEmojiModel::ReplacedTextRole);
}
if (role == SubtitleRole) {
return m_filterModel->data(filterIndex, EmojiModel::DescriptionRole);
}
}
// if (m_autoCompletionType == Emoji) {
// if (role == DisplayNameRole) {
// return m_filterModel->data(filterIndex, CustomEmojiModel::DisplayRole);
// }
// if (role == IconNameRole) {
// return m_filterModel->data(filterIndex, CustomEmojiModel::MxcUrl);
// }
// if (role == ReplacedTextRole) {
// return m_filterModel->data(filterIndex, CustomEmojiModel::ReplacedTextRole);
// }
// if (role == SubtitleRole) {
// // TODO return m_filterModel->data(filterIndex, EmojiModel::DescriptionRole);
// }
// }
return {};
}
@@ -147,8 +148,8 @@ void CompletionModel::updateCompletion()
|| (m_fullText.indexOf(QLatin1Char(' ')) != -1 && m_fullText.indexOf(QLatin1Char(':'), 1) > m_fullText.indexOf(QLatin1Char(' '), 1)))) {
m_filterModel->setSourceModel(m_emojiModel);
m_autoCompletionType = Emoji;
m_filterModel->setFilterRole(CustomEmojiModel::Name);
m_filterModel->setSecondaryFilterRole(EmojiModel::DescriptionRole);
// m_filterModel->setFilterRole(CustomEmojiModel::Name);
// TODO m_filterModel->setSecondaryFilterRole(EmojiModel::DescriptionRole);
m_filterModel->setFullText(m_fullText);
m_filterModel->setFilterText(m_text);
m_filterModel->invalidate();

View File

@@ -1,213 +0,0 @@
// SPDX-FileCopyrightText: 2021 Carson Black <uhhadd@gmail.com>
// SPDX-License-Identifier: GPL-2.0-or-later
#include "customemojimodel.h"
#include <QImage>
#include <QMimeDatabase>
#include "emojimodel.h"
#include <Quotient/csapi/account-data.h>
#include <Quotient/csapi/content-repo.h>
using namespace Quotient;
void CustomEmojiModel::setConnection(NeoChatConnection *connection)
{
if (connection == m_connection) {
return;
}
m_connection = connection;
Q_EMIT connectionChanged();
fetchEmojis();
}
NeoChatConnection *CustomEmojiModel::connection() const
{
return m_connection;
}
void CustomEmojiModel::fetchEmojis()
{
if (!m_connection) {
return;
}
const auto &data = m_connection->accountData("im.ponies.user_emotes"_ls);
if (data == nullptr) {
return;
}
QJsonObject emojis = data->contentJson()["images"_ls].toObject();
// TODO: Remove with stable migration
const auto legacyEmojis = data->contentJson()["emoticons"_ls].toObject();
for (const auto &emoji : legacyEmojis.keys()) {
if (!emojis.contains(emoji)) {
emojis[emoji] = legacyEmojis[emoji];
}
}
beginResetModel();
m_emojis.clear();
for (const auto &emoji : emojis.keys()) {
const auto &data = emojis[emoji];
const auto e = emoji.startsWith(":"_ls) ? emoji : (QStringLiteral(":") + emoji + QStringLiteral(":"));
m_emojis << CustomEmoji{e, data.toObject()["url"_ls].toString(), QRegularExpression(e)};
}
endResetModel();
}
void CustomEmojiModel::addEmoji(const QString &name, const QUrl &location)
{
using namespace Quotient;
auto job = m_connection->uploadFile(location.toLocalFile());
connect(job, &BaseJob::success, this, [name, location, job, this] {
const auto &data = m_connection->accountData("im.ponies.user_emotes"_ls);
auto json = data != nullptr ? data->contentJson() : QJsonObject();
auto emojiData = json["images"_ls].toObject();
QString url;
url = job->contentUri().toString();
QImage image(location.toLocalFile());
QJsonObject imageInfo;
imageInfo["w"_ls] = image.width();
imageInfo["h"_ls] = image.height();
imageInfo["mimetype"_ls] = QMimeDatabase().mimeTypeForFile(location.toLocalFile()).name();
imageInfo["size"_ls] = image.sizeInBytes();
emojiData[QStringLiteral("%1").arg(name)] = QJsonObject({
{QStringLiteral("url"), url},
{QStringLiteral("info"), imageInfo},
{QStringLiteral("body"), location.fileName()},
{"usage"_ls, "emoticon"_ls},
});
json["images"_ls] = emojiData;
m_connection->setAccountData("im.ponies.user_emotes"_ls, json);
});
}
void CustomEmojiModel::removeEmoji(const QString &name)
{
using namespace Quotient;
const auto &data = m_connection->accountData("im.ponies.user_emotes"_ls);
Q_ASSERT(data);
auto json = data->contentJson();
const QString _name = name.mid(1).chopped(1);
auto emojiData = json["images"_ls].toObject();
if (emojiData.contains(name)) {
emojiData.remove(name);
json["images"_ls] = emojiData;
}
if (emojiData.contains(_name)) {
emojiData.remove(_name);
json["images"_ls] = emojiData;
}
emojiData = json["emoticons"_ls].toObject();
if (emojiData.contains(name)) {
emojiData.remove(name);
json["emoticons"_ls] = emojiData;
}
if (emojiData.contains(_name)) {
emojiData.remove(_name);
json["emoticons"_ls] = emojiData;
}
m_connection->setAccountData("im.ponies.user_emotes"_ls, json);
}
CustomEmojiModel::CustomEmojiModel(QObject *parent)
: QAbstractListModel(parent)
{
connect(this, &CustomEmojiModel::connectionChanged, this, [this]() {
if (!m_connection) {
return;
}
CustomEmojiModel::fetchEmojis();
connect(m_connection, &Connection::accountDataChanged, this, [this](const QString &id) {
if (id != QStringLiteral("im.ponies.user_emotes")) {
return;
}
fetchEmojis();
});
});
CustomEmojiModel::fetchEmojis();
}
QVariant CustomEmojiModel::data(const QModelIndex &idx, int role) const
{
const auto row = idx.row();
if (row >= m_emojis.length()) {
return QVariant();
}
const auto &data = m_emojis[row];
switch (Roles(role)) {
case Roles::ModelData:
return QVariant::fromValue(Emoji(m_connection->makeMediaUrl(QUrl(data.url)).toString(), data.name, true));
case Roles::Name:
case Roles::DisplayRole:
case Roles::ReplacedTextRole:
return data.name;
case Roles::ImageURL:
return m_connection->makeMediaUrl(QUrl(data.url));
case Roles::MxcUrl:
return m_connection->makeMediaUrl(QUrl(data.url));
default:
return {};
}
return QVariant();
}
int CustomEmojiModel::rowCount(const QModelIndex &parent) const
{
Q_UNUSED(parent)
return m_emojis.length();
}
QHash<int, QByteArray> CustomEmojiModel::roleNames() const
{
return {
{Name, "name"},
{ImageURL, "imageURL"},
{ModelData, "modelData"},
{MxcUrl, "mxcUrl"},
};
}
QString CustomEmojiModel::preprocessText(QString text)
{
for (const auto &emoji : std::as_const(m_emojis)) {
text.replace(
emoji.regexp,
QStringLiteral(R"(<img data-mx-emoticon="" src="%1" alt="%2" title="%2" height="32" vertical-align="middle" />)").arg(emoji.url, emoji.name));
}
return text;
}
QVariantList CustomEmojiModel::filterModel(const QString &filter)
{
QVariantList results;
for (const auto &emoji : std::as_const(m_emojis)) {
if (results.length() >= 10)
break;
if (!emoji.name.contains(filter, Qt::CaseInsensitive))
continue;
results << QVariant::fromValue(Emoji(m_connection->makeMediaUrl(QUrl(emoji.url)).toString(), emoji.name, true));
}
return results;
}
#include "moc_customemojimodel.cpp"

View File

@@ -1,116 +0,0 @@
// SPDX-FileCopyrightText: 2021 Carson Black <uhhadd@gmail.com>
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <QAbstractListModel>
#include <QQmlEngine>
#include <QRegularExpression>
#include "neochatconnection.h"
struct CustomEmoji {
QString name; // with :semicolons:
QString url; // mxc://
QRegularExpression regexp;
Q_GADGET
Q_PROPERTY(QString unicode MEMBER url)
Q_PROPERTY(QString name MEMBER name)
};
/**
* @class CustomEmojiModel
*
* This class defines the model for custom user emojis.
*
* This is based upon the im.ponies.user_emotes spec (MSC2545).
*/
class CustomEmojiModel : public QAbstractListModel
{
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
Q_PROPERTY(NeoChatConnection *connection READ connection WRITE setConnection NOTIFY connectionChanged)
public:
/**
* @brief Defines the model roles.
*/
enum Roles {
Name = Qt::DisplayRole, /**< The name of the emoji. */
ImageURL, /**< The URL for the custom emoji. */
ModelData, /**< for emulating the regular emoji model's usage, otherwise the UI code would get too complicated. */
MxcUrl = 50, /**< The mxc source URL for the custom emoji. */
DisplayRole = 51, /**< The name of the emoji. For compatibility with EmojiModel. */
ReplacedTextRole = 52, /**< The name of the emoji. For compatibility with EmojiModel. */
DescriptionRole = 53, /**< Invalid, reserved. For compatibility with EmojiModel. */
};
Q_ENUM(Roles)
static CustomEmojiModel &instance()
{
static CustomEmojiModel _instance;
return _instance;
}
static CustomEmojiModel *create(QQmlEngine *engine, QJSEngine *)
{
engine->setObjectOwnership(&instance(), QQmlEngine::CppOwnership);
return &instance();
}
/**
* @brief Get the given role value at the given index.
*
* @sa QAbstractItemModel::data
*/
QVariant data(const QModelIndex &idx, int role = Qt::DisplayRole) const override;
/**
* @brief Number of rows in the model.
*
* @sa QAbstractItemModel::rowCount
*/
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
/**
* @brief Returns a mapping from Role enum values to role names.
*
* @sa Roles, QAbstractItemModel::roleNames()
*/
QHash<int, QByteArray> roleNames() const override;
/**
* @brief Substitute any custom emojis for an image in the input text.
*/
Q_INVOKABLE QString preprocessText(QString text);
/**
* @brief Return a list of custom emojis where the name contains the filter text.
*/
Q_INVOKABLE QVariantList filterModel(const QString &filter);
/**
* @brief Add a new emoji to the model.
*/
Q_INVOKABLE void addEmoji(const QString &name, const QUrl &location);
/**
* @brief Remove an emoji from the model.
*/
Q_INVOKABLE void removeEmoji(const QString &name);
void setConnection(NeoChatConnection *connection);
NeoChatConnection *connection() const;
Q_SIGNALS:
void connectionChanged();
private:
explicit CustomEmojiModel(QObject *parent = nullptr);
QList<CustomEmoji> m_emojis;
QPointer<NeoChatConnection> m_connection;
void fetchEmojis();
};

View File

@@ -1,242 +0,0 @@
// SPDX-FileCopyrightText: 2017 Konstantinos Sideris <siderisk@auth.gr>
// SPDX-License-Identifier: GPL-3.0-or-later
#include <QVariant>
#include "emojimodel.h"
#include "emojitones.h"
#include <QDebug>
#include <algorithm>
#include "customemojimodel.h"
#include <KLocalizedString>
EmojiModel::EmojiModel(QObject *parent)
: QAbstractListModel(parent)
, m_config(KSharedConfig::openStateConfig())
, m_configGroup(KConfigGroup(m_config, QStringLiteral("Editor")))
{
if (_emojis.isEmpty()) {
#include "emojis.h"
}
}
int EmojiModel::rowCount(const QModelIndex &parent) const
{
Q_UNUSED(parent);
int total = 0;
for (const auto &category : std::as_const(_emojis)) {
total += category.count();
}
return total;
}
QVariant EmojiModel::data(const QModelIndex &index, int role) const
{
auto row = index.row();
for (const auto &category : std::as_const(_emojis)) {
if (row >= category.count()) {
row -= category.count();
continue;
}
auto emoji = category[row].value<Emoji>();
switch (role) {
case ShortNameRole:
return QStringLiteral(":%1:").arg(emoji.shortName);
case UnicodeRole:
case ReplacedTextRole:
return emoji.unicode;
case InvalidRole:
return QStringLiteral("invalid");
case DisplayRole:
return QStringLiteral("%2 :%1:").arg(emoji.shortName, emoji.unicode);
case DescriptionRole:
return emoji.description;
}
}
return {};
}
QHash<int, QByteArray> EmojiModel::roleNames() const
{
return {{ShortNameRole, "shortName"}, {UnicodeRole, "unicode"}};
}
QStringList EmojiModel::lastUsedEmojis() const
{
return m_configGroup.readEntry(QStringLiteral("lastUsedEmojis"), QStringList());
}
QVariantList EmojiModel::filterModel(const QString &filter, bool limit)
{
auto emojis = CustomEmojiModel::instance().filterModel(filter);
emojis += filterModelNoCustom(filter, limit);
return emojis;
}
QVariantList EmojiModel::filterModelNoCustom(const QString &filter, bool limit)
{
QVariantList result;
const auto &values = _emojis.values();
for (const auto &e : values) {
for (const auto &variant : e) {
const auto &emoji = qvariant_cast<Emoji>(variant);
if (emoji.shortName.contains(filter, Qt::CaseInsensitive)) {
result.append(variant);
if (result.length() > 10 && limit) {
return result;
}
}
}
}
return result;
}
void EmojiModel::emojiUsed(const QVariant &modelData)
{
auto list = lastUsedEmojis();
const auto emoji = modelData.value<Emoji>();
auto it = list.begin();
while (it != list.end()) {
if (*it == emoji.shortName) {
it = list.erase(it);
} else {
it++;
}
}
list.push_front(emoji.shortName);
m_configGroup.writeEntry(QStringLiteral("lastUsedEmojis"), list);
Q_EMIT historyChanged();
}
QVariantList EmojiModel::emojis(Category category) const
{
if (category == History) {
return emojiHistory();
}
if (category == HistoryNoCustom) {
QVariantList list;
const auto &history = emojiHistory();
for (const auto &e : history) {
auto emoji = qvariant_cast<Emoji>(e);
if (!emoji.isCustom) {
list.append(e);
}
}
return list;
}
if (category == Custom) {
return CustomEmojiModel::instance().filterModel({});
}
return _emojis[category];
}
QVariantList EmojiModel::tones(const QString &baseEmoji) const
{
if (baseEmoji.endsWith(QStringLiteral("tone"))) {
return EmojiTones::_tones.values(baseEmoji.split(QStringLiteral(":"))[0]);
}
return EmojiTones::_tones.values(baseEmoji);
}
QHash<EmojiModel::Category, QVariantList> EmojiModel::_emojis;
QVariantList EmojiModel::categories() const
{
return QVariantList{
{QVariantMap{
{QStringLiteral("category"), EmojiModel::HistoryNoCustom},
{QStringLiteral("name"), i18nc("Previously used emojis", "History")},
{QStringLiteral("emoji"), QStringLiteral("⌛️")},
}},
{QVariantMap{
{QStringLiteral("category"), EmojiModel::Smileys},
{QStringLiteral("name"), i18nc("'Smileys' is a category of emoji", "Smileys")},
{QStringLiteral("emoji"), QStringLiteral("😏")},
}},
{QVariantMap{
{QStringLiteral("category"), EmojiModel::People},
{QStringLiteral("name"), i18nc("'People' is a category of emoji", "People")},
{QStringLiteral("emoji"), QStringLiteral("🙋‍♂️")},
}},
{QVariantMap{
{QStringLiteral("category"), EmojiModel::Nature},
{QStringLiteral("name"), i18nc("'Nature' is a category of emoji", "Nature")},
{QStringLiteral("emoji"), QStringLiteral("🌲")},
}},
{QVariantMap{
{QStringLiteral("category"), EmojiModel::Food},
{QStringLiteral("name"), i18nc("'Food' is a category of emoji", "Food")},
{QStringLiteral("emoji"), QStringLiteral("🍛")},
}},
{QVariantMap{
{QStringLiteral("category"), EmojiModel::Activities},
{QStringLiteral("name"), i18nc("'Activities' is a category of emoji", "Activities")},
{QStringLiteral("emoji"), QStringLiteral("🚁")},
}},
{QVariantMap{
{QStringLiteral("category"), EmojiModel::Travel},
{QStringLiteral("name"), i18nc("'Travel' is a category of emoji", "Travel")},
{QStringLiteral("emoji"), QStringLiteral("🚅")},
}},
{QVariantMap{
{QStringLiteral("category"), EmojiModel::Objects},
{QStringLiteral("name"), i18nc("'Objects' is a category of emoji", "Objects")},
{QStringLiteral("emoji"), QStringLiteral("💡")},
}},
{QVariantMap{
{QStringLiteral("category"), EmojiModel::Symbols},
{QStringLiteral("name"), i18nc("'Symbols' is a category of emoji", "Symbols")},
{QStringLiteral("emoji"), QStringLiteral("🔣")},
}},
{QVariantMap{
{QStringLiteral("category"), EmojiModel::Flags},
{QStringLiteral("name"), i18nc("'Flags' is a category of emoji", "Flags")},
{QStringLiteral("emoji"), QStringLiteral("🏁")},
}},
};
}
QVariantList EmojiModel::categoriesWithCustom() const
{
auto cats = categories();
cats.removeAt(0);
cats.insert(0,
QVariantMap{
{QStringLiteral("category"), EmojiModel::History},
{QStringLiteral("name"), i18nc("Previously used emojis", "History")},
{QStringLiteral("emoji"), QStringLiteral("⌛️")},
});
cats.insert(1,
QVariantMap{
{QStringLiteral("category"), EmojiModel::Custom},
{QStringLiteral("name"), i18nc("'Custom' is a category of emoji", "Custom")},
{QStringLiteral("emoji"), QStringLiteral("🖼️")},
});
;
return cats;
}
QVariantList EmojiModel::emojiHistory() const
{
QVariantList list;
const auto &lastUsed = lastUsedEmojis();
for (const auto &historicEmoji : lastUsed) {
for (const auto &emojiCategory : std::as_const(_emojis)) {
for (const auto &emoji : emojiCategory) {
if (qvariant_cast<Emoji>(emoji).shortName == historicEmoji) {
list.append(emoji);
}
}
}
}
return list;
}
#include "moc_emojimodel.cpp"

View File

@@ -1,184 +0,0 @@
// SPDX-FileCopyrightText: 2018 Black Hat <bhat@encom.eu.org>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <KConfigGroup>
#include <KSharedConfig>
#include <QAbstractListModel>
#include <QObject>
#include <QQmlEngine>
struct Emoji {
Emoji(QString unicode, QString shortname, bool isCustom = false)
: unicode(std::move(unicode))
, shortName(std::move(shortname))
, isCustom(isCustom)
{
}
Emoji(QString unicode, QString shortname, QString description)
: unicode(std::move(unicode))
, shortName(std::move(shortname))
, description(std::move(description))
{
}
Emoji() = default;
QString unicode;
QString shortName;
QString description;
bool isCustom = false;
Q_GADGET
Q_PROPERTY(QString unicode MEMBER unicode)
Q_PROPERTY(QString shortName MEMBER shortName)
Q_PROPERTY(QString description MEMBER description)
Q_PROPERTY(bool isCustom MEMBER isCustom)
};
Q_DECLARE_METATYPE(Emoji)
/**
* @class EmojiModel
*
* This class defines the model for visualising a list of emojis.
*/
class EmojiModel : public QAbstractListModel
{
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
/**
* @brief Return a list of emoji categories.
*
* @note No custom emoji categories will be included.
*/
Q_PROPERTY(QVariantList categories READ categories CONSTANT)
/**
* @brief Return a list of emoji categories with custom emojis.
*/
Q_PROPERTY(QVariantList categoriesWithCustom READ categoriesWithCustom CONSTANT)
public:
static EmojiModel &instance()
{
static EmojiModel _instance;
return _instance;
}
static EmojiModel *create(QQmlEngine *engine, QJSEngine *)
{
engine->setObjectOwnership(&instance(), QQmlEngine::CppOwnership);
return &instance();
}
/**
* @brief Defines the model roles.
*/
enum RoleNames {
ShortNameRole = Qt::DisplayRole, /**< The name of the emoji. */
UnicodeRole, /**< The unicode character of the emoji. */
InvalidRole = 50, /**< Invalid, reserved. */
DisplayRole = 51, /**< The display text for an emoji. */
ReplacedTextRole = 52, /**< The text to replace the short name with (i.e. the unicode character). */
DescriptionRole = 53, /**< The long description of an emoji. */
};
Q_ENUM(RoleNames)
/**
* @brief Defines the potential categories an emoji can be placed in.
*/
enum Category {
Custom, /**< A custom user emoji. */
Search, /**< The results of a filter. */
SearchNoCustom, /**< The results of a filter with no custom emojis. */
History, /**< Recently used emojis. */
HistoryNoCustom, /**< Recently used emojis with no custom emojis. */
Smileys, /**< Smileys & emotion emojis. */
People, /**< People & Body emojis. */
Nature, /**< Animals & Nature emojis. */
Food, /**< Food & Drink emojis. */
Activities, /**< Activities emojis. */
Travel, /**< Travel & Places emojis. */
Objects, /**< Objects emojis. */
Symbols, /**< Symbols emojis. */
Flags, /**< Flags emojis. */
Component, /**< ??? */
};
Q_ENUM(Category)
/**
* @brief Get the given role value at the given index.
*
* @sa QAbstractItemModel::data
*/
[[nodiscard]] QVariant data(const QModelIndex &idx, int role = Qt::DisplayRole) const override;
/**
* @brief Number of rows in the model.
*
* @sa QAbstractItemModel::rowCount
*/
[[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override;
/**
* @brief Returns a mapping from Role enum values to role names.
*
* @sa RoleNames, QAbstractItemModel::roleNames()
*/
[[nodiscard]] QHash<int, QByteArray> roleNames() const override;
/**
* @brief Return a filtered list of emojis.
*
* @note This includes custom emojis, use filterModelNoCustom to return a result
* without custom emojis.
*
* @sa filterModelNoCustom
*/
Q_INVOKABLE static QVariantList filterModel(const QString &filter, bool limit = true);
/**
* @brief Return a filtered list of emojis without custom emojis.
*
* @note Use filterModel to return a result with custom emojis.
*
* @sa filterModel
*/
Q_INVOKABLE static QVariantList filterModelNoCustom(const QString &filter, bool limit = true);
/**
* @brief Return a list of emojis for the given category.
*/
Q_INVOKABLE QVariantList emojis(Category category) const;
/**
* @brief Return a list of emoji tones for the given base emoji.
*/
Q_INVOKABLE QVariantList tones(const QString &baseEmoji) const;
/**
* @brief Return a list of the last used emoji shortnames
*/
QStringList lastUsedEmojis() const;
QVariantList categories() const;
QVariantList categoriesWithCustom() const;
Q_SIGNALS:
void historyChanged();
public Q_SLOTS:
void emojiUsed(const QVariant &modelData);
private:
static QHash<Category, QVariantList> _emojis;
/// Returns QVariants containing the last used Emojis
QVariantList emojiHistory() const;
KSharedConfig::Ptr m_config;
KConfigGroup m_configGroup;
EmojiModel(QObject *parent = nullptr);
};

View File

@@ -1,57 +0,0 @@
// SPDX-FileCopyrightText: 2023 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: GPL-2.0-or-later
#include "emoticonfiltermodel.h"
#include "accountemoticonmodel.h"
#include "stickermodel.h"
EmoticonFilterModel::EmoticonFilterModel(QObject *parent)
: QSortFilterProxyModel(parent)
{
connect(this, &EmoticonFilterModel::sourceModelChanged, this, [this]() {
if (dynamic_cast<StickerModel *>(sourceModel())) {
m_stickerRole = StickerModel::IsStickerRole;
m_emojiRole = StickerModel::IsEmojiRole;
} else {
m_stickerRole = AccountEmoticonModel::IsStickerRole;
m_emojiRole = AccountEmoticonModel::IsEmojiRole;
}
});
}
bool EmoticonFilterModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const
{
Q_UNUSED(sourceParent);
auto stickerUsage = sourceModel()->data(sourceModel()->index(sourceRow, 0), m_stickerRole).toBool();
auto emojiUsage = sourceModel()->data(sourceModel()->index(sourceRow, 0), m_emojiRole).toBool();
return (stickerUsage && m_showStickers) || (emojiUsage && m_showEmojis);
}
bool EmoticonFilterModel::showStickers() const
{
return m_showStickers;
}
void EmoticonFilterModel::setShowStickers(bool showStickers)
{
beginResetModel();
m_showStickers = showStickers;
endResetModel();
Q_EMIT showStickersChanged();
}
bool EmoticonFilterModel::showEmojis() const
{
return m_showEmojis;
}
void EmoticonFilterModel::setShowEmojis(bool showEmojis)
{
beginResetModel();
m_showEmojis = showEmojis;
endResetModel();
Q_EMIT showEmojisChanged();
}
#include "moc_emoticonfiltermodel.cpp"

View File

@@ -1,55 +0,0 @@
// SPDX-FileCopyrightText: 2023 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <QQmlEngine>
#include <QSortFilterProxyModel>
/**
* @class EmoticonFilterModel
*
* This class creates a custom QSortFilterProxyModel for filtering a emoticon by type
* (Sticker or Emoji).
*/
class EmoticonFilterModel : public QSortFilterProxyModel
{
Q_OBJECT
QML_ELEMENT
/**
* @brief Whether stickers should be shown
*/
Q_PROPERTY(bool showStickers READ showStickers WRITE setShowStickers NOTIFY showStickersChanged)
/**
* @brief Whether emojis show be shown
*/
Q_PROPERTY(bool showEmojis READ showEmojis WRITE setShowEmojis NOTIFY showEmojisChanged)
public:
explicit EmoticonFilterModel(QObject *parent = nullptr);
/**
* @brief Custom filter function checking the type of emoticon
*
* @note The filter cannot be modified and will always use the same filter properties.
*/
bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override;
[[nodiscard]] bool showStickers() const;
void setShowStickers(bool showStickers);
[[nodiscard]] bool showEmojis() const;
void setShowEmojis(bool showEmojis);
Q_SIGNALS:
void showStickersChanged();
void showEmojisChanged();
private:
bool m_showStickers = false;
bool m_showEmojis = false;
int m_stickerRole = 0;
int m_emojiRole = 0;
};

View File

@@ -1,170 +0,0 @@
// SPDX-FileCopyrightText: 2021 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: LGPL-2.0-or-later
#include "imagepacksmodel.h"
#include "neochatroom.h"
#include <KLocalizedString>
using namespace Quotient;
ImagePacksModel::ImagePacksModel(QObject *parent)
: QAbstractListModel(parent)
{
}
int ImagePacksModel::rowCount(const QModelIndex &index) const
{
Q_UNUSED(index);
return m_events.count();
}
QVariant ImagePacksModel::data(const QModelIndex &index, int role) const
{
const auto row = index.row();
if (row < 0 || row >= m_events.size()) {
return {};
}
const auto &event = m_events[row];
if (role == DisplayNameRole) {
if (event.pack->displayName) {
return *event.pack->displayName;
}
}
if (role == AvatarUrlRole) {
if (event.pack->avatarUrl) {
return m_room->connection()->makeMediaUrl(*event.pack->avatarUrl);
} else if (!event.images.empty()) {
return m_room->connection()->makeMediaUrl(event.images[0].url);
}
}
return {};
}
QHash<int, QByteArray> ImagePacksModel::roleNames() const
{
return {
{DisplayNameRole, "displayName"},
{AvatarUrlRole, "avatarUrl"},
{AttributionRole, "attribution"},
{IdRole, "id"},
};
}
NeoChatRoom *ImagePacksModel::room() const
{
return m_room;
}
void ImagePacksModel::setRoom(NeoChatRoom *room)
{
if (m_room) {
disconnect(m_room, nullptr, this, nullptr);
disconnect(m_room->connection(), nullptr, this, nullptr);
}
m_room = room;
if (m_room) {
connect(m_room->connection(), &Connection::accountDataChanged, this, [this](const QString &type) {
if (type == "im.ponies.user_emotes"_ls) {
reloadImages();
}
});
}
// TODO listen to packs changing
reloadImages();
Q_EMIT roomChanged();
}
void ImagePacksModel::reloadImages()
{
if (!m_room) {
return;
}
beginResetModel();
m_events.clear();
// Load emoticons from the account data
if (m_room->connection()->hasAccountData("im.ponies.user_emotes"_ls)) {
auto json = m_room->connection()->accountData("im.ponies.user_emotes"_ls)->contentJson();
json["pack"_ls] = QJsonObject{
{"display_name"_ls,
m_showStickers ? i18nc("As in 'The user's own Stickers'", "Own Stickers") : i18nc("As in 'The user's own emojis", "Own Emojis")},
};
const auto &content = ImagePackEventContent(json);
if (!content.images.isEmpty()) {
m_events += ImagePackEventContent(json);
}
}
// Load emoticons from the saved rooms
const auto &accountData = m_room->connection()->accountData("im.ponies.emote_rooms"_ls);
if (accountData) {
const auto &rooms = accountData->contentJson()["rooms"_ls].toObject();
for (const auto &roomId : rooms.keys()) {
if (roomId == m_room->id()) {
continue;
}
auto packs = rooms[roomId].toObject();
const auto &stickerRoom = m_room->connection()->room(roomId);
if (!stickerRoom) {
continue;
}
for (const auto &packKey : packs.keys()) {
if (const auto &pack = stickerRoom->currentState().get<ImagePackEvent>(packKey)) {
const auto packContent = pack->content();
if ((!packContent.pack || !packContent.pack->usage || (packContent.pack->usage->contains("emoticon"_ls) && showEmoticons())
|| (packContent.pack->usage->contains("sticker"_ls) && showStickers()))
&& !packContent.images.isEmpty()) {
m_events += packContent;
}
}
}
}
}
// Load emoticons from the current room
auto events = m_room->currentState().eventsOfType("im.ponies.room_emotes"_ls);
for (const auto &event : events) {
auto packContent = eventCast<const ImagePackEvent>(event)->content();
if (packContent.pack.has_value()) {
if (!packContent.pack->usage || (packContent.pack->usage->contains("emoticon"_ls) && showEmoticons())
|| (packContent.pack->usage->contains("sticker"_ls) && showStickers())) {
m_events += packContent;
}
}
}
Q_EMIT imagesLoaded();
endResetModel();
}
bool ImagePacksModel::showStickers() const
{
return m_showStickers;
}
void ImagePacksModel::setShowStickers(bool showStickers)
{
m_showStickers = showStickers;
Q_EMIT showStickersChanged();
}
bool ImagePacksModel::showEmoticons() const
{
return m_showEmoticons;
}
void ImagePacksModel::setShowEmoticons(bool showEmoticons)
{
m_showEmoticons = showEmoticons;
Q_EMIT showEmoticonsChanged();
}
QList<Quotient::ImagePackEventContent::ImagePackImage> ImagePacksModel::images(int index)
{
if (index < 0 || index >= m_events.size()) {
return {};
}
return m_events[index].images;
}
#include "moc_imagepacksmodel.cpp"

View File

@@ -1,103 +0,0 @@
// SPDX-FileCopyrightText: 2021-2023 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: LGPL-2.0-or-later
#pragma once
#include "events/imagepackevent.h"
#include <QAbstractListModel>
#include <QList>
#include <QPointer>
#include <QQmlEngine>
class NeoChatRoom;
/**
* @class ImagePacksModel
*
* Defines the model for visualising image packs.
*
* See Matrix MSC2545 for more details on image packs.
* https://github.com/Sorunome/matrix-doc/blob/soru/emotes/proposals/2545-emotes.md
*/
class ImagePacksModel : public QAbstractListModel
{
Q_OBJECT
QML_ELEMENT
/**
* @brief The current room that the model is being used in.
*/
Q_PROPERTY(NeoChatRoom *room READ room WRITE setRoom NOTIFY roomChanged)
/**
* @brief Whether sticker image packs should be shown.
*/
Q_PROPERTY(bool showStickers READ showStickers WRITE setShowStickers NOTIFY showStickersChanged)
/**
* @brief Whether emoticon image packs should be shown.
*/
Q_PROPERTY(bool showEmoticons READ showEmoticons WRITE setShowEmoticons NOTIFY showEmoticonsChanged)
public:
/**
* @brief Defines the model roles.
*/
enum Roles {
DisplayNameRole = Qt::DisplayRole, /**< The display name of the image pack. */
AvatarUrlRole, /**< The source mxc URL for the pack avatar. */
AttributionRole, /**< The attribution for the pack author(s). */
IdRole, /**< The ID of the image pack. */
};
Q_ENUM(Roles)
explicit ImagePacksModel(QObject *parent = nullptr);
/**
* @brief Get the given role value at the given index.
*
* @sa QAbstractItemModel::data
*/
[[nodiscard]] QVariant data(const QModelIndex &index, int role) const override;
/**
* @brief Number of rows in the model.
*
* @sa QAbstractItemModel::rowCount
*/
[[nodiscard]] int rowCount(const QModelIndex &index) const override;
/**
* @brief Returns a mapping from Role enum values to role names.
*
* @sa Roles, QAbstractItemModel::roleNames()
*/
[[nodiscard]] QHash<int, QByteArray> roleNames() const override;
[[nodiscard]] NeoChatRoom *room() const;
void setRoom(NeoChatRoom *room);
[[nodiscard]] bool showStickers() const;
void setShowStickers(bool showStickers);
[[nodiscard]] bool showEmoticons() const;
void setShowEmoticons(bool showEmoticons);
/**
* @brief Return a vector of the images in the pack at the given index.
*/
[[nodiscard]] QList<Quotient::ImagePackEventContent::ImagePackImage> images(int index);
Q_SIGNALS:
void roomChanged();
void showStickersChanged();
void showEmoticonsChanged();
void imagesLoaded();
private:
QPointer<NeoChatRoom> m_room;
QList<Quotient::ImagePackEventContent> m_events;
bool m_showStickers = true;
bool m_showEmoticons = true;
void reloadImages();
};

View File

@@ -0,0 +1,60 @@
// SPDX-FileCopyrightText: 2024 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.neochat
QQC2.ScrollView {
id: root
QQC2.ScrollBar.horizontal.height: QQC2.ScrollBar.horizontal.visible ? QQC2.ScrollBar.horizontal.implicitHeight : 0
required property var model
property int currentIndex: 0
readonly property string category: root.model.data(root.model.index(root.currentIndex, 0), ImageContentPackRole.IdentifierRole)
implicitHeight: Kirigami.Units.iconSizes.large + root.QQC2.ScrollBar.horizontal.height
ListView {
id: categories
clip: true
focus: true
orientation: ListView.Horizontal
currentIndex: root.currentIndex
Keys.onReturnPressed: if (emojiGrid.count > 0) emojiGrid.focus = true
Keys.onEnterPressed: if (emojiGrid.count > 0) emojiGrid.focus = true
KeyNavigation.down: emojiGrid.count > 0 ? emojiGrid : categories
KeyNavigation.tab: emojiGrid.count > 0 ? emojiGrid : categories
keyNavigationEnabled: true
keyNavigationWraps: true
Keys.forwardTo: searchField
interactive: width !== contentWidth
Component.onCompleted: categories.forceActiveFocus()
model: root.model
delegate: EmojiDelegate {
id: packDelegate
required property string name
required property string i18nName
width: Kirigami.Units.iconSizes.large
height: width
checked: categories.currentIndex === model.index
toolTip: packDelegate.i18nName
text: packDelegate.name
onClicked: {
root.currentIndex = index;
}
}
}
}

View File

@@ -0,0 +1,39 @@
// SPDX-FileCopyrightText: 2023 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
Kirigami.NavigationTabBar {
id: root
enum EmoticonType {
Emoji,
Sticker
}
Kirigami.Theme.colorSet: Kirigami.Theme.View
property var selectedType: EmojiPickerTypeHeader.EmoticonType.Emoji
background: null
actions: [
Kirigami.Action {
id: emojis
icon.name: "smiley"
text: i18n("Emojis")
checked: root.selectedType === EmojiPickerTypeHeader.EmoticonType.Emoji
onTriggered: root.selectedType = EmojiPickerTypeHeader.EmoticonType.Emoji
},
Kirigami.Action {
id: stickers
icon.name: "stickers"
text: i18n("Stickers")
checked: root.selectedType === EmojiPickerTypeHeader.EmoticonType.Sticker
onTriggered: root.selectedType = EmojiPickerTypeHeader.EmoticonType.Sticker
}
]
}

View File

@@ -156,7 +156,6 @@ QQC2.Control {
EmojiDialog {
id: emojiDialog
currentRoom: root.currentRoom
showQuickReaction: true
onChosen: emoji => {
root.currentRoom.toggleReaction(root.delegate.eventId, emoji);
if (!Kirigami.Settings.isMobile) {

View File

@@ -42,7 +42,6 @@ Kirigami.ApplicationWindow {
}
onConnectionChanged: {
CustomEmojiModel.connection = root.connection;
SpaceHierarchyCache.connection = root.connection;
NeoChatSettingsView.connection = root.connection;
if (ShareHandler.text && root.connection) {
@@ -176,7 +175,6 @@ Kirigami.ApplicationWindow {
}
Component.onCompleted: {
CustomEmojiModel.connection = root.connection;
SpaceHierarchyCache.connection = root.connection;
RoomSettingsView.window = root;
NeoChatSettingsView.window = root;

37
src/qml/QuickReaction.qml Normal file
View File

@@ -0,0 +1,37 @@
// SPDX-FileCopyrightText: 2023 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.neochat
QQC2.ScrollView {
id: root
signal chosen(string text)
QQC2.ScrollBar.horizontal.height: QQC2.ScrollBar.horizontal.visible ? QQC2.ScrollBar.horizontal.implicitHeight : 0
implicitHeight: Kirigami.Units.iconSizes.large + QQC2.ScrollBar.horizontal.height
ListView {
id: quickReactions
Layout.fillWidth: true
model: ["👍", "👎", "😄", "🎉", "😕", "❤", "🚀", "👀"]
delegate: EmojiDelegate {
height: Kirigami.Units.iconSizes.large
width: height
text: modelData
onClicked: root.chosen(modelData)
}
orientation: Qt.Horizontal
}
}

View File

@@ -18,7 +18,6 @@
#include <Kirigami/Platform/PlatformTheme>
#include "messagecomponenttype.h"
#include "models/customemojimodel.h"
#include "utils.h"
using namespace Qt::StringLiterals;
@@ -81,7 +80,7 @@ QString TextHandler::handleSendText()
switch (m_nextTokenType) {
case Text:
nextTokenBuffer = escapeHtml(nextTokenBuffer);
nextTokenBuffer = CustomEmojiModel::instance().preprocessText(nextTokenBuffer);
//TODO: nextTokenBuffer = CustomEmojiModel::instance().preprocessText(nextTokenBuffer);
break;
case TextCode:
nextTokenBuffer = escapeHtml(nextTokenBuffer);

View File

@@ -1,102 +0,0 @@
#!/bin/python
# SPDX-FileCopyrightText: 2022 Tobias Fella <tobias.fella@kde.org>
# SPDX-FileCopyrightText: 2022 Gary Wang <wzc782970009@gmail.com>
# SPDX-License-Identifier: BSD-2-Clause
import requests
import re
def escape_sequence(unicode_str: str, codepoint_spliter: str) -> str:
codepoints = unicode_str.split(codepoint_spliter)
escape_sequence = ""
for codepoint in codepoints:
escape_sequence += "\\U" + codepoint.rjust(8, "0")
return escape_sequence
# GitLab uses the emoji shortnames from Gemojione
# See also: https://docs.gitlab.com/ee/development/fe_guide/emojis.html
gemojione = requests.get('https://raw.githubusercontent.com/bonusly/gemojione/master/config/index.json')
emoji_unicode_shortname_map = {}
gemojione_json = gemojione.json()
for (shortcode, props) in gemojione_json.items():
escaped_sequence = escape_sequence(props['unicode'], "-")
emoji_unicode_shortname_map[escaped_sequence] = shortcode
response = requests.get('https://unicode.org/Public/emoji/14.0/emoji-test.txt')
group = ""
file = open("../src/emojis.h", "w")
# REUSE-IgnoreStart
file.write("// SPDX-FileCopyrightText: None\n")
file.write("// SPDX-License-Identifier: LGPL-2.0-or-later\n")
# REUSE-IgnoreEnd
file.write("// This file is auto-generated. All changes will be lost. See tools/update-emojis.py\n")
file.write("// clang-format off\n")
tones_file = open("../src/emojitones_data.h", "w")
# REUSE-IgnoreStart
tones_file.write("// SPDX-FileCopyrightText: None\n")
tones_file.write("// SPDX-License-Identifier: LGPL-2.0-or-later\n")
# REUSE-IgnoreEnd
tones_file.write("// This file is auto-generated. All changes will be lost. See tools/update-emojis.py\n")
tones_file.write("// clang-format off\n")
for line in response.text.split("\n"):
if line.startswith("# group"):
raw_group = line.split(": ")[1]
if raw_group == "Activities":
group = "Activities"
elif raw_group == "Animals & Nature":
group = "Nature"
elif raw_group == "Component":
group = "Component"
elif raw_group == "Flags":
group = "Flags"
elif raw_group == "Food & Drink":
group = "Food"
elif raw_group == "Objects":
group = "Objects"
elif raw_group == "People & Body":
group = "People"
elif raw_group == "Smileys & Emotion":
group = "Smileys"
elif raw_group == "Symbols":
group = "Symbols"
elif raw_group == "Travel & Places":
group = "Travel"
else:
print("Unknown group:" + group)
group = ""
elif line.startswith("#") or line == "":
pass
else:
parts = line.split(";")
first = parts[0].strip()
escaped_sequence = escape_sequence(first, " ")
x = re.search(".*E[0-9]+.[0-9] ", parts[1])
description = parts[1].removeprefix(x.group())
shortcode = description
if "flag:" in description:
description = "Flag of " + description.split(": ")[1]
if "unqualified" in line or "minimally-qualified" in line:
continue
is_skin_tone = "skin tone" in description
if escaped_sequence in emoji_unicode_shortname_map:
shortcode = emoji_unicode_shortname_map[escaped_sequence]
emoji_args = 'QString::fromUtf8("{0}"), QStringLiteral("{1}"), QStringLiteral("{2}")'.format(escaped_sequence, shortcode, description)
emoji_qvariant = 'QVariant::fromValue(Emoji{' + emoji_args + '})'
if is_skin_tone:
tones_file.write("{QStringLiteral(\"" + description.split(":")[0] + "\"), " + emoji_qvariant + "},\n")
continue
file.write("_emojis[" + group + "].append(" + emoji_qvariant + ");\n")
file.close()
tones_file.close()