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
86 changed files with 11330 additions and 13454 deletions

View File

@@ -130,8 +130,7 @@ void EventHandlerTest::timeString()
QLocale().toString(QDateTime::fromMSecsSinceEpoch(1432735824654, QTimeZone(QTimeZone::UTC)).toLocalTime().time(), QLocale::ShortFormat));
QCOMPARE(EventHandler::timeString(room, event, true),
format.formatRelativeDate(QDateTime::fromMSecsSinceEpoch(1432735824654, QTimeZone(QTimeZone::UTC)).toLocalTime().date(), QLocale::ShortFormat));
QCOMPARE(EventHandler::timeString(room, event, u"hh:mm"_s),
QDateTime::fromMSecsSinceEpoch(1432735824654, QTimeZone(QTimeZone::LocalTime)).toString(u"hh:mm"_s));
QCOMPARE(EventHandler::timeString(room, event, u"hh:mm"_s), QDateTime::fromMSecsSinceEpoch(1432735824654, QTimeZone(QTimeZone::UTC)).toString(u"hh:mm"_s));
const auto txID = room->postJson("m.room.message"_L1, event->fullJson());
QCOMPARE(room->pendingEvents().size(), 1);

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

File diff suppressed because it is too large Load Diff

View File

@@ -106,7 +106,7 @@ KirigamiComponents.ConvergentContextMenu {
}
QQC2.Action {
text: i18n("Logout")
text: i18n("Logout")
icon.name: "im-kick-user"
onTriggered: confirmLogoutDialogComponent.createObject(root).open()
}

View File

@@ -52,15 +52,6 @@ ColumnLayout {
Layout.alignment: Qt.AlignHCenter
}
Kirigami.SelectableLabel {
Layout.fillWidth: true
font: Kirigami.Theme.smallFont
textFormat: TextEdit.PlainText
visible: root.currentRoom && root.currentRoom.canonicalAlias
text: root.currentRoom && root.currentRoom.canonicalAlias ? root.currentRoom.canonicalAlias : ""
color: Kirigami.Theme.disabledTextColor
}
Kirigami.Heading {
text: root.currentRoom.displayName
@@ -79,14 +70,7 @@ ColumnLayout {
spacing: Kirigami.Units.smallSpacing
Kirigami.Heading {
text: root.invitingMember.displayName
Layout.alignment: Qt.AlignHCenter
}
QQC2.Label {
text: root.invitingMember.id
color: Kirigami.Theme.disabledTextColor
text: root.currentRoom.displayName
Layout.alignment: Qt.AlignHCenter
}
@@ -175,7 +159,7 @@ ColumnLayout {
QQC2.Label {
color: Kirigami.Theme.disabledTextColor
text: xi18nc("@info:label Ensure you are referring to the same translation used for that settings page", "You can reject invitations from unknown users under the <interface>Security & Safety</interface> settings.")
text: i18nc("@info:label", "You can reject invitations from unknown users under Security settings.")
wrapMode: Text.WordWrap
// + 5 to prevent it from wrapping unnecessarily

View File

@@ -47,7 +47,7 @@ Kirigami.Page {
icon.name: "document-edit"
visible: root.allowEdit
enabled: room.canSendState(root.type) && (!root.stateKey.startsWith("@") || root.stateKey === root.room.connection.localUserId) && root.type !== "m.room.create"
onTriggered: pageStack.pushDialogLayer(Qt.createComponent("org.kde.neochat", "EditStateDialog"), {
onTriggered: pageStack.pushDialogLayer(Qt.createComponent("org.kde.neochat", "EditStateDialog.qml"), {
room: root.room,
type: root.type,
stateKey: root.stateKey,

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

@@ -236,18 +236,11 @@ void RoomManager::resolveResource(Uri uri, const QString &action)
}
}
void RoomManager::maximizeMedia(const QString &eventId)
void RoomManager::maximizeMedia(int index)
{
if (eventId.isEmpty()) {
qWarning() << "Tried to open media for empty event id";
if (index < -1 || index > m_mediaMessageFilterModel->rowCount()) {
return;
}
const auto index = m_mediaMessageFilterModel->getRowForEventId(eventId);
if (index == -1) {
return;
}
Q_EMIT showMaximizedMedia(index);
}
@@ -404,9 +397,7 @@ void RoomManager::joinRoom(Quotient::Connection *account, const QString &roomAli
// If no one gives us a homeserver suggestion, try the server specified in the alias/id.
// Otherwise joining a remote room not on our homeserver will fail.
// This is a hack and we're not supposed to do it. With room ids not containing the server going forward, it won't work anymore for new room versions.
// FIXME: Let's keep it around anyway for now, remove it at some point, though
if (vias.empty() && roomAliasOrId.contains(':'_L1)) {
if (vias.empty()) {
vias.append(roomAliasOrId.mid(roomAliasOrId.lastIndexOf(':'_L1) + 1));
}

View File

@@ -212,8 +212,12 @@ public:
/**
* @brief Show a media item maximized.
*
* @param index the index to open the maximize delegate model at. This is the
* index in the MediaMessageFilterModel owned by this RoomManager. A value
* of -1 opens a the default item.
*/
Q_INVOKABLE void maximizeMedia(const QString &eventId);
Q_INVOKABLE void maximizeMedia(int index);
Q_INVOKABLE void maximizeCode(NeochatRoomMember *author, const QDateTime &time, const QString &codeText, const QString &language);

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

@@ -70,23 +70,13 @@ public:
*
* @param event the event to return a type for.
*
* @param isInReply whether this event is to be treated like a replied-to event (i.e., a basic text fallback should be shown if no other type is used)
*
* @sa Type
*/
static Type typeForEvent(const Quotient::RoomEvent &event, bool isInReply = false)
static Type typeForEvent(const Quotient::RoomEvent &event)
{
using namespace Quotient;
if (event.isRedacted()) {
return MessageComponentType::Text;
}
if (const auto e = eventCast<const RoomMessageEvent>(&event)) {
if (e->rawMsgtype() == u"m.key.verification.request"_s) {
return MessageComponentType::Verification;
}
switch (e->msgtype()) {
case MessageEventType::Emote:
return MessageComponentType::Text;
@@ -113,8 +103,7 @@ public:
if (event.matrixType() == u"org.matrix.msc3672.beacon_info"_s) {
return MessageComponentType::LiveLocation;
}
// In the (unlikely) case that this is a reply to a state event, we do want to show something
return isInReply ? MessageComponentType::Text : MessageComponentType::Other;
return MessageComponentType::Other;
}
if (is<const EncryptedEvent>(event)) {
return MessageComponentType::Encrypted;
@@ -127,8 +116,7 @@ public:
return MessageComponentType::Poll;
}
// In the (unlikely) case that this is a reply to an unusual event, we do want to show something
return isInReply ? MessageComponentType::Text : MessageComponentType::Other;
return MessageComponentType::Other;
}
/**

View File

@@ -448,12 +448,6 @@ QString EventHandler::getBody(const NeoChatRoom *room, const Quotient::RoomEvent
[](const PollStartEvent &e) {
return e.question();
},
[](const EncryptedEvent &) {
return i18nc("@info In room list", "Encrypted event");
},
[](const ReactionEvent &e) {
return i18nc("[user] reacted with <emoji>", "reacted with %1", e.key());
},
i18n("Unknown event"));
}

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

@@ -31,7 +31,13 @@ auto leaveRoomLambda = [](const QString &text, NeoChatRoom *room, ChatBarCache *
Q_EMIT room->showMessage(MessageType::Information, i18n("Leaving this room."));
room->forget();
} else {
// FIXME: re-add sanity check for roomId/alias
QRegularExpression roomRegex(uR"(^[#!][^:]+:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?)"_s);
auto regexMatch = roomRegex.match(text);
if (!regexMatch.hasMatch()) {
Q_EMIT room->showMessage(MessageType::Error,
i18nc("'<text>' does not look like a room id or alias.", "'%1' does not look like a room id or alias.", text));
return QString();
}
auto leaving = dynamic_cast<NeoChatRoom *>(room->connection()->room(text));
if (!leaving) {
leaving = dynamic_cast<NeoChatRoom *>(room->connection()->roomByAlias(text));
@@ -211,7 +217,13 @@ QList<ActionsModel::Action> actions{
Action{
u"join"_s,
[](const QString &text, NeoChatRoom *room, ChatBarCache *) {
// FIXME: re-add sanity check for roomId/alias
QRegularExpression roomRegex(uR"(^[#!][^:]+:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?)"_s);
auto regexMatch = roomRegex.match(text);
if (!regexMatch.hasMatch()) {
Q_EMIT room->showMessage(MessageType::Error,
i18nc("'<text>' does not look like a room id or alias.", "'%1' does not look like a room id or alias.", text));
return QString();
}
auto targetRoom = text.startsWith(QLatin1Char('!')) ? room->connection()->room(text) : room->connection()->roomByAlias(text);
if (targetRoom) {
ActionsModel::instance().resolveResource(targetRoom->id());
@@ -230,18 +242,25 @@ QList<ActionsModel::Action> actions{
[](const QString &text, NeoChatRoom *room, ChatBarCache *) {
auto parts = text.split(u" "_s);
QString roomName = parts[0];
// FIXME: re-add sanity check for roomId/alias
if (const auto targetRoom = text.startsWith(QLatin1Char('!')) ? room->connection()->room(text) : room->connection()->roomByAlias(text)) {
QRegularExpression roomRegex(uR"(^[#!][^:]+:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?)"_s);
auto regexMatch = roomRegex.match(roomName);
if (!regexMatch.hasMatch()) {
Q_EMIT room->showMessage(MessageType::Error,
i18nc("'<text>' does not look like a room id or alias.", "'%1' does not look like a room id or alias.", text));
return QString();
}
auto targetRoom = text.startsWith(QLatin1Char('!')) ? room->connection()->room(text) : room->connection()->roomByAlias(text);
if (targetRoom) {
ActionsModel::instance().resolveResource(targetRoom->id());
return QString();
}
Q_EMIT room->showMessage(MessageType::Information, i18nc("Knocking room <roomname>.", "Knocking room %1.", text));
auto connection = dynamic_cast<NeoChatConnection *>(room->connection());
const auto knownServer = roomName.contains(":"_L1) ? QStringList{roomName.mid(roomName.indexOf(":"_L1) + 1)} : QStringList();
const auto knownServer = roomName.mid(roomName.indexOf(":"_L1) + 1);
if (parts.length() >= 2) {
ActionsModel::instance().knockRoom(connection, roomName, parts[1], knownServer);
ActionsModel::instance().knockRoom(connection, roomName, parts[1], QStringList{knownServer});
} else {
ActionsModel::instance().knockRoom(connection, roomName, QString(), knownServer);
ActionsModel::instance().knockRoom(connection, roomName, QString(), QStringList{knownServer});
}
return QString();
},
@@ -252,7 +271,13 @@ QList<ActionsModel::Action> actions{
Action{
u"j"_s,
[](const QString &text, NeoChatRoom *room, ChatBarCache *) {
// FIXME: re-add sanity check for roomId/alias
QRegularExpression roomRegex(uR"(^[#!][^:]+:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?)"_s);
auto regexMatch = roomRegex.match(text);
if (!regexMatch.hasMatch()) {
Q_EMIT room->showMessage(MessageType::Error,
i18nc("'<text>' does not look like a room id or alias.", "'%1' does not look like a room id or alias.", text));
return QString();
}
if (room->connection()->room(text) || room->connection()->roomByAlias(text)) {
Q_EMIT room->showMessage(MessageType::Information, i18nc("You are already in room <roomname>.", "You are already in room %1.", text));
return QString();

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

@@ -100,10 +100,6 @@ QVariant UserListModel::data(const QModelIndex &index, int role) const
return plEvent->powerLevelForUser(memberId);
}
if (role == PowerLevelStringRole) {
if (m_currentRoom->roomCreatorHasUltimatePowerLevel() && m_currentRoom->isCreator(memberId)) {
return i18nc("@info the person that created this room", "Creator");
}
auto pl = m_currentRoom->currentState().get<RoomPowerLevelsEvent>();
// User might not in the room yet, in this case pl can be nullptr.
// e.g. When invited but user not accepted or denied the invitation.

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

@@ -359,14 +359,9 @@ const RoomEvent *NeoChatRoom::lastEvent(std::function<bool(const RoomEvent *)> f
if (auto lastEvent = eventCast<const RoomMessageEvent>(event)) {
return lastEvent;
}
if (auto lastEvent = eventCast<const PollStartEvent>(event)) {
return lastEvent;
}
if (auto lastEvent = eventCast<const EncryptedEvent>(event)) {
return lastEvent;
}
}
if (m_cachedEvent != nullptr) {
@@ -446,19 +441,20 @@ void NeoChatRoom::onRedaction(const RoomEvent &prevEvent, const RoomEvent & /*af
}
}
QDateTime NeoChatRoom::lastActiveTime() const
QDateTime NeoChatRoom::lastActiveTime()
{
// Find the last relevant event:
if (const auto event = lastEvent(m_hiddenFilter)) {
if (timelineSize() == 0) {
if (m_cachedEvent != nullptr) {
return m_cachedEvent->originTimestamp();
}
return QDateTime();
}
if (auto event = lastEvent()) {
return event->originTimestamp();
}
// If nothing is loaded yet, and there is no cached event:
if (timelineSize() == 0) {
return {};
}
// No message found, take last event:
// no message found, take last event
return messageEvents().rbegin()->get()->originTimestamp();
}
@@ -536,9 +532,6 @@ bool NeoChatRoom::containsUser(const QString &userID) const
bool NeoChatRoom::canSendEvent(const QString &eventType) const
{
if (roomCreatorHasUltimatePowerLevel() && isCreator(localMember().id())) {
return true;
}
auto plEvent = currentState().get<RoomPowerLevelsEvent>();
if (!plEvent) {
return false;
@@ -551,9 +544,6 @@ bool NeoChatRoom::canSendEvent(const QString &eventType) const
bool NeoChatRoom::canSendState(const QString &eventType) const
{
if (roomCreatorHasUltimatePowerLevel() && isCreator(localMember().id())) {
return true;
}
auto plEvent = currentState().get<RoomPowerLevelsEvent>();
if (!plEvent) {
return false;
@@ -1680,14 +1670,8 @@ void NeoChatRoom::setRoomState(const QString &type, const QString &stateKey, con
NeochatRoomMember *NeoChatRoom::qmlSafeMember(const QString &memberId)
{
if (memberId.isEmpty()) {
return nullptr;
}
if (!m_memberObjects.contains(memberId)) {
auto member = m_memberObjects.emplace(memberId, std::make_unique<NeochatRoomMember>(this, memberId)).first->second.get();
QQmlEngine::setObjectOwnership(member, QQmlEngine::CppOwnership);
return member;
return m_memberObjects.emplace(memberId, std::make_unique<NeochatRoomMember>(this, memberId)).first->second.get();
}
return m_memberObjects[memberId].get();
@@ -1747,20 +1731,4 @@ void NeoChatRoom::setHiddenFilter(std::function<bool(const Quotient::RoomEvent *
NeoChatRoom::m_hiddenFilter = hiddenFilter;
}
bool NeoChatRoom::roomCreatorHasUltimatePowerLevel() const
{
bool ok = false;
auto version = this->version().toInt(&ok);
// This is terrible. For non-numeric room versions, I don't think there's a way of knowing whether they're pre- or post hydra.
// We just assume they are. Shouldn't matter for normal users anyway.
return !ok || version > 11;
}
bool NeoChatRoom::isCreator(const QString &userId) const
{
auto createEvent = currentState().get<RoomCreateEvent>();
return roomCreatorHasUltimatePowerLevel() && createEvent
&& (createEvent->senderId() == userId || createEvent->contentPart<QStringList>(u"additional_creators"_s).contains(userId));
}
#include "moc_neochatroom.cpp"

View File

@@ -208,7 +208,7 @@ public:
bool visible() const;
void setVisible(bool visible);
[[nodiscard]] QDateTime lastActiveTime() const;
[[nodiscard]] QDateTime lastActiveTime();
/**
* @brief Get the last interesting event.
@@ -589,18 +589,6 @@ public:
static void setHiddenFilter(std::function<bool(const Quotient::RoomEvent *)> hiddenFilter);
/**
* @brief Whether this room has a room version where the creator is treated as having an ultimate power level
*
* For unusual room versions, this information might be wrong.
*/
bool roomCreatorHasUltimatePowerLevel() const;
/**
* @brief Whether this user is considered a creator of this room. Only applies to post-v12 rooms.
*/
bool isCreator(const QString &userId) const;
private:
bool m_visible = false;

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

@@ -570,9 +570,8 @@ QVariantMap TextHandler::getAttributes(const QString &tag, const QString &tagStr
QList<MessageComponent>
TextHandler::textComponents(QString string, Qt::TextFormat inputFormat, const NeoChatRoom *room, const Quotient::RoomEvent *event, bool isEdited)
{
if (string.trimmed().isEmpty() && event->is<Quotient::RoomMessageEvent>()
&& !eventCast<const Quotient::RoomMessageEvent>(event)->has<Quotient::EventContent::FileContentBase>()) {
return {MessageComponent{MessageComponentType::Text, i18n("<i>This event does not have any content.</i>"), {}}};
if (string.isEmpty()) {
return {};
}
// Strip mx-reply if present.
@@ -591,7 +590,7 @@ TextHandler::textComponents(QString string, Qt::TextFormat inputFormat, const Ne
string = string.trimmed();
if (event != nullptr && room != nullptr) {
if (auto e = eventCast<const Quotient::RoomMessageEvent>(event); e && e->msgtype() == Quotient::MessageEventType::Emote && components.size() == 1) {
if (auto e = eventCast<const Quotient::RoomMessageEvent>(event); e->msgtype() == Quotient::MessageEventType::Emote && components.size() == 1) {
if (components[0].type == MessageComponentType::Text) {
components[0].content = emoteString(room, event) + components[0].content;
} else {

View File

@@ -158,7 +158,12 @@ Item {
}
root.Message.timeline.interactive = false;
if (!root.mediaInfo.isSticker) {
RoomManager.maximizeMedia(root.eventId);
// We need to make sure the index is that of the MediaMessageFilterModel.
if (root.Message.timeline.model instanceof MessageFilterModel) {
RoomManager.maximizeMedia(RoomManager.mediaMessageFilterModel.getRowForSourceItem(root.Message.index));
} else {
RoomManager.maximizeMedia(root.Message.index);
}
}
}
}

View File

@@ -385,7 +385,12 @@ Video {
onTriggered: {
root.Message.timeline.interactive = false;
root.pause();
RoomManager.maximizeMedia(root.eventId);
// We need to make sure the index is that of the MediaMessageFilterModel.
if (root.Message.timeline.model instanceof MessageFilterModel) {
RoomManager.maximizeMedia(RoomManager.mediaMessageFilterModel.getRowForSourceItem(root.Message.index));
} else {
RoomManager.maximizeMedia(root.Message.index);
}
}
}
}

View File

@@ -29,6 +29,7 @@
#include "chatbarcache.h"
#include "contentprovider.h"
#include "filetype.h"
#include "linkpreviewer.h"
#include "models/reactionmodel.h"
#include "neochatconnection.h"
#include "neochatroom.h"
@@ -421,8 +422,7 @@ bool MessageContentModel::hasComponentType(MessageComponentType::Type type)
!= m_components.cend();
}
void MessageContentModel::forEachComponentOfType(MessageComponentType::Type type,
std::function<MessageContentModel::ComponentIt(MessageContentModel::ComponentIt)> function)
void MessageContentModel::forEachComponentOfType(MessageComponentType::Type type, std::function<void(const QModelIndex &)> function)
{
auto it = m_components.begin();
while ((it = std::find_if(it,
@@ -431,12 +431,12 @@ void MessageContentModel::forEachComponentOfType(MessageComponentType::Type type
return component.type == type;
}))
!= m_components.end()) {
it = function(it);
function(index(it - m_components.begin()));
++it;
}
}
void MessageContentModel::forEachComponentOfType(QList<MessageComponentType::Type> types,
std::function<MessageContentModel::ComponentIt(MessageContentModel::ComponentIt)> function)
void MessageContentModel::forEachComponentOfType(QList<MessageComponentType::Type> types, std::function<void(const QModelIndex &)> function)
{
for (const auto &type : types) {
forEachComponentOfType(type, function);
@@ -466,10 +466,6 @@ void MessageContentModel::resetModel()
m_components += messageContentComponents();
endResetModel();
if (m_room->urlPreviewEnabled()) {
forEachComponentOfType({MessageComponentType::Text, MessageComponentType::Quote}, m_linkPreviewFunction);
}
updateReplyModel();
updateReactionModel();
}
@@ -489,10 +485,6 @@ void MessageContentModel::resetContent(bool isEditing, bool isThreading)
m_components += newComponents;
endInsertRows();
if (m_room->urlPreviewEnabled()) {
forEachComponentOfType({MessageComponentType::Text, MessageComponentType::Quote}, m_linkPreviewFunction);
}
updateReplyModel();
updateReactionModel();
}
@@ -506,13 +498,27 @@ QList<MessageComponent> MessageContentModel::messageContentComponents(bool isEdi
QList<MessageComponent> newComponents;
const auto roomMessageEvent = eventCast<const Quotient::RoomMessageEvent>(event.first);
if (roomMessageEvent && roomMessageEvent->rawMsgtype() == u"m.key.verification.request"_s) {
newComponents += MessageComponent{MessageComponentType::Verification, QString(), {}};
return newComponents;
}
if (event.first->isRedacted()) {
newComponents += MessageComponent{MessageComponentType::Text, QString(), {}};
return newComponents;
}
if (isEditing) {
newComponents += MessageComponent{MessageComponentType::ChatBar, QString(), {}};
} else {
newComponents.append(componentsForType(MessageComponentType::typeForEvent(*event.first, m_isReply)));
newComponents.append(componentsForType(MessageComponentType::typeForEvent(*event.first)));
}
if (m_room->urlPreviewEnabled()) {
newComponents = addLinkPreviews(newComponents);
}
const auto roomMessageEvent = eventCast<const Quotient::RoomMessageEvent>(event.first);
#if Quotient_VERSION_MINOR > 9 || (Quotient_VERSION_MINOR == 9 && Quotient_VERSION_PATCH > 1)
if (m_threadsEnabled && roomMessageEvent && (roomMessageEvent->isThreaded() || m_room->threads().contains(roomMessageEvent->id()))
&& roomMessageEvent->id() == roomMessageEvent->threadRootEventId()) {
@@ -591,26 +597,22 @@ QList<MessageComponent> MessageContentModel::componentsForType(MessageComponentT
}
switch (type) {
case MessageComponentType::Verification: {
return {MessageComponent{MessageComponentType::Verification, QString(), {}}};
}
case MessageComponentType::Text: {
if (const auto roomMessageEvent = eventCast<const Quotient::RoomMessageEvent>(event.first)) {
return TextHandler().textComponents(EventHandler::rawMessageBody(*roomMessageEvent),
EventHandler::messageBodyInputFormat(*roomMessageEvent),
m_room,
roomMessageEvent,
roomMessageEvent->isReplaced());
} else {
return TextHandler().textComponents(EventHandler::plainBody(m_room, event.first), Qt::TextFormat::PlainText, m_room, event.first, false);
}
const auto roomMessageEvent = eventCast<const Quotient::RoomMessageEvent>(event.first);
return TextHandler().textComponents(EventHandler::rawMessageBody(*roomMessageEvent),
EventHandler::messageBodyInputFormat(*roomMessageEvent),
m_room,
roomMessageEvent,
roomMessageEvent->isReplaced());
auto body = EventHandler::rawMessageBody(*roomMessageEvent);
if (body.trimmed().isEmpty()) {
return TextHandler().textComponents(i18n("<i>This event does not have any content.</i>"),
Qt::TextFormat::RichText,
m_room,
roomMessageEvent,
roomMessageEvent->isReplaced());
} else {
return TextHandler().textComponents(body,
EventHandler::messageBodyInputFormat(*roomMessageEvent),
m_room,
roomMessageEvent,
roomMessageEvent->isReplaced());
}
}
case MessageComponentType::File: {
QList<MessageComponent> components;
@@ -701,20 +703,42 @@ MessageComponent MessageContentModel::linkPreviewComponent(const QUrl &link)
}
if (linkPreviewer->loaded()) {
return MessageComponent{MessageComponentType::LinkPreview, QString(), {{"link"_L1, link}}};
}
connect(linkPreviewer, &LinkPreviewer::loadedChanged, this, [this, link]() {
const auto linkPreviewer = dynamic_cast<NeoChatConnection *>(m_room->connection())->previewerForLink(link);
if (linkPreviewer != nullptr && linkPreviewer->loaded()) {
forEachComponentOfType(MessageComponentType::LinkPreviewLoad, [this, link](ComponentIt it) {
if (it->attributes["link"_L1].toUrl() == link) {
it->type = MessageComponentType::LinkPreview;
Q_EMIT dataChanged(index(it - m_components.begin()), index(it - m_components.begin()), {ComponentTypeRole});
} else {
connect(linkPreviewer, &LinkPreviewer::loadedChanged, this, [this, link]() {
const auto linkPreviewer = dynamic_cast<NeoChatConnection *>(m_room->connection())->previewerForLink(link);
if (linkPreviewer != nullptr && linkPreviewer->loaded()) {
for (auto it = m_components.begin(); it != m_components.end(); it++) {
if (it->attributes["link"_L1].toUrl() == link) {
it->type = MessageComponentType::LinkPreview;
Q_EMIT dataChanged(index(it - m_components.begin()), index(it - m_components.begin()), {ComponentTypeRole});
}
}
return it;
});
}
});
return MessageComponent{MessageComponentType::LinkPreviewLoad, QString(), {{"link"_L1, link}}};
}
}
QList<MessageComponent> MessageContentModel::addLinkPreviews(QList<MessageComponent> inputComponents)
{
int i = 0;
while (i < inputComponents.size()) {
const auto component = inputComponents.at(i);
if (component.type == MessageComponentType::Text || component.type == MessageComponentType::Quote) {
if (LinkPreviewer::hasPreviewableLinks(component.content)) {
const auto links = LinkPreviewer::linkPreviews(component.content);
for (qsizetype j = 0; j < links.size(); ++j) {
const auto linkPreview = linkPreviewComponent(links[j]);
if (!m_removedLinkPreviews.contains(links[j]) && !linkPreview.isEmpty()) {
inputComponents.insert(i + j + 1, linkPreview);
}
};
}
}
});
return MessageComponent{MessageComponentType::LinkPreviewLoad, QString(), {{"link"_L1, link}}};
i++;
}
return inputComponents;
}
void MessageContentModel::closeLinkPreview(int row)

View File

@@ -9,7 +9,6 @@
#include <Quotient/events/roomevent.h>
#include "enums/messagecomponenttype.h"
#include "linkpreviewer.h"
#include "messagecomponent.h"
#include "models/itinerarymodel.h"
#include "models/reactionmodel.h"
@@ -134,32 +133,13 @@ private:
void initializeEvent();
void getEvent();
using ComponentIt = QList<MessageComponent>::iterator;
QList<MessageComponent> m_components;
bool hasComponentType(MessageComponentType::Type type);
void forEachComponentOfType(MessageComponentType::Type type, std::function<ComponentIt(ComponentIt)> function);
void forEachComponentOfType(QList<MessageComponentType::Type> types, std::function<ComponentIt(ComponentIt)> function);
void forEachComponentOfType(MessageComponentType::Type type, std::function<void(const QModelIndex &)> function);
void forEachComponentOfType(QList<MessageComponentType::Type> types, std::function<void(const QModelIndex &)> function);
std::function<ComponentIt(const ComponentIt &)> m_fileInfoFunction = [this](ComponentIt it) {
Q_EMIT dataChanged(index(it - m_components.begin()), index(it - m_components.begin()), {MessageContentModel::FileTransferInfoRole});
return ++it;
};
std::function<ComponentIt(const ComponentIt &)> m_linkPreviewFunction = [this](ComponentIt it) {
bool previewAdded = false;
if (LinkPreviewer::hasPreviewableLinks(it->content)) {
const auto links = LinkPreviewer::linkPreviews(it->content);
for (qsizetype j = 0; j < links.size(); ++j) {
const auto linkPreview = linkPreviewComponent(links[j]);
if (!m_removedLinkPreviews.contains(links[j]) && !linkPreview.isEmpty()) {
beginInsertRows({}, std::distance(m_components.begin(), it) + j + 1, std::distance(m_components.begin(), it) + j + 1);
it = m_components.insert(it + j + 1, linkPreview);
previewAdded = true;
endInsertRows();
}
};
}
return previewAdded ? it : ++it;
std::function<void(const QModelIndex &)> m_fileInfoFunction = [this](const QModelIndex &index) {
Q_EMIT dataChanged(index, index, {MessageContentModel::FileTransferInfoRole});
};
void resetModel();
@@ -174,6 +154,7 @@ private:
QList<MessageComponent> componentsForType(MessageComponentType::Type type);
MessageComponent linkPreviewComponent(const QUrl &link);
QList<MessageComponent> addLinkPreviews(QList<MessageComponent> inputComponents);
QList<QUrl> m_removedLinkPreviews;

View File

@@ -153,7 +153,7 @@ QQC2.ScrollView {
Delegates.RoundedItemDelegate {
id: leaveButton
icon.name: "arrow-left-symbolic"
text: root.room.isSpace ? i18nc("@action:button", "Leave this space") : i18nc("@action:button", "Leave this room")
text: root.room.isSpace ? i18nc("@action:button", "Leave this space") : i18nc("@action:button", "Leave this room")
activeFocusOnTab: true
Layout.fillWidth: true

View File

@@ -58,7 +58,7 @@ KirigamiComponents.ConvergentContextMenu {
icon.name: "notifications"
Kirigami.Action {
text: i18nc("@action:inmenu Notification 'Default Settings'", "Default Settings")
text: i18n("Follow Global Setting")
icon.name: "globe"
checkable: true
autoExclusive: true
@@ -152,7 +152,7 @@ KirigamiComponents.ConvergentContextMenu {
}
QQC2.Action {
text: i18n("Leave Room")
text: i18n("Leave Room")
icon.name: "go-previous"
onTriggered: {
Qt.createComponent('org.kde.neochat', 'ConfirmLeaveDialog').createObject(root.QQC2.ApplicationWindow.window, {

View File

@@ -70,10 +70,8 @@ KirigamiComponents.ConvergentContextMenu {
}
QQC2.Action {
text: i18nc("'Space' is a matrix space", "Leave Space")
text: i18nc("'Space' is a matrix space", "Leave Space")
icon.name: "go-previous"
onTriggered: Qt.createComponent('org.kde.neochat', 'ConfirmLeaveDialog').createObject(root.QQC2.ApplicationWindow.window, {
room: root.room
}).open();
onTriggered: root.room.forget()
}
}

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;
}
}
}
}
@@ -252,7 +291,7 @@ FormCard.FormCardPage {
FormCard.FormCard {
FormCard.FormButtonDelegate {
id: deactivateAccountButton
text: i18nc("@action:button", "Deactivate Account")
text: i18n("Deactivate Account")
icon.name: "trash-empty-symbolic"
onClicked: {
const component = Qt.createComponent('org.kde.neochat', 'ConfirmDeactivateAccountDialog');

View File

@@ -85,7 +85,7 @@ FormCard.FormCardPage {
}
QQC2.ToolButton {
text: i18n("Logout")
text: i18n("Logout")
icon.name: "im-kick-user"
onClicked: confirmLogoutDialogComponent.createObject(root.QQC2.Overlay.overlay).open()
}

View File

@@ -45,26 +45,6 @@ FormCard.FormCardPage {
}
}
FormCard.FormCard {
Layout.topMargin: Kirigami.Units.largeSpacing
FormCard.AbstractFormDelegate {
contentItem: RowLayout {
spacing: Kirigami.Units.largeSpacing
Kirigami.Icon {
source: "data-information"
width: Kirigami.Units.iconSizes.sizeForLabels
height: Kirigami.Units.iconSizes.sizeForLabels
}
QQC2.Label {
text: i18nc("@info", "These are the default notification settings for all rooms. You can customize notifications per-room in the room list or room settings.")
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
}
}
}
FormCard.FormHeader {
title: i18nc("@title:group", "Room Notifications")
}

View File

@@ -345,7 +345,7 @@ FormCard.FormCardPage {
FormCard.FormCard {
FormCard.FormButtonDelegate {
icon.name: "kt-restore-defaults-symbolic"
text: i18nc("@action:button", "Reset all configuration values to their default")
text: i18nc("@action:button", "Reset all configuration values to their default")
onClicked: resetDialog.open()
}
}

View File

@@ -28,7 +28,7 @@ FormCard.FormCardPage {
FormCard.FormCard {
FormCard.FormRadioDelegate {
text: i18nc("As in the default notification setting", "Default Settings")
text: i18n("Follow global setting")
checked: room.pushNotificationState === PushNotificationState.Default
enabled: room.pushNotificationState !== PushNotificationState.Unknown
onToggled: {

View File

@@ -25,34 +25,13 @@ FormCard.FormCardPage {
title: i18nc("@option:check", "Encryption")
}
FormCard.FormCard {
FormCard.AbstractFormDelegate {
visible: room.usesEncryption
contentItem: RowLayout {
spacing: Kirigami.Units.largeSpacing
Kirigami.Icon {
source: "lock"
width: Kirigami.Units.iconSizes.sizeForLabels
height: Kirigami.Units.iconSizes.sizeForLabels
}
QQC2.Label {
text: i18nc("@info", "This room uses encryption.")
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
}
}
FormCard.FormButtonDelegate {
FormCard.FormSwitchDelegate {
id: enableEncryptionSwitch
icon.name: "lock-symbolic"
text: i18nc("@action:button Enable encryption in this room", "Enable Encryption…")
description: i18nc("@info:description", "Once enabled, encryption cannot be disabled.")
text: i18n("Enable encryption")
description: i18nc("option:check", "Once enabled, encryption cannot be disabled.")
enabled: room.canEncryptRoom
visible: !room.usesEncryption
onClicked: {
checked: room.usesEncryption
onToggled: if (checked) {
let dialog = confirmEncryptionDialog.createObject(QQC2.Overlay.overlay, {
room: room
});

View File

@@ -95,11 +95,9 @@ ColumnLayout {
}
}
QQC2.Button {
text: i18nc("@action:button", "Leave this space")
text: i18nc("@action:button", "Leave this space")
icon.name: "go-previous"
onClicked: Qt.createComponent('org.kde.neochat', 'ConfirmLeaveDialog').createObject(root.QQC2.ApplicationWindow.window, {
room: root.room
}).open();
onClicked: root.room.forget()
}
Item {
Layout.fillWidth: true

View File

@@ -123,7 +123,7 @@ KirigamiComponents.ConvergentContextMenu {
component ReportMessageAction: Kirigami.Action {
text: i18nc("@action:button 'Report' as in 'Report this event to the administrators'", "Report")
text: i18nc("@action:button 'Report' as in 'Report this event to the administrators'", "Report")
icon.name: "dialog-warning-symbolic"
visible: !author.isLocalMember
onTriggered: {

View File

@@ -159,7 +159,7 @@ QQC2.ScrollView {
function onReadMarkerAdded() {
if (root.markReadCondition == LibNeoChat.TimelineMarkReadCondition.EntryVisible && messageListView.allUnreadVisible()) {
_private.room.markAllMessagesAsRead();
root.room.markAllMessagesAsRead();
}
}

View File

@@ -85,14 +85,9 @@ QHash<int, QByteArray> MediaMessageFilterModel::roleNames() const
return roles;
}
int MediaMessageFilterModel::getRowForEventId(const QString &eventId) const
int MediaMessageFilterModel::getRowForSourceItem(int sourceRow) const
{
for (auto i = 0; i < rowCount(); i++) {
if (data(index(i, 0), MessageModel::EventIdRole).toString() == eventId) {
return i;
}
}
return -1;
return mapFromSource(sourceModel()->index(sourceRow, 0)).row();
}
#include "moc_mediamessagefiltermodel.cpp"

View File

@@ -63,5 +63,5 @@ public:
*/
[[nodiscard]] QHash<int, QByteArray> roleNames() const override;
int getRowForEventId(const QString &eventId) const;
Q_INVOKABLE int getRowForSourceItem(int sourceRow) const;
};