diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 9a8fdfaf8..53eccea28 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -64,6 +64,7 @@ add_library(neochat STATIC chatdocumenthandler.h models/devicesmodel.cpp models/devicesmodel.h + models/devicesproxymodel.cpp filetypesingleton.cpp filetypesingleton.h login.cpp diff --git a/src/main.cpp b/src/main.cpp index ea6aba754..29449764d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -49,6 +49,7 @@ #include "models/collapsestateproxymodel.h" #include "models/customemojimodel.h" #include "models/devicesmodel.h" +#include "models/devicesproxymodel.h" #include "models/emojimodel.h" #include "models/emoticonfiltermodel.h" #include "models/imagepacksmodel.h" @@ -236,6 +237,7 @@ int main(int argc, char *argv[]) qmlRegisterType("org.kde.neochat", 1, 0, "SortFilterRoomListModel"); qmlRegisterType("org.kde.neochat", 1, 0, "SortFilterSpaceListModel"); qmlRegisterType("org.kde.neochat", 1, 0, "DevicesModel"); + qmlRegisterType("org.kde.neochat", 1, 0, "DevicesProxyModel"); qmlRegisterType("org.kde.neochat", 1, 0, "LinkPreviewer"); qmlRegisterType("org.kde.neochat", 1, 0, "CompletionModel"); qmlRegisterType("org.kde.neochat", 1, 0, "StateModel"); diff --git a/src/models/devicesmodel.cpp b/src/models/devicesmodel.cpp index b3aab48cf..9beae7067 100644 --- a/src/models/devicesmodel.cpp +++ b/src/models/devicesmodel.cpp @@ -6,6 +6,7 @@ #include #include "controller.h" +#include #include #include @@ -14,12 +15,6 @@ using namespace Quotient; DevicesModel::DevicesModel(QObject *parent) : QAbstractListModel(parent) { - connect(&Controller::instance(), &Controller::activeConnectionChanged, this, [this]() { - DevicesModel::fetchDevices(); - Q_EMIT connectionChanged(); - }); - - fetchDevices(); } void DevicesModel::fetchDevices() @@ -30,6 +25,7 @@ void DevicesModel::fetchDevices() beginResetModel(); m_devices = job->devices(); endResetModel(); + Q_EMIT countChanged(); }); } } @@ -40,16 +36,33 @@ QVariant DevicesModel::data(const QModelIndex &index, int role) const return {}; } + const auto &device = m_devices[index.row()]; + switch (role) { case Id: - return m_devices[index.row()].deviceId; + return device.deviceId; case DisplayName: - return m_devices[index.row()].displayName; + return device.displayName; case LastIp: - return m_devices[index.row()].lastSeenIp; + return device.lastSeenIp; case LastTimestamp: - if (m_devices[index.row()].lastSeenTs) - return *m_devices[index.row()].lastSeenTs; + if (device.lastSeenTs) { + return *device.lastSeenTs; + } else { + return false; + } + case Type: + if (device.deviceId == m_connection->deviceId()) { + return This; + } + if (!m_connection->isKnownE2eeCapableDevice(m_connection->userId(), device.deviceId)) { + return Unencrypted; + } + if (m_connection->isVerifiedDevice(m_connection->userId(), device.deviceId)) { + return Verified; + } else { + return Unverified; + } } return {}; } @@ -62,11 +75,21 @@ int DevicesModel::rowCount(const QModelIndex &parent) const QHash DevicesModel::roleNames() const { - return {{Id, "id"}, {DisplayName, "displayName"}, {LastIp, "lastIp"}, {LastTimestamp, "lastTimestamp"}}; + return { + {Id, "id"}, + {DisplayName, "displayName"}, + {LastIp, "lastIp"}, + {LastTimestamp, "lastTimestamp"}, + {Type, "type"}, + }; } -void DevicesModel::logout(int index, const QString &password) +void DevicesModel::logout(const QString &deviceId, const QString &password) { + int index; + for (index = 0; m_devices[index].deviceId != deviceId; index++) + ; + auto job = Controller::instance().activeConnection()->callApi(m_devices[index].deviceId); connect(job, &BaseJob::result, this, [this, job, password, index] { @@ -74,6 +97,7 @@ void DevicesModel::logout(int index, const QString &password) beginRemoveRows(QModelIndex(), index, index); m_devices.remove(index); endRemoveRows(); + Q_EMIT countChanged(); }; if (job->error() != BaseJob::Success) { QJsonObject replyData = job->jsonData(); @@ -91,8 +115,11 @@ void DevicesModel::logout(int index, const QString &password) }); } -void DevicesModel::setName(int index, const QString &name) +void DevicesModel::setName(const QString &deviceId, const QString &name) { + int index; + for (index = 0; m_devices[index].deviceId != deviceId; index++); + auto job = Controller::instance().activeConnection()->callApi(m_devices[index].deviceId, name); QString oldName = m_devices[index].displayName; beginResetModel(); @@ -107,7 +134,27 @@ void DevicesModel::setName(int index, const QString &name) Connection *DevicesModel::connection() const { - return Controller::instance().activeConnection(); + return m_connection; +} + +void DevicesModel::setConnection(Connection *connection) +{ + if (m_connection) { + disconnect(m_connection, nullptr, this, nullptr); + } + m_connection = connection; + Q_EMIT connectionChanged(); + fetchDevices(); + + connect(m_connection, &Connection::sessionVerified, this, [this](const QString &userId, const QString &deviceId) { + Q_UNUSED(deviceId); + if (userId == Controller::instance().activeConnection()->userId()) { + fetchDevices(); + } + }); + connect(m_connection, &Connection::finishedQueryingKeys, this, [this]() { + fetchDevices(); + }); } #include "moc_devicesmodel.cpp" diff --git a/src/models/devicesmodel.h b/src/models/devicesmodel.h index 6dfc50952..106d73158 100644 --- a/src/models/devicesmodel.h +++ b/src/models/devicesmodel.h @@ -5,6 +5,7 @@ #include +#include #include namespace Quotient @@ -28,7 +29,7 @@ class DevicesModel : public QAbstractListModel /** * @brief The current connection that the model is getting its devices from. */ - Q_PROPERTY(Quotient::Connection *connection READ connection NOTIFY connectionChanged) + Q_PROPERTY(Quotient::Connection *connection READ connection WRITE setConnection NOTIFY connectionChanged REQUIRED) public: /** @@ -39,10 +40,17 @@ public: DisplayName, /**< Display name set by the user for this device. */ LastIp, /**< The IP address where this device was last seen. */ LastTimestamp, /**< The timestamp when this devices was last seen. */ + Type, /**< The category to sort this device into. */ }; Q_ENUM(Roles) - DevicesModel(QObject *parent = nullptr); + enum DeviceType { + This, + Verified, + Unverified, + Unencrypted, + }; + Q_ENUM(DeviceType); /** * @brief Get the given role value at the given index. @@ -66,21 +74,27 @@ public: QHash roleNames() const override; /** - * @brief Logout the device at the given index. + * @brief Logout the device with the given id. */ - Q_INVOKABLE void logout(int index, const QString &password); + Q_INVOKABLE void logout(const QString &deviceId, const QString &password); /** - * @brief Set the display name of the device at the given index. + * @brief Set the display name of the device with the given id. */ - Q_INVOKABLE void setName(int index, const QString &name); + Q_INVOKABLE void setName(const QString &deviceId, const QString &name); - Quotient::Connection *connection() const; + explicit DevicesModel(QObject *parent = nullptr); + + + [[nodiscard]] Quotient::Connection *connection() const; + void setConnection(Quotient::Connection *connection); Q_SIGNALS: void connectionChanged(); + void countChanged(); private: void fetchDevices(); QVector m_devices; + QPointer m_connection; }; diff --git a/src/models/devicesproxymodel.cpp b/src/models/devicesproxymodel.cpp new file mode 100644 index 000000000..5e15b4738 --- /dev/null +++ b/src/models/devicesproxymodel.cpp @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2023 Tobias Fella +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "devicesproxymodel.h" +#include "devicesmodel.h" + +int DevicesProxyModel::type() const +{ + return m_type; +} +void DevicesProxyModel::setType(int type) +{ + m_type = type; + Q_EMIT typeChanged(); +} + +bool DevicesProxyModel::filterAcceptsRow(int source_row, const QModelIndex &source_parent) const +{ + Q_UNUSED(source_parent) + return sourceModel()->data(sourceModel()->index(source_row, 0), DevicesModel::Type).toInt() == m_type; +} +DevicesProxyModel::DevicesProxyModel(QObject *parent) + : QSortFilterProxyModel(parent) + , m_type(0) +{ + setSortRole(DevicesModel::LastTimestamp); + sort(0, Qt::DescendingOrder); +} diff --git a/src/models/devicesproxymodel.h b/src/models/devicesproxymodel.h new file mode 100644 index 000000000..1cb5eb238 --- /dev/null +++ b/src/models/devicesproxymodel.h @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2023 Tobias Fella +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include + +class DevicesProxyModel : public QSortFilterProxyModel +{ + Q_OBJECT + Q_PROPERTY(int type READ type WRITE setType NOTIFY typeChanged); + +public: + DevicesProxyModel(QObject *parent = nullptr); + [[nodiscard]] bool filterAcceptsRow(int source_row, const QModelIndex &source_parent) const override; + + void setType(int type); + [[nodiscard]] int type() const; + +Q_SIGNALS: + void typeChanged(); + +private: + int m_type; +}; diff --git a/src/qml/Page/RoomList/AccountMenu.qml b/src/qml/Page/RoomList/AccountMenu.qml index 8ef6e7dad..38fccb163 100644 --- a/src/qml/Page/RoomList/AccountMenu.qml +++ b/src/qml/Page/RoomList/AccountMenu.qml @@ -26,12 +26,22 @@ QQC2.Menu { QQC2.MenuItem { text: i18n("Notification settings") icon.name: "notifications" - onTriggered: pageStack.pushDialogLayer("qrc:/SettingsPage.qml", {defaultPage: "notifications"}, { title: i18n("Configure")}) + onTriggered: pageStack.pushDialogLayer("qrc:/SettingsPage.qml", { + defaultPage: "notifications", + connection: Controller.activeConnection, + }, { + title: i18n("Configure") + }); } QQC2.MenuItem { text: i18n("Devices") icon.name: "computer-symbolic" - onTriggered: pageStack.pushDialogLayer("qrc:/SettingsPage.qml", {defaultPage: "devices"}, { title: i18n("Configure")}) + onTriggered: pageStack.pushDialogLayer("qrc:/SettingsPage.qml", { + defaultPage: "devices", + connection: Controller.activeConnection, + }, { + title: i18n("Configure") + }) } QQC2.MenuItem { text: i18n("Logout") diff --git a/src/qml/Page/RoomList/UserInfo.qml b/src/qml/Page/RoomList/UserInfo.qml index 29453bfa7..6510497d2 100644 --- a/src/qml/Page/RoomList/UserInfo.qml +++ b/src/qml/Page/RoomList/UserInfo.qml @@ -214,7 +214,7 @@ QQC2.ToolBar { } QQC2.ToolButton { icon.name: "settings-configure" - onClicked: pageStack.pushDialogLayer("qrc:/SettingsPage.qml", {}, { title: i18n("Configure") }) + onClicked: pageStack.pushDialogLayer("qrc:/SettingsPage.qml", {connection: Controller.activeConnection}, { title: i18n("Configure") }) text: i18n("Open Settings") display: QQC2.AbstractButton.IconOnly Layout.minimumWidth: Layout.preferredWidth diff --git a/src/qml/Settings/DeviceDelegate.qml b/src/qml/Settings/DeviceDelegate.qml new file mode 100644 index 000000000..0a8a21025 --- /dev/null +++ b/src/qml/Settings/DeviceDelegate.qml @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: 2020 - 2023 Tobias Fella +// SPDX-FileCopyrightText: 2022 James Graham +// SPDX-License-Identifier: GPL-2.0-or-later + +import QtQuick 2.15 +import QtQuick.Controls 2.15 as QQC2 +import QtQuick.Layouts 1.15 + +import org.kde.kirigami 2.19 as Kirigami +import org.kde.kirigamiaddons.labs.mobileform 0.1 as MobileForm + +import org.kde.neochat 1.0 + +MobileForm.AbstractFormDelegate { + id: deviceDelegate + + required property string id + required property int lastTimestamp + required property string displayName + + property bool editDeviceName: false + property bool showVerifyButton + + Layout.fillWidth: true + + onClicked: deviceDelegate.editDeviceName = true + + contentItem: RowLayout { + spacing: Kirigami.Units.largeSpacing + + Kirigami.Icon { + source: "network-connect" + implicitWidth: Kirigami.Units.iconSizes.medium + implicitHeight: Kirigami.Units.iconSizes.medium + } + ColumnLayout { + id: deviceLabel + Layout.fillWidth: true + spacing: Kirigami.Units.smallSpacing + visible: !deviceDelegate.editDeviceName + + QQC2.Label { + Layout.fillWidth: true + text: deviceDelegate.displayName + elide: Text.ElideRight + wrapMode: Text.Wrap + maximumLineCount: 2 + } + + QQC2.Label { + Layout.fillWidth: true + text: deviceDelegate.id + ", Last activity: " + (new Date(deviceDelegate.lastTimestamp)).toLocaleString(Qt.locale(), Locale.ShortFormat) + color: Kirigami.Theme.disabledTextColor + font: Kirigami.Theme.smallFont + elide: Text.ElideRight + visible: text.length > 0 + } + } + Kirigami.ActionTextField { + id: nameField + Accessible.description: i18n("New device name") + Layout.fillWidth: true + Layout.preferredHeight: deviceLabel.implicitHeight + visible: deviceDelegate.editDeviceName + + text: deviceDelegate.displayName + + rightActions: [ + Kirigami.Action { + text: i18n("Cancel editing display name") + icon.name: "edit-delete-remove" + onTriggered: { + deviceDelegate.editDeviceName = false + } + }, + Kirigami.Action { + text: i18n("Confirm new display name") + icon.name: "checkmark" + visible: nameField.text !== deviceDelegate.displayName + onTriggered: { + devicesModel.setName(deviceDelegate.id, nameField.text) + } + } + ] + + onAccepted: devicesModel.setName(deviceDelegate.id, nameField.text) + } + QQC2.ToolButton { + display: QQC2.AbstractButton.IconOnly + action: Kirigami.Action { + id: editDeviceAction + text: i18n("Edit device name") + icon.name: "document-edit" + onTriggered: deviceDelegate.editDeviceName = true + } + QQC2.ToolTip { + text: editDeviceAction.text + delay: Kirigami.Units.toolTipDelay + } + } + QQC2.ToolButton { + display: QQC2.AbstractButton.IconOnly + visible: Controller.encryptionSupported && deviceDelegate.showVerifyButton + action: Kirigami.Action { + id: verifyDeviceAction + text: i18n("Verify device") + icon.name: "security-low-symbolic" + onTriggered: { + devicesModel.connection.startKeyVerificationSession(devicesModel.connection.localUserId, deviceDelegate.id) + } + } + QQC2.ToolTip { + text: verifyDeviceAction.text + delay: Kirigami.Units.toolTipDelay + } + } + QQC2.ToolButton { + display: QQC2.AbstractButton.IconOnly + action: Kirigami.Action { + id: logoutDeviceAction + text: i18n("Logout device") + icon.name: "edit-delete-remove" + onTriggered: { + passwordSheet.deviceId = deviceDelegate.id + passwordSheet.open() + } + } + QQC2.ToolTip { + text: logoutDeviceAction.text + delay: Kirigami.Units.toolTipDelay + } + } + } +} \ No newline at end of file diff --git a/src/qml/Settings/DevicesCard.qml b/src/qml/Settings/DevicesCard.qml new file mode 100644 index 000000000..77575bf4a --- /dev/null +++ b/src/qml/Settings/DevicesCard.qml @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2020 - 2023 Tobias Fella +// SPDX-FileCopyrightText: 2022 James Graham +// SPDX-License-Identifier: GPL-2.0-or-later + +import QtQuick 2.15 +import QtQuick.Controls 2.15 as QQC2 +import QtQuick.Layouts 1.15 + +import org.kde.kirigami 2.19 as Kirigami +import org.kde.kirigamiaddons.labs.mobileform 0.1 as MobileForm + +import org.kde.neochat 1.0 + +ColumnLayout { + id: root + + required property string title + required property var type + required property bool showVerifyButton + + visible: deviceRepeater.count > 0 + MobileForm.FormHeader { + title: root.title + Layout.fillWidth: true + } + + MobileForm.FormCard { + id: devicesCard + + Layout.fillWidth: true + + + contentItem: ColumnLayout { + spacing: 0 + + Repeater { + id: deviceRepeater + model: DevicesProxyModel { + sourceModel: devicesModel + type: root.type + } + + Kirigami.LoadingPlaceholder { + visible: deviceModel.count === 0 // We can assume 0 means loading since there is at least one device + anchors.centerIn: parent + } + + delegate: DeviceDelegate { + showVerifyButton: root.showVerifyButton + } + } + } + } +} + + diff --git a/src/qml/Settings/DevicesPage.qml b/src/qml/Settings/DevicesPage.qml index 0f32f5f13..1b43e6c82 100644 --- a/src/qml/Settings/DevicesPage.qml +++ b/src/qml/Settings/DevicesPage.qml @@ -1,4 +1,5 @@ -// SPDX-FileCopyrightText: Tobias Fella +// SPDX-FileCopyrightText: 2020 - 2023 Tobias Fella +// SPDX-FileCopyrightText: 2022 James Graham // SPDX-License-Identifier: GPL-2.0-or-later import QtQuick 2.15 @@ -12,155 +13,44 @@ import org.kde.neochat 1.0 Kirigami.ScrollablePage { title: i18n("Devices") - topPadding: 0 + + property alias connection: devicesModel.connection + leftPadding: 0 rightPadding: 0 + + DevicesModel { + id: devicesModel + } + ColumnLayout { - spacing: 0 - MobileForm.FormHeader { - Layout.fillWidth: true - title: i18n("Devices") + DevicesCard { + title: i18n("This Device") + type: DevicesModel.This + showVerifyButton: false } - MobileForm.FormCard { - Layout.fillWidth: true - - contentItem: ColumnLayout { - spacing: 0 - MobileForm.AbstractFormDelegate { - Layout.fillWidth: true - visible: Controller.activeConnection && deviceRepeater.count === 0 // We can assume 0 means loading since there is at least one device - contentItem: Kirigami.LoadingPlaceholder { } - } - Repeater { - id: deviceRepeater - model: DevicesModel { - id: devices - } - - Kirigami.LoadingPlaceholder { - visible: parent.count === 0 // We can assume 0 means loading since there is at least one device - anchors.centerIn: parent - } - - delegate: MobileForm.AbstractFormDelegate { - id: deviceDelegate - - property bool editDeviceName: false - - Layout.fillWidth: true - - onClicked: deviceDelegate.editDeviceName = true - - contentItem: RowLayout { - spacing: Kirigami.Units.largeSpacing - - Kirigami.Icon { - source: "network-connect" - implicitWidth: Kirigami.Units.iconSizes.medium - implicitHeight: Kirigami.Units.iconSizes.medium - } - ColumnLayout { - id: deviceLabel - Layout.fillWidth: true - spacing: Kirigami.Units.smallSpacing - visible: !deviceDelegate.editDeviceName - - QQC2.Label { - Layout.fillWidth: true - text: model.displayName - elide: Text.ElideRight - wrapMode: Text.Wrap - maximumLineCount: 2 - } - - QQC2.Label { - Layout.fillWidth: true - text: model.id + ", Last activity: " + (new Date(model.lastTimestamp)).toLocaleString(Qt.locale(), Locale.ShortFormat) - color: Kirigami.Theme.disabledTextColor - font: Kirigami.Theme.smallFont - elide: Text.ElideRight - visible: text !== "" - } - } - Kirigami.ActionTextField { - id: nameField - Accessible.description: i18n("New device name") - Layout.fillWidth: true - Layout.preferredHeight: deviceLabel.implicitHeight - visible: deviceDelegate.editDeviceName - - text: model.displayName - - rightActions: [ - Kirigami.Action { - text: i18n("Cancel editing display name") - icon.name: "edit-delete-remove" - onTriggered: { - deviceDelegate.editDeviceName = false - } - }, - Kirigami.Action { - text: i18n("Confirm new display name") - icon.name: "checkmark" - visible: nameField.text != model.displayName - onTriggered: { - devices.setName(model.index, nameField.text) - } - } - ] - - onAccepted: devices.setName(model.index, nameField.text) - } - QQC2.ToolButton { - display: QQC2.AbstractButton.IconOnly - action: Kirigami.Action { - id: editDeviceAction - text: i18n("Edit device name") - icon.name: "document-edit" - onTriggered: deviceDelegate.editDeviceName = true - } - QQC2.ToolTip { - text: editDeviceAction.text - delay: Kirigami.Units.toolTipDelay - } - } - QQC2.ToolButton { - display: QQC2.AbstractButton.IconOnly - visible: Controller.encryptionSupported - action: Kirigami.Action { - id: verifyDeviceAction - text: i18n("Verify device") - icon.name: "security-low-symbolic" - onTriggered: { - devices.connection.startKeyVerificationSession(devices.connection.localUserId, model.id) - } - } - QQC2.ToolTip { - text: verifyDeviceAction.text - delay: Kirigami.Units.toolTipDelay - } - } - QQC2.ToolButton { - display: QQC2.AbstractButton.IconOnly - action: Kirigami.Action { - id: logoutDeviceAction - text: i18n("Logout device") - icon.name: "edit-delete-remove" - onTriggered: { - passwordSheet.index = index - passwordSheet.open() - } - } - QQC2.ToolTip { - text: logoutDeviceAction.text - delay: Kirigami.Units.toolTipDelay - } - } - } - } - } - } + DevicesCard { + title: i18n("Verified Devices") + type: DevicesModel.Verified + showVerifyButton: true } + DevicesCard { + title: i18n("Unverified Devices") + type: DevicesModel.Unverified + showVerifyButton: true + } + DevicesCard { + title: i18n("Devices without Encryption Support") + type: DevicesModel.Unencrypted + showVerifyButton: false + } + + MobileForm.AbstractFormDelegate { + Layout.fillWidth: true + visible: Controller.activeConnection && devicesModel.count === 0 // We can assume 0 means loading since there is at least one device + contentItem: Kirigami.LoadingPlaceholder { } + } + Kirigami.InlineMessage { Layout.fillWidth: true Layout.maximumWidth: Kirigami.Units.gridUnit * 30 @@ -174,7 +64,7 @@ Kirigami.ScrollablePage { Kirigami.OverlaySheet { id: passwordSheet - property var index + property string deviceId title: i18n("Remove device") Kirigami.FormLayout { @@ -186,7 +76,7 @@ Kirigami.ScrollablePage { QQC2.Button { text: i18n("Confirm") onClicked: { - devices.logout(passwordSheet.index, passwordField.text) + devicesModel.logout(passwordSheet.deviceId, passwordField.text) passwordField.text = "" passwordSheet.close() } diff --git a/src/qml/Settings/SettingsPage.qml b/src/qml/Settings/SettingsPage.qml index f0e74c680..8e21c2ddd 100644 --- a/src/qml/Settings/SettingsPage.qml +++ b/src/qml/Settings/SettingsPage.qml @@ -6,6 +6,10 @@ import org.kde.kirigami 2.18 as Kirigami import QtQuick.Layouts 1.15 Kirigami.CategorizedSettings { + id: settingsPage + + required property var connection + objectName: "settingsPage" actions: [ Kirigami.SettingAction { @@ -57,6 +61,11 @@ Kirigami.CategorizedSettings { text: i18n("Devices") icon.name: "computer" page: Qt.resolvedUrl("DevicesPage.qml") + initialProperties: { + return { + connection: settingsPage.connection + } + } }, Kirigami.SettingAction { actionName: "aboutNeochat" diff --git a/src/res.qrc b/src/res.qrc index f9bcbf7b8..5e1af11cc 100644 --- a/src/res.qrc +++ b/src/res.qrc @@ -108,6 +108,8 @@ qml/Settings/AccountsPage.qml qml/Settings/AccountEditorPage.qml qml/Settings/DevicesPage.qml + qml/Settings/DeviceDelegate.qml + qml/Settings/DevicesCard.qml qml/Settings/About.qml qml/Settings/AboutKDE.qml qml/Settings/SonnetConfigPage.qml