Refactor input stuff

This is the start of a significant refactoring of everything related to sending messages, which is roughly:
- the chatbox
- action handling
- message sending on the c++ side
- autocompletion of users/rooms/emojis/commands/things i forgot

Notable changes so far include:
- ChatBox is now a ColumnLayout. As part of this, i removed the height animations for now. <del>as far as i can tell, they were broken anyway.</del> I'll readd them later
- Actions were refactored to live outside of the message sending function and are now each an object; it's mostly a wrapper around a function that is executed when the action is invoked
- Everything that used to live in ChatBoxHelper is now in NeoChatRoom; that means that the exact input status (text, message being replied to, message being edited, attachment) is now saved between room switching).
- To edit/reply an event, set `NeoChatRoom::chatBox{edit,reply}Id` to the desired event id, `NeoChatRoom::chatBox{reply,edit}{User,Message}` will then be updated automatically
- Attachments behave equivalently with `NeoChatRoom::chatBoxAttachmentPath`
- Error message reporting from ActionsHandler has been fixed (same fix as in !517) and moved to NeoChatRoom


Broken at the moment:
- [x] Any kind of autocompletion
- [x] Mentions
- [x] Fancy effects
- [x] sed-style edits
- [x] last-user-message edits and replies
- [x] Some of the actions, probably
- [x] Replies from notifications
- [x] Lots of keyboard shortcuts
- [x] Custom emojis
- [x] ChatBox height animations

TODO:
- [x] User / room mentions based on QTextCursors instead of the hack we currently use
- [x] Refactor autocompletion stuff
- [x] ???
- [x] Profit
This commit is contained in:
Tobias Fella
2022-10-10 23:10:00 +00:00
parent b2fa269515
commit 4bfd857093
38 changed files with 1579 additions and 1300 deletions

View File

@@ -30,8 +30,6 @@ add_library(neochat STATIC
filetypesingleton.cpp
login.cpp
stickerevent.cpp
chatboxhelper.cpp
commandmodel.cpp
webshortcutmodel.cpp
blurhash.cpp
blurhashimageprovider.cpp
@@ -40,6 +38,9 @@ add_library(neochat STATIC
urlhelper.cpp
windowcontroller.cpp
linkpreviewer.cpp
completionmodel.cpp
completionproxymodel.cpp
actionsmodel.cpp
)
add_executable(neochat-app
@@ -84,7 +85,7 @@ else()
endif()
target_include_directories(neochat PRIVATE ${CMAKE_BINARY_DIR})
target_link_libraries(neochat PUBLIC Qt::Core Qt::Quick Qt::Qml Qt::Gui Qt::Multimedia Qt::Network Qt::QuickControls2 KF5::I18n KF5::Kirigami2 KF5::Notifications KF5::ConfigCore KF5::ConfigGui KF5::CoreAddons Quotient cmark::cmark ${QTKEYCHAIN_LIBRARIES})
target_link_libraries(neochat PUBLIC Qt::Core Qt::Quick Qt::Qml Qt::Gui Qt::Multimedia Qt::Network Qt::QuickControls2 KF5::I18n KF5::Kirigami2 KF5::Notifications KF5::ConfigCore KF5::ConfigGui KF5::CoreAddons KF5::SonnetCore Quotient cmark::cmark ${QTKEYCHAIN_LIBRARIES})
if(TARGET QCoro5::Coro)
target_link_libraries(neochat PUBLIC QCoro5::Coro)
else()

View File

@@ -3,21 +3,49 @@
#include "actionshandler.h"
#include "controller.h"
#include <csapi/joining.h>
#include <events/roommemberevent.h>
#include <cmark.h>
#include <KLocalizedString>
#include <QStringBuilder>
#include "actionsmodel.h"
#include "controller.h"
#include "customemojimodel.h"
#include "neochatconfig.h"
#include "neochatroom.h"
#include "roommanager.h"
#include "neochatuser.h"
using namespace Quotient;
QString markdownToHTML(const QString &markdown)
{
const auto str = markdown.toUtf8();
char *tmp_buf = cmark_markdown_to_html(str.constData(), str.size(), CMARK_OPT_HARDBREAKS);
const std::string html(tmp_buf);
free(tmp_buf);
auto result = QString::fromStdString(html).trimmed();
result.replace("<!-- raw HTML omitted -->", "");
result.replace("<p>", "");
result.replace("</p>", "");
return result;
}
ActionsHandler::ActionsHandler(QObject *parent)
: QObject(parent)
{
}
ActionsHandler::~ActionsHandler(){};
NeoChatRoom *ActionsHandler::room() const
{
return m_room;
@@ -33,298 +61,106 @@ void ActionsHandler::setRoom(NeoChatRoom *room)
Q_EMIT roomChanged();
}
Connection *ActionsHandler::connection() const
void ActionsHandler::handleMessage()
{
return m_connection;
}
void ActionsHandler::setConnection(Connection *connection)
{
if (m_connection == connection) {
checkEffects();
if (!m_room->chatBoxAttachmentPath().isEmpty()) {
auto path = m_room->chatBoxAttachmentPath();
path = path.mid(path.lastIndexOf('/') + 1);
m_room->uploadFile(m_room->chatBoxAttachmentPath(), m_room->chatBoxText().isEmpty() ? path : m_room->chatBoxText());
m_room->setChatBoxAttachmentPath({});
m_room->setChatBoxText({});
return;
}
if (m_connection != nullptr) {
disconnect(m_connection, &Connection::directChatAvailable, nullptr, nullptr);
}
m_connection = connection;
if (m_connection != nullptr) {
connect(m_connection, &Connection::directChatAvailable, this, [this](Quotient::Room *room) {
room->setDisplayed(true);
RoomManager::instance().enterRoom(qobject_cast<NeoChatRoom *>(room));
});
}
Q_EMIT connectionChanged();
}
QString handledText = m_room->chatBoxText();
void ActionsHandler::postEdit(const QString &text)
{
const auto localId = Controller::instance().activeConnection()->userId();
for (auto it = m_room->messageEvents().crbegin(); it != m_room->messageEvents().crend(); ++it) {
const auto &evt = **it;
if (const auto event = eventCast<const RoomMessageEvent>(&evt)) {
if (event->senderId() == localId && event->hasTextContent()) {
static QRegularExpression re("^s/([^/]*)/([^/]*)(/g)?");
auto match = re.match(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(), nullptr);
std::sort(m_room->mentions()->begin(), m_room->mentions()->end(), [](const auto &a, const auto &b) -> bool {
return a.cursor.anchor() > b.cursor.anchor();
});
for (const auto &mention : *m_room->mentions()) {
handledText = handledText.replace(mention.cursor.anchor(),
mention.cursor.position() - mention.cursor.anchor(),
QStringLiteral("[%1](https://matrix.to/#/%2)").arg(mention.text, mention.id));
}
if (NeoChatConfig::allowQuickEdit()) {
QRegularExpression sed("^s/([^/]*)/([^/]*)(/g)?$");
auto match = sed.match(m_room->chatBoxText());
if (match.hasMatch()) {
const QString regex = match.captured(1);
const QString replacement = match.captured(2);
const QString flags = match.captured(3);
for (auto it = m_room->messageEvents().crbegin(); it != m_room->messageEvents().crend(); it++) {
if (const auto event = eventCast<const RoomMessageEvent>(&**it)) {
if (event->senderId() == m_room->localUser()->id() && event->hasTextContent()) {
QString originalString;
if (event->content()) {
originalString = static_cast<const Quotient::EventContent::TextContent *>(event->content())->body;
} else {
originalString = event->plainBody();
}
if (flags == "/g") {
m_room->postHtmlMessage(handledText, originalString.replace(regex, replacement), event->msgtype(), "", event->id());
} else {
m_room->postHtmlMessage(handledText,
originalString.replace(originalString.indexOf(regex), regex.size(), replacement),
event->msgtype(),
"",
event->id());
}
return;
}
}
const QString regex = match.captured(1);
const QString replacement = match.captured(2);
const QString flags = match.captured(3);
QString originalString;
if (event->content()) {
originalString = static_cast<const Quotient::EventContent::TextContent *>(event->content())->body;
} else {
originalString = event->plainBody();
}
if (flags == "/g") {
m_room->postHtmlMessage(text, originalString.replace(regex, replacement), event->msgtype(), "", event->id());
} else {
m_room->postHtmlMessage(text,
originalString.replace(originalString.indexOf(regex), regex.size(), replacement),
event->msgtype(),
"",
event->id());
}
return;
}
}
}
}
auto messageType = RoomMessageEvent::MsgType::Text;
void ActionsHandler::postMessage(const QString &text,
const QString &attachmentPath,
const QString &replyEventId,
const QString &editEventId,
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() + ")");
}
if (attachmentPath.length() > 0) {
m_room->uploadFile(attachmentPath, cleanedText);
}
if (cleanedText.length() == 0) {
return;
}
auto messageEventType = RoomMessageEvent::MsgType::Text;
// Message commands
static const QString shrugPrefix = QStringLiteral("/shrug");
static const QString lennyPrefix = QStringLiteral("/lenny");
static const QString tableflipPrefix = QStringLiteral("/tableflip");
static const QString unflipPrefix = QStringLiteral("/unflip");
// static const QString plainPrefix = QStringLiteral("/plain "); // TODO
// static const QString htmlPrefix = QStringLiteral("/html "); // TODO
static const QString rainbowPrefix = QStringLiteral("/rainbow ");
static const QString rainbowmePrefix = QStringLiteral("/rainbowme ");
static const QString spoilerPrefix = QStringLiteral("/spoiler ");
static const QString mePrefix = QStringLiteral("/me ");
static const QString noticePrefix = QStringLiteral("/notice ");
// Actions commands
// static const QString ddgPrefix = QStringLiteral("/ddg "); // TODO
// static const QString nickPrefix = QStringLiteral("/nick "); // TODO
// static const QString meroomnickPrefix = QStringLiteral("/myroomnick "); // TODO
// static const QString roomavatarPrefix = QStringLiteral("/roomavatar "); // TODO
// static const QString myroomavatarPrefix = QStringLiteral("/myroomavatar "); // TODO
// static const QString myavatarPrefix = QStringLiteral("/myavatar "); // TODO
static const QString invitePrefix = QStringLiteral("/invite ");
static const QString joinPrefix = QStringLiteral("/join ");
static const QString joinShortPrefix = QStringLiteral("/j ");
static const QString partPrefix = QStringLiteral("/part");
static const QString leavePrefix = QStringLiteral("/leave");
static const QString ignorePrefix = QStringLiteral("/ignore ");
static const QString unignorePrefix = QStringLiteral("/unignore ");
// static const QString queryPrefix = QStringLiteral("/query "); // TODO
// static const QString msgPrefix = QStringLiteral("/msg "); // TODO
static const QString reactPrefix = QStringLiteral("/react ");
// Admin commands
static QStringList rainbowColors{"#ff2b00", "#ff5500", "#ff8000", "#ffaa00", "#ffd500", "#ffff00", "#d4ff00", "#aaff00", "#80ff00",
"#55ff00", "#2bff00", "#00ff00", "#00ff2b", "#00ff55", "#00ff80", "#00ffaa", "#00ffd5", "#00ffff",
"#00d4ff", "#00aaff", "#007fff", "#0055ff", "#002bff", "#0000ff", "#2a00ff", "#5500ff", "#7f00ff",
"#aa00ff", "#d400ff", "#ff00ff", "#ff00d4", "#ff00aa", "#ff0080", "#ff0055", "#ff002b", "#ff0000"};
if (cleanedText.indexOf(shrugPrefix) == 0) {
cleanedText = QStringLiteral("¯\\_(ツ)_/¯") % cleanedText.remove(0, shrugPrefix.length());
m_room->postHtmlMessage(cleanedText, cleanedText, messageEventType, replyEventId, editEventId);
return;
}
if (cleanedText.indexOf(lennyPrefix) == 0) {
cleanedText = QStringLiteral("( ͡° ͜ʖ ͡°)") % cleanedText.remove(0, lennyPrefix.length());
m_room->postHtmlMessage(cleanedText, cleanedText, messageEventType, replyEventId, editEventId);
return;
}
if (cleanedText.indexOf(tableflipPrefix) == 0) {
cleanedText = QStringLiteral("(╯°□°)╯︵ ┻━┻") % cleanedText.remove(0, tableflipPrefix.length());
m_room->postHtmlMessage(cleanedText, cleanedText, messageEventType, replyEventId, editEventId);
return;
}
if (cleanedText.indexOf(unflipPrefix) == 0) {
cleanedText = QStringLiteral("┬──┬ ( ゜-゜ノ)") % cleanedText.remove(0, unflipPrefix.length());
m_room->postHtmlMessage(cleanedText, cleanedText, messageEventType, replyEventId, editEventId);
return;
}
if (cleanedText.indexOf(rainbowPrefix) == 0) {
cleanedText = cleanedText.remove(0, rainbowPrefix.length());
QString rainbowText;
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, preprocess(rainbowText), RoomMessageEvent::MsgType::Notice, replyEventId, editEventId);
return;
}
if (cleanedText.indexOf(spoilerPrefix) == 0) {
cleanedText = cleanedText.remove(0, spoilerPrefix.length());
const QStringList splittedText = rawText.split(" ");
QString spoilerHtml = QStringLiteral("<span data-mx-spoiler>") % preprocess(cleanedText) % QStringLiteral("</span>");
m_room->postHtmlMessage(cleanedText, spoilerHtml, RoomMessageEvent::MsgType::Notice, replyEventId, editEventId);
return;
}
if (cleanedText.indexOf(rainbowmePrefix) == 0) {
cleanedText = cleanedText.remove(0, rainbowmePrefix.length());
QString rainbowText;
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, preprocess(rainbowText), messageEventType, replyEventId, editEventId);
return;
}
if (rawText.indexOf(joinPrefix) == 0 || rawText.indexOf(joinShortPrefix) == 0) {
if (rawText.indexOf(joinPrefix) == 0) {
rawText = rawText.remove(0, joinPrefix.length());
} else {
rawText = rawText.remove(0, joinShortPrefix.length());
}
const QStringList splittedText = rawText.split(" ");
if (text.count() == 0) {
Q_EMIT showMessage(MessageType::Error, i18n("Invalid command"));
return;
}
if (splittedText.count() > 1) {
Controller::instance().joinRoom(splittedText[0] + ":" + splittedText[1]);
return;
} else if (splittedText[0].indexOf(":") != -1) {
Controller::instance().joinRoom(splittedText[0]);
return;
} else {
Controller::instance().joinRoom(splittedText[0] + ":matrix.org");
}
return;
}
if (rawText.indexOf(invitePrefix) == 0) {
rawText = rawText.remove(0, invitePrefix.length());
const QStringList splittedText = rawText.split(" ");
if (splittedText.count() == 0) {
Q_EMIT showMessage(MessageType::Error, i18n("Invalid command"));
return;
}
m_room->inviteToRoom(splittedText[0]);
return;
}
if (rawText.indexOf(partPrefix) == 0 || rawText.indexOf(leavePrefix) == 0) {
if (rawText.indexOf(partPrefix) == 0) {
rawText = rawText.remove(0, partPrefix.length());
} else {
rawText = rawText.remove(0, leavePrefix.length());
}
const QStringList splittedText = rawText.split(" ");
if (splittedText.count() == 0 || splittedText[0].isEmpty()) {
// leave current room
m_connection->leaveRoom(m_room);
return;
}
m_connection->leaveRoom(m_connection->room(splittedText[0]));
return;
}
if (rawText.indexOf(ignorePrefix) == 0) {
rawText = rawText.remove(0, ignorePrefix.length());
const QStringList splittedText = rawText.split(" ");
if (splittedText.count() == 0) {
Q_EMIT showMessage(MessageType::Error, i18n("Invalid command"));
return;
}
if (m_connection->users().contains(splittedText[0])) {
Q_EMIT showMessage(MessageType::Error, i18n("Invalid command"));
return;
}
const auto *user = m_connection->users()[splittedText[0]];
m_connection->addToIgnoredUsers(user);
return;
}
if (rawText.indexOf(unignorePrefix) == 0) {
rawText = rawText.remove(0, unignorePrefix.length());
const QStringList splittedText = rawText.split(" ");
if (splittedText.count() == 0) {
Q_EMIT showMessage(MessageType::Error, i18n("Invalid command"));
return;
}
if (m_connection->users().contains(splittedText[0])) {
Q_EMIT showMessage(MessageType::Error, i18n("Invalid command"));
return;
}
const auto *user = m_connection->users()[splittedText[0]];
m_connection->removeFromIgnoredUsers(user);
return;
}
if (rawText.indexOf(reactPrefix) == 0) {
rawText = rawText.remove(0, reactPrefix.length());
if (replyEventId.isEmpty()) {
for (auto it = m_room->messageEvents().crbegin(); it != m_room->messageEvents().crend(); ++it) {
const auto &evt = **it;
if (const auto event = eventCast<const RoomMessageEvent>(&evt)) {
m_room->toggleReaction(event->id(), rawText);
if (handledText.startsWith(QLatin1Char('/'))) {
for (const auto &action : ActionsModel::instance().allActions()) {
if (handledText.indexOf(action.prefix) == 1
&& (handledText.indexOf(" ") == action.prefix.length() + 1 || handledText.length() == action.prefix.length() + 1)) {
handledText = action.handle(handledText.mid(action.prefix.length() + 1).trimmed(), m_room);
if (action.messageType.has_value()) {
messageType = *action.messageType;
}
if (action.messageAction) {
break;
} else {
return;
}
}
Q_EMIT showMessage(MessageType::Error, i18n("Couldn't find a message to react to"));
return;
}
m_room->toggleReaction(replyEventId, rawText);
}
handledText = markdownToHTML(handledText);
handledText = CustomEmojiModel::instance().preprocessText(handledText);
if (handledText.length() == 0) {
return;
}
if (cleanedText.indexOf(mePrefix) == 0) {
cleanedText = cleanedText.remove(0, mePrefix.length());
messageEventType = RoomMessageEvent::MsgType::Emote;
rawText = rawText.remove(0, mePrefix.length());
} else if (cleanedText.indexOf(noticePrefix) == 0) {
cleanedText = cleanedText.remove(0, noticePrefix.length());
messageEventType = RoomMessageEvent::MsgType::Notice;
}
m_room->postMessage(rawText, preprocess(m_room->preprocessText(cleanedText)), messageEventType, replyEventId, editEventId);
m_room->postMessage(m_room->chatBoxText(), handledText, messageType, m_room->chatBoxReplyId(), m_room->chatBoxEditId());
}
void ActionsHandler::checkEffects()
{
std::optional<QString> effect = std::nullopt;
const auto &text = m_room->chatBoxText();
if (text.contains("\u2744")) {
effect = QLatin1String("snowflake");
} else if (text.contains("\u1F386")) {
effect = QLatin1String("fireworks");
} else if (text.contains("\u2F387")) {
effect = QLatin1String("fireworks");
} else if (text.contains("\u1F389")) {
effect = QLatin1String("confetti");
} else if (text.contains("\u1F38A")) {
effect = QLatin1String("confetti");
}
if (effect.has_value()) {
Q_EMIT showEffect(*effect);
}
}

View File

@@ -5,13 +5,11 @@
#include <QObject>
namespace Quotient
{
class Connection;
}
#include <events/roommessageevent.h>
class NeoChatRoom;
class CustomEmojiModel;
class NeoChatRoom;
/// \brief Handles user interactions with NeoChat (joining room, creating room,
/// sending message). Account management is handled by Controller.
@@ -19,56 +17,29 @@ class ActionsHandler : public QObject
{
Q_OBJECT
/// \brief The connection that will handle sending the message.
Q_PROPERTY(Quotient::Connection *connection READ connection WRITE setConnection NOTIFY connectionChanged)
/// \brief The connection that will handle sending the message.
/// \brief The room that messages will be sent to.
Q_PROPERTY(NeoChatRoom *room READ room WRITE setRoom NOTIFY roomChanged)
public:
enum MessageType {
Info,
Error,
};
Q_ENUM(MessageType);
explicit ActionsHandler(QObject *parent = nullptr);
~ActionsHandler();
[[nodiscard]] Quotient::Connection *connection() const;
void setConnection(Quotient::Connection *connection);
[[nodiscard]] NeoChatRoom *room() const;
void setRoom(NeoChatRoom *room);
Q_SIGNALS:
/// \brief Show error or information message.
///
/// These messages will be displayed in the room view header.
void showMessage(ActionsHandler::MessageType messageType, QString message);
void roomChanged();
void connectionChanged();
void showEffect(QString effect);
public Q_SLOTS:
/// \brief Post a message.
///
/// This also interprets commands if any.
void 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/)
///
/// This will automatically edit the last message posted and send the sed
/// instruction to IRC.
void postEdit(const QString &text);
void handleMessage();
private:
Quotient::Connection *m_connection = nullptr;
NeoChatRoom *m_room = nullptr;
void checkEffects();
};
QString markdownToHTML(const QString &markdown);

401
src/actionsmodel.cpp Normal file
View File

@@ -0,0 +1,401 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: LGPL-2.0-or-later
#include "actionsmodel.h"
#include "controller.h"
#include "neochatroom.h"
#include "neochatuser.h"
#include <events/roommemberevent.h>
#include <KLocalizedString>
using Action = ActionsModel::Action;
using namespace Quotient;
QStringList rainbowColors{"#ff2b00", "#ff5500", "#ff8000", "#ffaa00", "#ffd500", "#ffff00", "#d4ff00", "#aaff00", "#80ff00", "#55ff00", "#2bff00", "#00ff00",
"#00ff2b", "#00ff55", "#00ff80", "#00ffaa", "#00ffd5", "#00ffff", "#00d4ff", "#00aaff", "#007fff", "#0055ff", "#002bff", "#0000ff",
"#2a00ff", "#5500ff", "#7f00ff", "#aa00ff", "#d400ff", "#ff00ff", "#ff00d4", "#ff00aa", "#ff0080", "#ff0055", "#ff002b", "#ff0000"};
QVector<ActionsModel::Action> actions{
Action{
QStringLiteral("shrug"),
[](const QString &message, NeoChatRoom *) {
return QStringLiteral("¯\\\\_(ツ)_/¯ %1").arg(message);
},
true,
std::nullopt,
kli18n("<message>"),
kli18n("Prepends ¯\\_(ツ)_/¯ to a plain-text message"),
},
Action{
QStringLiteral("lenny"),
[](const QString &message, NeoChatRoom *) {
return QStringLiteral("( ͡° ͜ʖ ͡°) %1").arg(message);
},
true,
std::nullopt,
kli18n("<message>"),
kli18n("Prepends ( ͡° ͜ʖ ͡°) to a plain-text message"),
},
Action{
QStringLiteral("tableflip"),
[](const QString &message, NeoChatRoom *) {
return QStringLiteral("(╯°□°)╯︵ ┻━┻ %1").arg(message);
},
true,
std::nullopt,
kli18n("<message>"),
kli18n("Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message"),
},
Action{
QStringLiteral("unflip"),
[](const QString &message, NeoChatRoom *) {
return QStringLiteral("┬──┬ ( ゜-゜ノ) %1").arg(message);
},
true,
std::nullopt,
kli18n("<message>"),
kli18n("Prepends ┬──┬ ( ゜-゜ノ) to a plain-text message"),
},
Action{
QStringLiteral("rainbow"),
[](const QString &text, NeoChatRoom *room) {
QString rainbowText;
for (int i = 0; i < text.length(); i++) {
rainbowText += QStringLiteral("<font color='%2'>%3</font>").arg(rainbowColors[i % rainbowColors.length()], text.at(i));
}
// Ideally, we would just return rainbowText and let that do the rest, but the colors don't survive markdownToHTML.
room->postMessage(QStringLiteral("/rainbow %1").arg(text),
rainbowText,
RoomMessageEvent::MsgType::Text,
room->chatBoxReplyId(),
room->chatBoxEditId());
return QString();
},
false,
std::nullopt,
kli18n("<message>"),
kli18n("Sends the given message colored as a rainbow"),
},
Action{
QStringLiteral("rainbowme"),
[](const QString &text, NeoChatRoom *room) {
QString rainbowText;
for (int i = 0; i < text.length(); i++) {
rainbowText += QStringLiteral("<font color='%2'>%3</font>").arg(rainbowColors[i % rainbowColors.length()], text.at(i));
}
// Ideally, we would just return rainbowText and let that do the rest, but the colors don't survive markdownToHTML.
room->postMessage(QStringLiteral("/rainbow %1").arg(text),
rainbowText,
RoomMessageEvent::MsgType::Emote,
room->chatBoxReplyId(),
room->chatBoxEditId());
return QString();
},
false,
std::nullopt,
kli18n("<message>"),
kli18n("Sends the given emote colored as a rainbow"),
},
Action{
QStringLiteral("spoiler"),
[](const QString &text, NeoChatRoom *room) {
// Ideally, we would just return rainbowText and let that do the rest, but the colors don't survive markdownToHTML.
room->postMessage(QStringLiteral("/rainbow %1").arg(text),
QStringLiteral("<span data-mx-spoiler>%1</span>").arg(text),
RoomMessageEvent::MsgType::Text,
room->chatBoxReplyId(),
room->chatBoxEditId());
return QString();
},
false,
std::nullopt,
kli18n("<message>"),
kli18n("Sends the given message as a spoiler"),
},
Action{
QStringLiteral("me"),
[](const QString &text, NeoChatRoom *) {
return text;
},
true,
RoomMessageEvent::MsgType::Emote,
kli18n("<message>"),
kli18n("Sends the given emote"),
},
Action{
QStringLiteral("notice"),
[](const QString &text, NeoChatRoom *) {
return text;
},
true,
RoomMessageEvent::MsgType::Notice,
kli18n("<message>"),
kli18n("Sends the given message as a notice"),
},
Action{
QStringLiteral("invite"),
[](const QString &text, NeoChatRoom *room) {
static const QRegularExpression mxidRegex(
QStringLiteral(R"((^|[][[:space:](){}`'";])([!#@][-a-z0-9_=#/.]{1,252}:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?))"));
auto regexMatch = mxidRegex.match(text);
if (!regexMatch.hasMatch()) {
Q_EMIT room->showMessage(NeoChatRoom::Error, i18nc("'<text>' does not look like a matrix id.", "'%1' does not look like a matrix id.", text));
return QString();
}
#ifdef QUOTIENT_07
if (room->currentState().get<RoomMemberEvent>(text)->membership() == Membership::Invite) {
Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc("<user> is already invited to this room.", "%1 is already invited to this room.", text));
return QString();
}
if (room->currentState().get<RoomMemberEvent>(text)->membership() == Membership::Ban) {
Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc("<user> is banned from this room.", "%1 is banned from this room.", text));
return QString();
}
#endif
if (room->localUser()->id() == text) {
Q_EMIT room->showMessage(NeoChatRoom::Positive, i18n("You are already in this room."));
return QString();
}
if (room->users().contains(room->user(text))) {
Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc("<user> is already in this room.", "%1 is already in this room.", text));
return QString();
}
room->inviteToRoom(text);
Q_EMIT room->showMessage(NeoChatRoom::Positive, i18nc("<username> was invited into this room", "%1 was invited into this room", text));
return QString();
},
false,
std::nullopt,
kli18n("<user id>"),
kli18n("Invites the user to this room"),
},
Action{
QStringLiteral("join"),
[](const QString &text, NeoChatRoom *room) {
QRegularExpression roomRegex(QStringLiteral(R"(^[#!][^:]+:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?)"));
auto regexMatch = roomRegex.match(text);
if (!regexMatch.hasMatch()) {
Q_EMIT room->showMessage(NeoChatRoom::Error,
i18nc("'<text>' does not look like a room id or alias.", "'%1' does not look like a room id or alias.", text));
return QString();
}
if (Controller::instance().activeConnection()->room(text) || Controller::instance().activeConnection()->roomByAlias(text)) {
Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc("You are already in room <roomname>.", "You are already in room %1.", text));
return QString();
}
Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc("Joining room <roomname>.", "Joining room %1.", text));
Controller::instance().joinRoom(text);
return QString();
},
false,
std::nullopt,
kli18n("<room alias or id>"),
kli18n("Joins the given room"),
},
Action{
QStringLiteral("j"),
[](const QString &text, NeoChatRoom *room) {
QRegularExpression roomRegex(QStringLiteral(R"(^[#!][^:]+:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?)"));
auto regexMatch = roomRegex.match(text);
if (!regexMatch.hasMatch()) {
Q_EMIT room->showMessage(NeoChatRoom::Error,
i18nc("'<text>' does not look like a room id or alias.", "'%1' does not look like a room id or alias.", text));
return QString();
}
if (Controller::instance().activeConnection()->room(text) || Controller::instance().activeConnection()->roomByAlias(text)) {
Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc("You are already in room <roomname>.", "You are already in room %1.", text));
return QString();
}
Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc("Joining room <roomname>.", "Joining room %1.", text));
Controller::instance().joinRoom(text);
return QString();
},
false,
std::nullopt,
kli18n("<room alias or id>"),
kli18n("Joins the given room"),
},
Action{
QStringLiteral("part"),
[](const QString &text, NeoChatRoom *room) {
if (text.isEmpty()) {
Q_EMIT room->showMessage(NeoChatRoom::Info, i18n("Leaving this room."));
room->connection()->leaveRoom(room);
} else {
QRegularExpression roomRegex(QStringLiteral(R"(^[#!][^:]+:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?)"));
auto regexMatch = roomRegex.match(text);
if (!regexMatch.hasMatch()) {
Q_EMIT room->showMessage(NeoChatRoom::Error,
i18nc("'<text>' does not look like a room id or alias.", "'%1' does not look like a room id or alias.", text));
return QString();
}
auto leaving = room->connection()->room(text);
if (!leaving) {
leaving = room->connection()->roomByAlias(text);
}
if (leaving) {
Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc("Leaving room <roomname>.", "Leaving room %1.", text));
room->connection()->leaveRoom(leaving);
} else {
Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc("Room <roomname> not found", "Room %1 not found.", text));
}
}
return QString();
},
false,
std::nullopt,
kli18n("[<room alias or id>]"),
kli18n("Leaves the given room or this room, if there is none given"),
},
Action{
QStringLiteral("leave"),
[](const QString &text, NeoChatRoom *room) {
if (text.isEmpty()) {
Q_EMIT room->showMessage(NeoChatRoom::Info, i18n("Leaving this room."));
room->connection()->leaveRoom(room);
} else {
QRegularExpression roomRegex(QStringLiteral(R"(^[#!][^:]+:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?)"));
auto regexMatch = roomRegex.match(text);
if (!regexMatch.hasMatch()) {
Q_EMIT room->showMessage(NeoChatRoom::Error,
i18nc("'<text>' does not look like a room id or alias.", "'%1' does not look like a room id or alias.", text));
return QString();
}
auto leaving = room->connection()->room(text);
if (!leaving) {
leaving = room->connection()->roomByAlias(text);
}
if (leaving) {
Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc("Leaving room <roomname>.", "Leaving room %1.", text));
room->connection()->leaveRoom(leaving);
} else {
Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc("Room <roomname> not found", "Room %1 not found.", text));
}
}
return QString();
},
false,
std::nullopt,
kli18n("[<room alias or id>]"),
kli18n("Leaves the given room or this room, if there is none given"),
},
Action{
QStringLiteral("ignore"),
[](const QString &text, NeoChatRoom *room) {
static const QRegularExpression mxidRegex(
QStringLiteral(R"((^|[][[:space:](){}`'";])([!#@][-a-z0-9_=#/.]{1,252}:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?))"));
auto regexMatch = mxidRegex.match(text);
if (!regexMatch.hasMatch()) {
Q_EMIT room->showMessage(NeoChatRoom::Error, i18nc("'<text>' does not look like a matrix id.", "'%1' does not look like a matrix id.", text));
return QString();
}
auto user = room->connection()->users()[text];
if (room->connection()->ignoredUsers().contains(user->id())) {
Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc("<username> is already ignored.", "%1 is already ignored.", text));
return QString();
}
if (user) {
room->connection()->addToIgnoredUsers(user);
Q_EMIT room->showMessage(NeoChatRoom::Positive, i18nc("<username> is now ignored", "%1 is now ignored.", text));
} else {
Q_EMIT room->showMessage(NeoChatRoom::Error, i18nc("<username> is not a known user", "%1 is not a known user.", text));
}
return QString();
},
false,
std::nullopt,
kli18n("<user id>"),
kli18n("Ignores the given user"),
},
Action{
QStringLiteral("unignore"),
[](const QString &text, NeoChatRoom *room) {
static const QRegularExpression mxidRegex(
QStringLiteral(R"((^|[][[:space:](){}`'";])([!#@][-a-z0-9_=#/.]{1,252}:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?))"));
auto regexMatch = mxidRegex.match(text);
if (!regexMatch.hasMatch()) {
Q_EMIT room->showMessage(NeoChatRoom::Error, i18nc("'<text>' does not look like a matrix id.", "'%1' does not look like a matrix id.", text));
return QString();
}
auto user = room->connection()->users()[text];
if (user) {
if (!room->connection()->ignoredUsers().contains(user->id())) {
Q_EMIT room->showMessage(NeoChatRoom::Info, i18nc("<username> is not ignored.", "%1 is not ignored.", text));
return QString();
}
room->connection()->removeFromIgnoredUsers(user);
Q_EMIT room->showMessage(NeoChatRoom::Positive, i18nc("<username> is no longer ignored.", "%1 is no longer ignored.", text));
} else {
Q_EMIT room->showMessage(NeoChatRoom::Error, i18nc("<username> is not a known user", "%1 is not a known user.", text));
}
return QString();
},
false,
std::nullopt,
kli18n("<user id>"),
kli18n("Unignores the given user"),
},
Action{
QStringLiteral("react"),
[](const QString &text, NeoChatRoom *room) {
QString replyEventId = room->chatBoxReplyId();
if (replyEventId.isEmpty()) {
for (auto it = room->messageEvents().crbegin(); it != room->messageEvents().crend(); it++) {
const auto &evt = **it;
if (const auto event = eventCast<const RoomMessageEvent>(&evt)) {
room->toggleReaction(event->id(), text);
return QString();
}
}
}
room->toggleReaction(replyEventId, text);
return QString();
},
false,
std::nullopt,
kli18n("<reaction text>"),
kli18n("React to the message with the given text"),
},
};
int ActionsModel::rowCount(const QModelIndex &parent) const
{
Q_UNUSED(parent);
return actions.size();
}
QVariant ActionsModel::data(const QModelIndex &index, int role) const
{
if (index.row() < 0 || index.row() >= actions.size()) {
return {};
}
if (role == Prefix) {
return actions[index.row()].prefix;
}
if (role == Description) {
return actions[index.row()].description.toString();
}
if (role == CompletionType) {
return QStringLiteral("action");
}
if (role == Parameters) {
return actions[index.row()].parameters.toString();
}
return {};
}
QHash<int, QByteArray> ActionsModel::roleNames() const
{
return {
{Prefix, "prefix"},
{Description, "description"},
{CompletionType, "completionType"},
};
}
QVector<Action> &ActionsModel::allActions() const
{
return actions;
}

49
src/actionsmodel.h Normal file
View File

@@ -0,0 +1,49 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: LGPL-2.0-or-later
#pragma once
#include <KLazyLocalizedString>
#include <QAbstractListModel>
#include <events/roommessageevent.h>
class NeoChatRoom;
class ActionsModel : public QAbstractListModel
{
public:
struct Action {
// The prefix, without '/' and space after the word
QString prefix;
std::function<QString(const QString &, NeoChatRoom *)> handle;
// If this is true, this action transforms a message to a different message and it will be sent.
// If this is false, this message does some action on the client and should not be sent as a message.
bool messageAction;
// If this action changes the message type, this is the new message type. Otherwise it's nullopt
std::optional<Quotient::RoomMessageEvent::MsgType> messageType = std::nullopt;
KLazyLocalizedString parameters;
KLazyLocalizedString description;
};
static ActionsModel &instance()
{
static ActionsModel _instance;
return _instance;
}
enum Roles {
Prefix = Qt::DisplayRole,
Description,
CompletionType,
Parameters,
};
Q_ENUM(Roles);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
QHash<int, QByteArray> roleNames() const override;
QVector<Action> &allActions() const;
private:
ActionsModel() = default;
};

View File

@@ -1,75 +0,0 @@
// SPDX-FileCopyrightText: 2020 Carl Schwan <carlschwan@kde.org>
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QObject>
#include <QVariant>
/// Helper singleton for keeping the chatbar state in sync in the application.
class ChatBoxHelper : public QObject
{
Q_OBJECT
/// True, iff the user is currently editing one of their previous message.
Q_PROPERTY(bool isEditing READ isEditing NOTIFY isEditingChanged)
Q_PROPERTY(QString editEventId READ editEventId WRITE setEditEventId NOTIFY editEventIdChanged)
Q_PROPERTY(QString editContent READ editContent WRITE setEditContent NOTIFY editContentChanged)
Q_PROPERTY(bool isReplying READ isReplying NOTIFY isReplyingChanged)
Q_PROPERTY(QString replyEventId READ replyEventId WRITE setReplyEventId NOTIFY replyEventIdChanged)
Q_PROPERTY(QString replyEventContent READ replyEventContent WRITE setReplyEventContent NOTIFY replyEventContentChanged)
Q_PROPERTY(QVariant replyUser READ replyUser WRITE setReplyUser NOTIFY replyUserChanged)
Q_PROPERTY(QString attachmentPath READ attachmentPath WRITE setAttachmentPath NOTIFY attachmentPathChanged)
Q_PROPERTY(bool hasAttachment READ hasAttachment NOTIFY hasAttachmentChanged)
public:
ChatBoxHelper(QObject *parent = nullptr);
~ChatBoxHelper() = default;
bool isEditing() const;
QString editEventId() const;
QString editContent() const;
QString replyEventId() const;
QString replyEventContent() const;
QVariant replyUser() const;
bool isReplying() const;
QString attachmentPath() const;
bool hasAttachment() const;
void setEditEventId(const QString &editEventId);
void setEditContent(const QString &editContent);
void setReplyEventId(const QString &replyEventId);
void setReplyEventContent(const QString &replyEventContent);
void setAttachmentPath(const QString &attachmentPath);
void setReplyUser(const QVariant &replyUser);
Q_INVOKABLE void replyToMessage(const QString &replyEventid, const QString &replyEvent, const QVariant &replyUser);
Q_INVOKABLE void edit(const QString &message, const QString &formattedBody, const QString &eventId);
Q_INVOKABLE void clear();
Q_INVOKABLE void clearEditReply();
Q_INVOKABLE void clearAttachment();
Q_SIGNALS:
void isEditingChanged(bool isEditing);
void editEventIdChanged(const QString &editEventId);
void editContentChanged();
void replyEventIdChanged(const QString &replyEventId);
void replyEventContentChanged(const QString &replyEventContent);
void replyUserChanged();
void isReplyingChanged(bool isReplying);
void attachmentPathChanged(const QString &attachmentPath);
void hasAttachmentChanged(bool hasAttachment);
void editing(const QString &message, const QString &formattedBody);
void shouldClearText();
private:
QString m_editEventId;
QString m_editContent;
QString m_replyEventId;
QString m_replyEventContent;
QVariant m_replyUser;
QString m_attachmentPath;
};

View File

@@ -7,18 +7,138 @@
#include <QQmlFileSelector>
#include <QQuickTextDocument>
#include <QStringBuilder>
#include <QSyntaxHighlighter>
#include <QTextBlock>
#include <QTextDocument>
#include <Sonnet/BackgroundChecker>
#include <Sonnet/Settings>
#include "actionsmodel.h"
#include "completionmodel.h"
#include "neochatroom.h"
#include "roomlistmodel.h"
class SyntaxHighlighter : public QSyntaxHighlighter
{
public:
QTextCharFormat mentionFormat;
QTextCharFormat errorFormat;
Sonnet::BackgroundChecker *checker = new Sonnet::BackgroundChecker;
Sonnet::Settings settings;
QList<QPair<int, QString>> errors;
QString previousText;
SyntaxHighlighter(QObject *parent)
: QSyntaxHighlighter(parent)
{
mentionFormat.setFontWeight(QFont::Bold);
mentionFormat.setForeground(Qt::blue);
errorFormat.setForeground(Qt::red);
errorFormat.setUnderlineStyle(QTextCharFormat::SpellCheckUnderline);
connect(checker, &Sonnet::BackgroundChecker::misspelling, this, [this](const QString &word, int start) {
errors += {start, word};
rehighlight();
checker->continueChecking();
});
}
void highlightBlock(const QString &text) override
{
if (settings.checkerEnabledByDefault()) {
if (text != previousText) {
previousText = text;
checker->stop();
errors.clear();
checker->setText(text);
}
for (const auto &error : errors) {
setFormat(error.first, error.second.size(), errorFormat);
}
}
auto room = dynamic_cast<ChatDocumentHandler *>(parent())->room();
if (!room) {
return;
}
auto mentions = room->mentions();
mentions->erase(std::remove_if(mentions->begin(),
mentions->end(),
[this](auto &mention) {
if (document()->toPlainText().isEmpty()) {
return false;
}
if (mention.cursor.position() == 0 && mention.cursor.anchor() == 0) {
return true;
}
if (mention.cursor.position() - mention.cursor.anchor() != mention.text.size()) {
mention.cursor.setPosition(mention.start);
mention.cursor.setPosition(mention.cursor.anchor() + mention.text.size(), QTextCursor::KeepAnchor);
}
if (mention.cursor.selectedText() != mention.text) {
return true;
}
if (currentBlock() == mention.cursor.block()) {
mention.start = mention.cursor.anchor();
mention.position = mention.cursor.position();
setFormat(mention.cursor.selectionStart(), mention.cursor.selectedText().size(), mentionFormat);
}
return false;
}),
mentions->end());
}
};
ChatDocumentHandler::ChatDocumentHandler(QObject *parent)
: QObject(parent)
, m_document(nullptr)
, m_cursorPosition(-1)
, m_selectionStart(-1)
, m_selectionEnd(-1)
, m_highlighter(new SyntaxHighlighter(this))
, m_completionModel(new CompletionModel())
{
connect(this, &ChatDocumentHandler::roomChanged, this, [this]() {
m_completionModel->setRoom(m_room);
static NeoChatRoom *previousRoom = nullptr;
if (previousRoom) {
disconnect(nullptr, &NeoChatRoom::chatBoxTextChanged, this, nullptr);
}
previousRoom = m_room;
connect(m_room, &NeoChatRoom::chatBoxTextChanged, this, [this]() {
int start = completionStartIndex();
m_completionModel->setText(m_room->chatBoxText().mid(start, cursorPosition() - start), m_room->chatBoxText().mid(start));
});
});
connect(this, &ChatDocumentHandler::documentChanged, this, [this]() {
m_highlighter->setDocument(m_document->textDocument());
});
connect(this, &ChatDocumentHandler::cursorPositionChanged, this, [this]() {
if (!m_room) {
return;
}
int start = completionStartIndex();
m_completionModel->setText(m_room->chatBoxText().mid(start, cursorPosition() - start), m_room->chatBoxText().mid(start));
});
}
int ChatDocumentHandler::completionStartIndex() const
{
if (!m_room) {
return 0;
}
const auto &cursor = cursorPosition();
const auto &text = m_room->chatBoxText();
auto start = std::min(cursor, text.size()) - 1;
while (start > -1) {
if (text.at(start) == QLatin1Char(' ')) {
start++;
break;
}
start--;
}
return start;
}
QQuickTextDocument *ChatDocumentHandler::document() const
@@ -49,67 +169,12 @@ void ChatDocumentHandler::setCursorPosition(int position)
if (position == m_cursorPosition) {
return;
}
m_cursorPosition = position;
if (m_room) {
m_cursorPosition = position;
}
Q_EMIT cursorPositionChanged();
}
int ChatDocumentHandler::selectionStart() const
{
return m_selectionStart;
}
void ChatDocumentHandler::setSelectionStart(int position)
{
if (position == m_selectionStart) {
return;
}
m_selectionStart = position;
Q_EMIT selectionStartChanged();
}
int ChatDocumentHandler::selectionEnd() const
{
return m_selectionEnd;
}
void ChatDocumentHandler::setSelectionEnd(int position)
{
if (position == m_selectionEnd) {
return;
}
m_selectionEnd = position;
Q_EMIT selectionEndChanged();
}
QTextCursor ChatDocumentHandler::textCursor() const
{
QTextDocument *doc = textDocument();
if (!doc) {
return QTextCursor();
}
QTextCursor cursor = QTextCursor(doc);
if (m_selectionStart != m_selectionEnd) {
cursor.setPosition(m_selectionStart);
cursor.setPosition(m_selectionEnd, QTextCursor::KeepAnchor);
} else {
cursor.setPosition(m_cursorPosition);
}
return cursor;
}
QTextDocument *ChatDocumentHandler::textDocument() const
{
if (!m_document) {
return nullptr;
}
return m_document->textDocument();
}
NeoChatRoom *ChatDocumentHandler::room() const
{
return m_room;
@@ -125,91 +190,55 @@ void ChatDocumentHandler::setRoom(NeoChatRoom *room)
Q_EMIT roomChanged();
}
QVariantMap ChatDocumentHandler::getAutocompletionInfo(bool isAutocompleting)
void ChatDocumentHandler::complete(int index)
{
QTextCursor cursor = textCursor();
if (cursor.block().text() == m_lastState) {
// ignore change, it was caused by autocompletion
return QVariantMap{
{"type", AutoCompletionType::Ignore},
};
if (m_completionModel->autoCompletionType() == ChatDocumentHandler::User) {
auto name = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::Text).toString();
auto id = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::Subtitle).toString();
auto text = m_room->chatBoxText();
auto at = text.lastIndexOf(QLatin1Char('@'), cursorPosition() - 1);
QTextCursor cursor(document()->textDocument());
cursor.setPosition(at);
cursor.setPosition(cursorPosition(), QTextCursor::KeepAnchor);
cursor.insertText(name % " ");
cursor.setPosition(at);
cursor.setPosition(cursor.position() + name.size(), QTextCursor::KeepAnchor);
cursor.setKeepPositionOnInsert(true);
m_room->mentions()->push_back({cursor, name, 0, 0, id});
m_highlighter->rehighlight();
} else if (m_completionModel->autoCompletionType() == ChatDocumentHandler::Command) {
auto command = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::ReplacedText).toString();
auto text = m_room->chatBoxText();
auto at = text.lastIndexOf(QLatin1Char('/'));
QTextCursor cursor(document()->textDocument());
cursor.setPosition(at);
cursor.setPosition(cursorPosition(), QTextCursor::KeepAnchor);
cursor.insertText(QStringLiteral("/%1 ").arg(command));
} else if (m_completionModel->autoCompletionType() == ChatDocumentHandler::Room) {
auto alias = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::Subtitle).toString();
auto text = m_room->chatBoxText();
auto at = text.lastIndexOf(QLatin1Char('#'), cursorPosition() - 1);
QTextCursor cursor(document()->textDocument());
cursor.setPosition(at);
cursor.setPosition(cursorPosition(), QTextCursor::KeepAnchor);
cursor.insertText(alias % " ");
cursor.setPosition(at);
cursor.setPosition(cursor.position() + alias.size(), QTextCursor::KeepAnchor);
cursor.setKeepPositionOnInsert(true);
m_room->mentions()->push_back({cursor, alias, 0, 0, alias});
m_highlighter->rehighlight();
} else if (m_completionModel->autoCompletionType() == ChatDocumentHandler::Emoji) {
auto shortcode = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::Text).toString();
auto text = m_room->chatBoxText();
auto at = text.lastIndexOf(QLatin1Char(':'));
QTextCursor cursor(document()->textDocument());
cursor.setPosition(at);
cursor.setPosition(cursorPosition(), QTextCursor::KeepAnchor);
cursor.insertText(shortcode);
}
QString text = cursor.block().text();
QString textBeforeCursor = text;
textBeforeCursor.truncate(m_cursorPosition);
QString autoCompletePrefix = textBeforeCursor.section(" ", -1);
if (autoCompletePrefix.isEmpty()) {
return QVariantMap{
{"type", AutoCompletionType::None},
};
}
if (autoCompletePrefix.startsWith("@") || autoCompletePrefix.startsWith(":") || autoCompletePrefix.startsWith("/")) {
m_autoCompleteBeginPosition = textBeforeCursor.lastIndexOf(" ") + 1; // 1 == space
if (autoCompletePrefix.startsWith("@")) {
autoCompletePrefix.remove(0, 1);
return QVariantMap{
{"keyword", autoCompletePrefix},
{"type", AutoCompletionType::User},
};
}
if (autoCompletePrefix.startsWith("/") && text.trimmed().length() <= 1) {
return QVariantMap{
{"keyword", autoCompletePrefix},
{"type", AutoCompletionType::Command},
};
}
if (!isAutocompleting) {
return QVariantMap{
{"keyword", autoCompletePrefix},
{"type", AutoCompletionType::Emoji},
};
} else {
return QVariantMap{
{"type", AutoCompletionType::Ignore},
{"keyword", autoCompletePrefix},
};
}
}
return QVariantMap{
{"type", AutoCompletionType::None},
};
}
void ChatDocumentHandler::replaceAutoComplete(const QString &word)
CompletionModel *ChatDocumentHandler::completionModel() const
{
QTextCursor cursor = textCursor();
if (cursor.block().text() == m_lastState) {
m_document->textDocument()->undo();
}
cursor.beginEditBlock();
cursor.select(QTextCursor::WordUnderCursor);
cursor.removeSelectedText();
cursor.deletePreviousChar();
while (!cursor.atBlockStart()) {
cursor.movePosition(QTextCursor::PreviousCharacter, QTextCursor::KeepAnchor);
if (cursor.selectedText() == " ") {
cursor.movePosition(QTextCursor::NextCharacter);
break;
}
}
cursor.insertHtml(word);
// Add space after autocomplete if not already there
if (!cursor.block().text().endsWith(QStringLiteral(" "))) {
cursor.insertText(QStringLiteral(" "));
}
m_lastState = cursor.block().text();
cursor.endEditBlock();
return m_completionModel;
}

View File

@@ -4,30 +4,33 @@
#pragma once
#include <QObject>
#include <QTextCursor>
#include "userlistmodel.h"
class QTextDocument;
class QQuickTextDocument;
class NeoChatRoom;
class Controller;
class SyntaxHighlighter;
class CompletionModel;
class ChatDocumentHandler : public QObject
{
Q_OBJECT
Q_PROPERTY(QQuickTextDocument *document READ document WRITE setDocument NOTIFY documentChanged)
Q_PROPERTY(int cursorPosition READ cursorPosition WRITE setCursorPosition NOTIFY cursorPositionChanged)
Q_PROPERTY(int selectionStart READ selectionStart WRITE setSelectionStart NOTIFY selectionStartChanged)
Q_PROPERTY(int selectionEnd READ selectionEnd WRITE setSelectionEnd NOTIFY selectionEndChanged)
Q_PROPERTY(CompletionModel *completionModel READ completionModel NOTIFY completionModelChanged)
Q_PROPERTY(NeoChatRoom *room READ room WRITE setRoom NOTIFY roomChanged)
Q_PROPERTY(NeoChatRoom *room READ room NOTIFY roomChanged)
public:
enum AutoCompletionType {
User,
Room,
Emoji,
Command,
None,
Ignore,
};
Q_ENUM(AutoCompletionType)
@@ -39,44 +42,34 @@ public:
[[nodiscard]] int cursorPosition() const;
void setCursorPosition(int position);
[[nodiscard]] int selectionStart() const;
void setSelectionStart(int position);
[[nodiscard]] int selectionEnd() const;
void setSelectionEnd(int position);
[[nodiscard]] NeoChatRoom *room() const;
void setRoom(NeoChatRoom *room);
/// This function will look at the current QTextCursor and determine if there
/// is the possibility to autocomplete it.
Q_INVOKABLE QVariantMap getAutocompletionInfo(bool isAutocompleting);
Q_INVOKABLE void replaceAutoComplete(const QString &word);
Q_INVOKABLE void complete(int index);
void updateCompletions();
CompletionModel *completionModel() const;
Q_SIGNALS:
void documentChanged();
void cursorPositionChanged();
void selectionStartChanged();
void selectionEndChanged();
void roomChanged();
void joinRoom(QString roomName);
void completionModelChanged();
private:
[[nodiscard]] QTextCursor textCursor() const;
[[nodiscard]] QTextDocument *textDocument() const;
int completionStartIndex() const;
QQuickTextDocument *m_document;
NeoChatRoom *m_room;
NeoChatRoom *m_room = nullptr;
bool completionVisible = false;
int m_cursorPosition;
int m_selectionStart;
int m_selectionEnd;
int m_autoCompleteBeginPosition = -1;
int m_autoCompleteEndPosition = -1;
SyntaxHighlighter *m_highlighter = nullptr;
QString m_lastState;
AutoCompletionType m_completionType = None;
CompletionModel *m_completionModel = nullptr;
};
Q_DECLARE_METATYPE(ChatDocumentHandler::AutoCompletionType);

View File

@@ -4,12 +4,14 @@
#include "clipboard.h"
#include <QClipboard>
#include <QDateTime>
#include <QDir>
#include <QFileInfo>
#include <QGuiApplication>
#include <QImage>
#include <QMimeData>
#include <QRegularExpression>
#include <QStandardPaths>
#include <QUrl>
Clipboard::Clipboard(QObject *parent)
@@ -29,27 +31,31 @@ QImage Clipboard::image() const
return m_clipboard->image();
}
bool Clipboard::saveImage(const QUrl &localPath) const
QString Clipboard::saveImage(QString localPath) const
{
if (!localPath.isLocalFile()) {
return false;
if (localPath.isEmpty()) {
localPath = QStringLiteral("file://%1/screenshots/%2.png")
.arg(QStandardPaths::writableLocation(QStandardPaths::CacheLocation),
QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd-hh-mm-ss")));
}
QUrl url(localPath);
if (!url.isLocalFile()) {
return {};
}
auto image = this->image();
if (image.isNull()) {
return {};
}
auto i = image();
if (i.isNull()) {
return false;
}
QString path = QFileInfo(localPath.toLocalFile()).absolutePath();
QDir dir;
if (!dir.exists(path)) {
dir.mkpath(path);
if (!dir.exists(localPath)) {
dir.mkpath(localPath);
}
i.save(localPath.toLocalFile());
image.save(url.toLocalFile());
return true;
return localPath;
}
void Clipboard::saveText(QString message)

View File

@@ -23,7 +23,7 @@ public:
[[nodiscard]] bool hasImage() const;
[[nodiscard]] QImage image() const;
Q_INVOKABLE bool saveImage(const QUrl &localPath) const;
Q_INVOKABLE QString saveImage(QString localPath = {}) const;
Q_INVOKABLE void saveText(QString message);

187
src/completionmodel.cpp Normal file
View File

@@ -0,0 +1,187 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: LGPL-2.0-or-later
#include "completionmodel.h"
#include <QDebug>
#include "actionsmodel.h"
#include "chatdocumenthandler.h"
#include "completionproxymodel.h"
#include "customemojimodel.h"
#include "neochatroom.h"
#include "roomlistmodel.h"
#include "userlistmodel.h"
CompletionModel::CompletionModel(QObject *parent)
: QAbstractListModel(parent)
, m_filterModel(new CompletionProxyModel())
, m_userListModel(new UserListModel(this))
{
connect(this, &CompletionModel::textChanged, this, &CompletionModel::updateCompletion);
connect(this, &CompletionModel::roomChanged, this, [this]() {
m_userListModel->setRoom(m_room);
});
}
QString CompletionModel::text() const
{
return m_text;
}
void CompletionModel::setText(const QString &text, const QString &fullText)
{
m_text = text;
m_fullText = fullText;
Q_EMIT textChanged();
}
int CompletionModel::rowCount(const QModelIndex &parent) const
{
Q_UNUSED(parent);
if (m_autoCompletionType == ChatDocumentHandler::None) {
return 0;
}
return m_filterModel->rowCount();
}
QVariant CompletionModel::data(const QModelIndex &index, int role) const
{
if (index.row() < 0 || index.row() >= m_filterModel->rowCount()) {
return {};
}
auto filterIndex = m_filterModel->index(index.row(), 0);
if (m_autoCompletionType == ChatDocumentHandler::User) {
if (role == Text) {
return m_filterModel->data(filterIndex, UserListModel::NameRole);
}
if (role == Subtitle) {
return m_filterModel->data(filterIndex, UserListModel::UserIDRole);
}
if (role == Icon) {
return m_filterModel->data(filterIndex, UserListModel::AvatarRole);
}
}
if (m_autoCompletionType == ChatDocumentHandler::Command) {
if (role == Text) {
return m_filterModel->data(filterIndex, ActionsModel::Prefix).toString() + QStringLiteral(" ")
+ m_filterModel->data(filterIndex, ActionsModel::Parameters).toString();
}
if (role == Subtitle) {
return m_filterModel->data(filterIndex, ActionsModel::Description);
}
if (role == Icon) {
return QStringLiteral("invalid");
}
if (role == ReplacedText) {
return m_filterModel->data(filterIndex, ActionsModel::Prefix);
}
}
if (m_autoCompletionType == ChatDocumentHandler::Room) {
if (role == Text) {
return m_filterModel->data(filterIndex, RoomListModel::DisplayNameRole);
}
if (role == Subtitle) {
return m_filterModel->data(filterIndex, RoomListModel::CanonicalAliasRole);
}
if (role == Icon) {
return m_filterModel->data(filterIndex, RoomListModel::AvatarRole);
}
}
if (m_autoCompletionType == ChatDocumentHandler::Emoji) {
if (role == Text) {
return m_filterModel->data(filterIndex, CustomEmojiModel::Name);
}
if (role == Icon) {
return m_filterModel->data(filterIndex, CustomEmojiModel::MxcUrl);
}
}
return {};
}
QHash<int, QByteArray> CompletionModel::roleNames() const
{
return {
{Text, "text"},
{Subtitle, "subtitle"},
{Icon, "icon"},
{ReplacedText, "replacedText"},
};
}
void CompletionModel::updateCompletion()
{
if (text().startsWith(QLatin1Char('@'))) {
m_filterModel->setSourceModel(m_userListModel);
m_filterModel->setFilterRole(UserListModel::UserIDRole);
m_filterModel->setSecondaryFilterRole(UserListModel::NameRole);
m_filterModel->setFullText(m_fullText);
m_filterModel->setFilterText(m_text);
m_autoCompletionType = ChatDocumentHandler::User;
m_filterModel->invalidate();
} else if (text().startsWith(QLatin1Char('/'))) {
m_filterModel->setSourceModel(&ActionsModel::instance());
m_filterModel->setFilterRole(ActionsModel::Prefix);
m_filterModel->setSecondaryFilterRole(-1);
m_filterModel->setFullText(m_fullText);
m_filterModel->setFilterText(m_text.mid(1));
m_autoCompletionType = ChatDocumentHandler::Command;
m_filterModel->invalidate();
} else if (text().startsWith(QLatin1Char('#'))) {
m_autoCompletionType = ChatDocumentHandler::Room;
m_filterModel->setSourceModel(m_roomListModel);
m_filterModel->setFilterRole(RoomListModel::CanonicalAliasRole);
m_filterModel->setSecondaryFilterRole(RoomListModel::DisplayNameRole);
m_filterModel->setFullText(m_fullText);
m_filterModel->setFilterText(m_text);
m_filterModel->invalidate();
} else if (text().startsWith(QLatin1Char(':'))
&& (m_fullText.indexOf(QLatin1Char(':'), 1) == -1
|| (m_fullText.indexOf(QLatin1Char(' ')) != -1 && m_fullText.indexOf(QLatin1Char(':'), 1) > m_fullText.indexOf(QLatin1Char(' '), 1)))) {
m_autoCompletionType = ChatDocumentHandler::Emoji;
m_filterModel->setSourceModel(&CustomEmojiModel::instance());
m_filterModel->setFilterRole(CustomEmojiModel::Name);
m_filterModel->setSecondaryFilterRole(-1);
m_filterModel->setFullText(m_fullText);
m_filterModel->setFilterText(m_text);
m_filterModel->invalidate();
} else {
m_autoCompletionType = ChatDocumentHandler::None;
}
beginResetModel();
endResetModel();
}
NeoChatRoom *CompletionModel::room() const
{
return m_room;
}
void CompletionModel::setRoom(NeoChatRoom *room)
{
m_room = room;
Q_EMIT roomChanged();
}
ChatDocumentHandler::AutoCompletionType CompletionModel::autoCompletionType() const
{
return m_autoCompletionType;
}
void CompletionModel::setAutoCompletionType(ChatDocumentHandler::AutoCompletionType autoCompletionType)
{
m_autoCompletionType = autoCompletionType;
Q_EMIT autoCompletionTypeChanged();
}
RoomListModel *CompletionModel::roomListModel() const
{
return m_roomListModel;
}
void CompletionModel::setRoomListModel(RoomListModel *roomListModel)
{
m_roomListModel = roomListModel;
Q_EMIT roomListModelChanged();
}

67
src/completionmodel.h Normal file
View File

@@ -0,0 +1,67 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: LGPL-2.0-or-later
#pragma once
#include <QSortFilterProxyModel>
#include "chatdocumenthandler.h"
class CompletionProxyModel;
class UserListModel;
class NeoChatRoom;
class RoomListModel;
class CompletionModel : public QAbstractListModel
{
Q_OBJECT
Q_PROPERTY(QString text READ text NOTIFY textChanged)
Q_PROPERTY(NeoChatRoom *room READ room WRITE setRoom NOTIFY roomChanged)
Q_PROPERTY(ChatDocumentHandler::AutoCompletionType autoCompletionType READ autoCompletionType NOTIFY autoCompletionTypeChanged);
Q_PROPERTY(RoomListModel *roomListModel READ roomListModel WRITE setRoomListModel NOTIFY roomListModelChanged);
public:
enum Roles {
Text = Qt::DisplayRole,
Subtitle,
Icon,
ReplacedText,
};
Q_ENUM(Roles);
CompletionModel(QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
QHash<int, QByteArray> roleNames() const override;
QString text() const;
void setText(const QString &text, const QString &fullText);
void updateCompletion();
NeoChatRoom *room() const;
void setRoom(NeoChatRoom *room);
RoomListModel *roomListModel() const;
void setRoomListModel(RoomListModel *roomListModel);
ChatDocumentHandler::AutoCompletionType autoCompletionType() const;
Q_SIGNALS:
void textChanged();
void roomChanged();
void autoCompletionTypeChanged();
void roomListModelChanged();
private:
QString m_text;
QString m_fullText;
CompletionProxyModel *m_filterModel;
NeoChatRoom *m_room = nullptr;
ChatDocumentHandler::AutoCompletionType m_autoCompletionType = ChatDocumentHandler::None;
void setAutoCompletionType(ChatDocumentHandler::AutoCompletionType autoCompletionType);
UserListModel *m_userListModel;
RoomListModel *m_roomListModel;
};

View File

@@ -0,0 +1,46 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: LGPL-2.0-or-later
#include "completionproxymodel.h"
#include <QDebug>
#include "neochatroom.h"
bool CompletionProxyModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const
{
Q_UNUSED(sourceParent);
if (m_filterText.isEmpty()) {
return false;
}
return (sourceModel()->data(sourceModel()->index(sourceRow, 0), filterRole()).toString().startsWith(m_filterText)
&& !m_fullText.startsWith(sourceModel()->data(sourceModel()->index(sourceRow, 0), filterRole()).toString()))
|| (m_secondaryFilterRole != -1
&& sourceModel()->data(sourceModel()->index(sourceRow, 0), secondaryFilterRole()).toString().startsWith(m_filterText.midRef(1)));
}
int CompletionProxyModel::secondaryFilterRole() const
{
return m_secondaryFilterRole;
}
void CompletionProxyModel::setSecondaryFilterRole(int role)
{
m_secondaryFilterRole = role;
Q_EMIT secondaryFilterRoleChanged();
}
QString CompletionProxyModel::filterText() const
{
return m_filterText;
}
void CompletionProxyModel::setFilterText(const QString &filterText)
{
m_filterText = filterText;
Q_EMIT filterTextChanged();
}
void CompletionProxyModel::setFullText(const QString &fullText)
{
m_fullText = fullText;
}

View File

@@ -0,0 +1,33 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <fella@posteo.de>
// SPDX-License-Identifier: LGPL-2.0-or-later
#pragma once
#include <QSortFilterProxyModel>
class CompletionProxyModel : public QSortFilterProxyModel
{
Q_OBJECT
Q_PROPERTY(int secondaryFilterRole READ secondaryFilterRole WRITE setSecondaryFilterRole NOTIFY secondaryFilterRoleChanged)
Q_PROPERTY(QString filterText READ filterText WRITE setFilterText NOTIFY filterTextChanged)
public:
bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override;
int secondaryFilterRole() const;
void setSecondaryFilterRole(int role);
QString filterText() const;
void setFilterText(const QString &filterText);
void setFullText(const QString &fullText);
Q_SIGNALS:
void secondaryFilterRoleChanged();
void filterTextChanged();
private:
int m_secondaryFilterRole = -1;
QString m_filterText;
QString m_fullText;
};

View File

@@ -4,7 +4,9 @@
#include <csapi/account-data.h>
#include <csapi/content-repo.h>
#include "customemojimodel_p.h"
#include "controller.h"
#include "customemojimodel.h"
#include <connection.h>
#ifdef QUOTIENT_07
#define running isJobPending
@@ -12,35 +14,35 @@
#define running isJobRunning
#endif
void CustomEmojiModel::fetchEmojies()
void CustomEmojiModel::fetchEmojis()
{
if (d->conn == nullptr) {
if (!Controller::instance().activeConnection()) {
return;
}
const auto &data = d->conn->accountData("im.ponies.user_emotes");
const auto &data = Controller::instance().activeConnection()->accountData("im.ponies.user_emotes");
if (data == nullptr) {
return;
}
QJsonObject emojies = data->contentJson()["images"].toObject();
QJsonObject emojis = data->contentJson()["images"].toObject();
// TODO: Remove with stable migration
const auto legacyEmojies = data->contentJson()["emoticons"].toObject();
for (const auto &emoji : legacyEmojies.keys()) {
if (!emojies.contains(emoji)) {
emojies[emoji] = legacyEmojies[emoji];
const auto legacyEmojis = data->contentJson()["emoticons"].toObject();
for (const auto &emoji : legacyEmojis.keys()) {
if (!emojis.contains(emoji)) {
emojis[emoji] = legacyEmojis[emoji];
}
}
beginResetModel();
d->emojies.clear();
m_emojis.clear();
for (const auto &emoji : emojies.keys()) {
const auto &data = emojies[emoji];
for (const auto &emoji : emojis.keys()) {
const auto &data = emojis[emoji];
const auto e = emoji.startsWith(":") ? emoji : (QStringLiteral(":") + emoji + QStringLiteral(":"));
d->emojies << CustomEmoji{e, data.toObject()["url"].toString(), QRegularExpression(QStringLiteral(R"((^|[^\\]))") + e)};
m_emojis << CustomEmoji{e, data.toObject()["url"].toString(), QRegularExpression(QStringLiteral(R"((^|[^\\]))") + e)};
}
endResetModel();
@@ -50,11 +52,11 @@ void CustomEmojiModel::addEmoji(const QString &name, const QUrl &location)
{
using namespace Quotient;
auto job = d->conn->uploadFile(location.toLocalFile());
auto job = Controller::instance().activeConnection()->uploadFile(location.toLocalFile());
if (running(job)) {
connect(job, &BaseJob::success, this, [this, name, job] {
const auto &data = d->conn->accountData("im.ponies.user_emotes");
const auto &data = Controller::instance().activeConnection()->accountData("im.ponies.user_emotes");
auto json = data != nullptr ? data->contentJson() : QJsonObject();
auto emojiData = json["images"].toObject();
emojiData[QStringLiteral("%1").arg(name)] = QJsonObject({
@@ -65,7 +67,7 @@ void CustomEmojiModel::addEmoji(const QString &name, const QUrl &location)
#endif
});
json["images"] = emojiData;
d->conn->setAccountData("im.ponies.user_emotes", json);
Controller::instance().activeConnection()->setAccountData("im.ponies.user_emotes", json);
});
}
}
@@ -74,8 +76,8 @@ 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
const auto &data = Controller::instance().activeConnection()->accountData("im.ponies.user_emotes");
Q_ASSERT(data);
auto json = data->contentJson();
const QString _name = name.mid(1).chopped(1);
auto emojiData = json["images"].toObject();
@@ -97,5 +99,5 @@ void CustomEmojiModel::removeEmoji(const QString &name)
emojiData.remove(_name);
json["emoticons"] = emojiData;
}
d->conn->setAccountData("im.ponies.user_emotes", json);
Controller::instance().activeConnection()->setAccountData("im.ponies.user_emotes", json);
}

View File

@@ -1,48 +1,39 @@
// SPDX-FileCopyrightText: 2021 Carson Black <uhhadd@gmail.com>
// SPDX-License-Identifier: GPL-2.0-or-later
#include "customemojimodel_p.h"
#include "customemojimodel.h"
#include "controller.h"
#include "emojimodel.h"
#include <connection.h>
using namespace Quotient;
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)
connect(&Controller::instance(), &Controller::activeConnectionChanged, this, [this]() {
if (!Controller::instance().activeConnection()) {
return;
connect(d->conn, &Connection::accountDataChanged, this, [this](const QString &id) {
}
CustomEmojiModel::fetchEmojis();
disconnect(nullptr, &Connection::accountDataChanged, this, nullptr);
connect(Controller::instance().activeConnection(), &Connection::accountDataChanged, this, [this](const QString &id) {
if (id != QStringLiteral("im.ponies.user_emotes")) {
return;
}
fetchEmojies();
fetchEmojis();
});
});
}
CustomEmojiModel::~CustomEmojiModel()
{
}
QVariant CustomEmojiModel::data(const QModelIndex &idx, int role) const
{
const auto row = idx.row();
if (row >= d->emojies.length()) {
if (row >= m_emojis.length()) {
return QVariant();
}
const auto &data = d->emojies[row];
const auto &data = m_emojis[row];
switch (Roles(role)) {
case Roles::ModelData:
@@ -51,6 +42,8 @@ QVariant CustomEmojiModel::data(const QModelIndex &idx, int role) const
return data.name;
case Roles::ImageURL:
return QUrl(QStringLiteral("image://mxc/") + data.url.mid(6));
case Roles::MxcUrl:
return data.url.mid(6);
}
return QVariant();
@@ -60,7 +53,7 @@ int CustomEmojiModel::rowCount(const QModelIndex &parent) const
{
Q_UNUSED(parent)
return d->emojies.length();
return m_emojis.length();
}
QHash<int, QByteArray> CustomEmojiModel::roleNames() const
@@ -69,41 +62,25 @@ QHash<int, QByteArray> CustomEmojiModel::roleNames() const
{Name, "name"},
{ImageURL, "imageURL"},
{ModelData, "modelData"},
{MxcUrl, "mxcUrl"},
};
}
Connection *CustomEmojiModel::connection() const
QString CustomEmojiModel::preprocessText(const QString &text)
{
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 : std::as_const(d->emojies)) {
cp.replace(
auto handledText = text;
for (const auto &emoji : std::as_const(m_emojis)) {
handledText.replace(
emoji.regexp,
QStringLiteral(R"(<img data-mx-emoticon="" src="%1" alt="%2" title="%2" height="32" vertical-align="middle" />)").arg(emoji.url, emoji.name));
}
return cp;
return handledText;
}
QVariantList CustomEmojiModel::filterModel(const QString &filter)
{
QVariantList results;
for (const auto &emoji : std::as_const(d->emojies)) {
for (const auto &emoji : std::as_const(m_emojis)) {
if (results.length() >= 10)
break;
if (!emoji.name.contains(filter, Qt::CaseInsensitive))

View File

@@ -5,47 +5,46 @@
#include <QAbstractListModel>
#include <memory>
#include <QRegularExpression>
namespace Quotient
{
class Connection;
}
struct CustomEmoji {
QString name; // with :semicolons:
QString url; // mxc://
QRegularExpression regexp;
};
class CustomEmojiModel : public QAbstractListModel
{
Q_OBJECT
Q_PROPERTY(Quotient::Connection *connection READ connection WRITE setConnection NOTIFY connectionChanged)
public:
// constructors
enum Roles {
Name,
ImageURL,
ModelData, // for emulating the regular emoji model's usage, otherwise the UI code would get too complicated
MxcUrl,
};
Q_ENUM(Roles);
explicit CustomEmojiModel(QObject *parent = nullptr);
~CustomEmojiModel();
// model
static CustomEmojiModel &instance()
{
static CustomEmojiModel _instance;
return _instance;
}
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
Quotient::Connection *connection() const;
void setConnection(Quotient::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;
explicit CustomEmojiModel(QObject *parent = nullptr);
QList<CustomEmoji> m_emojis;
void fetchEmojies();
void fetchEmojis();
};

View File

@@ -39,11 +39,9 @@
#include "actionshandler.h"
#include "blurhashimageprovider.h"
#include "chatboxhelper.h"
#include "chatdocumenthandler.h"
#include "clipboard.h"
#include "collapsestateproxymodel.h"
#include "commandmodel.h"
#include "controller.h"
#include "customemojimodel.h"
#include "devicesmodel.h"
@@ -76,6 +74,8 @@
#ifdef HAVE_COLORSCHEME
#include "colorschemer.h"
#endif
#include "completionmodel.h"
#include "neochatuser.h"
#ifdef HAVE_RUNNER
#include "runner.h"
@@ -167,7 +167,6 @@ int main(int argc, char *argv[])
FileTypeSingleton fileTypeSingleton;
Login *login = new Login();
ChatBoxHelper chatBoxHelper;
UrlHelper urlHelper;
#ifdef HAVE_COLORSCHEME
@@ -187,20 +186,18 @@ int main(int argc, char *argv[])
qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "LoginHelper", login);
qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "UrlHelper", &urlHelper);
qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "EmojiModel", new EmojiModel(&app));
qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "CommandModel", new CommandModel(&app));
#ifdef QUOTIENT_07
qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "AccountRegistry", &Quotient::Accounts);
#else
qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "AccountRegistry", &Quotient::AccountRegistry::instance());
#endif
qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "SpaceHierarchyCache", &SpaceHierarchyCache::instance());
qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "CustomEmojiModel", &CustomEmojiModel::instance());
qmlRegisterType<ActionsHandler>("org.kde.neochat", 1, 0, "ActionsHandler");
qmlRegisterType<ChatBoxHelper>("org.kde.neochat", 1, 0, "ChatBoxHelper");
qmlRegisterType<ChatDocumentHandler>("org.kde.neochat", 1, 0, "ChatDocumentHandler");
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<CollapseStateProxyModel>("org.kde.neochat", 1, 0, "CollapseStateProxyModel");
qmlRegisterType<MessageFilterModel>("org.kde.neochat", 1, 0, "MessageFilterModel");
@@ -210,10 +207,12 @@ int main(int argc, char *argv[])
qmlRegisterType<SortFilterSpaceListModel>("org.kde.neochat", 1, 0, "SortFilterSpaceListModel");
qmlRegisterType<DevicesModel>("org.kde.neochat", 1, 0, "DevicesModel");
qmlRegisterType<LinkPreviewer>("org.kde.neochat", 1, 0, "LinkPreviewer");
qmlRegisterType<CompletionModel>("org.kde.neochat", 1, 0, "CompletionModel");
qmlRegisterUncreatableType<RoomMessageEvent>("org.kde.neochat", 1, 0, "RoomMessageEvent", "ENUM");
qmlRegisterUncreatableType<PushNotificationState>("org.kde.neochat", 1, 0, "PushNotificationState", "ENUM");
qmlRegisterUncreatableType<NeoChatRoomType>("org.kde.neochat", 1, 0, "NeoChatRoomType", "ENUM");
qmlRegisterUncreatableType<UserType>("org.kde.neochat", 1, 0, "UserType", "ENUM");
qmlRegisterUncreatableType<NeoChatUser>("org.kde.neochat", 1, 0, "NeoChatUser", {});
qRegisterMetaType<User *>("User*");
qRegisterMetaType<User *>("const User*");

View File

@@ -3,8 +3,6 @@
#include "neochatroom.h"
#include <cmark.h>
#include <QFileInfo>
#include <QMetaObject>
#include <QMimeDatabase>
@@ -642,24 +640,6 @@ void NeoChatRoom::removeLocalAlias(const QString &alias)
setLocalAliases(a);
}
QString NeoChatRoom::markdownToHTML(const QString &markdown)
{
const auto str = markdown.toUtf8();
char *tmp_buf = cmark_markdown_to_html(str.constData(), str.size(), CMARK_OPT_HARDBREAKS);
const std::string html(tmp_buf);
free(tmp_buf);
auto result = QString::fromStdString(html).trimmed();
result.replace("<!-- raw HTML omitted -->", "");
result.replace("<p>", "");
result.replace("</p>", "");
return result;
}
QString msgTypeToString(MessageEventType msgType)
{
switch (msgType) {
@@ -684,11 +664,6 @@ 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)
{
postHtmlMessage(rawText, text, type, replyEventId, relateToEventId);
@@ -1094,7 +1069,99 @@ void NeoChatRoom::reportEvent(const QString &eventId, const QString &reason)
auto job = connection()->callApi<ReportContentJob>(id(), eventId, -50, reason);
connect(job, &BaseJob::finished, this, [this, job]() {
if (job->error() == BaseJob::Success) {
Q_EMIT positiveMessage(i18n("Report sent successfully."));
Q_EMIT showMessage(Positive, i18n("Report sent successfully."));
Q_EMIT showMessage(MessageType::Positive, i18n("Report sent successfully."));
}
});
}
QString NeoChatRoom::chatBoxText() const
{
return m_chatBoxText;
}
void NeoChatRoom::setChatBoxText(const QString &text)
{
m_chatBoxText = text;
Q_EMIT chatBoxTextChanged();
}
QString NeoChatRoom::chatBoxReplyId() const
{
return m_chatBoxReplyId;
}
void NeoChatRoom::setChatBoxReplyId(const QString &replyId)
{
m_chatBoxReplyId = replyId;
Q_EMIT chatBoxReplyIdChanged();
}
QString NeoChatRoom::chatBoxEditId() const
{
return m_chatBoxEditId;
}
void NeoChatRoom::setChatBoxEditId(const QString &editId)
{
m_chatBoxEditId = editId;
Q_EMIT chatBoxEditIdChanged();
}
NeoChatUser *NeoChatRoom::chatBoxReplyUser() const
{
if (m_chatBoxReplyId.isEmpty()) {
return nullptr;
}
return static_cast<NeoChatUser *>(user((*findInTimeline(m_chatBoxReplyId))->senderId()));
}
QString NeoChatRoom::chatBoxReplyMessage() const
{
if (m_chatBoxReplyId.isEmpty()) {
return {};
}
return eventToString(*static_cast<const RoomMessageEvent *>(&**findInTimeline(m_chatBoxReplyId)));
}
NeoChatUser *NeoChatRoom::chatBoxEditUser() const
{
if (m_chatBoxEditId.isEmpty()) {
return nullptr;
}
return static_cast<NeoChatUser *>(user((*findInTimeline(m_chatBoxEditId))->senderId()));
}
QString NeoChatRoom::chatBoxEditMessage() const
{
if (m_chatBoxEditId.isEmpty()) {
return {};
}
return eventToString(*static_cast<const RoomMessageEvent *>(&**findInTimeline(m_chatBoxEditId)));
}
QString NeoChatRoom::chatBoxAttachmentPath() const
{
return m_chatBoxAttachmentPath;
}
void NeoChatRoom::setChatBoxAttachmentPath(const QString &attachmentPath)
{
m_chatBoxAttachmentPath = attachmentPath;
Q_EMIT chatBoxAttachmentPathChanged();
}
QVector<Mention> *NeoChatRoom::mentions()
{
return &m_mentions;
}
QString NeoChatRoom::savedText() const
{
return m_savedText;
}
void NeoChatRoom::setSavedText(const QString &savedText)
{
m_savedText = savedText;
}

View File

@@ -6,9 +6,12 @@
#include <room.h>
#include <QObject>
#include <events/roomevent.h>
#include <QTextCursor>
#include <qcoro/task.h>
class NeoChatUser;
class PushNotificationState : public QObject
{
Q_OBJECT
@@ -24,11 +27,20 @@ public:
Q_ENUM(State);
};
struct Mention {
QTextCursor cursor;
QString text;
int start = 0;
int position = 0;
QString id;
};
class NeoChatRoom : public Quotient::Room
{
Q_OBJECT
Q_PROPERTY(QVariantList usersTyping READ getUsersTyping NOTIFY typingChanged)
Q_PROPERTY(QString cachedInput MEMBER m_cachedInput NOTIFY cachedInputChanged)
Q_PROPERTY(bool hasFileUploading READ hasFileUploading WRITE setHasFileUploading NOTIFY hasFileUploadingChanged)
Q_PROPERTY(int fileUploadingProgress READ fileUploadingProgress NOTIFY fileUploadingProgressChanged)
Q_PROPERTY(QString avatarMediaId READ avatarMediaId NOTIFY avatarChanged STORED false)
@@ -40,7 +52,24 @@ class NeoChatRoom : public Quotient::Room
Q_PROPERTY(PushNotificationState::State pushNotificationState MEMBER m_currentPushNotificationState WRITE setPushNotificationState NOTIFY
pushNotificationStateChanged)
// Due to problems with QTextDocument, unlike the other properties here, chatBoxText is *not* used to store the text when switching rooms
Q_PROPERTY(QString chatBoxText READ chatBoxText WRITE setChatBoxText NOTIFY chatBoxTextChanged)
Q_PROPERTY(QString chatBoxReplyId READ chatBoxReplyId WRITE setChatBoxReplyId NOTIFY chatBoxReplyIdChanged)
Q_PROPERTY(QString chatBoxEditId READ chatBoxEditId WRITE setChatBoxEditId NOTIFY chatBoxEditIdChanged)
Q_PROPERTY(NeoChatUser *chatBoxReplyUser READ chatBoxReplyUser NOTIFY chatBoxReplyIdChanged)
Q_PROPERTY(QString chatBoxReplyMessage READ chatBoxReplyMessage NOTIFY chatBoxReplyIdChanged)
Q_PROPERTY(NeoChatUser *chatBoxEditUser READ chatBoxEditUser NOTIFY chatBoxEditIdChanged)
Q_PROPERTY(QString chatBoxEditMessage READ chatBoxEditMessage NOTIFY chatBoxEditIdChanged)
Q_PROPERTY(QString chatBoxAttachmentPath READ chatBoxAttachmentPath WRITE setChatBoxAttachmentPath NOTIFY chatBoxAttachmentPathChanged)
public:
enum MessageType {
Positive,
Info,
Error,
};
Q_ENUM(MessageType);
explicit NeoChatRoom(Quotient::Connection *connection, QString roomId, Quotient::JoinState joinState = {});
[[nodiscard]] QVariantList getUsersTyping() const;
@@ -137,6 +166,29 @@ public:
Q_INVOKABLE void setPushNotificationState(PushNotificationState::State state);
QString chatBoxText() const;
void setChatBoxText(const QString &text);
QString chatBoxReplyId() const;
void setChatBoxReplyId(const QString &replyId);
NeoChatUser *chatBoxReplyUser() const;
QString chatBoxReplyMessage() const;
QString chatBoxEditId() const;
void setChatBoxEditId(const QString &editId);
NeoChatUser *chatBoxEditUser() const;
QString chatBoxEditMessage() const;
QString chatBoxAttachmentPath() const;
void setChatBoxAttachmentPath(const QString &attachmentPath);
QVector<Mention> *mentions();
QString savedText() const;
void setSavedText(const QString &savedText);
#ifndef QUOTIENT_07
Q_INVOKABLE QString htmlSafeMemberName(const QString &userId) const
{
@@ -145,7 +197,6 @@ public:
#endif
private:
QString m_cachedInput;
QSet<const Quotient::RoomEvent *> highlights;
bool m_hasFileUploading = false;
@@ -160,10 +211,16 @@ private:
void onAddHistoricalTimelineEvents(rev_iter_t from) override;
void onRedaction(const Quotient::RoomEvent &prevEvent, const Quotient::RoomEvent &after) override;
static QString markdownToHTML(const QString &markdown);
QCoro::Task<void> doDeleteMessagesByUser(const QString &user);
QCoro::Task<void> doUploadFile(QUrl url, QString body = QString());
QString m_chatBoxText;
QString m_chatBoxReplyId;
QString m_chatBoxEditId;
QString m_chatBoxAttachmentPath;
QVector<Mention> m_mentions;
QString m_savedText;
private Q_SLOTS:
void countChanged();
void updatePushNotificationState(QString type);
@@ -179,14 +236,17 @@ Q_SIGNALS:
void isInviteChanged();
void displayNameChanged();
void pushNotificationStateChanged(PushNotificationState::State state);
void positiveMessage(const QString &message);
void showMessage(MessageType messageType, const QString &message);
void chatBoxTextChanged();
void chatBoxReplyIdChanged();
void chatBoxEditIdChanged();
void chatBoxAttachmentPathChanged();
public Q_SLOTS:
void uploadFile(const QUrl &url, const QString &body = QString());
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.

View File

@@ -16,6 +16,7 @@
#include <jobs/basejob.h>
#include <user.h>
#include "actionshandler.h"
#include "controller.h"
#include "neochatconfig.h"
#include "neochatroom.h"
@@ -73,7 +74,7 @@ void NotificationsManager::postNotification(NeoChatRoom *room,
std::unique_ptr<KNotificationReplyAction> replyAction(new KNotificationReplyAction(i18n("Reply")));
replyAction->setPlaceholderText(i18n("Reply..."));
connect(replyAction.get(), &KNotificationReplyAction::replied, this, [room, replyEventId](const QString &text) {
room->postMessage(text, room->preprocessText(text), RoomMessageEvent::MsgType::Text, replyEventId, QString());
room->postMessage(text, markdownToHTML(text), RoomMessageEvent::MsgType::Text, replyEventId, QString());
});
notification->setReplyAction(std::move(replyAction));
}

View File

@@ -337,6 +337,9 @@ QVariant RoomListModel::data(const QModelIndex &index, int role) const
if (role == AvatarRole) {
return room->avatarMediaId();
}
if (role == CanonicalAliasRole) {
return room->canonicalAlias();
}
if (role == TopicRole) {
return room->topic();
}
@@ -429,6 +432,7 @@ QHash<int, QByteArray> RoomListModel::roleNames() const
roles[NameRole] = "name";
roles[DisplayNameRole] = "displayName";
roles[AvatarRole] = "avatar";
roles[CanonicalAliasRole] = "canonicalAlias";
roles[TopicRole] = "topic";
roles[CategoryRole] = "category";
roles[UnreadCountRole] = "unreadCount";

View File

@@ -42,6 +42,7 @@ public:
NameRole = Qt::UserRole + 1,
DisplayNameRole,
AvatarRole,
CanonicalAliasRole,
TopicRole,
CategoryRole,
UnreadCountRole,

View File

@@ -10,6 +10,7 @@
#include <QDesktopServices>
#include <QStandardPaths>
#include <qt_connection_util.h>
#include <QQuickTextDocument>
#include <user.h>
#ifndef Q_OS_ANDROID
@@ -123,6 +124,12 @@ void RoomManager::openRoomForActiveConnection()
void RoomManager::enterRoom(NeoChatRoom *room)
{
if (m_chatDocumentHandler) {
// We're doing these things here because it is critical that they are switched at the same time
m_currentRoom->setSavedText(m_chatDocumentHandler->document()->textDocument()->toPlainText());
m_chatDocumentHandler->setRoom(room);
m_chatDocumentHandler->document()->textDocument()->setPlainText(room->savedText());
}
m_lastCurrentRoom = std::exchange(m_currentRoom, room);
Q_EMIT currentRoomChanged();
@@ -232,3 +239,15 @@ void RoomManager::leaveRoom(NeoChatRoom *room)
room->forget();
}
ChatDocumentHandler *RoomManager::chatDocumentHandler() const
{
return m_chatDocumentHandler;
}
void RoomManager::setChatDocumentHandler(ChatDocumentHandler *handler)
{
m_chatDocumentHandler = handler;
m_chatDocumentHandler->setRoom(m_currentRoom);
Q_EMIT chatDocumentHandlerChanged();
}

View File

@@ -8,6 +8,8 @@
#include <QObject>
#include <uriresolver.h>
#include "chatdocumenthandler.h"
class NeoChatRoom;
namespace Quotient
@@ -29,6 +31,7 @@ class RoomManager : public QObject, public UriResolverBase
/// This property holds whether a room is currently open in NeoChat.
/// \sa room
Q_PROPERTY(bool hasOpenRoom READ hasOpenRoom NOTIFY currentRoomChanged)
Q_PROPERTY(ChatDocumentHandler *chatDocumentHandler READ chatDocumentHandler WRITE setChatDocumentHandler NOTIFY chatDocumentHandlerChanged)
public:
explicit RoomManager(QObject *parent = nullptr);
@@ -64,6 +67,9 @@ public:
/// Call this when the current used connection is dropped.
Q_INVOKABLE void reset();
ChatDocumentHandler *chatDocumentHandler() const;
void setChatDocumentHandler(ChatDocumentHandler *handler);
void setUrlArgument(const QString &arg);
Q_SIGNALS:
@@ -98,6 +104,8 @@ Q_SIGNALS:
/// Displays warning to the user.
void warning(const QString &title, const QString &message);
void chatDocumentHandlerChanged();
private:
void openRoomForActiveConnection();
@@ -106,4 +114,5 @@ private:
QString m_arg;
KConfig m_config;
KConfigGroup m_lastRoomConfig;
ChatDocumentHandler *m_chatDocumentHandler;
};

View File

@@ -34,7 +34,7 @@ class UserListModel : public QAbstractListModel
Q_PROPERTY(Quotient::Room *room READ room WRITE setRoom NOTIFY roomChanged)
public:
enum EventRoles {
NameRole = Qt::UserRole + 1,
NameRole = Qt::DisplayRole,
UserIDRole,
AvatarRole,
ObjectRole,