Move NeoChatConnection and NeoChatRoom to LibNeoChat
Move `NeoChatConnection` and `NeoChatRoom` to `LibNeoChat` along with any required dependencies.
This commit is contained in:
@@ -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()
|
||||
|
||||
282
src/libneochat/chatbarcache.cpp
Normal file
282
src/libneochat/chatbarcache.cpp
Normal 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"
|
||||
211
src/libneochat/chatbarcache.h
Normal file
211
src/libneochat/chatbarcache.h
Normal 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();
|
||||
};
|
||||
85
src/libneochat/clipboard.cpp
Normal file
85
src/libneochat/clipboard.cpp
Normal 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"
|
||||
76
src/libneochat/clipboard.h
Normal file
76
src/libneochat/clipboard.h
Normal 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
1857
src/libneochat/emojis.h
Normal file
File diff suppressed because it is too large
Load Diff
11
src/libneochat/emojitones.cpp
Normal file
11
src/libneochat/emojitones.cpp
Normal 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"
|
||||
};
|
||||
21
src/libneochat/emojitones.h
Normal file
21
src/libneochat/emojitones.h
Normal 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;
|
||||
};
|
||||
1784
src/libneochat/emojitones_data.h
Normal file
1784
src/libneochat/emojitones_data.h
Normal file
File diff suppressed because it is too large
Load Diff
139
src/libneochat/enums/messagecomponenttype.h
Normal file
139
src/libneochat/enums/messagecomponenttype.h
Normal 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;
|
||||
}
|
||||
};
|
||||
31
src/libneochat/enums/messagetype.h
Normal file
31
src/libneochat/enums/messagetype.h
Normal 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);
|
||||
};
|
||||
109
src/libneochat/enums/powerlevel.cpp
Normal file
109
src/libneochat/enums/powerlevel.cpp
Normal 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"
|
||||
110
src/libneochat/enums/powerlevel.h
Normal file
110
src/libneochat/enums/powerlevel.h
Normal 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;
|
||||
};
|
||||
188
src/libneochat/enums/pushrule.h
Normal file
188
src/libneochat/enums/pushrule.h
Normal 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 {};
|
||||
}
|
||||
};
|
||||
};
|
||||
883
src/libneochat/eventhandler.cpp
Normal file
883
src/libneochat/eventhandler.cpp
Normal 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"
|
||||
262
src/libneochat/eventhandler.h
Normal file
262
src/libneochat/eventhandler.h
Normal 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);
|
||||
};
|
||||
83
src/libneochat/events/imagepackevent.cpp
Normal file
83
src/libneochat/events/imagepackevent.cpp
Normal 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;
|
||||
}
|
||||
92
src/libneochat/events/imagepackevent.h
Normal file
92
src/libneochat/events/imagepackevent.h
Normal 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;
|
||||
};
|
||||
}
|
||||
14
src/libneochat/events/locationbeaconevent.h
Normal file
14
src/libneochat/events/locationbeaconevent.h
Normal 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
|
||||
72
src/libneochat/events/pollevent.cpp
Normal file
72
src/libneochat/events/pollevent.cpp
Normal 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);
|
||||
}
|
||||
235
src/libneochat/events/pollevent.h
Normal file
235
src/libneochat/events/pollevent.h
Normal 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;
|
||||
};
|
||||
}
|
||||
14
src/libneochat/events/widgetevent.h
Normal file
14
src/libneochat/events/widgetevent.h
Normal 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
|
||||
67
src/libneochat/filetransferpseudojob.cpp
Normal file
67
src/libneochat/filetransferpseudojob.cpp
Normal 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;
|
||||
}
|
||||
62
src/libneochat/filetransferpseudojob.h
Normal file
62
src/libneochat/filetransferpseudojob.h
Normal 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;
|
||||
};
|
||||
116
src/libneochat/linkpreviewer.cpp
Normal file
116
src/libneochat/linkpreviewer.cpp
Normal 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"
|
||||
100
src/libneochat/linkpreviewer.h
Normal file
100
src/libneochat/linkpreviewer.h
Normal 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 *)
|
||||
22
src/libneochat/messagecomponent.h
Normal file
22
src/libneochat/messagecomponent.h
Normal 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;
|
||||
}
|
||||
};
|
||||
625
src/libneochat/models/actionsmodel.cpp
Normal file
625
src/libneochat/models/actionsmodel.cpp
Normal 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;
|
||||
}
|
||||
120
src/libneochat/models/actionsmodel.h
Normal file
120
src/libneochat/models/actionsmodel.h
Normal 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;
|
||||
};
|
||||
212
src/libneochat/models/customemojimodel.cpp
Normal file
212
src/libneochat/models/customemojimodel.cpp
Normal 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"
|
||||
116
src/libneochat/models/customemojimodel.h
Normal file
116
src/libneochat/models/customemojimodel.h
Normal 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();
|
||||
};
|
||||
244
src/libneochat/models/emojimodel.cpp
Normal file
244
src/libneochat/models/emojimodel.cpp
Normal 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"
|
||||
184
src/libneochat/models/emojimodel.h
Normal file
184
src/libneochat/models/emojimodel.h
Normal 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);
|
||||
};
|
||||
562
src/libneochat/neochatconnection.cpp
Normal file
562
src/libneochat/neochatconnection.cpp
Normal 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 ¤tPassword, 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"
|
||||
239
src/libneochat/neochatconnection.h
Normal file
239
src/libneochat/neochatconnection.h
Normal 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 ¤tPassword, 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;
|
||||
};
|
||||
1699
src/libneochat/neochatroom.cpp
Normal file
1699
src/libneochat/neochatroom.cpp
Normal file
File diff suppressed because it is too large
Load Diff
684
src/libneochat/neochatroom.h
Normal file
684
src/libneochat/neochatroom.h
Normal 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);
|
||||
};
|
||||
165
src/libneochat/neochatroommember.cpp
Normal file
165
src/libneochat/neochatroommember.cpp
Normal 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"
|
||||
82
src/libneochat/neochatroommember.h
Normal file
82
src/libneochat/neochatroommember.h
Normal 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();
|
||||
};
|
||||
38
src/libneochat/roomlastmessageprovider.cpp
Normal file
38
src/libneochat/roomlastmessageprovider.cpp
Normal 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);
|
||||
}
|
||||
41
src/libneochat/roomlastmessageprovider.h
Normal file
41
src/libneochat/roomlastmessageprovider.h
Normal 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;
|
||||
};
|
||||
243
src/libneochat/spacehierarchycache.cpp
Normal file
243
src/libneochat/spacehierarchycache.cpp
Normal 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"
|
||||
119
src/libneochat/spacehierarchycache.h
Normal file
119
src/libneochat/spacehierarchycache.h
Normal 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;
|
||||
};
|
||||
734
src/libneochat/texthandler.cpp
Normal file
734
src/libneochat/texthandler.cpp
Normal 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 ¤tToken, 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"<"_s);
|
||||
stringIn.replace(u'>', u">"_s);
|
||||
return stringIn;
|
||||
}
|
||||
|
||||
QString TextHandler::unescapeHtml(QString stringIn)
|
||||
{
|
||||
// For those situations where brackets in code block get double escaped
|
||||
stringIn.replace(u"&lt;"_s, u"<"_s);
|
||||
stringIn.replace(u"&gt;"_s, u">"_s);
|
||||
stringIn.replace(u"<"_s, u"<"_s);
|
||||
stringIn.replace(u">"_s, u">"_s);
|
||||
stringIn.replace(u"&"_s, u"&"_s);
|
||||
stringIn.replace(u"""_s, u"\""_s);
|
||||
stringIn.replace(u"'"_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"
|
||||
147
src/libneochat/texthandler.h
Normal file
147
src/libneochat/texthandler.h
Normal 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 ¤tToken, 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);
|
||||
};
|
||||
33
src/libneochat/urlhelper.cpp
Normal file
33
src/libneochat/urlhelper.cpp
Normal 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"
|
||||
31
src/libneochat/urlhelper.h
Normal file
31
src/libneochat/urlhelper.h
Normal 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
80
src/libneochat/utils.cpp
Normal 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
92
src/libneochat/utils.h
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user