Compare commits

...

1 Commits

Author SHA1 Message Date
Joshua Goins
db2238805b Add support for profile fields
This is client support for MSC4311, which allows us to (finally) set and
read custom profile fields. I think we should take the approach of only
allowing a small subset, so in line with that I added timezone and
pronouns to the account editor.

These settings are hidden or disabled depending of it's supported or
allowed on your server.

Fixes #661
2025-07-08 18:17:38 -04:00
11 changed files with 479 additions and 5 deletions

View File

@@ -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 {

View File

@@ -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

View File

@@ -0,0 +1,20 @@
// SPDX-FileCopyrightText: 2025 Joshua Goins <josh@redstrate.com>
// 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});
}

View File

@@ -0,0 +1,49 @@
// SPDX-FileCopyrightText: 2025 Joshua Goins <josh@redstrate.com>
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <Quotient/jobs/basejob.h>
// 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<QString>(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);
};

View File

@@ -0,0 +1,48 @@
// SPDX-FileCopyrightText: 2025 Joshua Goins <josh@redstrate.com>
// SPDX-License-Identifier: GPL-2.0-or-later
#include "timezonemodel.h"
#include <KLocalizedString>
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"

View File

@@ -0,0 +1,24 @@
// SPDX-FileCopyrightText: 2025 Joshua Goins <josh@redstrate.com>
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <QAbstractListModel>
#include <QQmlEngine>
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<QByteArray> m_timezoneIds;
};

View File

@@ -6,6 +6,7 @@
#include <QImageReader>
#include <QJsonDocument>
#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<NeoChatGetProfileFieldJob>(BackgroundRequest, userId(), QStringLiteral("us.cloke.msc4175.tz")).then([this](const auto &job) {
m_timezone = job->value();
Q_EMIT timezoneChanged();
});
callApi<NeoChatGetProfileFieldJob>(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<NeoChatSetProfileFieldJob>(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<NeoChatSetProfileFieldJob>(BackgroundRequest,
userId(),
QStringLiteral("io.fsky.nyx.pronouns"),
QString::fromUtf8(QJsonDocument(pronounsObj).toJson(QJsonDocument::Compact)));
}
#include "moc_neochatconnection.cpp"

View File

@@ -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;
};

View File

@@ -0,0 +1,110 @@
// SPDX-FileCopyrightText: 2025 Joshua Goins <josh@redstrate.com>
// 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 <Quotient/csapi/profile.h>
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<NeoChatGetProfileFieldJob>(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<NeoChatGetProfileFieldJob>(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();
}

View File

@@ -0,0 +1,79 @@
// SPDX-FileCopyrightText: 2025 Joshua Goins <josh@redstrate.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#pragma once
#include <QObject>
#include <QQmlEngine>
#include <Quotient/jobs/basejob.h>
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<NeoChatConnection> m_connection;
QString m_userId;
bool m_loading = true;
QString m_timezone;
bool m_fetchedTimezone = false;
QString m_pronouns;
bool m_fetchedPronouns = false;
};

View File

@@ -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;
}
}
}
}