feat(StatusEmojiPopup): reimplement around C++ EmojiModel

- the new C++ EmojiModel provides a simple wrapper around the existing
JSON to facilitate a faster access and to be able to search/filter in
QML using SFPM
- no more nested GridViews inside Repeaters
- get rid of emoji manipulation and search/filter using JavaScript
- included the C++ script to generate the emojiList.js
This commit is contained in:
Lukáš Tinkl 2024-08-26 10:16:59 +02:00 committed by Lukáš Tinkl
parent 19e0be07d8
commit 9d9fb69e3b
18 changed files with 703 additions and 346 deletions

View File

@ -26,7 +26,7 @@ Language: Cpp
# void formatted_code_again;
DisableFormat: false
Standard: Cpp11
Standard: Cpp17
AccessModifierOffset: -4
AlignAfterOpenBracket: true

View File

@ -50,11 +50,11 @@ SplitView {
modal: false
anchors.centerIn: parent
settings: settings
emojiModel: StatusQUtils.Emoji.emojiModel
onEmojiSelected: d.lastSelectedEmoji = emoji
}
}
LogsAndControlsPanel {
SplitView.minimumHeight: 200
SplitView.preferredHeight: 200
@ -73,8 +73,8 @@ SplitView {
}
}
Text {
text: "Last selected: %1".arg(d.lastSelectedEmoji)
Label {
text: "Last selected: %1 ('%2')".arg(d.lastSelectedEmoji).arg(settings.recentEmojis[0])
}
Button {
@ -88,3 +88,5 @@ SplitView {
}
// category: Popups
// https://www.figma.com/design/Mr3rqxxgKJ2zMQ06UAKiWL/%F0%9F%92%AC-Chat%E2%8E%9CDesktop?node-id=1006-0&t=VC6BL8H0Il3VbDxX-0

View File

@ -116,6 +116,7 @@ add_library(StatusQ SHARED
include/StatusQ/singleroleaggregator.h
include/StatusQ/snapshotmodel.h
include/StatusQ/snapshotobject.h
include/StatusQ/statusemojimodel.h
include/StatusQ/statussyntaxhighlighter.h
include/StatusQ/statuswindow.h
include/StatusQ/stringutilsinternal.h
@ -148,6 +149,7 @@ add_library(StatusQ SHARED
src/singleroleaggregator.cpp
src/snapshotmodel.cpp
src/snapshotobject.cpp
src/statusemojimodel.cpp
src/statussyntaxhighlighter.cpp
src/statuswindow.cpp
src/stringutilsinternal.cpp

View File

@ -0,0 +1,63 @@
#pragma once
#include <QAbstractListModel>
#include <QJsonArray>
class StatusEmojiModel : public QAbstractListModel
{
Q_OBJECT
Q_PROPERTY(QJsonArray emojiJson READ emojiJson WRITE setEmojiJson NOTIFY emojiJsonChanged
REQUIRED FINAL)
Q_PROPERTY(QStringList categories READ categories CONSTANT FINAL)
Q_PROPERTY(QStringList recentEmojis READ recentEmojis WRITE setRecentEmojis NOTIFY
recentEmojisChanged FINAL)
Q_PROPERTY(QString recentCategoryName READ recentCategoryName CONSTANT FINAL)
Q_PROPERTY(QString baseSkinColorName READ baseSkinColorName CONSTANT FINAL)
public:
enum EmojiRoles {
AliasesRole = Qt::UserRole + 1,
AliasesAsciiRole,
CategoryRole,
EmojiRole,
EmojiOrderRole,
KeywordsRole,
NameRole,
ShortnameRole,
UnicodeRole,
SkinColorRole,
};
Q_ENUM(EmojiRoles)
explicit StatusEmojiModel(QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = {}) const override;
QVariant data(const QModelIndex &index, int role) const override;
QHash<int, QByteArray> roleNames() const override;
Q_INVOKABLE QString getEmojiUnicodeFromShortname(const QString &shortname) const;
Q_INVOKABLE int getCategoryOffset(int categoryIndex) const;
Q_INVOKABLE void addRecentEmoji(const QString &hexcode);
signals:
void emojiJsonChanged();
void recentEmojisChanged();
private:
QJsonArray emojiJson() const;
void setEmojiJson(const QJsonArray &newEmojiJson);
QJsonArray m_emojiJson;
QStringList categories() const;
QStringList recentEmojis() const;
void setRecentEmojis(const QStringList &newRecentEmojis);
QStringList m_recentEmojis;
QJsonArray m_recentEmojiJson;
void cleanAndResizeRecentEmojis();
void addRecentEmojisToModel(const QStringList &emojiHexcodes);
QString recentCategoryName() const;
QString baseSkinColorName() const;
};

View File

@ -1,10 +1,6 @@
import QtQuick 2.13
import StatusQ.Core 0.1
import QtQuick 2.15
Image {
id: root
property string emojiId: ""
width: 14

View File

@ -1,7 +1,5 @@
import QtQuick 2.14
import QtQuick.Controls 2.14
import StatusQ.Controls 0.1
import QtQuick 2.15
import QtQuick.Controls 2.15
/*!
\qmltype StatusGridView

View File

@ -1,6 +1,8 @@
pragma Singleton
import QtQuick 2.13
import QtQuick 2.15
import StatusQ 0.1
import "../../../assets/twemoji/twemoji.js" as Twemoji
import "./emojiList.js" as EmojiJSON
@ -20,6 +22,10 @@ QtObject {
readonly property string base: Qt.resolvedUrl("../../../assets/twemoji/")
property var emojiJSON: EmojiJSON
readonly property StatusEmojiModel emojiModel: StatusEmojiModel {
emojiJson: EmojiJSON.emoji_json
}
function parse(text, renderSize = size.small, renderFormat = format.svg) {
const renderSizes = renderSize.split("x");
if (!renderSize.includes("x") || renderSizes.length !== 2) {
@ -87,13 +93,9 @@ QtObject {
return value.match(emojiRegexp, "$1");
}
function getEmojiUnicode(shortname) {
const _emoji = EmojiJSON.emoji_json.find(function(emoji) {
return (emoji.shortname === shortname)
})
if (_emoji !== undefined)
return _emoji.unicode;
return undefined;
const _emoji = emojiModel.getEmojiUnicodeFromShortname(shortname);
if (!!_emoji)
return _emoji;
}
function getEmojiCodepoint(iconCodePoint) {
@ -119,10 +121,10 @@ QtObject {
}
function getEmojiFromId(emojiId) {
let shortcode = Emoji.getShortcodeFromId(emojiId)
let emojiUnicode = Emoji.getEmojiUnicode(shortcode)
let shortcode = getShortcodeFromId(emojiId)
let emojiUnicode = getEmojiUnicode(shortcode)
if (emojiUnicode) {
return Emoji.fromCodePoint(emojiUnicode)
return fromCodePoint(emojiUnicode)
}
return undefined
}
@ -153,6 +155,6 @@ QtObject {
const encodedIcon = getEmojiCodepoint(iconCodePoint)
// Adding a space because otherwise, some emojis would fuse since emoji is just a string
return Emoji.parse(encodedIcon, size || undefined) + ' '
return parse(encodedIcon, size || undefined) + ' '
}
}

View File

@ -0,0 +1,25 @@
cmake_minimum_required(VERSION 3.16)
project(emojibase-parser LANGUAGES CXX)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Core)
find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core)
add_executable(emojibase-parser
main.cpp
emoji-json.qrc
)
target_link_libraries(emojibase-parser Qt${QT_VERSION_MAJOR}::Core)
include(GNUInstallDirs)
install(TARGETS emojibase-parser
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
)

View File

@ -0,0 +1,7 @@
<RCC>
<qresource prefix="/">
<file>resources/json/data.raw.json</file>
<file>resources/json/joypixels.raw.json</file>
<file>resources/json/messages.raw.json</file>
</qresource>
</RCC>

View File

@ -0,0 +1,198 @@
#include <QCoreApplication>
#include <QElapsedTimer>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
auto getDoc(const QString &filename) -> QJsonDocument
{
QFile dataFile(filename);
if (!dataFile.open(QIODevice::ReadOnly | QIODevice::Text)) {
qWarning() << "Cannot open" << dataFile.fileName() << "file, quit...";
return {};
}
QJsonParseError error;
QJsonDocument dataDoc = QJsonDocument::fromJson(dataFile.readAll(), &error);
if (error.error != QJsonParseError::NoError) {
qWarning() << "Error" << error.error << "while parsing" << dataFile.fileName() << "file:" << error.errorString()
<< "; at offset" << error.offset;
return {};
}
return dataDoc;
}
// https://github.com/milesj/emojibase/blob/master/packages/data/en/messages.raw.json
auto getGroups() -> QMap<int, QString>
{
return {
{0, QStringLiteral("smileys, people & body")},
{1, QStringLiteral("smileys, people & body")},
// 2 -> skip components
{3, QStringLiteral("animals & nature")},
{4, QStringLiteral("food & drink")},
{5, QStringLiteral("travel & places")},
{6, QStringLiteral("activities")},
{7, QStringLiteral("objects")},
{8, QStringLiteral("symbols")},
{9, QStringLiteral("flags")},
};
}
auto getShortnames() -> QMap<QString, QStringList>
{
// https://github.com/milesj/emojibase/blob/master/packages/data/en/shortcodes/joypixels.raw.json
const auto filename = QStringLiteral(":/resources/json/joypixels.raw.json");
QJsonDocument dataDoc = getDoc(filename);
if (dataDoc.isEmpty() || dataDoc.isNull() || !dataDoc.isObject()) {
qWarning() << "Empty, null or invalid JSON in" << filename;
return {};
}
constexpr auto wrapInColonsIfNotEmpty = [](const auto &string) {
if (string.isEmpty())
return string;
return QStringLiteral(":%1:").arg(string);
};
QJsonObject shortnamesObj = dataDoc.object();
QMap<QString, QStringList> result;
for (auto it = shortnamesObj.constBegin(); it != shortnamesObj.constEnd(); it++) {
const auto key = it.key();
const auto value = it.value();
QStringList shortnames;
if (value.isString())
shortnames.append(wrapInColonsIfNotEmpty(value.toString()));
else if (value.isArray()) {
const auto shortnamesArr = value.toArray();
for (int i = 0; i < shortnamesArr.size(); i++)
shortnames.append(wrapInColonsIfNotEmpty(shortnamesArr.at(i).toString()));
}
result.insert(key, shortnames);
}
return result;
}
auto transformEmoji(const QString& label, const QString& hexcode, const QString& emoji, int order, const QString& category, const QJsonArray& keywords,
const QJsonArray& aliases_ascii, const QString& shortname, const QJsonArray& aliases, bool hasSkins) -> QJsonObject {
return {{"name", label},
{"unicode", hexcode.toLower()},
{"emoji_order", order},
{"category", category},
{"emoji", emoji},
{"keywords", keywords},
{"aliases_ascii", aliases_ascii},
{"shortname", shortname},
{"aliases", aliases},
{"hasSkins", hasSkins}
};
}
int main(int argc, char *argv[])
{
QCoreApplication a(argc, argv);
QElapsedTimer timer;
timer.start();
// https://github.com/milesj/emojibase/blob/master/packages/data/en/data.raw.json
QJsonDocument dataDoc = getDoc(QStringLiteral(":/resources/json/data.raw.json"));
if (dataDoc.isEmpty() || dataDoc.isNull() || !dataDoc.isArray()) {
qWarning() << "Unexpected dataFile structure, quit...";
return EXIT_FAILURE;
}
QJsonArray dataArray = dataDoc.array();
const auto size = dataArray.size();
qWarning() << "Initially found" << size << "emojis";
// The mapping:
// label (string) -> name
// hexcode (string) -> unicode
// order (int) -> emoji_order
// group (int) -> category (string)
// tags -> keywords (NEW)
// emoji -> emoji (NEW)
// shortname (array lookup) -> shortname + aliases (array)
// emoticon (array or string!) -> aliases_ascii (array)
const auto groupsMap = getGroups();
const auto shortNamesMap = getShortnames();
QJsonArray result;
for (int i = 0; i < size; i++) {
const auto value = dataArray.at(i).toObject();
if (value.isEmpty()) {
qWarning() << "Unexpected value type at index " << i << "; skipping";
continue;
}
const auto hexcode = value.value(QStringLiteral("hexcode")).toString();
if (!value.contains(QStringLiteral("group"))) {
qWarning() << "Skipping modifier emoji:" << hexcode;
continue;
}
const auto groupId = value.value(QStringLiteral("group")).toInt();
if (groupId == 2) {
qWarning() << "Skipping skin tone emoji:" << hexcode;
continue;
}
const auto emoji = value.value(QStringLiteral("emoji")).toString();
const auto label = value.value(QStringLiteral("label")).toString();
const auto order = value.value(QStringLiteral("order")).toInt();
const auto category = groupsMap.value(groupId);
const auto keywords = value.value(QStringLiteral("tags")).toArray();
auto shortnames = shortNamesMap.value(hexcode);
const auto shortname = !shortnames.isEmpty() ? shortnames.takeFirst() : QString();
const auto aliases = QJsonArray::fromStringList(shortnames);
qDebug() << "Found emoji:" << emoji << QStringLiteral("(hex: '%1' label: '%2' groupId: %3 order: %4)").arg(hexcode).arg(label).arg(groupId).arg(order);
QJsonArray aliases_ascii;
const auto emoticon = value.value(QStringLiteral("emoticon"));
if (emoticon.isString())
aliases_ascii.append(emoticon.toString());
else if (emoticon.isArray())
aliases_ascii = emoticon.toArray();
const auto skins = value.value(QStringLiteral("skins"));
result.append(transformEmoji(label, hexcode, emoji, order, category, keywords, aliases_ascii, shortname, aliases, skins.isArray() && !skins.isNull()));
if (skins.isArray()) {
const auto skinsArr = skins.toArray();
for (int j = 0; j < skinsArr.size(); j++) {
const auto skinnedEmoji = skinsArr.at(j).toObject();
const auto hexcode = skinnedEmoji.value(QStringLiteral("hexcode")).toString();
const auto label = skinnedEmoji.value(QStringLiteral("label")).toString();
const auto emoji = skinnedEmoji.value(QStringLiteral("emoji")).toString();
const auto order = skinnedEmoji.value(QStringLiteral("order")).toInt();
result.append(transformEmoji(label, hexcode, emoji, order, category, keywords, aliases_ascii, shortname, aliases, false));
}
}
}
QJsonDocument resultDoc(result);
QFile resultFile(QByteArrayLiteral("/tmp/emojiList.json"));
if (resultFile.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) {
resultFile.write(resultDoc.toJson(QJsonDocument::Indented));
qWarning() << "RESULT: exported" << result.size() << "emojis to" << resultFile.fileName();
} else {
qWarning() << "Cannot open" << resultFile.fileName() << "for writing the result, quit...";
return EXIT_FAILURE;
}
qWarning() << "RUNTIME:" << timer.elapsed() << "ms";
return EXIT_SUCCESS;
}

View File

@ -23,6 +23,7 @@
#include "StatusQ/rolesrenamingmodel.h"
#include "StatusQ/rxvalidator.h"
#include "StatusQ/snapshotobject.h"
#include "StatusQ/statusemojimodel.h"
#include "StatusQ/statussyntaxhighlighter.h"
#include "StatusQ/statuswindow.h"
#include "StatusQ/stringutilsinternal.h"
@ -71,6 +72,7 @@ public:
qmlRegisterType<LeftJoinModel>("StatusQ", 0, 1, "LeftJoinModel");
qmlRegisterType<RoleRename>("StatusQ", 0, 1, "RoleRename");
qmlRegisterType<RolesRenamingModel>("StatusQ", 0, 1, "RolesRenamingModel");
qmlRegisterType<StatusEmojiModel>("StatusQ", 0, 1, "StatusEmojiModel");
qmlRegisterType<SumAggregator>("StatusQ", 0, 1, "SumAggregator");
qmlRegisterType<FunctionAggregator>("StatusQ", 0, 1, "FunctionAggregator");
qmlRegisterType<WritableProxyModel>("StatusQ", 0, 1, "WritableProxyModel");

View File

@ -0,0 +1,236 @@
#include "StatusQ/statusemojimodel.h"
#include <QDebug>
#include <QJsonObject>
#include <array>
#include <algorithm>
namespace {
constexpr auto kAliases = "aliases";
constexpr auto kAliasesAscii = "aliases_ascii";
constexpr auto kCategory = "category";
constexpr auto kEmoji = "emoji";
constexpr auto kEmojiOrder = "emoji_order";
constexpr auto kKeywords = "keywords";
constexpr auto kName = "name";
constexpr auto kShortname = "shortname";
constexpr auto kUnicode = "unicode";
constexpr auto kHasSkins = "hasSkins";
constexpr auto kSkinColor = "skinColor";
constexpr auto kBaseColor = "base";
const auto skinColors = std::array<const char*, 5>{"1f3fb", "1f3fc", "1f3fd", "1f3fe", "1f3ff"};
constexpr auto MAX_EMOJI_NUMBER = 36;
constexpr auto kRecentCategoryName = "recent";
}
StatusEmojiModel::StatusEmojiModel(QObject *parent)
: QAbstractListModel(parent)
{}
int StatusEmojiModel::rowCount(const QModelIndex &parent) const
{
return m_recentEmojis.size() + m_emojiJson.size();
}
QVariant StatusEmojiModel::data(const QModelIndex &index, int role) const
{
const auto row = index.row();
if (row < 0 || row >= rowCount())
return {};
const auto &emoji = row < m_recentEmojiJson.size()
? m_recentEmojiJson.at(row).toObject()
: m_emojiJson.at(row - m_recentEmojiJson.size()).toObject();
const auto unicodeValue = emoji.value(kUnicode).toString();
switch (static_cast<EmojiRoles>(role)) {
case AliasesRole:
return emoji.value(kAliases).toArray();
case AliasesAsciiRole:
return emoji.value(kAliasesAscii).toArray();
case CategoryRole:
return emoji.value(kCategory).toString();
case EmojiRole:
return emoji.value(kEmoji).toString();
case EmojiOrderRole:
return emoji.value(kEmojiOrder).toInt();
case KeywordsRole:
return emoji.value(kKeywords).toArray();
case NameRole:
return emoji.value(kName).toString();
case ShortnameRole:
return emoji.value(kShortname).toString();
case UnicodeRole:
return unicodeValue;
case SkinColorRole:
const auto colorIt = std::find_if(skinColors.cbegin(),
skinColors.cend(),
[unicodeValue](const auto &skinColor) {
return unicodeValue.contains(skinColor);
});
if (colorIt != skinColors.cend())
return *colorIt;
if (emoji.value(kHasSkins).toBool(false))
return kBaseColor;
return QString();
}
return {};
}
QHash<int, QByteArray> StatusEmojiModel::roleNames() const
{
static const QHash<int, QByteArray> roles{
{AliasesRole, kAliases},
{AliasesAsciiRole, kAliasesAscii},
{CategoryRole, kCategory},
{EmojiRole, kEmoji},
{EmojiOrderRole, kEmojiOrder},
{KeywordsRole, kKeywords},
{NameRole, kName},
{ShortnameRole, kShortname},
{UnicodeRole, kUnicode},
{SkinColorRole, kSkinColor},
};
return roles;
}
QJsonArray StatusEmojiModel::emojiJson() const
{
return m_emojiJson;
}
void StatusEmojiModel::setEmojiJson(const QJsonArray &newEmojiJson)
{
if (newEmojiJson == m_emojiJson)
return;
beginResetModel();
m_emojiJson = newEmojiJson;
emit emojiJsonChanged();
endResetModel();
}
QString StatusEmojiModel::getEmojiUnicodeFromShortname(const QString &shortname) const
{
const auto it = std::find_if(m_emojiJson.cbegin(),
m_emojiJson.cend(),
[shortname](const auto &emoji) {
return emoji.isObject()
&& emoji.toObject().value(kShortname).toString()
== shortname;
});
if (it != m_emojiJson.cend())
return it->toObject().value(kUnicode).toString();
return {};
}
int StatusEmojiModel::getCategoryOffset(int categoryIndex) const {
if (categoryIndex <= 0 || categoryIndex >= categories().size())
return 0;
const auto categoryName = categories().at(categoryIndex);
const auto catIt = std::find_if(m_emojiJson.cbegin(),
m_emojiJson.cend(),
[categoryName](const auto &emoji) {
return emoji.isObject()
&& emoji.toObject().value(kCategory).toString()
== categoryName;
});
if (catIt != m_emojiJson.cend())
return std::distance(m_emojiJson.cbegin(), catIt) + m_recentEmojiJson.size();
return 0;
}
QStringList StatusEmojiModel::categories() const
{
static const QStringList categories{kRecentCategoryName,
QStringLiteral("smileys, people & body"),
QStringLiteral("animals & nature"),
QStringLiteral("food & drink"),
QStringLiteral("travel & places"),
QStringLiteral("activities"),
QStringLiteral("objects"),
QStringLiteral("symbols"),
QStringLiteral("flags")};
return categories;
}
QStringList StatusEmojiModel::recentEmojis() const
{
return m_recentEmojis;
}
void StatusEmojiModel::setRecentEmojis(const QStringList &newRecentEmojis)
{
if (m_recentEmojis == newRecentEmojis)
return;
m_recentEmojis = newRecentEmojis;
cleanAndResizeRecentEmojis();
addRecentEmojisToModel(m_recentEmojis);
emit recentEmojisChanged();
}
void StatusEmojiModel::addRecentEmoji(const QString &hexcode)
{
m_recentEmojis.prepend(hexcode);
cleanAndResizeRecentEmojis();
addRecentEmojisToModel(m_recentEmojis);
emit recentEmojisChanged();
}
void StatusEmojiModel::cleanAndResizeRecentEmojis()
{
m_recentEmojis.removeDuplicates();
if (m_recentEmojis.size() > MAX_EMOJI_NUMBER) {
while (m_recentEmojis.size() > MAX_EMOJI_NUMBER)
m_recentEmojis.removeLast();
}
}
void StatusEmojiModel::addRecentEmojisToModel(const QStringList &emojiHexcodes)
{
const auto emojiForHexcode = [&](const QString &hexcode) -> QJsonValue {
const auto it = std::find_if(m_emojiJson.cbegin(),
m_emojiJson.cend(),
[hexcode](const auto &emoji) {
return emoji.isObject()
&& emoji.toObject().value(kUnicode).toString()
== hexcode;
});
if (it != m_emojiJson.cend())
return *it;
return {QJsonValue::Null};
};
QJsonArray recentEmojiArr;
for (const auto &hexcode : emojiHexcodes) {
const auto emoji = emojiForHexcode(hexcode);
if (!emoji.isNull() && emoji.isObject()) {
auto emojiObj = emoji.toObject();
emojiObj[kCategory] = kRecentCategoryName;
emojiObj[kEmojiOrder] = 0; // sort by insertion order, before any regular ones
recentEmojiArr.append(emojiObj);
}
}
beginResetModel();
m_recentEmojiJson = recentEmojiArr;
endResetModel();
}
QString StatusEmojiModel::recentCategoryName() const
{
return kRecentCategoryName;
}
QString StatusEmojiModel::baseSkinColorName() const
{
return kBaseColor;
}

View File

@ -619,6 +619,7 @@ Item {
sourceComponent: StatusEmojiPopup {
height: 440
settings: localAccountSensitiveSettings
emojiModel: SQUtils.Emoji.emojiModel
}
}

View File

@ -126,6 +126,10 @@ Rectangle {
textInput.append(text)
}
function clear() {
textInput.clear()
}
implicitWidth: layout.implicitWidth + layout.anchors.leftMargin + layout.anchors.rightMargin
implicitHeight: layout.implicitHeight + layout.anchors.topMargin + layout.anchors.bottomMargin
@ -775,7 +779,8 @@ Rectangle {
const emojis = StatusQUtils.Emoji.emojiJSON.emoji_json.filter(function (emoji) {
return emoji.name.includes(emojiPart) ||
emoji.shortname.includes(emojiPart) ||
emoji.aliases.some(a => a.includes(emojiPart))
emoji.aliases.some(a => a.includes(emojiPart)) ||
emoji.keywords.some(k => k.includes(emojiPart))
})
emojiSuggestions.openPopup(emojis, emojiPart)
@ -976,7 +981,7 @@ Rectangle {
index = emojiSuggestions.listView.currentIndex
}
const unicode = emojiSuggestions.modelList[index].unicode_alternates || emojiSuggestions.modelList[index].unicode
const unicode = emojiSuggestions.modelList[index].unicode
replaceWithEmoji(extrapolateCursorPosition(), emojiSuggestions.shortname, unicode);
}
}

View File

@ -2,24 +2,26 @@ import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import StatusQ 0.1
import StatusQ.Core 0.1
import StatusQ.Controls 0.1
import StatusQ.Components 0.1
import StatusQ.Core.Theme 0.1
import StatusQ.Core.Utils 0.1 as StatusQUtils
import utils 1.0
import shared.panels 1.0
import shared.controls 1.0
StatusDropdown {
id: popup
import SortFilterProxyModel 0.2
StatusDropdown {
id: root
required property StatusEmojiModel emojiModel
required property var settings
property var categories: []
property alias searchString: searchBox.text
property var skinColors: ["1f3fb", "1f3fc", "1f3fd", "1f3fe", "1f3ff"]
property string emojiSize: ""
signal emojiSelected(string emoji, bool atCu)
@ -28,186 +30,116 @@ StatusDropdown {
width: 360
padding: 0
function containsSkinColor(code) {
return skinColors.some(function (color) {
return code.includes(color)
});
function addEmoji(hexcode) {
const extensionIndex = hexcode.lastIndexOf('.');
let iconCodePoint = hexcode
if (extensionIndex > -1) {
iconCodePoint = iconCodePoint.substring(0, extensionIndex)
}
function addEmoji(emoji) {
const MAX_EMOJI_NUMBER = 36
const extenstionIndex = emoji.filename.lastIndexOf('.');
let iconCodePoint = emoji.filename
if (extenstionIndex > -1) {
iconCodePoint = iconCodePoint.substring(0, extenstionIndex)
}
const encodedIcon = StatusQUtils.Emoji.getEmojiCodepoint(iconCodePoint)
// Split the filename to get all the parts and then encode them from hex to utf8
const splitCodePoint = iconCodePoint.split('-')
let codePointParts = []
splitCodePoint.forEach(function (codePoint) {
codePointParts.push(`0x${codePoint}`)
})
const encodedIcon = String.fromCodePoint(...codePointParts);
// Add at the start of the list
let recentEmojis = settings.recentEmojis
if (recentEmojis === undefined) {
recentEmojis = []
}
recentEmojis.unshift(emoji)
// Remove duplicates
recentEmojis = recentEmojis.filter(function (e, index) {
return !recentEmojis.some(function (e2, index2) {
return index2 < index && e2.filename === e.filename
})
})
if (recentEmojis.length > MAX_EMOJI_NUMBER) {
// remove last one
recentEmojis.splice(MAX_EMOJI_NUMBER - 1)
}
emojiSectionsRepeater.itemAt(0).allEmojis = recentEmojis
settings.recentEmojis = recentEmojis
root.emojiModel.addRecentEmoji(hexcode)
// Adding a space because otherwise, some emojis would fuse since emoji is just a string
popup.emojiSelected(StatusQUtils.Emoji.parse(encodedIcon, popup.emojiSize || undefined) + ' ', true)
popup.close()
root.emojiSelected(StatusQUtils.Emoji.parse(encodedIcon, root.emojiSize || undefined) + ' ', true)
root.close()
}
function populateCategories() {
var categoryNames = {"recent": 0}
var newCategories = [[]]
const emojisWithColors = [
"1f64c",
"1f44f",
"1f44b",
"1f44d",
"1f44e",
"1f44a",
"270a",
"270c",
"1f44c",
"270b",
"1f450",
"1f4aa",
"1f64f",
"261d",
"1f446",
"1f447",
"1f448",
"1f449",
"1f595",
"1f590",
"1f918",
"1f596",
"270d",
"1f485",
"1f442",
"1f443",
"1f476",
"1f466",
"1f467",
"1f468",
"1f469",
"1f471",
"1f474",
"1f475",
"1f472",
"1f473",
"1f46e",
"1f477",
"1f482",
"1f385",
"1f47c",
"1f478",
"1f470",
"1f6b6",
"1f3c3",
"1f483",
"1f647",
"1f481",
"1f645",
"1f646",
"1f64b",
"1f64e",
"1f64d",
"1f487",
"1f486",
"1f6a3",
"1f3ca",
"1f3c4",
"1f6c0",
"26f9",
"1f3cb",
"1f6b4",
"1f6b5",
"1f3c7",
"1f575"
]
StatusQUtils.Emoji.emojiJSON.emoji_json.forEach(function (emoji) {
if (!categoryNames[emoji.category] && categoryNames[emoji.category] !== 0) {
categoryNames[emoji.category] = newCategories.length
newCategories.push([])
}
if (settings.skinColor !== "") {
if (emoji.unicode.includes(settings.skinColor)) {
newCategories[categoryNames[emoji.category]].push(Object.assign({}, emoji, {filename: emoji.unicode}));
} else {
if (!emojisWithColors.includes(emoji.unicode) && !containsSkinColor(emoji.unicode)) {
newCategories[categoryNames[emoji.category]].push(Object.assign({}, emoji, {filename: emoji.unicode}));
}
}
} else {
if (!containsSkinColor(emoji.unicode)) {
newCategories[categoryNames[emoji.category]].push(Object.assign({}, emoji, {filename: emoji.unicode}));
}
}
})
if (newCategories[categoryNames.recent].length === 0) {
newCategories[categoryNames.recent].push({category: "recent", empty: true})
}
categories = newCategories;
const recent = settings.recentEmojis;
if (!!recent) {
emojiSectionsRepeater.itemAt(0).allEmojis = recent;
}
Component.onCompleted: {
root.emojiModel.recentEmojis = settings.recentEmojis
}
onOpened: {
searchBox.text = ""
searchBox.input.edit.forceActiveFocus()
Qt.callLater(populateCategories);
emojiGrid.positionViewAtBeginning()
}
onClosed: {
popup.emojiSize = ""
const recent = root.emojiModel.recentEmojis
if (recent.length)
settings.recentEmojis = recent
searchBox.text = ""
root.emojiSize = ""
skinToneEmoji.expandSkinColorOptions = false
}
QtObject {
id: d
readonly property string searchStringLowercase: searchBox.text.toLowerCase()
readonly property int headerMargin: 8
readonly property int imageWidth: 26
readonly property int imageMargin: 4
readonly property var filteredModel: SortFilterProxyModel {
sourceModel: root.emojiModel
filters: [
FastExpressionFilter {
expression: {
if (model.category === root.emojiModel.recentCategoryName) // don't show/duplicate recents when searching
return false
d.searchStringLowercase
return model.name.toLowerCase().includes(d.searchStringLowercase) ||
model.shortname.toLowerCase().includes(d.searchStringLowercase) ||
model.aliases.some(a => a.includes(d.searchStringLowercase)) ||
model.keywords.some(k => k.includes(d.searchStringLowercase))
}
expectedRoles: ["name", "shortname", "aliases", "keywords", "category"]
enabled: !!d.searchStringLowercase
},
AnyOf {
ValueFilter {
roleName: "skinColor"
value: ""
}
ValueFilter {
roleName: "skinColor"
value: root.emojiModel.baseSkinColorName
}
enabled: settings.skinColor === ""
},
AnyOf {
ValueFilter {
roleName: "skinColor"
value: ""
}
ValueFilter {
roleName: "skinColor"
value: settings.skinColor
}
enabled: settings.skinColor !== ""
}
]
sorters: RoleSorter {
roleName: "emoji_order"
}
}
}
contentItem: ColumnLayout {
spacing: Style.current.smallPadding
spacing: 0
Item {
readonly property int headerMargin: 8
id: emojiHeader
Layout.fillWidth: true
height: searchBox.height + emojiHeader.headerMargin
Layout.preferredHeight: searchBox.height + d.headerMargin
SearchBox {
input.edit.objectName: "StatusEmojiPopup_searchBox"
id: searchBox
anchors.right: skinToneEmoji.left
anchors.rightMargin: emojiHeader.headerMargin
anchors.rightMargin: d.headerMargin
anchors.top: parent.top
anchors.topMargin: emojiHeader.headerMargin
anchors.topMargin: d.headerMargin
anchors.left: parent.left
anchors.leftMargin: emojiHeader.headerMargin
anchors.leftMargin: d.headerMargin
minimumHeight: 36
maximumHeight: 36
input.topPadding: 0
input.bottomPadding: 0
}
Row {
@ -222,7 +154,7 @@ StatusDropdown {
visible: (opacity > 0.1)
anchors.verticalCenter: searchBox.verticalCenter
anchors.right: parent.right
anchors.rightMargin: emojiHeader.headerMargin
anchors.rightMargin: d.headerMargin
Repeater {
id: skinColorEmojiRepeater
model: ["1f590-1f3fb", "1f590-1f3fc", "1f590-1f3fd", "1f590-1f3fe", "1f590-1f3ff", "1f590"]
@ -235,7 +167,6 @@ StatusDropdown {
anchors.fill: parent
onClicked: {
settings.skinColor = (index === 5) ? "" : modelData.split("-")[1];
popup.populateCategories();
skinToneEmoji.expandSkinColorOptions = false;
}
}
@ -248,7 +179,7 @@ StatusDropdown {
height: 22
anchors.verticalCenter: searchBox.verticalCenter
anchors.right: parent.right
anchors.rightMargin: emojiHeader.headerMargin
anchors.rightMargin: d.headerMargin
visible: !skinToneEmoji.expandSkinColorOptions
emojiId: "1f590" + ((settings.skinColor !== "" && visible) ? ("-" + settings.skinColor) : "")
MouseArea {
@ -261,57 +192,58 @@ StatusDropdown {
}
}
StatusScrollView {
readonly property ScrollBar vScrollBar: ScrollBar.vertical
property var categrorySectionHeightRatios: []
property int activeCategory: 0
StatusBaseText {
Layout.fillWidth: true
Layout.leftMargin: d.headerMargin
Layout.topMargin: 8
Layout.bottomMargin: 4
font.weight: Font.Medium
color: Style.current.secondaryText
font.pixelSize: Style.current.additionalTextSize
text: d.searchStringLowercase ? (d.filteredModel.count ? qsTr("Search Results") : qsTr("No results found"))
: emojiGrid.currentCategory
font.capitalization: Font.AllUppercase
}
id: scrollView
padding: Style.current.smallPadding
StatusGridView {
id: emojiGrid
Layout.fillWidth: true
Layout.fillHeight: true
ScrollBar.vertical.policy: ScrollBar.AlwaysOn
contentWidth: availableWidth
Layout.leftMargin: d.headerMargin
ScrollBar.vertical.onPositionChanged: function () {
if (vScrollBar.position < categrorySectionHeightRatios[scrollView.activeCategory - 1]) {
scrollView.activeCategory--
} else if (vScrollBar.position > categrorySectionHeightRatios[scrollView.activeCategory]) {
scrollView.activeCategory++
readonly property string currentCategory: {
const item = emojiGrid.itemAt(contentX, contentY + (contentY !== originY ? cellHeight : 0)) // taking the 2nd row; 1st might be split between 2 categories
return !!item ? item.category : root.emojiModel.recentCategoryName
}
readonly property string currentCategoryIndex: root.emojiModel.categories.indexOf(currentCategory)
model: d.filteredModel
cellWidth: d.imageWidth + d.imageMargin * 2
cellHeight: d.imageWidth + d.imageMargin * 2
ScrollBar.vertical: StatusScrollBar {
policy: ScrollBar.AsNeeded
}
function scrollToCategory(category) {
if (category === 0) {
return vScrollBar.setPosition(0)
}
vScrollBar.setPosition(categrorySectionHeightRatios[category - 1])
}
delegate: Item {
readonly property string category: model.category
contentHeight: {
var totalHeight = 0
var categoryHeights = []
for (let i = 0; i < emojiSectionsRepeater.count; i++) {
totalHeight += emojiSectionsRepeater.itemAt(i).height
categoryHeights.push(totalHeight)
width: emojiGrid.cellWidth
height: emojiGrid.cellHeight
StatusEmoji {
objectName: "statusEmoji_" + model.shortname.replace(/:/g, "")
anchors.centerIn: parent
width: d.imageWidth
height: d.imageWidth
emojiId: model.unicode
MouseArea {
cursorShape: Qt.PointingHandCursor
anchors.fill: parent
onClicked: root.addEmoji(model.unicode)
}
var ratios = []
categoryHeights.forEach(function (catHeight) {
ratios.push(catHeight / totalHeight)
})
categrorySectionHeightRatios = ratios
return totalHeight - scrollView.topPadding - scrollView.bottomPadding
}
Repeater {
id: emojiSectionsRepeater
model: popup.categories
StatusEmojiSection {
width: scrollView.availableWidth
searchString: popup.searchString
addEmoji: popup.addEmoji
}
}
}
@ -326,10 +258,10 @@ StatusDropdown {
StatusTabBarIconButton {
icon.name: modelData
highlighted: index === scrollView.activeCategory
highlighted: !!d.searchStringLowercase ? index === 0 : index == emojiGrid.currentCategoryIndex
onClicked: {
scrollView.activeCategory = index
scrollView.scrollToCategory(index)
const offset = d.filteredModel.mapFromSource(root.emojiModel.getCategoryOffset(index))
emojiGrid.positionViewAtIndex(offset, GridView.Beginning)
}
}
}

View File

@ -1,110 +0,0 @@
import QtQuick 2.15
import QtQuick.Layouts 1.15
import StatusQ.Core 0.1
import StatusQ.Components 0.1
import utils 1.0
import shared 1.0
import shared.panels 1.0
Item {
id: emojiSection
property string searchString: ""
readonly property string searchStringLowercase: searchString.toLowerCase()
property int imageWidth: 26
property int imageMargin: 4
property var emojis: []
property var allEmojis: modelData
property var addEmoji: function () {}
visible: emojis.length > 0 || !!(modelData && modelData.length && modelData[0].empty && searchString === "")
anchors.top: index === 0 ? parent.top : parent.children[index - 1].bottom
anchors.topMargin: 0
implicitHeight: visible ? childrenRect.height + Style.current.padding : 0
StyledText {
id: categoryText
text: modelData && modelData.length ? modelData[0].category.toUpperCase() : ""
color: Style.current.secondaryText
font.pixelSize: 13
}
StyledText {
id: noRecentText
visible: !!(allEmojis && allEmojis.length && allEmojis[0].empty)
text: qsTr("No recent emojis")
color: Style.current.secondaryText
font.pixelSize: 10
anchors.top: categoryText.bottom
anchors.topMargin: Style.current.smallPadding
}
onSearchStringLowercaseChanged: {
if (emojiSection.searchStringLowercase === "") {
this.emojis = allEmojis
return
}
this.emojis = modelData.filter(function (emoji) {
if (emoji.empty) {
return false
}
return emoji.name.includes(emojiSection.searchStringLowercase) ||
emoji.shortname.includes(emojiSection.searchStringLowercase) ||
emoji.aliases.some(a => a.includes(emojiSection.searchStringLowercase)) ||
emoji.keywords.some(a => a.includes(emojiSection.searchStringLowercase)) ||
emoji.aliases_ascii.some(a => a.includes(emojiSection.searchString))
})
}
onAllEmojisChanged: {
if (!!emojiSection.allEmojis[0] && this.allEmojis[0].empty) {
return
}
this.emojis = this.allEmojis
}
StatusGridView {
id: emojiGrid
anchors.top: categoryText.bottom
anchors.topMargin: Style.current.smallPadding
width: parent.width
height: childrenRect.height
visible: count > 0
cellWidth: emojiSection.imageWidth + emojiSection.imageMargin * 2
cellHeight: emojiSection.imageWidth + emojiSection.imageMargin * 2
model: emojiSection.emojis
focus: true
interactive: false
delegate: Item {
id: emojiContainer
width: emojiGrid.cellWidth
height: emojiGrid.cellHeight
Column {
anchors.fill: parent
anchors.topMargin: emojiSection.imageMargin
anchors.leftMargin: emojiSection.imageMargin
StatusEmoji {
objectName: "statusEmoji_" + modelData.shortname.replace(/:/g, "")
width: emojiSection.imageWidth
height: emojiSection.imageWidth
emojiId: modelData.filename
MouseArea {
cursorShape: Qt.PointingHandCursor
anchors.fill: parent
onClicked: {
emojiSection.addEmoji(modelData)
}
}
}
}
}
}
}

View File

@ -12,8 +12,7 @@ StatusInputListPopup {
if(listView.currentIndex < 0 || listView.currentIndex >= emojiSuggestions.modelList.count)
return ""
return emojiSuggestions.modelList[listView.currentIndex].unicode_alternates ||
emojiSuggestions.modelList[listView.currentIndex].unicode
return emojiSuggestions.modelList[listView.currentIndex].unicode
}
getImageSource: function (modelData) {

View File

@ -10,7 +10,6 @@ StatusChatInputImageArea 1.0 StatusChatInputImageArea.qml
StatusChatInputReplyArea 1.0 StatusChatInputReplyArea.qml
StatusChatInputTextFormationAction 1.0 StatusChatInputTextFormationAction.qml
StatusEmojiPopup 1.0 StatusEmojiPopup.qml
StatusEmojiSection 1.0 StatusEmojiSection.qml
StatusEmojiSuggestionPopup 1.0 StatusEmojiSuggestionPopup.qml
StatusGifPopup 1.0 StatusGifPopup.qml
StatusImageModal 1.0 StatusImageModal.qml