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
18 changed files with 749 additions and 113 deletions

View File

@@ -12,6 +12,8 @@ add_library(neochat STATIC
models/userdirectorylistmodel.h
notificationsmanager.cpp
notificationsmanager.h
blurhash.cpp
blurhash.h
blurhashimageprovider.cpp
blurhashimageprovider.h
windowcontroller.cpp

198
src/app/blurhash.cpp Normal file
View File

@@ -0,0 +1,198 @@
// SPDX-FileCopyrightText: 2018 Wolt Enterprises
// SPDX-License-Identifier: MIT
#include "blurhash.h"
#include <math.h>
#include <stdbool.h>
#include <stdlib.h>
#include <string.h>
#include <vector>
namespace
{
const char chars[] = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~";
struct Color {
float r = 0;
float g = 0;
float b = 0;
};
inline int linearTosRGB(float value)
{
float v = fmaxf(0, fminf(1, value));
if (v <= 0.0031308)
return v * 12.92 * 255 + 0.5;
else
return (1.055 * powf(v, 1 / 2.4) - 0.055) * 255 + 0.5;
}
inline float sRGBToLinear(int value)
{
float v = (float)value / 255;
if (v <= 0.04045)
return v / 12.92;
else
return powf((v + 0.055) / 1.055, 2.4);
}
inline float signPow(float value, float exp)
{
return copysignf(powf(fabsf(value), exp), value);
}
inline uint8_t clampToUByte(int *src)
{
if (*src >= 0 && *src <= 255) {
return *src;
}
return (*src < 0) ? 0 : 255;
}
inline uint8_t *createByteArray(int size)
{
return (uint8_t *)malloc(size * sizeof(uint8_t));
}
int decodeToInt(const char *string, int start, int end)
{
int value = 0;
for (int iter1 = start; iter1 < end; iter1++) {
int index = -1;
for (int iter2 = 0; iter2 < 83; iter2++) {
if (chars[iter2] == string[iter1]) {
index = iter2;
break;
}
}
if (index == -1) {
return -1;
}
value = value * 83 + index;
}
return value;
}
void decodeDC(int value, Color *color)
{
color->r = sRGBToLinear(value >> 16);
color->g = sRGBToLinear((value >> 8) & 255);
color->b = sRGBToLinear(value & 255);
}
void decodeAC(int value, float maximumValue, Color *color)
{
int quantR = (int)floorf(value / (19 * 19));
int quantG = (int)floorf(value / 19) % 19;
int quantB = (int)value % 19;
color->r = signPow(((float)quantR - 9) / 9, 2.0) * maximumValue;
color->g = signPow(((float)quantG - 9) / 9, 2.0) * maximumValue;
color->b = signPow(((float)quantB - 9) / 9, 2.0) * maximumValue;
}
int decodeToArray(const char *blurhash, int width, int height, int punch, int nChannels, uint8_t *pixelArray)
{
if (!isValidBlurhash(blurhash)) {
return -1;
}
if (punch < 1) {
punch = 1;
}
int sizeFlag = decodeToInt(blurhash, 0, 1);
int numY = (int)floorf(sizeFlag / 9) + 1;
int numX = (sizeFlag % 9) + 1;
int iter = 0;
Color color;
int quantizedMaxValue = decodeToInt(blurhash, 1, 2);
if (quantizedMaxValue == -1) {
return -1;
}
const float maxValue = ((float)(quantizedMaxValue + 1)) / 166;
const int colors_size = numX * numY;
std::vector<Color> colors(colors_size, {0, 0, 0});
for (iter = 0; iter < colors_size; iter++) {
if (iter == 0) {
int value = decodeToInt(blurhash, 2, 6);
if (value == -1) {
return -1;
}
decodeDC(value, &color);
colors[iter] = color;
} else {
int value = decodeToInt(blurhash, 4 + iter * 2, 6 + iter * 2);
if (value == -1) {
return -1;
}
decodeAC(value, maxValue * punch, &color);
colors[iter] = color;
}
}
int bytesPerRow = width * nChannels;
int x = 0, y = 0, i = 0, j = 0;
int intR = 0, intG = 0, intB = 0;
for (y = 0; y < height; y++) {
for (x = 0; x < width; x++) {
float r = 0, g = 0, b = 0;
for (j = 0; j < numY; j++) {
for (i = 0; i < numX; i++) {
float basics = cos((M_PI * x * i) / width) * cos((M_PI * y * j) / height);
int idx = i + j * numX;
r += colors[idx].r * basics;
g += colors[idx].g * basics;
b += colors[idx].b * basics;
}
}
intR = linearTosRGB(r);
intG = linearTosRGB(g);
intB = linearTosRGB(b);
pixelArray[nChannels * x + 0 + y * bytesPerRow] = clampToUByte(&intR);
pixelArray[nChannels * x + 1 + y * bytesPerRow] = clampToUByte(&intG);
pixelArray[nChannels * x + 2 + y * bytesPerRow] = clampToUByte(&intB);
if (nChannels == 4) {
pixelArray[nChannels * x + 3 + y * bytesPerRow] = 255;
}
}
}
return 0;
}
}
uint8_t *decode(const char *blurhash, int width, int height, int punch, int nChannels)
{
int bytesPerRow = width * nChannels;
uint8_t *pixelArray = createByteArray(bytesPerRow * height);
if (decodeToArray(blurhash, width, height, punch, nChannels, pixelArray) == -1) {
return nullptr;
}
return pixelArray;
}
bool isValidBlurhash(const char *blurhash)
{
const int hashLength = strlen(blurhash);
if (!blurhash || strlen(blurhash) < 6) {
return false;
}
int sizeFlag = decodeToInt(blurhash, 0, 1);
int numY = (int)floorf(sizeFlag / 9) + 1;
int numX = (sizeFlag % 9) + 1;
return hashLength == 4 + 2 * numX * numY;
}

28
src/app/blurhash.h Normal file
View File

@@ -0,0 +1,28 @@
// SPDX-FileCopyrightText: 2018 Wolt Enterprises
// SPDX-License-Identifier: MIT
#pragma once
#include <stdint.h>
/**
* @brief Returns the pixel array of the result image given the blurhash string.
*
* @param blurhash a string representing the blurhash to be decoded.
* @param width the width of the resulting image.
* @param height the height of the resulting image.
* @param punch the factor to improve the contrast, default = 1.
* @param nChannels the number of channels in the resulting image array, 3 = RGB, 4 = RGBA.
*
* @return A pointer to memory region where pixels are stored in (H, W, C) format.
*/
uint8_t *decode(const char *blurhash, int width, int height, int punch, int nChannels);
/**
* @brief Checks if the Blurhash is valid or not.
*
* @param blurhash a string representing the blurhash.
*
* @return A bool (true if it is a valid blurhash, else false).
*/
bool isValidBlurhash(const char *blurhash);

View File

@@ -1,97 +1,31 @@
// SPDX-FileCopyrightText: 2024 Joshua Goins <josh@redstrate.com>
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2021 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: LGPL-2.0-or-later
#include "blurhashimageprovider.h"
#include <Quotient/blurhash.h>
#include <QImage>
#include <QString>
/*
* Qt unfortunately re-encodes the base83 string in QML.
* The only special ASCII characters used in the blurhash base83 string are:
* #$%*+,-.:;=?@[]^_{|}~
* QUrl::fromPercentEncoding is too greedy, and spits out invalid characters
* for parts of valid base83 like %14.
*/
// clang-format off
static const QMap<QLatin1String, QLatin1String> knownEncodings = {
{QLatin1String("%23A"), QLatin1String(":")},
{QLatin1String("%3F"), QLatin1String("?")},
{QLatin1String("%23"), QLatin1String("#")},
{QLatin1String("%5B"), QLatin1String("[")},
{QLatin1String("%5D"), QLatin1String("]")},
{QLatin1String("%40"), QLatin1String("@")},
{QLatin1String("%24"), QLatin1String("$")},
{QLatin1String("%2A"), QLatin1String("*")},
{QLatin1String("%2B"), QLatin1String("+")},
{QLatin1String("%2C"), QLatin1String(",")},
{QLatin1String("%2D"), QLatin1String("-")},
{QLatin1String("%2E"), QLatin1String(".")},
{QLatin1String("%3D"), QLatin1String("=")},
{QLatin1String("%25"), QLatin1String("%")},
{QLatin1String("%5E"), QLatin1String("^")},
{QLatin1String("%7C"), QLatin1String("|")},
{QLatin1String("%7B"), QLatin1String("{")},
{QLatin1String("%7D"), QLatin1String("}")},
{QLatin1String("%7E"), QLatin1String("~")},
};
// clang-format on
#include "blurhash.h"
class AsyncImageResponseRunnable : public QObject, public QRunnable
BlurhashImageProvider::BlurhashImageProvider()
: QQuickImageProvider(QQuickImageProvider::Image)
{
Q_OBJECT
}
Q_SIGNALS:
void done(QImage image);
public:
AsyncImageResponseRunnable(const QString &id, const QSize &requestedSize)
: m_id(id)
, m_requestedSize(requestedSize)
{
if (m_requestedSize.width() == -1)
m_requestedSize.setWidth(64);
if (m_requestedSize.height() == -1)
m_requestedSize.setHeight(64);
QImage BlurhashImageProvider::requestImage(const QString &id, QSize *size, const QSize &requestedSize)
{
if (id.isEmpty()) {
return QImage();
}
void run() override
{
if (m_id.isEmpty())
return;
QString decodedId = m_id;
for (auto i = knownEncodings.constBegin(); i != knownEncodings.constEnd(); ++i)
decodedId.replace(i.key(), i.value());
Q_EMIT done(Quotient::BlurHash::decode(decodedId, m_requestedSize));
*size = requestedSize;
if (size->width() == -1) {
size->setWidth(256);
}
private:
QString m_id;
QSize m_requestedSize;
};
AsyncImageResponse::AsyncImageResponse(const QString &id, const QSize &requestedSize, QThreadPool *pool)
{
const auto runnable = new AsyncImageResponseRunnable(id, requestedSize);
connect(runnable, &AsyncImageResponseRunnable::done, this, &AsyncImageResponse::handleDone);
pool->start(runnable);
}
void AsyncImageResponse::handleDone(QImage image)
{
m_image = std::move(image);
Q_EMIT finished();
}
QQuickTextureFactory *AsyncImageResponse::textureFactory() const
{
return QQuickTextureFactory::textureFactoryForImage(m_image);
}
QQuickImageResponse *BlurHashImageProvider::requestImageResponse(const QString &id, const QSize &requestedSize)
{
return new AsyncImageResponse(id, requestedSize, &pool);
}
#include "blurhashimageprovider.moc"
if (size->height() == -1) {
size->setHeight(256);
}
auto data = decode(QUrl::fromPercentEncoding(id.toLatin1()).toLatin1().data(), size->width(), size->height(), 1, 3);
QImage image(data, size->width(), size->height(), size->width() * 3, QImage::Format_RGB888, free, data);
return image;
}

View File

@@ -1,25 +1,26 @@
// SPDX-FileCopyrightText: 2024 Joshua Goins <josh@redstrate.com>
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2021 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: LGPL-2.0-or-later
#pragma once
#include <QQuickAsyncImageProvider>
#include <QThreadPool>
#include <QQuickImageProvider>
class AsyncImageResponse final : public QQuickImageResponse
/**
* @class BlurhashImageProvider
*
* A QQuickImageProvider for blurhashes.
*
* @sa QQuickImageProvider
*/
class BlurhashImageProvider : public QQuickImageProvider
{
public:
AsyncImageResponse(const QString &id, const QSize &requestedSize, QThreadPool *pool);
void handleDone(QImage image);
QQuickTextureFactory *textureFactory() const override;
QImage m_image;
};
BlurhashImageProvider();
class BlurHashImageProvider : public QQuickAsyncImageProvider
{
public:
QQuickImageResponse *requestImageResponse(const QString &id, const QSize &requestedSize) override;
private:
QThreadPool pool;
/**
* @brief Return an image for a given ID.
*
* @sa QQuickImageProvider::requestImage
*/
QImage requestImage(const QString &id, QSize *size, const QSize &requestedSize) override;
};

View File

@@ -292,7 +292,7 @@ int main(int argc, char *argv[])
ShareHandler::instance().setText(parser.value(shareOption));
}
engine.addImageProvider(u"blurhash"_s, new BlurHashImageProvider);
engine.addImageProvider(u"blurhash"_s, new BlurhashImageProvider);
engine.loadFromModule("org.kde.neochat", "Main");

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

@@ -18,7 +18,6 @@
#include <qcoro/qcorosignal.h>
#include <Quotient/avatar.h>
#include <Quotient/blurhash.h>
#include <Quotient/connection.h>
#include <Quotient/csapi/account-data.h>
#include <Quotient/csapi/directory.h>
@@ -242,7 +241,7 @@ QCoro::Task<void> NeoChatRoom::doUploadFile(QUrl url, QString body, std::optiona
EventContent::FileContentBase *content;
if (mime.name().startsWith("image/"_L1)) {
QImage image(url.toLocalFile());
content = new EventContent::ImageContent(url, fileInfo.size(), mime, image.size(), fileInfo.fileName(), BlurHash::encode(image));
content = new EventContent::ImageContent(url, fileInfo.size(), mime, image.size(), fileInfo.fileName());
} else if (mime.name().startsWith("audio/"_L1)) {
content = new EventContent::AudioContent(url, fileInfo.size(), mime, fileInfo.fileName());
} else if (mime.name().startsWith("video/"_L1)) {

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