add user search to invite dialog (#1253)

This commit is contained in:
Malte E 2023-02-01 00:59:49 +08:00 committed by GitHub
parent 3abb49c4a2
commit 5ed3bfc8f8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 437 additions and 99 deletions

View file

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

View file

@ -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);
}

View file

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

View file

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

View file

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

View file

@ -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");
// 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();
return;
}
displayName_ = QString::fromStdString(res.display_name);
avatarUrl_ = QString::fromStdString(res.avatar_url);
emit userInfoLoaded();
});
});
} else {
displayName_ = displayName;
avatarUrl_ = avatarUrl;
emit userInfoLoaded();
}
}

View file

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

View file

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

105
src/UserDirectoryModel.cpp Normal file
View file

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

69
src/UserDirectoryModel.h Normal file
View file

@ -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);
};

View file

@ -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();
}

View file

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