Move NeoChatConnection and NeoChatRoom to LibNeoChat

Move `NeoChatConnection` and `NeoChatRoom` to `LibNeoChat` along with any required dependencies.
This commit is contained in:
James Graham
2025-04-07 18:52:15 +00:00
parent 8327b4369e
commit aef4f75c33
54 changed files with 57 additions and 72 deletions

View File

@@ -4,7 +4,30 @@
add_library(LibNeoChat STATIC)
target_sources(LibNeoChat PRIVATE
neochatconnection.cpp
neochatroom.cpp
neochatroommember.cpp
chatbarcache.cpp
clipboard.cpp
delegatesizehelper.cpp
emojitones.cpp
eventhandler.cpp
filetransferpseudojob.cpp
linkpreviewer.cpp
roomlastmessageprovider.cpp
spacehierarchycache.cpp
texthandler.cpp
urlhelper.cpp
utils.cpp
enums/messagecomponenttype.h
enums/messagetype.h
enums/powerlevel.cpp
enums/pushrule.h
events/imagepackevent.cpp
events/pollevent.cpp
models/actionsmodel.cpp
models/customemojimodel.cpp
models/emojimodel.cpp
)
ecm_add_qml_module(LibNeoChat GENERATE_PLUGIN_SOURCE
@@ -12,8 +35,33 @@ ecm_add_qml_module(LibNeoChat GENERATE_PLUGIN_SOURCE
OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/src/org/kde/neochat/libneochat
)
ecm_qt_declare_logging_category(LibNeoChat
HEADER "eventhandler_logging.h"
IDENTIFIER "EventHandling"
CATEGORY_NAME "org.kde.neochat.eventhandler"
DEFAULT_SEVERITY Info
)
generate_export_header(LibNeoChat BASE_NAME LibNeoChat)
target_include_directories(LibNeoChat PRIVATE ${CMAKE_BINARY_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/enums ${CMAKE_CURRENT_SOURCE_DIR}/events ${CMAKE_CURRENT_SOURCE_DIR}/models)
target_link_libraries(LibNeoChat PUBLIC
Qt::Core
Qt::Multimedia
Qt::Quick
KF6::ConfigCore
KF6::CoreAddons
KF6::I18n
KF6::Kirigami
QuotientQt6
cmark::cmark
QCoro::Core
QCoro::Network
)
if(NOT ANDROID)
target_link_libraries(LibNeoChat PUBLIC
KF6::KIOWidgets
ICU::uc
)
target_compile_definitions(LibNeoChat PUBLIC -DHAVE_ICU)
endif()

View File

@@ -0,0 +1,282 @@
// SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#include "chatbarcache.h"
#include <Quotient/roommember.h>
#include "eventhandler.h"
#include "models/actionsmodel.h"
#include "neochatroom.h"
#include "texthandler.h"
using namespace Qt::StringLiterals;
ChatBarCache::ChatBarCache(QObject *parent)
: QObject(parent)
{
}
QString ChatBarCache::text() const
{
return m_text;
}
void ChatBarCache::setText(const QString &text)
{
if (text == m_text) {
return;
}
m_text = text;
Q_EMIT textChanged();
}
QString ChatBarCache::sendText() const
{
if (!attachmentPath().isEmpty()) {
QUrl url(attachmentPath());
auto path = url.isLocalFile() ? url.toLocalFile() : url.toString();
return text().isEmpty() ? path.mid(path.lastIndexOf(u'/') + 1) : text();
}
return formatMentions();
}
QString ChatBarCache::formatMentions() const
{
auto mentions = m_mentions;
std::sort(mentions.begin(), mentions.end(), [](const auto &a, const auto &b) {
return a.cursor.anchor() > b.cursor.anchor();
});
auto formattedText = text();
for (const auto &mention : mentions) {
if (mention.text.isEmpty() || mention.id.isEmpty()) {
continue;
}
formattedText = formattedText.replace(mention.cursor.anchor(),
mention.cursor.position() - mention.cursor.anchor(),
u"[%1](https://matrix.to/#/%2)"_s.arg(mention.text.toHtmlEscaped(), mention.id));
}
return formattedText;
}
bool ChatBarCache::isReplying() const
{
return m_relationType == Reply && !m_relationId.isEmpty();
}
QString ChatBarCache::replyId() const
{
if (m_relationType != Reply) {
return {};
}
return m_relationId;
}
void ChatBarCache::setReplyId(const QString &replyId)
{
if (m_relationType == Reply && m_relationId == replyId) {
return;
}
const auto oldEventId = std::exchange(m_relationId, replyId);
if (m_relationId.isEmpty()) {
m_relationType = None;
} else {
m_relationType = Reply;
}
m_attachmentPath = QString();
Q_EMIT relationIdChanged(oldEventId, m_relationId);
Q_EMIT attachmentPathChanged();
}
bool ChatBarCache::isEditing() const
{
return m_relationType == Edit && !m_relationId.isEmpty();
}
QString ChatBarCache::editId() const
{
if (m_relationType != Edit) {
return {};
}
return m_relationId;
}
void ChatBarCache::setEditId(const QString &editId)
{
if (m_relationType == Edit && m_relationId == editId) {
return;
}
const auto oldEventId = std::exchange(m_relationId, editId);
if (m_relationId.isEmpty()) {
m_relationType = None;
} else {
m_relationType = Edit;
}
m_attachmentPath = QString();
Q_EMIT relationIdChanged(oldEventId, m_relationId);
Q_EMIT attachmentPathChanged();
}
Quotient::RoomMember ChatBarCache::relationAuthor() const
{
if (parent() == nullptr) {
qWarning() << "ChatBarCache created with no parent, a NeoChatRoom must be set as the parent on creation.";
return {};
}
auto room = dynamic_cast<NeoChatRoom *>(parent());
if (room == nullptr) {
qWarning() << "ChatBarCache created with incorrect parent, a NeoChatRoom must be set as the parent on creation.";
return {};
}
if (m_relationId.isEmpty()) {
return room->member(QString());
}
return room->member((*room->findInTimeline(m_relationId))->senderId());
}
QString ChatBarCache::relationMessage() const
{
if (parent() == nullptr) {
qWarning() << "ChatBarCache created with no parent, a NeoChatRoom must be set as the parent on creation.";
return {};
}
if (m_relationId.isEmpty()) {
return {};
}
auto room = dynamic_cast<NeoChatRoom *>(parent());
if (room == nullptr) {
qWarning() << "ChatBarCache created with incorrect parent, a NeoChatRoom must be set as the parent on creation.";
return {};
}
if (auto event = room->findInTimeline(m_relationId); event != room->historyEdge()) {
return EventHandler::markdownBody(&**event);
}
return {};
}
bool ChatBarCache::isThreaded() const
{
return !m_threadId.isEmpty();
}
QString ChatBarCache::threadId() const
{
return m_threadId;
}
void ChatBarCache::setThreadId(const QString &threadId)
{
if (m_threadId == threadId) {
return;
}
const auto oldThreadId = std::exchange(m_threadId, threadId);
Q_EMIT threadIdChanged(oldThreadId, m_threadId);
}
QString ChatBarCache::attachmentPath() const
{
return m_attachmentPath;
}
void ChatBarCache::setAttachmentPath(const QString &attachmentPath)
{
if (attachmentPath == m_attachmentPath) {
return;
}
m_attachmentPath = attachmentPath;
Q_EMIT attachmentPathChanged();
#if (Quotient_VERSION_MINOR < 10 && Quotient_VERSION_PATCH < 3) || Quotient_VERSION_MINOR < 9
m_relationType = None;
const auto oldEventId = std::exchange(m_relationId, QString());
Q_EMIT relationIdChanged(oldEventId, m_relationId);
#endif
}
void ChatBarCache::clearRelations()
{
const auto oldEventId = std::exchange(m_relationId, QString());
const auto oldThreadId = std::exchange(m_threadId, QString());
m_attachmentPath = QString();
Q_EMIT relationIdChanged(oldEventId, m_relationId);
Q_EMIT threadIdChanged(oldThreadId, m_threadId);
Q_EMIT attachmentPathChanged();
}
QList<Mention> *ChatBarCache::mentions()
{
return &m_mentions;
}
QString ChatBarCache::savedText() const
{
return m_savedText;
}
void ChatBarCache::setSavedText(const QString &savedText)
{
m_savedText = savedText;
}
void ChatBarCache::postMessage()
{
auto room = dynamic_cast<NeoChatRoom *>(parent());
if (room == nullptr) {
qWarning() << "ChatBarCache created with incorrect parent, a NeoChatRoom must be set as the parent on creation.";
return;
}
bool isReply = !replyId().isEmpty();
std::optional<Quotient::EventRelation> relatesTo = std::nullopt;
if (!threadId().isEmpty()) {
relatesTo = Quotient::EventRelation::replyInThread(threadId(), !isReply, isReply ? replyId() : threadId());
} else if (!editId().isEmpty()) {
relatesTo = Quotient::EventRelation::replace(editId());
} else if (isReply) {
relatesTo = Quotient::EventRelation::replyTo(replyId());
}
if (!attachmentPath().isEmpty()) {
room->uploadFile(QUrl(attachmentPath()), sendText(), relatesTo);
clearCache();
return;
}
const auto result = ActionsModel::handleAction(room, this);
if (!result.second.has_value()) {
return;
}
TextHandler textHandler;
textHandler.setData(*std::get<std::optional<QString>>(result));
const auto sendText = textHandler.handleSendText();
if (sendText.length() == 0) {
return;
}
const auto replyIt = room->findInTimeline(replyId());
if (replyIt == room->historyEdge()) {
isReply = false;
}
auto content = std::make_unique<Quotient::EventContent::TextContent>(sendText, u"text/html"_s);
room->post<Quotient::RoomMessageEvent>(text(), *std::get<std::optional<Quotient::RoomMessageEvent::MsgType>>(result), std::move(content), relatesTo);
clearCache();
}
void ChatBarCache::clearCache()
{
setText({});
m_mentions.clear();
m_savedText = QString();
clearRelations();
}
#include "moc_chatbarcache.cpp"

View File

@@ -0,0 +1,211 @@
// SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#pragma once
#include <QObject>
#include <QQmlEngine>
#include <QQuickTextDocument>
#include <QTextCursor>
namespace Quotient
{
class RoomMember;
}
/**
* @brief Defines a user mention in the current chat or edit text.
*/
struct Mention {
QTextCursor cursor; /**< Contains the mention's text and position in the text. */
QString text; /**< The inserted text of the mention. */
int start = 0; /**< Start position of the mention. */
int position = 0; /**< End position of the mention. */
QString id; /**< The id the mention (used to create link when sending the message). */
};
/**
* @class ChatBarCache
*
* A class to cache data from a chat bar.
*
* A chat bar can be anything that allows users to compose or edit message, it doesn't
* necessarily have to use the ChatBar component, e.g. ChatBarComponent.
*
* This object is intended to allow the current contents of a chat bar to be cached
* between different rooms, i.e. there is an expectation that each NeoChatRoom could
* have a separate cache for each chat bar.
*
* @note The NeoChatRoom which this component is created in is expected to be set
* as it's parent. This is necessary for certain functions which need to get
* relevant room information.
*
* @sa ChatBar, ChatBarComponent, NeoChatRoom
*/
class ChatBarCache : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("")
/**
* @brief The text in the chat bar.
*
* Due to problems with QTextDocument, unlike the other properties here,
* text is *not* used to store the text when switching rooms.
*/
Q_PROPERTY(QString text READ text WRITE setText NOTIFY textChanged)
/**
* @brief Whether the chat bar is currently replying to a message.
*/
Q_PROPERTY(bool isReplying READ isReplying NOTIFY relationIdChanged)
/**
* @brief The Matrix message ID of an event being replied to, if any.
*
* Will return empty if the RelationType is currently set to None or Edit.
*
* @note Replying, editing and attachments are exclusive so setting this will
* clear an edit or attachment.
*
* @sa RelationType
*/
Q_PROPERTY(QString replyId READ replyId WRITE setReplyId NOTIFY relationIdChanged)
/**
* @brief Whether the chat bar is currently editing a message.
*/
Q_PROPERTY(bool isEditing READ isEditing NOTIFY relationIdChanged)
/**
* @brief The Matrix message ID of an event being edited, if any.
*
* Will return empty if the RelationType is currently set to None or Reply.
*
* @note Replying, editing and attachments are exclusive so setting this will
* clear an reply or attachment.
*
* @sa RelationType
*/
Q_PROPERTY(QString editId READ editId WRITE setEditId NOTIFY relationIdChanged)
/**
* @brief Get the RoomMember object for the message being replied to.
*
* Returns an empty RoomMember if not replying to a message.
*
* @sa Quotient::RoomMember
*/
Q_PROPERTY(Quotient::RoomMember relationAuthor READ relationAuthor NOTIFY relationIdChanged)
/**
* @brief The content of the related message.
*
* Will be QString() if no related message.
*/
Q_PROPERTY(QString relationMessage READ relationMessage NOTIFY relationIdChanged)
/**
* @brief Whether the chat bar is replying in a thread.
*/
Q_PROPERTY(bool isThreaded READ isThreaded NOTIFY threadIdChanged)
/**
* @brief The Matrix message ID of thread root event, if any.
*/
Q_PROPERTY(QString threadId READ threadId WRITE setThreadId NOTIFY threadIdChanged)
/**
* @brief The local path for a file to send, if any.
*
* @note Replying, editing and attachments are exclusive so setting this will
* clear an edit or reply.
*/
Q_PROPERTY(QString attachmentPath READ attachmentPath WRITE setAttachmentPath NOTIFY attachmentPathChanged)
public:
/**
* @brief Describes the type of relation which relationId can refer to.
*
* A chat bar can only be relating to a single message at a time making these
* exclusive.
*/
enum RelationType {
Reply, /**< The current relation is a message being replied to. */
Edit, /**< The current relation is a message being edited. */
None, /**< There is currently no relation event */
};
Q_ENUM(RelationType)
explicit ChatBarCache(QObject *parent = nullptr);
QString text() const;
QString sendText() const;
void setText(const QString &text);
bool isReplying() const;
QString replyId() const;
void setReplyId(const QString &replyId);
bool isEditing() const;
QString editId() const;
void setEditId(const QString &editId);
Quotient::RoomMember relationAuthor() const;
QString relationMessage() const;
bool isThreaded() const;
QString threadId() const;
void setThreadId(const QString &threadId);
QString attachmentPath() const;
void setAttachmentPath(const QString &attachmentPath);
/**
* @brief Clear all relations in the cache.
*
* This includes relation ID, thread root ID and attachment path.
*/
Q_INVOKABLE void clearRelations();
/**
* @brief Retrieve the mentions for the current chat bar text.
*/
QList<Mention> *mentions();
/**
* @brief Get the saved chat bar text.
*/
QString savedText() const;
/**
* @brief Save the chat bar text.
*/
void setSavedText(const QString &savedText);
/**
* @brief Post the contents of the cache as a message in the room.
*/
Q_INVOKABLE void postMessage();
Q_SIGNALS:
void textChanged();
void relationIdChanged(const QString &oldEventId, const QString &newEventId);
void threadIdChanged(const QString &oldThreadId, const QString &newThreadId);
void attachmentPathChanged();
private:
QString m_text = QString();
QString formatMentions() const;
QString m_relationId = QString();
RelationType m_relationType = RelationType::None;
QString m_threadId = QString();
QString m_attachmentPath = QString();
QList<Mention> m_mentions;
QString m_savedText;
void clearCache();
};

View File

@@ -0,0 +1,85 @@
// SPDX-FileCopyrightText: 2019 Black Hat <bhat@encom.eu.org>
// SPDX-License-Identifier: GPL-3.0-only
#include "clipboard.h"
#include <QClipboard>
#include <QDateTime>
#include <QDir>
#include <QFileInfo>
#include <QGuiApplication>
#include <QImage>
#include <QMimeData>
#include <QRegularExpression>
#include <QStandardPaths>
#include <QUrl>
using namespace Qt::StringLiterals;
Clipboard::Clipboard(QObject *parent)
: QObject(parent)
, m_clipboard(QGuiApplication::clipboard())
{
connect(m_clipboard, &QClipboard::changed, this, &Clipboard::imageChanged);
}
bool Clipboard::hasImage() const
{
return !image().isNull();
}
QImage Clipboard::image() const
{
return m_clipboard->image();
}
QString Clipboard::saveImage(QString localPath) const
{
QString imageDir(u"%1/screenshots"_s.arg(QStandardPaths::writableLocation(QStandardPaths::CacheLocation)));
if (!QDir().exists(imageDir)) {
QDir().mkdir(imageDir);
}
if (localPath.isEmpty()) {
localPath = u"file://%1/%2.png"_s.arg(imageDir, QDateTime::currentDateTime().toString(u"yyyy-MM-dd-hh-mm-ss"_s));
}
QUrl url(localPath);
if (!url.isLocalFile()) {
return {};
}
auto image = this->image();
if (image.isNull()) {
return {};
}
if (image.save(url.toLocalFile())) {
return localPath;
} else {
return {};
}
}
void Clipboard::saveText(QString message)
{
static QRegularExpression re(u"<[^>]*>"_s);
auto *mimeData = new QMimeData; // ownership is transferred to clipboard
mimeData->setHtml(message);
mimeData->setText(message.replace(re, QString()));
m_clipboard->setMimeData(mimeData);
}
void Clipboard::setImage(const QUrl &url)
{
if (url.isLocalFile()) {
QImage img(url.path());
auto *mimeData = new QMimeData;
mimeData->setImageData(img);
if (!img.isNull()) {
m_clipboard->setMimeData(mimeData);
}
}
}
#include "moc_clipboard.cpp"

View File

@@ -0,0 +1,76 @@
// SPDX-FileCopyrightText: 2019 Black Hat <bhat@encom.eu.org>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <QObject>
#include <QQmlEngine>
class QClipboard;
class QImage;
/**
* @class Clipboard
*
* Clipboard proxy
*
* Used to set and retrieve content from the clipboard.
*/
class Clipboard : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
/**
* @brief Whether the current clipboard content is an image.
*/
Q_PROPERTY(bool hasImage READ hasImage NOTIFY imageChanged)
/**
* @brief Return the current clipboard content image.
*
* Returns a null image if the clipboard does not contain an image or if it
* contains an image in an unsupported image format.
*/
Q_PROPERTY(QImage image READ image NOTIFY imageChanged)
public:
explicit Clipboard(QObject *parent = nullptr);
[[nodiscard]] bool hasImage() const;
[[nodiscard]] QImage image() const;
/**
* @brief Save the current clipboard image to file.
*
* If the clipboard does not contain an image or if it contains an image in an
* unsupported image format nothing happens.
*
* The given file path must be both valid and local or nothing happens.
*
* @param localPath the path to save the image. A default path for the app cache
* will be used if available and this is empty.
*
* @return A QString with the path that the image was saved to. The string will
* be empty if nothing was saved.
*/
Q_INVOKABLE QString saveImage(QString localPath = {}) const;
/**
* @brief Set the clipboard content to the input message.
*/
Q_INVOKABLE void saveText(QString message);
/**
* @brief Set the clipboard content to the input image.
*/
Q_INVOKABLE void setImage(const QUrl &image);
private:
QClipboard *m_clipboard;
Q_SIGNALS:
void imageChanged();
};

1857
src/libneochat/emojis.h Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,139 @@
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#pragma once
#include <QObject>
#include <QQmlEngine>
#include <Quotient/events/encryptedevent.h>
#include <Quotient/events/roomevent.h>
#include <Quotient/events/roommessageevent.h>
#include <Quotient/events/stickerevent.h>
#include "events/pollevent.h"
using namespace Qt::StringLiterals;
/**
* @class MessageComponentType
*
* This class is designed to define the MessageComponentType enumeration.
*/
class MessageComponentType : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("")
public:
/**
* @brief The type of component that is needed for an event.
*
* @note While similar this is not the matrix event or message type. This is
* to tell a QML Bubble what component to use to visualise all or part of
* a room message.
*/
enum Type {
Author, /**< The message sender and time. */
Text, /**< A text message. */
Image, /**< A message that is an image. */
Audio, /**< A message that is an audio recording. */
Video, /**< A message that is a video. */
Code, /**< A code section. */
Quote, /**< A quote section. */
File, /**< A message that is a file. */
Itinerary, /**< A preview for a file that can integrate with KDE itinerary. */
Pdf, /**< A preview for a PDF file. */
Poll, /**< The initial event for a poll. */
Location, /**< A location event. */
LiveLocation, /**< The initial event of a shared live location (i.e., the place where this is supposed to be shown in the timeline). */
Encrypted, /**< An encrypted message that cannot be decrypted. */
Reply, /**< A component to show a replied-to message. */
Reaction, /**< A component to show the reactions to this message. */
LinkPreview, /**< A preview of a URL in the message. */
LinkPreviewLoad, /**< A loading dialog for a link preview. */
ChatBar, /**< A text edit for editing a message. */
ThreadRoot, /**< The root message of the thread. */
ThreadBody, /**< The other messages in the thread. */
ReplyButton, /**< A button to reply in the current thread. */
FetchButton, /**< A button to fetch more messages in the current thread. */
Verification, /**< A user verification session start message. */
Loading, /**< The component is loading. */
Separator, /**< A horizontal separator. */
Other, /**< Anything that cannot be classified as another type. */
};
Q_ENUM(Type);
/**
* @brief Return the delegate type for the given event.
*
* @param event the event to return a type for.
*
* @sa Type
*/
static Type typeForEvent(const Quotient::RoomEvent &event)
{
using namespace Quotient;
if (const auto e = eventCast<const RoomMessageEvent>(&event)) {
switch (e->msgtype()) {
case MessageEventType::Emote:
return MessageComponentType::Text;
case MessageEventType::Notice:
return MessageComponentType::Text;
case MessageEventType::Image:
return MessageComponentType::Image;
case MessageEventType::Audio:
return MessageComponentType::Audio;
case MessageEventType::Video:
return MessageComponentType::Video;
case MessageEventType::Location:
return MessageComponentType::Location;
case MessageEventType::File:
return MessageComponentType::File;
default:
return MessageComponentType::Text;
}
}
if (is<const StickerEvent>(event)) {
return MessageComponentType::Image;
}
if (event.isStateEvent()) {
if (event.matrixType() == u"org.matrix.msc3672.beacon_info"_s) {
return MessageComponentType::LiveLocation;
}
return MessageComponentType::Other;
}
if (is<const EncryptedEvent>(event)) {
return MessageComponentType::Encrypted;
}
if (is<PollStartEvent>(event)) {
const auto pollEvent = eventCast<const PollStartEvent>(&event);
if (pollEvent->isRedacted()) {
return MessageComponentType::Text;
}
return MessageComponentType::Poll;
}
return MessageComponentType::Other;
}
/**
* @brief Return MessageComponentType for the given html tag.
*
* @param tag the tag name to return a type for.
*
* @sa Type
*/
static Type typeForTag(const QString &tag)
{
if (tag == u"pre"_s || tag == u"pre"_s) {
return Code;
}
if (tag == u"blockquote"_s) {
return Quote;
}
return Text;
}
};

View File

@@ -0,0 +1,31 @@
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#pragma once
#include <QObject>
#include <QQmlEngine>
/**
* @class MessageType
*
* This class is designed to define the MessageType enumeration.
*/
class MessageType : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("")
public:
/**
* @brief The types of messages that can be shown.
*/
enum Type {
Information = 0, /**< Info message, typically highlight color. */
Positive, /**< Positive message, typically green. */
Warning, /**< Warning message, typically amber. */
Error, /**< Error message, typically red. */
};
Q_ENUM(Type);
};

View File

@@ -0,0 +1,109 @@
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#include "powerlevel.h"
QString PowerLevel::nameForLevel(Level level)
{
switch (level) {
case PowerLevel::Member:
return i18n("Member");
case PowerLevel::Moderator:
return i18n("Moderator");
case PowerLevel::Admin:
return i18n("Admin");
case PowerLevel::Mute:
return i18n("Mute");
case PowerLevel::Custom:
return i18n("Custom");
default:
return {};
}
}
int PowerLevel::valueForLevel(Level level)
{
switch (level) {
case PowerLevel::Member:
return 0;
case PowerLevel::Moderator:
return 50;
case PowerLevel::Admin:
return 100;
case PowerLevel::Mute:
return -1;
default:
return {};
}
}
PowerLevel::Level PowerLevel::levelForValue(int value)
{
switch (value) {
case 0:
return PowerLevel::Member;
case 50:
return PowerLevel::Moderator;
case 100:
return PowerLevel::Admin;
case -1:
return PowerLevel::Mute;
default:
return PowerLevel::Custom;
}
}
PowerLevelModel::PowerLevelModel(QObject *parent)
: QAbstractListModel(parent)
{
}
bool PowerLevelModel::showMute() const
{
return m_showMute;
}
void PowerLevelModel::setShowMute(bool showMute)
{
if (showMute == m_showMute) {
return;
}
m_showMute = showMute;
Q_EMIT showMuteChanged();
}
QVariant PowerLevelModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid()) {
return {};
}
if (index.row() >= rowCount()) {
qDebug() << "PowerLevelModel, something's wrong: index.row() >= m_rules.count()";
return {};
}
const auto level = static_cast<PowerLevel::Level>(index.row());
if (role == NameRole) {
return i18nc("%1 is the name of the power level, e.g. admin and %2 is the value that represents.",
"%1 (%2)",
PowerLevel::nameForLevel(level),
PowerLevel::valueForLevel(level));
}
if (role == ValueRole) {
return PowerLevel::valueForLevel(level);
}
return {};
}
int PowerLevelModel::rowCount(const QModelIndex &parent) const
{
Q_UNUSED(parent)
return PowerLevel::NUMLevels - (m_showMute ? 0 : 1);
}
QHash<int, QByteArray> PowerLevelModel::roleNames() const
{
return {{NameRole, "name"}, {ValueRole, "value"}};
}
#include "moc_powerlevel.cpp"

View File

@@ -0,0 +1,110 @@
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#pragma once
#include <QAbstractListModel>
#include <QObject>
#include <QQmlEngine>
#include <KLocalizedString>
/**
* @class PowerLevel
*
* This class is designed to define the PowerLevel enumeration.
*/
class PowerLevel : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("")
public:
/**
* @brief The type of delegate that is needed for the event.
*
* @note While similar this is not the matrix event or message type. This is
* to tell a QML ListView what delegate to show for each event. So while
* similar to the spec it is not the same.
*/
enum Level {
Member, /**< A basic member. */
Moderator, /**< A moderator with enhanced powers. */
Admin, /**< The highest power level in the room. */
Mute, /**< The level to remove posting privileges. */
NUMLevels,
Custom, /**< A non-standard value. Intentionally after NUMLevels so it doesn't appear in the model. */
};
Q_ENUM(Level);
/**
* @brief Return a string representation of the enum value.
*/
static QString nameForLevel(Level level);
/**
* @brief Return the integer representation of the enum value.
*/
static int valueForLevel(Level level);
/**
* @brief Return the enum value for the given integer power level.
*/
static Level levelForValue(int value);
};
/**
* @class PowerLevelModel
*
* A model visualize the allowed power levels.
*/
class PowerLevelModel : public QAbstractListModel
{
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(bool showMute READ showMute WRITE setShowMute NOTIFY showMuteChanged)
public:
/**
* @brief Defines the model roles.
*/
enum Roles {
NameRole = Qt::DisplayRole, /**< The power level name. */
ValueRole, /**< The power level value. */
};
Q_ENUM(Roles)
explicit PowerLevelModel(QObject *parent = nullptr);
[[nodiscard]] bool showMute() const;
void setShowMute(bool showMute);
/**
* @brief Get the given role value at the given index.
*
* @sa QAbstractItemModel::data
*/
[[nodiscard]] QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
/**
* @brief Number of rows in the model.
*
* @sa QAbstractItemModel::rowCount
*/
[[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override;
/**
* @brief Returns a mapping from Role enum values to role names.
*
* @sa Roles, QAbstractItemModel::roleNames()
*/
[[nodiscard]] QHash<int, QByteArray> roleNames() const override;
Q_SIGNALS:
void showMuteChanged();
private:
bool m_showMute = true;
};

View File

@@ -0,0 +1,188 @@
// SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#pragma once
#include <QObject>
#include <QQmlEngine>
using namespace Qt::StringLiterals;
/**
* @class PushRuleKind
*
* A class with the Kind enum for push notifications and helper functions.
*
* The kind relates to the kinds of push rule defined in the matrix spec, see
* https://spec.matrix.org/v1.7/client-server-api/#push-rules for full details.
*/
class PushRuleKind : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("")
public:
/**
* @brief Defines the different kinds of push rule.
*/
enum Kind {
Override = 0, /**< The highest priority rules. */
Content, /**< These configure behaviour for messages that match certain patterns. */
Room, /**< These rules change the behaviour of all messages for a given room. */
Sender, /**< These rules configure notification behaviour for messages from a specific Matrix user ID. */
Underride, /**< These are identical to override rules, but have a lower priority than content, room and sender rules. */
};
Q_ENUM(Kind)
/**
* @brief Translate the Kind enum value to a human readable string.
*
* @sa Kind
*/
static QString kindString(Kind kind)
{
switch (kind) {
case Kind::Override:
return u"override"_s;
case Kind::Content:
return u"content"_s;
case Kind::Room:
return u"room"_s;
case Kind::Sender:
return u"sender"_s;
case Kind::Underride:
return u"underride"_s;
default:
return {};
}
};
};
/**
* @class PushRuleAction
*
* A class with the Action enum for push notifications.
*
* The action relates to the actions of push rule defined in the matrix spec, see
* https://spec.matrix.org/v1.7/client-server-api/#push-rules for full details.
*/
class PushRuleAction : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("")
public:
/**
* @brief Defines the global push notification actions.
*/
enum Action {
Unknown = 0, /**< The action has not yet been obtained from the server. */
Off, /**< No push notifications are to be sent. */
On, /**< Push notifications are on. */
Noisy, /**< Push notifications are on, also trigger a notification sound. */
Highlight, /**< Push notifications are on, also the event should be highlighted in chat. */
NoisyHighlight, /**< Push notifications are on, also trigger a notification sound and highlight in chat. */
};
Q_ENUM(Action)
};
/**
* @class PushNotificationState
*
* A class with the State enum for room push notification state.
*
* The state define whether the room adheres to the global push rule states for the
* account or is overridden for a room.
*
* @note This is different to the PushRuleAction which defines the type of notification
* for an individual rule.
*
* @sa PushRuleAction
*/
class PushNotificationState : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("")
public:
/**
* @brief Describes the push notification state for the room.
*/
enum State {
Unknown, /**< The state has not yet been obtained from the server. */
Default, /**< The room follows the globally configured rules for the local user. */
Mute, /**< No notifications for messages in the room. */
MentionKeyword, /**< Notifications only for local user mentions and keywords. */
All, /**< Notifications for all messages. */
};
Q_ENUM(State)
};
/**
* @class PushRuleSection
*
* A class with the Section enum for push notifications and helper functions.
*
* @note This is different from the PushRuleKind and instead is used for sorting
* in the settings page which is not necessarily by Kind.
*
* @sa PushRuleKind
*/
class PushRuleSection : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("")
public:
/**
* @brief Defines the sections to sort push rules into.
*/
enum Section {
Master = 0, /**< The master push rule */
Room, /**< Push rules relating to all rooms. */
Mentions, /**< Push rules relating to user mentions. */
Keywords, /**< Global Keyword push rules. */
RoomKeywords, /**< Keyword push rules that only apply to a specific room. */
Invites, /**< Push rules relating to invites. */
Unknown, /**< New default push rules that have not been added to the model yet. */
/**
* @brief Push rules that should never be shown.
*
* There are numerous rules that get set that shouldn't be shown in the general
* list e.g. The array of rules used to override global settings in individual
* rooms.
*
* This is specifically different to unknown which are just new default push
* rule that haven't been added to the model yet.
*/
Undefined,
};
Q_ENUM(Section)
/**
* @brief Translate the Section enum value to a human readable string.
*
* @sa Section
*/
static QString sectionString(Section section)
{
switch (section) {
case Section::Master:
return u"Master"_s;
case Section::Room:
return u"Room Notifications"_s;
case Section::Mentions:
return u"@Mentions"_s;
case Section::Keywords:
return u"Keywords"_s;
case Section::Invites:
return u"Invites"_s;
default:
return {};
}
};
};

View File

@@ -0,0 +1,883 @@
// SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#include "eventhandler.h"
#include <QMovie>
#include <KFormat>
#include <KLocalizedString>
#include <Quotient/events/encryptionevent.h>
#include <Quotient/events/event.h>
#include <Quotient/events/eventcontent.h>
#include <Quotient/events/reactionevent.h>
#include <Quotient/events/redactionevent.h>
#include <Quotient/events/roomavatarevent.h>
#include <Quotient/events/roomcanonicalaliasevent.h>
#include <Quotient/events/roomevent.h>
#include <Quotient/events/roommemberevent.h>
#include <Quotient/events/roompowerlevelsevent.h>
#include <Quotient/events/simplestateevents.h>
#include <Quotient/events/stickerevent.h>
#include <Quotient/quotient_common.h>
#include <Quotient/roommember.h>
#include "eventhandler_logging.h"
#include "events/locationbeaconevent.h"
#include "events/pollevent.h"
#include "events/widgetevent.h"
#include "neochatroom.h"
#include "texthandler.h"
#include "utils.h"
using namespace Quotient;
namespace
{
enum MemberChange {
None = 0,
AddName = 1,
Rename = 2,
RemoveName = 4,
AddAvatar = 8,
UpdateAvatar = 16,
RemoveAvatar = 32,
};
Q_DECLARE_FLAGS(MemberChanges, MemberChange)
Q_DECLARE_OPERATORS_FOR_FLAGS(MemberChanges)
};
QString EventHandler::authorDisplayName(const NeoChatRoom *room, const Quotient::RoomEvent *event, bool isPending)
{
if (room == nullptr) {
qCWarning(EventHandling) << "authorDisplayName called with room set to nullptr.";
return {};
}
if (event == nullptr) {
qCWarning(EventHandling) << "authorDisplayName called with event set to nullptr.";
return {};
}
if (is<RoomMemberEvent>(*event) && event->unsignedJson()["prev_content"_L1].toObject().contains("displayname"_L1)
&& event->stateKey() == event->senderId()) {
auto previousDisplayName = event->unsignedJson()["prev_content"_L1]["displayname"_L1].toString().toHtmlEscaped();
if (previousDisplayName.isEmpty()) {
previousDisplayName = event->senderId();
}
return previousDisplayName;
} else {
const auto author = isPending ? room->localMember() : room->member(event->senderId());
return author.htmlSafeDisplayName();
}
}
QString EventHandler::singleLineAuthorDisplayname(const NeoChatRoom *room, const Quotient::RoomEvent *event, bool isPending)
{
if (room == nullptr) {
qCWarning(EventHandling) << "singleLineAuthorDisplayname called with room set to nullptr.";
return {};
}
if (event == nullptr) {
qCWarning(EventHandling) << "singleLineAuthorDisplayname called with event set to nullptr.";
return {};
}
const auto author = isPending ? room->localMember() : room->member(event->senderId());
auto displayName = author.displayName();
displayName.replace(u"<br>\n"_s, u" "_s);
displayName.replace(u"<br>"_s, u" "_s);
displayName.replace(u"<br />\n"_s, u" "_s);
displayName.replace(u"<br />"_s, u" "_s);
displayName.replace(u'\n', u" "_s);
displayName.replace(u'\u2028', u" "_s);
return displayName;
}
QDateTime EventHandler::time(const NeoChatRoom *room, const Quotient::RoomEvent *event, bool isPending)
{
if (room == nullptr) {
qCWarning(EventHandling) << "time called with room set to nullptr.";
return {};
}
if (event == nullptr) {
qCWarning(EventHandling) << "time called with event set to nullptr.";
return {};
}
if (isPending) {
const auto pendingIt = room->findPendingEvent(event->transactionId());
if (pendingIt != room->pendingEvents().end()) {
return pendingIt->lastUpdated();
}
return {};
}
return event->originTimestamp();
}
QString EventHandler::timeString(const NeoChatRoom *room, const Quotient::RoomEvent *event, bool relative, QLocale::FormatType format, bool isPending)
{
auto ts = time(room, event, isPending);
if (ts.isValid()) {
if (relative) {
KFormat formatter;
return formatter.formatRelativeDate(ts.toLocalTime().date(), format);
} else {
return QLocale().toString(ts.toLocalTime().time(), format);
}
}
return {};
}
QString EventHandler::timeString(const NeoChatRoom *room, const Quotient::RoomEvent *event, const QString &format, bool isPending)
{
return time(room, event, isPending).toLocalTime().toString(format);
}
bool EventHandler::isHighlighted(const NeoChatRoom *room, const Quotient::RoomEvent *event)
{
if (room == nullptr) {
qCWarning(EventHandling) << "isHighlighted called with room set to nullptr.";
return false;
}
if (event == nullptr) {
qCWarning(EventHandling) << "isHighlighted called with event set to nullptr.";
return false;
}
return !room->isDirectChat() && room->isEventHighlighted(event);
}
bool EventHandler::isHidden(const NeoChatRoom *room, const Quotient::RoomEvent *event, std::function<bool(const Quotient::RoomEvent *)> filter)
{
if (room == nullptr) {
qCWarning(EventHandling) << "isHidden called with room set to nullptr.";
return false;
}
if (event == nullptr) {
qCWarning(EventHandling) << "isHidden called with event set to nullptr.";
return false;
}
if (filter && filter(event)) {
return true;
}
if (event->isStateEvent() && eventCast<const StateEvent>(event)->repeatsState()) {
return true;
}
// isReplacement?
if (auto e = eventCast<const RoomMessageEvent>(event)) {
if (!e->replacedEvent().isEmpty()) {
return true;
}
}
if (is<RedactionEvent>(*event) || is<ReactionEvent>(*event)) {
return true;
}
if (auto e = eventCast<const RoomMessageEvent>(event)) {
if (!e->replacedEvent().isEmpty() && e->replacedEvent() != e->id()) {
return true;
}
}
if (room->connection()->isIgnored(event->senderId())) {
return true;
}
// hide ending live location beacons
if (event->isStateEvent() && event->matrixType() == "org.matrix.msc3672.beacon_info"_L1 && !event->contentJson()["live"_L1].toBool()) {
return true;
}
return false;
}
Qt::TextFormat EventHandler::messageBodyInputFormat(const Quotient::RoomMessageEvent &event)
{
if (event.mimeType().name() == "text/plain"_L1) {
return Qt::PlainText;
} else {
return Qt::RichText;
}
}
QString EventHandler::rawMessageBody(const Quotient::RoomMessageEvent &event)
{
QString body;
if (event.has<EventContent::FileContent>()) {
// if filename is given or body is equal to filename,
// then body is a caption
QString filename = event.get<EventContent::FileContent>()->originalName;
QString body = event.plainBody();
if (filename.isEmpty() || filename == body) {
return QString();
}
return body;
}
if (event.has<EventContent::TextContent>() && event.content()) {
body = event.get<EventContent::TextContent>()->body;
} else {
body = event.plainBody();
}
return body;
}
QString EventHandler::richBody(const NeoChatRoom *room, const Quotient::RoomEvent *event, bool stripNewlines)
{
if (room == nullptr) {
qCWarning(EventHandling) << "richBody called with room set to nullptr.";
return {};
}
if (event == nullptr) {
qCWarning(EventHandling) << "richBody called with event set to nullptr.";
return {};
}
return getBody(room, event, Qt::RichText, stripNewlines);
}
QString EventHandler::plainBody(const NeoChatRoom *room, const Quotient::RoomEvent *event, bool stripNewlines)
{
if (room == nullptr) {
qCWarning(EventHandling) << "plainBody called with room set to nullptr.";
return {};
}
if (event == nullptr) {
qCWarning(EventHandling) << "plainBody called with event set to nullptr.";
return {};
}
return getBody(room, event, Qt::PlainText, stripNewlines);
}
QString EventHandler::markdownBody(const Quotient::RoomEvent *event)
{
if (event == nullptr) {
qCWarning(EventHandling) << "markdownBody called with event set to nullptr.";
return {};
}
if (!event->is<RoomMessageEvent>()) {
qCWarning(EventHandling) << "markdownBody called when event isn't a RoomMessageEvent.";
return {};
}
const auto roomMessageEvent = eventCast<const RoomMessageEvent>(event);
QString plainBody = roomMessageEvent->plainBody();
plainBody.remove(TextRegex::removeReply);
return plainBody;
}
QString EventHandler::getBody(const NeoChatRoom *room, const Quotient::RoomEvent *event, Qt::TextFormat format, bool stripNewlines)
{
if (event->isRedacted() && !event->isStateEvent()) {
auto reason = event->redactedBecause()->reason();
return (reason.isEmpty()) ? i18n("<i>[This message was deleted]</i>") : i18n("<i>[This message was deleted: %1]</i>", reason.toHtmlEscaped());
}
const bool prettyPrint = (format == Qt::RichText);
return switchOnType(
*event,
[room, format, stripNewlines](const RoomMessageEvent &event) {
return getMessageBody(room, event, format, stripNewlines);
},
[](const StickerEvent &e) {
return e.body();
},
[room, prettyPrint](const RoomMemberEvent &e) {
// FIXME: Rewind to the name that was at the time of this event
auto subjectName = prettyPrint ? room->member(e.userId()).htmlSafeDisplayName() : room->member(e.userId()).displayName();
if (e.membership() == Membership::Leave) {
if (e.prevContent() && e.prevContent()->displayName) {
subjectName = sanitized(*e.prevContent()->displayName);
if (prettyPrint) {
subjectName = subjectName.toHtmlEscaped();
}
}
}
if (prettyPrint) {
subjectName =
u"<a href=\"https://matrix.to/#/%1\" style=\"color: %2\">%3</a>"_s.arg(e.userId(), room->member(e.userId()).color().name(), subjectName);
}
// The below code assumes senderName output in AuthorRole
switch (e.membership()) {
case Membership::Invite:
if (e.repeatsState()) {
auto text = i18n("reinvited %1 to the room", subjectName);
if (!e.reason().isEmpty()) {
text += i18nc("Optional reason for an invitation", ": %1") + (prettyPrint ? e.reason().toHtmlEscaped() : e.reason());
}
return text;
}
Q_FALLTHROUGH();
case Membership::Join: {
QString text{};
// Part 1: invites and joins
if (e.repeatsState()) {
text = i18n("joined the room (repeated)");
} else if (e.changesMembership()) {
text = e.membership() == Membership::Invite ? i18n("invited %1 to the room", subjectName) : i18n("joined the room");
}
if (!text.isEmpty()) {
if (!e.reason().isEmpty()) {
text += i18n(": %1", e.reason().toHtmlEscaped());
}
return text;
}
// Part 2: profile changes of joined members
if (e.isRename()) {
if (!e.newDisplayName()) {
text = i18nc("their refers to a singular user", "cleared their display name");
} else {
text = i18nc("their refers to a singular user",
"changed their display name to %1",
prettyPrint ? e.newDisplayName()->toHtmlEscaped() : *e.newDisplayName());
}
}
if (e.isAvatarUpdate()) {
if (!text.isEmpty()) {
text += i18n(" and ");
}
if (!e.newAvatarUrl()) {
text += i18nc("their refers to a singular user", "cleared their avatar");
} else if (!e.prevContent()->avatarUrl) {
text += i18n("set an avatar");
} else {
text += i18nc("their refers to a singular user", "updated their avatar");
}
}
if (text.isEmpty()) {
text = i18nc("<user> changed nothing", "changed nothing");
}
return text;
}
case Membership::Leave:
if (e.prevContent() && e.prevContent()->membership == Membership::Invite) {
return (e.senderId() != e.userId()) ? i18n("withdrew %1's invitation", subjectName) : i18n("rejected the invitation");
}
if (e.prevContent() && e.prevContent()->membership == Membership::Ban) {
return (e.senderId() != e.userId()) ? i18n("unbanned %1", subjectName) : i18n("self-unbanned");
}
if (e.senderId() == e.userId()) {
return i18n("left the room");
}
if (const auto &reason = e.contentJson()["reason"_L1].toString().toHtmlEscaped(); !reason.isEmpty()) {
return i18n("has put %1 out of the room: %2", subjectName, reason);
}
return i18n("has put %1 out of the room", subjectName);
case Membership::Ban:
if (e.senderId() != e.userId()) {
if (e.reason().isEmpty()) {
return i18n("banned %1 from the room", subjectName);
} else {
return i18n("banned %1 from the room: %2", subjectName, prettyPrint ? e.reason().toHtmlEscaped() : e.reason());
}
} else {
return i18n("self-banned from the room");
}
case Membership::Knock: {
QString reason(e.contentJson()["reason"_L1].toString().toHtmlEscaped());
return reason.isEmpty() ? i18n("requested an invite") : i18n("requested an invite with reason: %1", reason);
}
default:;
}
return i18n("made something unknown");
},
[](const RoomCanonicalAliasEvent &e) {
return (e.alias().isEmpty()) ? i18n("cleared the room main alias") : i18n("set the room main alias to: %1", e.alias());
},
[prettyPrint](const RoomNameEvent &e) {
return (e.name().isEmpty()) ? i18n("cleared the room name") : i18n("set the room name to: %1", prettyPrint ? e.name().toHtmlEscaped() : e.name());
},
[prettyPrint, stripNewlines](const RoomTopicEvent &e) {
return (e.topic().isEmpty()) ? i18n("cleared the topic")
: i18n("set the topic to: %1",
prettyPrint ? Quotient::prettyPrint(e.topic())
: stripNewlines ? e.topic().replace(u'\n', u' ')
: e.topic());
},
[](const RoomAvatarEvent &) {
return i18n("changed the room avatar");
},
[](const EncryptionEvent &) {
return i18n("activated End-to-End Encryption");
},
[prettyPrint](const RoomCreateEvent &e) {
return e.isUpgrade()
? i18n("upgraded the room to version %1", e.version().isEmpty() ? "1"_L1 : (prettyPrint ? e.version().toHtmlEscaped() : e.version()))
: i18n("created the room, version %1", e.version().isEmpty() ? "1"_L1 : (prettyPrint ? e.version().toHtmlEscaped() : e.version()));
},
[](const RoomPowerLevelsEvent &) {
return i18nc("'power level' means permission level", "changed the power levels for this room");
},
[](const LocationBeaconEvent &e) {
return e.contentJson()["description"_L1].toString();
},
[](const RoomServerAclEvent &) {
return i18n("changed the server access control lists for this room");
},
[](const WidgetEvent &e) {
if (e.fullJson()["unsigned"_L1]["prev_content"_L1].toObject().isEmpty()) {
return i18nc("[User] added <name> widget", "added %1 widget", e.contentJson()["name"_L1].toString());
}
if (e.contentJson().isEmpty()) {
return i18nc("[User] removed <name> widget", "removed %1 widget", e.fullJson()["unsigned"_L1]["prev_content"_L1]["name"_L1].toString());
}
return i18nc("[User] configured <name> widget", "configured %1 widget", e.contentJson()["name"_L1].toString());
},
[prettyPrint](const StateEvent &e) {
return e.stateKey().isEmpty() ? i18n("updated %1 state", e.matrixType())
: i18n("updated %1 state for %2", e.matrixType(), prettyPrint ? e.stateKey().toHtmlEscaped() : e.stateKey());
},
[](const PollStartEvent &e) {
return e.question();
},
i18n("Unknown event"));
}
QString EventHandler::getMessageBody(const NeoChatRoom *room, const RoomMessageEvent &event, Qt::TextFormat format, bool stripNewlines)
{
TextHandler textHandler;
if (event.has<EventContent::FileContent>()) {
QString fileCaption = event.get<EventContent::FileContent>()->originalName;
if (fileCaption.isEmpty()) {
fileCaption = event.plainBody();
} else if (fileCaption != event.plainBody()) {
fileCaption = event.plainBody() + " | "_L1 + fileCaption;
}
textHandler.setData(fileCaption);
return !fileCaption.isEmpty() ? textHandler.handleRecievePlainText(Qt::PlainText, stripNewlines) : i18n("a file");
}
QString body;
if (event.has<EventContent::TextContent>() && event.content()) {
body = event.get<EventContent::TextContent>()->body;
} else {
body = event.plainBody();
}
textHandler.setData(body);
Qt::TextFormat inputFormat;
if (event.mimeType().name() == "text/plain"_L1) {
inputFormat = Qt::PlainText;
} else {
inputFormat = Qt::RichText;
}
if (format == Qt::RichText) {
return textHandler.handleRecieveRichText(inputFormat, room, &event, stripNewlines, event.isReplaced());
} else {
return textHandler.handleRecievePlainText(inputFormat, stripNewlines);
}
}
QString EventHandler::genericBody(const NeoChatRoom *room, const Quotient::RoomEvent *event)
{
if (room == nullptr) {
qCWarning(EventHandling) << "genericBody called with room set to nullptr.";
return {};
}
if (event == nullptr) {
qCWarning(EventHandling) << "genericBody called with event set to nullptr.";
return {};
}
if (event->isRedacted() && !event->isStateEvent()) {
return i18n("<i>[This message was deleted]</i>");
}
const auto sender = room->member(event->senderId());
const auto senderString = u"<a href=\"https://matrix.to/#/%1\">%2</a>"_s.arg(sender.id(), sender.htmlSafeDisplayName());
return switchOnType(
*event,
[senderString](const RoomMessageEvent &) {
return i18n("%1 sent a message", senderString);
},
[senderString](const StickerEvent &) {
return i18n("%1 sent a sticker", senderString);
},
[senderString](const RoomMemberEvent &e) {
switch (e.membership()) {
case Membership::Invite:
if (e.repeatsState()) {
return i18n("%1 reinvited someone to the room", senderString);
}
Q_FALLTHROUGH();
case Membership::Join: {
// Part 1: invites and joins
if (e.repeatsState()) {
return i18n("%1 joined the room (repeated)", senderString);
} else if (e.changesMembership()) {
return e.membership() == Membership::Invite ? i18n("%1 invited someone to the room", senderString)
: i18n("%1 joined the room", senderString);
}
// Part 2: profile changes of joined members
MemberChanges changes = None;
if (e.isRename()) {
if (!e.newDisplayName()) {
changes |= RemoveName;
} else if (!e.prevContent()->displayName) {
changes |= AddName;
} else {
changes |= Rename;
}
}
if (e.isAvatarUpdate()) {
if (!e.newAvatarUrl()) {
changes |= RemoveAvatar;
} else if (!e.prevContent()->avatarUrl) {
changes |= AddAvatar;
} else {
changes |= UpdateAvatar;
}
}
if (changes.testFlag(AddName)) {
if (changes.testFlag(AddAvatar)) {
return i18n("%1 set a display name and set an avatar", senderString);
} else if (changes.testFlag(UpdateAvatar)) {
return i18n("%1 set a display name and updated their avatar", senderString);
} else if (changes.testFlag(RemoveAvatar)) {
return i18n("%1 set a display name and cleared their avatar", senderString);
}
return i18n("%1 set a display name for this room", senderString);
} else if (changes.testFlag(Rename)) {
if (changes.testFlag(AddAvatar)) {
return i18n("%1 changed their display name and set an avatar", senderString);
} else if (changes.testFlag(UpdateAvatar)) {
return i18n("%1 changed their display name and updated their avatar", senderString);
} else if (changes.testFlag(RemoveAvatar)) {
return i18n("%1 changed their display name and cleared their avatar", senderString);
}
return i18n("%1 changed their display name", senderString);
} else if (changes.testFlag(RemoveName)) {
if (changes.testFlag(AddAvatar)) {
return i18n("%1 cleared their display name and set an avatar", senderString);
} else if (changes.testFlag(UpdateAvatar)) {
return i18n("%1 cleared their display name and updated their avatar", senderString);
} else if (changes.testFlag(RemoveAvatar)) {
return i18n("%1 cleared their display name and cleared their avatar", senderString);
}
return i18n("%1 cleared their display name", senderString);
}
return i18nc("<user> changed nothing", "%1 changed nothing", senderString);
}
case Membership::Leave:
if (e.prevContent() && e.prevContent()->membership == Membership::Invite) {
return (e.senderId() != e.userId()) ? i18n("%1 withdrew a user's invitation", senderString)
: i18n("%1 rejected the invitation", senderString);
}
if (e.prevContent() && e.prevContent()->membership == Membership::Ban) {
return (e.senderId() != e.userId()) ? i18n("%1 unbanned a user", senderString) : i18n("%1 self-unbanned", senderString);
}
return (e.senderId() != e.userId()) ? i18n("%1 put a user out of the room", senderString) : i18n("%1 left the room", senderString);
case Membership::Ban:
if (e.senderId() != e.userId()) {
return i18n("%1 banned a user from the room", senderString);
} else {
return i18n("%1 self-banned from the room", senderString);
}
case Membership::Knock: {
return i18n("%1 requested an invite", senderString);
}
default:;
}
return i18n("%1 made something unknown", senderString);
},
[senderString](const RoomCanonicalAliasEvent &e) {
return (e.alias().isEmpty()) ? i18n("%1 cleared the room main alias", senderString) : i18n("%1 set the room main alias", senderString);
},
[senderString](const RoomNameEvent &e) {
return (e.name().isEmpty()) ? i18n("%1 cleared the room name", senderString) : i18n("%1 set the room name", senderString);
},
[senderString](const RoomTopicEvent &e) {
return (e.topic().isEmpty()) ? i18n("%1 cleared the topic", senderString) : i18n("%1 set the topic", senderString);
},
[senderString](const RoomAvatarEvent &) {
return i18n("%1 changed the room avatar", senderString);
},
[senderString](const EncryptionEvent &) {
return i18n("%1 activated End-to-End Encryption", senderString);
},
[senderString](const RoomCreateEvent &e) {
return e.isUpgrade() ? i18n("%1 upgraded the room version", senderString) : i18n("%1 created the room", senderString);
},
[senderString](const RoomPowerLevelsEvent &) {
return i18nc("'power level' means permission level", "%1 changed the power levels for this room", senderString);
},
[senderString](const LocationBeaconEvent &) {
return i18n("%1 sent a live location beacon", senderString);
},
[senderString](const RoomServerAclEvent &) {
return i18n("%1 changed the server access control lists for this room", senderString);
},
[senderString](const WidgetEvent &e) {
if (e.fullJson()["unsigned"_L1]["prev_content"_L1].toObject().isEmpty()) {
return i18n("%1 added a widget", senderString);
}
if (e.contentJson().isEmpty()) {
return i18n("%1 removed a widget", senderString);
}
return i18n("%1 configured a widget", senderString);
},
[senderString](const StateEvent &) {
return i18n("%1 updated the state", senderString);
},
[senderString](const PollStartEvent &) {
return i18n("%1 started a poll", senderString);
},
i18n("Unknown event"));
}
QString EventHandler::subtitleText(const NeoChatRoom *room, const Quotient::RoomEvent *event)
{
if (room == nullptr) {
qCWarning(EventHandling) << "subtitleText called with room set to nullptr.";
return {};
}
if (event == nullptr) {
qCWarning(EventHandling) << "subtitleText called with event set to nullptr.";
return {};
}
return singleLineAuthorDisplayname(room, event) + (event->isStateEvent() ? u" "_s : u": "_s) + plainBody(room, event, true);
}
QVariantMap EventHandler::mediaInfo(const NeoChatRoom *room, const Quotient::RoomEvent *event)
{
if (room == nullptr) {
qCWarning(EventHandling) << "mediaInfo called with room set to nullptr.";
return {};
}
if (event == nullptr) {
qCWarning(EventHandling) << "mediaInfo called with event set to nullptr.";
return {};
}
return getMediaInfoForEvent(room, event);
}
QVariantMap EventHandler::getMediaInfoForEvent(const NeoChatRoom *room, const Quotient::RoomEvent *event)
{
QString eventId = event->id();
// Get the file info for the event.
if (event->is<RoomMessageEvent>()) {
auto roomMessageEvent = eventCast<const RoomMessageEvent>(event);
if (!roomMessageEvent->has<EventContent::FileContentBase>()) {
return {};
}
const auto content = roomMessageEvent->get<EventContent::FileContentBase>();
QVariantMap mediaInfo = getMediaInfoFromFileInfo(room, content.get(), eventId, false, false);
// if filename isn't specifically given, it is in body
// https://spec.matrix.org/latest/client-server-api/#mfile
mediaInfo["filename"_L1] = content->commonInfo().originalName.isEmpty() ? roomMessageEvent->plainBody() : content->commonInfo().originalName;
return mediaInfo;
} else if (event->is<StickerEvent>()) {
auto stickerEvent = eventCast<const StickerEvent>(event);
auto content = &stickerEvent->image();
return getMediaInfoFromFileInfo(room, content, eventId, false, true);
} else {
return {};
}
}
QVariantMap EventHandler::getMediaInfoFromFileInfo(const NeoChatRoom *room,
const Quotient::EventContent::FileContentBase *fileContent,
const QString &eventId,
bool isThumbnail,
bool isSticker)
{
QVariantMap mediaInfo;
// Get the mxc URL for the media.
if (!fileContent->url().isValid() || fileContent->url().scheme() != u"mxc"_s || eventId.isEmpty()) {
mediaInfo["source"_L1] = QUrl();
} else {
QUrl source = room->makeMediaUrl(eventId, fileContent->url());
if (source.isValid()) {
mediaInfo["source"_L1] = source;
} else {
mediaInfo["source"_L1] = QUrl();
}
}
auto mimeType = fileContent->type();
// Add the MIME type for the media if available.
mediaInfo["mimeType"_L1] = mimeType.name();
// Add the MIME type icon if available.
mediaInfo["mimeIcon"_L1] = mimeType.iconName();
// Add media size if available.
mediaInfo["size"_L1] = fileContent->commonInfo().payloadSize;
mediaInfo["isSticker"_L1] = isSticker;
// Add parameter depending on media type.
if (mimeType.name().contains(u"image"_s)) {
if (auto castInfo = static_cast<const EventContent::ImageContent *>(fileContent)) {
mediaInfo["width"_L1] = castInfo->imageSize.width();
mediaInfo["height"_L1] = castInfo->imageSize.height();
// TODO: Images in certain formats (e.g. WebP) will be erroneously marked as animated, even if they are static.
mediaInfo["animated"_L1] = QMovie::supportedFormats().contains(mimeType.preferredSuffix().toUtf8());
QVariantMap tempInfo;
auto thumbnailInfo = getMediaInfoFromTumbnail(room, castInfo->thumbnail, eventId);
if (thumbnailInfo["source"_L1].toUrl().scheme() == "mxc"_L1) {
tempInfo = thumbnailInfo;
} else {
QString blurhash = castInfo->originalInfoJson["xyz.amorgan.blurhash"_L1].toString();
if (blurhash.isEmpty()) {
tempInfo["source"_L1] = QUrl();
} else {
tempInfo["source"_L1] = QUrl("image://blurhash/"_L1 + blurhash);
}
}
mediaInfo["tempInfo"_L1] = tempInfo;
}
}
if (mimeType.name().contains(u"video"_s)) {
if (auto castInfo = static_cast<const EventContent::VideoContent *>(fileContent)) {
mediaInfo["width"_L1] = castInfo->imageSize.width();
mediaInfo["height"_L1] = castInfo->imageSize.height();
mediaInfo["duration"_L1] = castInfo->duration;
if (!isThumbnail) {
QVariantMap tempInfo;
auto thumbnailInfo = getMediaInfoFromTumbnail(room, castInfo->thumbnail, eventId);
if (thumbnailInfo["source"_L1].toUrl().scheme() == "mxc"_L1) {
tempInfo = thumbnailInfo;
} else {
QString blurhash = castInfo->originalInfoJson["xyz.amorgan.blurhash"_L1].toString();
if (blurhash.isEmpty()) {
tempInfo["source"_L1] = QUrl();
} else {
tempInfo["source"_L1] = QUrl("image://blurhash/"_L1 + blurhash);
}
}
mediaInfo["tempInfo"_L1] = tempInfo;
}
}
}
if (mimeType.name().contains(u"audio"_s)) {
if (auto castInfo = static_cast<const EventContent::AudioContent *>(fileContent)) {
mediaInfo["duration"_L1] = castInfo->duration;
}
}
return mediaInfo;
}
QVariantMap EventHandler::getMediaInfoFromTumbnail(const NeoChatRoom *room, const Quotient::EventContent::Thumbnail &thumbnail, const QString &eventId)
{
QVariantMap thumbnailInfo;
if (!thumbnail.url().isValid() || thumbnail.url().scheme() != u"mxc"_s || eventId.isEmpty()) {
thumbnailInfo["source"_L1] = QUrl();
} else {
QUrl source = room->makeMediaUrl(eventId, thumbnail.url());
if (source.isValid()) {
thumbnailInfo["source"_L1] = source;
} else {
thumbnailInfo["source"_L1] = QUrl();
}
}
auto mimeType = thumbnail.mimeType;
// Add the MIME type for the media if available.
thumbnailInfo["mimeType"_L1] = mimeType.name();
// Add the MIME type icon if available.
thumbnailInfo["mimeIcon"_L1] = mimeType.iconName();
// Add media size if available.
thumbnailInfo["size"_L1] = thumbnail.payloadSize;
thumbnailInfo["width"_L1] = thumbnail.imageSize.width();
thumbnailInfo["height"_L1] = thumbnail.imageSize.height();
return thumbnailInfo;
}
Quotient::RoomMember EventHandler::replyAuthor(const NeoChatRoom *room, const Quotient::RoomEvent *event)
{
if (room == nullptr) {
qCWarning(EventHandling) << "replyAuthor called with room set to nullptr.";
return {};
}
if (event == nullptr) {
qCWarning(EventHandling) << "replyAuthor called with event set to nullptr. Returning empty user.";
return {};
}
if (auto replyPtr = room->getReplyForEvent(*event)) {
return room->member(replyPtr->senderId());
} else {
return room->member(QString());
}
}
float EventHandler::latitude(const Quotient::RoomEvent *event)
{
if (event == nullptr) {
qCWarning(EventHandling) << "latitude called with event set to nullptr.";
return -100.0;
}
const auto geoUri = event->contentJson()["geo_uri"_L1].toString();
if (geoUri.isEmpty()) {
return -100.0; // latitude runs from -90deg to +90deg so -100 is out of range.
}
const auto latitude = geoUri.split(u';')[0].split(u':')[1].split(u',')[0];
return latitude.toFloat();
}
float EventHandler::longitude(const Quotient::RoomEvent *event)
{
if (event == nullptr) {
qCWarning(EventHandling) << "longitude called with event set to nullptr.";
return -200.0;
}
const auto geoUri = event->contentJson()["geo_uri"_L1].toString();
if (geoUri.isEmpty()) {
return -200.0; // longitude runs from -180deg to +180deg so -200 is out of range.
}
const auto latitude = geoUri.split(u';')[0].split(u':')[1].split(u',')[1];
return latitude.toFloat();
}
QString EventHandler::locationAssetType(const Quotient::RoomEvent *event)
{
if (event == nullptr) {
qCWarning(EventHandling) << "locationAssetType called with event set to nullptr.";
return {};
}
const auto assetType = event->contentJson()["org.matrix.msc3488.asset"_L1].toObject()["type"_L1].toString();
if (assetType.isEmpty()) {
return {};
}
return assetType;
}
#include "moc_eventhandler.cpp"

View File

@@ -0,0 +1,262 @@
// SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#pragma once
#include <QDateTime>
#include <QString>
#include <Quotient/events/eventcontent.h>
namespace Quotient
{
namespace EventContent
{
class FileInfo;
}
class RoomEvent;
class RoomMember;
class RoomMessageEvent;
}
class NeoChatRoom;
/**
* @class EventHandler
*
* This class is designed to handle a Quotient::RoomEvent allowing data to be extracted
* in a form ready for the NeoChat UI.
*
* To use this properly both the room and the event should be set (and the event should
* be from the given room).
*
* @note EventHandler will always try to return something even when not properly
* initialised, this is usually the best empty value it can create with available
* information. This is to minimize warnings from QML especially during startup
* and room changes.
*/
class EventHandler
{
public:
/**
* @brief Get the display name of the event author.
*
* This method is special in that it will return
* the old display name of the author if the current event is one that caused it
* to change. This allows for scenarios where the UI wishes to notify that a
* user's display name has changed and what it changed from.
*
* @param isPending whether the event is pending as this cannot be derived from
* just the event object.
*/
static QString authorDisplayName(const NeoChatRoom *room, const Quotient::RoomEvent *event, bool isPending = false);
/**
* @brief Get the display name of the event author but with any newlines removed.
*
* Turns out you can put newlines in your display name so we need to handle that
* primarily for the room list subtitle.
*
* @param isPending whether the event is pending as this cannot be derived from
* just the event object.
*/
static QString singleLineAuthorDisplayname(const NeoChatRoom *room, const Quotient::RoomEvent *event, bool isPending = false);
/**
* @brief Return a QDateTime object for the event timestamp.
*/
static QDateTime time(const NeoChatRoom *room, const Quotient::RoomEvent *event, bool isPending = false);
/**
* @brief Return a QString for the event timestamp.
*
* This is intended to return a string that is read for display in the UI without
* any further manipulation required.
*
* @param relative whether the string is realtive to the current date, i.e.
* Yesterday or Wednesday, etc.
* @param format the QLocale::FormatType to use.
* @param isPending whether the event is pending as this cannot be derived from
* just the event object.
* @param lastUpdated the time the event was last updated locally as this cannot be
* obtained from the event.
*/
static QString timeString(const NeoChatRoom *room,
const Quotient::RoomEvent *event,
bool relative,
QLocale::FormatType format = QLocale::ShortFormat,
bool isPending = false);
/**
* @brief Return a QString for the event timestamp.
*
* This is intended to return a string that is read for display in the UI without
* any further manipulation required.
*
* @param format the format to use as a string.
* @param isPending whether the event is pending as this cannot be derived from
* just the event object.
* @param lastUpdated the time the event was last updated locally as this cannot be
* obtained from the event.
*/
static QString timeString(const NeoChatRoom *room, const Quotient::RoomEvent *event, const QString &format, bool isPending = false);
/**
* @brief Whether the event should be highlighted in the timeline.
*
* @note Messages in direct chats are never highlighted.
*/
static bool isHighlighted(const NeoChatRoom *room, const Quotient::RoomEvent *event);
/**
* @brief Whether the event should be hidden in the timeline.
*
* This could be for numerous reasons, e.g. if it's a replacement event, if the
* user has hidden all state events or if the sender has been ignored by the local
* user.
*/
static bool isHidden(const NeoChatRoom *room, const Quotient::RoomEvent *event, std::function<bool(const Quotient::RoomEvent *)> filter = {});
/**
* @brief The input format of the body in the message.
*
* I.e. if the message has only a body the format will be Qt::PlainText, if it
* has a formatted body it will be Qt::RichText.
*/
static Qt::TextFormat messageBodyInputFormat(const Quotient::RoomMessageEvent &event);
/**
* @brief Output a string for the room message content without any formatting.
*
* This is the content of the formatted_body key if present or the body key if
* not.
*/
static QString rawMessageBody(const Quotient::RoomMessageEvent &event);
/**
* @brief Output a string for the message content ready for display in a rich text field.
*
* The output string is dependant upon the event type and the desired output format.
*
* For most messages this is the body content of the message. For media messages
* this will be the caption and for state events it will be a string specific
* to that event with some dynamic details about the event added.
*
* E.g. For a room topic state event the text will be:
* "set the topic to: <new topic text>"
*
* @param stripNewlines whether the output should have new lines in it.
*/
static QString richBody(const NeoChatRoom *room, const Quotient::RoomEvent *event, bool stripNewlines = false);
/**
* @brief Output a string for the message content ready for display in a plain text field.
*
* The output string is dependant upon the event type and the desired output format.
*
* For most messages this is the body content of the message. For media messages
* this will be the caption and for state events it will be a string specific
* to that event with some dynamic details about the event added.
*
* E.g. For a room topic state event the text will be:
* "set the topic to: <new topic text>"
*
* @param stripNewlines whether the output should have new lines in it.
*/
static QString plainBody(const NeoChatRoom *room, const Quotient::RoomEvent *event, bool stripNewlines = false);
/**
* @brief Output the original body for the message content, useful for editing the original message.
*
* The event type must be a room message event.
*/
static QString markdownBody(const Quotient::RoomEvent *event);
/**
* @brief Output a generic string for the message content ready for display.
*
* The output string is dependant upon the event type.
*
* Unlike EventHandler::getRichBody or EventHandler::getPlainBody the string
* is the same for all events of the same type.
*
* E.g. For a message the text will be:
* "sent a message"
*
* @sa richBody(), plainBody()
*/
static QString genericBody(const NeoChatRoom *room, const Quotient::RoomEvent *event);
/**
* @brief Output a string for the event to be used as a RoomList subtitle.
*
* The output includes the username followed by the plain message, all with no
* line breaks.
*/
static QString subtitleText(const NeoChatRoom *room, const Quotient::RoomEvent *event);
/**
* @brief Return the media info for the event.
*
* An empty QVariantMap will be returned for any event that doesn't have any
* media info.
*
* @return This should consist of the following:
* - source - The mxc URL for the media.
* - mimeType - The MIME type of the media (should be image/xxx for this delegate).
* - mimeIcon - The MIME icon name (should be image-xxx).
* - size - The file size in bytes.
* - width - The width in pixels of the audio media.
* - height - The height in pixels of the audio media.
* - tempInfo - mediaInfo (with the same properties as this except no tempInfo) for a temporary image while the file downloads.
* - isSticker - Whether the image is a sticker or not
*/
static QVariantMap mediaInfo(const NeoChatRoom *room, const Quotient::RoomEvent *event);
/**
* @brief Get the author of the event replied to in context of the room.
*
* An empty Quotient::RoomMember will be returned if the EventHandler hasn't had
* the room or event initialised.
*
* @param isPending if the event is pending, i.e. has not been confirmed by
* the server.
*
* @return a Quotient::RoomMember object for the user.
*
* @sa Quotient::RoomMember
*/
static Quotient::RoomMember replyAuthor(const NeoChatRoom *room, const Quotient::RoomEvent *event);
/**
* @brief Return the latitude for the event.
*
* Returns -100.0 if the event doesn't have a location (latitudes are in the
* range -90deg to +90deg so -100 is out of range).
*/
static float latitude(const Quotient::RoomEvent *event);
/**
* @brief Return the longitude for the event.
*
* Returns -200.0 if the event doesn't have a location (latitudes are in the
* range -180deg to +180deg so -200 is out of range).
*/
static float longitude(const Quotient::RoomEvent *event);
/**
* @brief Return the type of location marker for the event.
*/
static QString locationAssetType(const Quotient::RoomEvent *event);
private:
static QString getBody(const NeoChatRoom *room, const Quotient::RoomEvent *event, Qt::TextFormat format, bool stripNewlines);
static QString getMessageBody(const NeoChatRoom *room, const Quotient::RoomMessageEvent &event, Qt::TextFormat format, bool stripNewlines);
static QVariantMap getMediaInfoForEvent(const NeoChatRoom *room, const Quotient::RoomEvent *event);
QVariantMap static getMediaInfoFromFileInfo(const NeoChatRoom *room,
const Quotient::EventContent::FileContentBase *fileContent,
const QString &eventId,
bool isThumbnail = false,
bool isSticker = false);
static QVariantMap getMediaInfoFromTumbnail(const NeoChatRoom *room, const Quotient::EventContent::Thumbnail &thumbnail, const QString &eventId);
};

View File

@@ -0,0 +1,83 @@
// SPDX-FileCopyrightText: 2021-2023 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: LGPL-2.0-or-later
#include "imagepackevent.h"
#include <QJsonObject>
using namespace Quotient;
ImagePackEventContent::ImagePackEventContent(const QJsonObject &json)
{
if (json.contains("pack"_L1)) {
pack = ImagePackEventContent::Pack{
fromJson<std::optional<QString>>(json["pack"_L1].toObject()["display_name"_L1]),
fromJson<std::optional<QUrl>>(json["pack"_L1].toObject()["avatar_url"_L1]),
fromJson<std::optional<QStringList>>(json["pack"_L1].toObject()["usage"_L1]),
fromJson<std::optional<QString>>(json["pack"_L1].toObject()["attribution"_L1]),
};
} else {
pack = std::nullopt;
}
const auto &keys = json["images"_L1].toObject().keys();
for (const auto &k : keys) {
std::optional<EventContent::ImageInfo> info;
if (json["images"_L1][k].toObject().contains("info"_L1)) {
info = EventContent::ImageInfo(QUrl(json["images"_L1][k]["url"_L1].toString()), json["images"_L1][k]["info"_L1].toObject(), k);
} else {
info = std::nullopt;
}
images += ImagePackImage{
k,
fromJson<QUrl>(json["images"_L1][k]["url"_L1].toString()),
fromJson<std::optional<QString>>(json["images"_L1][k]["body"_L1]),
info,
fromJson<std::optional<QStringList>>(json["images"_L1][k]["usage"_L1]),
};
}
}
void ImagePackEventContent::fillJson(QJsonObject *o) const
{
if (pack) {
QJsonObject packJson;
if (pack->displayName) {
packJson["display_name"_L1] = *pack->displayName;
}
if (pack->usage) {
QJsonArray usageJson;
for (const auto &usage : *pack->usage) {
usageJson += usage;
}
packJson["usage"_L1] = usageJson;
}
if (pack->avatarUrl) {
packJson["avatar_url"_L1] = pack->avatarUrl->toString();
}
if (pack->attribution) {
packJson["attribution"_L1] = *pack->attribution;
}
(*o)["pack"_L1] = packJson;
}
QJsonObject imagesJson;
for (const auto &image : images) {
QJsonObject imageJson;
imageJson["url"_L1] = image.url.toString();
if (image.body) {
imageJson["body"_L1] = *image.body;
}
if (image.usage) {
QJsonArray usageJson;
for (const auto &usage : *image.usage) {
usageJson += usage;
}
imageJson["usage"_L1] = usageJson;
}
if (image.info.has_value()) {
imageJson["info"_L1] = Quotient::EventContent::toInfoJson(*image.info);
}
imagesJson[image.shortcode] = imageJson;
}
(*o)["images"_L1] = imagesJson;
}

View File

@@ -0,0 +1,92 @@
// SPDX-FileCopyrightText: 2021-2023 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: LGPL-2.0-or-later
#pragma once
#include <QList>
#include <Quotient/events/eventcontent.h>
#include <Quotient/events/stateevent.h>
namespace Quotient
{
/**
* @class ImagePackEventContent
*
* A class to define the content of an image pack event.
*
* See Matrix MSC2545 for more details.
* https://github.com/Sorunome/matrix-doc/blob/soru/emotes/proposals/2545-emotes.md
*
* @sa ImagePackEvent
*/
class ImagePackEventContent
{
public:
/**
* @brief Defines the properties of an image pack.
*/
struct Pack {
std::optional<QString> displayName; /**< The display name of the pack. */
std::optional<QUrl> avatarUrl; /**< The source mxc URL for the pack avatar. */
std::optional<QStringList> usage; /**< An array of the usages for this pack. Possible usages are "emoticon" and "sticker". */
std::optional<QString> attribution; /**< The attribution for the pack author(s). */
};
/**
* @brief Defines the properties of an image pack image.
*/
struct ImagePackImage {
QString shortcode; /**< The shortcode for the image. */
QUrl url; /**< The mxc URL for this image. */
std::optional<QString> body; /**< An optional text body for this image. */
std::optional<Quotient::EventContent::ImageInfo> info; /**< The ImageInfo object used for the info block of m.sticker events. */
/**
* @brief An array of the usages for this image.
*
* The possible values match those of the usage key of a pack object.
*/
std::optional<QStringList> usage;
};
/**
* @brief Return the pack properties.
*
* @sa Pack
*/
std::optional<Pack> pack;
/**
* @brief Return a vector of images in the pack.
*
* @sa ImagePackImage
*/
QList<ImagePackEventContent::ImagePackImage> images;
explicit ImagePackEventContent(const QJsonObject &o);
/**
* @brief The definition of how to convert the content to Json.
*
* This is a specialization of the standard fillJson function from libQuotient.
*
* @sa Quotient::converters
*/
void fillJson(QJsonObject *o) const;
};
/**
* @class ImagePackEvent
*
* Class to define an image pack state event.
*
* The event content is ImagePackEventContent.
*
* @sa Quotient::StateEvent, ImagePackEventContent
*/
class ImagePackEvent : public KeyedStateEventBase<ImagePackEvent, ImagePackEventContent>
{
public:
QUO_EVENT(ImagePackEvent, "im.ponies.room_emotes")
using KeyedStateEventBase::KeyedStateEventBase;
};
}

View File

@@ -0,0 +1,14 @@
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#pragma once
#include <Quotient/events/simplestateevents.h>
namespace Quotient
{
// Defined so we can directly switch on type.
DEFINE_SIMPLE_STATE_EVENT(LocationBeaconEvent, "org.matrix.msc3672.beacon_info", QString, body, "body")
} // namespace Quotient

View File

@@ -0,0 +1,72 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: LGPL-2.0-or-later
#include "pollevent.h"
#include <Quotient/converters.h>
using namespace Quotient;
PollKind::Kind PollStartEvent::kind() const
{
return content().kind;
}
int PollStartEvent::maxSelections() const
{
return content().maxSelection > 0 ? content().maxSelection : 1;
}
QString PollStartEvent::question() const
{
return content().question;
}
QList<EventContent::Answer> PollStartEvent::answers() const
{
return content().answers;
}
PollResponseEvent::PollResponseEvent(const QJsonObject &obj)
: RoomEvent(obj)
{
}
PollResponseEvent::PollResponseEvent(const QString &pollStartEventId, QStringList responses)
: RoomEvent(basicJson(TypeId,
{{"org.matrix.msc3381.poll.response"_L1, QJsonObject{{"answers"_L1, QJsonArray::fromStringList(responses)}}},
{"m.relates_to"_L1, QJsonObject{{"rel_type"_L1, "m.reference"_L1}, {"event_id"_L1, pollStartEventId}}}}))
{
}
QStringList PollResponseEvent::selections() const
{
const auto jsonSelections = contentPart<QJsonObject>("org.matrix.msc3381.poll.response"_L1)["answers"_L1].toArray();
QStringList selections;
for (const auto &selection : jsonSelections) {
selections += selection.toString();
}
return selections;
}
std::optional<EventRelation> PollResponseEvent::relatesTo() const
{
return contentPart<std::optional<EventRelation>>(RelatesToKey);
}
PollEndEvent::PollEndEvent(const QJsonObject &obj)
: RoomEvent(obj)
{
}
PollEndEvent::PollEndEvent(const QString &pollStartEventId, const QString &endText)
: RoomEvent(basicJson(TypeId,
{{"org.matrix.msc1767.text"_L1, endText},
{"org.matrix.msc3381.poll.end"_L1, QJsonObject{}},
{"m.relates_to"_L1, QJsonObject{{"rel_type"_L1, "m.reference"_L1}, {"event_id"_L1, pollStartEventId}}}}))
{
}
std::optional<EventRelation> PollEndEvent::relatesTo() const
{
return contentPart<std::optional<EventRelation>>(RelatesToKey);
}

View File

@@ -0,0 +1,235 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: LGPL-2.0-or-later
#pragma once
#include <QQmlEngine>
#include <Quotient/converters.h>
#include <Quotient/events/eventrelation.h>
#include <Quotient/events/roomevent.h>
#include <Quotient/quotient_common.h>
using namespace Qt::StringLiterals;
/**
* @class PollKind
*
* This class is designed to define the PollKind enumeration.
*/
class PollKind : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("")
public:
/**
* @brief Enum representing the available poll kinds.
*/
enum Kind {
Disclosed, /**< The poll results can been seen after the user votes. */
Undisclosed, /**< The poll results can only been seen after the poll ends. */
};
Q_ENUM(Kind);
/**
* @brief Return the string for the given Kind.
*
* @sa Kind
*/
static QString stringForKind(Kind kind)
{
switch (kind) {
case Undisclosed:
return "org.matrix.msc3381.poll.undisclosed"_L1;
default:
return "org.matrix.msc3381.poll.disclosed"_L1;
}
}
/**
* @brief Return the Kind for the given string.
*
* @sa Kind
*/
static Kind kindForString(const QString &kindString)
{
if (kindString == "org.matrix.msc3381.poll.undisclosed"_L1) {
return Undisclosed;
}
return Disclosed;
}
};
namespace Quotient
{
namespace EventContent
{
/**
* @brief An answer to the poll.
*/
struct Answer {
Q_GADGET
Q_PROPERTY(QString id MEMBER id CONSTANT)
Q_PROPERTY(QString text MEMBER text CONSTANT)
public:
QString id;
QString text;
int operator==(const Answer &right) const
{
return id == right.id && text == right.text;
}
};
/**
* @brief Struct representing the content of a poll event.
*/
struct PollStartContent {
PollKind::Kind kind;
int maxSelection;
QString question;
QList<EventContent::Answer> answers;
};
} // namespace EventContent
template<>
inline EventContent::Answer fromJson(const QJsonObject &jo)
{
return EventContent::Answer{fromJson<QString>(jo["id"_L1]), fromJson<QString>(jo["org.matrix.msc1767.text"_L1])};
}
template<>
inline auto toJson(const EventContent::Answer &c)
{
QJsonObject jo;
addParam<IfNotEmpty>(jo, "id"_L1, c.id);
addParam<IfNotEmpty>(jo, "org.matrix.msc1767.text"_L1, c.text);
return jo;
}
template<>
inline EventContent::PollStartContent fromJson(const QJsonObject &jo)
{
return EventContent::PollStartContent{
PollKind::kindForString(jo["org.matrix.msc3381.poll.start"_L1]["kind"_L1].toString()),
fromJson<int>(jo["org.matrix.msc3381.poll.start"_L1]["max_selections"_L1]),
fromJson<QString>(jo["org.matrix.msc3381.poll.start"_L1]["question"_L1]["org.matrix.msc1767.text"_L1]),
fromJson<QList<EventContent::Answer>>(jo["org.matrix.msc3381.poll.start"_L1]["answers"_L1]),
};
}
template<>
inline auto toJson(const EventContent::PollStartContent &c)
{
QJsonObject innerJo;
addParam<IfNotEmpty>(innerJo, "kind"_L1, PollKind::stringForKind(c.kind));
addParam(innerJo, "max_selections"_L1, c.maxSelection);
if (innerJo["max_selections"_L1].toInt() < 1) {
innerJo["max_selections"_L1] = 1;
}
innerJo.insert("question"_L1, QJsonObject{{"org.matrix.msc1767.text"_L1, c.question}});
addParam<IfNotEmpty>(innerJo, "answers"_L1, c.answers);
QJsonObject jo;
auto textString = c.question;
for (int i = 0; i < c.answers.length(); ++i) {
textString.append("\n%1. %2"_L1.arg(QString::number(i + 1), c.answers.at(i).text));
}
addParam<IfNotEmpty>(jo, "org.matrix.msc1767.text"_L1, textString);
jo.insert("org.matrix.msc3381.poll.start"_L1, innerJo);
return jo;
}
/**
* @class PollStartEvent
*
* Class to define a poll start event.
*
* See MSC3381 for full details on polls in the matrix spec
* https://github.com/matrix-org/matrix-spec-proposals/blob/travis/msc/polls/proposals/3381-polls.md.
*
* @sa Quotient::RoomEvent
*/
class PollStartEvent : public EventTemplate<PollStartEvent, RoomEvent, EventContent::PollStartContent>
{
public:
QUO_EVENT(PollStartEvent, "org.matrix.msc3381.poll.start");
using EventTemplate::EventTemplate;
/**
* @brief The poll kind.
*/
PollKind::Kind kind() const;
/**
* @brief The maximum number of options a user can select in a poll.
*/
int maxSelections() const;
/**
* @brief The question being asked in the poll.
*/
QString question() const;
/**
* @brief The list of answers to the poll.
*/
QList<EventContent::Answer> answers() const;
};
/**
* @class PollResponseEvent
*
* Class to define a poll response event.
*
* See MSC3381 for full details on polls in the matrix spec
* https://github.com/matrix-org/matrix-spec-proposals/blob/travis/msc/polls/proposals/3381-polls.md.
*
* @sa Quotient::RoomEvent
*/
class PollResponseEvent : public RoomEvent
{
public:
QUO_EVENT(PollResponseEvent, "org.matrix.msc3381.poll.response");
explicit PollResponseEvent(const QJsonObject &obj);
explicit PollResponseEvent(const QString &pollStartEventId, QStringList responses);
/**
* @brief The selected answers to the poll.
*/
QStringList selections() const;
/**
* @brief The EventRelation pointing to the PollStartEvent.
*/
std::optional<EventRelation> relatesTo() const;
};
/**
* @class PollEndEvent
*
* Class to define a poll end event.
*
* See MSC3381 for full details on polls in the matrix spec
* https://github.com/matrix-org/matrix-spec-proposals/blob/travis/msc/polls/proposals/3381-polls.md.
*
* @sa Quotient::RoomEvent
*/
class PollEndEvent : public RoomEvent
{
public:
QUO_EVENT(PollEndEvent, "org.matrix.msc3381.poll.end");
explicit PollEndEvent(const QJsonObject &obj);
explicit PollEndEvent(const QString &pollStartEventId, const QString &endText);
/**
* @brief The EventRelation pointing to the PollStartEvent.
*/
std::optional<EventRelation> relatesTo() const;
};
}

View File

@@ -0,0 +1,14 @@
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#pragma once
#include <Quotient/events/simplestateevents.h>
namespace Quotient
{
// Defined so we can directly switch on type.
DEFINE_SIMPLE_STATE_EVENT(WidgetEvent, "im.vector.modular.widgets", QString, name, "name")
} // namespace Quotient

View File

@@ -0,0 +1,67 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: GPL-2.0-or-later
#include "filetransferpseudojob.h"
#include <KLocalizedString>
#include <QDebug>
#include <QUrl>
FileTransferPseudoJob::FileTransferPseudoJob(Operation operation, const QString &path, const QString &eventId)
: KJob()
, m_path(path)
, m_eventId(eventId)
, m_operation(operation)
{
setCapabilities(KJob::Killable);
}
void FileTransferPseudoJob::fileTransferProgress(const QString &id, qint64 progress, qint64 total)
{
if (id != m_eventId) {
return;
}
setProcessedAmount(Unit::Bytes, progress);
setTotalAmount(Unit::Bytes, total);
}
void FileTransferPseudoJob::fileTransferCompleted(const QString &id, const QUrl &localFile)
{
Q_UNUSED(localFile);
if (id != m_eventId) {
return;
}
emitResult();
}
void FileTransferPseudoJob::fileTransferFailed(const QString &id, const QString &errorMessage)
{
if (id != m_eventId) {
return;
}
setErrorText(errorMessage);
emitResult();
}
void FileTransferPseudoJob::fileTransferCanceled(const QString &id)
{
if (id != m_eventId) {
return;
}
setError(KJob::KilledJobError);
emitResult();
}
void FileTransferPseudoJob::start()
{
setTotalAmount(Unit::Files, 1);
Q_EMIT description(this,
m_operation == Download ? i18nc("Job heading, like 'Copying'", "Downloading") : i18nc("Job heading, like 'Copying'", "Uploading"),
{i18nc("The URL being downloaded/uploaded", "Source"), m_path},
{i18nc("The location being downloaded to", "Destination"), m_path});
}
bool FileTransferPseudoJob::doKill()
{
Q_EMIT cancelRequested(m_eventId);
return true;
}

View File

@@ -0,0 +1,62 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <KJob>
#include <QString>
/**
* @class FileTransferPseudoJob
*
* A class inherited from KJob to track a file download.
*
* @sa KJob
*/
class FileTransferPseudoJob : public KJob
{
Q_OBJECT
public:
enum Operation {
Download,
Upload,
};
Q_ENUM(Operation)
FileTransferPseudoJob(Operation operation, const QString &srcDest, const QString &path);
/**
* @brief Set the current number of bytes transferred.
*/
void fileTransferProgress(const QString &id, qint64 progress, qint64 total);
/**
* @brief Set the file transfer as complete.
*/
void fileTransferCompleted(const QString &id, const QUrl &localFile);
/**
* @brief Set the file transfer as failed.
*/
void fileTransferFailed(const QString &id, const QString &errorMessage = {});
/**
* @brief Set the file transfer as canceled.
*/
void fileTransferCanceled(const QString &id);
/**
* @brief Start the file transfer.
*/
void start() override;
protected:
bool doKill() override;
Q_SIGNALS:
void cancelRequested(const QString &id);
private:
QString m_path;
QString m_eventId;
Operation m_operation;
};

View File

@@ -0,0 +1,116 @@
// SPDX-FileCopyrightText: 2022 Bharadwaj Raju <bharadwaj.raju777@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-or-later OR LicenseRef-KDE-Accepted-GPL
#include "linkpreviewer.h"
#include <Quotient/connection.h>
#include <Quotient/csapi/authed-content-repo.h>
#include <Quotient/csapi/content-repo.h>
#include <Quotient/events/roommessageevent.h>
#include "utils.h"
using namespace Quotient;
LinkPreviewer::LinkPreviewer(const QUrl &url, QObject *parent)
: QObject(parent)
, m_loaded(false)
, m_url(url)
{
Q_ASSERT(dynamic_cast<Connection *>(this->parent()));
connect(this, &LinkPreviewer::urlChanged, this, &LinkPreviewer::emptyChanged);
loadUrlPreview();
}
bool LinkPreviewer::loaded() const
{
return m_loaded;
}
QString LinkPreviewer::title() const
{
return m_title;
}
QString LinkPreviewer::description() const
{
return m_description;
}
QUrl LinkPreviewer::imageSource() const
{
return m_imageSource;
}
QUrl LinkPreviewer::url() const
{
return m_url;
}
void LinkPreviewer::loadUrlPreview()
{
if (m_url.scheme() == u"https"_s) {
m_loaded = false;
Q_EMIT loadedChanged();
auto conn = dynamic_cast<Connection *>(this->parent());
if (conn == nullptr) {
return;
}
BaseJob *job = nullptr;
if (conn->supportedMatrixSpecVersions().contains("v1.11"_L1)) {
job = conn->callApi<GetUrlPreviewAuthedJob>(m_url);
} else {
QT_IGNORE_DEPRECATIONS(job = conn->callApi<GetUrlPreviewJob>(m_url);)
}
connect(job, &BaseJob::success, this, [this, job, conn]() {
const auto json = job->jsonData();
m_title = json["og:title"_L1].toString().trimmed();
m_description = json["og:description"_L1].toString().trimmed().replace("\n"_L1, " "_L1);
auto imageUrl = QUrl(json["og:image"_L1].toString());
if (imageUrl.isValid() && imageUrl.scheme() == u"mxc"_s) {
m_imageSource = conn->makeMediaUrl(imageUrl);
} else {
m_imageSource = QUrl();
}
m_loaded = true;
Q_EMIT titleChanged();
Q_EMIT descriptionChanged();
Q_EMIT imageSourceChanged();
Q_EMIT loadedChanged();
});
}
}
bool LinkPreviewer::empty() const
{
return m_url.isEmpty();
}
QList<QUrl> LinkPreviewer::linkPreviews(QString string)
{
auto data = string.remove(TextRegex::removeRichReply);
auto linksMatch = TextRegex::url.globalMatch(data);
QList<QUrl> links;
while (linksMatch.hasNext()) {
auto link = linksMatch.next().captured();
if (!link.contains(u"matrix.to"_s) && !links.contains(QUrl(link))) {
links += QUrl(link);
}
}
return links;
}
bool LinkPreviewer::hasPreviewableLinks(const QString &string)
{
return !linkPreviews(string).isEmpty();
}
#include "moc_linkpreviewer.cpp"

View File

@@ -0,0 +1,100 @@
// SPDX-FileCopyrightText: 2022 Bharadwaj Raju <bharadwaj.raju777@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-or-later OR LicenseRef-KDE-Accepted-GPL
#pragma once
#include <QObject>
#include <QQmlEngine>
#include <QUrl>
class NeoChatRoom;
/**
* @class LinkPreviewer
*
* A class to download the link preview info for a URL and provide a QML interface for it.
*
* To use set the URL property and then access the other parameters which will be
* populated once loaded is true.
*/
class LinkPreviewer : public QObject
{
Q_OBJECT
QML_ELEMENT
/**
* @brief The URL to get the preview for.
*/
Q_PROPERTY(QUrl url READ url NOTIFY urlChanged)
/**
* @brief Whether the preview information has been loaded.
*/
Q_PROPERTY(bool loaded READ loaded NOTIFY loadedChanged)
/**
* @brief The title of the preview.
*/
Q_PROPERTY(QString title READ title NOTIFY titleChanged)
/**
* @brief The description of the preview.
*/
Q_PROPERTY(QString description READ description NOTIFY descriptionChanged)
/**
* @brief The image source for the preview.
*/
Q_PROPERTY(QUrl imageSource READ imageSource NOTIFY imageSourceChanged)
/**
* @brief Whether there is a link to preview.
*
* A linkPreviwer is empty if the URL is empty.
*/
Q_PROPERTY(bool empty READ empty NOTIFY emptyChanged)
public:
LinkPreviewer() = default;
explicit LinkPreviewer(const QUrl &url, QObject *parent = nullptr);
[[nodiscard]] QUrl url() const;
[[nodiscard]] bool loaded() const;
[[nodiscard]] QString title() const;
[[nodiscard]] QString description() const;
[[nodiscard]] QUrl imageSource() const;
[[nodiscard]] bool empty() const;
/**
* @brief Whether the given string has at least 1 pre-viewable link.
*
* A link is only pre-viewable if it is http, https or something starting with www.
*/
static bool hasPreviewableLinks(const QString &string);
/**
* @brief Return previewable links from the given string.
*
* This function is designed to give only links that should be previewed so
* http, https or something starting with www. The first valid link is returned.
*/
static QList<QUrl> linkPreviews(QString string);
private:
bool m_loaded;
QString m_title = QString();
QString m_description = QString();
QUrl m_imageSource = QUrl();
QUrl m_url;
void loadUrlPreview();
Q_SIGNALS:
void loadedChanged();
void titleChanged();
void descriptionChanged();
void imageSourceChanged();
void urlChanged();
void emptyChanged();
};
Q_DECLARE_METATYPE(LinkPreviewer *)

View File

@@ -0,0 +1,22 @@
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#pragma once
#include "enums/messagecomponenttype.h"
struct MessageComponent {
MessageComponentType::Type type = MessageComponentType::Other;
QString content;
QVariantMap attributes;
int operator==(const MessageComponent &right) const
{
return type == right.type && content == right.content && attributes == right.attributes;
}
bool isEmpty() const
{
return type == MessageComponentType::Other;
}
};

View File

@@ -0,0 +1,625 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: LGPL-2.0-or-later
#include "actionsmodel.h"
#include "chatbarcache.h"
#include "enums/messagetype.h"
#include "neochatconnection.h"
#include "neochatroom.h"
#include <Quotient/events/eventcontent.h>
#include <Quotient/events/roommemberevent.h>
#include <Quotient/events/roompowerlevelsevent.h>
#include <Quotient/user.h>
#include <KLocalizedString>
using Action = ActionsModel::Action;
using namespace Quotient;
using namespace Qt::StringLiterals;
bool ActionsModel::m_allowQuickEdit = false;
QStringList rainbowColors{"#ff2b00"_L1, "#ff5500"_L1, "#ff8000"_L1, "#ffaa00"_L1, "#ffd500"_L1, "#ffff00"_L1, "#d4ff00"_L1, "#aaff00"_L1, "#80ff00"_L1,
"#55ff00"_L1, "#2bff00"_L1, "#00ff00"_L1, "#00ff2b"_L1, "#00ff55"_L1, "#00ff80"_L1, "#00ffaa"_L1, "#00ffd5"_L1, "#00ffff"_L1,
"#00d4ff"_L1, "#00aaff"_L1, "#007fff"_L1, "#0055ff"_L1, "#002bff"_L1, "#0000ff"_L1, "#2a00ff"_L1, "#5500ff"_L1, "#7f00ff"_L1,
"#aa00ff"_L1, "#d400ff"_L1, "#ff00ff"_L1, "#ff00d4"_L1, "#ff00aa"_L1, "#ff0080"_L1, "#ff0055"_L1, "#ff002b"_L1, "#ff0000"_L1};
auto leaveRoomLambda = [](const QString &text, NeoChatRoom *room, ChatBarCache *) {
if (text.isEmpty()) {
Q_EMIT room->showMessage(MessageType::Information, i18n("Leaving this room."));
room->connection()->leaveRoom(room);
} else {
QRegularExpression roomRegex(uR"(^[#!][^:]+:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?)"_s);
auto regexMatch = roomRegex.match(text);
if (!regexMatch.hasMatch()) {
Q_EMIT room->showMessage(MessageType::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(MessageType::Information, i18nc("Leaving room <roomname>.", "Leaving room %1.", text));
room->connection()->leaveRoom(leaving);
} else {
Q_EMIT room->showMessage(MessageType::Information, i18nc("Room <roomname> not found", "Room %1 not found.", text));
}
}
return QString();
};
auto roomNickLambda = [](const QString &text, NeoChatRoom *room, ChatBarCache *) {
if (text.isEmpty()) {
Q_EMIT room->showMessage(MessageType::Error, i18n("No new nickname provided, no changes will happen."));
} else {
room->connection()->user()->rename(text, room);
}
return QString();
};
QList<ActionsModel::Action> actions{
Action{
u"shrug"_s,
[](const QString &message, NeoChatRoom *, ChatBarCache *) {
return u"¯\\\\_(ツ)_/¯ %1"_s.arg(message);
},
Quotient::RoomMessageEvent::MsgType::Text,
kli18n("<message>"),
kli18n("Prepends ¯\\_(ツ)_/¯ to a plain-text message"),
},
Action{
u"lenny"_s,
[](const QString &message, NeoChatRoom *, ChatBarCache *) {
return u"( ͡° ͜ʖ ͡°) %1"_s.arg(message);
},
Quotient::RoomMessageEvent::MsgType::Text,
kli18n("<message>"),
kli18n("Prepends ( ͡° ͜ʖ ͡°) to a plain-text message"),
},
Action{
u"tableflip"_s,
[](const QString &message, NeoChatRoom *, ChatBarCache *) {
return u"(╯°□°)╯︵ ┻━┻ %1"_s.arg(message);
},
Quotient::RoomMessageEvent::MsgType::Text,
kli18n("<message>"),
kli18n("Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message"),
},
Action{
u"unflip"_s,
[](const QString &message, NeoChatRoom *, ChatBarCache *) {
return u"┬──┬ ( ゜-゜ノ) %1"_s.arg(message);
},
Quotient::RoomMessageEvent::MsgType::Text,
kli18n("<message>"),
kli18n("Prepends ┬──┬ ( ゜-゜ノ) to a plain-text message"),
},
Action{
u"rainbow"_s,
[](const QString &text, NeoChatRoom *room, ChatBarCache *chatBarCache) {
QString rainbowText;
for (int i = 0; i < text.length(); i++) {
rainbowText += u"<font color='%2'>%3</font>"_s.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.
auto content = std::make_unique<Quotient::EventContent::TextContent>(rainbowText, u"text/html"_s);
EventRelation relatesTo =
chatBarCache->isReplying() ? EventRelation::replyTo(chatBarCache->replyId()) : EventRelation::replace(chatBarCache->editId());
room->post<Quotient::RoomMessageEvent>("/rainbow %1"_L1.arg(text), MessageEventType::Text, std::move(content), relatesTo);
return QString();
},
std::nullopt,
kli18n("<message>"),
kli18n("Sends the given message colored as a rainbow"),
},
Action{
u"rainbowme"_s,
[](const QString &text, NeoChatRoom *room, ChatBarCache *chatBarCache) {
QString rainbowText;
for (int i = 0; i < text.length(); i++) {
rainbowText += u"<font color='%2'>%3</font>"_s.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.
auto content = std::make_unique<Quotient::EventContent::TextContent>(rainbowText, u"text/html"_s);
EventRelation relatesTo =
chatBarCache->isReplying() ? EventRelation::replyTo(chatBarCache->replyId()) : EventRelation::replace(chatBarCache->editId());
room->post<Quotient::RoomMessageEvent>(u"/rainbow %1"_s.arg(text), MessageEventType::Emote, std::move(content), relatesTo);
return QString();
},
std::nullopt,
kli18n("<message>"),
kli18n("Sends the given emote colored as a rainbow"),
},
Action{
u"plain"_s,
[](const QString &text, NeoChatRoom *room, ChatBarCache *) {
#if Quotient_VERSION_MINOR > 9
room->postText(text.toHtmlEscaped());
#else
room->postPlainText(text.toHtmlEscaped());
#endif
return QString();
},
std::nullopt,
kli18n("<message>"),
kli18n("Sends the given message as plain text"),
},
Action{
u"spoiler"_s,
[](const QString &text, NeoChatRoom *room, ChatBarCache *chatBarCache) {
// Ideally, we would just return rainbowText and let that do the rest, but the colors don't survive markdownToHTML.
auto content = std::make_unique<Quotient::EventContent::TextContent>(u"<span data-mx-spoiler>%1</span>"_s.arg(text), u"text/html"_s);
EventRelation relatesTo =
chatBarCache->isReplying() ? EventRelation::replyTo(chatBarCache->replyId()) : EventRelation::replace(chatBarCache->editId());
room->post<Quotient::RoomMessageEvent>(u"/spoiler %1"_s.arg(text), MessageEventType::Text, std::move(content), relatesTo);
return QString();
},
std::nullopt,
kli18n("<message>"),
kli18n("Sends the given message as a spoiler"),
},
Action{
u"me"_s,
[](const QString &text, NeoChatRoom *, ChatBarCache *) {
return text;
},
RoomMessageEvent::MsgType::Emote,
kli18n("<message>"),
kli18n("Sends the given emote"),
},
Action{
u"notice"_s,
[](const QString &text, NeoChatRoom *, ChatBarCache *) {
return text;
},
RoomMessageEvent::MsgType::Notice,
kli18n("<message>"),
kli18n("Sends the given message as a notice"),
},
Action{
u"invite"_s,
[](const QString &text, NeoChatRoom *room, ChatBarCache *) {
static const QRegularExpression mxidRegex(uR"((^|[][[:space:](){}`'";])([!#@][-a-z0-9_=#/.]{1,252}:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?))"_s);
auto regexMatch = mxidRegex.match(text);
if (!regexMatch.hasMatch()) {
Q_EMIT room->showMessage(MessageType::Error, i18nc("'<text>' does not look like a matrix id.", "'%1' does not look like a matrix id.", text));
return QString();
}
const RoomMemberEvent *roomMemberEvent = room->currentState().get<RoomMemberEvent>(text);
if (roomMemberEvent && roomMemberEvent->membership() == Membership::Invite) {
Q_EMIT room->showMessage(MessageType::Information,
i18nc("<user> is already invited to this room.", "%1 is already invited to this room.", text));
return QString();
}
if (roomMemberEvent && roomMemberEvent->membership() == Membership::Ban) {
Q_EMIT room->showMessage(MessageType::Information, i18nc("<user> is banned from this room.", "%1 is banned from this room.", text));
return QString();
}
if (room->localMember().id() == text) {
Q_EMIT room->showMessage(MessageType::Positive, i18n("You are already in this room."));
return QString();
}
if (room->joinedMemberIds().contains(text)) {
Q_EMIT room->showMessage(MessageType::Information, i18nc("<user> is already in this room.", "%1 is already in this room.", text));
return QString();
}
room->inviteToRoom(text);
Q_EMIT room->showMessage(MessageType::Positive, i18nc("<username> was invited into this room.", "%1 was invited into this room.", text));
return QString();
},
std::nullopt,
kli18n("<user id>"),
kli18n("Invites the user to this room"),
},
Action{
u"join"_s,
[](const QString &text, NeoChatRoom *room, ChatBarCache *) {
QRegularExpression roomRegex(uR"(^[#!][^:]+:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?)"_s);
auto regexMatch = roomRegex.match(text);
if (!regexMatch.hasMatch()) {
Q_EMIT room->showMessage(MessageType::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 targetRoom = text.startsWith(QLatin1Char('!')) ? room->connection()->room(text) : room->connection()->roomByAlias(text);
if (targetRoom) {
ActionsModel::instance().resolveResource(targetRoom->id());
return QString();
}
Q_EMIT room->showMessage(MessageType::Information, i18nc("Joining room <roomname>.", "Joining room %1.", text));
ActionsModel::instance().resolveResource(text, "join"_L1);
return QString();
},
std::nullopt,
kli18n("<room alias or id>"),
kli18n("Joins the given room"),
},
Action{
u"knock"_s,
[](const QString &text, NeoChatRoom *room, ChatBarCache *) {
auto parts = text.split(u" "_s);
QString roomName = parts[0];
QRegularExpression roomRegex(uR"(^[#!][^:]+:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?)"_s);
auto regexMatch = roomRegex.match(roomName);
if (!regexMatch.hasMatch()) {
Q_EMIT room->showMessage(MessageType::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 targetRoom = text.startsWith(QLatin1Char('!')) ? room->connection()->room(text) : room->connection()->roomByAlias(text);
if (targetRoom) {
ActionsModel::instance().resolveResource(targetRoom->id());
return QString();
}
Q_EMIT room->showMessage(MessageType::Information, i18nc("Knocking room <roomname>.", "Knocking room %1.", text));
auto connection = dynamic_cast<NeoChatConnection *>(room->connection());
const auto knownServer = roomName.mid(roomName.indexOf(":"_L1) + 1);
if (parts.length() >= 2) {
ActionsModel::instance().knockRoom(connection, roomName, parts[1], QStringList{knownServer});
} else {
ActionsModel::instance().knockRoom(connection, roomName, QString(), QStringList{knownServer});
}
return QString();
},
std::nullopt,
kli18n("<room alias or id> [<reason>]"),
kli18n("Requests to join the given room"),
},
Action{
u"j"_s,
[](const QString &text, NeoChatRoom *room, ChatBarCache *) {
QRegularExpression roomRegex(uR"(^[#!][^:]+:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?)"_s);
auto regexMatch = roomRegex.match(text);
if (!regexMatch.hasMatch()) {
Q_EMIT room->showMessage(MessageType::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 (room->connection()->room(text) || room->connection()->roomByAlias(text)) {
Q_EMIT room->showMessage(MessageType::Information, i18nc("You are already in room <roomname>.", "You are already in room %1.", text));
return QString();
}
Q_EMIT room->showMessage(MessageType::Information, i18nc("Joining room <roomname>.", "Joining room %1.", text));
ActionsModel::instance().resolveResource(text, "join"_L1);
return QString();
},
std::nullopt,
kli18n("<room alias or id>"),
kli18n("Joins the given room"),
},
Action{
u"part"_s,
leaveRoomLambda,
std::nullopt,
kli18n("[<room alias or id>]"),
kli18n("Leaves the given room or this room, if there is none given"),
},
Action{
u"leave"_s,
leaveRoomLambda,
std::nullopt,
kli18n("[<room alias or id>]"),
kli18n("Leaves the given room or this room, if there is none given"),
},
Action{
u"nick"_s,
[](const QString &text, NeoChatRoom *room, ChatBarCache *) {
if (text.isEmpty()) {
Q_EMIT room->showMessage(MessageType::Error, i18n("No new nickname provided, no changes will happen."));
} else {
room->connection()->user()->rename(text);
}
return QString();
},
std::nullopt,
kli18n("<display name>"),
kli18n("Changes your global display name"),
},
Action{
u"roomnick"_s,
roomNickLambda,
std::nullopt,
kli18n("<display name>"),
kli18n("Changes your display name in this room"),
},
Action{
u"myroomnick"_s,
roomNickLambda,
std::nullopt,
kli18n("<display name>"),
kli18n("Changes your display name in this room"),
},
Action{
u"ignore"_s,
[](const QString &text, NeoChatRoom *room, ChatBarCache *) {
static const QRegularExpression mxidRegex(uR"((^|[][[:space:](){}`'";])([!#@][-a-z0-9_=#/.]{1,252}:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?))"_s);
auto regexMatch = mxidRegex.match(text);
if (!regexMatch.hasMatch()) {
Q_EMIT room->showMessage(MessageType::Error, i18nc("'<text>' does not look like a matrix id.", "'%1' does not look like a matrix id.", text));
return QString();
}
if (room->connection()->ignoredUsers().contains(text)) {
Q_EMIT room->showMessage(MessageType::Information, i18nc("<username> is already ignored.", "%1 is already ignored.", text));
return QString();
}
room->connection()->addToIgnoredUsers(text);
Q_EMIT room->showMessage(MessageType::Positive, i18nc("<username> is now ignored", "%1 is now ignored.", text));
return QString();
},
std::nullopt,
kli18n("<user id>"),
kli18n("Ignores the given user"),
},
Action{
u"unignore"_s,
[](const QString &text, NeoChatRoom *room, ChatBarCache *) {
static const QRegularExpression mxidRegex(uR"((^|[][[:space:](){}`'";])([!#@][-a-z0-9_=#/.]{1,252}:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?))"_s);
auto regexMatch = mxidRegex.match(text);
if (!regexMatch.hasMatch()) {
Q_EMIT room->showMessage(MessageType::Error, i18nc("'<text>' does not look like a matrix id.", "'%1' does not look like a matrix id.", text));
return QString();
}
if (!room->connection()->ignoredUsers().contains(text)) {
Q_EMIT room->showMessage(MessageType::Information, i18nc("<username> is not ignored.", "%1 is not ignored.", text));
return QString();
}
room->connection()->removeFromIgnoredUsers(text);
Q_EMIT room->showMessage(MessageType::Positive, i18nc("<username> is no longer ignored.", "%1 is no longer ignored.", text));
return QString();
},
std::nullopt,
kli18n("<user id>"),
kli18n("Unignores the given user"),
},
Action{
u"react"_s,
[](const QString &text, NeoChatRoom *room, ChatBarCache *chatBarCache) {
if (chatBarCache->replyId().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(chatBarCache->replyId(), text);
return QString();
},
std::nullopt,
kli18n("<reaction text>"),
kli18n("React to the message with the given text"),
},
Action{
u"ban"_s,
[](const QString &text, NeoChatRoom *room, ChatBarCache *) {
auto parts = text.split(u" "_s);
static const QRegularExpression mxidRegex(uR"((^|[][[:space:](){}`'";])([!#@][-a-z0-9_=#/.]{1,252}:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?))"_s);
auto regexMatch = mxidRegex.match(parts[0]);
if (!regexMatch.hasMatch()) {
Q_EMIT room->showMessage(MessageType::Error, i18nc("'<text>' does not look like a matrix id.", "'%1' does not look like a matrix id.", text));
return QString();
}
auto state = room->currentState().get<RoomMemberEvent>(parts[0]);
if (state && state->membership() == Membership::Ban) {
Q_EMIT room->showMessage(MessageType::Information,
i18nc("<user> is already banned from this room.", "%1 is already banned from this room.", text));
return QString();
}
auto plEvent = room->currentState().get<RoomPowerLevelsEvent>();
if (!plEvent) {
return QString();
}
if (plEvent->ban() > plEvent->powerLevelForUser(room->localMember().id())) {
Q_EMIT room->showMessage(MessageType::Error, i18n("You are not allowed to ban users from this room."));
return QString();
}
if (plEvent->powerLevelForUser(room->localMember().id()) <= plEvent->powerLevelForUser(parts[0])) {
Q_EMIT room->showMessage(
MessageType::Error,
i18nc("You are not allowed to ban <username> from this room.", "You are not allowed to ban %1 from this room.", parts[0]));
return QString();
}
room->ban(parts[0], parts.size() > 1 ? parts.mid(1).join(QLatin1Char(' ')) : QString());
Q_EMIT room->showMessage(MessageType::Positive, i18nc("<username> was banned from this room.", "%1 was banned from this room.", parts[0]));
return QString();
},
std::nullopt,
kli18n("<user id> [<reason>]"),
kli18n("Bans the given user"),
},
Action{
u"unban"_s,
[](const QString &text, NeoChatRoom *room, ChatBarCache *) {
static const QRegularExpression mxidRegex(uR"((^|[][[:space:](){}`'";])([!#@][-a-z0-9_=#/.]{1,252}:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?))"_s);
auto regexMatch = mxidRegex.match(text);
if (!regexMatch.hasMatch()) {
Q_EMIT room->showMessage(MessageType::Error, i18nc("'<text>' does not look like a matrix id.", "'%1' does not look like a matrix id.", text));
return QString();
}
auto plEvent = room->currentState().get<RoomPowerLevelsEvent>();
if (!plEvent) {
return QString();
}
if (plEvent->ban() > plEvent->powerLevelForUser(room->localMember().id())) {
Q_EMIT room->showMessage(MessageType::Error, i18n("You are not allowed to unban users from this room."));
return QString();
}
auto state = room->currentState().get<RoomMemberEvent>(text);
if (state && state->membership() != Membership::Ban) {
Q_EMIT room->showMessage(MessageType::Information, i18nc("<user> is not banned from this room.", "%1 is not banned from this room.", text));
return QString();
}
room->unban(text);
Q_EMIT room->showMessage(MessageType::Positive, i18nc("<username> was unbanned from this room.", "%1 was unbanned from this room.", text));
return QString();
},
std::nullopt,
kli18n("<user id>"),
kli18n("Removes the ban of the given user"),
},
Action{
u"kick"_s,
[](const QString &text, NeoChatRoom *room, ChatBarCache *) {
auto parts = text.split(u" "_s);
static const QRegularExpression mxidRegex(uR"((^|[][[:space:](){}`'";])([!#@][-a-z0-9_=#/.]{1,252}:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?))"_s);
auto regexMatch = mxidRegex.match(parts[0]);
if (!regexMatch.hasMatch()) {
Q_EMIT room->showMessage(MessageType::Error,
i18nc("'<text>' does not look like a matrix id.", "'%1' does not look like a matrix id.", parts[0]));
return QString();
}
if (parts[0] == room->localMember().id()) {
Q_EMIT room->showMessage(MessageType::Error, i18n("You cannot kick yourself from the room."));
return QString();
}
if (!room->isMember(parts[0])) {
Q_EMIT room->showMessage(MessageType::Error, i18nc("<username> is not in this room", "%1 is not in this room.", parts[0]));
return QString();
}
auto plEvent = room->currentState().get<RoomPowerLevelsEvent>();
if (!plEvent) {
return QString();
}
auto kick = plEvent->kick();
if (plEvent->powerLevelForUser(room->localMember().id()) < kick) {
Q_EMIT room->showMessage(MessageType::Error, i18n("You are not allowed to kick users from this room."));
return QString();
}
if (plEvent->powerLevelForUser(room->localMember().id()) <= plEvent->powerLevelForUser(parts[0])) {
Q_EMIT room->showMessage(
MessageType::Error,
i18nc("You are not allowed to kick <username> from this room", "You are not allowed to kick %1 from this room.", parts[0]));
return QString();
}
room->kickMember(parts[0], parts.size() > 1 ? parts.mid(1).join(QLatin1Char(' ')) : QString());
Q_EMIT room->showMessage(MessageType::Positive, i18nc("<username> was kicked from this room.", "%1 was kicked from this room.", parts[0]));
return QString();
},
std::nullopt,
kli18n("<user id> [<reason>]"),
kli18n("Removes the user from the room"),
},
};
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 u"action"_s;
}
if (role == Parameters) {
return actions[index.row()].parameters.toString();
}
return {};
}
QHash<int, QByteArray> ActionsModel::roleNames() const
{
return {
{Prefix, "prefix"},
{Description, "description"},
{CompletionType, "completionType"},
};
}
QList<Action> &ActionsModel::allActions()
{
return actions;
}
bool ActionsModel::handleQuickEditAction(NeoChatRoom *room, const QString &messageText)
{
if (room == nullptr) {
return false;
}
if (m_allowQuickEdit) {
QRegularExpression sed(u"^s/([^/]*)/([^/]*)(/g)?$"_s);
auto match = sed.match(messageText);
if (match.hasMatch()) {
const QString regex = match.captured(1);
const QString replacement = match.captured(2).toHtmlEscaped();
const QString flags = match.captured(3);
for (auto it = room->messageEvents().crbegin(); it != room->messageEvents().crend(); it++) {
if (const auto event = eventCast<const RoomMessageEvent>(&**it)) {
if (event->senderId() == room->localMember().id() && event->has<EventContent::TextContent>()) {
QString originalString;
if (event->content()) {
originalString = static_cast<const Quotient::EventContent::TextContent *>(event->content().get())->body;
} else {
originalString = event->plainBody();
}
QString replaceId = event->id();
const auto eventRelation = event->relatesTo();
if (eventRelation && eventRelation->type == "m.replace"_L1) {
replaceId = eventRelation->eventId;
}
std::unique_ptr<EventContent::TextContent> content = nullptr;
if (flags == "/g"_L1) {
content = std::make_unique<Quotient::EventContent::TextContent>(originalString.replace(regex, replacement), u"text/html"_s);
} else {
content = std::make_unique<Quotient::EventContent::TextContent>(originalString.replace(regex, replacement), u"text/html"_s);
}
Quotient::EventRelation relatesTo = Quotient::EventRelation::replace(replaceId);
room->post<Quotient::RoomMessageEvent>(messageText, event->msgtype(), std::move(content), relatesTo);
return true;
}
}
}
}
}
return false;
}
std::pair<std::optional<QString>, std::optional<Quotient::RoomMessageEvent::MsgType>> ActionsModel::handleAction(NeoChatRoom *room, ChatBarCache *chatBarCache)
{
auto sendText = chatBarCache->sendText();
const auto edited = handleQuickEditAction(room, sendText);
if (edited) {
return std::make_pair(std::nullopt, std::nullopt);
}
std::optional<Quotient::RoomMessageEvent::MsgType> messageType = Quotient::RoomMessageEvent::MsgType::Text;
if (sendText.startsWith(QLatin1Char('/'))) {
for (const auto &action : ActionsModel::instance().allActions()) {
if (sendText.indexOf(action.prefix) == 1
&& (sendText.indexOf(" "_L1) == action.prefix.length() + 1 || sendText.length() == action.prefix.length() + 1)) {
sendText = action.handle(sendText.mid(action.prefix.length() + 1).trimmed(), room, chatBarCache);
if (action.messageType.has_value()) {
messageType = action.messageType;
} else {
messageType = std::nullopt;
}
}
}
}
return std::make_pair(messageType.has_value() ? std::make_optional(sendText) : std::nullopt, messageType);
}
void ActionsModel::setAllowQuickEdit(bool allow)
{
m_allowQuickEdit = allow;
}

View File

@@ -0,0 +1,120 @@
// SPDX-FileCopyrightText: 2022 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: LGPL-2.0-or-later
#pragma once
#include <KLazyLocalizedString>
#include <QAbstractListModel>
#include <Quotient/events/roommessageevent.h>
class ChatBarCache;
class NeoChatConnection;
class NeoChatRoom;
/**
* @class ActionsModel
*
* This class defines a model for chat actions.
*
* @note A chat action is a message starting with /, resulting in something other
* than a normal message being sent (e.g. /me, /join).
*/
class ActionsModel : public QAbstractListModel
{
Q_OBJECT
public:
/**
* @brief Definition of an action.
*/
struct Action {
QString prefix; /**< The prefix, without '/' and space after the word. */
/**
* @brief The function to execute when the action is triggered.
*/
std::function<QString(const QString &, NeoChatRoom *, ChatBarCache *)> handle;
/**
* @brief The new message type of a message being sent.
*
* For a non-message action, it's nullopt.
*/
std::optional<Quotient::RoomMessageEvent::MsgType> messageType = std::nullopt;
KLazyLocalizedString parameters; /**< The input parameters expected by the action. */
KLazyLocalizedString description; /**< The description of the action. */
};
static ActionsModel &instance()
{
static ActionsModel _instance;
return _instance;
}
/**
* @brief Defines the model roles.
*/
enum Roles {
Prefix = Qt::DisplayRole, /**< The prefix, without '/' and space after the word. */
Description, /**< The description of the action. */
CompletionType, /**< The completion type (always "action" for this model). */
Parameters, /**< The input parameters expected by the action. */
};
Q_ENUM(Roles)
/**
* @brief Get the given role value at the given index.
*
* @sa QAbstractItemModel::data
*/
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
/**
* @brief Number of rows in the model.
*
* @sa QAbstractItemModel::rowCount
*/
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
/**
* @brief Returns a mapping from Role enum values to role names.
*
* @sa EventRoles, QAbstractItemModel::roleNames()
*/
QHash<int, QByteArray> roleNames() const override;
/**
* @brief Return a vector with all supported actions.
*/
static QList<Action> &allActions();
/**
* @brief Handle special sed style edit action.
*
* @return True if the message has a sed edit which was actioned. False otherwise.
*/
static bool handleQuickEditAction(NeoChatRoom *room, const QString &messageText);
/**
* @brief Handle any action within the message contained in the given ChatBarCache.
*
* @return A modified or unmodified string that needs to be sent or an empty string if
* the handled action replaces sending a normal message.
*/
static std::pair<std::optional<QString>, std::optional<Quotient::RoomMessageEvent::MsgType>> handleAction(NeoChatRoom *room, ChatBarCache *chatBarCache);
static void setAllowQuickEdit(bool allow);
Q_SIGNALS:
/**
* @brief Request a resource is resolved.
*/
void resolveResource(const QString &idOrUri, const QString &action = {});
/**
* @brief Request a room Knock.
*/
void knockRoom(NeoChatConnection *account, const QString &roomAliasOrId, const QString &reason, const QStringList &viaServers);
private:
ActionsModel() = default;
static bool m_allowQuickEdit;
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,562 @@
// SPDX-FileCopyrightText: 2023 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: GPL-2.0-or-later
#include "neochatconnection.h"
#include <QImageReader>
#include <QJsonDocument>
#include "neochatroom.h"
#include "spacehierarchycache.h"
#include <Quotient/jobs/basejob.h>
#include <Quotient/quotient_common.h>
#include <qt6keychain/keychain.h>
#include <KLocalizedString>
#include <Quotient/csapi/content-repo.h>
#include <Quotient/csapi/profile.h>
#include <Quotient/csapi/registration.h>
#include <Quotient/csapi/versions.h>
#include <Quotient/jobs/downloadfilejob.h>
#include <Quotient/qt_connection_util.h>
#include <Quotient/room.h>
#include <Quotient/settings.h>
#include <Quotient/user.h>
#ifdef HAVE_KUNIFIEDPUSH
#include <QCoroNetwork>
#include <Quotient/csapi/pusher.h>
#include <Quotient/networkaccessmanager.h>
#endif
using namespace Quotient;
using namespace Qt::StringLiterals;
bool NeoChatConnection::m_globalUrlPreviewDefault = true;
NeoChatConnection::NeoChatConnection(QObject *parent)
: Connection(parent)
{
m_linkPreviewers.setMaxCost(20);
connectSignals();
}
NeoChatConnection::NeoChatConnection(const QUrl &server, QObject *parent)
: Connection(server, parent)
{
m_linkPreviewers.setMaxCost(20);
connectSignals();
}
void NeoChatConnection::connectSignals()
{
connect(this, &NeoChatConnection::accountDataChanged, this, [this](const QString &type) {
if (type == u"org.kde.neochat.account_label"_s) {
Q_EMIT labelChanged();
}
if (type == u"m.identity_server"_s) {
Q_EMIT identityServerChanged();
}
});
connect(this, &NeoChatConnection::syncDone, this, [this] {
setIsOnline(true);
});
connect(this, &NeoChatConnection::networkError, this, [this]() {
setIsOnline(false);
});
connect(this, &NeoChatConnection::requestFailed, this, [this](BaseJob *job) {
if (job->error() == BaseJob::UserConsentRequired) {
Q_EMIT userConsentRequired(job->errorUrl());
}
});
connect(this, &NeoChatConnection::requestFailed, this, [this](BaseJob *job) {
if (dynamic_cast<DownloadFileJob *>(job) && job->jsonData()["errcode"_L1].toString() == "M_TOO_LARGE"_L1) {
Q_EMIT showMessage(MessageType::Warning, i18n("File too large to download.<br />Contact your matrix server administrator for support."));
}
});
connect(this, &NeoChatConnection::directChatsListChanged, this, [this](DirectChatsMap additions, DirectChatsMap removals) {
Q_EMIT directChatInvitesChanged();
for (const auto &chatId : additions) {
if (const auto chat = room(chatId)) {
connect(chat, &Room::unreadStatsChanged, this, [this]() {
refreshBadgeNotificationCount();
Q_EMIT directChatNotificationsChanged();
Q_EMIT directChatsHaveHighlightNotificationsChanged();
});
}
}
for (const auto &chatId : removals) {
if (const auto chat = room(chatId)) {
disconnect(chat, &Room::unreadStatsChanged, this, nullptr);
}
}
});
connect(this, &NeoChatConnection::joinedRoom, this, [this](Room *room) {
if (room->isDirectChat()) {
connect(room, &Room::unreadStatsChanged, this, [this]() {
Q_EMIT directChatNotificationsChanged();
Q_EMIT directChatsHaveHighlightNotificationsChanged();
});
}
connect(room, &Room::unreadStatsChanged, this, [this]() {
refreshBadgeNotificationCount();
Q_EMIT homeNotificationsChanged();
Q_EMIT homeHaveHighlightNotificationsChanged();
});
});
connect(this, &NeoChatConnection::leftRoom, this, [this](Room *room, Room *prev) {
Q_UNUSED(room)
if (prev && prev->isDirectChat()) {
Q_EMIT directChatInvitesChanged();
Q_EMIT directChatNotificationsChanged();
Q_EMIT directChatsHaveHighlightNotificationsChanged();
}
refreshBadgeNotificationCount();
Q_EMIT homeNotificationsChanged();
Q_EMIT homeHaveHighlightNotificationsChanged();
});
connect(&SpaceHierarchyCache::instance(), &SpaceHierarchyCache::spaceHierarchyChanged, this, [this]() {
refreshBadgeNotificationCount();
Q_EMIT homeNotificationsChanged();
Q_EMIT homeHaveHighlightNotificationsChanged();
});
connect(this, &NeoChatConnection::globalUrlPreviewEnabledChanged, this, [this]() {
if (!m_globalUrlPreviewDefault) {
m_linkPreviewers.clear();
}
});
// Fetch unstable features
// TODO: Expose unstableFeatures() in libQuotient
connect(
this,
&Connection::connected,
this,
[this] {
auto job = callApi<GetVersionsJob>(BackgroundRequest);
connect(job, &GetVersionsJob::success, this, [this, job] {
m_canCheckMutualRooms = job->unstableFeatures().contains("uk.half-shot.msc2666.query_mutual_rooms"_L1);
Q_EMIT canCheckMutualRoomsChanged();
m_canEraseData = job->unstableFeatures().contains("org.matrix.msc4025"_L1) || job->versions().count("v1.10"_L1);
Q_EMIT canEraseDataChanged();
});
},
Qt::SingleShotConnection);
}
int NeoChatConnection::badgeNotificationCount() const
{
return m_badgeNotificationCount;
}
void NeoChatConnection::refreshBadgeNotificationCount()
{
int count = 0;
for (const auto &r : allRooms()) {
if (const auto room = static_cast<NeoChatRoom *>(r)) {
count += room->contextAwareNotificationCount();
}
}
if (count != m_badgeNotificationCount) {
m_badgeNotificationCount = count;
Q_EMIT badgeNotificationCountChanged(this, m_badgeNotificationCount);
}
}
bool NeoChatConnection::globalUrlPreviewEnabled()
{
return m_globalUrlPreviewDefault;
}
void NeoChatConnection::setGlobalUrlPreviewDefault(bool useByDefault)
{
NeoChatConnection::m_globalUrlPreviewDefault = useByDefault;
}
void NeoChatConnection::logout(bool serverSideLogout)
{
SettingsGroup(u"Accounts"_s).remove(userId());
QKeychain::DeletePasswordJob job(qAppName());
job.setAutoDelete(true);
job.setKey(userId());
QEventLoop loop;
QKeychain::DeletePasswordJob::connect(&job, &QKeychain::Job::finished, &loop, &QEventLoop::quit);
job.start();
loop.exec();
if (!serverSideLogout) {
return;
}
Connection::logout();
}
bool NeoChatConnection::setAvatar(const QUrl &avatarSource)
{
QString decoded = avatarSource.path();
if (decoded.isEmpty()) {
callApi<SetAvatarUrlJob>(user()->id(), avatarSource);
return true;
}
if (QImageReader(decoded).read().isNull()) {
return false;
} else {
return user()->setAvatar(decoded);
}
}
QVariantList NeoChatConnection::getSupportedRoomVersions() const
{
const auto &roomVersions = availableRoomVersions();
QVariantList supportedRoomVersions;
for (const auto &v : roomVersions) {
QVariantMap roomVersionMap;
roomVersionMap.insert("id"_L1, v.id);
roomVersionMap.insert("status"_L1, v.status);
roomVersionMap.insert("isStable"_L1, v.isStable());
supportedRoomVersions.append(roomVersionMap);
}
return supportedRoomVersions;
}
bool NeoChatConnection::canCheckMutualRooms() const
{
return m_canCheckMutualRooms;
}
void NeoChatConnection::changePassword(const QString &currentPassword, const QString &newPassword)
{
auto job = callApi<ChangePasswordJob>(newPassword, false);
connect(job, &BaseJob::result, this, [this, job, currentPassword, newPassword] {
if (job->error() == 103) {
QJsonObject replyData = job->jsonData();
AuthenticationData authData;
authData.session = replyData["session"_L1].toString();
authData.type = "m.login.password"_L1;
authData.authInfo["password"_L1] = currentPassword;
authData.authInfo["user"_L1] = user()->id();
authData.authInfo["identifier"_L1] = QJsonObject{{"type"_L1, "m.id.user"_L1}, {"user"_L1, user()->id()}};
auto innerJob = callApi<ChangePasswordJob>(newPassword, false, authData);
connect(innerJob, &BaseJob::success, this, [this]() {
Q_EMIT passwordStatus(PasswordStatus::Success);
});
connect(innerJob, &BaseJob::failure, this, [innerJob, this]() {
Q_EMIT passwordStatus(innerJob->jsonData()["errcode"_L1] == "M_FORBIDDEN"_L1 ? PasswordStatus::Wrong : PasswordStatus::Other);
});
}
});
}
void NeoChatConnection::setLabel(const QString &label)
{
QJsonObject json{
{"account_label"_L1, label},
};
setAccountData("org.kde.neochat.account_label"_L1, json);
Q_EMIT labelChanged();
}
QString NeoChatConnection::label() const
{
return accountDataJson("org.kde.neochat.account_label"_L1)["account_label"_L1].toString();
}
void NeoChatConnection::deactivateAccount(const QString &password, const bool erase)
{
auto job = callApi<DeactivateAccountJob>();
connect(job, &BaseJob::result, this, [this, job, password, erase] {
if (job->error() == 103) {
QJsonObject replyData = job->jsonData();
AuthenticationData authData;
authData.session = replyData["session"_L1].toString();
authData.authInfo["password"_L1] = password;
authData.type = "m.login.password"_L1;
authData.authInfo["user"_L1] = user()->id();
QJsonObject identifier = {{"type"_L1, "m.id.user"_L1}, {"user"_L1, user()->id()}};
authData.authInfo["identifier"_L1] = identifier;
auto innerJob = callApi<DeactivateAccountJob>(authData, QString{}, erase);
connect(innerJob, &BaseJob::success, this, [this]() {
logout(false);
});
}
});
}
bool NeoChatConnection::hasIdentityServer() const
{
if (!hasAccountData(u"m.identity_server"_s)) {
return false;
}
const auto url = accountData(u"m.identity_server"_s)->contentPart<QUrl>("base_url"_L1);
if (!url.isEmpty()) {
return true;
}
return false;
}
QUrl NeoChatConnection::identityServer() const
{
if (!hasAccountData(u"m.identity_server"_s)) {
return {};
}
const auto url = accountData(u"m.identity_server"_s)->contentPart<QUrl>("base_url"_L1);
if (!url.isEmpty()) {
return url;
}
return {};
}
QString NeoChatConnection::identityServerUIString() const
{
if (!hasIdentityServer()) {
return i18nc("@info", "No identity server configured");
}
return identityServer().toString();
}
void NeoChatConnection::createRoom(const QString &name, const QString &topic, const QString &parent, bool setChildParent)
{
QList<CreateRoomJob::StateEvent> initialStateEvents;
if (!parent.isEmpty()) {
initialStateEvents.append(CreateRoomJob::StateEvent{
"m.space.parent"_L1,
QJsonObject{
{"canonical"_L1, true},
{"via"_L1, QJsonArray{domain()}},
},
parent,
});
}
const auto job = Connection::createRoom(Connection::PublishRoom, QString(), name, topic, QStringList(), {}, {}, {}, initialStateEvents);
if (!parent.isEmpty()) {
connect(job, &Quotient::CreateRoomJob::success, this, [this, parent, setChildParent, job]() {
if (setChildParent) {
if (auto parentRoom = room(parent)) {
parentRoom->setState(u"m.space.child"_s, job->roomId(), QJsonObject{{"via"_L1, QJsonArray{domain()}}});
}
}
});
}
connect(job, &CreateRoomJob::failure, this, [this, job] {
Q_EMIT errorOccured(i18n("Room creation failed: %1", job->errorString()));
});
}
void NeoChatConnection::createSpace(const QString &name, const QString &topic, const QString &parent, bool setChildParent)
{
QList<CreateRoomJob::StateEvent> initialStateEvents;
if (!parent.isEmpty()) {
initialStateEvents.append(CreateRoomJob::StateEvent{
"m.space.parent"_L1,
QJsonObject{
{"canonical"_L1, true},
{"via"_L1, QJsonArray{domain()}},
},
parent,
});
}
const auto job =
Connection::createRoom(Connection::UnpublishRoom, {}, name, topic, {}, {}, {}, false, initialStateEvents, {}, QJsonObject{{"type"_L1, "m.space"_L1}});
if (!parent.isEmpty()) {
connect(job, &Quotient::CreateRoomJob::success, this, [this, parent, setChildParent, job]() {
if (setChildParent) {
if (auto parentRoom = room(parent)) {
parentRoom->setState(u"m.space.child"_s, job->roomId(), QJsonObject{{"via"_L1, QJsonArray{domain()}}});
}
}
});
}
connect(job, &CreateRoomJob::failure, this, [this, job] {
Q_EMIT errorOccured(i18n("Space creation failed: %1", job->errorString()));
});
}
bool NeoChatConnection::directChatExists(Quotient::User *user)
{
return directChats().contains(user);
}
qsizetype NeoChatConnection::directChatNotifications() const
{
qsizetype notifications = 0;
QStringList added; // The same ID can be in the list multiple times.
for (const auto &chatId : directChats()) {
if (!added.contains(chatId)) {
if (const auto chat = room(chatId)) {
notifications += dynamic_cast<NeoChatRoom *>(chat)->contextAwareNotificationCount();
added += chatId;
}
}
}
return notifications;
}
bool NeoChatConnection::directChatsHaveHighlightNotifications() const
{
for (const auto &childId : directChats()) {
if (const auto child = static_cast<NeoChatRoom *>(room(childId))) {
if (child->highlightCount() > 0) {
return true;
}
}
}
return false;
}
qsizetype NeoChatConnection::homeNotifications() const
{
qsizetype notifications = 0;
QStringList added;
const auto &spaceHierarchyCache = SpaceHierarchyCache::instance();
for (const auto &r : allRooms()) {
if (const auto room = static_cast<NeoChatRoom *>(r)) {
if (!added.contains(room->id()) && !room->isDirectChat() && !spaceHierarchyCache.isChild(room->id())) {
notifications += dynamic_cast<NeoChatRoom *>(room)->contextAwareNotificationCount();
added += room->id();
}
}
}
return notifications;
}
bool NeoChatConnection::homeHaveHighlightNotifications() const
{
const auto &spaceHierarchyCache = SpaceHierarchyCache::instance();
for (const auto &r : allRooms()) {
if (const auto room = static_cast<NeoChatRoom *>(r)) {
if (!room->isDirectChat() && !spaceHierarchyCache.isChild(room->id()) && room->highlightCount() > 0) {
return true;
}
}
}
return false;
}
bool NeoChatConnection::directChatInvites() const
{
auto inviteRooms = rooms(JoinState::Invite);
for (const auto inviteRoom : inviteRooms) {
if (inviteRoom->isDirectChat()) {
return true;
}
}
return false;
}
QCoro::Task<void> NeoChatConnection::setupPushNotifications(QString endpoint)
{
#ifdef HAVE_KUNIFIEDPUSH
QUrl gatewayEndpoint(endpoint);
gatewayEndpoint.setPath(u"/_matrix/push/v1/notify"_s);
QNetworkRequest checkGateway(gatewayEndpoint);
auto reply = co_await NetworkAccessManager::instance()->get(checkGateway);
// We want to check if this UnifiedPush server has a Matrix gateway
// This is because Matrix does not natively support UnifiedPush
const auto &replyJson = QJsonDocument::fromJson(reply->readAll()).object();
if (replyJson["unifiedpush"_L1]["gateway"_L1].toString() == u"matrix"_s) {
callApi<PostPusherJob>(endpoint,
u"http"_s,
u"org.kde.neochat"_s,
u"NeoChat"_s,
deviceId(),
QString(), // profileTag is intentionally left empty for now, it's optional
u"en-US"_s,
PostPusherJob::PusherData{QUrl::fromUserInput(gatewayEndpoint.toString()), u" "_s},
false);
qInfo() << "Registered for push notifications";
m_pushNotificationsEnabled = true;
} else {
qWarning() << "There's no gateway, not setting up push notifications.";
m_pushNotificationsEnabled = false;
}
Q_EMIT enablePushNotificationsChanged();
#else
Q_UNUSED(endpoint)
co_return;
#endif
}
bool NeoChatConnection::isOnline() const
{
return m_isOnline;
}
void NeoChatConnection::setIsOnline(bool isOnline)
{
if (isOnline == m_isOnline) {
return;
}
m_isOnline = isOnline;
Q_EMIT isOnlineChanged();
}
QString NeoChatConnection::accountDataJsonString(const QString &type) const
{
return QString::fromUtf8(QJsonDocument(accountDataJson(type)).toJson());
}
LinkPreviewer *NeoChatConnection::previewerForLink(const QUrl &link)
{
if (!m_globalUrlPreviewDefault) {
return nullptr;
}
auto previewer = m_linkPreviewers.object(link);
if (previewer != nullptr) {
return previewer;
}
previewer = new LinkPreviewer(link, this);
m_linkPreviewers.insert(link, previewer);
return previewer;
}
KeyImport::Error NeoChatConnection::exportMegolmSessions(const QString &passphrase, const QString &path)
{
KeyImport keyImport;
auto result = keyImport.exportKeys(passphrase, this);
if (!result.has_value()) {
return result.error();
}
QUrl url(path);
QFile file(url.toLocalFile());
file.open(QFile::WriteOnly);
file.write(result.value());
file.close();
return KeyImport::Success;
}
bool NeoChatConnection::canEraseData() const
{
return m_canEraseData;
}
bool NeoChatConnection::pushNotificationsAvailable() const
{
#ifdef HAVE_KUNIFIEDPUSH
return true;
#else
return false;
#endif
}
bool NeoChatConnection::enablePushNotifications() const
{
return m_pushNotificationsEnabled;
}
#include "moc_neochatconnection.cpp"

View File

@@ -0,0 +1,239 @@
// SPDX-FileCopyrightText: 2023 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <QCache>
#include <QObject>
#include <QQmlEngine>
#include <QCoroTask>
#include <Quotient/connection.h>
#include <Quotient/keyimport.h>
#include "enums/messagetype.h"
#include "linkpreviewer.h"
class NeoChatConnection : public Quotient::Connection
{
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("")
/**
* @brief The account label for this account.
*
* Account labels are a concept specific to NeoChat, allowing accounts to be
* labelled, e.g. for "Work", "Private", etc.
*
* Set to an empty string to remove the label.
*/
Q_PROPERTY(QString label READ label WRITE setLabel NOTIFY labelChanged)
/**
* @brief Whether an identity server is configured.
*/
Q_PROPERTY(bool hasIdentityServer READ hasIdentityServer NOTIFY identityServerChanged)
/**
* @brief The identity server URL as a string for showing in a UI.
*
* Will return the string "No identity server configured" if no identity
* server configured. Otherwise it returns the URL as a string.
*/
Q_PROPERTY(QString identityServer READ identityServerUIString NOTIFY identityServerChanged)
/**
* @brief The total number of notifications for all direct chats.
*/
Q_PROPERTY(qsizetype directChatNotifications READ directChatNotifications NOTIFY directChatNotificationsChanged)
/**
* @brief Whether any direct chats have highlight notifications.
*/
Q_PROPERTY(bool directChatsHaveHighlightNotifications READ directChatsHaveHighlightNotifications NOTIFY directChatsHaveHighlightNotificationsChanged)
/**
* @brief The total number of notifications for all rooms in the home tab.
*/
Q_PROPERTY(qsizetype homeNotifications READ homeNotifications NOTIFY homeNotificationsChanged)
/**
* @brief Whether any of the rooms in the home tab have highlight notifications.
*/
Q_PROPERTY(bool homeHaveHighlightNotifications READ homeHaveHighlightNotifications NOTIFY homeHaveHighlightNotificationsChanged)
/**
* @brief Whether there is at least one invite to a direct chat.
*/
Q_PROPERTY(bool directChatInvites READ directChatInvites NOTIFY directChatInvitesChanged)
/**
* @brief Whether NeoChat is currently able to connect to the server.
*/
Q_PROPERTY(bool isOnline READ isOnline WRITE setIsOnline NOTIFY isOnlineChanged)
/**
* @brief Whether the server supports querying a user's mutual rooms.
*/
Q_PROPERTY(bool canCheckMutualRooms READ canCheckMutualRooms NOTIFY canCheckMutualRoomsChanged)
/**
* @brief Whether the server supports erasing user data when deactivating the account. This checks for MSC4025.
*/
Q_PROPERTY(bool canEraseData READ canEraseData NOTIFY canEraseDataChanged)
/**
* @brief Whether this build of NeoChat supports push notifications via KUnifiedPush.
*/
Q_PROPERTY(bool pushNotificationsAvailable READ pushNotificationsAvailable CONSTANT)
/**
* @brief Whether we successfully set up push notifications with the server.
*/
Q_PROPERTY(bool enablePushNotifications READ enablePushNotifications NOTIFY enablePushNotificationsChanged)
public:
/**
* @brief Defines the status after an attempt to change the password on an account.
*/
enum PasswordStatus {
Success, /**< The password was successfully changed. */
Wrong, /**< The current password entered was wrong. */
Other, /**< An unknown problem occurred. */
};
Q_ENUM(PasswordStatus)
NeoChatConnection(QObject *parent = nullptr);
NeoChatConnection(const QUrl &server, QObject *parent = nullptr);
Q_INVOKABLE void logout(bool serverSideLogout);
Q_INVOKABLE QVariantList getSupportedRoomVersions() const;
bool canCheckMutualRooms() const;
bool canEraseData() const;
/**
* @brief Change the password for an account.
*
* The function emits a passwordStatus signal with a PasswordStatus value when
* complete.
*
* @sa PasswordStatus, passwordStatus
*/
Q_INVOKABLE void changePassword(const QString &currentPassword, const QString &newPassword);
/**
* @brief Change the avatar for an account.
*/
Q_INVOKABLE bool setAvatar(const QUrl &avatarSource);
[[nodiscard]] QString label() const;
void setLabel(const QString &label);
Q_INVOKABLE void deactivateAccount(const QString &password, bool erase);
bool hasIdentityServer() const;
/**
* @brief The identity server URL.
*
* Empty if no identity server configured.
*/
QUrl identityServer() const;
QString identityServerUIString() const;
/**
* @brief Create new room for a group chat.
*/
Q_INVOKABLE void createRoom(const QString &name, const QString &topic, const QString &parent = {}, bool setChildParent = false);
/**
* @brief Create new space.
*/
Q_INVOKABLE void createSpace(const QString &name, const QString &topic, const QString &parent = {}, bool setChildParent = false);
/**
* @brief Whether a direct chat with the user exists.
*/
Q_INVOKABLE bool directChatExists(Quotient::User *user);
/**
* @brief Get the account data with \param type as a formatted JSON string.
*/
Q_INVOKABLE QString accountDataJsonString(const QString &type) const;
Q_INVOKABLE Quotient::KeyImport::Error exportMegolmSessions(const QString &passphrase, const QString &path);
qsizetype directChatNotifications() const;
bool directChatsHaveHighlightNotifications() const;
qsizetype homeNotifications() const;
bool homeHaveHighlightNotifications() const;
int badgeNotificationCount() const;
void refreshBadgeNotificationCount();
bool globalUrlPreviewEnabled();
/**
* @brief Whether URL previews are enabled globally by default for all connections.
*/
static void setGlobalUrlPreviewDefault(bool useByDefault);
bool directChatInvites() const;
// note: this is intentionally a copied QString because
// the reference could be destroyed before the task is finished
QCoro::Task<void> setupPushNotifications(QString endpoint);
bool pushNotificationsAvailable() const;
bool enablePushNotifications() const;
bool isOnline() const;
LinkPreviewer *previewerForLink(const QUrl &link);
Q_SIGNALS:
void globalUrlPreviewEnabledChanged();
void labelChanged();
void identityServerChanged();
void directChatNotificationsChanged();
void directChatsHaveHighlightNotificationsChanged();
void homeNotificationsChanged();
void homeHaveHighlightNotificationsChanged();
void directChatInvitesChanged();
void isOnlineChanged();
void passwordStatus(NeoChatConnection::PasswordStatus status);
void userConsentRequired(QUrl url);
void badgeNotificationCountChanged(NeoChatConnection *connection, int count);
void canCheckMutualRoomsChanged();
void canEraseDataChanged();
void enablePushNotificationsChanged();
/**
* @brief Request a message be shown to the user of the given type.
*/
void showMessage(MessageType::Type messageType, const QString &message);
/**
* @brief Request a error message be shown to the user.
*/
void errorOccured(const QString &error);
private:
static bool m_globalUrlPreviewDefault;
bool m_isOnline = true;
void setIsOnline(bool isOnline);
void connectSignals();
int m_badgeNotificationCount = 0;
QCache<QUrl, LinkPreviewer> m_linkPreviewers;
bool m_canCheckMutualRooms = false;
bool m_canEraseData = false;
bool m_pushNotificationsEnabled = false;
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,684 @@
// SPDX-FileCopyrightText: 2018-2019 Black Hat <bhat@encom.eu.org>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <Quotient/events/roomevent.h>
#include <Quotient/room.h>
#include <QCache>
#include <QObject>
#include <QQmlEngine>
#include <QCoroTask>
#include "enums/messagetype.h"
#include "enums/pushrule.h"
#include "events/pollevent.h"
#include "neochatroommember.h"
namespace Quotient
{
class User;
}
class ChatBarCache;
/**
* @class NeoChatRoom
*
* This class is designed to act as a wrapper over Quotient::Room to provide API and
* functionality not available in Quotient::Room.
*
* The functions fall into two main categories:
* - Helper functions to make functionality easily accessible in QML.
* - Implement functions not yet available in Quotient::Room.
*
* @sa Quotient::Room
*/
class NeoChatRoom : public Quotient::Room
{
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("")
/**
* @brief Convenience function to get the QDateTime of the last event.
*
* @sa lastEvent()
*/
Q_PROPERTY(QDateTime lastActiveTime READ lastActiveTime NOTIFY lastActiveTimeChanged)
/**
* @brief Whether a file is being uploaded to the server.
*/
Q_PROPERTY(bool hasFileUploading READ hasFileUploading WRITE setHasFileUploading NOTIFY hasFileUploadingChanged)
/**
* @brief Progress of a file upload as a percentage 0 - 100%.
*
* The value will be 0 if no file is uploading.
*
* @sa hasFileUploading
*/
Q_PROPERTY(int fileUploadingProgress READ fileUploadingProgress NOTIFY fileUploadingProgressChanged)
/**
* @brief Whether the read marker should be shown.
*/
Q_PROPERTY(bool readMarkerLoaded READ readMarkerLoaded NOTIFY readMarkerLoadedChanged)
/**
* @brief The avatar image to be used for the room, as a mxc:// URL.
*/
Q_PROPERTY(QUrl avatarMediaUrl READ avatarMediaUrl NOTIFY avatarChanged STORED false)
/**
* @brief Get a RoomMember object for the other person in a direct chat.
*/
Q_PROPERTY(NeochatRoomMember *directChatRemoteMember READ directChatRemoteMember CONSTANT)
/**
* @brief The Matrix IDs of this room's parents.
*
* Empty if no parent space is set.
*/
Q_PROPERTY(QList<QString> parentIds READ parentIds NOTIFY parentIdsChanged)
/**
* @brief The current canonical parent for the room.
*
* Empty if no canonical parent is set. The write method can only be used to
* set an existing parent as canonical; If you wish to add a new parent and set
* it as canonical use the addParent method and pass true to the canonical
* parameter.
*
* Setting will fail if the user doesn't have the required privileges (see
* canModifyParent) or if the given room ID is not a parent room.
*
* @sa canModifyParent, addParent
*/
Q_PROPERTY(QString canonicalParent READ canonicalParent WRITE setCanonicalParent NOTIFY canonicalParentChanged)
/**
* @brief If the room is a space.
*/
Q_PROPERTY(bool isSpace READ isSpace CONSTANT)
/**
* @brief The number of notifications in this room's children.
*
* Will always return 0 if this is not a space.
*/
Q_PROPERTY(qsizetype childrenNotificationCount READ childrenNotificationCount NOTIFY childrenNotificationCountChanged)
/**
* @brief Whether this room's children have any highlight notifications.
*
* Will always return false if this is not a space.
*/
Q_PROPERTY(bool childrenHaveHighlightNotifications READ childrenHaveHighlightNotifications NOTIFY childrenHaveHighlightNotificationsChanged)
/**
* @brief Whether the local user has an invite to the room.
*
* False for any other state including if the local user is a member.
*/
Q_PROPERTY(bool isInvite READ isInvite NOTIFY isInviteChanged)
/**
* @brief Whether the local user can send messages in the room.
*/
Q_PROPERTY(bool readOnly READ readOnly NOTIFY readOnlyChanged)
/**
* @brief Get the maximum room version that the server supports.
*
* Only returns main integer room versions (i.e. no msc room versions).
*/
Q_PROPERTY(int maxRoomVersion READ maxRoomVersion NOTIFY maxRoomVersionChanged)
/**
* @brief The rule for which messages should generate notifications for the room.
*
* @sa PushNotificationState::State
*/
Q_PROPERTY(PushNotificationState::State pushNotificationState READ pushNotificationState WRITE setPushNotificationState NOTIFY pushNotificationStateChanged)
/**
* @brief The current history visibilty setting for the room.
*
* Possible values are [invited, joined, shared, world_readable].
*
* @sa https://spec.matrix.org/v1.5/client-server-api/#room-history-visibility
*/
Q_PROPERTY(QString historyVisibility READ historyVisibility WRITE setHistoryVisibility NOTIFY historyVisibilityChanged)
/**
* @brief Set the default URL preview state for room members.
*
* Assumed false if the org.matrix.room.preview_urls state message has never been
* set. Can only be set if the calling user has a high enough power level.
*/
Q_PROPERTY(bool defaultUrlPreviewState READ defaultUrlPreviewState WRITE setDefaultUrlPreviewState NOTIFY defaultUrlPreviewStateChanged)
/**
* @brief Enable URL previews for the local user.
*/
Q_PROPERTY(bool urlPreviewEnabled READ urlPreviewEnabled WRITE setUrlPreviewEnabled NOTIFY urlPreviewEnabledChanged)
/**
* @brief Whether the local user can encrypt the room.
*
* A local user can encrypt a room if they have permission to send the m.room.encryption
* state event.
*
* @sa https://spec.matrix.org/v1.5/client-server-api/#mroomencryption
*/
Q_PROPERTY(bool canEncryptRoom READ canEncryptRoom NOTIFY canEncryptRoomChanged)
/**
* @brief The cache for the main chat bar in the room.
*/
Q_PROPERTY(ChatBarCache *mainCache READ mainCache CONSTANT)
/**
* @brief The cache for the edit chat bar in the room.
*/
Q_PROPERTY(ChatBarCache *editCache READ editCache CONSTANT)
/**
* @brief The cache for the thread chat bar in the room.
*/
Q_PROPERTY(ChatBarCache *threadCache READ threadCache CONSTANT)
public:
explicit NeoChatRoom(Quotient::Connection *connection, QString roomId, Quotient::JoinState joinState = {});
bool visible() const;
void setVisible(bool visible);
[[nodiscard]] QDateTime lastActiveTime();
/**
* @brief Get the last interesting event.
*
* This function respects the user's state event setting and discards
* other not interesting events.
*
* @warning This function can return an empty pointer if the room does not have
* any RoomMessageEvents loaded.
*/
[[nodiscard]] const Quotient::RoomEvent *lastEvent(std::function<bool(const Quotient::RoomEvent *)> filter = {}) const;
/**
* @brief Convenient way to check if the last event looks like it has spoilers.
*
* This does a basic check to see if the message contains a data-mx-spoiler
* attribute within the text which makes it likely that the message has a spoiler
* section. However this is not 100% reliable as during parsing it may be
* removed if used within an illegal tag or on a tag for which data-mx-spoiler
* is not a valid attribute.
*
* @sa lastEvent()
*/
[[nodiscard]] bool lastEventIsSpoiler() const;
/**
* @brief Return the notification count for the room accounting for tags and notification state.
*
* The following rules are observed:
* - Rooms tagged as low priority or mentions and keywords notification state
* only return the number of highlights.
* - Muted rooms always return 0.
*/
int contextAwareNotificationCount() const;
[[nodiscard]] bool hasFileUploading() const;
void setHasFileUploading(bool value);
[[nodiscard]] int fileUploadingProgress() const;
void setFileUploadingProgress(int value);
/**
* @brief Download a file for the given event to a local file location.
*/
Q_INVOKABLE void download(const QString &eventId, const QUrl &localFilename = {});
/**
* @brief Download a file for the given event as a temporary file.
*/
Q_INVOKABLE bool downloadTempFile(const QString &eventId);
/**
* @brief Check if the given event is highlighted.
*
* An event is highlighted if it contains the local user's id but was not sent by the
* local user.
*/
bool isEventHighlighted(const Quotient::RoomEvent *e) const;
/**
* @brief Convenience function to find out if the room contains the given user.
*
* A room contains the user if the user can be found and their JoinState is
* not JoinState::Leave.
*/
Q_INVOKABLE [[nodiscard]] bool containsUser(const QString &userID) const;
/**
* @brief True if the given user ID is banned from the room.
*/
Q_INVOKABLE [[nodiscard]] bool isUserBanned(const QString &user) const;
/**
* @brief True if the local user can send the given event type.
*/
Q_INVOKABLE [[nodiscard]] bool canSendEvent(const QString &eventType) const;
/**
* @brief True if the local user can send the given state event type.
*/
Q_INVOKABLE [[nodiscard]] bool canSendState(const QString &eventType) const;
/**
* @brief Send a report to the server for an event.
*
* @param eventId the ID of the event being reported.
* @param reason the reason given for reporting the event.
*/
Q_INVOKABLE void reportEvent(const QString &eventId, const QString &reason);
Q_INVOKABLE QByteArray getEventJsonSource(const QString &eventId);
/**
* @brief Open the media for the given event in an appropriate external app.
*
* Will do nothing if the event has no media.
*/
Q_INVOKABLE void openEventMediaExternally(const QString &eventId);
/**
* @brief Copy the media for the given event to the clipboard.
*
* Will do nothing if the event has no media.
*/
Q_INVOKABLE void copyEventMedia(const QString &eventId);
[[nodiscard]] bool readMarkerLoaded() const;
[[nodiscard]] QUrl avatarMediaUrl() const;
NeochatRoomMember *directChatRemoteMember();
/**
* @brief Whether this room has one or more parent spaces set.
*/
Q_INVOKABLE bool hasParent() const;
QList<QString> parentIds() const;
/**
* @brief Get a list of parent space objects for this room.
*
* Will only return retrun spaces that are know, i.e. the user has joined and
* a valid NeoChatRoom is available.
*
* @param multiLevel whether the function should recursively gather all levels
* of parents
*/
Q_INVOKABLE QList<NeoChatRoom *> parentObjects(bool multiLevel = false) const;
QString canonicalParent() const;
void setCanonicalParent(const QString &parentId);
/**
* @brief Whether the local user has permission to set the given space as a parent.
*
* @note This follows the rules determined in the Matrix spec
* https://spec.matrix.org/v1.7/client-server-api/#mspaceparent-relationships
*/
Q_INVOKABLE bool canModifyParent(const QString &parentId) const;
/**
* @brief Add the given room as a parent.
*
* Will fail if the user doesn't have the required privileges (see
* canModifyParent()).
*
* @sa canModifyParent()
*/
Q_INVOKABLE void addParent(const QString &parentId, bool canonical = false, bool setParentChild = false);
/**
* @brief Remove the given room as a parent.
*
* Will fail if the user doesn't have the required privileges (see
* canModifyParent()).
*
* @sa canModifyParent()
*/
Q_INVOKABLE void removeParent(const QString &parentId);
[[nodiscard]] bool isSpace() const;
qsizetype childrenNotificationCount();
bool childrenHaveHighlightNotifications() const;
/**
* @brief Add the given room as a child.
*
* Will fail if the user doesn't have the required privileges or this room is
* not a space.
*/
Q_INVOKABLE void addChild(const QString &childId, bool setChildParent = false, bool canonical = false, bool suggested = false, const QString &order = {});
/**
* @brief Remove the given room as a child.
*
* Will fail if the user doesn't have the required privileges or this room is
* not a space.
*/
Q_INVOKABLE void removeChild(const QString &childId, bool unsetChildParent = false);
/**
* @brief Whether the given child is a suggested room in the space.
*/
Q_INVOKABLE bool isSuggested(const QString &childId);
/**
* @brief Toggle whether the given child is a suggested room in the space.
*
* Will fail if the user doesn't have the required privileges, this room is
* not a space or the given room is not a child of this space.
*/
Q_INVOKABLE void toggleChildSuggested(const QString &childId);
void setChildOrder(const QString &childId, const QString &order = {});
bool isInvite() const;
bool readOnly() const;
int maxRoomVersion() const;
/**
* @brief Map an alias to the room and publish.
*
* The alias is first mapped to the room and then published as an
* alternate alias. Publishing the alias will fail if the user does not have
* permission to send m.room.canonical_alias event messages.
*
* @note This is different to Quotient::Room::setLocalAliases() as that can only
* get the room to publish an alias that is already mapped.
*
* @property alias QString in the form #new_alias:server.org
*
* @sa Quotient::Room::setLocalAliases()
*/
Q_INVOKABLE void mapAlias(const QString &alias);
/**
* @brief Unmap an alias from the room.
*
* An unmapped alias is also removed as either the canonical alias or an alternate
* alias.
*
* @note This is different to Quotient::Room::setLocalAliases() as that can only
* get the room to un-publish an alias, while the mapping still exists.
*
* @property alias QString in the form #mapped_alias:server.org
*
* @sa Quotient::Room::setLocalAliases()
*/
Q_INVOKABLE void unmapAlias(const QString &alias);
/**
* @brief Set the canonical alias of the room to an available mapped alias.
*
* If the new alias was already published as an alternate alias it will be removed
* from that list.
*
* @note This is an overload of the function Quotient::Room::setCanonicalAlias().
* This is to provide the functionality to remove the new canonical alias as a
* published alt alias when set.
*
* @property newAlias QString in the form #new_alias:server.org
*
* @sa Quotient::Room::setCanonicalAlias()
* */
Q_INVOKABLE void setCanonicalAlias(const QString &newAlias);
Q_INVOKABLE void setRoomState(const QString &type, const QString &stateKey, const QByteArray &content);
PushNotificationState::State pushNotificationState() const;
void setPushNotificationState(PushNotificationState::State state);
[[nodiscard]] QString historyVisibility() const;
void setHistoryVisibility(const QString &historyVisibilityRule);
[[nodiscard]] bool defaultUrlPreviewState() const;
void setDefaultUrlPreviewState(const bool &defaultUrlPreviewState);
[[nodiscard]] bool urlPreviewEnabled() const;
void setUrlPreviewEnabled(const bool &urlPreviewEnabled);
bool canEncryptRoom() const;
Q_INVOKABLE void setUserPowerLevel(const QString &userID, const int &powerLevel);
ChatBarCache *mainCache() const;
ChatBarCache *editCache() const;
ChatBarCache *threadCache() const;
/**
* @brief Reply to the last message sent in the timeline.
*
* @note This checks a maximum of the previous 35 message for performance reasons.
*/
Q_INVOKABLE void replyLastMessage();
/**
* @brief Edit the last message sent by the local user.
*
* @note This checks a maximum of the previous 35 message for performance reasons.
*/
Q_INVOKABLE void editLastMessage();
Q_INVOKABLE void postPoll(PollKind::Kind kind, const QString &question, const QList<QString> &answers);
/**
* @brief Get the full Json data for a given room account data event.
*/
Q_INVOKABLE QByteArray roomAcountDataJson(const QString &eventType);
/**
* @brief Loads the event with the given id from the server and saves it locally.
*
* Intended to retrieve events that are needed, e.g. replied to events that are
* not currently in the timeline.
*
* If the event is already in the timeline nothing will happen.
*/
void downloadEventFromServer(const QString &eventId);
/**
* @brief Returns the event with the given ID if available.
*
* This function will check both the timeline and extra events and return a
* non-nullptr value if it is found in either.
*
* The result will be nullptr if not found so needs to be managed.
*/
std::pair<const Quotient::RoomEvent *, bool> getEvent(const QString &eventId) const;
/**
* @brief Returns the event that is being replied to. This includes events that were manually loaded using NeoChatRoom::loadReply.
*/
const Quotient::RoomEvent *getReplyForEvent(const Quotient::RoomEvent &event) const;
/**
* If we're invited to this room, the user that invited us. Undefined in other cases.
*/
Q_INVOKABLE QString invitingUserId() const;
/**
* @brief Return the cached file transfer information for the event.
*
* If we downloaded the file previously, return a struct with Completed status
* and the local file path stored in KSharedCOnfig
*/
Quotient::FileTransferInfo cachedFileTransferInfo(const Quotient::RoomEvent *event) const;
/**
* @brief Return a NeochatRoomMember object for the given user ID.
*
* @warning Because we can't guarantee that a member state event is downloaded
* before a message they sent arrives this will create the object unconditionally
* assuming that the state event will turn up later. It is therefor the
* responsibility of the caller to ensure that they only ask for objects
* for real senders.
*/
NeochatRoomMember *qmlSafeMember(const QString &memberId);
/**
* @brief Pin a message in the room.
* @param eventId The id of the event to pin.
*/
Q_INVOKABLE void pinEvent(const QString &eventId);
/**
* @brief Unpin a message in the room.
* @param eventId The id of the event to unpin.
*/
Q_INVOKABLE void unpinEvent(const QString &eventId);
/**
* @return True if @p eventId is pinned in the room.
*/
Q_INVOKABLE bool isEventPinned(const QString &eventId) const;
private:
bool m_visible = false;
QSet<const Quotient::RoomEvent *> highlights;
bool m_hasFileUploading = false;
int m_fileUploadingProgress = 0;
PushNotificationState::State m_currentPushNotificationState = PushNotificationState::Unknown;
bool m_pushNotificationStateUpdating = false;
void checkForHighlights(const Quotient::TimelineItem &ti);
void onAddNewTimelineEvents(timeline_iter_t from) override;
void onAddHistoricalTimelineEvents(rev_iter_t from) override;
void onRedaction(const Quotient::RoomEvent &prevEvent, const Quotient::RoomEvent &after) override;
QCoro::Task<void> doDeleteMessagesByUser(const QString &user, QString reason);
QCoro::Task<void> doUploadFile(QUrl url, QString body = QString(), std::optional<Quotient::EventRelation> relatesTo = std::nullopt);
std::unique_ptr<Quotient::RoomEvent> m_cachedEvent;
ChatBarCache *m_mainCache;
ChatBarCache *m_editCache;
ChatBarCache *m_threadCache;
std::vector<Quotient::event_ptr_tt<Quotient::RoomEvent>> m_extraEvents;
void cleanupExtraEventRange(Quotient::RoomEventsRange events);
void cleanupExtraEvent(const QString &eventId);
std::unordered_map<QString, std::unique_ptr<NeochatRoomMember>> m_memberObjects;
private Q_SLOTS:
void updatePushNotificationState(QString type);
void cacheLastEvent();
Q_SIGNALS:
void cachedInputChanged();
void busyChanged();
void hasFileUploadingChanged();
void fileUploadingProgressChanged();
void backgroundChanged();
void readMarkerLoadedChanged();
void parentIdsChanged();
void canonicalParentChanged();
void lastActiveTimeChanged();
void childrenNotificationCountChanged();
void childrenHaveHighlightNotificationsChanged();
void isInviteChanged();
void readOnlyChanged();
void displayNameChanged();
void pushNotificationStateChanged(PushNotificationState::State state);
void canEncryptRoomChanged();
void historyVisibilityChanged();
void defaultUrlPreviewStateChanged();
void urlPreviewEnabledChanged();
void maxRoomVersionChanged();
void extraEventLoaded(const QString &eventId);
void extraEventNotFound(const QString &eventId);
/**
* @brief Request a message be shown to the user of the given type.
*/
void showMessage(MessageType::Type messageType, const QString &message);
public Q_SLOTS:
/**
* @brief Upload a file to the matrix server and post the file to the room.
*
* @param url the location of the file to be uploaded.
* @param body the caption that is to be given to the file.
*/
void uploadFile(const QUrl &url, const QString &body = QString(), std::optional<Quotient::EventRelation> relatesTo = std::nullopt);
/**
* @brief Accept an invitation for the local user to join the room.
*/
void acceptInvitation();
/**
* @brief Leave and forget the room for the local user.
*
* @note This means that not only will the user no longer receive events in
* the room but the will forget any history up to this point.
*
* @sa https://spec.matrix.org/latest/client-server-api/#leaving-rooms
*/
void forget();
/**
* @brief Set the typing notification state on the room for the local user.
*/
void sendTypingNotification(bool isTyping);
/**
* @brief Set the room avatar.
*/
void changeAvatar(const QUrl &localFile);
/**
* @brief Toggle the reaction state of the given reaction for the local user.
*/
void toggleReaction(const QString &eventId, const QString &reaction);
/**
* @brief Delete recent messages by the given user.
*
* This will delete all messages by that user in this room that are currently loaded.
*/
void deleteMessagesByUser(const QString &user, const QString &reason);
/**
* @brief Sends a location to a room
* The event is sent in the migration format as specified in MSC3488
* @param lat latitude
* @param lon longitude
* @param description description for the location
*/
void sendLocation(float lat, float lon, const QString &description);
};

View File

@@ -0,0 +1,165 @@
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#include "neochatroommember.h"
#include "neochatroom.h"
NeochatRoomMember::NeochatRoomMember(NeoChatRoom *room, const QString &memberId)
: m_room(room)
, m_memberId(memberId)
{
Q_ASSERT(!m_memberId.isEmpty());
if (m_room != nullptr) {
connect(m_room, &NeoChatRoom::memberNameUpdated, this, [this](Quotient::RoomMember member) {
if (member.id() == m_memberId) {
Q_EMIT displayNameUpdated();
}
});
connect(m_room, &NeoChatRoom::memberAvatarUpdated, this, [this](Quotient::RoomMember member) {
if (member.id() == m_memberId) {
Q_EMIT avatarUpdated();
}
});
}
}
QString NeochatRoomMember::id() const
{
return m_memberId;
}
Quotient::Uri NeochatRoomMember::uri() const
{
if (m_room == nullptr || m_memberId.isEmpty()) {
return {};
}
return m_room->member(m_memberId).uri();
}
bool NeochatRoomMember::isLocalMember() const
{
if (m_room == nullptr || m_memberId.isEmpty()) {
return false;
}
return m_room->member(m_memberId).isLocalMember();
}
Quotient::Membership NeochatRoomMember::membershipState() const
{
if (m_room == nullptr || m_memberId.isEmpty()) {
return Quotient::Membership::Leave;
}
return m_room->member(m_memberId).membershipState();
}
QString NeochatRoomMember::name() const
{
if (m_room == nullptr || m_memberId.isEmpty()) {
return id();
}
return m_room->member(m_memberId).name();
}
QString NeochatRoomMember::displayName() const
{
if (m_room == nullptr || m_memberId.isEmpty()) {
return id();
}
const auto memberObject = m_room->member(m_memberId);
return memberObject.isEmpty() ? id() : memberObject.displayName();
}
QString NeochatRoomMember::htmlSafeDisplayName() const
{
if (m_room == nullptr || m_memberId.isEmpty()) {
return id();
}
const auto memberObject = m_room->member(m_memberId);
return memberObject.isEmpty() ? id() : memberObject.htmlSafeDisplayName();
}
QString NeochatRoomMember::fullName() const
{
if (m_room == nullptr || m_memberId.isEmpty()) {
return id();
}
const auto memberObject = m_room->member(m_memberId);
return memberObject.isEmpty() ? id() : memberObject.fullName();
}
QString NeochatRoomMember::htmlSafeFullName() const
{
if (m_room == nullptr || m_memberId.isEmpty()) {
return id();
}
const auto memberObject = m_room->member(m_memberId);
return memberObject.isEmpty() ? id() : memberObject.htmlSafeFullName();
}
QString NeochatRoomMember::disambiguatedName() const
{
if (m_room == nullptr || m_memberId.isEmpty()) {
return id();
}
const auto memberObject = m_room->member(m_memberId);
return memberObject.isEmpty() ? id() : memberObject.disambiguatedName();
}
QString NeochatRoomMember::htmlSafeDisambiguatedName() const
{
if (m_room == nullptr || m_memberId.isEmpty()) {
return id();
}
const auto memberObject = m_room->member(m_memberId);
return memberObject.isEmpty() ? id() : memberObject.htmlSafeDisambiguatedName();
}
int NeochatRoomMember::hue() const
{
if (m_room == nullptr || m_memberId.isEmpty()) {
return 0;
}
return m_room->member(m_memberId).hue();
}
qreal NeochatRoomMember::hueF() const
{
if (m_room == nullptr || m_memberId.isEmpty()) {
return 0.0;
}
return m_room->member(m_memberId).hueF();
}
QColor NeochatRoomMember::color() const
{
if (m_room == nullptr || m_memberId.isEmpty()) {
return {};
}
return m_room->member(m_memberId).color();
}
QUrl NeochatRoomMember::avatarUrl() const
{
if (m_room == nullptr || m_memberId.isEmpty()) {
return {};
}
return m_room->member(m_memberId).avatarUrl();
}
#include "moc_neochatroommember.cpp"

View File

@@ -0,0 +1,82 @@
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#pragma once
#include <QObject>
#include <QPointer>
#include <qqmlintegration.h>
#include <Quotient/roommember.h>
#include <Quotient/uri.h>
class NeoChatRoom;
/**
* @class NeochatRoomMember
*
* This class is a shim around RoomMember that can be safety passed to QML.
*
* Because RoomMember has an internal pointer to a RoomMemberEvent it is
* designed to be created used then quickly discarded as the stste event is changed
* every time the member updates. Passing these to QML which will hold onto them
* can lead to accessing an already deleted Quotient::RoomMemberEvent relatively easily.
*
* This class instead holds a member ID and can therefore always safely create and
* access Quotient::RoomMember objects while being used as long as needed by QML.
*
* @note This is only needed to pass to QML if only accessing in CPP RoomMmeber can
* be used safely.
*
* @note The interface is the same as Quotient::RoomMember.
*
* @sa Quotient::RoomMember
*/
class NeochatRoomMember : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("")
Q_PROPERTY(QString id READ id CONSTANT)
Q_PROPERTY(Quotient::Uri uri READ uri CONSTANT)
Q_PROPERTY(bool isLocalMember READ isLocalMember CONSTANT)
Q_PROPERTY(QString displayName READ displayName NOTIFY displayNameUpdated)
Q_PROPERTY(QString htmlSafeDisplayName READ htmlSafeDisplayName NOTIFY displayNameUpdated)
Q_PROPERTY(QString fullName READ fullName NOTIFY displayNameUpdated)
Q_PROPERTY(QString htmlSafeFullName READ htmlSafeFullName NOTIFY displayNameUpdated)
Q_PROPERTY(QString disambiguatedName READ disambiguatedName NOTIFY displayNameUpdated)
Q_PROPERTY(QString htmlSafeDisambiguatedName READ htmlSafeDisambiguatedName NOTIFY displayNameUpdated)
Q_PROPERTY(int hue READ hue CONSTANT)
Q_PROPERTY(qreal hueF READ hueF CONSTANT)
Q_PROPERTY(QColor color READ color CONSTANT)
Q_PROPERTY(QUrl avatarUrl READ avatarUrl NOTIFY avatarUpdated)
public:
NeochatRoomMember() = default;
explicit NeochatRoomMember(NeoChatRoom *room, const QString &memberId);
QString id() const;
Quotient::Uri uri() const;
bool isLocalMember() const;
Quotient::Membership membershipState() const;
QString name() const;
QString displayName() const;
QString htmlSafeDisplayName() const;
QString fullName() const;
QString htmlSafeFullName() const;
QString disambiguatedName() const;
QString htmlSafeDisambiguatedName() const;
int hue() const;
qreal hueF() const;
QColor color() const;
QUrl avatarUrl() const;
Q_SIGNALS:
void displayNameUpdated();
void avatarUpdated();
private:
QPointer<NeoChatRoom> m_room;
const QString m_memberId = QString();
};

View File

@@ -0,0 +1,38 @@
// SPDX-FileCopyrightText: 2023 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: LGPL-2.0-or-later
#include "roomlastmessageprovider.h"
using namespace Qt::Literals::StringLiterals;
RoomLastMessageProvider::RoomLastMessageProvider()
: m_config(KSharedConfig::openStateConfig())
, m_configGroup(KConfigGroup(m_config, u"EventCache"_s))
{
}
RoomLastMessageProvider::~RoomLastMessageProvider()
{
m_config->sync();
}
RoomLastMessageProvider &RoomLastMessageProvider::self()
{
static RoomLastMessageProvider instance;
return instance;
}
bool RoomLastMessageProvider::hasKey(const QString &roomId) const
{
return m_configGroup.hasKey(roomId);
}
QByteArray RoomLastMessageProvider::read(const QString &roomId) const
{
return m_configGroup.readEntry(roomId, QByteArray{});
}
void RoomLastMessageProvider::write(const QString &roomId, const QByteArray &json)
{
m_configGroup.writeEntry(roomId, json);
}

View File

@@ -0,0 +1,41 @@
// SPDX-FileCopyrightText: 2023 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: LGPL-2.0-or-later
#pragma once
#include <KConfigGroup>
#include <KSharedConfig>
/**
* Store and retrieve the last message of a room.
*/
class RoomLastMessageProvider
{
public:
/**
* Get the global instance of RoomLastMessageProvider.
*/
static RoomLastMessageProvider &self();
~RoomLastMessageProvider();
/**
* Check if we have the last message content for the specified roomId.
*/
bool hasKey(const QString &roomId) const;
/**
* Read the last message content of the specified roomId.
*/
QByteArray read(const QString &roomId) const;
/**
* Write the last message content for the specified roomId.
*/
void write(const QString &roomId, const QByteArray &json);
private:
RoomLastMessageProvider();
KSharedConfig::Ptr m_config;
KConfigGroup m_configGroup;
};

View File

@@ -0,0 +1,243 @@
// SPDX-FileCopyrightText: 2022 Snehit Sah <hi@snehit.dev>
// SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
#include "spacehierarchycache.h"
#include <Quotient/csapi/space_hierarchy.h>
#include <Quotient/qt_connection_util.h>
#include <KConfigGroup>
#include <KSharedConfig>
#include "neochatconnection.h"
#include "neochatroom.h"
using namespace Quotient;
SpaceHierarchyCache::SpaceHierarchyCache(QObject *parent)
: QObject{parent}
{
}
void SpaceHierarchyCache::cacheSpaceHierarchy()
{
if (!m_connection) {
return;
}
const auto &roomList = m_connection->allRooms();
for (const auto &room : roomList) {
const auto neoChatRoom = static_cast<NeoChatRoom *>(room);
if (neoChatRoom->isSpace()) {
populateSpaceHierarchy(neoChatRoom->id());
} else {
connect(
neoChatRoom,
&Room::baseStateLoaded,
neoChatRoom,
[this, neoChatRoom]() {
if (neoChatRoom->isSpace()) {
populateSpaceHierarchy(neoChatRoom->id());
}
},
Qt::SingleShotConnection);
}
connect(neoChatRoom, &NeoChatRoom::unreadStatsChanged, this, [this, neoChatRoom]() {
if (neoChatRoom != nullptr) {
const auto parents = parentSpaces(neoChatRoom->id());
if (parents.count() > 0) {
Q_EMIT spaceNotifcationCountChanged(parents);
}
}
});
}
}
void SpaceHierarchyCache::populateSpaceHierarchy(const QString &spaceId)
{
if (!m_connection) {
return;
}
m_nextBatchTokens[spaceId] = QString();
auto job = m_connection->callApi<GetSpaceHierarchyJob>(spaceId, std::nullopt, std::nullopt, std::nullopt, *m_nextBatchTokens[spaceId]);
auto group = KConfigGroup(KSharedConfig::openStateConfig("SpaceHierarchy"_L1), "Cache"_L1);
m_spaceHierarchy.insert(spaceId, group.readEntry(spaceId, QStringList()));
connect(job, &BaseJob::success, this, [this, job, spaceId]() {
addBatch(spaceId, job);
});
}
void SpaceHierarchyCache::addBatch(const QString &spaceId, Quotient::GetSpaceHierarchyJob *job)
{
const auto rooms = job->rooms();
QStringList roomList = m_spaceHierarchy[spaceId];
for (unsigned long i = 0; i < rooms.size(); ++i) {
for (const auto &state : rooms[i].childrenState) {
if (!roomList.contains(state->stateKey())) {
roomList.push_back(state->stateKey());
}
}
}
m_spaceHierarchy.insert(spaceId, roomList);
Q_EMIT spaceHierarchyChanged();
auto group = KConfigGroup(KSharedConfig::openStateConfig("SpaceHierarchy"_L1), "Cache"_L1);
group.writeEntry(spaceId, roomList);
group.sync();
const auto nextBatchToken = job->nextBatch();
if (!nextBatchToken.isEmpty() && nextBatchToken != *m_nextBatchTokens[spaceId] && m_connection) {
*m_nextBatchTokens[spaceId] = nextBatchToken;
auto nextJob = m_connection->callApi<GetSpaceHierarchyJob>(spaceId, std::nullopt, std::nullopt, std::nullopt, *m_nextBatchTokens[spaceId]);
connect(nextJob, &BaseJob::success, this, [this, nextJob, spaceId]() {
addBatch(spaceId, nextJob);
});
} else {
m_nextBatchTokens[spaceId].reset();
}
}
void SpaceHierarchyCache::addSpaceToHierarchy(Quotient::Room *room)
{
connect(
room,
&Quotient::Room::baseStateLoaded,
this,
[this, room]() {
const auto neoChatRoom = static_cast<NeoChatRoom *>(room);
if (neoChatRoom->isSpace()) {
populateSpaceHierarchy(neoChatRoom->id());
}
},
Qt::SingleShotConnection);
}
void SpaceHierarchyCache::removeSpaceFromHierarchy(Quotient::Room *room)
{
const auto neoChatRoom = static_cast<NeoChatRoom *>(room);
if (neoChatRoom->isSpace()) {
m_spaceHierarchy.remove(neoChatRoom->id());
}
}
QStringList SpaceHierarchyCache::parentSpaces(const QString &roomId)
{
auto spaces = m_spaceHierarchy.keys();
QStringList parents;
for (const auto &space : spaces) {
if (m_spaceHierarchy[space].contains(roomId)) {
parents += space;
}
}
return parents;
}
bool SpaceHierarchyCache::isSpaceChild(const QString &spaceId, const QString &roomId)
{
return getRoomListForSpace(spaceId, false).contains(roomId);
}
QList<QString> &SpaceHierarchyCache::getRoomListForSpace(const QString &spaceId, bool updateCache)
{
if (updateCache) {
populateSpaceHierarchy(spaceId);
}
return m_spaceHierarchy[spaceId];
}
qsizetype SpaceHierarchyCache::notificationCountForSpace(const QString &spaceId)
{
qsizetype notifications = 0;
auto children = m_spaceHierarchy[spaceId];
QStringList added;
for (const auto &childId : children) {
if (const auto child = static_cast<NeoChatRoom *>(m_connection->room(childId))) {
if (!added.contains(child->id())) {
notifications += child->contextAwareNotificationCount();
added += child->id();
}
}
}
return notifications;
}
bool SpaceHierarchyCache::spaceHasHighlightNotifications(const QString &spaceId)
{
auto children = m_spaceHierarchy[spaceId];
for (const auto &childId : children) {
if (const auto child = static_cast<NeoChatRoom *>(m_connection->room(childId))) {
if (child->highlightCount() > 0) {
return true;
}
}
}
return false;
}
bool SpaceHierarchyCache::isChild(const QString &roomId) const
{
const auto childrens = m_spaceHierarchy.values();
for (const auto &children : childrens) {
if (children.contains(roomId)) {
return true;
}
}
return false;
}
NeoChatConnection *SpaceHierarchyCache::connection() const
{
return m_connection;
}
void SpaceHierarchyCache::setConnection(NeoChatConnection *connection)
{
if (m_connection == connection) {
return;
}
m_connection = connection;
Q_EMIT connectionChanged();
m_spaceHierarchy.clear();
cacheSpaceHierarchy();
connect(connection, &Connection::joinedRoom, this, &SpaceHierarchyCache::addSpaceToHierarchy);
connect(connection, &Connection::aboutToDeleteRoom, this, &SpaceHierarchyCache::removeSpaceFromHierarchy);
}
QString SpaceHierarchyCache::recommendedSpaceId() const
{
return KConfigGroup(KSharedConfig::openConfig(), u"RecommendedSpace"_s).readEntry(u"Id"_s, {});
}
QString SpaceHierarchyCache::recommendedSpaceAvatar() const
{
return KConfigGroup(KSharedConfig::openConfig(), u"RecommendedSpace"_s).readEntry(u"Avatar"_s, {});
}
QString SpaceHierarchyCache::recommendedSpaceDisplayName() const
{
return KConfigGroup(KSharedConfig::openConfig(), u"RecommendedSpace"_s).readEntry(u"DisplayName"_s, {});
}
QString SpaceHierarchyCache::recommendedSpaceDescription() const
{
return KConfigGroup(KSharedConfig::openConfig(), u"RecommendedSpace"_s).readEntry(u"Description"_s, {});
}
bool SpaceHierarchyCache::recommendedSpaceHidden() const
{
KConfigGroup group(KSharedConfig::openStateConfig(), u"RecommendedSpace"_s);
return group.readEntry<bool>(u"hidden"_s, false);
}
void SpaceHierarchyCache::setRecommendedSpaceHidden(bool hidden)
{
KConfigGroup group(KSharedConfig::openStateConfig(), u"RecommendedSpace"_s);
group.writeEntry(u"hidden"_s, hidden);
group.sync();
Q_EMIT recommendedSpaceHiddenChanged();
}
#include "moc_spacehierarchycache.cpp"

View File

@@ -0,0 +1,119 @@
// SPDX-FileCopyrightText: 2022 Snehit Sah <hi@snehit.dev>
// SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
#pragma once
#include <QHash>
#include <QList>
#include <QObject>
#include <QQmlEngine>
#include <QString>
namespace Quotient
{
class Room;
class GetSpaceHierarchyJob;
}
class NeoChatConnection;
/**
* @class SpaceHierarchyCache
*
* A class to store the child spaces for each space.
*
* Spaces are cached on startup or when the user enters a new space.
*/
class SpaceHierarchyCache : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
Q_PROPERTY(NeoChatConnection *connection READ connection WRITE setConnection NOTIFY connectionChanged)
Q_PROPERTY(QString recommendedSpaceId READ recommendedSpaceId CONSTANT)
Q_PROPERTY(QString recommendedSpaceAvatar READ recommendedSpaceAvatar CONSTANT)
Q_PROPERTY(QString recommendedSpaceDisplayName READ recommendedSpaceDisplayName CONSTANT)
Q_PROPERTY(QString recommendedSpaceDescription READ recommendedSpaceDescription CONSTANT)
Q_PROPERTY(bool recommendedSpaceHidden READ recommendedSpaceHidden WRITE setRecommendedSpaceHidden NOTIFY recommendedSpaceHiddenChanged)
public:
static SpaceHierarchyCache &instance()
{
static SpaceHierarchyCache _instance;
return _instance;
}
static SpaceHierarchyCache *create(QQmlEngine *engine, QJSEngine *)
{
engine->setObjectOwnership(&instance(), QQmlEngine::CppOwnership);
return &instance();
}
/**
* @brief Returns the list of parent spaces for a child if any.
*/
QStringList parentSpaces(const QString &roomId);
/**
* @brief Whether the given room is a member of the given space.
*/
Q_INVOKABLE bool isSpaceChild(const QString &spaceId, const QString &roomId);
/**
* @brief Return the list of child rooms for the given space ID.
*/
[[nodiscard]] QList<QString> &getRoomListForSpace(const QString &spaceId, bool updateCache);
/**
* @brief Return the number of notifications for the child rooms in a given space ID.
*/
qsizetype notificationCountForSpace(const QString &spaceId);
/**
* @brief Whether any of the child rooms have highlight notifications.
*/
bool spaceHasHighlightNotifications(const QString &spaceId);
/**
* @brief Returns whether the room is a child space of any space.
*
* @note We need to do this from the hierarchy as it is not guaranteed that the
* child knows it's in a space. See
* https://spec.matrix.org/v1.8/client-server-api/#managing-roomsspaces-included-in-a-space
*/
[[nodiscard]] bool isChild(const QString &roomId) const;
NeoChatConnection *connection() const;
void setConnection(NeoChatConnection *connection);
QString recommendedSpaceId() const;
QString recommendedSpaceAvatar() const;
QString recommendedSpaceDisplayName() const;
QString recommendedSpaceDescription() const;
bool recommendedSpaceHidden() const;
void setRecommendedSpaceHidden(bool hidden);
Q_SIGNALS:
void spaceHierarchyChanged();
void connectionChanged();
void spaceNotifcationCountChanged(const QStringList &spaces);
void recommendedSpaceHiddenChanged();
private Q_SLOTS:
void addSpaceToHierarchy(Quotient::Room *room);
void removeSpaceFromHierarchy(Quotient::Room *room);
private:
explicit SpaceHierarchyCache(QObject *parent = nullptr);
QList<QString> m_activeSpaceRooms;
QHash<QString, QList<QString>> m_spaceHierarchy;
void cacheSpaceHierarchy();
QHash<QString, std::optional<QString>> m_nextBatchTokens;
void populateSpaceHierarchy(const QString &spaceId);
void addBatch(const QString &spaceId, Quotient::GetSpaceHierarchyJob *job);
QPointer<NeoChatConnection> m_connection;
};

View File

@@ -0,0 +1,734 @@
// SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#include "texthandler.h"
#include <QDebug>
#include <QFontMetrics>
#include <QGuiApplication>
#include <QStringLiteral>
#include <QTextBlock>
#include <QUrl>
#include <Quotient/events/roommessageevent.h>
#include <Quotient/util.h>
#include <cmark.h>
#include <Kirigami/Platform/PlatformTheme>
#include "messagecomponenttype.h"
#include "models/customemojimodel.h"
#include "utils.h"
using namespace Qt::StringLiterals;
static const QStringList allowedTags = {
u"font"_s, u"del"_s, u"h1"_s, u"h2"_s, u"h3"_s, u"h4"_s, u"h5"_s, u"h6"_s, u"blockquote"_s, u"p"_s, u"a"_s, u"ul"_s, u"ol"_s,
u"sup"_s, u"sub"_s, u"li"_s, u"b"_s, u"i"_s, u"u"_s, u"strong"_s, u"em"_s, u"strike"_s, u"code"_s, u"hr"_s, u"br"_s, u"div"_s,
u"table"_s, u"thead"_s, u"tbody"_s, u"tr"_s, u"th"_s, u"td"_s, u"caption"_s, u"pre"_s, u"span"_s, u"img"_s, u"details"_s, u"summary"_s};
static const QHash<QString, QStringList> allowedAttributes = {{u"font"_s, {u"data-mx-bg-color"_s, u"data-mx-color"_s, u"color"_s}},
{u"span"_s, {u"data-mx-bg-color"_s, u"data-mx-color"_s, u"data-mx-spoiler"_s}},
{u"a"_s, {u"name"_s, u"target"_s, u"href"_s}},
{u"img"_s, {u"style"_s, u"width"_s, u"height"_s, u"alt"_s, u"title"_s, u"src"_s}},
{u"ol"_s, {u"start"_s}},
{u"code"_s, {u"class"_s}}};
static const QStringList allowedLinkSchemes = {u"https"_s, u"http"_s, u"ftp"_s, u"mailto"_s, u"magnet"_s};
static const QStringList blockTags = {u"blockquote"_s, u"p"_s, u"ul"_s, u"ol"_s, u"div"_s, u"table"_s, u"pre"_s};
static const QString customEmojiStyle = u"vertical-align:bottom"_s;
QString TextHandler::data() const
{
return m_data;
}
void TextHandler::setData(const QString &string)
{
m_data = string;
m_pos = 0;
}
QString TextHandler::handleSendText()
{
m_pos = 0;
m_dataBuffer = markdownToHTML(m_data);
m_nextTokenType = nextTokenType(m_dataBuffer, m_pos, m_nextToken, m_nextTokenType);
// Strip any disallowed tags/attributes.
QString outputString;
while (m_pos < m_dataBuffer.length()) {
next();
QString nextTokenBuffer = m_nextToken;
switch (m_nextTokenType) {
case Text:
nextTokenBuffer = escapeHtml(nextTokenBuffer);
nextTokenBuffer = CustomEmojiModel::instance().preprocessText(nextTokenBuffer);
break;
case TextCode:
nextTokenBuffer = escapeHtml(nextTokenBuffer);
break;
case Tag:
if (!isAllowedTag(getTagType(m_nextToken))) {
nextTokenBuffer = QString();
}
nextTokenBuffer = cleanAttributes(getTagType(m_nextToken), nextTokenBuffer);
default:
break;
}
outputString.append(nextTokenBuffer);
m_nextTokenType = nextTokenType(m_dataBuffer, m_pos, m_nextToken, m_nextTokenType);
}
if (outputString.count("<p>"_L1) == 1 && outputString.count("</p>"_L1) == 1 && outputString.startsWith("<p>"_L1) && outputString.endsWith("</p>"_L1)) {
outputString.remove("<p>"_L1);
outputString.remove("</p>"_L1);
}
return outputString;
}
QString
TextHandler::handleRecieveRichText(Qt::TextFormat inputFormat, const NeoChatRoom *room, const Quotient::RoomEvent *event, bool stripNewlines, bool isEdited)
{
m_pos = 0;
m_dataBuffer = m_data;
// Strip mx-reply if present.
m_dataBuffer.remove(TextRegex::removeRichReply);
// For plain text, convert links, escape html and convert line brakes.
if (inputFormat == Qt::PlainText) {
m_dataBuffer = escapeHtml(m_dataBuffer);
m_dataBuffer.replace(u'\n', u"<br>"_s);
}
// Linkify any plain text urls
m_dataBuffer = linkifyUrls(m_dataBuffer);
// Apply user style
m_dataBuffer.replace(TextRegex::userPill, uR"(<b>\1</b>)"_s);
// Make all media URLs resolvable.
if (room && event) {
QRegularExpressionMatchIterator i = TextRegex::mxcImage.globalMatch(m_dataBuffer);
while (i.hasNext()) {
const QRegularExpressionMatch match = i.next();
const QUrl mediaUrl = room->makeMediaUrl(event->id(), QUrl(u"mxc://"_s + match.captured(2) + u'/' + match.captured(3)));
QStringList extraAttributes = match.captured(4).split(QChar::Space);
const bool isEmoticon = match.captured(1).contains(u"data-mx-emoticon"_s);
// If the image does not have an explicit width, but has a vertical-align it's most likely an emoticon.
// We must do some pre-processing for it to show up nicely in and around text.
if (isEmoticon) {
// Align it properly
extraAttributes.append(u"style=\"%1\""_s.arg(customEmojiStyle));
}
m_dataBuffer.replace(match.captured(0),
u"<img "_s + match.captured(1) + u"src=\""_s + mediaUrl.toString() + u'"' + extraAttributes.join(QChar::Space) + u'>');
}
}
// Strip any disallowed tags/attributes.
QString outputString;
m_nextTokenType = nextTokenType(m_dataBuffer, m_pos, m_nextToken, m_nextTokenType);
while (m_pos < m_dataBuffer.length()) {
next();
QString nextTokenBuffer = m_nextToken;
if (m_nextTokenType == Type::Text || m_nextTokenType == Type::TextCode) {
nextTokenBuffer = escapeHtml(nextTokenBuffer);
} else if (m_nextTokenType == Type::Tag) {
if (!isAllowedTag(getTagType(m_nextToken))) {
nextTokenBuffer = QString();
} else if ((getTagType(m_nextToken) == u"br"_s && stripNewlines)) {
nextTokenBuffer = u' ';
}
nextTokenBuffer = cleanAttributes(getTagType(m_nextToken), nextTokenBuffer);
}
outputString.append(nextTokenBuffer);
m_nextTokenType = nextTokenType(m_dataBuffer, m_pos, m_nextToken, m_nextTokenType);
}
if (isEdited) {
if (outputString.endsWith(u"</p>"_s)) {
outputString.insert(outputString.length() - 4, editString());
} else if (outputString.endsWith(u"</pre>"_s) || outputString.endsWith(u"</blockquote>"_s) || outputString.endsWith(u"</table>"_s)
|| outputString.endsWith(u"</ol>"_s) || outputString.endsWith(u"</ul>"_s)) {
outputString.append(u"<p>%1</p>"_s.arg(editString()));
} else {
outputString.append(editString());
}
}
/**
* Replace <del> with <s>
* Note: <s> is still not a valid tag for the message from the server. We
* convert as that is what is needed for Qt::RichText.
*/
outputString.replace(TextRegex::strikethrough, u"<s>\\1</s>"_s);
if (outputString.count("<p>"_L1) == 1 && outputString.count("</p>"_L1) == 1 && outputString.startsWith("<p>"_L1) && outputString.endsWith("</p>"_L1)) {
outputString.remove("<p>"_L1);
outputString.remove("</p>"_L1);
}
return outputString;
}
QString TextHandler::handleRecievePlainText(Qt::TextFormat inputFormat, const bool &stripNewlines)
{
m_pos = 0;
m_dataBuffer = m_data;
// Strip mx-reply if present.
m_dataBuffer.remove(TextRegex::removeRichReply);
// Escaping then unescaping allows < and > to be maintained in a plain text string
// otherwise markdownToHTML will strip what it thinks is a bad html tag entirely.
if (inputFormat == Qt::PlainText) {
m_dataBuffer = escapeHtml(m_dataBuffer);
}
/**
* This seems counterproductive but by converting any markup which could
* arrive (e.g. in a caption body) it can then be stripped by the same code.
*/
m_dataBuffer = markdownToHTML(m_dataBuffer);
// This is how \n is converted and for plain text we need it to just be <br>
// to prevent extra newlines being inserted.
m_dataBuffer.replace(u"<br />\n"_s, u"<br>"_s);
if (stripNewlines) {
m_dataBuffer.replace(u"<br>\n"_s, u" "_s);
m_dataBuffer.replace(u"<br>"_s, u" "_s);
m_dataBuffer.replace(u"<br />\n"_s, u" "_s);
m_dataBuffer.replace(u"<br />"_s, u" "_s);
m_dataBuffer.replace(u'\n', u" "_s);
m_dataBuffer.replace(u'\u2028', u" "_s);
}
// Strip all tags/attributes except code blocks which will be escaped.
QString outputString;
m_nextTokenType = nextTokenType(m_dataBuffer, m_pos, m_nextToken, m_nextTokenType);
while (m_pos < m_dataBuffer.length()) {
next();
QString nextTokenBuffer = m_nextToken;
if (m_nextTokenType == Type::TextCode) {
nextTokenBuffer = unescapeHtml(nextTokenBuffer);
} else if (m_nextTokenType == Type::Tag) {
if (getTagType(m_nextToken) == u"br"_s && !stripNewlines) {
nextTokenBuffer = u'\n';
} else {
nextTokenBuffer = QString();
}
}
outputString.append(nextTokenBuffer);
m_nextTokenType = nextTokenType(m_dataBuffer, m_pos, m_nextToken, m_nextTokenType);
}
// Escaping then unescaping allows < and > to be maintained in a plain text string
// otherwise markdownToHTML will strip what it thinks is a bad html tag entirely.
outputString = unescapeHtml(outputString);
outputString = outputString.trimmed();
return outputString;
}
void TextHandler::next()
{
QString searchStr;
if (m_nextTokenType == Type::Tag) {
searchStr = u'>';
} else if (m_nextTokenType == Type::TextCode) {
// Anything between code tags is assumed to be plain text
searchStr = u"</code>"_s;
} else {
searchStr = u'<';
}
int tokenEnd = m_dataBuffer.indexOf(searchStr, m_pos + 1);
if (tokenEnd == -1) {
tokenEnd = m_dataBuffer.length();
}
m_nextToken = m_dataBuffer.mid(m_pos, tokenEnd - m_pos + (m_nextTokenType == Type::Tag ? 1 : 0));
m_pos = tokenEnd + (m_nextTokenType == Type::Tag ? 1 : 0);
}
TextHandler::Type TextHandler::nextTokenType(const QString &string, int currentPos, const QString &currentToken, Type currentTokenType) const
{
if (currentPos >= string.length()) {
// This is to stop the function accessing an index outside the length of
// string during the final loop.
return Type::End;
} else if (currentTokenType == Type::Tag && getTagType(currentToken) == u"code"_s && !isCloseTag(currentToken)
&& string.indexOf(u"</code>"_s, currentPos) != currentPos) {
return Type::TextCode;
} else if (string[currentPos] == u'<' && string[currentPos + 1] != u' ') {
return Type::Tag;
} else {
return Type::Text;
}
}
int TextHandler::nextBlockPos(const QString &string)
{
if (string.isEmpty()) {
return -1;
}
const auto nextTokenType = this->nextTokenType(string, 0, {}, Text);
// If there is no tag at the start we need to handle potentially having some
// text with no <p> tag.
if (nextTokenType == Text) {
int pos = 0;
while (pos < string.size()) {
pos = string.indexOf(u'<', pos);
if (pos == -1) {
pos = string.size();
} else {
const auto tagType = getTagType(string.mid(pos, string.indexOf(u'>', pos) - pos));
if (blockTags.contains(tagType)) {
return pos;
}
}
pos++;
}
return string.size();
}
int tagEndPos = string.indexOf(u'>');
QString tag = string.first(tagEndPos + 1);
QString tagType = getTagType(tag);
// If the start tag is not a block tag there can be only 1 block.
if (!blockTags.contains(tagType)) {
return string.size();
}
const auto closeTag = u"</%1>"_s.arg(tagType);
int closeTagPos = string.indexOf(closeTag);
// If the close tag can't be found assume malformed html and process as single block.
if (closeTagPos == -1) {
return string.size();
}
return closeTagPos + closeTag.size();
}
MessageComponent TextHandler::nextBlock(const QString &string,
int nextBlockPos,
Qt::TextFormat inputFormat,
const NeoChatRoom *room,
const Quotient::RoomEvent *event,
bool isEdited)
{
if (string.isEmpty()) {
return {};
}
int tagEndPos = string.indexOf(u'>');
QString tag = string.first(tagEndPos + 1);
QString tagType = getTagType(tag);
const auto messageComponentType = MessageComponentType::typeForTag(tagType);
QVariantMap attributes;
if (messageComponentType == MessageComponentType::Code) {
attributes = getAttributes(u"code"_s, string.mid(tagEndPos + 1, string.indexOf(u'>', tagEndPos + 1) - tagEndPos));
}
auto content = stripBlockTags(string.first(nextBlockPos), tagType);
setData(content);
switch (messageComponentType) {
case MessageComponentType::Code:
content = unescapeHtml(content);
break;
default:
content = handleRecieveRichText(inputFormat, room, event, false, isEdited);
}
return MessageComponent{messageComponentType, content, attributes};
}
QString TextHandler::stripBlockTags(QString string, const QString &tagType) const
{
if (blockTags.contains(tagType) && tagType != u"ol"_s && tagType != u"ul"_s && tagType != u"table"_s && string.startsWith(u"<%1"_s.arg(tagType))) {
string.remove(0, string.indexOf(u'>') + 1).remove(string.indexOf(u"</%1>"_s.arg(tagType)), string.size());
}
if (string.startsWith(u"\n"_s)) {
string.remove(0, 1);
}
if (string.endsWith(u"\n"_s)) {
string.remove(string.size() - 1, string.size());
}
if (tagType == u"pre"_s) {
if (string.startsWith(u"<code"_s)) {
string.remove(0, string.indexOf(u'>') + 1);
string.remove(string.indexOf(u"</code>"_s), string.size());
}
if (string.endsWith(u"\n"_s)) {
string.remove(string.size() - 1, string.size());
}
}
if (tagType == u"blockquote"_s) {
if (string.startsWith(u"<p>"_s)) {
string.remove(0, string.indexOf(u'>') + 1);
string.remove(string.indexOf(u"</p>"_s), string.size());
}
// This is not a normal quotation mark but U+201C
if (!string.startsWith(u'')) {
string.prepend(u'');
}
// This is U+201D
if (!string.endsWith(u'')) {
string.append(u'');
}
}
return string;
}
QString TextHandler::getTagType(const QString &tagToken) const
{
if (tagToken.isEmpty() || tagToken.length() < 2) {
return QString();
}
const int tagTypeStart = tagToken[1] == u'/' ? 2 : 1;
const int tagTypeEnd = tagToken.indexOf(TextRegex::endTagType, tagTypeStart);
return tagToken.mid(tagTypeStart, tagTypeEnd - tagTypeStart);
}
bool TextHandler::isCloseTag(const QString &tagToken) const
{
if (tagToken.isEmpty()) {
return false;
}
return tagToken[1] == u'/';
}
QString TextHandler::getAttributeType(const QString &string)
{
if (!string.contains(u'=')) {
return string;
}
const int equalsPos = string.indexOf(u'=');
return string.left(equalsPos);
}
QString TextHandler::getAttributeData(const QString &string, bool stripQuotes)
{
if (!string.contains(u'=')) {
return QString();
}
const int equalsPos = string.indexOf(u'=');
auto data = string.right(string.length() - equalsPos - 1);
if (stripQuotes) {
data = TextRegex::attributeData.match(data).captured(1);
}
return data;
}
bool TextHandler::isAllowedTag(const QString &type)
{
return allowedTags.contains(type);
}
bool TextHandler::isAllowedAttribute(const QString &tag, const QString &attribute)
{
return allowedAttributes[tag].contains(attribute);
}
bool TextHandler::isAllowedLink(const QString &link, bool isImg)
{
const QUrl linkUrl = QUrl(link);
if (isImg) {
return !linkUrl.isRelative() && linkUrl.scheme() == u"mxc"_s;
} else {
return !linkUrl.isRelative() && allowedLinkSchemes.contains(linkUrl.scheme());
}
}
QString TextHandler::cleanAttributes(const QString &tag, const QString &tagString)
{
int nextAttributeIndex = tagString.indexOf(u' ', 1);
if (nextAttributeIndex != -1) {
QString outputString = tagString.left(nextAttributeIndex);
QString nextAttribute;
int nextSpaceIndex;
nextAttributeIndex += 1;
while (nextAttributeIndex < tagString.length()) {
nextSpaceIndex = tagString.indexOf(TextRegex::endAttributeType, nextAttributeIndex);
if (nextSpaceIndex == -1) {
nextSpaceIndex = tagString.length();
}
nextAttribute = tagString.mid(nextAttributeIndex, nextSpaceIndex - nextAttributeIndex);
if (isAllowedAttribute(tag, getAttributeType(nextAttribute))) {
QString style;
if (tag == u"img"_s && getAttributeType(nextAttribute) == u"src"_s) {
QString attributeData = TextRegex::attributeData.match(getAttributeData(nextAttribute)).captured(1);
if (isAllowedLink(attributeData, true)) {
outputString.append(u' ' + nextAttribute);
}
} else if (tag == u'a' && getAttributeType(nextAttribute) == u"href"_s) {
QString attributeData = TextRegex::attributeData.match(getAttributeData(nextAttribute)).captured(1);
if (isAllowedLink(attributeData)) {
outputString.append(u' ' + nextAttribute);
}
} else if (tag == u"code"_s && getAttributeType(nextAttribute) == u"class"_s) {
if (getAttributeData(nextAttribute).remove(u'"').startsWith(u"language-"_s)) {
outputString.append(u' ' + nextAttribute);
}
} else if (tag == u"img"_s && getAttributeType(nextAttribute) == u"style"_s) {
const QString attributeData = TextRegex::attributeData.match(getAttributeData(nextAttribute)).captured(1);
// Ignore every other style attribute except for our own, which we use to align custom emoticons
if (attributeData == customEmojiStyle) {
outputString.append(u' ' + nextAttribute);
}
} else if (getAttributeType(nextAttribute) == u"data-mx-color"_s) {
const QString attributeData = TextRegex::attributeData.match(getAttributeData(nextAttribute)).captured(1);
style.append(u"color: "_s + attributeData + u';');
} else if (getAttributeType(nextAttribute) == u"data-mx-bg-color"_s) {
const QString attributeData = TextRegex::attributeData.match(getAttributeData(nextAttribute)).captured(1);
style.append(u"background-color: "_s + attributeData + u';');
} else {
outputString.append(u' ' + nextAttribute);
}
if (!style.isEmpty()) {
outputString.append(u" style=\""_s + style + u'"');
}
}
nextAttributeIndex = nextSpaceIndex + 1;
}
outputString += u'>';
return outputString;
}
return tagString;
}
QVariantMap TextHandler::getAttributes(const QString &tag, const QString &tagString)
{
QVariantMap attributes;
int nextAttributeIndex = tagString.indexOf(u' ', 1);
if (nextAttributeIndex != -1) {
QString nextAttribute;
int nextSpaceIndex;
nextAttributeIndex += 1;
while (nextAttributeIndex < tagString.length()) {
nextSpaceIndex = tagString.indexOf(TextRegex::endAttributeType, nextAttributeIndex);
if (nextSpaceIndex == -1) {
nextSpaceIndex = tagString.length();
}
nextAttribute = tagString.mid(nextAttributeIndex, nextSpaceIndex - nextAttributeIndex);
if (isAllowedAttribute(tag, getAttributeType(nextAttribute))) {
if (tag == u"img"_s && getAttributeType(nextAttribute) == u"src"_s) {
QString attributeData = TextRegex::attributeData.match(getAttributeData(nextAttribute)).captured(1);
if (isAllowedLink(attributeData, true)) {
attributes[getAttributeType(nextAttribute)] = getAttributeData(nextAttribute, true);
}
} else if (tag == u'a' && getAttributeType(nextAttribute) == u"href"_s) {
QString attributeData = TextRegex::attributeData.match(getAttributeData(nextAttribute)).captured(1);
if (isAllowedLink(attributeData)) {
attributes[getAttributeType(nextAttribute)] = getAttributeData(nextAttribute, true);
}
} else if (tag == u"code"_s && getAttributeType(nextAttribute) == u"class"_s) {
if (getAttributeData(nextAttribute).remove(u'"').startsWith(u"language-"_s)) {
attributes[getAttributeType(nextAttribute)] = convertCodeLanguageString(getAttributeData(nextAttribute, true));
}
} else {
attributes[getAttributeType(nextAttribute)] = getAttributeData(nextAttribute, true);
}
}
nextAttributeIndex = nextSpaceIndex + 1;
}
}
return attributes;
}
QList<MessageComponent>
TextHandler::textComponents(QString string, Qt::TextFormat inputFormat, const NeoChatRoom *room, const Quotient::RoomEvent *event, bool isEdited)
{
if (string.isEmpty()) {
return {};
}
// Strip mx-reply if present.
string.remove(TextRegex::removeRichReply);
QList<MessageComponent> components;
while (!string.isEmpty()) {
const auto nextBlockPos = this->nextBlockPos(string);
const auto nextBlock = this->nextBlock(string, nextBlockPos, inputFormat, room, event, nextBlockPos == string.size() ? isEdited : false);
components += nextBlock;
string.remove(0, nextBlockPos);
if (string.startsWith(u"\n"_s)) {
string.remove(0, 1);
}
string = string.trimmed();
if (event != nullptr && room != nullptr) {
if (auto e = eventCast<const Quotient::RoomMessageEvent>(event); e->msgtype() == Quotient::MessageEventType::Emote && components.size() == 1) {
if (components[0].type == MessageComponentType::Text) {
components[0].content = emoteString(room, event) + components[0].content;
} else {
components.prepend(MessageComponent{MessageComponentType::Text, emoteString(room, event), {}});
}
}
}
}
if (isEdited && components.last().type != MessageComponentType::Text) {
components += MessageComponent{MessageComponentType::Text, editString(), {}};
}
return components;
}
QString TextHandler::markdownToHTML(const QString &markdown)
{
const auto str = markdown.toUtf8();
char *tmp_buf = cmark_markdown_to_html(str.constData(), str.size(), CMARK_OPT_HARDBREAKS | CMARK_OPT_UNSAFE);
const std::string html(tmp_buf);
free(tmp_buf);
auto result = QString::fromStdString(html).trimmed();
result.replace(u"<!-- raw HTML omitted -->"_s, QString());
return result;
}
/**
* TODO: make this more intelligent currently other characters are not escaped
* especially & as this can conflict with the cmark markdown to html conversion
* which already escapes characters in code blocks. The < > still need to be handled
* when the user manually types in the html.
*/
QString TextHandler::escapeHtml(QString stringIn)
{
stringIn.replace(u'<', u"&lt;"_s);
stringIn.replace(u'>', u"&gt;"_s);
return stringIn;
}
QString TextHandler::unescapeHtml(QString stringIn)
{
// For those situations where brackets in code block get double escaped
stringIn.replace(u"&amp;lt;"_s, u"<"_s);
stringIn.replace(u"&amp;gt;"_s, u">"_s);
stringIn.replace(u"&lt;"_s, u"<"_s);
stringIn.replace(u"&gt;"_s, u">"_s);
stringIn.replace(u"&amp;"_s, u"&"_s);
stringIn.replace(u"&quot;"_s, u"\""_s);
stringIn.replace(u"&#x27;"_s, u"'"_s);
return stringIn;
}
QString TextHandler::linkifyUrls(QString stringIn)
{
QRegularExpressionMatch match;
int start = 0;
for (int index = 0; index != -1; index = stringIn.indexOf(TextRegex::mxId, start, &match)) {
int skip = 0;
if (match.captured(0).size() > 0) {
if (stringIn.left(index).count(u"<code>"_s) == stringIn.left(index).count(u"</code>"_s)) {
auto replacement = u"<a href=\"https://matrix.to/#/%1\">%1</a>"_s.arg(match.captured(1));
stringIn = stringIn.replace(index, match.captured(0).size(), replacement);
} else {
skip = match.captured().length();
}
}
start = index + skip;
match = {};
}
start = 0;
match = {};
for (int index = 0; index != -1; index = stringIn.indexOf(TextRegex::plainUrl, start, &match)) {
int skip = 0;
if (match.captured(0).size() > 0) {
if (stringIn.left(index).count(u"<code>"_s) == stringIn.left(index).count(u"</code>"_s)) {
auto replacement = u"<a href=\"%1\">%1</a>"_s.arg(match.captured(1));
stringIn = stringIn.replace(index, match.captured(0).size(), replacement);
skip = replacement.length();
} else {
skip = match.captured().length();
}
}
start = index + skip;
match = {};
}
start = 0;
match = {};
for (int index = 0; index != -1; index = stringIn.indexOf(TextRegex::emailAddress, start, &match)) {
int skip = 0;
if (match.captured(0).size() > 0) {
if (stringIn.left(index).count(u"<code>"_s) == stringIn.left(index).count(u"</code>"_s)) {
auto replacement = u"<a href=\"mailto:%1\">%1</a>"_s.arg(match.captured(2));
stringIn = stringIn.replace(index, match.captured(0).size(), replacement);
skip = replacement.length();
} else {
skip = match.captured().length();
}
}
start = index + skip;
match = {};
}
return stringIn;
}
QString TextHandler::editString() const
{
Kirigami::Platform::PlatformTheme *theme =
static_cast<Kirigami::Platform::PlatformTheme *>(qmlAttachedPropertiesObject<Kirigami::Platform::PlatformTheme>(this, true));
QString editTextColor;
if (theme != nullptr) {
editTextColor = theme->disabledTextColor().name();
} else {
editTextColor = u"#000000"_s;
}
return u" <span style=\"color:"_s + editTextColor + u"\">(edited)</span>"_s;
}
QString TextHandler::emoteString(const NeoChatRoom *room, const Quotient::RoomEvent *event) const
{
if (room == nullptr || event == nullptr) {
return {};
}
auto e = eventCast<const Quotient::RoomMessageEvent>(event);
auto author = room->member(e->senderId());
return u"* <a href=\"https://matrix.to/#/"_s + e->senderId() + u"\" style=\"color:"_s + author.color().name() + u"\">"_s + author.htmlSafeDisplayName()
+ u"</a> "_s;
}
QString TextHandler::convertCodeLanguageString(const QString &languageString)
{
const int equalsPos = languageString.indexOf(u'-');
return languageString.right(languageString.length() - equalsPos - 1);
}
#include "moc_texthandler.cpp"

View File

@@ -0,0 +1,147 @@
// SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#pragma once
#include <QObject>
#include <QString>
#include "messagecomponent.h"
#include "neochatroom.h"
namespace Quotient
{
class RoomMessageEvent;
}
/**
* @class TextHandler
*
* This class is designed to handle the text of both incoming and outgoing messages.
*
* This includes converting markdown to html and removing any html tags that shouldn't
* be present as per the matrix spec
* (https://spec.matrix.org/v1.5/client-server-api/#mroommessage-msgtypes).
*/
class TextHandler : public QObject
{
Q_OBJECT
public:
/**
* @brief List of token types
*/
enum Type {
Text, /*!< Anything not a tag that doesn't have special handling */
Tag, /*!< For any generic tag that doesn't have special handling */
TextCode, /*!< Text between code tags */
End, /*!< End of the input string */
};
/**
* @brief Get the string being handled.
*
* Setting new data resets the TextHandler.
*/
QString data() const;
/**
* @brief Set the string being handled.
*
* @note The TextHandler doesn't modify the input data variable so the unhandled
* text can always be retrieved.
*/
void setData(const QString &string);
/**
* @brief Handle the text for a message that is being sent.
*/
QString handleSendText();
/**
* @brief Handle the text as a rich output for a message being received.
*
* The function does the following:
* - Removes invalid html tags and attributes
* - Strips any reply from the message
* - Formats user mentions
*
* @note In this case the rich text refers to the output format. The input
* can be in either and the parameter inputFormat just needs to be set
* appropriately.
*/
QString handleRecieveRichText(Qt::TextFormat inputFormat = Qt::RichText,
const NeoChatRoom *room = nullptr,
const Quotient::RoomEvent *event = nullptr,
bool stripNewlines = false,
bool isEdited = false);
/**
* @brief Handle the text as a plain output for a message being received.
*
* The function does the following:
* - Removes all html tags and attributes (except inside of code tags)
* - Strips any reply from the message
*
* @note In this case the plain text refers to the output format. The input
* can be in either and the parameter inputFormat just needs to be set
* appropriately.
*
* @warning The output of this function should NEVER be input into a rich text
* control. It will try to preserve < and > in the plain string which
* could be malicious tags if the control uses rich text format.
*/
QString handleRecievePlainText(Qt::TextFormat inputFormat = Qt::PlainText, const bool &stripNewlines = false);
/**
* @brief Split the given string into MessageComponent blocks.
*
* Separate blocks are used for thing like paragraphs, codeblocks and quotes.
* Each block will have handleRecieveRichText() called on it.
*/
QList<MessageComponent> textComponents(QString string,
Qt::TextFormat inputFormat = Qt::RichText,
const NeoChatRoom *room = nullptr,
const Quotient::RoomEvent *event = nullptr,
bool isEdited = false);
private:
QString m_data;
QString m_dataBuffer;
int m_pos;
Type m_nextTokenType = Text;
QString m_nextToken;
void next();
Type nextTokenType(const QString &string, int currentPos, const QString &currentToken, Type currentTokenType) const;
int nextBlockPos(const QString &string);
MessageComponent nextBlock(const QString &string,
int nextBlockPos,
Qt::TextFormat inputFormat = Qt::RichText,
const NeoChatRoom *room = nullptr,
const Quotient::RoomEvent *event = nullptr,
bool isEdited = false);
QString stripBlockTags(QString string, const QString &tagType) const;
QString getTagType(const QString &tagToken) const;
bool isCloseTag(const QString &tagToken) const;
QString getAttributeType(const QString &string);
QString getAttributeData(const QString &string, bool stripQuotes = false);
bool isAllowedTag(const QString &type);
bool isAllowedAttribute(const QString &tag, const QString &attribute);
bool isAllowedLink(const QString &link, bool isImg = false);
QString cleanAttributes(const QString &tag, const QString &tagString);
QVariantMap getAttributes(const QString &tag, const QString &tagString);
QString markdownToHTML(const QString &markdown);
QString escapeHtml(QString stringIn);
QString unescapeHtml(QString stringIn);
QString linkifyUrls(QString stringIn);
QString editString() const;
QString emoteString(const NeoChatRoom *room = nullptr, const Quotient::RoomEvent *event = nullptr) const;
static QString convertCodeLanguageString(const QString &languageString);
};

View File

@@ -0,0 +1,33 @@
// SPDX-FileCopyrightText: 2022 Nicolas Fella <nicolas.fella@gmx.de>
// SPDX-License-Identifier: GPL-2.0-or-later
#include "urlhelper.h"
#include <QFile>
#include <QtGlobal>
#ifdef Q_OS_ANDROID
#include <QDesktopServices>
#else
#include <KIO/OpenUrlJob>
#endif
// QDesktopServices::openUrl doesn't support XDG activation yet, OpenUrlJob does
// On Android XDG activation is not relevant, so use QDesktopServices::openUrl to avoid the heavy KIO dependency
void UrlHelper::openUrl(const QUrl &url)
{
#ifdef Q_OS_ANDROID
QDesktopServices::openUrl(url);
#else
auto *job = new KIO::OpenUrlJob(url);
job->start();
#endif
}
void UrlHelper::copyTo(const QUrl &origin, const QUrl &destination)
{
QFile originFile(origin.toLocalFile());
originFile.copy(destination.toLocalFile());
}
#include "moc_urlhelper.cpp"

View File

@@ -0,0 +1,31 @@
// SPDX-FileCopyrightText: 2022 Nicolas Fella <nicolas.fella@gmx.de>
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <QObject>
#include <QQmlEngine>
#include <QUrl>
/**
* @class UrlHelper
*
* A class to help manage URLs.
*/
class UrlHelper : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
public:
/**
* @brief Open the given URL in an appropriate app.
*/
Q_INVOKABLE void openUrl(const QUrl &url);
/**
* @brief Copy the given URL to the given location.
*/
Q_INVOKABLE void copyTo(const QUrl &origin, const QUrl &destination);
};

80
src/libneochat/utils.cpp Normal file
View File

@@ -0,0 +1,80 @@
// SPDX-FileCopyrightText: 2024 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: LGPL-2.0-or-later
#include "utils.h"
#ifdef HAVE_ICU
#include <QTextBoundaryFinder>
#include <QTextCharFormat>
#include <unicode/uchar.h>
#include <unicode/urename.h>
#endif
#include <Quotient/connection.h>
#include <QJsonDocument>
#include <QQuickWindow>
using namespace Quotient;
bool QmlUtils::isEmoji(const QString &text)
{
return Utils::isEmoji(text);
}
bool QmlUtils::isValidJson(const QByteArray &json)
{
return !QJsonDocument::fromJson(json).isNull();
}
QString QmlUtils::escapeString(const QString &string)
{
return string.toHtmlEscaped();
}
QColor QmlUtils::getUserColor(qreal hueF)
{
const auto lightness = static_cast<QGuiApplication *>(QGuiApplication::instance())->palette().color(QPalette::Active, QPalette::Window).lightnessF();
// https://github.com/quotient-im/libQuotient/wiki/User-color-coding-standard-draft-proposal
return QColor::fromHslF(hueF, 1, -0.7 * lightness + 0.9, 1);
}
QQuickItem *QmlUtils::focusedWindowItem()
{
const auto window = qobject_cast<QQuickWindow *>(QGuiApplication::focusWindow());
if (window) {
return window->contentItem();
} else {
return nullptr;
}
}
QString QmlUtils::nameForPowerLevelValue(const int value)
{
return PowerLevel::nameForLevel(PowerLevel::levelForValue(value));
}
bool Utils::isEmoji(const QString &text)
{
#ifdef HAVE_ICU
QTextBoundaryFinder finder(QTextBoundaryFinder::Grapheme, text);
int from = 0;
while (finder.toNextBoundary() != -1) {
auto to = finder.position();
if (text[from].isSpace()) {
from = to;
continue;
}
auto first = text.mid(from, to - from).toUcs4()[0];
if (!u_hasBinaryProperty(first, UCHAR_EMOJI_PRESENTATION)) {
return false;
}
from = to;
}
return true;
#else
return false;
#endif
}
#include "moc_utils.cpp"

92
src/libneochat/utils.h Normal file
View File

@@ -0,0 +1,92 @@
// SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#pragma once
#include <QColor>
#include <QGuiApplication>
#include <QPalette>
#include <QQmlEngine>
#include <QQuickItem>
#include <QRegularExpression>
#include <Quotient/user.h>
#include "enums/powerlevel.h"
using namespace Qt::StringLiterals;
class QmlUtils : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
public:
static QmlUtils *create(QQmlEngine *, QJSEngine *)
{
QQmlEngine::setObjectOwnership(&instance(), QQmlEngine::CppOwnership);
return &instance();
}
static QmlUtils &instance()
{
static QmlUtils _instance;
return _instance;
}
Q_INVOKABLE bool isEmoji(const QString &text);
Q_INVOKABLE bool isValidJson(const QByteArray &json);
Q_INVOKABLE QString escapeString(const QString &string);
Q_INVOKABLE QColor getUserColor(qreal hueF);
Q_INVOKABLE QQuickItem *focusedWindowItem();
/**
* @brief Invokable version of PowerLevel::nameForLevel which also calls PowerLevel::levelForValue.
*/
Q_INVOKABLE QString nameForPowerLevelValue(int value);
private:
QmlUtils() = default;
};
namespace Utils
{
/**
* @brief Get a color for a user from a hueF value.
*
* The lightness of the color will be modified depending on the current palette in
* order to maintain contrast.
*/
inline QColor getUserColor(qreal hueF)
{
const auto lightness = static_cast<QGuiApplication *>(QGuiApplication::instance())->palette().color(QPalette::Active, QPalette::Window).lightnessF();
// https://github.com/quotient-im/libQuotient/wiki/User-color-coding-standard-draft-proposal
return QColor::fromHslF(hueF, 1, -0.7 * lightness + 0.9, 1);
}
bool isEmoji(const QString &text);
}
namespace TextRegex
{
static const QRegularExpression endTagType{u"[> /]"_s};
static const QRegularExpression endAttributeType{u"[> ]"_s};
static const QRegularExpression attributeData{u"['\"](.*?)['\"]"_s};
static const QRegularExpression removeReply{u"> <.*?>.*?\\n\\n"_s, QRegularExpression::DotMatchesEverythingOption};
static const QRegularExpression removeRichReply{u"<mx-reply>.*?</mx-reply>"_s, QRegularExpression::DotMatchesEverythingOption};
static const QRegularExpression codePill{u"<pre><code[^>]*>(.*?)</code></pre>"_s, QRegularExpression::DotMatchesEverythingOption};
static const QRegularExpression userPill{u"(<a href=\"https://matrix.to/#/@.*?:.*?\">.*?</a>)"_s, QRegularExpression::DotMatchesEverythingOption};
static const QRegularExpression blockQuote{u"<blockquote>\n?(?:<p>)?(.*?)(?:</p>)?\n?</blockquote>"_s, QRegularExpression::DotMatchesEverythingOption};
static const QRegularExpression strikethrough{u"<del>(.*?)</del>"_s, QRegularExpression::DotMatchesEverythingOption};
static const QRegularExpression mxcImage{uR"AAA(<img(.*?)src="mxc:\/\/(.*?)\/(.*?)"(.*?)>)AAA"_s};
static const QRegularExpression plainUrl(
uR"(<a.*?<\/a>(*SKIP)(*F)|\b((www\.(?!\.)(?!(\w|\.|-)+@)|(https?|ftp):(//)?\w|(magnet|matrix):)(&(?![lg]t;)|[^&\s<>'"])+(&(?![lg]t;)|[^?&!,.\s<>'"\]):])))"_s,
QRegularExpression::CaseInsensitiveOption | QRegularExpression::UseUnicodePropertiesOption);
static const QRegularExpression url(uR"(\b((www\.(?!\.)(?!(\w|\.|-)+@)|https?:(//)?\w)(&(?![lg]t;)|[^&\s<>'"])+(&(?![lg]t;)|[^&!,.\s<>'"\]):])))"_s,
QRegularExpression::CaseInsensitiveOption | QRegularExpression::UseUnicodePropertiesOption);
static const QRegularExpression emailAddress(uR"(<a.*?<\/a>(*SKIP)(*F)|\b(mailto:)?((\w|\.|-)+@(\w|\.|-)+\.\w+\b))"_s,
QRegularExpression::CaseInsensitiveOption | QRegularExpression::UseUnicodePropertiesOption);
static const QRegularExpression mxId(uR"((?<=^|[][[:space:](){}`'";])([!#@][-a-z0-9_=#/.]{1,252}:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?))"_s,
QRegularExpression::CaseInsensitiveOption | QRegularExpression::UseUnicodePropertiesOption);
}