Improve DevicesPage and DevicesModel

- Split the list into sections for "this devices", "verified devices", "unverified devices", and "devices without encryption support"
- Sort the lists by last activity
This commit is contained in:
Tobias Fella
2023-03-17 23:59:55 +01:00
parent 7587a1a418
commit 8db2526153
13 changed files with 390 additions and 172 deletions

View File

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

View File

@@ -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<SortFilterRoomListModel>("org.kde.neochat", 1, 0, "SortFilterRoomListModel");
qmlRegisterType<SortFilterSpaceListModel>("org.kde.neochat", 1, 0, "SortFilterSpaceListModel");
qmlRegisterType<DevicesModel>("org.kde.neochat", 1, 0, "DevicesModel");
qmlRegisterType<DevicesProxyModel>("org.kde.neochat", 1, 0, "DevicesProxyModel");
qmlRegisterType<LinkPreviewer>("org.kde.neochat", 1, 0, "LinkPreviewer");
qmlRegisterType<CompletionModel>("org.kde.neochat", 1, 0, "CompletionModel");
qmlRegisterType<StateModel>("org.kde.neochat", 1, 0, "StateModel");

View File

@@ -6,6 +6,7 @@
#include <csapi/device_management.h>
#include "controller.h"
#include <KLocalizedString>
#include <connection.h>
#include <user.h>
@@ -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<int, QByteArray> 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<NeochatDeleteDeviceJob>(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<UpdateDeviceJob>(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"

View File

@@ -5,6 +5,7 @@
#include <QAbstractListModel>
#include <QPointer>
#include <csapi/definitions/client_device.h>
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<int, QByteArray> 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<Quotient::Device> m_devices;
QPointer<Quotient::Connection> m_connection;
};

View File

@@ -0,0 +1,28 @@
// SPDX-FileCopyrightText: 2023 Tobias Fella <tobias.fella@kde.org>
// 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);
}

View File

@@ -0,0 +1,25 @@
// SPDX-FileCopyrightText: 2023 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <QSortFilterProxyModel>
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;
};

View File

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

View File

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

View File

@@ -0,0 +1,134 @@
// SPDX-FileCopyrightText: 2020 - 2023 Tobias Fella <tobias.fella@kde.org>
// SPDX-FileCopyrightText: 2022 James Graham <james.h.graham@protonmail.com>
// 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
}
}
}
}

View File

@@ -0,0 +1,56 @@
// SPDX-FileCopyrightText: 2020 - 2023 Tobias Fella <tobias.fella@kde.org>
// SPDX-FileCopyrightText: 2022 James Graham <james.h.graham@protonmail.com>
// 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
}
}
}
}
}

View File

@@ -1,4 +1,5 @@
// SPDX-FileCopyrightText: Tobias Fella <tobias.fella@kde.org>
// SPDX-FileCopyrightText: 2020 - 2023 Tobias Fella <tobias.fella@kde.org>
// SPDX-FileCopyrightText: 2022 James Graham <james.h.graham@protonmail.com>
// 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()
}

View File

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

View File

@@ -108,6 +108,8 @@
<file alias="AccountsPage.qml">qml/Settings/AccountsPage.qml</file>
<file alias="AccountEditorPage.qml">qml/Settings/AccountEditorPage.qml</file>
<file alias="DevicesPage.qml">qml/Settings/DevicesPage.qml</file>
<file alias="DeviceDelegate.qml">qml/Settings/DeviceDelegate.qml</file>
<file alias="DevicesCard.qml">qml/Settings/DevicesCard.qml</file>
<file alias="About.qml">qml/Settings/About.qml</file>
<file alias="AboutKDE.qml">qml/Settings/AboutKDE.qml</file>
<file alias="SonnetConfigPage.qml">qml/Settings/SonnetConfigPage.qml</file>