Devtools Improvements

- Now has tabs setup as more features are added
- First extra tab has basic server info
- Use mobileform to make it look nicer
- For the room data tab allow the room to be changed from within devtools
- For the room data tab allow m.room.member events to be filtered out so other event types can be found easily
- For the room data tab allow viewing room account data

network/neochat#557
This commit is contained in:
James Graham
2023-04-29 15:20:51 +00:00
committed by Tobias Fella
parent ca805917de
commit 85b40ca536
14 changed files with 347 additions and 23 deletions

View File

@@ -44,6 +44,7 @@ add_library(neochat STATIC
models/actionsmodel.cpp models/actionsmodel.cpp
models/serverlistmodel.cpp models/serverlistmodel.cpp
models/statemodel.cpp models/statemodel.cpp
models/statefiltermodel.cpp
filetransferpseudojob.cpp filetransferpseudojob.cpp
models/searchmodel.cpp models/searchmodel.cpp
texthandler.cpp texthandler.cpp

View File

@@ -769,3 +769,19 @@ QString Controller::activeAccountLabel() const
} }
return m_connection->accountDataJson("org.kde.neochat.account_label")["account_label"].toString(); return m_connection->accountDataJson("org.kde.neochat.account_label")["account_label"].toString();
} }
QVariantList Controller::getSupportedRoomVersions(Quotient::Connection *connection)
{
auto roomVersions = connection->availableRoomVersions();
QVariantList supportedRoomVersions;
for (const Quotient::Connection::SupportedRoomVersion &v : roomVersions) {
QVariantMap roomVersionMap;
roomVersionMap.insert("id", v.id);
roomVersionMap.insert("status", v.status);
roomVersionMap.insert("isStable", v.isStable());
supportedRoomVersions.append(roomVersionMap);
}
return supportedRoomVersions;
}

View File

@@ -214,6 +214,8 @@ public:
*/ */
Q_INVOKABLE void forceRefreshTextDocument(QQuickTextDocument *textDocument, QQuickItem *item); Q_INVOKABLE void forceRefreshTextDocument(QQuickTextDocument *textDocument, QQuickItem *item);
Q_INVOKABLE QVariantList getSupportedRoomVersions(Quotient::Connection *connection);
private: private:
explicit Controller(QObject *parent = nullptr); explicit Controller(QObject *parent = nullptr);

View File

@@ -71,6 +71,7 @@
#ifdef QUOTIENT_07 #ifdef QUOTIENT_07
#include "pollhandler.h" #include "pollhandler.h"
#endif #endif
#include "models/statefiltermodel.h"
#include "roommanager.h" #include "roommanager.h"
#include "spacehierarchycache.h" #include "spacehierarchycache.h"
#include "urlhelper.h" #include "urlhelper.h"
@@ -234,6 +235,7 @@ int main(int argc, char *argv[])
qmlRegisterType<LinkPreviewer>("org.kde.neochat", 1, 0, "LinkPreviewer"); qmlRegisterType<LinkPreviewer>("org.kde.neochat", 1, 0, "LinkPreviewer");
qmlRegisterType<CompletionModel>("org.kde.neochat", 1, 0, "CompletionModel"); qmlRegisterType<CompletionModel>("org.kde.neochat", 1, 0, "CompletionModel");
qmlRegisterType<StateModel>("org.kde.neochat", 1, 0, "StateModel"); qmlRegisterType<StateModel>("org.kde.neochat", 1, 0, "StateModel");
qmlRegisterType<StateFilterModel>("org.kde.neochat", 1, 0, "StateFilterModel");
qmlRegisterType<SearchModel>("org.kde.neochat", 1, 0, "SearchModel"); qmlRegisterType<SearchModel>("org.kde.neochat", 1, 0, "SearchModel");
#ifdef QUOTIENT_07 #ifdef QUOTIENT_07
qmlRegisterType<PollHandler>("org.kde.neochat", 1, 0, "PollHandler"); qmlRegisterType<PollHandler>("org.kde.neochat", 1, 0, "PollHandler");

View File

@@ -0,0 +1,32 @@
// SPDX-FileCopyrightText: 2022 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#include "statefiltermodel.h"
#include "statemodel.h"
bool StateFilterModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const
{
Q_UNUSED(sourceParent);
// No need to run the check if there are no items in m_stateEventTypesFiltered.
if (m_stateEventTypesFiltered.empty()) {
return true;
}
return !m_stateEventTypesFiltered.contains(sourceModel()->data(sourceModel()->index(sourceRow, 0), StateModel::TypeRole).toString());
}
void StateFilterModel::addStateEventTypeFiltered(const QString &stateEventType)
{
if (!m_stateEventTypesFiltered.contains(stateEventType)) {
m_stateEventTypesFiltered.append(stateEventType);
invalidateFilter();
}
}
void StateFilterModel::removeStateEventTypeFiltered(const QString &stateEventType)
{
if (m_stateEventTypesFiltered.contains(stateEventType)) {
m_stateEventTypesFiltered.removeAll(stateEventType);
invalidateFilter();
}
}

View File

@@ -0,0 +1,44 @@
// SPDX-FileCopyrightText: 2022 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#pragma once
#include <QSortFilterProxyModel>
/**
* @class StateFilterModel
*
* This class creates a custom QSortFilterProxyModel for filtering a list of state events.
* Event types can be filtered out by adding them to m_stateEventTypesFiltered.
*/
class StateFilterModel : public QSortFilterProxyModel
{
Q_OBJECT
public:
/**
* @brief Custom filter function checking if an event type has been filtered out.
*
* The filter rejects a row if the state event type has been added to m_stateEventTypesFiltered.
*
* @sa m_stateEventTypesFiltered
*/
bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override;
/**
* @brief Add an event type to m_stateEventTypesFiltered.
*
* @sa m_stateEventTypesFiltered
*/
Q_INVOKABLE void addStateEventTypeFiltered(const QString &stateEventType);
/**
* @brief Remove an event type from m_stateEventTypesFiltered.
*
* @sa m_stateEventTypesFiltered
*/
Q_INVOKABLE void removeStateEventTypeFiltered(const QString &stateEventType);
private:
QStringList m_stateEventTypesFiltered;
};

View File

@@ -10,7 +10,7 @@ StateModel::StateModel(QObject *parent)
QHash<int, QByteArray> StateModel::roleNames() const QHash<int, QByteArray> StateModel::roleNames() const
{ {
return {{TypeRole, "type"}, {StateKeyRole, "stateKey"}, {SourceRole, "source"}}; return {{TypeRole, "type"}, {StateKeyRole, "stateKey"}};
} }
QVariant StateModel::data(const QModelIndex &index, int role) const QVariant StateModel::data(const QModelIndex &index, int role) const
{ {
@@ -18,11 +18,9 @@ QVariant StateModel::data(const QModelIndex &index, int role) const
auto row = index.row(); auto row = index.row();
switch (role) { switch (role) {
case TypeRole: case TypeRole:
return m_room->currentState().events().keys()[row].first; return m_stateEvents[row].first;
case StateKeyRole: case StateKeyRole:
return m_room->currentState().events().keys()[row].second; return m_stateEvents[row].second;
case SourceRole:
return QJsonDocument(m_room->currentState().events()[m_room->currentState().events().keys()[row]]->fullJson()).toJson();
} }
#endif #endif
return {}; return {};
@@ -48,9 +46,27 @@ void StateModel::setRoom(NeoChatRoom *room)
m_room = room; m_room = room;
Q_EMIT roomChanged(); Q_EMIT roomChanged();
beginResetModel(); beginResetModel();
m_stateEvents.clear();
#ifdef QUOTIENT_07
m_stateEvents = m_room->currentState().events().keys();
#endif
endResetModel(); endResetModel();
connect(room, &NeoChatRoom::changed, this, [this] { connect(room, &NeoChatRoom::changed, this, [this] {
beginResetModel(); beginResetModel();
m_stateEvents.clear();
#ifdef QUOTIENT_07
m_stateEvents = m_room->currentState().events().keys();
#endif
endResetModel(); endResetModel();
}); });
} }
QByteArray StateModel::stateEventJson(const QModelIndex &index)
{
auto row = index.row();
#ifdef QUOTIENT_07
return QJsonDocument(m_room->currentState().events()[m_stateEvents[row]]->fullJson()).toJson();
#else
return {};
#endif
}

View File

@@ -26,9 +26,8 @@ public:
* @brief Defines the model roles. * @brief Defines the model roles.
*/ */
enum Roles { enum Roles {
TypeRole, /**< The type of the state event. */ TypeRole = 0, /**< The type of the state event. */
StateKeyRole, /**< The state key of the state event. */ StateKeyRole, /**< The state key of the state event. */
SourceRole, /**< The full event source JSON. */
}; };
Q_ENUM(Roles); Q_ENUM(Roles);
@@ -57,10 +56,25 @@ public:
* @sa Roles, QAbstractItemModel::roleNames() * @sa Roles, QAbstractItemModel::roleNames()
*/ */
QHash<int, QByteArray> roleNames() const override; QHash<int, QByteArray> roleNames() const override;
/**
* @brief Get the full JSON for an event.
*
* This is used to avoid having the model hold all the JSON data. The JSON for
* a single item is only ever shown, no need to access simultaneously.
*/
Q_INVOKABLE QByteArray stateEventJson(const QModelIndex &index);
Q_SIGNALS: Q_SIGNALS:
void roomChanged(); void roomChanged();
private: private:
NeoChatRoom *m_room = nullptr; NeoChatRoom *m_room = nullptr;
/**
* @brief The room state events in a QList.
*
* This is done for performance, accessing all the data from the parent QHash
* was slower.
*/
QList<std::pair<QString, QString>> m_stateEvents;
}; };

View File

@@ -1908,3 +1908,8 @@ void NeoChatRoom::sendLocation(float lat, float lon, const QString &description)
}; };
postJson("m.room.message", content); postJson("m.room.message", content);
} }
QByteArray NeoChatRoom::roomAcountDataJson(const QString &eventType)
{
return QJsonDocument(accountData(eventType)->fullJson()).toJson();
}

View File

@@ -749,6 +749,11 @@ public:
Q_INVOKABLE PollHandler *poll(const QString &eventId); Q_INVOKABLE PollHandler *poll(const QString &eventId);
#endif #endif
/**
* @brief Get the full Json data for a given room account data event.
*/
Q_INVOKABLE QByteArray roomAcountDataJson(const QString &eventType);
private: private:
QSet<const Quotient::RoomEvent *> highlights; QSet<const Quotient::RoomEvent *> highlights;

View File

@@ -0,0 +1,114 @@
// SPDX-FileCopyrightText: 2022 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
import QtQuick 2.15
import QtQuick.Controls 2.15 as QQC2
import QtQuick.Layouts 1.15
import org.kde.kirigami 2.15 as Kirigami
import org.kde.kirigamiaddons.labs.mobileform 0.1 as MobileForm
import org.kde.kitemmodels 1.0
import org.kde.neochat 1.0
ColumnLayout {
MobileForm.FormCard {
Layout.fillWidth: true
contentItem: ColumnLayout {
spacing: 0
MobileForm.FormComboBoxDelegate {
text: i18n("Room")
textRole: "name"
valueRole: "id"
model: RoomListModel {
id: roomListModel
connection: Controller.activeConnection
}
Component.onCompleted: currentIndex = indexOfValue(room.id)
onCurrentValueChanged: room = roomListModel.roomByAliasOrId(currentValue)
}
MobileForm.FormCheckDelegate {
text: i18n("Show m.room.member events")
checked: true
onToggled: {
if (checked) {
stateEventFilterModel.removeStateEventTypeFiltered("m.room.member");
} else {
stateEventFilterModel.addStateEventTypeFiltered("m.room.member");
}
}
}
MobileForm.FormCheckDelegate {
id: roomAccoutnDataVisibleCheck
text: i18n("Show room account data")
checked: false
}
}
}
MobileForm.FormCard {
Layout.fillWidth: true
visible: roomAccoutnDataVisibleCheck.checked
contentItem: ColumnLayout {
spacing: 0
MobileForm.FormCardHeader {
title: i18n("Room Account Data for %1 - %2", room.displayName, room.id)
}
Repeater {
model: room.accountDataEventTypes
delegate: MobileForm.FormTextDelegate {
text: modelData
onClicked: applicationWindow().pageStack.pushDialogLayer("qrc:/MessageSourceSheet.qml", {
"sourceText": room.roomAcountDataJson(text)
}, {
"title": i18n("Event Source"),
"width": Kirigami.Units.gridUnit * 25
})
}
}
}
}
MobileForm.FormCard {
Layout.fillWidth: true
Layout.fillHeight: true
contentItem: ColumnLayout {
spacing: 0
MobileForm.FormCardHeader {
id: stateEventListHeader
title: i18n("Room State for %1", room.displayName)
subtitle: room.id
}
QQC2.ScrollView {
Layout.fillWidth: true
Layout.fillHeight: true
// HACK: Hide unnecessary horizontal scrollbar (https://bugreports.qt.io/browse/QTBUG-83890)
QQC2.ScrollBar.horizontal.policy: QQC2.ScrollBar.AlwaysOff
ListView {
id: stateEventListView
clip: true
model: StateFilterModel {
id: stateEventFilterModel
sourceModel: StateModel {
id: stateModel
room: devtoolsPage.room
}
}
delegate: MobileForm.FormTextDelegate {
text: model.type
description: model.stateKey
onClicked: applicationWindow().pageStack.pushDialogLayer('qrc:/MessageSourceSheet.qml', {
sourceText: stateModel.stateEventJson(stateEventFilterModel.mapToSource(stateEventFilterModel.index(model.index, 0)))
}, {
title: i18n("Event Source"),
width: Kirigami.Units.gridUnit * 25
});
}
}
}
}
}
}

View File

@@ -0,0 +1,63 @@
// SPDX-FileCopyrightText: 2022 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
import QtQuick 2.15
import QtQuick.Controls 2.15 as QQC2
import QtQuick.Layouts 1.15
import org.kde.kirigami 2.15 as Kirigami
import org.kde.kirigamiaddons.labs.mobileform 0.1 as MobileForm
import org.kde.kitemmodels 1.0
import org.kde.neochat 1.0
ColumnLayout {
MobileForm.FormCard {
Layout.fillWidth: true
contentItem: ColumnLayout {
spacing: 0
MobileForm.FormCardHeader {
title: i18n("Server Capabilities")
}
MobileForm.FormTextDelegate {
text: i18n("Can change password")
description: Controller.activeConnection.canChangePassword
}
}
}
MobileForm.FormCard {
Layout.fillWidth: true
contentItem: ColumnLayout {
spacing: 0
MobileForm.FormCardHeader {
title: i18n("Default Room Version")
}
MobileForm.FormTextDelegate {
text: Controller.activeConnection.defaultRoomVersion
}
}
}
MobileForm.FormCard {
Layout.fillWidth: true
contentItem: ColumnLayout {
spacing: 0
MobileForm.FormCardHeader {
title: i18n("Available Room Versions")
}
Repeater {
model: Controller.getSupportedRoomVersions(room.connection)
delegate: MobileForm.FormTextDelegate {
text: modelData.id
contentItem.children: QQC2.Label {
text: modelData.status
color: Kirigami.Theme.disabledTextColor
}
}
}
}
}
Item {
Layout.fillHeight: true
}
}

View File

@@ -3,32 +3,40 @@
import QtQuick 2.15 import QtQuick 2.15
import QtQuick.Controls 2.15 as QQC2 import QtQuick.Controls 2.15 as QQC2
import QtQuick.Layouts 1.15
import org.kde.kirigami 2.20 as Kirigami import org.kde.kirigami 2.20 as Kirigami
import org.kde.neochat 1.0 import org.kde.neochat 1.0
Kirigami.ScrollablePage { Kirigami.Page {
id: devtoolsPage id: devtoolsPage
property var room property var room
title: i18n("Room State - %1", room.displayName) title: i18n("Developer Tools")
ListView { leftPadding: 0
anchors.fill: parent rightPadding: 0
model: StateModel {
room: devtoolsPage.room header: QQC2.TabBar {
id: tabBar
QQC2.TabButton {
text: qsTr("Room Data")
} }
QQC2.TabButton {
delegate: Kirigami.BasicListItem { text: qsTr("Server Info")
text: model.type
subtitle: model.stateKey
onClicked: applicationWindow().pageStack.pushDialogLayer('qrc:/MessageSourceSheet.qml', {
sourceText: model.source
}, {
title: i18n("Event Source"),
width: Kirigami.Units.gridUnit * 25
});
} }
} }
StackLayout {
id: swipeView
anchors.fill: parent
currentIndex: tabBar.currentIndex
RoomData {}
ServerData {}
}
} }

View File

@@ -41,6 +41,8 @@
<file alias="CompletionMenu.qml">qml/Component/ChatBox/CompletionMenu.qml</file> <file alias="CompletionMenu.qml">qml/Component/ChatBox/CompletionMenu.qml</file>
<file alias="PieProgressBar.qml">qml/Component/ChatBox/PieProgressBar.qml</file> <file alias="PieProgressBar.qml">qml/Component/ChatBox/PieProgressBar.qml</file>
<file alias="QuickFormatBar.qml">qml/Component/ChatBox/QuickFormatBar.qml</file> <file alias="QuickFormatBar.qml">qml/Component/ChatBox/QuickFormatBar.qml</file>
<file alias="RoomData.qml">qml/Component/Devtools/RoomData.qml</file>
<file alias="ServerData.qml">qml/Component/Devtools/ServerData.qml</file>
<file alias="EmojiPicker.qml">qml/Component/Emoji/EmojiPicker.qml</file> <file alias="EmojiPicker.qml">qml/Component/Emoji/EmojiPicker.qml</file>
<file alias="ReplyComponent.qml">qml/Component/Timeline/ReplyComponent.qml</file> <file alias="ReplyComponent.qml">qml/Component/Timeline/ReplyComponent.qml</file>
<file alias="StateDelegate.qml">qml/Component/Timeline/StateDelegate.qml</file> <file alias="StateDelegate.qml">qml/Component/Timeline/StateDelegate.qml</file>