From 9d9fb69e3be1bcd39ead3e94bf7b903ab301d382 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Tinkl?= Date: Mon, 26 Aug 2024 10:16:59 +0200 Subject: [PATCH] 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 --- .clang-format | 2 +- storybook/pages/StatusEmojiPopupPage.qml | 8 +- ui/StatusQ/CMakeLists.txt | 2 + ui/StatusQ/include/StatusQ/statusemojimodel.h | 63 ++++ .../src/StatusQ/Components/StatusEmoji.qml | 6 +- .../src/StatusQ/Core/StatusGridView.qml | 6 +- ui/StatusQ/src/StatusQ/Core/Utils/Emoji.qml | 26 +- .../Utils/emojibase-parser/CMakeLists.txt | 25 ++ .../Utils/emojibase-parser/emoji-json.qrc | 7 + .../Core/Utils/emojibase-parser/main.cpp | 198 ++++++++++ ui/StatusQ/src/plugin.cpp | 2 + ui/StatusQ/src/statusemojimodel.cpp | 236 ++++++++++++ ui/app/mainui/AppMain.qml | 1 + ui/imports/shared/status/StatusChatInput.qml | 9 +- ui/imports/shared/status/StatusEmojiPopup.qml | 344 +++++++----------- .../shared/status/StatusEmojiSection.qml | 110 ------ .../status/StatusEmojiSuggestionPopup.qml | 3 +- ui/imports/shared/status/qmldir | 1 - 18 files changed, 703 insertions(+), 346 deletions(-) create mode 100644 ui/StatusQ/include/StatusQ/statusemojimodel.h create mode 100644 ui/StatusQ/src/StatusQ/Core/Utils/emojibase-parser/CMakeLists.txt create mode 100644 ui/StatusQ/src/StatusQ/Core/Utils/emojibase-parser/emoji-json.qrc create mode 100644 ui/StatusQ/src/StatusQ/Core/Utils/emojibase-parser/main.cpp create mode 100644 ui/StatusQ/src/statusemojimodel.cpp delete mode 100644 ui/imports/shared/status/StatusEmojiSection.qml diff --git a/.clang-format b/.clang-format index 20f071dc79..176d3ca4c6 100644 --- a/.clang-format +++ b/.clang-format @@ -26,7 +26,7 @@ Language: Cpp # void formatted_code_again; DisableFormat: false -Standard: Cpp11 +Standard: Cpp17 AccessModifierOffset: -4 AlignAfterOpenBracket: true diff --git a/storybook/pages/StatusEmojiPopupPage.qml b/storybook/pages/StatusEmojiPopupPage.qml index a512cd7dfb..944527195a 100644 --- a/storybook/pages/StatusEmojiPopupPage.qml +++ b/storybook/pages/StatusEmojiPopupPage.qml @@ -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 diff --git a/ui/StatusQ/CMakeLists.txt b/ui/StatusQ/CMakeLists.txt index 6ece1f77dd..2bf3a24f39 100644 --- a/ui/StatusQ/CMakeLists.txt +++ b/ui/StatusQ/CMakeLists.txt @@ -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 diff --git a/ui/StatusQ/include/StatusQ/statusemojimodel.h b/ui/StatusQ/include/StatusQ/statusemojimodel.h new file mode 100644 index 0000000000..a37333701d --- /dev/null +++ b/ui/StatusQ/include/StatusQ/statusemojimodel.h @@ -0,0 +1,63 @@ +#pragma once + +#include +#include + +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 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; +}; diff --git a/ui/StatusQ/src/StatusQ/Components/StatusEmoji.qml b/ui/StatusQ/src/StatusQ/Components/StatusEmoji.qml index 9ad6e92716..c3a0359249 100644 --- a/ui/StatusQ/src/StatusQ/Components/StatusEmoji.qml +++ b/ui/StatusQ/src/StatusQ/Components/StatusEmoji.qml @@ -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 diff --git a/ui/StatusQ/src/StatusQ/Core/StatusGridView.qml b/ui/StatusQ/src/StatusQ/Core/StatusGridView.qml index 5d3eeaa10a..bdf334a44d 100644 --- a/ui/StatusQ/src/StatusQ/Core/StatusGridView.qml +++ b/ui/StatusQ/src/StatusQ/Core/StatusGridView.qml @@ -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 diff --git a/ui/StatusQ/src/StatusQ/Core/Utils/Emoji.qml b/ui/StatusQ/src/StatusQ/Core/Utils/Emoji.qml index c89465e0d4..ebae9183f6 100644 --- a/ui/StatusQ/src/StatusQ/Core/Utils/Emoji.qml +++ b/ui/StatusQ/src/StatusQ/Core/Utils/Emoji.qml @@ -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) + ' ' } } diff --git a/ui/StatusQ/src/StatusQ/Core/Utils/emojibase-parser/CMakeLists.txt b/ui/StatusQ/src/StatusQ/Core/Utils/emojibase-parser/CMakeLists.txt new file mode 100644 index 0000000000..095d37ce00 --- /dev/null +++ b/ui/StatusQ/src/StatusQ/Core/Utils/emojibase-parser/CMakeLists.txt @@ -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} +) diff --git a/ui/StatusQ/src/StatusQ/Core/Utils/emojibase-parser/emoji-json.qrc b/ui/StatusQ/src/StatusQ/Core/Utils/emojibase-parser/emoji-json.qrc new file mode 100644 index 0000000000..ebdcbee348 --- /dev/null +++ b/ui/StatusQ/src/StatusQ/Core/Utils/emojibase-parser/emoji-json.qrc @@ -0,0 +1,7 @@ + + + resources/json/data.raw.json + resources/json/joypixels.raw.json + resources/json/messages.raw.json + + diff --git a/ui/StatusQ/src/StatusQ/Core/Utils/emojibase-parser/main.cpp b/ui/StatusQ/src/StatusQ/Core/Utils/emojibase-parser/main.cpp new file mode 100644 index 0000000000..725f4a4573 --- /dev/null +++ b/ui/StatusQ/src/StatusQ/Core/Utils/emojibase-parser/main.cpp @@ -0,0 +1,198 @@ +#include + +#include +#include + +#include +#include +#include + +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 +{ + 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 +{ + // 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 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; +} diff --git a/ui/StatusQ/src/plugin.cpp b/ui/StatusQ/src/plugin.cpp index 60895196d9..5a9779a60e 100644 --- a/ui/StatusQ/src/plugin.cpp +++ b/ui/StatusQ/src/plugin.cpp @@ -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("StatusQ", 0, 1, "LeftJoinModel"); qmlRegisterType("StatusQ", 0, 1, "RoleRename"); qmlRegisterType("StatusQ", 0, 1, "RolesRenamingModel"); + qmlRegisterType("StatusQ", 0, 1, "StatusEmojiModel"); qmlRegisterType("StatusQ", 0, 1, "SumAggregator"); qmlRegisterType("StatusQ", 0, 1, "FunctionAggregator"); qmlRegisterType("StatusQ", 0, 1, "WritableProxyModel"); diff --git a/ui/StatusQ/src/statusemojimodel.cpp b/ui/StatusQ/src/statusemojimodel.cpp new file mode 100644 index 0000000000..bafea55778 --- /dev/null +++ b/ui/StatusQ/src/statusemojimodel.cpp @@ -0,0 +1,236 @@ +#include "StatusQ/statusemojimodel.h" + +#include +#include + +#include +#include + +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{"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(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 StatusEmojiModel::roleNames() const +{ + static const QHash 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; +} diff --git a/ui/app/mainui/AppMain.qml b/ui/app/mainui/AppMain.qml index 31fb506237..bf8c76895f 100644 --- a/ui/app/mainui/AppMain.qml +++ b/ui/app/mainui/AppMain.qml @@ -619,6 +619,7 @@ Item { sourceComponent: StatusEmojiPopup { height: 440 settings: localAccountSensitiveSettings + emojiModel: SQUtils.Emoji.emojiModel } } diff --git a/ui/imports/shared/status/StatusChatInput.qml b/ui/imports/shared/status/StatusChatInput.qml index a7b814a1a3..0e8295dedf 100644 --- a/ui/imports/shared/status/StatusChatInput.qml +++ b/ui/imports/shared/status/StatusChatInput.qml @@ -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); } } diff --git a/ui/imports/shared/status/StatusEmojiPopup.qml b/ui/imports/shared/status/StatusEmojiPopup.qml index cf6edc2d40..9317d614a8 100644 --- a/ui/imports/shared/status/StatusEmojiPopup.qml +++ b/ui/imports/shared/status/StatusEmojiPopup.qml @@ -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(emoji) { - const MAX_EMOJI_NUMBER = 36 - const extenstionIndex = emoji.filename.lastIndexOf('.'); - let iconCodePoint = emoji.filename - if (extenstionIndex > -1) { - iconCodePoint = iconCodePoint.substring(0, extenstionIndex) + function addEmoji(hexcode) { + const extensionIndex = hexcode.lastIndexOf('.'); + let iconCodePoint = hexcode + if (extensionIndex > -1) { + iconCodePoint = iconCodePoint.substring(0, extensionIndex) } - // 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); + const encodedIcon = StatusQUtils.Emoji.getEmojiCodepoint(iconCodePoint) - // 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) - } - var ratios = [] - categoryHeights.forEach(function (catHeight) { - ratios.push(catHeight / totalHeight) - }) + width: emojiGrid.cellWidth + height: emojiGrid.cellHeight - categrorySectionHeightRatios = ratios - return totalHeight - scrollView.topPadding - scrollView.bottomPadding - } + StatusEmoji { + objectName: "statusEmoji_" + model.shortname.replace(/:/g, "") + anchors.centerIn: parent + width: d.imageWidth + height: d.imageWidth + emojiId: model.unicode - Repeater { - id: emojiSectionsRepeater - model: popup.categories - - StatusEmojiSection { - width: scrollView.availableWidth - searchString: popup.searchString - addEmoji: popup.addEmoji + MouseArea { + cursorShape: Qt.PointingHandCursor + anchors.fill: parent + onClicked: root.addEmoji(model.unicode) + } } } } @@ -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) } } } diff --git a/ui/imports/shared/status/StatusEmojiSection.qml b/ui/imports/shared/status/StatusEmojiSection.qml deleted file mode 100644 index 8c06bd0748..0000000000 --- a/ui/imports/shared/status/StatusEmojiSection.qml +++ /dev/null @@ -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) - } - } - } - } - } - } -} diff --git a/ui/imports/shared/status/StatusEmojiSuggestionPopup.qml b/ui/imports/shared/status/StatusEmojiSuggestionPopup.qml index 119fe506df..2186585ae6 100644 --- a/ui/imports/shared/status/StatusEmojiSuggestionPopup.qml +++ b/ui/imports/shared/status/StatusEmojiSuggestionPopup.qml @@ -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) { diff --git a/ui/imports/shared/status/qmldir b/ui/imports/shared/status/qmldir index 2ee0e5996a..46023230e1 100644 --- a/ui/imports/shared/status/qmldir +++ b/ui/imports/shared/status/qmldir @@ -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