diff --git a/CMakeLists.txt b/CMakeLists.txt index 242744eb..d4724a1c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -491,6 +491,8 @@ set(SRC_FILES src/SingleImagePackModel.h src/TrayIcon.cpp src/TrayIcon.h + src/UserDirectoryModel.cpp + src/UserDirectoryModel.h src/UserSettingsPage.cpp src/UserSettingsPage.h src/UsersModel.cpp diff --git a/resources/qml/Root.qml b/resources/qml/Root.qml index 5f7d7229..f60ebac1 100644 --- a/resources/qml/Root.qml +++ b/resources/qml/Root.qml @@ -34,6 +34,10 @@ Pane { id: publicRooms } + UserDirectoryModel { + id: userDirectory + } + //Timer { // onTriggered: gc() // interval: 1000 @@ -198,11 +202,15 @@ Pane { } function onOpenInviteUsersDialog(invitees) { - var dialog = Qt.createComponent("qrc:/qml/dialogs/InviteDialog.qml").createObject(timelineRoot, { + var component = Qt.createComponent("qrc:/qml/dialogs/InviteDialog.qml") + var dialog = component.createObject(timelineRoot, { "roomId": Rooms.currentRoom.roomId, "plainRoomName": Rooms.currentRoom.plainRoomName, "invitees": invitees }); + if (component.status != Component.Ready) { + console.log("Failed to create component: " + component.errorString()); + } dialog.show(); destroyOnClose(dialog); } diff --git a/resources/qml/components/UserListRow.qml b/resources/qml/components/UserListRow.qml new file mode 100644 index 00000000..8cbbd195 --- /dev/null +++ b/resources/qml/components/UserListRow.qml @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2021 Nheko Contributors +// SPDX-FileCopyrightText: 2022 Nheko Contributors +// SPDX-FileCopyrightText: 2023 Nheko Contributors +// +// SPDX-License-Identifier: GPL-3.0-or-later + +import ".." +import QtQuick 2.12 +import QtQuick.Controls 2.12 +import QtQuick.Layouts 1.12 +import im.nheko 1.0 + +ItemDelegate { + property alias bgColor: background.color + property alias userid: avatar.userid + property alias displayName: avatar.displayName + property string avatarUrl + implicitHeight: layout.implicitHeight + Nheko.paddingSmall * 2 + background: Rectangle {id: background} + GridLayout { + id: layout + anchors.centerIn: parent + width: parent.width - Nheko.paddingSmall * 2 + rows: 2 + columns: 2 + rowSpacing: Nheko.paddingSmall + columnSpacing: Nheko.paddingMedium + + Avatar { + id: avatar + Layout.rowSpan: 2 + Layout.preferredWidth: Nheko.avatarSize + Layout.preferredHeight: Nheko.avatarSize + Layout.alignment: Qt.AlignLeft + url: avatarUrl.replace("mxc://", "image://MxcImage/") + enabled: false + } + Label { + Layout.fillWidth: true + text: displayName + color: TimelineManager.userColor(userid, Nheko.colors.window) + font.pointSize: fontMetrics.font.pointSize + } + + Label { + Layout.fillWidth: true + Layout.alignment: Qt.AlignTop + text: userid + color: Nheko.colors.buttonText + font.pointSize: fontMetrics.font.pointSize * 0.9 + } + } +} diff --git a/resources/qml/dialogs/InviteDialog.qml b/resources/qml/dialogs/InviteDialog.qml index 33fd32c4..20699a65 100644 --- a/resources/qml/dialogs/InviteDialog.qml +++ b/resources/qml/dialogs/InviteDialog.qml @@ -5,6 +5,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later import ".." +import "../components" import QtQuick 2.12 import QtQuick.Controls 2.12 import QtQuick.Layouts 1.12 @@ -16,17 +17,25 @@ ApplicationWindow { property string roomId property string plainRoomName property InviteesModel invitees + property var friendsCompleter + property var profile + minimumWidth: 300 - function addInvite() { - if (inviteeEntry.isValidMxid) { - invitees.addUser(inviteeEntry.text); - inviteeEntry.clear(); - } + Component.onCompleted: { + friendsCompleter = TimelineManager.completerFor("user", "friends") + width = 600 + } + + function addInvite(mxid, displayName, avatarUrl) { + if (mxid.match("@.+?:.{3,}")) { + invitees.addUser(mxid, displayName, avatarUrl); + } else + console.log("invalid mxid: " + mxid) } function cleanUpAndClose() { if (inviteeEntry.isValidMxid) - addInvite(); + addInvite(inviteeEntry.text, "", ""); invitees.accept(); close(); @@ -53,13 +62,40 @@ ApplicationWindow { anchors.fill: parent anchors.margins: Nheko.paddingMedium spacing: Nheko.paddingMedium + Flow { + layoutDirection: Qt.LeftToRight + Layout.fillWidth: true + Layout.preferredHeight: implicitHeight + spacing: 4 + visible: !inviteesList.visible + Repeater { + id: inviteesRepeater + model: invitees + delegate: ItemDelegate { + onClicked: invitees.removeUser(model.mxid) + id: inviteeButton + contentItem: Label { + anchors.centerIn: parent + id: inviteeUserid + text: model.displayName != "" ? model.displayName : model.userid + color: inviteeButton.hovered ? Nheko.colors.highlightedText: Nheko.colors.text + maximumLineCount: 1 + } + background: Rectangle { + border.color: Nheko.colors.text + color: inviteeButton.hovered ? Nheko.colors.highlight : Nheko.colors.window + border.width: 1 + radius: inviteeButton.height / 2 + } + } + } + } Label { - text: qsTr("User ID to invite") + text: qsTr("Search user") Layout.fillWidth: true color: Nheko.colors.text } - RowLayout { spacing: Nheko.paddingMedium @@ -72,9 +108,14 @@ ApplicationWindow { placeholderText: qsTr("@joe:matrix.org", "Example user id. The name 'joe' can be localized however you want.") Layout.fillWidth: true onAccepted: { - if (isValidMxid) - addInvite(); - + if (isValidMxid) { + addInvite(text, "", ""); + clear() + } + else if (userSearch.count > 0) { + addInvite(userSearch.itemAtIndex(0).userid, userSearch.itemAtIndex(0).displayName, userSearch.itemAtIndex(0).avatarUrl) + clear() + } } Component.onCompleted: forceActiveFocus() Keys.onShortcutOverride: event.accepted = ((event.key === Qt.Key_Return || event.key === Qt.Key_Enter) && (event.modifiers & Qt.ControlModifier)) @@ -83,85 +124,107 @@ ApplicationWindow { cleanUpAndClose(); } + onTextChanged: { + searchTimer.restart() + if(isValidMxid) { + profile = TimelineManager.getGlobalUserProfile(text); + } else + profile = null; + } + Timer { + id: searchTimer + + interval: 350 + onTriggered: { + userSearch.model.setSearchString(parent.text) + } + } } - Button { - text: qsTr("Add") - enabled: inviteeEntry.isValidMxid - onClicked: addInvite() + ToggleButton { + id: searchOnServer + checked: false + onClicked: userSearch.model.setSearchString(inviteeEntry.text) + } + MatrixText { + text: qsTr("Search on Server") } } - - ListView { - id: inviteesList - - Layout.fillWidth: true - Layout.fillHeight: true - model: invitees - - delegate: ItemDelegate { - id: del - - hoverEnabled: true - width: ListView.view.width - height: layout.implicitHeight + Nheko.paddingSmall * 2 - onClicked: TimelineManager.openGlobalUserProfile(model.mxid) - background: Rectangle { - color: del.hovered ? Nheko.colors.dark : inviteDialogRoot.color + RowLayout { + UserListRow { + visible: inviteeEntry.isValidMxid + id: del3 + Layout.preferredWidth: inviteDialogRoot.width/2 + Layout.alignment: Qt.AlignTop + Layout.preferredHeight: implicitHeight + displayName: profile? profile.displayName : "" + avatarUrl: profile? profile.avatarUrl : "" + userid: inviteeEntry.text + onClicked: addInvite(inviteeEntry.text, displayName, avatarUrl) + bgColor: del3.hovered ? Nheko.colors.dark : inviteDialogRoot.color + } + ListView { + visible: !inviteeEntry.isValidMxid + id: userSearch + model: searchOnServer.checked? userDirectory : friendsCompleter + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + delegate: UserListRow { + id: del2 + width: ListView.view.width + height: implicitHeight + displayName: model.displayName + userid: model.userid + avatarUrl: model.avatarUrl + onClicked: addInvite(userid, displayName, avatarUrl) + bgColor: del2.hovered ? Nheko.colors.dark : inviteDialogRoot.color } + } + Rectangle { + Layout.fillHeight: true + visible: inviteesList.visible + width: 1 + color: Nheko.theme.separator + } + ListView { + id: inviteesList - RowLayout { - id: layout - - spacing: Nheko.paddingMedium - anchors.centerIn: parent - width: del.width - Nheko.paddingSmall * 2 - - Avatar { - width: Nheko.avatarSize - height: Nheko.avatarSize - userid: model.mxid - url: model.avatarUrl.replace("mxc://", "image://MxcImage/") - displayName: model.displayName - enabled: false - } - - ColumnLayout { - spacing: Nheko.paddingSmall - - Label { - text: model.displayName - color: TimelineManager.userColor(model ? model.mxid : "", del.background.color) - font.pointSize: fontMetrics.font.pointSize - } - - Label { - text: model.mxid - color: del.hovered ? Nheko.colors.brightText : Nheko.colors.buttonText - font.pointSize: fontMetrics.font.pointSize * 0.9 - } - - } - - Item { - Layout.fillWidth: true - } - + Layout.fillWidth: true + Layout.fillHeight: true + model: invitees + clip: true + visible: inviteDialogRoot.width >= 500 + + delegate: UserListRow { + id: del + hoverEnabled: true + width: ListView.view.width + height: implicitHeight + onClicked: TimelineManager.openGlobalUserProfile(model.mxid) + userid: model.mxid + avatarUrl: model.avatarUrl + displayName: model.displayName + bgColor: del.hovered ? Nheko.colors.dark : inviteDialogRoot.color ImageButton { + anchors.right: parent.right + anchors.rightMargin: Nheko.paddingSmall + anchors.top: parent.top + anchors.topMargin: Nheko.paddingSmall + id: removeButton image: ":/icons/icons/ui/dismiss.svg" onClicked: invitees.removeUser(model.mxid) } - } + CursorShape { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + } - CursorShape { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor } } - } } diff --git a/resources/res.qrc b/resources/res.qrc index 297cd3e9..9eca9a98 100644 --- a/resources/res.qrc +++ b/resources/res.qrc @@ -133,6 +133,7 @@ qml/components/ReorderableListview.qml qml/components/SpaceMenuLevel.qml qml/components/TextButton.qml + qml/components/UserListRow.qml qml/delegates/Encrypted.qml qml/delegates/FileMessage.qml qml/delegates/ImageMessage.qml diff --git a/src/InviteesModel.cpp b/src/InviteesModel.cpp index 52dd4e43..cf78d63e 100644 --- a/src/InviteesModel.cpp +++ b/src/InviteesModel.cpp @@ -17,7 +17,7 @@ InviteesModel::InviteesModel(QObject *parent) } void -InviteesModel::addUser(QString mxid) +InviteesModel::addUser(QString mxid, QString displayName, QString avatarUrl) { for (const auto &invitee : qAsConst(invitees_)) if (invitee->mxid_ == mxid) @@ -25,7 +25,7 @@ InviteesModel::addUser(QString mxid) beginInsertRows(QModelIndex(), invitees_.count(), invitees_.count()); - auto invitee = new Invitee{mxid, this}; + auto invitee = new Invitee{mxid, displayName, avatarUrl, this}; auto indexOfInvitee = invitees_.count(); connect(invitee, &Invitee::userInfoLoaded, this, [this, indexOfInvitee]() { emit dataChanged(index(indexOfInvitee), index(indexOfInvitee)); @@ -85,21 +85,30 @@ InviteesModel::mxids() return mxidList; } -Invitee::Invitee(QString mxid, QObject *parent) +Invitee::Invitee(QString mxid, QString displayName, QString avatarUrl, QObject *parent) : QObject{parent} , mxid_{std::move(mxid)} { - http::client()->get_profile( - mxid_.toStdString(), [this](const mtx::responses::Profile &res, mtx::http::RequestErr err) { - if (err) { - nhlog::net()->warn("failed to retrieve profile info"); - emit userInfoLoaded(); - return; - } - - displayName_ = QString::fromStdString(res.display_name); - avatarUrl_ = QString::fromStdString(res.avatar_url); + // checking for empty avatarUrl will cause profiles that don't have an avatar + // to needlessly be loaded. Can we make sure we either provide both or none? + if (displayName == "" && avatarUrl == "") { + http::client()->get_profile( + mxid_.toStdString(), + [this](const mtx::responses::Profile &res, mtx::http::RequestErr err) { + if (err) { + nhlog::net()->warn("failed to retrieve profile info"); + emit userInfoLoaded(); + return; + } + + displayName_ = QString::fromStdString(res.display_name); + avatarUrl_ = QString::fromStdString(res.avatar_url); - emit userInfoLoaded(); - }); + emit userInfoLoaded(); + }); + } else { + displayName_ = displayName; + avatarUrl_ = avatarUrl; + emit userInfoLoaded(); + } } diff --git a/src/InviteesModel.h b/src/InviteesModel.h index 91b89a21..828f80e2 100644 --- a/src/InviteesModel.h +++ b/src/InviteesModel.h @@ -15,7 +15,10 @@ class Invitee final : public QObject Q_OBJECT public: - Invitee(QString mxid, QObject *parent = nullptr); + Invitee(QString mxid, + QString displayName = "", + QString avatarUrl = "", + QObject *parent = nullptr); signals: void userInfoLoaded(); @@ -44,7 +47,7 @@ public: InviteesModel(QObject *parent = nullptr); - Q_INVOKABLE void addUser(QString mxid); + Q_INVOKABLE void addUser(QString mxid, QString displayName = "", QString avatarUrl = ""); Q_INVOKABLE void removeUser(QString mxid); [[nodiscard]] QHash roleNames() const override; diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index 8c2b4c35..8b453346 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -38,6 +38,7 @@ #include "RoomsModel.h" #include "SingleImagePackModel.h" #include "TrayIcon.h" +#include "UserDirectoryModel.h" #include "UserSettingsPage.h" #include "UsersModel.h" #include "Utils.h" @@ -70,6 +71,7 @@ Q_DECLARE_METATYPE(std::vector) Q_DECLARE_METATYPE(std::vector) Q_DECLARE_METATYPE(mtx::responses::PublicRoom) Q_DECLARE_METATYPE(mtx::responses::Profile) +Q_DECLARE_METATYPE(mtx::responses::User) MainWindow *MainWindow::instance_ = nullptr; @@ -148,6 +150,7 @@ MainWindow::registerQmlTypes() qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); + qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); @@ -155,7 +158,9 @@ MainWindow::registerQmlTypes() qRegisterMetaType>(); qRegisterMetaType>(); + qRegisterMetaType>(); + qRegisterMetaType(); qmlRegisterUncreatableMetaObject(qml_mtx_events::staticMetaObject, "im.nheko", 1, @@ -185,6 +190,7 @@ MainWindow::registerQmlTypes() qmlRegisterType("im.nheko", 1, 0, "MxcAnimatedImage"); qmlRegisterType("im.nheko", 1, 0, "MxcMedia"); qmlRegisterType("im.nheko", 1, 0, "RoomDirectoryModel"); + qmlRegisterType("im.nheko", 1, 0, "UserDirectoryModel"); qmlRegisterType("im.nheko", 1, 0, "Login"); qmlRegisterType("im.nheko", 1, 0, "Registration"); qmlRegisterType("im.nheko", 1, 0, "HiddenEvents"); diff --git a/src/UserDirectoryModel.cpp b/src/UserDirectoryModel.cpp new file mode 100644 index 00000000..2c44df40 --- /dev/null +++ b/src/UserDirectoryModel.cpp @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: 2021 Nheko Contributors +// SPDX-FileCopyrightText: 2022 Nheko Contributors +// SPDX-FileCopyrightText: 2023 Nheko Contributors +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "UserDirectoryModel.h" + +#include "Cache.h" +#include "Logging.h" +#include +#include "MatrixClient.h" +#include "mtx/responses/users.hpp" + +UserDirectoryModel::UserDirectoryModel(QObject *parent) + : QAbstractListModel{parent} +{ +} + +QHash +UserDirectoryModel::roleNames() const +{ + return { + {Roles::DisplayName, "displayName"}, + {Roles::Mxid, "userid"}, + {Roles::AvatarUrl, "avatarUrl"}, + }; +} + +void +UserDirectoryModel::setSearchString(const QString &f) +{ + userSearchString_ = f.toStdString(); + nhlog::ui()->debug("Received user directory query: {}", userSearchString_); + beginResetModel(); + results_.clear(); + if (userSearchString_ == "") + nhlog::ui()->debug("Rejecting empty search string"); + else + canFetchMore_ = true; + endResetModel(); +} + +void +UserDirectoryModel::fetchMore(const QModelIndex &) +{ + if (!canFetchMore_) + return; + + nhlog::net()->debug("Fetching users from mtxclient..."); + std::string searchTerm = userSearchString_; + searchingUsers_ = true; + emit searchingUsersChanged(); + auto job = QSharedPointer::create(); + connect(job.data(), + &FetchUsersFromDirectoryJob::fetchedSearchResults, + this, + &UserDirectoryModel::displaySearchResults); + http::client()->search_user_directory( + searchTerm, + [job, searchTerm](const mtx::responses::Users &res, mtx::http::RequestErr err) { + if (err) { + nhlog::net()->error("Failed to retrieve users from mtxclient - {} - {} - {}", + mtx::errors::to_string(err->matrix_error.errcode), + err->matrix_error.error, + err->parse_error); + } else { + emit job->fetchedSearchResults(res.results, searchTerm); + } + }, + 50); +} + +QVariant +UserDirectoryModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() >= (int)results_.size() || index.row() < 0) + return {}; + switch (role) { + case Roles::DisplayName: + return QString::fromStdString(results_[index.row()].display_name); + case Roles::Mxid: + return QString::fromStdString(results_[index.row()].user_id); + case Roles::AvatarUrl: + return QString::fromStdString(results_[index.row()].avatar_url); + } + return {}; +} + +void +UserDirectoryModel::displaySearchResults(std::vector results, const std::string &searchTerm) +{ + if (searchTerm != this->userSearchString_) + return; + searchingUsers_ = false; + emit searchingUsersChanged(); + if (results.empty()) { + nhlog::net()->debug("mtxclient helper thread yielded no results!"); + return; + } + beginInsertRows(QModelIndex(), 0, static_cast(results.size()) - 1); + results_ = results; + endInsertRows(); + canFetchMore_ = false; +} diff --git a/src/UserDirectoryModel.h b/src/UserDirectoryModel.h new file mode 100644 index 00000000..87f8163c --- /dev/null +++ b/src/UserDirectoryModel.h @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: 2021 Nheko Contributors +// SPDX-FileCopyrightText: 2022 Nheko Contributors +// SPDX-FileCopyrightText: 2023 Nheko Contributors +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include + +#include + +class FetchUsersFromDirectoryJob final : public QObject +{ + Q_OBJECT +public: + explicit FetchUsersFromDirectoryJob(QObject *p = nullptr) + : QObject(p) + { + } +signals: + void fetchedSearchResults(std::vector results, const std::string &searchTerm); +}; +class UserDirectoryModel : public QAbstractListModel +{ + Q_OBJECT + + Q_PROPERTY(bool searchingUsers READ searchingUsers NOTIFY searchingUsersChanged) + +public: + explicit UserDirectoryModel(QObject *parent = nullptr); + + enum Roles + { + DisplayName, + Mxid, + AvatarUrl, + }; + QHash roleNames() const override; + + QVariant data(const QModelIndex &index, int role) const override; + + inline int rowCount(const QModelIndex &parent = QModelIndex()) const override + { + (void)parent; + return static_cast(results_.size()); + } + bool canFetchMore(const QModelIndex &) const override { return canFetchMore_; } + void fetchMore(const QModelIndex &) override; + +private: + std::vector results_; + std::string userSearchString_; + bool searchingUsers_{false}; + bool canFetchMore_{false}; + +signals: + void searchingUsersChanged(); + +public slots: + void setSearchString(const QString &f); + bool searchingUsers() const { return searchingUsers_; } + +private slots: + void displaySearchResults(std::vector results, const std::string &searchTerm); +}; diff --git a/src/UsersModel.cpp b/src/UsersModel.cpp index 0399bde6..5dc3b3f1 100644 --- a/src/UsersModel.cpp +++ b/src/UsersModel.cpp @@ -9,6 +9,7 @@ #include #include "Cache.h" +#include "Cache_p.h" #include "CompletionModelRoles.h" #include "UserSettingsPage.h" @@ -16,10 +17,29 @@ UsersModel::UsersModel(const std::string &roomId, QObject *parent) : QAbstractListModel(parent) , room_id(roomId) { - roomMembers_ = cache::roomMembers(roomId); - for (const auto &m : roomMembers_) { - displayNames.push_back(QString::fromStdString(cache::displayName(room_id, m))); - userids.push_back(QString::fromStdString(m)); + // obviously, "friends" isn't a room, but I felt this was the least invasive way + if (roomId == "friends") { + auto e = cache::client()->getAccountData(mtx::events::EventType::Direct); + if (e) { + if (auto event = + std::get_if>( + &e.value())) { + for (const auto &[userId, roomIds] : event->content.user_to_rooms) { + displayNames.push_back( + QString::fromStdString(cache::displayName(roomIds[0], userId))); + userids.push_back(QString::fromStdString(userId)); + avatarUrls.push_back(cache::avatarUrl(QString::fromStdString(roomIds[0]), + QString::fromStdString(userId))); + } + } + } + } else { + for (const auto &m : cache::roomMembers(roomId)) { + displayNames.push_back(QString::fromStdString(cache::displayName(room_id, m))); + userids.push_back(QString::fromStdString(m)); + avatarUrls.push_back( + cache::avatarUrl(QString::fromStdString(room_id), QString::fromStdString(m))); + } } } @@ -59,8 +79,7 @@ UsersModel::data(const QModelIndex &index, int role) const case CompletionModel::SearchRole2: return userids[index.row()]; case Roles::AvatarUrl: - return cache::avatarUrl(QString::fromStdString(room_id), - QString::fromStdString(roomMembers_[index.row()])); + return avatarUrls[index.row()]; case Roles::UserID: return userids[index.row()].toHtmlEscaped(); } diff --git a/src/UsersModel.h b/src/UsersModel.h index aa71990c..525d8f0d 100644 --- a/src/UsersModel.h +++ b/src/UsersModel.h @@ -23,13 +23,13 @@ public: int rowCount(const QModelIndex &parent = QModelIndex()) const override { (void)parent; - return (int)roomMembers_.size(); + return (int)userids.size(); } QVariant data(const QModelIndex &index, int role) const override; private: std::string room_id; - std::vector roomMembers_; + std::vector avatarUrls; std::vector displayNames; std::vector userids; };