Compare commits

..

1 Commits

Author SHA1 Message Date
Carl Schwan
586054bc0b Use struct instead of class for forward definition of Quotient::FileInfo 2025-01-07 16:52:35 +01:00
123 changed files with 18738 additions and 23992 deletions

View File

@@ -2,7 +2,7 @@
"id": "org.kde.neochat",
"branch": "master",
"runtime": "org.kde.Platform",
"runtime-version": "6.8",
"runtime-version": "6.7",
"sdk": "org.kde.Sdk",
"command": "neochat",
"tags": [

View File

@@ -5,13 +5,10 @@ include:
- project: sysadmin/ci-utilities
file:
- /gitlab-templates/reuse-lint.yml
- /gitlab-templates/json-validation.yml
- /gitlab-templates/xml-lint.yml
- /gitlab-templates/yaml-lint.yml
- /gitlab-templates/android-qt6.yml
- /gitlab-templates/linux-qt6.yml
- /gitlab-templates/windows-qt6.yml
# - /gitlab-templates/freebsd-qt6.yml
# - /gitlab-templates/freebsd-qt6.yml
- /gitlab-templates/flatpak.yml
- /gitlab-templates/snap-snapcraft-lxd.yml
- /gitlab-templates/craft-android-qt6-apks.yml

View File

@@ -2,43 +2,42 @@
# SPDX-License-Identifier: BSD-2-Clause
Dependencies:
- 'on': ['Linux', 'Android', 'FreeBSD', 'Windows']
'require':
'frameworks/extra-cmake-modules': '@latest-kf6'
'frameworks/kcoreaddons': '@latest-kf6'
'frameworks/kirigami': '@latest-kf6'
'frameworks/ki18n': '@latest-kf6'
'frameworks/kconfig': '@latest-kf6'
'frameworks/syntax-highlighting': '@latest-kf6'
'frameworks/kitemmodels': '@latest-kf6'
'frameworks/kquickcharts': '@latest-kf6'
'frameworks/knotifications': '@latest-kf6'
'frameworks/kcolorscheme': '@latest-kf6'
'frameworks/kiconthemes': '@latest-kf6'
'libraries/kquickimageeditor': '@latest-kf6'
'frameworks/sonnet': '@latest-kf6'
'frameworks/prison': '@latest-kf6'
'libraries/kirigami-addons': '@latest-kf6'
'third-party/libquotient': '@latest'
'third-party/qtkeychain': '@latest'
'third-party/cmark': '@latest'
'third-party/qcoro': '@latest'
- 'on': ['Windows', 'Linux', 'FreeBSD']
'require':
'frameworks/qqc2-desktop-style': '@latest-kf6'
'frameworks/kio': '@latest-kf6'
'frameworks/kwindowsystem': '@latest-kf6'
'frameworks/kstatusnotifieritem': '@latest-kf6'
'frameworks/kcrash': '@latest-kf6'
- 'on': ['Linux', 'FreeBSD']
'require':
'frameworks/kdbusaddons': '@latest-kf6'
'frameworks/purpose': '@latest-kf6'
- 'on': ['Linux', 'Android', 'FreeBSD', 'Windows']
'require':
'frameworks/extra-cmake-modules': '@latest-kf6'
'frameworks/kcoreaddons': '@latest-kf6'
'frameworks/kirigami': '@latest-kf6'
'frameworks/ki18n': '@latest-kf6'
'frameworks/kconfig': '@latest-kf6'
'frameworks/syntax-highlighting': '@latest-kf6'
'frameworks/kitemmodels': '@latest-kf6'
'frameworks/kquickcharts': '@latest-kf6'
'frameworks/knotifications': '@latest-kf6'
'frameworks/kcolorscheme': '@latest-kf6'
'libraries/kquickimageeditor': '@latest-kf6'
'frameworks/sonnet': '@latest-kf6'
'frameworks/prison': '@latest-kf6'
'libraries/kirigami-addons': '@latest-kf6'
'third-party/libquotient': '@latest'
'third-party/qtkeychain': '@latest'
'third-party/cmark': '@latest'
'third-party/qcoro': '@latest'
- 'on': ['Windows', 'Linux', 'FreeBSD']
'require':
'frameworks/qqc2-desktop-style': '@latest-kf6'
'frameworks/kio': '@latest-kf6'
'frameworks/kwindowsystem': '@latest-kf6'
'frameworks/kstatusnotifieritem': '@latest-kf6'
'frameworks/kcrash': '@latest-kf6'
- 'on': ['Linux', 'FreeBSD']
'require':
'frameworks/kdbusaddons': '@latest-kf6'
'frameworks/purpose': '@latest-kf6'
- 'on': ['Linux']
'require':
'sdk/selenium-webdriver-at-spi': '@latest-kf6'
- 'on': ['Linux']
'require':
'sdk/selenium-webdriver-at-spi': '@latest-kf6'
Options:
per-test-timeout: 90
require-passing-tests-on: ['Linux', 'Android', 'FreeBSD', 'Windows']
require-passing-tests-on: [ 'Linux', 'Android', 'FreeBSD', 'Windows' ]

View File

@@ -66,9 +66,7 @@ if (QT_KNOWN_POLICY_QTP0004)
qt_policy(SET QTP0004 NEW)
endif ()
find_package(sentry REQUIRED)
find_package(KF6 ${KF_MIN_VERSION} COMPONENTS Kirigami I18n Notifications Config CoreAddons Sonnet ItemModels IconThemes ColorScheme)
find_package(KF6 ${KF_MIN_VERSION} COMPONENTS Kirigami I18n Notifications Config CoreAddons Sonnet ItemModels ColorScheme)
set_package_properties(KF6 PROPERTIES
TYPE REQUIRED
PURPOSE "Basic application components"

View File

@@ -137,11 +137,7 @@ void TimelineMessageModelTest::pendingEvent()
model->setRoom(room);
QCOMPARE(model->rowCount(), 0);
#if Quotient_VERSION_MINOR > 9
auto txnId = room->postText("New plain message"_L1);
#else
auto txnId = room->postPlainText("New plain message"_L1);
#endif
QCOMPARE(model->rowCount(), 1);
QCOMPARE(spyInsert.count(), 1);
@@ -149,11 +145,7 @@ void TimelineMessageModelTest::pendingEvent()
QCOMPARE(model->rowCount(), 0);
QCOMPARE(spyRemove.count(), 1);
#if Quotient_VERSION_MINOR > 9
txnId = room->postText("New plain message"_L1);
#else
txnId = room->postPlainText("New plain message"_L1);
#endif
QCOMPARE(model->rowCount(), 1);
QCOMPARE(spyInsert.count(), 2);

View File

@@ -649,9 +649,6 @@
<url>https://carlschwan.eu/2020/12/23/announcing-neochat-1.0-the-kde-matrix-client/</url>
</release>
</releases>
<requires>
<display_length compare="ge">360</display_length>
</requires>
<branding>
<color type="primary" scheme_preference="light">#a6e4f3</color>
<color type="primary" scheme_preference="dark">#235670</color>

View File

@@ -119,7 +119,6 @@ Comment[ta]=மேட்ரிக்ஸில் உரையாட உதவு
Comment[tr]=Matrix üzerinde sohbet edin
Comment[uk]=Спілкування у Matrix
Comment[x-test]=xxChat on Matrixxx
Comment[zh_CN]=在 Matrix 上聊天
Comment[zh_TW]=在 Matrix 上聊天
MimeType=x-scheme-handler/matrix;
Exec=neochat %u

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -28,8 +28,8 @@ apps:
compression: lzo
package-repositories:
- type: apt
ppa: ubuntu-toolchain-r/test
- type: apt
ppa: ubuntu-toolchain-r/test
slots:
session-dbus-interface:
@@ -66,7 +66,7 @@ parts:
- -Dcrypto=disabled
- -Dgtk_doc=false
build-packages:
- meson
- meson
- libglib2.0-dev
- libgcrypt20-dev
prime:
@@ -81,8 +81,7 @@ parts:
plugin: cmake
build-environment:
- PATH: /snap/bin:${PATH}
- PKG_CONFIG_PATH: "$CRAFT_STAGE/usr/lib/$CRAFT_ARCH_TRIPLET\
/pkgconfig:$PKG_CONFIG_PATH"
- PKG_CONFIG_PATH: $CRAFT_STAGE/usr/lib/$CRAFT_ARCH_TRIPLET/pkgconfig:$PKG_CONFIG_PATH
cmake-parameters:
- -DCMAKE_INSTALL_PREFIX=/usr
- -DCMAKE_BUILD_TYPE=Release
@@ -116,9 +115,7 @@ parts:
- -DQuotient_ENABLE_E2EE=ON
- -DBUILD_WITH_QT6=ON
override-build: |
"update-alternatives --install /usr/bin/gcc gcc\
/usr/bin/gcc-13 100 --slave /usr/bin/g++ g++ \
/usr/bin/g++-13 --slave /usr/bin/gcov gcov /usr/bin/gcov-13"
update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-13 100 --slave /usr/bin/g++ g++ /usr/bin/g++-13 --slave /usr/bin/gcov gcov /usr/bin/gcov-13
craftctl default
prime:
- -usr/include
@@ -175,3 +172,4 @@ parts:
- libcmark0.30.2
prime:
- usr/lib/*/libcmark.so*

View File

@@ -126,6 +126,8 @@ add_library(neochat STATIC
registration.cpp
neochatconnection.cpp
neochatconnection.h
jobs/neochatdeactivateaccountjob.cpp
jobs/neochatdeactivateaccountjob.h
jobs/neochatgetcommonroomsjob.cpp
jobs/neochatgetcommonroomsjob.h
mediasizehelper.cpp
@@ -192,14 +194,6 @@ add_library(neochat STATIC
models/roomsortparametermodel.h
models/messagemodel.cpp
models/messagemodel.h
models/messagecontentfiltermodel.cpp
models/messagecontentfiltermodel.h
models/pinnedmessagemodel.cpp
models/pinnedmessagemodel.h
models/commonroomsmodel.cpp
models/commonroomsmodel.h
sentryintegration.cpp
sentryintegration.h
)
set_source_files_properties(qml/OsmLocationPlugin.qml PROPERTIES
@@ -219,7 +213,7 @@ ecm_add_qml_module(neochat URI org.kde.neochat GENERATE_PLUGIN_SOURCE
qml/AccountMenu.qml
qml/ExploreComponent.qml
qml/ExploreComponentMobile.qml
qml/RoomContextMenu.qml
qml/ContextMenu.qml
qml/CollapsedRoomDelegate.qml
qml/RoomDelegate.qml
qml/RoomListPage.qml
@@ -255,7 +249,6 @@ ecm_add_qml_module(neochat URI org.kde.neochat GENERATE_PLUGIN_SOURCE
qml/MessageSourceSheet.qml
qml/ConfirmEncryptionDialog.qml
qml/RoomSearchPage.qml
qml/RoomPinnedMessagesPage.qml
qml/LocationChooser.qml
qml/TimelineView.qml
qml/InvitationView.qml
@@ -300,7 +293,6 @@ ecm_add_qml_module(neochat URI org.kde.neochat GENERATE_PLUGIN_SOURCE
qml/HoverLinkIndicator.qml
qml/AvatarNotification.qml
qml/ReasonDialog.qml
qml/SendLogsDialog.qml
DEPENDENCIES
QtCore
QtQuick
@@ -426,14 +418,12 @@ target_link_libraries(neochat PUBLIC
KF6::ConfigGui
KF6::CoreAddons
KF6::SonnetCore
KF6::IconThemes
KF6::ColorScheme
KF6::ItemModels
QuotientQt6
cmark::cmark
QCoro::Core
QCoro::Network
sentry::sentry
)
if (TARGET KF6::Crash)
@@ -501,7 +491,6 @@ if(ANDROID)
"network-connect"
"list-remove-user"
"org.kde.neochat"
"org.kde.neochat.tray"
"preferences-system-users"
"preferences-desktop-theme-global"
"notifications"
@@ -539,16 +528,12 @@ if(ANDROID)
"object-rotate-left"
"object-rotate-right"
"add-subtitle"
"security-high"
"security-low"
"security-low-symbolic"
"kde"
"list-remove-symbolic"
"edit-delete"
"user-home-symbolic"
"pin-symbolic"
"kt-restore-defaults-symbolic"
"user-symbolic"
)
ecm_add_android_apk(neochat-app ANDROID_DIR ${CMAKE_SOURCE_DIR}/android)
else()

View File

@@ -168,6 +168,7 @@ void Controller::addConnection(NeoChatConnection *c)
connect(c, &NeoChatConnection::syncDone, this, [this, c]() {
m_notificationsManager.handleNotifications(c);
});
connect(c, &NeoChatConnection::showInviteNotification, &m_notificationsManager, &NotificationsManager::postInviteNotification);
c->sync();

View File

@@ -53,13 +53,9 @@ public:
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);

View File

@@ -11,7 +11,7 @@ namespace Quotient
{
namespace EventContent
{
class FileInfo;
struct FileInfo;
}
class RoomEvent;
class RoomMember;

View File

@@ -0,0 +1,14 @@
// SPDX-FileCopyrightText: 2023 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: GPL-2.0-or-later
#include "neochatdeactivateaccountjob.h"
using namespace Quotient;
NeoChatDeactivateAccountJob::NeoChatDeactivateAccountJob(const std::optional<QJsonObject> &auth)
: BaseJob(HttpVerb::Post, u"DisableDeviceJob"_s, "_matrix/client/v3/account/deactivate")
{
QJsonObject data;
addParam<IfNotEmpty>(data, u"auth"_s, auth);
setRequestData(data);
}

View File

@@ -0,0 +1,12 @@
// SPDX-FileCopyrightText: 2023 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <Quotient/jobs/basejob.h>
class NeoChatDeactivateAccountJob : public Quotient::BaseJob
{
public:
explicit NeoChatDeactivateAccountJob(const std::optional<QJsonObject> &auth = {});
};

View File

@@ -37,7 +37,6 @@
#include <KCrash>
#endif
#include <KIconTheme>
#include <KLocalizedContext>
#include <KLocalizedString>
@@ -50,7 +49,6 @@
#include "controller.h"
#include "logger.h"
#include "roommanager.h"
#include "sentryintegration.h"
#include "sharehandler.h"
#include "windowcontroller.h"
@@ -103,7 +101,6 @@ Q_DECL_EXPORT
#endif
int main(int argc, char *argv[])
{
KIconTheme::initTheme();
QNetworkProxyFactory::setUseSystemConfiguration(true);
#ifdef HAVE_WEBVIEW
@@ -175,8 +172,6 @@ int main(int argc, char *argv[])
KCrash::initialize();
#endif
Sentry::instance();
initLogging();
Connection::setEncryptionDefault(true);

View File

@@ -136,11 +136,7 @@ QList<ActionsModel::Action> actions{
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,

View File

@@ -1,74 +0,0 @@
// SPDX-FileCopyrightText: 2025 Joshua Goins <josh@redstrate.com>
// SPDX-License-Identifier: GPL-2.0-or-later
#include "commonroomsmodel.h"
#include "jobs/neochatgetcommonroomsjob.h"
#include <QGuiApplication>
using namespace Quotient;
CommonRoomsModel::CommonRoomsModel(QObject *parent)
: QAbstractListModel(parent)
{
}
NeoChatConnection *CommonRoomsModel::connection() const
{
return m_connection;
}
void CommonRoomsModel::setConnection(NeoChatConnection *connection)
{
m_connection = connection;
Q_EMIT connectionChanged();
reload();
}
QString CommonRoomsModel::userId() const
{
return m_userId;
}
void CommonRoomsModel::setUserId(const QString &userId)
{
m_userId = userId;
Q_EMIT userIdChanged();
reload();
}
QVariant CommonRoomsModel::data(const QModelIndex &index, int roleName) const
{
Q_UNUSED(index)
Q_UNUSED(roleName)
return {};
}
int CommonRoomsModel::rowCount(const QModelIndex &parent) const
{
Q_UNUSED(parent)
return m_commonRooms.size();
}
void CommonRoomsModel::reload()
{
if (!m_connection || m_userId.isEmpty()) {
return;
}
if (!m_connection->canCheckMutualRooms()) {
return;
}
m_connection->callApi<NeochatGetCommonRoomsJob>(m_userId).then([this](const auto job) {
const auto &replyData = job->jsonData();
beginResetModel();
for (const auto &roomId : replyData[u"joined"_s].toArray()) {
m_commonRooms.push_back(roomId.toString());
}
endResetModel();
Q_EMIT countChanged();
});
}
#include "moc_commonroomsmodel.cpp"

View File

@@ -1,58 +0,0 @@
// SPDX-FileCopyrightText: 2025 Joshua Goins <josh@redstrate.com>
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <QAbstractListModel>
#include <QQmlEngine>
#include "neochatconnection.h"
#include "neochatroom.h"
#include <Quotient/events/roommessageevent.h>
#include <Quotient/roommember.h>
/**
* @brief Model to show the common or mutual rooms between you and another user.
*/
class CommonRoomsModel : public QAbstractListModel
{
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(NeoChatConnection *connection WRITE setConnection READ connection NOTIFY connectionChanged REQUIRED)
Q_PROPERTY(QString userId WRITE setUserId READ userId NOTIFY userIdChanged REQUIRED)
Q_PROPERTY(int count READ rowCount NOTIFY countChanged)
public:
enum Roles {
TextRole = Qt::DisplayRole,
LongitudeRole,
LatitudeRole,
AssetRole,
AuthorRole,
};
Q_ENUM(Roles)
explicit CommonRoomsModel(QObject *parent = nullptr);
[[nodiscard]] NeoChatConnection *connection() const;
void setConnection(NeoChatConnection *connection);
[[nodiscard]] QString userId() const;
void setUserId(const QString &userId);
[[nodiscard]] QVariant data(const QModelIndex &index, int roleName) const override;
[[nodiscard]] Q_INVOKABLE int rowCount(const QModelIndex &parent = {}) const override;
Q_SIGNALS:
void connectionChanged();
void userIdChanged();
void countChanged();
private:
void reload();
QPointer<NeoChatConnection> m_connection;
QString m_userId;
QList<QString> m_commonRooms;
};

View File

@@ -39,6 +39,14 @@ QVariant MediaMessageFilterModel::data(const QModelIndex &index, int role) const
const auto previousEventDay = mapToSource(this->index(index.row() + 1, 0)).data(TimelineMessageModel::TimeRole).toDateTime().toLocalTime().date();
return day != previousEventDay;
}
// Catch and force the author to be shown for all rows
if (role == TimelineMessageModel::ContentModelRole) {
const auto model = qvariant_cast<MessageContentModel *>(mapToSource(index).data(TimelineMessageModel::ContentModelRole));
if (model != nullptr) {
model->setShowAuthor(true);
}
return QVariant::fromValue<MessageContentModel *>(model);
}
QVariantMap mediaInfo = mapToSource(index).data(TimelineMessageModel::MediaInfoRole).toMap();

View File

@@ -1,37 +0,0 @@
// SPDX-FileCopyrightText: 2025 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 "messagecontentfiltermodel.h"
#include "enums/messagecomponenttype.h"
#include "models/messagecontentmodel.h"
MessageContentFilterModel::MessageContentFilterModel(QObject *parent)
: QSortFilterProxyModel(parent)
{
}
bool MessageContentFilterModel::showAuthor() const
{
return m_showAuthor;
}
void MessageContentFilterModel::setShowAuthor(bool showAuthor)
{
if (showAuthor == m_showAuthor) {
return;
}
m_showAuthor = showAuthor;
Q_EMIT showAuthorChanged();
}
bool MessageContentFilterModel::filterAcceptsRow(int source_row, const QModelIndex &source_parent) const
{
if (m_showAuthor) {
return true;
}
const auto index = sourceModel()->index(source_row, 0, source_parent);
auto contentType = static_cast<MessageComponentType::Type>(index.data(MessageContentModel::ComponentTypeRole).toInt());
return contentType != MessageComponentType::Author;
}

View File

@@ -1,43 +0,0 @@
// SPDX-FileCopyrightText: 2025 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 <QQmlEngine>
#include <QSortFilterProxyModel>
/**
* @class MessageContentFilterModel
*
* This model filters a message's contents.
*/
class MessageContentFilterModel : public QSortFilterProxyModel
{
Q_OBJECT
QML_ELEMENT
/**
* @brief Whether the author component should be shown.
*/
Q_PROPERTY(bool showAuthor READ showAuthor WRITE setShowAuthor NOTIFY showAuthorChanged)
public:
explicit MessageContentFilterModel(QObject *parent = nullptr);
bool showAuthor() const;
void setShowAuthor(bool showAuthor);
Q_SIGNALS:
void showAuthorChanged();
protected:
/**
* @brief Whether a row should be shown out or not.
*
* @sa QSortFilterProxyModel::filterAcceptsRow
*/
[[nodiscard]] bool filterAcceptsRow(int source_row, const QModelIndex &source_parent) const override;
private:
bool m_showAuthor = true;
};

View File

@@ -233,6 +233,32 @@ NeochatRoomMember *MessageContentModel::senderObject() const
return m_room->qmlSafeMember(eventResult.first->senderId());
}
bool MessageContentModel::showAuthor() const
{
return m_showAuthor;
}
void MessageContentModel::setShowAuthor(bool showAuthor)
{
if (showAuthor == m_showAuthor) {
return;
}
m_showAuthor = showAuthor;
if (m_room->connection()->isIgnored(senderId())) {
if (showAuthor) {
beginInsertRows({}, 0, 0);
m_components.prepend(MessageComponent{MessageComponentType::Author, QString(), {}});
endInsertRows();
} else {
beginRemoveRows({}, 0, 0);
m_components.remove(0, 1);
endRemoveRows();
}
}
Q_EMIT showAuthorChanged();
}
static LinkPreviewer *emptyLinkPreview = new LinkPreviewer;
QVariant MessageContentModel::data(const QModelIndex &index, int role) const
@@ -340,17 +366,6 @@ QVariant MessageContentModel::data(const QModelIndex &index, int role) const
if (role == ReplyContentModelRole) {
return QVariant::fromValue<MessageContentModel *>(m_replyModel);
}
if (role == ThreadRootRole) {
auto roomMessageEvent = eventCast<const RoomMessageEvent>(event.first);
#if Quotient_VERSION_MINOR > 9 || (Quotient_VERSION_MINOR == 9 && Quotient_VERSION_PATCH > 1)
if (roomMessageEvent && (roomMessageEvent->isThreaded() || m_room->threads().contains(roomMessageEvent->id()))) {
#else
if (roomMessageEvent && roomMessageEvent->isThreaded()) {
#endif
return roomMessageEvent->threadRootEventId();
}
return {};
}
if (role == LinkPreviewerRole) {
if (component.type == MessageComponentType::LinkPreview) {
return QVariant::fromValue<LinkPreviewer *>(
@@ -377,32 +392,27 @@ int MessageContentModel::rowCount(const QModelIndex &parent) const
QHash<int, QByteArray> MessageContentModel::roleNames() const
{
return roleNamesStatic();
}
QHash<int, QByteArray> MessageContentModel::roleNamesStatic()
{
QHash<int, QByteArray> roles;
roles[MessageContentModel::DisplayRole] = "display";
roles[MessageContentModel::ComponentTypeRole] = "componentType";
roles[MessageContentModel::ComponentAttributesRole] = "componentAttributes";
roles[MessageContentModel::EventIdRole] = "eventId";
roles[MessageContentModel::TimeRole] = "time";
roles[MessageContentModel::TimeStringRole] = "timeString";
roles[MessageContentModel::AuthorRole] = "author";
roles[MessageContentModel::MediaInfoRole] = "mediaInfo";
roles[MessageContentModel::FileTransferInfoRole] = "fileTransferInfo";
roles[MessageContentModel::ItineraryModelRole] = "itineraryModel";
roles[MessageContentModel::LatitudeRole] = "latitude";
roles[MessageContentModel::LongitudeRole] = "longitude";
roles[MessageContentModel::AssetRole] = "asset";
roles[MessageContentModel::PollHandlerRole] = "pollHandler";
roles[MessageContentModel::ReplyEventIdRole] = "replyEventId";
roles[MessageContentModel::ReplyAuthorRole] = "replyAuthor";
roles[MessageContentModel::ReplyContentModelRole] = "replyContentModel";
roles[MessageContentModel::ThreadRootRole] = "threadRoot";
roles[MessageContentModel::LinkPreviewerRole] = "linkPreviewer";
roles[MessageContentModel::ChatBarCacheRole] = "chatBarCache";
QHash<int, QByteArray> roles = QAbstractItemModel::roleNames();
roles[DisplayRole] = "display";
roles[ComponentTypeRole] = "componentType";
roles[ComponentAttributesRole] = "componentAttributes";
roles[EventIdRole] = "eventId";
roles[TimeRole] = "time";
roles[TimeStringRole] = "timeString";
roles[AuthorRole] = "author";
roles[MediaInfoRole] = "mediaInfo";
roles[FileTransferInfoRole] = "fileTransferInfo";
roles[ItineraryModelRole] = "itineraryModel";
roles[LatitudeRole] = "latitude";
roles[LongitudeRole] = "longitude";
roles[AssetRole] = "asset";
roles[PollHandlerRole] = "pollHandler";
roles[ReplyEventIdRole] = "replyEventId";
roles[ReplyAuthorRole] = "replyAuthor";
roles[ReplyContentModelRole] = "replyContentModel";
roles[ThreadRootRole] = "threadRoot";
roles[LinkPreviewerRole] = "linkPreviewer";
roles[ChatBarCacheRole] = "chatBarCache";
return roles;
}
@@ -424,7 +434,9 @@ void MessageContentModel::resetModel()
return;
}
m_components += MessageComponent{MessageComponentType::Author, QString(), {}};
if (m_showAuthor) {
m_components += MessageComponent{MessageComponentType::Author, QString(), {}};
}
m_components += messageContentComponents();
endResetModel();
@@ -480,16 +492,6 @@ QList<MessageComponent> MessageContentModel::messageContentComponents(bool isEdi
newComponents = addLinkPreviews(newComponents);
}
#if Quotient_VERSION_MINOR > 9 || (Quotient_VERSION_MINOR == 9 && Quotient_VERSION_PATCH > 1)
if (roomMessageEvent && (roomMessageEvent->isThreaded() || m_room->threads().contains(roomMessageEvent->id()))
&& roomMessageEvent->id() == roomMessageEvent->threadRootEventId()) {
#else
if (isThreading && roomMessageEvent && roomMessageEvent->isThreaded() && roomMessageEvent->id() == roomMessageEvent->threadRootEventId()) {
#endif
newComponents += MessageComponent{MessageComponentType::Separator, {}, {}};
newComponents += MessageComponent{MessageComponentType::ThreadBody, u"Thread Body"_s, {}};
}
// If the event is already threaded the ThreadModel will handle displaying a chat bar.
#if Quotient_VERSION_MINOR > 9 || (Quotient_VERSION_MINOR == 9 && Quotient_VERSION_PATCH > 1)
if (isThreading && roomMessageEvent && !(roomMessageEvent->isThreaded() || m_room->threads().contains(roomMessageEvent->id()))) {

View File

@@ -25,6 +25,11 @@ class MessageContentModel : public QAbstractListModel
QML_ELEMENT
QML_UNCREATABLE("")
/**
* @brief Whether the author component is being shown.
*/
Q_PROPERTY(bool showAuthor READ showAuthor WRITE setShowAuthor NOTIFY showAuthorChanged)
public:
enum MessageState {
Unknown, /**< The message state is unknown. */
@@ -70,6 +75,9 @@ public:
bool isPending = false,
MessageContentModel *parent = nullptr);
bool showAuthor() const;
void setShowAuthor(bool showAuthor);
/**
* @brief Get the given role value at the given index.
*
@@ -91,8 +99,6 @@ public:
*/
[[nodiscard]] QHash<int, QByteArray> roleNames() const override;
static QHash<int, QByteArray> roleNamesStatic();
/**
* @brief Close the link preview at the given index.
*
@@ -111,6 +117,7 @@ private:
NeochatRoomMember *senderObject() const;
MessageState m_currentState = Unknown;
bool m_showAuthor = true;
bool m_isReply;
void initializeModel();

View File

@@ -92,8 +92,12 @@ QVariant MessageFilterModel::data(const QModelIndex &index, int role) const
return authorList(mapToSource(index).row());
} else if (role == ExcessAuthorsRole) {
return excessAuthors(mapToSource(index).row());
} else if (role == MessageModel::ShowAuthorRole) {
return showAuthor(index);
} else if (role == TimelineMessageModel::ContentModelRole) {
const auto model = qvariant_cast<MessageContentModel *>(mapToSource(index).data(TimelineMessageModel::ContentModelRole));
if (model != nullptr && !showAuthor(index)) {
model->setShowAuthor(false);
}
return QVariant::fromValue<MessageContentModel *>(model);
}
return QSortFilterProxyModel::data(index, role);
}

View File

@@ -7,7 +7,7 @@
#include <Quotient/events/roommessageevent.h>
#include <Quotient/events/stickerevent.h>
#if Quotient_VERSION_MINOR > 9 || (Quotient_VERSION_MINOR == 9 && Quotient_VERSION_PATCH > 1)
#if Quotient_VERSION_MINOR > 9
#include <Quotient/thread.h>
#endif
@@ -120,11 +120,16 @@ QVariant MessageModel::data(const QModelIndex &idx, int role) const
}
if (role == ContentModelRole) {
auto roomMessageEvent = eventCast<const RoomMessageEvent>(&event.value().get());
if (roomMessageEvent && roomMessageEvent->isThreaded()) {
return QVariant::fromValue<MessageContentModel *>(m_room->contentModelForEvent(roomMessageEvent->threadRootEventId()));
QString modelId;
if (!event->get().id().isEmpty() && m_contentModels.contains(event->get().id())) {
modelId = event.value().get().id();
} else if (!event.value().get().transactionId().isEmpty() && m_contentModels.contains(event.value().get().transactionId())) {
modelId = event.value().get().transactionId();
}
return QVariant::fromValue<MessageContentModel *>(m_room->contentModelForEvent(&event->get()));
if (!modelId.isEmpty()) {
return QVariant::fromValue<MessageContentModel *>(m_contentModels.at(modelId).get());
}
return {};
}
if (role == GenericDisplayRole) {
@@ -173,7 +178,7 @@ QVariant MessageModel::data(const QModelIndex &idx, int role) const
}
auto roomMessageEvent = eventCast<const RoomMessageEvent>(&event.value().get());
#if Quotient_VERSION_MINOR > 9 || (Quotient_VERSION_MINOR == 9 && Quotient_VERSION_PATCH > 1)
#if Quotient_VERSION_MINOR > 9
if (roomMessageEvent && (roomMessageEvent->isThreaded() || m_room->threads().contains(event.value().get().id()))) {
const auto &thread = m_room->threads().value(roomMessageEvent->isThreaded() ? roomMessageEvent->threadRootEventId() : event.value().get().id());
if (thread.latestEventId != event.value().get().id()) {
@@ -196,15 +201,13 @@ QVariant MessageModel::data(const QModelIndex &idx, int role) const
if (role == ProgressInfoRole) {
if (auto e = eventCast<const RoomMessageEvent>(&event.value().get())) {
if (e->has<EventContent::FileContent>() || e->has<EventContent::ImageContent>() || e->has<EventContent::VideoContent>()
|| e->has<EventContent::AudioContent>()) {
if (e->has<EventContent::FileContent>()) {
return QVariant::fromValue(m_room->cachedFileTransferInfo(&event.value().get()));
}
}
if (eventCast<const StickerEvent>(&event.value().get())) {
return QVariant::fromValue(m_room->cachedFileTransferInfo(&event.value().get()));
}
return {};
}
if (role == TimeRole) {
@@ -300,10 +303,6 @@ QVariant MessageModel::data(const QModelIndex &idx, int role) const
&& event.value().get().senderId() == m_room->localMember().id();
}
if (role == ShowAuthorRole) {
return true;
}
return {};
}
@@ -333,7 +332,6 @@ QHash<int, QByteArray> MessageModel::roleNames() const
roles[ContentModelRole] = "contentModel";
roles[MediaInfoRole] = "mediaInfo";
roles[IsEditableRole] = "isEditable";
roles[ShowAuthorRole] = "showAuthor";
return roles;
}
@@ -430,6 +428,17 @@ void MessageModel::createEventObjects(const Quotient::RoomEvent *event, bool isP
senderId = m_room->localMember().id();
}
if (!m_contentModels.contains(eventId) && !m_contentModels.contains(event->transactionId())) {
if (!event->isStateEvent() || event->matrixType() == u"org.matrix.msc3672.beacon_info"_s) {
m_contentModels[eventId] = std::unique_ptr<MessageContentModel>(new MessageContentModel(m_room, eventId, false, isPending));
}
}
const auto roomMessageEvent = eventCast<const Quotient::RoomMessageEvent>(event);
if (roomMessageEvent && roomMessageEvent->isThreaded() && !m_threadModels.contains(roomMessageEvent->threadRootEventId())) {
m_threadModels[roomMessageEvent->threadRootEventId()] = QSharedPointer<ThreadModel>(new ThreadModel(roomMessageEvent->threadRootEventId(), m_room));
}
// ReadMarkerModel handles updates to add and remove markers, we only need to
// handle adding and removing whole models here.
if (m_readMarkerModels.contains(eventId)) {
@@ -504,6 +513,7 @@ void MessageModel::clearModel()
void MessageModel::clearEventObjects()
{
m_contentModels.clear();
m_reactionModels.clear();
m_readMarkerModels.clear();
}
@@ -518,7 +528,7 @@ bool MessageModel::event(QEvent *event)
ThreadModel *MessageModel::threadModelForRootId(const QString &threadRootId) const
{
return m_room->modelForThread(threadRootId);
return m_threadModels[threadRootId].data();
}
#include "moc_messagemodel.cpp"

View File

@@ -7,6 +7,7 @@
#include <QQmlEngine>
#include <functional>
#include "messagecontentmodel.h"
#include "neochatroom.h"
#include "pollhandler.h"
#include "readmarkermodel.h"
@@ -45,7 +46,6 @@ class MessageModel : public QAbstractListModel
{
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("")
/**
* @brief The current room that the model is getting its messages from.
@@ -85,7 +85,6 @@ public:
IsRedactedRole, /**< Whether an event has been deleted. */
IsPendingRole, /**< Whether an event is waiting to be accepted by the server. */
IsEditableRole, /**< Whether the event can be edited by the user. */
ShowAuthorRole, /**< Whether the author of a message should be shown. */
LastRole, // Keep this last
};
Q_ENUM(EventRoles)
@@ -154,7 +153,9 @@ private:
bool resetting = false;
bool movingEvent = false;
std::map<QString, std::unique_ptr<MessageContentModel>> m_contentModels;
QMap<QString, QSharedPointer<ReadMarkerModel>> m_readMarkerModels;
QMap<QString, QSharedPointer<ThreadModel>> m_threadModels;
QMap<QString, QSharedPointer<ReactionModel>> m_reactionModels;
void createEventObjects(const Quotient::RoomEvent *event, bool isPending = false);

View File

@@ -1,67 +0,0 @@
// SPDX-FileCopyrightText: 2025 Joshua Goins <josh@redstrate.com>
// SPDX-License-Identifier: LGPL-2.0-or-later
#include "pinnedmessagemodel.h"
#include "enums/delegatetype.h"
#include "eventhandler.h"
#include "models/messagecontentmodel.h"
#include "neochatroom.h"
#include <QGuiApplication>
#include <KLocalizedString>
using namespace Quotient;
PinnedMessageModel::PinnedMessageModel(QObject *parent)
: MessageModel(parent)
{
connect(this, &MessageModel::roomChanged, this, &PinnedMessageModel::fill);
}
bool PinnedMessageModel::loading() const
{
return m_loading;
}
int PinnedMessageModel::rowCount(const QModelIndex &parent) const
{
Q_UNUSED(parent)
return m_pinnedEvents.size();
}
std::optional<std::reference_wrapper<const Quotient::RoomEvent>> PinnedMessageModel::getEventForIndex(const QModelIndex index) const
{
if (static_cast<size_t>(index.row()) >= m_pinnedEvents.size() || index.row() < 0) {
return std::nullopt;
}
return std::reference_wrapper{*m_pinnedEvents[index.row()].get()};
}
void PinnedMessageModel::setLoading(bool loading)
{
m_loading = loading;
Q_EMIT loadingChanged();
}
void PinnedMessageModel::fill()
{
if (!m_room) {
return;
}
const auto events = m_room->pinnedEventIds();
for (const auto &event : std::as_const(events)) {
auto job = m_room->connection()->callApi<GetOneRoomEventJob>(m_room->id(), event);
connect(job, &BaseJob::success, this, [this, job] {
beginInsertRows({}, m_pinnedEvents.size(), m_pinnedEvents.size());
m_pinnedEvents.push_back(std::move(fromJson<event_ptr_tt<RoomEvent>>(job->jsonData())));
Q_EMIT newEventAdded(m_pinnedEvents.back().get(), false);
endInsertRows();
});
}
}
#include "moc_pinnedmessagemodel.cpp"

View File

@@ -1,62 +0,0 @@
// SPDX-FileCopyrightText: 2025 Joshua Goins <josh@redstrate.com>
// SPDX-License-Identifier: LGPL-2.0-or-later
#pragma once
#include <QAbstractListModel>
#include <QQmlEngine>
#include <QString>
#include <Quotient/csapi/rooms.h>
#include "messagemodel.h"
#include "neochatroommember.h"
namespace Quotient
{
class Connection;
}
class NeoChatRoom;
/**
* @class PinnedMessageModel
*
* This class defines the model for visualising a room's pinned messages.
*/
class PinnedMessageModel : public MessageModel
{
Q_OBJECT
QML_ELEMENT
/**
* @brief Whether the model is currently loading.
*/
Q_PROPERTY(bool loading READ loading NOTIFY loadingChanged)
public:
explicit PinnedMessageModel(QObject *parent = nullptr);
/**
* @brief Number of rows in the model.
*
* @sa QAbstractItemModel::rowCount
*/
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
bool loading() const;
Q_SIGNALS:
void loadingChanged();
protected:
std::optional<std::reference_wrapper<const Quotient::RoomEvent>> getEventForIndex(QModelIndex index) const override;
private:
void setLoading(bool loading);
void fill();
bool m_loading = false;
std::vector<Quotient::event_ptr_tt<Quotient::RoomEvent>> m_pinnedEvents;
};

View File

@@ -11,18 +11,18 @@
#include "chatbarcache.h"
#include "eventhandler.h"
#include "messagecomponenttype.h"
#include "messagecontentmodel.h"
#include "neochatroom.h"
ThreadModel::ThreadModel(const QString &threadRootId, NeoChatRoom *room)
: QConcatenateTablesProxyModel(room)
, m_threadRootId(threadRootId)
, m_threadFetchModel(new ThreadFetchModel(this))
, m_threadChatBarModel(new ThreadChatBarModel(this, room))
{
Q_ASSERT(!m_threadRootId.isEmpty());
Q_ASSERT(room);
m_threadRootContentModel = std::unique_ptr<MessageContentModel>(new MessageContentModel(room, threadRootId));
#if Quotient_VERSION_MINOR > 9 || (Quotient_VERSION_MINOR == 9 && Quotient_VERSION_PATCH > 0)
connect(room, &Quotient::Room::pendingEventAdded, this, [this](const Quotient::RoomEvent *event) {
#else
@@ -49,7 +49,7 @@ ThreadModel::ThreadModel(const QString &threadRootId, NeoChatRoom *room)
// If the thread was created by the local user fetchMore() won't find the current
// pending event.
checkPending();
fetchMoreEvents(3);
fetchMore({});
addModels();
}
@@ -73,28 +73,34 @@ QString ThreadModel::threadRootId() const
return m_threadRootId;
}
QHash<int, QByteArray> ThreadModel::roleNames() const
MessageContentModel *ThreadModel::threadRootContentModel() const
{
return MessageContentModel::roleNamesStatic();
return m_threadRootContentModel.get();
}
bool ThreadModel::moreEventsAvailable(const QModelIndex &parent) const
QHash<int, QByteArray> ThreadModel::roleNames() const
{
return m_threadRootContentModel->roleNames();
}
bool ThreadModel::canFetchMore(const QModelIndex &parent) const
{
Q_UNUSED(parent);
return !m_currentJob && m_nextBatch.has_value();
}
void ThreadModel::fetchMoreEvents(int max)
void ThreadModel::fetchMore(const QModelIndex &parent)
{
Q_UNUSED(parent);
if (!m_currentJob && m_nextBatch.has_value()) {
const auto room = dynamic_cast<NeoChatRoom *>(QObject::parent());
const auto connection = room->connection();
m_currentJob = connection->callApi<Quotient::GetRelatingEventsWithRelTypeJob>(room->id(), m_threadRootId, u"m.thread"_s, *m_nextBatch, QString(), max);
Q_EMIT moreEventsAvailableChanged();
m_currentJob = connection->callApi<Quotient::GetRelatingEventsWithRelTypeJob>(room->id(), m_threadRootId, u"m.thread"_s, *m_nextBatch, QString(), 5);
connect(m_currentJob, &Quotient::BaseJob::success, this, [this]() {
const auto room = dynamic_cast<NeoChatRoom *>(QObject::parent());
auto newEvents = m_currentJob->chunk();
for (auto &event : newEvents) {
m_events.push_back(event->id());
m_contentModels.push_back(new MessageContentModel(room, event->id()));
}
addModels();
@@ -110,18 +116,18 @@ void ThreadModel::fetchMoreEvents(int max)
}
m_currentJob.clear();
Q_EMIT moreEventsAvailableChanged();
});
}
}
void ThreadModel::addNewEvent(const Quotient::RoomEvent *event)
{
const auto room = dynamic_cast<NeoChatRoom *>(QObject::parent());
auto eventId = event->id();
if (eventId.isEmpty()) {
eventId = event->transactionId();
}
m_events.push_front(eventId);
m_contentModels.push_front(new MessageContentModel(room, eventId));
}
void ThreadModel::addModels()
@@ -130,16 +136,9 @@ void ThreadModel::addModels()
clearModels();
}
const auto room = dynamic_cast<NeoChatRoom *>(QObject::parent());
if (room == nullptr) {
return;
}
addSourceModel(m_threadFetchModel);
for (auto it = m_events.crbegin(); it != m_events.crend(); ++it) {
const auto contentModel = room->contentModelForEvent(*it);
if (contentModel != nullptr) {
addSourceModel(room->contentModelForEvent(*it));
}
addSourceModel(m_threadRootContentModel.get());
for (auto it = m_contentModels.crbegin(); it != m_contentModels.crend(); ++it) {
addSourceModel(*it);
}
addSourceModel(m_threadChatBarModel);
@@ -149,90 +148,15 @@ void ThreadModel::addModels()
void ThreadModel::clearModels()
{
const auto room = dynamic_cast<NeoChatRoom *>(QObject::parent());
if (room == nullptr) {
return;
}
removeSourceModel(m_threadFetchModel);
for (const auto &model : m_events) {
const auto contentModel = room->contentModelForEvent(model);
if (sourceModels().contains(contentModel)) {
removeSourceModel(contentModel);
removeSourceModel(m_threadRootContentModel.get());
for (const auto &model : m_contentModels) {
if (sourceModels().contains(model)) {
removeSourceModel(model);
}
}
removeSourceModel(m_threadChatBarModel);
}
void ThreadModel::closeLinkPreview(int row)
{
if (row < 0 || row >= rowCount()) {
return;
}
const auto index = this->index(row, 0);
if (!index.isValid()) {
return;
}
const auto sourceIndex = mapToSource(index);
const auto sourceModel = sourceIndex.model();
if (sourceModel == nullptr) {
return;
}
// This is a bit silly but we can only get a const reference to the model from the
// index so we need to search the source models.
for (const auto &model : sourceModels()) {
if (model == sourceModel) {
const auto sourceContentModel = dynamic_cast<MessageContentModel *>(model);
if (sourceContentModel == nullptr) {
return;
}
sourceContentModel->closeLinkPreview(sourceIndex.row());
}
}
}
ThreadFetchModel::ThreadFetchModel(QObject *parent)
: QAbstractListModel(parent)
{
const auto threadModel = dynamic_cast<ThreadModel *>(parent);
Q_ASSERT(threadModel != nullptr);
connect(threadModel, &ThreadModel::moreEventsAvailableChanged, this, [this]() {
beginResetModel();
endResetModel();
});
}
QVariant ThreadFetchModel::data(const QModelIndex &idx, int role) const
{
if (idx.row() < 0 || idx.row() > 1) {
return {};
}
if (role == ComponentTypeRole) {
return MessageComponentType::FetchButton;
}
return {};
}
int ThreadFetchModel::rowCount(const QModelIndex &parent) const
{
Q_UNUSED(parent)
const auto threadModel = dynamic_cast<ThreadModel *>(this->parent());
if (threadModel == nullptr) {
qWarning() << "ThreadFetchModel created with incorrect parent, a ThreadModel must be set as the parent on creation.";
return {};
}
return threadModel->moreEventsAvailable({}) ? 1 : 0;
}
QHash<int, QByteArray> ThreadFetchModel::roleNames() const
{
return {
{ComponentTypeRole, "componentType"},
};
}
ThreadChatBarModel::ThreadChatBarModel(QObject *parent, NeoChatRoom *room)
: QAbstractListModel(parent)
, m_room(room)

View File

@@ -21,52 +21,6 @@
class NeoChatRoom;
class ReactionModel;
/**
* @class ThreadFetchModel
*
* A model to provide a fetch more historical messages button in a thread.
*/
class ThreadFetchModel : public QAbstractListModel
{
Q_OBJECT
public:
/**
* @brief Defines the model roles.
*
* The role values need to match MessageContentModel not to blow up.
*
* @sa MessageContentModel
*/
enum Roles {
ComponentTypeRole = MessageContentModel::ComponentTypeRole, /**< The type of component to visualise the message. */
};
Q_ENUM(Roles)
explicit ThreadFetchModel(QObject *parent);
/**
* @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 1 or 0, depending on whether there are more messages to download.
*
* @sa QAbstractItemModel::rowCount
*/
[[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override;
/**
* @brief Returns a map with ComponentTypeRole it's the only one.
*
* @sa Roles, QAbstractItemModel::roleNames()
*/
[[nodiscard]] QHash<int, QByteArray> roleNames() const override;
};
/**
* @class ThreadChatBarModel
*
@@ -137,6 +91,11 @@ public:
QString threadRootId() const;
/**
* @brief The content model for the thread root event.
*/
MessageContentModel *threadRootContentModel() const;
/**
* @brief Returns a mapping from Role enum values to role names.
*
@@ -145,33 +104,32 @@ public:
[[nodiscard]] QHash<int, QByteArray> roleNames() const override;
/**
* @brief Whether there are more events for the model to fetch.
*/
bool moreEventsAvailable(const QModelIndex &parent) const;
/**
* @brief Fetches the next batch of events if any is available.
*/
Q_INVOKABLE void fetchMoreEvents(int max = 5);
/**
* @brief Close the link preview at the given index.
* @brief Whether there is more data available for the model to fetch.
*
* If the given index is not a link preview component, nothing happens.
* @sa QAbstractItemModel::canFetchMore()
*/
Q_INVOKABLE void closeLinkPreview(int row);
bool canFetchMore(const QModelIndex &parent) const override;
Q_SIGNALS:
void moreEventsAvailableChanged();
/**
* @brief Fetches the next batch of model data if any is available.
*
* @sa QAbstractItemModel::fetchMore()
*/
void fetchMore(const QModelIndex &parent) override;
private:
QString m_threadRootId;
QPointer<MessageContentModel> m_threadRootContentModel;
std::deque<QString> m_events;
ThreadFetchModel *m_threadFetchModel;
std::unique_ptr<MessageContentModel> m_threadRootContentModel;
std::deque<MessageContentModel *> m_contentModels;
ThreadChatBarModel *m_threadChatBarModel;
QList<QString> m_events;
QList<QString> m_pendingEvents;
std::unordered_map<QString, std::unique_ptr<Quotient::RoomEvent>> m_unloadedEvents;
QMap<QString, QSharedPointer<ReactionModel>> m_reactionModels;
QPointer<Quotient::GetRelatingEventsWithRelTypeJob> m_currentJob = nullptr;

View File

@@ -174,8 +174,8 @@ void UserListModel::refreshAllMembers()
m_members = m_currentRoom->joinedMemberIds();
MemberSorter sorter;
std::sort(m_members.begin(), m_members.end(), [&sorter, this](const auto &left, const auto &right) {
const auto leftPl = m_currentRoom->memberEffectivePowerLevel(left);
const auto rightPl = m_currentRoom->memberEffectivePowerLevel(right);
const auto leftPl = m_currentRoom->getUserPowerLevel(left);
const auto rightPl = m_currentRoom->getUserPowerLevel(right);
if (leftPl > rightPl) {
return true;
} else if (rightPl > leftPl) {
@@ -184,6 +184,7 @@ void UserListModel::refreshAllMembers()
return sorter(m_currentRoom->member(left), m_currentRoom->member(right));
});
}
endResetModel();
Q_EMIT usersRefreshed();

View File

@@ -39,7 +39,7 @@ struct WebShortcutModelPrivate;
* }
* QQC2.MenuSeparator {}
* QQC2.MenuItem {
* text: i18n("Configure Web Shortcuts")
* text: i18n("Configure Web Shortcuts...")
* icon.name: "configure"
* onTriggered: webshortcutmodel.configureWebShortcuts()
* }

View File

@@ -223,10 +223,6 @@
<label>Reject unknown invites</label>
<default>false</default>
</entry>
<entry name="PreferUsingEncryption" type="bool">
<label>Prefer encrypting chats</label>
<default>true</default>
</entry>
</group>
</kcfg>

View File

@@ -6,6 +6,7 @@
#include <QImageReader>
#include <QJsonDocument>
#include "jobs/neochatdeactivateaccountjob.h"
#include "neochatconfig.h"
#include "neochatroom.h"
#include "spacehierarchycache.h"
@@ -20,6 +21,7 @@
#include <Quotient/csapi/profile.h>
#include <Quotient/csapi/registration.h>
#include <Quotient/csapi/versions.h>
#include <Quotient/database.h>
#include <Quotient/jobs/downloadfilejob.h>
#include <Quotient/qt_connection_util.h>
#include <Quotient/room.h>
@@ -107,6 +109,10 @@ void NeoChatConnection::connectSignals()
Q_EMIT homeHaveHighlightNotificationsChanged();
});
});
connect(this, &NeoChatConnection::invitedRoom, this, [this](Quotient::Room *room) {
auto r = dynamic_cast<NeoChatRoom *>(room);
connect(r, &NeoChatRoom::showInviteNotification, this, &NeoChatConnection::showInviteNotification);
});
connect(this, &NeoChatConnection::leftRoom, this, [this](Room *room, Room *prev) {
Q_UNUSED(room)
if (prev && prev->isDirectChat()) {
@@ -136,15 +142,9 @@ void NeoChatConnection::connectSignals()
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);
setDirectChatEncryptionDefault(NeoChatConfig::preferUsingEncryption());
connect(NeoChatConfig::self(), &NeoChatConfig::PreferUsingEncryptionChanged, this, [] {
setDirectChatEncryptionDefault(NeoChatConfig::preferUsingEncryption());
});
}
int NeoChatConnection::badgeNotificationCount() const
@@ -255,20 +255,20 @@ 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)
void NeoChatConnection::deactivateAccount(const QString &password)
{
auto job = callApi<DeactivateAccountJob>();
connect(job, &BaseJob::result, this, [this, job, password, erase] {
auto job = callApi<NeoChatDeactivateAccountJob>();
connect(job, &BaseJob::result, this, [this, job, password] {
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 authData;
authData["session"_L1] = replyData["session"_L1];
authData["password"_L1] = password;
authData["type"_L1] = "m.login.password"_L1;
authData["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);
authData["identifier"_L1] = identifier;
auto innerJob = callApi<NeoChatDeactivateAccountJob>(authData);
connect(innerJob, &BaseJob::success, this, [this]() {
logout(false);
});
@@ -481,6 +481,23 @@ QCoro::Task<void> NeoChatConnection::setupPushNotifications(QString endpoint)
#endif
}
QString NeoChatConnection::deviceKey() const
{
return edKeyForUserDevice(userId(), deviceId());
}
QString NeoChatConnection::encryptionKey() const
{
auto query = database()->prepareQuery(u"SELECT curveKey FROM tracked_devices WHERE matrixId=:matrixId AND deviceid=:deviceId LIMIT 1;"_s);
query.bindValue(u":matrixId"_s, userId());
query.bindValue(u":deviceId"_s, deviceId());
database()->execute(query);
if (!query.next()) {
return {};
}
return query.value(0).toString();
}
bool NeoChatConnection::isOnline() const
{
return m_isOnline;
@@ -531,9 +548,4 @@ KeyImport::Error NeoChatConnection::exportMegolmSessions(const QString &passphra
return KeyImport::Success;
}
bool NeoChatConnection::canEraseData() const
{
return m_canEraseData;
}
#include "moc_neochatconnection.cpp"

View File

@@ -31,6 +31,8 @@ class NeoChatConnection : public Quotient::Connection
* Set to an empty string to remove the label.
*/
Q_PROPERTY(QString label READ label WRITE setLabel NOTIFY labelChanged)
Q_PROPERTY(QString deviceKey READ deviceKey CONSTANT)
Q_PROPERTY(QString encryptionKey READ encryptionKey CONSTANT)
/**
* @brief The model with the account's 3PIDs.
@@ -85,11 +87,6 @@ class NeoChatConnection : public Quotient::Connection
*/
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)
public:
/**
* @brief Defines the status after an attempt to change the password on an account.
@@ -107,7 +104,6 @@ public:
Q_INVOKABLE void logout(bool serverSideLogout);
Q_INVOKABLE QVariantList getSupportedRoomVersions() const;
bool canCheckMutualRooms() const;
bool canEraseData() const;
/**
* @brief Change the password for an account.
@@ -127,7 +123,7 @@ public:
[[nodiscard]] QString label() const;
void setLabel(const QString &label);
Q_INVOKABLE void deactivateAccount(const QString &password, bool erase);
Q_INVOKABLE void deactivateAccount(const QString &password);
ThreePIdModel *threePIdModel() const;
@@ -178,6 +174,9 @@ public:
// the reference could be destroyed before the task is finished
QCoro::Task<void> setupPushNotifications(QString endpoint);
QString deviceKey() const;
QString encryptionKey() const;
bool isOnline() const;
LinkPreviewer *previewerForLink(const QUrl &link);
@@ -195,7 +194,6 @@ Q_SIGNALS:
void userConsentRequired(QUrl url);
void badgeNotificationCountChanged(NeoChatConnection *connection, int count);
void canCheckMutualRoomsChanged();
void canEraseDataChanged();
/**
* @brief Request a message be shown to the user of the given type.
@@ -207,6 +205,11 @@ Q_SIGNALS:
*/
void errorOccured(const QString &error);
/**
* @brief Request a notification be shown for an invite to this room.
*/
void showInviteNotification(NeoChatRoom *room);
private:
bool m_isOnline = true;
void setIsOnline(bool isOnline);
@@ -220,5 +223,4 @@ private:
QCache<QUrl, LinkPreviewer> m_linkPreviewers;
bool m_canCheckMutualRooms = false;
bool m_canEraseData = false;
};

View File

@@ -127,6 +127,9 @@ NeoChatRoom::NeoChatRoom(Connection *connection, QString roomId, JoinState joinS
updatePushNotificationState(u"m.push_rules"_s);
Q_EMIT canEncryptRoomChanged();
if (this->joinState() == JoinState::Invite) {
Q_EMIT showInviteNotification(this);
}
},
Qt::SingleShotConnection);
connect(this, &Room::changed, this, [this] {
@@ -170,8 +173,6 @@ void NeoChatRoom::setVisible(bool visible)
if (!visible) {
m_memberObjects.clear();
m_eventContentModels.clear();
m_threadModels.clear();
}
}
@@ -781,6 +782,22 @@ void NeoChatRoom::setUserPowerLevel(const QString &userID, const int &powerLevel
}
}
int NeoChatRoom::getUserPowerLevel(const QString &userId) const
{
if (!successorId().isEmpty()) {
return 0; // No one can upgrade a room that's already upgraded
}
const auto &mId = userId.isEmpty() ? connection()->userId() : userId;
if (const auto *plEvent = currentState().get<RoomPowerLevelsEvent>()) {
return plEvent->powerLevelForUser(mId);
}
if (const auto *createEvent = creation()) {
return createEvent->senderId() == mId ? 100 : 0;
}
return 0; // That's rather weird but may happen, according to rvdh
}
QCoro::Task<void> NeoChatRoom::doDeleteMessagesByUser(const QString &user, QString reason)
{
QStringList events;
@@ -1726,6 +1743,10 @@ void NeoChatRoom::setRoomState(const QString &type, const QString &stateKey, con
NeochatRoomMember *NeoChatRoom::qmlSafeMember(const QString &memberId)
{
if (!isMember(memberId)) {
return nullptr;
}
if (!m_memberObjects.contains(memberId)) {
return m_memberObjects.emplace(memberId, std::make_unique<NeochatRoomMember>(this, memberId)).first->second.get();
}
@@ -1733,73 +1754,4 @@ NeochatRoomMember *NeoChatRoom::qmlSafeMember(const QString &memberId)
return m_memberObjects[memberId].get();
}
MessageContentModel *NeoChatRoom::contentModelForEvent(const QString &eventId)
{
if (eventId.isEmpty()) {
return nullptr;
}
if (!m_eventContentModels.contains(eventId)) {
return m_eventContentModels.emplace(eventId, std::make_unique<MessageContentModel>(this, eventId)).first->second.get();
}
return m_eventContentModels[eventId].get();
}
MessageContentModel *NeoChatRoom::contentModelForEvent(const Quotient::RoomEvent *event)
{
const auto roomMessageEvent = eventCast<const Quotient::RoomMessageEvent>(event);
if (roomMessageEvent == nullptr) {
// If for some reason a model is there remove.
if (m_eventContentModels.contains(event->id())) {
m_eventContentModels.erase(event->id());
}
if (m_eventContentModels.contains(event->transactionId())) {
m_eventContentModels.erase(event->transactionId());
}
return nullptr;
}
if (event->isStateEvent() || event->matrixType() == u"org.matrix.msc3672.beacon_info"_s) {
return nullptr;
}
auto eventId = event->id();
const auto txnId = event->transactionId();
if (!m_eventContentModels.contains(eventId) && !m_eventContentModels.contains(txnId)) {
return m_eventContentModels.emplace(eventId, std::make_unique<MessageContentModel>(this, eventId.isEmpty() ? txnId : eventId, false, eventId.isEmpty()))
.first->second.get();
}
if (!eventId.isEmpty() && m_eventContentModels.contains(eventId)) {
return m_eventContentModels[eventId].get();
}
if (!txnId.isEmpty() && m_eventContentModels.contains(txnId)) {
if (eventId.isEmpty()) {
return m_eventContentModels[txnId].get();
}
// If we now have an event ID use that as the map key instead of transaction ID.
auto txnModel = std::move(m_eventContentModels[txnId]);
m_eventContentModels.erase(txnId);
return m_eventContentModels.emplace(eventId, std::move(txnModel)).first->second.get();
}
return nullptr;
}
ThreadModel *NeoChatRoom::modelForThread(const QString &threadRootId)
{
if (threadRootId.isEmpty()) {
return nullptr;
}
if (!m_threadModels.contains(threadRootId)) {
return m_threadModels.emplace(threadRootId, std::make_unique<ThreadModel>(threadRootId, this)).first->second.get();
}
return m_threadModels[threadRootId].get();
}
#include "moc_neochatroom.cpp"

View File

@@ -3,7 +3,6 @@
#pragma once
#include <Quotient/events/roomevent.h>
#include <Quotient/room.h>
#include <QCache>
@@ -15,8 +14,6 @@
#include "enums/messagetype.h"
#include "enums/pushrule.h"
#include "models/messagecontentmodel.h"
#include "models/threadmodel.h"
#include "neochatroommember.h"
#include "pollhandler.h"
@@ -504,6 +501,14 @@ public:
bool canEncryptRoom() const;
/**
* @brief Get the power level for the given user ID in the room.
*
* Returns the default value for a user in the room if they have no escalated
* privileges or if they are not a member so membership should be known before using.
*/
Q_INVOKABLE [[nodiscard]] int getUserPowerLevel(const QString &userId) const;
Q_INVOKABLE void setUserPowerLevel(const QString &userID, const int &powerLevel);
ChatBarCache *mainCache() const;
@@ -588,54 +593,8 @@ public:
*/
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 Returns the content model for the given event ID.
*
* A model is created is one doesn't exist. Will return nullptr if evtOrTxnId
* is empty.
*
* @warning If a non-empty ID is given it is assumed to be a valid Quotient::RoomMessageEvent
* event ID. The caller must ensure that the ID is a real event. A model will be
* returned unconditionally.
*
* @warning Do NOT use for pending events as this function has no way to differentiate.
*/
MessageContentModel *contentModelForEvent(const QString &evtOrTxnId);
/**
* @brief Returns the content model for the given event.
*
* A model is created is one doesn't exist. Will return nullptr if event is:
* - nullptr
* - not a Quotient::RoomMessageEvent (e.g a state event)
*
* @note This method is preferred to the version using just an event ID as it
* can perform some basic checks. If a copy of the event is not available,
* you may have to use the version that takes an event ID.
*
* @note This version must be used for pending events as it can differentiate.
*/
MessageContentModel *contentModelForEvent(const Quotient::RoomEvent *event);
/**
* @brief Returns the thread model for the given thread root event ID.
*
* A model is created is one doesn't exist. Will return nullptr if threadRootId
* is empty.
*/
Q_INVOKABLE ThreadModel *modelForThread(const QString &threadRootId);
private:
bool m_visible = false;
@@ -668,8 +627,6 @@ private:
void cleanupExtraEvent(const QString &eventId);
std::unordered_map<QString, std::unique_ptr<NeochatRoomMember>> m_memberObjects;
std::unordered_map<QString, std::unique_ptr<MessageContentModel>> m_eventContentModels;
std::unordered_map<QString, std::unique_ptr<ThreadModel>> m_threadModels;
private Q_SLOTS:
void updatePushNotificationState(QString type);
@@ -706,6 +663,14 @@ Q_SIGNALS:
*/
void showMessage(MessageType::Type messageType, const QString &message);
/**
* @brief Request a notification be shown for an invite to this room.
*
* @note This may later be blocked if there are any rules on where invites can
* come from, but this is not NeoChatRoom's responsibility.
*/
void showInviteNotification(NeoChatRoom *room);
public Q_SLOTS:
/**
* @brief Upload a file to the matrix server and post the file to the room.

View File

@@ -127,8 +127,9 @@ void NotificationsManager::processNotificationJob(QPointer<NeoChatConnection> co
}
auto sender = room->member(notification["event"_L1]["sender"_L1].toString());
// Don't display notifications for events in invited rooms
// This should prevent empty notifications from appearing when they shouldn't
if (room->joinState() == JoinState::Invite) {
postInviteNotification(qobject_cast<NeoChatRoom *>(room));
continue;
}
@@ -243,7 +244,7 @@ void NotificationsManager::postNotification(NeoChatRoom *room,
if (canReply) {
std::unique_ptr<KNotificationReplyAction> replyAction(new KNotificationReplyAction(i18n("Reply")));
replyAction->setPlaceholderText(i18n("Reply"));
replyAction->setPlaceholderText(i18n("Reply..."));
connect(replyAction.get(), &KNotificationReplyAction::replied, this, [room, replyEventId](const QString &text) {
TextHandler textHandler;
textHandler.setData(text);

View File

@@ -6,19 +6,20 @@ import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.components as KirigamiComponents
import org.kde.neochat
import org.kde.neochat.settings
import org.kde.neochat.devtools
KirigamiComponents.ConvergentContextMenu {
QQC2.Menu {
id: root
required property NeoChatConnection connection
required property Kirigami.ApplicationWindow window
QQC2.Action {
margins: Kirigami.Units.smallSpacing
QQC2.MenuItem {
text: i18nc("@action:button", "Show QR Code")
icon.name: "view-barcode-qr-symbolic"
onTriggered: {
@@ -35,8 +36,7 @@ KirigamiComponents.ConvergentContextMenu {
qrMax.open();
}
}
QQC2.Action {
QQC2.MenuItem {
text: i18n("Edit This Account")
icon.name: "document-edit"
onTriggered: pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat.settings', 'AccountEditorPage'), {
@@ -45,24 +45,21 @@ KirigamiComponents.ConvergentContextMenu {
title: i18n("Account editor")
})
}
QQC2.Action {
QQC2.MenuItem {
text: i18n("Notification Settings")
icon.name: "notifications"
onTriggered: {
NeoChatSettingsView.open('notifications');
}
}
QQC2.Action {
QQC2.MenuItem {
text: i18n("Devices")
icon.name: "computer-symbolic"
onTriggered: {
NeoChatSettingsView.open('devices');
}
}
Kirigami.Action {
QQC2.MenuItem {
text: i18n("Open Developer Tools")
icon.name: "tools"
visible: NeoChatConfig.developerTools
@@ -74,8 +71,7 @@ KirigamiComponents.ConvergentContextMenu {
height: Kirigami.Units.gridUnit * 42
})
}
Kirigami.Action {
QQC2.MenuItem {
text: i18nc("@action:inmenu", "Open Secret Backup")
icon.name: "unlock"
visible: NeoChatConfig.secretBackup
@@ -83,30 +79,22 @@ KirigamiComponents.ConvergentContextMenu {
title: i18nc("@title:window", "Open Key Backup")
})
}
QQC2.Action {
QQC2.MenuItem {
text: i18nc("@action:inmenu", "Verify This Device")
icon.name: "security-low"
onTriggered: root.connection.startSelfVerification()
enabled: Controller.csSupported
}
QQC2.Action {
text: i18nc("@action:inmenu", "Do Sentry Things")
onTriggered: root.window.pageStack.pushDialogLayer(Qt.createComponent("org.kde.neochat", "SendLogsDialog"), {}, {
title: i18nc("@title:window", "Send Logs")
})
}
QQC2.Action {
QQC2.MenuItem {
text: i18n("Logout")
icon.name: "im-kick-user"
onTriggered: confirmLogoutDialogComponent.createObject(QQC2.ApplicationWindow.window.overlay).open()
}
readonly property Component confirmLogoutDialogComponent: ConfirmLogoutDialog {
connection: root.connection
Component {
id: confirmLogoutDialogComponent
ConfirmLogoutDialog {
connection: root.connection
}
}
}

View File

@@ -2,7 +2,6 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls as QQC2
import org.kde.kirigamiaddons.formcard as FormCard
@@ -19,21 +18,12 @@ Kirigami.PromptDialog {
dialogType: Kirigami.PromptDialog.Warning
mainItem: ColumnLayout {
FormCard.FormTextFieldDelegate {
id: passwordField
label: i18nc("@label:textbox", "Password")
echoMode: TextInput.Password
horizontalPadding: 0
bottomPadding: 0
}
FormCard.FormCheckDelegate {
id: eraseDelegate
text: i18nc("@label:checkbox", "Erase Data")
description: i18nc("@info", "Request your server to delete as much user data as possible.")
visible: connection.canEraseData
}
mainItem: FormCard.FormTextFieldDelegate {
id: passwordField
label: i18nc("@label:textbox", "Password")
echoMode: TextInput.Password
horizontalPadding: 0
bottomPadding: 0
}
footer: QQC2.DialogButtonBox {
@@ -44,7 +34,7 @@ Kirigami.PromptDialog {
icon.name: "emblem-warning"
enabled: passwordField.text.length > 0
onClicked: {
root.connection.deactivateAccount(passwordField.text, eraseDelegate.checked);
root.connection.deactivateAccount(passwordField.text);
root.closeDialog();
}
}

246
src/qml/ContextMenu.qml Normal file
View File

@@ -0,0 +1,246 @@
// SPDX-FileCopyrightText: 2019 Black Hat <bhat@encom.eu.org>
// SPDX-FileCopyrightText: 2020 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.components as KirigamiComponents
import org.kde.kirigamiaddons.delegates as Delegates
import org.kde.neochat
import org.kde.neochat.settings
/**
* Context menu when clicking on a room in the room list
*/
Loader {
id: root
property NeoChatRoom room
required property NeoChatConnection connection
signal closed
Component {
id: regularMenu
QQC2.Menu {
QQC2.MenuItem {
text: i18n("Mark as Read")
icon.name: "checkmark"
enabled: room.notificationCount > 0
onTriggered: room.markAllMessagesAsRead()
}
QQC2.MenuSeparator {}
QQC2.Menu {
title: i18nc("@action:inmenu", "Notifications")
icon.name: "notifications"
QQC2.MenuItem {
text: i18n("Follow Global Setting")
icon.name: "globe"
checkable: true
autoExclusive: true
checked: room.pushNotificationState === PushNotificationState.Default
enabled: room.pushNotificationState != PushNotificationState.Unknown
onTriggered: {
room.pushNotificationState = PushNotificationState.Default;
}
}
QQC2.MenuItem {
text: i18nc("As in 'notify for all messages'", "All")
icon.name: "notifications"
checkable: true
autoExclusive: true
checked: room.pushNotificationState === PushNotificationState.All
enabled: room.pushNotificationState != PushNotificationState.Unknown
onTriggered: {
room.pushNotificationState = PushNotificationState.All;
}
}
QQC2.MenuItem {
text: i18nc("As in 'notify when the user is mentioned or the message contains a set keyword'", "@Mentions and Keywords")
icon.name: "im-user"
checkable: true
autoExclusive: true
checked: room.pushNotificationState === PushNotificationState.MentionKeyword
enabled: room.pushNotificationState != PushNotificationState.Unknown
onTriggered: {
room.pushNotificationState = PushNotificationState.MentionKeyword;
}
}
QQC2.MenuItem {
text: i18nc("As in 'do not notify for any messages'", "Off")
icon.name: "notifications-disabled"
checkable: true
autoExclusive: true
checked: room.pushNotificationState === PushNotificationState.Mute
enabled: room.pushNotificationState != PushNotificationState.Unknown
onTriggered: {
room.pushNotificationState = PushNotificationState.Mute;
}
}
}
QQC2.MenuItem {
text: room.isFavourite ? i18n("Remove from Favorites") : i18n("Add to Favorites")
icon.name: room.isFavourite ? "rating" : "rating-unrated"
onTriggered: room.isFavourite ? room.removeTag("m.favourite") : room.addTag("m.favourite", 1.0)
}
QQC2.MenuItem {
text: room.isLowPriority ? i18n("Reprioritize") : i18n("Deprioritize")
icon.name: room.isLowPriority ? "arrow-up-symbolic" : "arrow-down-symbolic"
onTriggered: room.isLowPriority ? room.removeTag("m.lowpriority") : room.addTag("m.lowpriority", 1.0)
}
QQC2.MenuSeparator {}
QQC2.MenuItem {
text: room.isDirectChat() ? i18nc("@action:inmenu", "Copy user's Matrix ID to Clipboard") : i18nc("@action:inmenu", "Copy Address to Clipboard")
icon.name: "edit-copy"
onTriggered: if (room.isDirectChat()) {
Clipboard.saveText(room.directChatRemoteMember.id);
} else if (room.canonicalAlias.length === 0) {
Clipboard.saveText(room.id);
} else {
Clipboard.saveText(room.canonicalAlias);
}
}
QQC2.MenuItem {
text: i18nc("@action:inmenu", "Room Settings")
icon.name: 'settings-configure-symbolic'
onTriggered: {
RoomSettingsView.openRoomSettings(root.room, RoomSettingsView.Room);
}
}
QQC2.MenuSeparator {}
QQC2.MenuItem {
text: i18n("Leave Room")
icon.name: "go-previous"
onTriggered: {
Qt.createComponent('org.kde.neochat', 'ConfirmLeaveDialog').createObject(root.QQC2.ApplicationWindow.window, {
room: root.room
}).open();
}
}
onClosed: {
root.closed();
}
}
}
Component {
id: mobileMenu
Kirigami.OverlayDrawer {
id: drawer
parent: applicationWindow().overlay
edge: Qt.BottomEdge
height: popupContent.implicitHeight
leftPadding: 0
rightPadding: 0
bottomPadding: 0
topPadding: 0
ColumnLayout {
id: popupContent
width: parent.width
spacing: 0
RowLayout {
id: headerLayout
Layout.fillWidth: true
Layout.margins: Kirigami.Units.largeSpacing
spacing: Kirigami.Units.largeSpacing
KirigamiComponents.Avatar {
id: avatar
source: room.avatarMediaUrl
name: room.displayName
Layout.preferredWidth: Kirigami.Units.gridUnit * 3
Layout.preferredHeight: Kirigami.Units.gridUnit * 3
Layout.alignment: Qt.AlignTop
}
Kirigami.Heading {
level: 5
Layout.fillWidth: true
text: room.displayName
elide: Text.ElideRight
}
QQC2.ToolButton {
checked: room.isFavourite
checkable: true
icon.name: 'favorite'
Accessible.name: room.isFavourite ? i18n("Remove from Favourites") : i18n("Add to Favourites")
onClicked: room.isFavourite ? room.removeTag("m.favourite") : room.addTag("m.favourite", 1.0)
}
QQC2.ToolButton {
text: i18nc("@action:button", "Room Settings")
icon.name: 'settings-configure-symbolic'
onClicked: {
RoomSettingsView.openRoomSettings(root.room, RoomSettingsView.Room);
drawer.close();
}
}
}
Delegates.RoundedItemDelegate {
text: room.isLowPriority ? i18n("Reprioritize") : i18n("Deprioritize")
icon.name: room.isLowPriority ? "arrow-up-symbolic" : "arrow-down-symbolic"
onClicked: room.isLowPriority ? room.removeTag("m.lowpriority") : room.addTag("m.lowpriority", 1.0)
Layout.fillWidth: true
}
Delegates.RoundedItemDelegate {
text: i18n("Mark as Read")
icon.name: "checkmark"
onClicked: room.markAllMessagesAsRead()
Layout.fillWidth: true
}
Delegates.RoundedItemDelegate {
text: i18n("Leave Room")
icon.name: "go-previous"
onClicked: {
Qt.createComponent('org.kde.neochat', 'ConfirmLeaveDialog').createObject(root.QQC2.ApplicationWindow.window, {
room: root.room
}).open();
drawer.close();
}
Layout.fillWidth: true
}
}
onClosed: root.closed()
}
}
asynchronous: true
sourceComponent: Kirigami.Settings.isMobile ? mobileMenu : regularMenu
function open() {
active = true;
}
onStatusChanged: if (status == Loader.Ready) {
if (Kirigami.Settings.isMobile) {
item.open();
} else {
item.popup();
}
}
}

View File

@@ -9,7 +9,6 @@ import Qt.labs.qmlmodels
import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.components as KirigamiComponents
import org.kde.kirigamiaddons.formcard as FormCard
import org.kde.kirigamiaddons.delegates as Delegates
import org.kde.neochat
@@ -26,7 +25,7 @@ import org.kde.neochat
* For event types that need alternate actions this class can be used as a base and
* the actions and nested actions can be overwritten to show the alternate items.
*/
KirigamiComponents.ConvergentContextMenu {
Loader {
id: root
/**
@@ -63,6 +62,25 @@ KirigamiComponents.ConvergentContextMenu {
*/
property string hoveredLink: ""
/**
* @brief The list of menu item actions that have sub-actions.
*
* Each action will be instantiated as a single line that open a sub menu.
*/
property list<Kirigami.Action> nestedActions
/**
* @brief The main list of menu item actions.
*
* Each action will be instantiated as a single line in the menu.
*/
property list<Kirigami.Action> actions
/**
* @brief Whether the web search menu should be shown or not.
*/
property bool enableWebSearch: true
/**
* Some common actions shared between menus
*/
@@ -94,7 +112,7 @@ KirigamiComponents.ConvergentContextMenu {
}
}
component ReplyMessageAction: QQC2.Action {
component ReplyMessageAction: Kirigami.Action {
text: i18n("Reply")
icon.name: "mail-replied-symbolic"
onTriggered: {
@@ -132,70 +150,90 @@ KirigamiComponents.ConvergentContextMenu {
}
}
headerContentItem: RowLayout {
spacing: Kirigami.Units.largeSpacing
Component {
id: regularMenu
KirigamiComponents.Avatar {
source: root.author.avatarUrl
QQC2.Menu {
id: menu
Instantiator {
model: root.nestedActions
delegate: QQC2.Menu {
id: menuItem
visible: modelData.visible
title: modelData.text
icon: modelData.icon
Layout.preferredWidth: Kirigami.Units.gridUnit * 2
Layout.preferredHeight: Kirigami.Units.gridUnit * 2
Layout.alignment: Qt.AlignTop
}
ColumnLayout {
spacing: 0
Layout.fillWidth: true
Kirigami.Heading {
level: 4
text: root.author.htmlSafeDisplayName
wrapMode: Text.WordWrap
Layout.fillWidth: true
Instantiator {
model: modelData.children
delegate: QQC2.MenuItem {
text: modelData.text
icon.name: modelData.icon.name
onTriggered: modelData.trigger()
}
onObjectAdded: (index, object) => {
menuItem.insertItem(0, object);
}
}
}
onObjectAdded: (index, object) => {
object.visible = false;
menu.addMenu(object);
}
}
QQC2.Label {
text: root.plainText
textFormat: Text.PlainText
elide: Text.ElideRight
onLinkActivated: RoomManager.resolveResource(link, "join")
Layout.fillWidth: true
}
}
}
Kirigami.Action {
visible: Kirigami.Settings.isMobile
displayComponent: RowLayout {
spacing: 0
Layout.fillWidth: true
Layout.preferredHeight: Kirigami.Units.gridUnit * 2.5
Repeater {
model: ["👍", "👎️", "😄", "🎉", "🚀", "👀"]
delegate: Delegates.RoundedItemDelegate {
Layout.fillWidth: true
Layout.fillHeight: true
model: root.actions
DelegateChooser {
role: "separator"
DelegateChoice {
roleValue: true
contentItem: Kirigami.Heading {
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
font.family: "emoji"
text: modelData
QQC2.MenuSeparator {
visible: modelData.visible
}
}
onClicked: {
currentRoom.toggleReaction(eventId, modelData);
root.item.close();
DelegateChoice {
roleValue: false
QQC2.MenuItem {
visible: modelData.visible
action: modelData
onClicked: root.item.close()
}
}
}
}
}
}
/*
QQC2.Menu {
id: webshortcutmenu
title: i18n("Search for '%1'", webshortcutmodel.trunkatedSearchText)
icon.name: "search-symbolic"
property bool isVisible: webshortcutmodel.enabled && root.enableWebSearch
Component.onCompleted: {
webshortcutmenu.parent.visible = isVisible;
}
onIsVisibleChanged: webshortcutmenu.parent.visible = isVisible
Instantiator {
model: WebShortcutModel {
id: webshortcutmodel
selectedText: root.selectedText.length > 0 ? root.selectedText : root.plainText
onOpenUrl: url => RoomManager.resolveResource(url.toString())
}
delegate: QQC2.MenuItem {
text: model.display
icon.name: model.decoration
onTriggered: webshortcutmodel.trigger(model.edit)
}
onObjectAdded: (index, object) => webshortcutmenu.insertItem(0, object)
}
QQC2.MenuSeparator {}
QQC2.MenuItem {
text: i18n("Configure Web Shortcuts...")
icon.name: "configure"
visible: !Controller.isFlatpak
onTriggered: webshortcutmodel.configureWebShortcuts()
}
}
}
}
Component {
@@ -292,6 +330,31 @@ KirigamiComponents.ConvergentContextMenu {
Kirigami.Separator {
Layout.fillWidth: true
}
RowLayout {
spacing: 0
Layout.fillWidth: true
Layout.preferredHeight: Kirigami.Units.gridUnit * 2.5
Repeater {
model: ["👍", "👎️", "😄", "🎉", "🚀", "👀"]
delegate: QQC2.ItemDelegate {
Layout.fillWidth: true
Layout.fillHeight: true
contentItem: Kirigami.Heading {
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
font.family: "emoji"
text: modelData
}
onClicked: {
currentRoom.toggleReaction(eventId, modelData);
root.item.close();
}
}
}
}
Kirigami.Separator {
Layout.fillWidth: true
}
@@ -359,5 +422,5 @@ KirigamiComponents.ConvergentContextMenu {
} else {
item.popup();
}
}*/
}
}

View File

@@ -19,6 +19,65 @@ RowLayout {
signal search
property Kirigami.Action exploreAction: Kirigami.Action {
text: i18n("Explore rooms")
icon.name: "compass"
onTriggered: {
let dialog = pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'ExploreRoomsPage'), {
connection: root.connection
}, {
title: i18nc("@title", "Explore Rooms")
});
dialog.roomSelected.connect((roomId, displayName, avatarUrl, alias, topic, memberCount, isJoined) => {
RoomManager.resolveResource(roomId.length > 0 ? roomId : alias, isJoined ? "" : "join");
});
}
}
property Kirigami.Action chatAction: Kirigami.Action {
text: i18n("Find your friends")
icon.name: "list-add-user"
onTriggered: pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'UserSearchPage'), {
connection: root.connection
}, {
title: i18nc("@title", "Find your friends")
})
}
property Kirigami.Action roomAction: Kirigami.Action {
text: i18n("Create a Room")
icon.name: "system-users-symbolic"
onTriggered: {
pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'CreateRoomDialog'), {
connection: root.connection
}, {
title: i18nc("@title", "Create a Room")
});
}
shortcut: StandardKey.New
}
property Kirigami.Action spaceAction: Kirigami.Action {
text: i18n("Create a Space")
icon.name: "list-add"
onTriggered: {
pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'CreateRoomDialog'), {
connection: root.connection,
isSpace: true,
title: i18nc("@title", "Create a Space")
}, {
title: i18nc("@title", "Create a Space")
});
}
}
property Kirigami.Action scanAction: Kirigami.Action {
text: i18n("Scan a QR Code")
icon.name: "view-barcode-qr"
onTriggered: pageStack.pushDialogLayer(Qt.createComponent("org.kde.neochat", "QrScannerPage"), {
connection: root.connection
}, {
title: i18nc("@title", "Scan a QR Code")
})
}
/**
* @brief Emitted when the text is changed in the search field.
*/
@@ -60,13 +119,18 @@ RowLayout {
Accessible.onPressAction: menuButton.action.trigger()
display: QQC2.AbstractButton.IconOnly
checkable: true
action: QQC2.Action {
action: Kirigami.Action {
text: i18nc("@action:button", "Show Menu")
icon.name: "application-menu-symbolic"
onTriggered: {
const item = menu.createObject(menuButton);
item.closed.connect(menuButton.toggle);
item.open();
if (Kirigami.isMobile) {
const menu = mobileMenu.createObject();
menu.open();
} else {
const menu = desktopMenu.createObject(menuButton);
menu.closed.connect(menuButton.toggle);
menu.open();
}
}
}
@@ -76,70 +140,86 @@ RowLayout {
}
Component {
id: menu
id: desktopMenu
QQC2.Menu {
x: mirrored ? parent.width - width : 0
y: parent ? parent.height : 0
modal: true
dim: false
QQC2.MenuItem {
text: i18n("Explore rooms")
icon.name: "compass"
onTriggered: {
let dialog = pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'ExploreRoomsPage'), {
connection: root.connection
}, {
title: i18nc("@title", "Explore Rooms")
});
dialog.roomSelected.connect((roomId, displayName, avatarUrl, alias, topic, memberCount, isJoined) => {
RoomManager.resolveResource(roomId.length > 0 ? roomId : alias, isJoined ? "" : "join");
});
Accessible.onPressAction: action.triggered()
action: exploreAction
}
QQC2.MenuItem {
Accessible.onPressAction: action.triggered()
action: chatAction
}
QQC2.MenuItem {
Accessible.onPressAction: action.triggered()
action: roomAction
}
QQC2.MenuItem {
Accessible.onPressAction: action.triggered()
action: spaceAction
}
QQC2.MenuItem {
Accessible.onPressAction: action.triggered()
action: scanAction
}
}
}
Component {
id: mobileMenu
Kirigami.OverlayDrawer {
id: menuRoot
edge: Qt.BottomEdge
parent: applicationWindow().overlay
leftPadding: 0
rightPadding: 0
bottomPadding: 0
topPadding: 0
ColumnLayout {
width: parent.width
spacing: 0
Kirigami.ListSectionHeader {
label: i18n("Create rooms and chats")
}
}
QQC2.MenuItem {
text: i18n("Find your friends")
icon.name: "list-add-user"
onTriggered: pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'UserSearchPage'), {
connection: root.connection
}, {
title: i18nc("@title", "Find your friends")
})
}
QQC2.MenuItem {
text: i18n("Create a Room")
icon.name: "system-users-symbolic"
action: QQC2.Action {
shortcut: StandardKey.New
onTriggered: {
pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'CreateRoomDialog'), {
connection: root.connection
}, {
title: i18nc("@title", "Create a Room")
});
}
Delegates.RoundedItemDelegate {
action: exploreAction
onClicked: menuRoot.close()
Layout.fillWidth: true
}
}
QQC2.MenuItem {
text: i18n("Create a Space")
icon.name: "list-add"
onTriggered: {
pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'CreateRoomDialog'), {
connection: root.connection,
isSpace: true,
title: i18nc("@title", "Create a Space")
}, {
title: i18nc("@title", "Create a Space")
});
Delegates.RoundedItemDelegate {
action: chatAction
onClicked: menuRoot.close()
Layout.fillWidth: true
}
}
QQC2.MenuItem {
text: i18n("Scan a QR Code")
icon.name: "view-barcode-qr"
onTriggered: pageStack.pushDialogLayer(Qt.createComponent("org.kde.neochat", "QrScannerPage"), {
connection: root.connection
}, {
title: i18nc("@title", "Scan a QR Code")
})
Delegates.RoundedItemDelegate {
action: roomAction
onClicked: menuRoot.close()
Layout.fillWidth: true
}
Delegates.RoundedItemDelegate {
action: roomAction
onClicked: menuRoot.close()
Layout.fillWidth: true
}
Delegates.RoundedItemDelegate {
action: scanAction
onClicked: menuRoot.close()
Layout.fillWidth: true
}
}
}
}

View File

@@ -33,97 +33,107 @@ DelegateContextMenu {
*/
required property var progressInfo
DelegateContextMenu.ReplyMessageAction {}
// Web search isn't useful for images
enableWebSearch: false
Kirigami.Action {
separator: true
}
QQC2.Action {
text: i18nc("@action:inmenu", "Open Image")
icon.name: "document-open"
onTriggered: {
currentRoom.openEventMediaExternally(root.eventId);
}
}
QQC2.Action {
text: i18nc("@action:inmenu", "Save Image…")
icon.name: "document-save"
onTriggered: {
var dialog = saveAsDialog.createObject(QQC2.Overlay.overlay);
dialog.selectedFile = currentRoom.fileNameToDownload(eventId);
dialog.open();
}
}
QQC2.Action {
text: i18nc("@action:inmenu", "Copy Image")
icon.name: "edit-copy"
onTriggered: {
currentRoom.copyEventMedia(root.eventId);
}
}
Kirigami.Action {
separator: true
}
Kirigami.Action {
visible: author.id === currentRoom.localMember.id || currentRoom.canSendState("redact")
text: i18n("Remove")
icon.name: "edit-delete-remove"
icon.color: "red"
onTriggered: {
let dialog = applicationWindow().pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'ReasonDialog'), {
title: i18nc("@title:dialog", "Remove Message"),
placeholder: i18nc("@info:placeholder", "Reason for removing this message"),
actionText: i18nc("@action:button 'Remove' as in 'Remove this message'", "Remove"),
icon: "delete"
}, {
title: i18nc("@title:dialog", "Remove Message"),
width: Kirigami.Units.gridUnit * 25
});
dialog.accepted.connect(reason => {
currentRoom.redactEvent(root.eventId, reason);
});
}
}
DelegateContextMenu.ReportMessageAction {}
DelegateContextMenu.ShowUserAction {}
Kirigami.Action {
separator: true
visible: viewSourceAction.visible
}
DelegateContextMenu.ViewSourceAction {
id: viewSourceAction
}
ShareAction {
id: shareAction
inputData: {
"urls": [filename],
"mimeType": [root.mimeType]
}
room: currentRoom
eventId: root.eventId
property string filename: Core.StandardPaths.writableLocation(Core.StandardPaths.CacheLocation) + "/" + eventId.replace(":", "_").replace("/", "_").replace("+", "_") + currentRoom.fileNameToDownload(eventId)
}
readonly property Component saveAsDialog: Dialogs.FileDialog {
fileMode: Dialogs.FileDialog.SaveFile
currentFolder: NeoChatConfig.lastSaveDirectory.length > 0 ? NeoChatConfig.lastSaveDirectory : Core.StandardPaths.writableLocation(Core.StandardPaths.DownloadLocation)
onAccepted: {
if (!selectedFile) {
return;
/**
* @brief The main list of menu item actions.
*
* Each action will be instantiated as a single line in the menu.
*/
property list<Kirigami.Action> actions: [
DelegateContextMenu.ReplyMessageAction {},
Kirigami.Action {
separator: true
},
Kirigami.Action {
text: i18nc("@action:inmenu", "Open Image")
icon.name: "document-open"
onTriggered: {
currentRoom.openEventMediaExternally(root.eventId);
}
},
Kirigami.Action {
text: i18nc("@action:inmenu", "Save Image…")
icon.name: "document-save"
onTriggered: {
var dialog = saveAsDialog.createObject(QQC2.Overlay.overlay);
dialog.selectedFile = currentRoom.fileNameToDownload(eventId);
dialog.open();
}
},
Kirigami.Action {
text: i18nc("@action:inmenu", "Copy Image")
icon.name: "edit-copy"
onTriggered: {
currentRoom.copyEventMedia(root.eventId);
}
},
Kirigami.Action {
separator: true
},
Kirigami.Action {
visible: author.id === currentRoom.localMember.id || currentRoom.canSendState("redact")
text: i18n("Remove")
icon.name: "edit-delete-remove"
icon.color: "red"
onTriggered: {
let dialog = applicationWindow().pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'ReasonDialog'), {
title: i18nc("@title:dialog", "Remove Message"),
placeholder: i18nc("@info:placeholder", "Reason for removing this message"),
actionText: i18nc("@action:button 'Remove' as in 'Remove this message'", "Remove"),
icon: "delete"
}, {
title: i18nc("@title:dialog", "Remove Message"),
width: Kirigami.Units.gridUnit * 25
});
dialog.accepted.connect(reason => {
currentRoom.redactEvent(root.eventId, reason);
});
}
},
DelegateContextMenu.ReportMessageAction {},
DelegateContextMenu.ShowUserAction {},
Kirigami.Action {
separator: true
visible: viewSourceAction.visible
},
DelegateContextMenu.ViewSourceAction {
id: viewSourceAction
}
]
/**
* @brief The list of menu item actions that have sub-actions.
*
* Each action will be instantiated as a single line that opens a sub menu.
*/
property list<Kirigami.Action> nestedActions: [
ShareAction {
id: shareAction
inputData: {
"urls": [filename],
"mimeType": [root.mimeType]
}
room: currentRoom
eventId: root.eventId
property string filename: Core.StandardPaths.writableLocation(Core.StandardPaths.CacheLocation) + "/" + eventId.replace(":", "_").replace("/", "_").replace("+", "_") + currentRoom.fileNameToDownload(eventId)
}
]
Component {
id: saveAsDialog
Dialogs.FileDialog {
fileMode: Dialogs.FileDialog.SaveFile
currentFolder: NeoChatConfig.lastSaveDirectory.length > 0 ? NeoChatConfig.lastSaveDirectory : Core.StandardPaths.writableLocation(Core.StandardPaths.DownloadLocation)
onAccepted: {
if (!selectedFile) {
return;
}
NeoChatConfig.lastSaveDirectory = currentFolder;
NeoChatConfig.save();
currentRoom.downloadFile(eventId, selectedFile);
}
NeoChatConfig.lastSaveDirectory = currentFolder;
NeoChatConfig.save();
currentRoom.downloadFile(eventId, selectedFile);
}
}
}

View File

@@ -22,7 +22,7 @@ Labs.MenuBar {
Labs.MenuItem {
enabled: pageStack.layers.currentItem.title !== i18n("Configure NeoChat…")
text: i18nc("menu", "Configure NeoChat")
text: i18nc("menu", "Configure NeoChat...")
shortcut: StandardKey.Preferences
onTriggered: NeoChatSettingsView.open()

View File

@@ -89,7 +89,7 @@ ColumnLayout {
}
onClicked: {
let map = Qt.createComponent('org.kde.neochat', 'QrCodeMaximizeComponent').createObject(QQC2.Overlay.overlay, {
let map = Qt.createComponent('org.kde.neochat', 'QrCodeMaximizeComponent').createObject(parent, {
text: barcode.content,
title: root.room ? root.room.displayName : "",
subtitle: root.room ? root.room.id : "",

View File

@@ -63,7 +63,7 @@ Kirigami.Page {
when: root.session.state === KeyVerificationSession.READY
PropertyChanges {
target: stateLoader
sourceComponent: chooseVerificationComponent
sourceComponent: emojiVerificationComponent
}
},
State {
@@ -152,22 +152,15 @@ Kirigami.Page {
}
Component {
id: chooseVerificationComponent
Item {
ColumnLayout {
anchors.centerIn: parent
spacing: Kirigami.Units.largeSpacing
QQC2.Label {
text: i18nc("@info", "Choose a verification method to continue")
}
QQC2.Button {
id: emojiVerification
text: i18nc("@action:button", "Emoji Verification")
icon.name: "smiley"
onClicked: root.session.sendStartSas()
Layout.alignment: Qt.AlignHCenter
}
id: emojiVerificationComponent
Delegates.RoundedItemDelegate {
id: emojiVerification
text: i18n("Emoji Verification")
contentItem: Delegates.SubtitleContentItem {
subtitle: i18n("Compare a set of emoji on both devices")
itemDelegate: emojiVerification
}
onClicked: root.session.sendStartSas()
}
}
}

View File

@@ -325,13 +325,11 @@ Kirigami.ApplicationWindow {
})
}
function showUserDetail(user, room) {
const dialog = Qt.createComponent("org.kde.neochat", "UserDetailDialog").createObject(root, {
Qt.createComponent("org.kde.neochat", "UserDetailDialog").createObject(root.QQC2.ApplicationWindow.window, {
room: room,
user: user,
connection: root.connection,
});
dialog.parent = QmlUtils.focusedWindowItem(); // Kirigami Dialogs overwrite the parent, so we need to set it again
dialog.open();
connection: root.connection
}).open();
}
function load() {

View File

@@ -32,101 +32,67 @@ DelegateContextMenu {
*/
required property string htmlText
Kirigami.Action {
text: i18n("Edit")
icon.name: "document-edit"
onTriggered: {
currentRoom.editCache.editId = eventId;
currentRoom.mainCache.replyId = "";
currentRoom.mainCache.threadId = "";
}
visible: root.author.isLocalMember && root.messageComponentType === MessageComponentType.Text
}
DelegateContextMenu.ReplyMessageAction {}
QQC2.Action {
text: i18nc("@action:inmenu As in 'Forward this message'", "Forward…")
icon.name: "mail-forward-symbolic"
onTriggered: {
let page = applicationWindow().pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'ChooseRoomDialog'), {
connection: root.connection
}, {
title: i18nc("@title", "Forward Message"),
width: Kirigami.Units.gridUnit * 25
});
page.chosen.connect(function (targetRoomId) {
root.connection.room(targetRoomId).postHtmlMessage(root.plainText, root.htmlText.length > 0 ? root.htmlText : root.plainText);
page.closeDialog();
});
}
}
Kirigami.Action {
separator: true
}
DelegateContextMenu.RemoveMessageAction {}
Kirigami.Action {
text: i18nc("@action:inmenu", "Copy Link Address")
icon.name: "edit-copy"
visible: root.hoveredLink.length > 0
onTriggered: Clipboard.saveText(root.hoveredLink)
}
QQC2.Action {
text: i18nc("@action:inmenu", "Copy Text")
icon.name: "edit-copy"
onTriggered: Clipboard.saveText(root.selectedText.length > 0 ? root.selectedText : root.plainText)
}
QQC2.Action {
text: i18nc("@action:inmenu", "Copy Message Link")
icon.name: "edit-copy"
onTriggered: {
Clipboard.saveText("https://matrix.to/#/" + currentRoom.id + "/" + root.eventId);
}
}
Kirigami.Action {
separator: true
}
DelegateContextMenu.ReportMessageAction {}
DelegateContextMenu.ShowUserAction {}
Kirigami.Action {
separator: true
visible: viewSourceAction.visible
}
DelegateContextMenu.ViewSourceAction {
id: viewSourceAction
}
Kirigami.Action {
separator: true
visible: webShortcutModel.enabled
}
Kirigami.Action {
id: webShortcutModelAction
text: i18n("Search for '%1'", webshortcutModel.trunkatedSearchText)
icon.name: "search-symbolic"
visible: webshortcutModel.enabled
readonly property Instantiator instantiator: Instantiator {
model: WebShortcutModel {
id: webshortcutModel
selectedText: root.selectedText.length > 0 ? root.selectedText : root.plainText
onOpenUrl: url => RoomManager.resolveResource(url.toString())
actions: [
Kirigami.Action {
text: i18n("Edit")
icon.name: "document-edit"
onTriggered: {
currentRoom.editCache.editId = eventId;
currentRoom.mainCache.replyId = "";
currentRoom.mainCache.threadId = "";
}
delegate: QQC2.Action {
text: model.display
icon.name: model.decoration
onTriggered: webshortcutModel.trigger(model.edit)
visible: root.author.isLocalMember && root.messageComponentType === MessageComponentType.Text
},
DelegateContextMenu.ReplyMessageAction {},
Kirigami.Action {
text: i18nc("@action:inmenu As in 'Forward this message'", "Forward…")
icon.name: "mail-forward-symbolic"
onTriggered: {
let page = applicationWindow().pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'ChooseRoomDialog'), {
connection: root.connection
}, {
title: i18nc("@title", "Forward Message"),
width: Kirigami.Units.gridUnit * 25
});
page.chosen.connect(function (targetRoomId) {
root.connection.room(targetRoomId).postHtmlMessage(root.plainText, root.htmlText.length > 0 ? root.htmlText : root.plainText);
page.closeDialog();
});
}
onObjectAdded: (index, object) => webShortcutModelAction.children.push(object)
},
Kirigami.Action {
separator: true
},
DelegateContextMenu.RemoveMessageAction {},
Kirigami.Action {
text: i18nc("@action:inmenu", "Copy Link Address")
icon.name: "edit-copy"
visible: root.hoveredLink.length > 0
onTriggered: Clipboard.saveText(root.hoveredLink)
},
Kirigami.Action {
text: i18nc("@action:inmenu", "Copy Text")
icon.name: "edit-copy"
onTriggered: Clipboard.saveText(root.selectedText.length > 0 ? root.selectedText : root.plainText)
},
Kirigami.Action {
text: i18nc("@action:inmenu", "Copy Message Link")
icon.name: "edit-copy"
onTriggered: {
Clipboard.saveText("https://matrix.to/#/" + currentRoom.id + "/" + root.eventId);
}
},
Kirigami.Action {
separator: true
},
DelegateContextMenu.ReportMessageAction {},
DelegateContextMenu.ShowUserAction {},
Kirigami.Action {
separator: true
visible: viewSourceAction.visible
},
DelegateContextMenu.ViewSourceAction {
id: viewSourceAction
}
}
Kirigami.Action {
text: i18n("Configure Web Shortcuts…")
icon.name: "configure"
visible: !Controller.isFlatpak && webshortcutModel.enabled
onTriggered: webshortcutModel.configureWebShortcuts()
}
]
}

View File

@@ -29,20 +29,13 @@ Components.AlbumMaximizeComponent {
readonly property var currentProgressInfo: model.data(model.index(content.currentIndex, 0), TimelineMessageModel.ProgressInfoRole)
onCurrentProgressInfoChanged: () => {
if (root.currentProgressInfo) {
root.downloadAction.progress = root.currentProgressInfo.progress / root.currentProgressInfo.total * 100.0;
} else {
root.downloadAction.progress = 0;
}
}
/**
* @brief Whether the delegate is part of a thread timeline.
*/
property bool isThread: false
downloadAction: Components.DownloadAction {
id: downloadAction
onTriggered: {
currentRoom.downloadFile(root.currentEventId, Core.StandardPaths.writableLocation(Core.StandardPaths.CacheLocation) + "/" + root.currentEventId.replace(":", "_").replace("/", "_").replace("+", "_") + currentRoom.fileNameToDownload(root.currentEventId));
}
@@ -69,11 +62,19 @@ Components.AlbumMaximizeComponent {
function onFileTransferProgress(id, progress, total) {
if (id == root.currentEventId) {
root.downloadAction.progress = progress / total * 100.0;
downloadAction.progress = progress / total * 100.0;
}
}
}
Connections {
target: content
function onCurrentIndexChanged() {
downloadAction.progress = currentProgressInfo.progress / currentProgressInfo.total * 100.0;
}
}
leading: RowLayout {
Components.Avatar {
id: userAvatar
@@ -105,7 +106,7 @@ Components.AlbumMaximizeComponent {
onOpened: forceActiveFocus()
onItemRightClicked: RoomManager.viewEventMenu(root.currentEventId, root.currentRoom, root.currentAuthor)
onItemRightClicked: RoomManager.viewEventMenu(root.currentEventId, root.currentRoom)
onSaveItem: {
var dialog = saveAsDialog.createObject(QQC2.Overlay.overlay);

View File

@@ -1,158 +0,0 @@
// SPDX-FileCopyrightText: 2019 Black Hat <bhat@encom.eu.org>
// SPDX-FileCopyrightText: 2020 Carl Schwan <carl@carlschwan.eu>
// SPDX-License-Identifier: GPL-3.0-only
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.components as KirigamiComponents
import org.kde.kirigamiaddons.delegates as Delegates
import org.kde.neochat
import org.kde.neochat.settings
/**
* Context menu when clicking on a room in the room list
*/
KirigamiComponents.ConvergentContextMenu {
id: root
property NeoChatRoom room
required property NeoChatConnection connection
headerContentItem: RowLayout {
id: headerLayout
Layout.fillWidth: true
spacing: Kirigami.Units.largeSpacing
KirigamiComponents.Avatar {
id: avatar
source: room.avatarMediaUrl
name: room.displayName
Layout.preferredWidth: Kirigami.Units.gridUnit + Kirigami.Units.largeSpacing * 2
Layout.preferredHeight: Kirigami.Units.gridUnit + Kirigami.Units.largeSpacing * 2
Layout.alignment: Qt.AlignTop
}
Kirigami.Heading {
level: 5
Layout.fillWidth: true
text: room.displayName
elide: Text.ElideRight
}
}
QQC2.Action {
text: i18n("Mark as Read")
icon.name: "checkmark"
enabled: room.notificationCount > 0
onTriggered: room.markAllMessagesAsRead()
}
Kirigami.Action {
separator: true
}
Kirigami.Action {
text: i18nc("@action:inmenu", "Notifications")
icon.name: "notifications"
Kirigami.Action {
text: i18n("Follow Global Setting")
icon.name: "globe"
checkable: true
autoExclusive: true
checked: room.pushNotificationState === PushNotificationState.Default
enabled: room.pushNotificationState != PushNotificationState.Unknown
onTriggered: {
room.pushNotificationState = PushNotificationState.Default;
}
}
Kirigami.Action {
text: i18nc("As in 'notify for all messages'", "All")
icon.name: "notifications"
checkable: true
autoExclusive: true
checked: room.pushNotificationState === PushNotificationState.All
enabled: room.pushNotificationState != PushNotificationState.Unknown
onTriggered: {
room.pushNotificationState = PushNotificationState.All;
}
}
Kirigami.Action {
text: i18nc("As in 'notify when the user is mentioned or the message contains a set keyword'", "@Mentions and Keywords")
icon.name: "im-user"
checkable: true
autoExclusive: true
checked: room.pushNotificationState === PushNotificationState.MentionKeyword
enabled: room.pushNotificationState != PushNotificationState.Unknown
onTriggered: {
room.pushNotificationState = PushNotificationState.MentionKeyword;
}
}
Kirigami.Action {
text: i18nc("As in 'do not notify for any messages'", "Off")
icon.name: "notifications-disabled"
checkable: true
autoExclusive: true
checked: room.pushNotificationState === PushNotificationState.Mute
enabled: room.pushNotificationState != PushNotificationState.Unknown
onTriggered: {
room.pushNotificationState = PushNotificationState.Mute;
}
}
}
QQC2.Action {
text: room.isFavourite ? i18n("Remove from Favorites") : i18n("Add to Favorites")
icon.name: room.isFavourite ? "rating" : "rating-unrated"
onTriggered: room.isFavourite ? room.removeTag("m.favourite") : room.addTag("m.favourite", 1.0)
}
QQC2.Action {
text: room.isLowPriority ? i18n("Reprioritize") : i18n("Deprioritize")
icon.name: room.isLowPriority ? "arrow-up-symbolic" : "arrow-down-symbolic"
onTriggered: room.isLowPriority ? room.removeTag("m.lowpriority") : room.addTag("m.lowpriority", 1.0)
}
Kirigami.Action {
separator: true
}
QQC2.Action {
text: room.isDirectChat() ? i18nc("@action:inmenu", "Copy user's Matrix ID to Clipboard") : i18nc("@action:inmenu", "Copy Address to Clipboard")
icon.name: "edit-copy"
onTriggered: if (room.isDirectChat()) {
Clipboard.saveText(room.directChatRemoteMember.id);
} else if (room.canonicalAlias.length === 0) {
Clipboard.saveText(room.id);
} else {
Clipboard.saveText(room.canonicalAlias);
}
}
QQC2.Action {
text: i18nc("@action:inmenu", "Room Settings")
icon.name: 'settings-configure-symbolic'
onTriggered: {
RoomSettingsView.openRoomSettings(root.room, RoomSettingsView.Room);
}
}
Kirigami.Action {
separator: true
}
QQC2.Action {
text: i18n("Leave Room")
icon.name: "go-previous"
onTriggered: {
Qt.createComponent('org.kde.neochat', 'ConfirmLeaveDialog').createObject(root.QQC2.ApplicationWindow.window, {
room: root.room
}).open();
}
}
}

View File

@@ -153,12 +153,12 @@ Delegates.RoundedItemDelegate {
}
}
function createRoomListContextMenu(): void {
const component = Qt.createComponent('org.kde.neochat', 'RoomContextMenu');
function createRoomListContextMenu() {
const component = Qt.createComponent('org.kde.neochat', 'ContextMenu');
if (component.status === Component.Error) {
console.error(component.errorString());
}
const menu = component.createObject(root.ListView.view ?? root.treeView, {
const menu = component.createObject(root, {
room: root.currentRoom,
connection: root.connection
});
@@ -172,6 +172,6 @@ Delegates.RoundedItemDelegate {
return root.hovered && !Kirigami.Settings.isMobile && !NeoChatConfig.compactRoomList;
});
});
menu.popup();
menu.open();
}
}

View File

@@ -87,7 +87,6 @@ Kirigami.OverlayDrawer {
Kirigami.Separator {
Layout.fillHeight: true
visible: root.modal
}
ColumnLayout {

View File

@@ -130,24 +130,6 @@ QQC2.ScrollView {
Layout.fillWidth: true
}
Delegates.RoundedItemDelegate {
id: pinnedMessagesButton
visible: !root.room.isSpace
icon.name: "pin-symbolic"
text: i18nc("@action:button", "Pinned messages")
activeFocusOnTab: true
Layout.fillWidth: true
onClicked: {
pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'RoomPinnedMessagesPage'), {
room: root.room
}, {
title: i18nc("@title", "Pinned Messages")
});
}
}
Delegates.RoundedItemDelegate {
id: leaveButton
icon.name: "arrow-left-symbolic"

View File

@@ -255,7 +255,7 @@ Kirigami.Page {
plainText: plainText,
htmlText: htmlText,
});
contextMenu.popup();
contextMenu.open();
}
function onShowFileMenu(eventId, author, messageComponentType, plainText, mimeType, progressInfo, isThread) {
@@ -266,7 +266,7 @@ Kirigami.Page {
mimeType: mimeType,
progressInfo: progressInfo,
});
contextMenu.popup();
contextMenu.open();
}
function onShowMaximizedMedia(index) {
@@ -309,7 +309,6 @@ Kirigami.Page {
NeochatMaximizeComponent {
currentRoom: root.currentRoom
model: root.mediaMessageFilterModel
parent: root.QQC2.Overlay.overlay
}
}
}

View File

@@ -1,64 +0,0 @@
// SPDX-FileCopyrightText: 2025 Joshua Goins <josh@redstrate.com>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.neochat
import org.kde.neochat.timeline
/**
* @brief Component for showing the pinned messages in a room.
*/
Kirigami.ScrollablePage {
id: root
/**
* @brief The room to show the pinned messages for.
*/
required property NeoChatRoom room
title: i18nc("@title", "Pinned Messages")
Kirigami.Theme.colorSet: Kirigami.Theme.Window
Kirigami.Theme.inherit: false
ListView {
id: listView
spacing: 0
model: PinnedMessageModel {
id: pinModel
room: root.room
}
delegate: EventDelegate {
room: root.room
}
section.property: "section"
Kirigami.PlaceholderMessage {
icon.name: "pin-symbolic"
anchors.centerIn: parent
text: i18nc("@info:placeholder", "No Pinned Messages")
visible: listView.count === 0
}
Kirigami.LoadingPlaceholder {
anchors.centerIn: parent
visible: listView.count === 0 && pinModel.loading
}
Keys.onUpPressed: {
if (listView.currentIndex > 0) {
listView.decrementCurrentIndex();
} else {
listView.currentIndex = -1; // This is so the list view doesn't appear to have two selected items
listView.headerItem.forceActiveFocus(Qt.TabFocusReason);
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More