diff --git a/src/app/qml/UserDetailDialog.qml b/src/app/qml/UserDetailDialog.qml index d1e18b0d0..d84c0e80d 100644 --- a/src/app/qml/UserDetailDialog.qml +++ b/src/app/qml/UserDetailDialog.qml @@ -23,6 +23,11 @@ Kirigami.Dialog { property NeoChatConnection connection + readonly property ProfileFieldsHelper profileFieldsHelper: ProfileFieldsHelper { + connection: root.connection + userId: root.user.id + } + leftPadding: 0 rightPadding: 0 topPadding: 0 @@ -126,14 +131,38 @@ Kirigami.Dialog { } } - Kirigami.Chip { - visible: root.room - text: root.room ? QmlUtils.nameForPowerLevelValue(root.room.memberEffectivePowerLevel(root.user.id)) : "" - closable: false - checkable: false + RowLayout { + spacing: Kirigami.Units.smallSpacing Layout.leftMargin: Kirigami.Units.largeSpacing Layout.bottomMargin: Kirigami.Units.largeSpacing + + Kirigami.Chip { + visible: root.room + text: root.room ? QmlUtils.nameForPowerLevelValue(root.room.memberEffectivePowerLevel(root.user.id)) : "" + closable: false + checkable: false + } + + QQC2.BusyIndicator { + visible: root.connection.supportsProfileFields && root.profileFieldsHelper.loading + } + + Kirigami.Chip { + id: timezoneChip + visible: root.connection.supportsProfileFields && !root.profileFieldsHelper.loading && root.profileFieldsHelper.timezone.length > 0 + text: root.profileFieldsHelper.timezone + closable: false + checkable: false + } + + Kirigami.Chip { + id: pronounsChip + visible: root.connection.supportsProfileFields && !root.profileFieldsHelper.loading && root.profileFieldsHelper.pronouns.length > 0 + text: root.profileFieldsHelper.pronouns + closable: false + checkable: false + } } Kirigami.Separator { diff --git a/src/libneochat/CMakeLists.txt b/src/libneochat/CMakeLists.txt index a3a0229a0..14b9392ee 100644 --- a/src/libneochat/CMakeLists.txt +++ b/src/libneochat/CMakeLists.txt @@ -22,6 +22,7 @@ target_sources(LibNeoChat PRIVATE texthandler.cpp urlhelper.cpp utils.cpp + profilefieldshelper.cpp enums/messagecomponenttype.h enums/messagetype.h enums/powerlevel.cpp @@ -32,6 +33,7 @@ target_sources(LibNeoChat PRIVATE events/imagepackevent.cpp events/pollevent.cpp jobs/neochatgetcommonroomsjob.cpp + jobs/neochatprofilefieldjobs.cpp models/actionsmodel.cpp models/completionmodel.cpp models/completionproxymodel.cpp @@ -44,6 +46,8 @@ target_sources(LibNeoChat PRIVATE models/stickermodel.cpp models/userfiltermodel.cpp models/userlistmodel.cpp + models/timezonemodel.cpp + models/timezonemodel.h ) ecm_add_qml_module(LibNeoChat GENERATE_PLUGIN_SOURCE diff --git a/src/libneochat/jobs/neochatprofilefieldjobs.cpp b/src/libneochat/jobs/neochatprofilefieldjobs.cpp new file mode 100644 index 000000000..d69c00896 --- /dev/null +++ b/src/libneochat/jobs/neochatprofilefieldjobs.cpp @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2025 Joshua Goins +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "neochatprofilefieldjobs.h" + +using namespace Quotient; + +NeoChatGetProfileFieldJob::NeoChatGetProfileFieldJob(const QString &userId, const QString &key) + : BaseJob(HttpVerb::Get, u"GetProfileFieldJob"_s, makePath("/_matrix/client/unstable/uk.tcpip.msc4133", "/profile/", userId, "/", key)) + , m_key(key) +{ +} + +NeoChatSetProfileFieldJob::NeoChatSetProfileFieldJob(const QString &userId, const QString &key, const QString &value) + : BaseJob(HttpVerb::Put, u"SetProfileFieldJob"_s, makePath("/_matrix/client/unstable/uk.tcpip.msc4133", "/profile/", userId, "/", key)) +{ + QJsonObject _dataJson; + addParam(_dataJson, key, value); + setRequestData({_dataJson}); +} diff --git a/src/libneochat/jobs/neochatprofilefieldjobs.h b/src/libneochat/jobs/neochatprofilefieldjobs.h new file mode 100644 index 000000000..db38ee98f --- /dev/null +++ b/src/libneochat/jobs/neochatprofilefieldjobs.h @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2025 Joshua Goins +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include + +// NOTE: This is currently being upstreamed to libQuotient, awaiting MSC4133: https://github.com/quotient-im/libQuotient/pull/869 + +//! \brief Get a user's profile field. +//! +//! Get one of the user's profile fields. This API may be used to fetch the user's +//! own profile field or to query the profile field of other users; either locally or +//! on remote homeservers. +class QUOTIENT_API NeoChatGetProfileFieldJob : public Quotient::BaseJob +{ +public: + //! \param userId + //! The user whose profile field to query. + //! \param key + //! The key of the profile field. + explicit NeoChatGetProfileFieldJob(const QString &userId, const QString &key); + + // Result properties + + //! The value of the profile field. + QString value() const + { + return loadFromJson(m_key); + } + +private: + QString m_key; +}; + +//! \brief Sets a user's profile field. +//! +//! Set one of the user's own profile fields. This may fail depending on if the server allows the +//! user to change their own profile field, or if the field isn't allowed. +class QUOTIENT_API NeoChatSetProfileFieldJob : public Quotient::BaseJob +{ +public: + //! \param userId + //! The user whose avatar URL to set. + //! + //! \param avatarUrl + //! The new avatar URL for this user. + explicit NeoChatSetProfileFieldJob(const QString &userId, const QString &key, const QString &value); +}; diff --git a/src/libneochat/models/timezonemodel.cpp b/src/libneochat/models/timezonemodel.cpp new file mode 100644 index 000000000..326af4c7a --- /dev/null +++ b/src/libneochat/models/timezonemodel.cpp @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2025 Joshua Goins +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "timezonemodel.h" + +#include + +using namespace Qt::Literals::StringLiterals; + +TimeZoneModel::TimeZoneModel(QObject *parent) + : QAbstractListModel(parent) +{ + m_timezoneIds = QTimeZone::availableTimeZoneIds(); +} + +QVariant TimeZoneModel::data(const QModelIndex &index, int role) const +{ + switch (role) { + case Qt::DisplayRole: { + if (index.row() == 0) { + return i18nc("@item:inlistbox Prefer not to say which timezone im in", "Prefer not to say"); + } else { + return m_timezoneIds[index.row() - 1]; + } + } + default: + return {}; + } +} + +int TimeZoneModel::rowCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent); + + return m_timezoneIds.count() + 1; +} + +int TimeZoneModel::indexOfValue(const QString &code) +{ + const auto it = std::ranges::find(std::as_const(m_timezoneIds), code.toUtf8()); + if (it != m_timezoneIds.cend()) { + return std::distance(m_timezoneIds.cbegin(), it) + 1; + } else { + return 0; + } +} + +#include "moc_timezonemodel.cpp" diff --git a/src/libneochat/models/timezonemodel.h b/src/libneochat/models/timezonemodel.h new file mode 100644 index 000000000..14536ff23 --- /dev/null +++ b/src/libneochat/models/timezonemodel.h @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2025 Joshua Goins +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include + +class TimeZoneModel : public QAbstractListModel +{ + Q_OBJECT + QML_ELEMENT + +public: + explicit TimeZoneModel(QObject *parent = nullptr); + + [[nodiscard]] QVariant data(const QModelIndex &index, int role) const override; + [[nodiscard]] int rowCount(const QModelIndex &parent) const override; + + Q_INVOKABLE int indexOfValue(const QString &code); + +private: + QList m_timezoneIds; +}; diff --git a/src/libneochat/neochatconnection.cpp b/src/libneochat/neochatconnection.cpp index b4ed79d55..a130ffa86 100644 --- a/src/libneochat/neochatconnection.cpp +++ b/src/libneochat/neochatconnection.cpp @@ -6,6 +6,7 @@ #include #include +#include "jobs/neochatprofilefieldjobs.h" #include "neochatroom.h" #include "spacehierarchycache.h" @@ -139,6 +140,19 @@ void NeoChatConnection::connectSignals() Q_EMIT canCheckMutualRoomsChanged(); m_canEraseData = job->unstableFeatures().contains("org.matrix.msc4025"_L1) || job->versions().count("v1.10"_L1); Q_EMIT canEraseDataChanged(); + m_supportsProfileFields = job->unstableFeatures().contains("uk.tcpip.msc4133"_L1); + Q_EMIT supportsProfileFieldsChanged(); + + if (m_supportsProfileFields) { + callApi(BackgroundRequest, userId(), QStringLiteral("us.cloke.msc4175.tz")).then([this](const auto &job) { + m_timezone = job->value(); + Q_EMIT timezoneChanged(); + }); + callApi(BackgroundRequest, userId(), QStringLiteral("io.fsky.nyx.pronouns")).then([this](const auto &job) { + m_pronouns = job->value(); + Q_EMIT pronounsChanged(); + }); + } }); }, Qt::SingleShotConnection); @@ -558,4 +572,33 @@ bool NeoChatConnection::enablePushNotifications() const return m_pushNotificationsEnabled; } +bool NeoChatConnection::supportsProfileFields() const +{ + return m_supportsProfileFields; +} + +QString NeoChatConnection::timezone() const +{ + return m_timezone; +} + +void NeoChatConnection::setTimezone(const QString &value) +{ + callApi(BackgroundRequest, userId(), QStringLiteral("us.cloke.msc4175.tz"), value); +} + +QString NeoChatConnection::pronouns() const +{ + return m_pronouns; +} + +void NeoChatConnection::setPronouns(const QString &value) +{ + const QJsonObject pronounsObj{{"summary"_L1, value}}; + callApi(BackgroundRequest, + userId(), + QStringLiteral("io.fsky.nyx.pronouns"), + QString::fromUtf8(QJsonDocument(pronounsObj).toJson(QJsonDocument::Compact))); +} + #include "moc_neochatconnection.cpp" diff --git a/src/libneochat/neochatconnection.h b/src/libneochat/neochatconnection.h index 84b22bbf5..2322c1813 100644 --- a/src/libneochat/neochatconnection.h +++ b/src/libneochat/neochatconnection.h @@ -90,6 +90,21 @@ class NeoChatConnection : public Quotient::Connection */ Q_PROPERTY(bool enablePushNotifications READ enablePushNotifications NOTIFY enablePushNotificationsChanged) + /** + * @brief If the server supports profile fields (MSC4133) + */ + Q_PROPERTY(bool supportsProfileFields READ supportsProfileFields NOTIFY supportsProfileFieldsChanged) + + /** + * @brief The timezone profile field for this account. + */ + Q_PROPERTY(QString timezone READ timezone WRITE setTimezone NOTIFY timezoneChanged) + + /** + * @brief The pronouns profile field for this account. + */ + Q_PROPERTY(QString pronouns READ pronouns WRITE setPronouns NOTIFY pronounsChanged) + public: /** * @brief Defines the status after an attempt to change the password on an account. @@ -204,6 +219,14 @@ public: bool pushNotificationsAvailable() const; bool enablePushNotifications() const; + bool supportsProfileFields() const; + + QString timezone() const; + void setTimezone(const QString &value); + + QString pronouns() const; + void setPronouns(const QString &value); + LinkPreviewer *previewerForLink(const QUrl &link); Q_SIGNALS: @@ -221,6 +244,9 @@ Q_SIGNALS: void canCheckMutualRoomsChanged(); void canEraseDataChanged(); void enablePushNotificationsChanged(); + void supportsProfileFieldsChanged(); + void timezoneChanged(); + void pronounsChanged(); /** * @brief Request a message be shown to the user of the given type. @@ -250,4 +276,7 @@ private: bool m_canCheckMutualRooms = false; bool m_canEraseData = false; bool m_pushNotificationsEnabled = false; + bool m_supportsProfileFields = false; + QString m_timezone; + QString m_pronouns; }; diff --git a/src/libneochat/profilefieldshelper.cpp b/src/libneochat/profilefieldshelper.cpp new file mode 100644 index 000000000..bddb78529 --- /dev/null +++ b/src/libneochat/profilefieldshelper.cpp @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: 2025 Joshua Goins +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#include "profilefieldshelper.h" +#include "jobs/neochatprofilefieldjobs.h" +#include "neochatconnection.h" + +#include + +using namespace Quotient; + +NeoChatConnection *ProfileFieldsHelper::connection() const +{ + return m_connection.get(); +} + +void ProfileFieldsHelper::setConnection(NeoChatConnection *connection) +{ + if (m_connection != connection) { + m_connection = connection; + Q_EMIT connectionChanged(); + load(); + } +} + +QString ProfileFieldsHelper::userId() const +{ + return m_userId; +} + +void ProfileFieldsHelper::setUserId(const QString &id) +{ + if (m_userId != id) { + m_userId = id; + Q_EMIT userIdChanged(); + load(); + } +} + +QString ProfileFieldsHelper::timezone() const +{ + if (m_timezone.isEmpty()) { + return {}; + } + return QTimeZone(m_timezone.toUtf8()).displayName(QTimeZone::GenericTime); +} + +QString ProfileFieldsHelper::pronouns() const +{ + return m_pronouns; +} + +bool ProfileFieldsHelper::loading() const +{ + return m_loading; +} + +void ProfileFieldsHelper::load() +{ + if (!m_connection || m_userId.isEmpty()) { + return; + } + + if (!m_connection->supportsProfileFields()) { + setLoading(false); + return; + } + + setLoading(true); + + m_connection->callApi(BackgroundRequest, m_userId, QStringLiteral("us.cloke.msc4175.tz")) + .then( + [this](const auto &job) { + m_timezone = job->value(); + Q_EMIT timezoneChanged(); + m_fetchedTimezone = true; + checkIfFinished(); + }, + [this] { + m_fetchedTimezone = true; + checkIfFinished(); + }); + + m_connection->callApi(BackgroundRequest, m_userId, QStringLiteral("io.fsky.nyx.pronouns")) + .then( + [this](const auto &job) { + const QJsonDocument document = QJsonDocument::fromJson(job->value().toUtf8()); + m_pronouns = document["summary"_L1].toString(); + Q_EMIT pronounsChanged(); + m_fetchedPronouns = true; + checkIfFinished(); + }, + [this] { + m_fetchedPronouns = true; + checkIfFinished(); + }); +} + +void ProfileFieldsHelper::checkIfFinished() +{ + if (m_fetchedTimezone && m_fetchedPronouns) { + setLoading(false); + } +} + +void ProfileFieldsHelper::setLoading(const bool loading) +{ + m_loading = loading; + Q_EMIT loadingChanged(); +} diff --git a/src/libneochat/profilefieldshelper.h b/src/libneochat/profilefieldshelper.h new file mode 100644 index 000000000..3577e9495 --- /dev/null +++ b/src/libneochat/profilefieldshelper.h @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: 2025 Joshua Goins +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#pragma once + +#include +#include + +#include + +class NeoChatConnection; + +/** + * @class ProfileFieldsHelper + * + * This class is designed to help grabbing the profile fields of a user. + */ +class ProfileFieldsHelper : public QObject +{ + Q_OBJECT + QML_ELEMENT + + /** + * @brief The connection to use. + */ + Q_PROPERTY(NeoChatConnection *connection READ connection WRITE setConnection NOTIFY connectionChanged REQUIRED) + + /** + * @brief The id of the user to grab profile fields from. + */ + Q_PROPERTY(QString userId READ userId WRITE setUserId NOTIFY userIdChanged REQUIRED) + + /** + * @brief The timezone field of the user. + */ + Q_PROPERTY(QString timezone READ timezone NOTIFY timezoneChanged) + + /** + * @brief The pronouns field of the user. + */ + Q_PROPERTY(QString pronouns READ pronouns NOTIFY pronounsChanged) + + /** + * @brief If the fields are loading. + */ + Q_PROPERTY(bool loading READ loading NOTIFY loadingChanged) + +public: + [[nodiscard]] NeoChatConnection *connection() const; + void setConnection(NeoChatConnection *connection); + + [[nodiscard]] QString userId() const; + void setUserId(const QString &id); + + [[nodiscard]] QString timezone() const; + [[nodiscard]] QString pronouns() const; + + [[nodiscard]] bool loading() const; + +Q_SIGNALS: + void connectionChanged(); + void userIdChanged(); + void timezoneChanged(); + void pronounsChanged(); + void loadingChanged(); + +private: + void load(); + void checkIfFinished(); + void setLoading(bool loading); + + QPointer m_connection; + QString m_userId; + bool m_loading = true; + QString m_timezone; + bool m_fetchedTimezone = false; + QString m_pronouns; + bool m_fetchedPronouns = false; +}; diff --git a/src/settings/AccountEditorPage.qml b/src/settings/AccountEditorPage.qml index 4383b6e07..4f35266cb 100644 --- a/src/settings/AccountEditorPage.qml +++ b/src/settings/AccountEditorPage.qml @@ -115,6 +115,39 @@ FormCard.FormCardPage { text: root.connection ? root.connection.label : "" } FormCard.FormDelegateSeparator {} + FormCard.FormComboBoxDelegate { + id: timezoneLabel + + property string textValue: root.connection ? root.connection.timezone : "" + + visible: root.connection.supportsProfileFields + enabled: root.connection.canChangeProfileFields && root.connection.profileFieldAllowed("us.cloke.msc4175.tz") + text: i18nc("@label:combobox", "Timezone:") + model: TimeZoneModel {} + textRole: "display" + valueRole: "display" + onActivated: index => { + // "Prefer not to say" choice clears it. + if (index === 0) { + textValue = ""; + return; + } + + // Otherwise, set it to the text value which is the IANA identifier + textValue = timezoneLabel.currentValue; + } + Component.onCompleted: currentIndex = model.indexOfValue(textValue) + } + FormCard.FormDelegateSeparator {} + FormCard.FormTextFieldDelegate { + id: pronounsLabel + visible: root.connection.supportsProfileFields + enabled: root.connection.canChangeProfileFields && root.connection.profileFieldAllowed("io.fsky.nyx.pronouns") + label: i18nc("@label:textbox", "Pronouns:") + placeholderText: i18nc("@placeholder", "she/her") + text: root.connection ? root.connection.pronouns : "" + } + FormCard.FormDelegateSeparator {} FormCard.FormButtonDelegate { text: i18nc("@action:button", "Show QR Code") icon.name: "view-barcode-qr-symbolic" @@ -146,6 +179,12 @@ FormCard.FormCardPage { if (root.connection.label !== accountLabel.text) { root.connection.label = accountLabel.text; } + if (root.connection.timezone !== timezoneLabel.textValue) { + root.connection.timezone = timezoneLabel.textValue; + } + if (root.connection.pronouns !== pronounsLabel.text) { + root.connection.pronouns = pronounsLabel.text; + } } } }