summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--CMakeLists.txt2
-rw-r--r--resources/qml/Root.qml10
-rw-r--r--resources/qml/components/UserListRow.qml53
-rw-r--r--resources/qml/dialogs/InviteDialog.qml211
-rw-r--r--resources/res.qrc1
-rw-r--r--src/InviteesModel.cpp39
-rw-r--r--src/InviteesModel.h7
-rw-r--r--src/MainWindow.cpp6
-rw-r--r--src/UserDirectoryModel.cpp105
-rw-r--r--src/UserDirectoryModel.h69
-rw-r--r--src/UsersModel.cpp31
-rw-r--r--src/UsersModel.h4
12 files changed, 438 insertions, 100 deletions
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 @@
         <file>qml/components/ReorderableListview.qml</file>
         <file>qml/components/SpaceMenuLevel.qml</file>
         <file>qml/components/TextButton.qml</file>
+        <file>qml/components/UserListRow.qml</file>
         <file>qml/delegates/Encrypted.qml</file>
         <file>qml/delegates/FileMessage.qml</file>
         <file>qml/delegates/ImageMessage.qml</file>
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<int, QByteArray> 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<DeviceInfo>)
 Q_DECLARE_METATYPE(std::vector<mtx::responses::PublicRoomsChunk>)
 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<mtx::events::msg::KeyVerificationRequest>();
     qRegisterMetaType<mtx::events::msg::KeyVerificationStart>();
     qRegisterMetaType<mtx::responses::PublicRoom>();
+    qRegisterMetaType<mtx::responses::User>();
     qRegisterMetaType<mtx::responses::Profile>();
     qRegisterMetaType<CombinedImagePackModel *>();
     qRegisterMetaType<RoomSettingsAllowedRoomsModel *>();
@@ -155,7 +158,9 @@ MainWindow::registerQmlTypes()
     qRegisterMetaType<std::vector<DeviceInfo>>();
 
     qRegisterMetaType<std::vector<mtx::responses::PublicRoomsChunk>>();
+    qRegisterMetaType<std::vector<mtx::responses::User>>();
 
+    qRegisterMetaType<mtx::responses::User>();
     qmlRegisterUncreatableMetaObject(qml_mtx_events::staticMetaObject,
                                      "im.nheko",
                                      1,
@@ -185,6 +190,7 @@ MainWindow::registerQmlTypes()
     qmlRegisterType<MxcAnimatedImage>("im.nheko", 1, 0, "MxcAnimatedImage");
     qmlRegisterType<MxcMediaProxy>("im.nheko", 1, 0, "MxcMedia");
     qmlRegisterType<RoomDirectoryModel>("im.nheko", 1, 0, "RoomDirectoryModel");
+    qmlRegisterType<UserDirectoryModel>("im.nheko", 1, 0, "UserDirectoryModel");
     qmlRegisterType<LoginPage>("im.nheko", 1, 0, "Login");
     qmlRegisterType<RegisterPage>("im.nheko", 1, 0, "Registration");
     qmlRegisterType<HiddenEvents>("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 <QSharedPointer>
+#include "MatrixClient.h"
+#include "mtx/responses/users.hpp"
+
+UserDirectoryModel::UserDirectoryModel(QObject *parent)
+  : QAbstractListModel{parent}
+{
+}
+
+QHash<int, QByteArray>
+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<FetchUsersFromDirectoryJob>::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<mtx::responses::User> 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<int>(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 <QAbstractListModel>
+#include <QString>
+#include <string>
+#include <vector>
+
+#include <mtx/responses/users.hpp>
+
+class FetchUsersFromDirectoryJob final : public QObject
+{
+    Q_OBJECT
+public:
+    explicit FetchUsersFromDirectoryJob(QObject *p = nullptr)
+    : QObject(p)
+    {
+    }
+signals:
+    void fetchedSearchResults(std::vector<mtx::responses::User> 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<int, QByteArray> 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<int>(results_.size());
+    }
+    bool canFetchMore(const QModelIndex &) const override { return canFetchMore_; }
+    void fetchMore(const QModelIndex &) override;
+
+private:
+    std::vector<mtx::responses::User> 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<mtx::responses::User> 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 <QUrl>
 
 #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<mtx::events::AccountDataEvent<mtx::events::account_data::Direct>>(
+                    &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<std::string> roomMembers_;
+    std::vector<QString> avatarUrls;
     std::vector<QString> displayNames;
     std::vector<QString> userids;
 };