feat: ponies.im emoji support (custom emojum)

This commit is contained in:
Jan Blackquill
2021-06-13 19:52:59 -04:00
parent 9961483f5c
commit 30965cb503
21 changed files with 537 additions and 37 deletions

View File

@@ -8,6 +8,8 @@ add_executable(neochat
controller.cpp
actionshandler.cpp
emojimodel.cpp
customemojimodel.cpp
customemojimodel+network.cpp
clipboard.cpp
matriximageprovider.cpp
messageeventmodel.cpp

View File

@@ -13,6 +13,7 @@
#include "controller.h"
#include "roommanager.h"
#include "customemojimodel.h"
ActionsHandler::ActionsHandler(QObject *parent)
: QObject(parent)
@@ -72,7 +73,7 @@ void ActionsHandler::postEdit(const QString &text)
if (!match.hasMatch()) {
// should not happen but still make sure to send the message normally
// just in case.
postMessage(text, QString(), QString(), QString(), QVariantMap());
postMessage(text, QString(), QString(), QString(), QVariantMap(), nullptr);
}
const QString regex = match.captured(1);
const QString replacement = match.captured(2);
@@ -93,11 +94,19 @@ void ActionsHandler::postMessage(const QString &text,
const QString &attachementPath,
const QString &replyEventId,
const QString &editEventId,
const QVariantMap &usernames)
const QVariantMap &usernames,
CustomEmojiModel* cem)
{
QString rawText = text;
QString cleanedText = text;
auto preprocess = [cem](const QString& it) -> QString {
if (cem == nullptr) {
return it;
}
return cem->preprocessText(it);
};
for (auto it = usernames.constBegin(); it != usernames.constEnd(); it++) {
cleanedText = cleanedText.replace(it.key(), "[" + it.key() + "](https://matrix.to/#/" + it.value().toString() + ")");
}
@@ -163,7 +172,7 @@ void ActionsHandler::postMessage(const QString &text,
for (int i = 0; i < cleanedText.length(); i++) {
rainbowText = rainbowText % QStringLiteral("<font color='") % rainbowColors.at(i % rainbowColors.length()) % "'>" % cleanedText.at(i) % "</font>";
}
m_room->postHtmlMessage(cleanedText, rainbowText, RoomMessageEvent::MsgType::Notice, replyEventId, editEventId);
m_room->postHtmlMessage(cleanedText, preprocess(rainbowText), RoomMessageEvent::MsgType::Notice, replyEventId, editEventId);
return;
}
@@ -173,7 +182,7 @@ void ActionsHandler::postMessage(const QString &text,
for (int i = 0; i < cleanedText.length(); i++) {
rainbowText = rainbowText % QStringLiteral("<font color='") % rainbowColors.at(i % rainbowColors.length()) % "'>" % cleanedText.at(i) % "</font>";
}
m_room->postHtmlMessage(cleanedText, rainbowText, messageEventType, replyEventId, editEventId);
m_room->postHtmlMessage(cleanedText, preprocess(rainbowText), messageEventType, replyEventId, editEventId);
return;
}
@@ -280,5 +289,5 @@ void ActionsHandler::postMessage(const QString &text,
cleanedText = cleanedText.remove(0, noticePrefix.length());
messageEventType = RoomMessageEvent::MsgType::Notice;
}
m_room->postMessage(rawText, cleanedText, messageEventType, replyEventId, editEventId);
m_room->postMessage(rawText, preprocess(m_room->preprocessText(cleanedText)), messageEventType, replyEventId, editEventId);
}

View File

@@ -10,6 +10,8 @@
using namespace Quotient;
class CustomEmojiModel;
/// \brief Handles user interactions with NeoChat (joining room, creating room,
/// sending message). Account management is handled by Controller.
class ActionsHandler : public QObject
@@ -56,7 +58,7 @@ public Q_SLOTS:
///
/// This also interprets commands if any.
void
postMessage(const QString &text, const QString &attachementPath, const QString &replyEventId, const QString &editEventId, const QVariantMap &usernames);
postMessage(const QString &text, const QString &attachementPath, const QString &replyEventId, const QString &editEventId, const QVariantMap &usernames, CustomEmojiModel* cem);
/// \brief Send edit instructions (.e.g s/hallo/hello/)
///

View File

@@ -0,0 +1,75 @@
// SPDX-FileCopyrightText: 2021 Carson Black <uhhadd@gmail.com>
// SPDX-License-Identifier: GPL-2.0-or-later
#include <csapi/account-data.h>
#include <csapi/profile.h>
#include <csapi/content-repo.h>
#include "customemojimodel_p.h"
#ifdef QUOTIENT_07
#define running isJobPending
#else
#define running isJobRunning
#endif
void CustomEmojiModel::fetchEmojies()
{
if (d->conn == nullptr) {
return;
}
const auto& data = d->conn->accountData("im.ponies.user_emotes");
if (data == nullptr) {
return;
}
const auto emojies = data->contentJson()["emoticons"].toObject();
beginResetModel();
d->emojies.clear();
for (const auto& emoji : emojies.keys()) {
const auto& data = emojies[emoji];
d->emojies << CustomEmoji {
.name = emoji,
.url = data["url"].toString(),
.regexp = QRegularExpression(QStringLiteral(R"((^|[^\\]))") + emoji)
};
}
endResetModel();
}
void CustomEmojiModel::addEmoji(const QString& name, const QUrl& location)
{
using namespace Quotient;
auto job = d->conn->uploadFile(location.toLocalFile());
if (running(job)) {
connect(job, &BaseJob::success, this, [this, name, job] {
const auto& data = d->conn->accountData("im.ponies.user_emotes");
auto json = data != nullptr ? data->contentJson() : QJsonObject();
auto emojiData = json["emoticons"].toObject();
emojiData[QStringLiteral(":%1:").arg(name)] = QJsonObject({
{QStringLiteral("url"), job->contentUri()}
});
json["emoticons"] = emojiData;
d->conn->setAccountData("im.ponies.user_emotes", json);
});
}
}
void CustomEmojiModel::removeEmoji(const QString& name)
{
using namespace Quotient;
const auto& data = d->conn->accountData("im.ponies.user_emotes");
Q_ASSERT(data != nullptr); // something's screwed if we get here with a nullptr
auto json = data->contentJson();
auto emojiData = json["emoticons"].toObject();
emojiData.remove(name);
json["emoticons"] = emojiData;
d->conn->setAccountData("im.ponies.user_emotes", json);
}

113
src/customemojimodel.cpp Normal file
View File

@@ -0,0 +1,113 @@
// SPDX-FileCopyrightText: 2021 Carson Black <uhhadd@gmail.com>
// SPDX-License-Identifier: GPL-2.0-or-later
#include "emojimodel.h"
#include "customemojimodel_p.h"
enum Roles {
Name,
ImageURL,
ModelData, // for emulating the regular emoji model's usage, otherwise the UI code would get too complicated
};
CustomEmojiModel::CustomEmojiModel(QObject* parent) : QAbstractListModel(parent), d(new Private)
{
connect(this, &CustomEmojiModel::connectionChanged, this, &CustomEmojiModel::fetchEmojies);
connect(this, &CustomEmojiModel::connectionChanged, this, [this]() {
if (!d->conn) return;
connect(d->conn, &Connection::accountDataChanged, this, [this](const QString& id) {
if (id != QStringLiteral("im.ponies.user_emotes")) {
return;
}
fetchEmojies();
});
});
}
CustomEmojiModel::~CustomEmojiModel()
{
}
QVariant CustomEmojiModel::data(const QModelIndex& idx, int role) const
{
const auto row = idx.row();
if (row >= d->emojies.length()) {
return QVariant();
}
const auto& data = d->emojies[row];
switch (Roles(role)) {
case Roles::ModelData:
return QVariant::fromValue(Emoji(
QStringLiteral("image://mxc/") + data.url.mid(6),
data.name,
true
));
case Roles::Name:
return data.name;
case Roles::ImageURL:
return QUrl(QStringLiteral("image://mxc/") + data.url.mid(6));
}
return QVariant();
}
int CustomEmojiModel::rowCount(const QModelIndex& parent) const
{
Q_UNUSED(parent)
return d->emojies.length();
}
QHash<int,QByteArray> CustomEmojiModel::roleNames() const
{
return {
{ Name, "name" },
{ ImageURL, "imageURL" },
{ ModelData, "modelData" },
};
}
Connection* CustomEmojiModel::connection() const
{
return d->conn;
}
void CustomEmojiModel::setConnection(Connection* it)
{
if (d->conn == it) {
return;
}
if (d->conn != nullptr) {
disconnect(d->conn, nullptr, this, nullptr);
}
d->conn = it;
Q_EMIT connectionChanged();
}
QString CustomEmojiModel::preprocessText(const QString &it)
{
auto cp = it;
for (const auto& emoji : d->emojies) {
cp.replace(emoji.regexp, QStringLiteral(R"(<img data-mx-emoticon="" src="%1" alt="%2" title="%2" height="32" vertical-align="middle" />)").arg(emoji.url).arg(emoji.name));
}
return cp;
}
QVariantList CustomEmojiModel::filterModel(const QString &filter)
{
QVariantList results;
for (const auto& emoji : d->emojies) {
if (results.length() >= 10) break;
if (!emoji.name.contains(filter, Qt::CaseInsensitive)) continue;
results << QVariant::fromValue(Emoji(
QStringLiteral("image://mxc/") + emoji.url.mid(6),
emoji.name,
true
));
}
return results;
}

55
src/customemojimodel.h Normal file
View File

@@ -0,0 +1,55 @@
// SPDX-FileCopyrightText: 2021 Carson Black <uhhadd@gmail.com>
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <QAbstractListModel>
#include <memory>
#include "connection.h"
using namespace Quotient;
class CustomEmojiModel : public QAbstractListModel
{
Q_OBJECT
Q_PROPERTY(Connection *connection READ connection WRITE setConnection NOTIFY connectionChanged)
public:
// constructors
explicit CustomEmojiModel(QObject* parent = nullptr);
~CustomEmojiModel();
// model
QVariant data(const QModelIndex& idx, int role = Qt::DisplayRole) const override;
int rowCount(const QModelIndex& parent = QModelIndex()) const override;
QHash<int,QByteArray> roleNames() const override;
// property setters
Connection* connection() const;
void setConnection(Connection* it);
Q_SIGNAL void connectionChanged();
// QML functions
Q_INVOKABLE QString preprocessText(const QString& it);
Q_INVOKABLE QVariantList filterModel(const QString &filter);
Q_INVOKABLE void addEmoji(const QString& name, const QUrl& location);
Q_INVOKABLE void removeEmoji(const QString& name);
private:
struct Private;
std::unique_ptr<Private> d;
void fetchEmojies();
};

19
src/customemojimodel_p.h Normal file
View File

@@ -0,0 +1,19 @@
// SPDX-FileCopyrightText: 2021 Carson Black <uhhadd@gmail.com>
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include "customemojimodel.h"
struct CustomEmoji
{
QString name; // with :semicolons:
QString url; // mxc://
QRegularExpression regexp;
};
struct CustomEmojiModel::Private
{
Connection* conn = nullptr;
QList<CustomEmoji> emojies;
};

View File

@@ -10,9 +10,10 @@
#include <utility>
struct Emoji {
Emoji(QString u, QString s)
Emoji(QString u, QString s, bool isCustom = false)
: unicode(std::move(std::move(u)))
, shortname(std::move(std::move(s)))
, isCustom(isCustom)
{
}
Emoji() = default;
@@ -28,15 +29,18 @@ struct Emoji {
{
arch >> object.unicode;
arch >> object.shortname;
object.isCustom = object.unicode.startsWith("image://");
return arch;
}
QString unicode;
QString shortname;
bool isCustom = false;
Q_GADGET
Q_PROPERTY(QString unicode MEMBER unicode)
Q_PROPERTY(QString shortname MEMBER shortname)
Q_PROPERTY(bool isCustom MEMBER isCustom)
};
Q_DECLARE_METATYPE(Emoji)

View File

@@ -58,6 +58,7 @@
#include "userlistmodel.h"
#include "webshortcutmodel.h"
#include "spellcheckhighlighter.h"
#include "customemojimodel.h"
#ifdef HAVE_COLORSCHEME
#include "colorschemer.h"
#endif
@@ -163,6 +164,7 @@ int main(int argc, char *argv[])
qmlRegisterType<RoomListModel>("org.kde.neochat", 1, 0, "RoomListModel");
qmlRegisterType<KWebShortcutModel>("org.kde.neochat", 1, 0, "WebShortcutModel");
qmlRegisterType<UserListModel>("org.kde.neochat", 1, 0, "UserListModel");
qmlRegisterType<CustomEmojiModel>("org.kde.neochat", 1, 0, "CustomEmojiModel");
qmlRegisterType<MessageEventModel>("org.kde.neochat", 1, 0, "MessageEventModel");
qmlRegisterType<MessageFilterModel>("org.kde.neochat", 1, 0, "MessageFilterModel");
qmlRegisterType<PublicRoomListModel>("org.kde.neochat", 1, 0, "PublicRoomListModel");

View File

@@ -302,7 +302,7 @@ QString NeoChatRoom::eventToString(const RoomEvent &evt, Qt::TextFormat format,
using namespace Quotient;
return visit(
evt,
[prettyPrint, removeReply](const RoomMessageEvent &e) {
[this, prettyPrint, removeReply](const RoomMessageEvent &e) {
using namespace MessageEventContent;
// 1. prettyPrint/HTML
@@ -314,6 +314,10 @@ QString NeoChatRoom::eventToString(const RoomEvent &evt, Qt::TextFormat format,
htmlBody.replace(utils::userPillRegExp, R"(<b class="user-pill">\1</b>)");
htmlBody.replace(utils::strikethroughRegExp, "<s>\\1</s>");
auto url = connection()->homeserver();
auto base = url.scheme() + QStringLiteral("://") + url.host() + (url.port() != -1 ? ':'+QString::number(url.port()) : QString());
htmlBody.replace(utils::mxcImageRegExp, QStringLiteral(R"(<img \1 src="%1/_matrix/media/r0/download/\2/\3" \4 > )").arg(base));
return htmlBody;
}
@@ -532,12 +536,14 @@ QString msgTypeToString(MessageEventType msgType)
}
}
QString NeoChatRoom::preprocessText(const QString& text)
{
return markdownToHTML(text);
}
void NeoChatRoom::postMessage(const QString &rawText, const QString &text, MessageEventType type, const QString &replyEventId, const QString &relateToEventId)
{
const auto html = markdownToHTML(text);
QString cleanText(text);
cleanText.replace(QRegularExpression("\\[(.+)\\]\\(.+\\)"), "\\1");
postHtmlMessage(rawText, html, type, replyEventId, relateToEventId);
postHtmlMessage(rawText, text, type, replyEventId, relateToEventId);
}
void NeoChatRoom::postHtmlMessage(const QString &text, const QString &html, MessageEventType type, const QString &replyEventId, const QString &relateToEventId)

View File

@@ -138,6 +138,8 @@ public Q_SLOTS:
void acceptInvitation();
void forget();
void sendTypingNotification(bool isTyping);
QString preprocessText(const QString &text);
/// @param rawText The text as it was typed.
/// @param cleanedText The text with link to the users.
void postMessage(const QString &rawText,

View File

@@ -64,7 +64,7 @@ void NotificationsManager::postNotification(NeoChatRoom *room,
std::unique_ptr<KNotificationReplyAction> replyAction(new KNotificationReplyAction(i18n("Reply")));
replyAction->setPlaceholderText(i18n("Reply..."));
QObject::connect(replyAction.get(), &KNotificationReplyAction::replied, [room, replyEventId](const QString &text) {
room->postMessage(text, text, RoomMessageEvent::MsgType::Text, replyEventId, QString());
room->postMessage(text, room->preprocessText(text), RoomMessageEvent::MsgType::Text, replyEventId, QString());
});
notification->setReplyAction(std::move(replyAction));
#endif

View File

@@ -22,4 +22,5 @@ static const QRegularExpression removeRichReplyRegex{"<mx-reply>.*?</mx-reply>",
static const QRegularExpression codePillRegExp{"<pre><code[^>]*>(.*?)</code></pre>", QRegularExpression::DotMatchesEverythingOption};
static const QRegularExpression userPillRegExp{"(<a href=\"https://matrix.to/#/@.*?:.*?\">.*?</a>)", QRegularExpression::DotMatchesEverythingOption};
static const QRegularExpression strikethroughRegExp{"<del>(.*?)</del>", QRegularExpression::DotMatchesEverythingOption};
static const QRegularExpression mxcImageRegExp{R"AAA(<img(.*?)src="mxc:\/\/(.*?)\/(.*?)"(.*?)>)AAA"};
} // namespace utils