diff --git a/CMakeLists.txt b/CMakeLists.txt index 3722787cf..df4dc0e84 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -144,6 +144,7 @@ set(spectral_SRCS src/trayicon.h src/userlistmodel.h src/publicroomlistmodel.h + src/userdirectorylistmodel.h src/utils.h src/accountlistmodel.cpp src/controller.cpp @@ -157,6 +158,7 @@ set(spectral_SRCS src/trayicon.cpp src/userlistmodel.cpp src/publicroomlistmodel.cpp + src/userdirectorylistmodel.cpp src/utils.cpp src/main.cpp ) diff --git a/imports/Spectral/Dialog/AccountDetailDialog.qml b/imports/Spectral/Dialog/AccountDetailDialog.qml index 2846ec6fd..75b598b8a 100644 --- a/imports/Spectral/Dialog/AccountDetailDialog.qml +++ b/imports/Spectral/Dialog/AccountDetailDialog.qml @@ -118,6 +118,36 @@ Dialog { } } + Control { + width: parent.width + + contentItem: RowLayout { + MaterialIcon { + Layout.preferredWidth: 48 + Layout.preferredHeight: 48 + + color: MPalette.foreground + icon: "\ue7ff" + } + + Label { + Layout.fillWidth: true + + color: MPalette.foreground + text: "Start a Chat" + } + } + + RippleEffect { + anchors.fill: parent + + onPrimaryClicked: { + startChatDialog.createObject(ApplicationWindow.overlay, {"controller": spectralController, "connection": spectralController.connection}).open() + root.close() + } + } + } + Control { width: parent.width diff --git a/imports/Spectral/Dialog/InviteUserDialog.qml b/imports/Spectral/Dialog/InviteUserDialog.qml index f56858ad0..b14e4da2d 100644 --- a/imports/Spectral/Dialog/InviteUserDialog.qml +++ b/imports/Spectral/Dialog/InviteUserDialog.qml @@ -3,26 +3,180 @@ import QtQuick.Controls 2.12 import QtQuick.Layouts 1.12 import Spectral.Component 2.0 +import Spectral.Effect 2.0 +import Spectral.Setting 0.1 + +import Spectral 0.1 Dialog { + property var controller property var room anchors.centerIn: parent - width: 360 + width: 480 + height: window.height - 100 id: root - title: "Invite User" + title: "Invite a User" - modal: true - standardButtons: Dialog.Ok | Dialog.Cancel + contentItem: ColumnLayout { + spacing: 0 - contentItem: AutoTextField { - id: inviteUserDialogTextField - placeholderText: "User ID" + RowLayout { + Layout.fillWidth: true + + AutoTextField { + property bool isUserID: text.match(/@(.+):(.+)/g) + + Layout.fillWidth: true + + id: identifierField + + placeholderText: "Find a user..." + + onAccepted: { + userDictListModel.search() + } + } + + Button { + visible: identifierField.isUserID + + text: "Add" + highlighted: true + + onClicked: { + room.inviteToRoom(identifierField.text) + } + } + } + + MenuSeparator { + Layout.fillWidth: true + } + + AutoListView { + Layout.fillWidth: true + Layout.fillHeight: true + + id: userDictListView + + clip: true + + spacing: 4 + + model: UserDirectoryListModel { + id: userDictListModel + + connection: root.room.connection + keyword: identifierField.text + } + + delegate: Control { + property bool inRoom: room && room.containsUser(userID) + + width: userDictListView.width + height: 48 + + id: delegate + + padding: 8 + + contentItem: RowLayout { + spacing: 8 + + Avatar { + Layout.preferredWidth: height + Layout.fillHeight: true + + source: avatar + hint: name + } + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + + spacing: 0 + + Label { + Layout.fillWidth: true + Layout.fillHeight: true + + text: name + color: MPalette.foreground + font.pixelSize: 13 + textFormat: Text.PlainText + elide: Text.ElideRight + wrapMode: Text.NoWrap + } + + Label { + Layout.fillWidth: true + Layout.fillHeight: true + + text: userID + color: MPalette.lighter + font.pixelSize: 10 + textFormat: Text.PlainText + elide: Text.ElideRight + wrapMode: Text.NoWrap + } + } + + Control { + Layout.preferredWidth: 32 + Layout.preferredHeight: 32 + + visible: inRoom + + contentItem: MaterialIcon { + icon: "\ue89e" + color: MPalette.lighter + font.pixelSize: 20 + } + + background: RippleEffect { + circular: true + } + } + + Control { + Layout.preferredWidth: 32 + Layout.preferredHeight: 32 + + visible: !inRoom + + contentItem: MaterialIcon { + icon: "\ue7fe" + color: MPalette.lighter + font.pixelSize: 20 + } + + background: RippleEffect { + circular: true + + onClicked: { + room.inviteToRoom(userID) + } + } + } + } + } + + ScrollBar.vertical: ScrollBar {} + + Label { + anchors.centerIn: parent + + visible: userDictListView.count < 1 + + text: "No users available" + color: MPalette.foreground + } + } } - onAccepted: room.inviteToRoom(inviteUserDialogTextField.text) - onClosed: destroy() } diff --git a/imports/Spectral/Dialog/JoinRoomDialog.qml b/imports/Spectral/Dialog/JoinRoomDialog.qml index 76d7a1d00..c8df13dbd 100644 --- a/imports/Spectral/Dialog/JoinRoomDialog.qml +++ b/imports/Spectral/Dialog/JoinRoomDialog.qml @@ -58,7 +58,7 @@ Dialog { if (identifierField.isJoined) { roomListForm.joinRoom(identifierField.room) } else { - spectralController.joinRoom(connection, identifierField.text) + controller.joinRoom(connection, identifierField.text) } } } @@ -248,7 +248,7 @@ Dialog { circular: true onClicked: { - spectralController.joinRoom(connection, roomID) + controller.joinRoom(connection, roomID) root.close() } } diff --git a/imports/Spectral/Dialog/StartChatDialog.qml b/imports/Spectral/Dialog/StartChatDialog.qml new file mode 100644 index 000000000..273f7cd12 --- /dev/null +++ b/imports/Spectral/Dialog/StartChatDialog.qml @@ -0,0 +1,182 @@ +import QtQuick 2.12 +import QtQuick.Controls 2.12 +import QtQuick.Layouts 1.12 + +import Spectral.Component 2.0 +import Spectral.Effect 2.0 +import Spectral.Setting 0.1 + +import Spectral 0.1 + +Dialog { + property var controller + property var connection + + anchors.centerIn: parent + width: 480 + height: window.height - 100 + + id: root + + title: "Start a Chat" + + contentItem: ColumnLayout { + spacing: 0 + + RowLayout { + Layout.fillWidth: true + + AutoTextField { + property bool isUserID: text.match(/@(.+):(.+)/g) + + Layout.fillWidth: true + + id: identifierField + + placeholderText: "Find a user..." + + onAccepted: { + userDictListModel.search() + } + } + + Button { + visible: identifierField.isUserID + + text: "Chat" + highlighted: true + + onClicked: { + controller.createDirectChat(connection, identifierField.text) + } + } + } + + MenuSeparator { + Layout.fillWidth: true + } + + AutoListView { + Layout.fillWidth: true + Layout.fillHeight: true + + id: userDictListView + + clip: true + + spacing: 4 + + model: UserDirectoryListModel { + id: userDictListModel + + connection: root.connection + keyword: identifierField.text + } + + delegate: Control { + width: userDictListView.width + height: 48 + + padding: 8 + + contentItem: RowLayout { + spacing: 8 + + Avatar { + Layout.preferredWidth: height + Layout.fillHeight: true + + source: avatar + hint: name + } + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + + spacing: 0 + + Label { + Layout.fillWidth: true + Layout.fillHeight: true + + text: name + color: MPalette.foreground + font.pixelSize: 13 + textFormat: Text.PlainText + elide: Text.ElideRight + wrapMode: Text.NoWrap + } + + Label { + Layout.fillWidth: true + Layout.fillHeight: true + + text: userID + color: MPalette.lighter + font.pixelSize: 10 + textFormat: Text.PlainText + elide: Text.ElideRight + wrapMode: Text.NoWrap + } + } + + Control { + Layout.preferredWidth: 32 + Layout.preferredHeight: 32 + + visible: directChats != null + + contentItem: MaterialIcon { + icon: "\ue89e" + color: MPalette.lighter + font.pixelSize: 20 + } + + background: RippleEffect { + circular: true + + onClicked: { + roomListForm.joinRoom(connection.room(directChats[0])) + root.close() + } + } + } + + Control { + Layout.preferredWidth: 32 + Layout.preferredHeight: 32 + + contentItem: MaterialIcon { + icon: "\ue7f0" + color: MPalette.lighter + font.pixelSize: 20 + } + + background: RippleEffect { + circular: true + + onClicked: { + controller.createDirectChat(connection, userID) + root.close() + } + } + } + } + } + + ScrollBar.vertical: ScrollBar {} + + Label { + anchors.centerIn: parent + + visible: userDictListView.count < 1 + + text: "No users available" + color: MPalette.foreground + } + } + } + + onClosed: destroy() +} diff --git a/imports/Spectral/Dialog/qmldir b/imports/Spectral/Dialog/qmldir index 51a130a89..70ca5b9e6 100644 --- a/imports/Spectral/Dialog/qmldir +++ b/imports/Spectral/Dialog/qmldir @@ -12,3 +12,4 @@ AccountDetailDialog 2.0 AccountDetailDialog.qml OpenFileDialog 2.0 OpenFileDialog.qml OpenFolderDialog 2.0 OpenFolderDialog.qml ImageClipboardDialog 2.0 ImageClipboardDialog.qml +StartChatDialog 2.0 StartChatDialog.qml diff --git a/imports/Spectral/Panel/RoomDrawer.qml b/imports/Spectral/Panel/RoomDrawer.qml index 5e9e1a930..48561703e 100644 --- a/imports/Spectral/Panel/RoomDrawer.qml +++ b/imports/Spectral/Panel/RoomDrawer.qml @@ -195,7 +195,7 @@ Drawer { color: MPalette.lighter } - onClicked: inviteUserDialog.createObject(ApplicationWindow.overlay, {"room": room}).open() + onClicked: inviteUserDialog.createObject(ApplicationWindow.overlay, {"controller": spectralController, "room": room}).open() } } @@ -249,6 +249,12 @@ Drawer { } } + onRoomChanged: { + if (room == null) { + close() + } + } + Component { id: roomSettingDialog diff --git a/qml/main.qml b/qml/main.qml index 74e30214a..11a55e4e0 100644 --- a/qml/main.qml +++ b/qml/main.qml @@ -135,6 +135,12 @@ ApplicationWindow { JoinRoomDialog {} } + Component { + id: startChatDialog + + StartChatDialog {} + } + Component { id: createRoomDialog diff --git a/res.qrc b/res.qrc index b4c8ae782..9bb99b5b2 100644 --- a/res.qrc +++ b/res.qrc @@ -58,5 +58,6 @@ imports/Spectral/Component/AutoRectangle.qml imports/Spectral/Component/Timeline/ReactionDelegate.qml imports/Spectral/Component/Timeline/AudioDelegate.qml + imports/Spectral/Dialog/StartChatDialog.qml diff --git a/src/main.cpp b/src/main.cpp index f7676fdcc..babe5d8d0 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -20,6 +20,7 @@ #include "spectralroom.h" #include "spectraluser.h" #include "trayicon.h" +#include "userdirectorylistmodel.h" #include "userlistmodel.h" using namespace Quotient; @@ -42,6 +43,8 @@ int main(int argc, char* argv[]) { qmlRegisterType("Spectral", 0, 1, "UserListModel"); qmlRegisterType("Spectral", 0, 1, "MessageEventModel"); qmlRegisterType("Spectral", 0, 1, "PublicRoomListModel"); + qmlRegisterType("Spectral", 0, 1, + "UserDirectoryListModel"); qmlRegisterType("Spectral", 0, 1, "EmojiModel"); qmlRegisterType("Spectral", 0, 1, "NotificationsManager"); diff --git a/src/spectralroom.cpp b/src/spectralroom.cpp index 110752bcb..40ed9b92c 100644 --- a/src/spectralroom.cpp +++ b/src/spectralroom.cpp @@ -573,3 +573,12 @@ void SpectralRoom::toggleReaction(const QString& eventId, postReaction(eventId, reaction); } } + +bool SpectralRoom::containsUser(QString userID) const { + auto u = Room::user(userID); + + if (!u) + return false; + + return Room::memberJoinState(u) != JoinState::Leave; +} diff --git a/src/spectralroom.h b/src/spectralroom.h index a051091e9..8fd0bf8cb 100644 --- a/src/spectralroom.h +++ b/src/spectralroom.h @@ -73,6 +73,8 @@ class SpectralRoom : public Room { Qt::TextFormat format = Qt::PlainText, bool removeReply = true) const; + Q_INVOKABLE bool containsUser(QString userID) const; + private: QString m_cachedInput; QSet highlights; diff --git a/src/userdirectorylistmodel.cpp b/src/userdirectorylistmodel.cpp new file mode 100644 index 000000000..b109f2bb1 --- /dev/null +++ b/src/userdirectorylistmodel.cpp @@ -0,0 +1,156 @@ +#include "userdirectorylistmodel.h" + +UserDirectoryListModel::UserDirectoryListModel(QObject* parent) + : QAbstractListModel(parent) {} + +void UserDirectoryListModel::setConnection(Connection* conn) { + if (m_connection == conn) + return; + + beginResetModel(); + + m_limited = false; + attempted = false; + users.clear(); + + if (m_connection) { + m_connection->disconnect(this); + } + + endResetModel(); + + m_connection = conn; + + if (job) { + job->abandon(); + job = nullptr; + } + + emit connectionChanged(); + emit limitedChanged(); +} + +void UserDirectoryListModel::setKeyword(const QString& value) { + if (m_keyword == value) + return; + + m_keyword = value; + + m_limited = false; + attempted = false; + + if (job) { + job->abandon(); + job = nullptr; + } + + emit keywordChanged(); + emit limitedChanged(); +} + +void UserDirectoryListModel::search(int count) { + if (count < 1) + return; + + if (job) { + qDebug() << "UserDirectoryListModel: Other jobs running, ignore"; + + return; + } + + if (attempted) + return; + + job = m_connection->callApi(m_keyword, count); + + connect(job, &BaseJob::finished, this, [=] { + attempted = true; + + if (job->status() == BaseJob::Success) { + auto users = job->results(); + + this->beginResetModel(); + + this->users = users; + this->m_limited = job->limited(); + + this->endResetModel(); + } + + this->job = nullptr; + + emit limitedChanged(); + }); +} + +QVariant UserDirectoryListModel::data(const QModelIndex& index, + int role) const { + if (!index.isValid()) + return QVariant(); + + if (index.row() >= users.count()) { + qDebug() << "UserDirectoryListModel, something's wrong: index.row() >= " + "users.count()"; + return {}; + } + auto user = users.at(index.row()); + if (role == NameRole) { + auto displayName = user.displayName; + if (!displayName.isEmpty()) { + return displayName; + } + + displayName = user.userId; + if (!displayName.isEmpty()) { + return displayName; + } + + return "Unknown User"; + } + if (role == AvatarRole) { + auto avatarUrl = user.avatarUrl; + + if (avatarUrl.isEmpty()) { + return ""; + } + + return avatarUrl.remove(0, 6); + } + if (role == UserIDRole) { + return user.userId; + } + if (role == DirectChatsRole) { + if (!m_connection) + return {}; + + auto userObj = m_connection->user(user.userId); + auto directChats = m_connection->directChats(); + + if (userObj && directChats.contains(userObj)) { + auto directChatsForUser = directChats.values(userObj); + if (!directChatsForUser.isEmpty()) { + return QVariant::fromValue(directChatsForUser); + } + } + } + + return {}; +} + +QHash UserDirectoryListModel::roleNames() const { + QHash roles; + + roles[NameRole] = "name"; + roles[AvatarRole] = "avatar"; + roles[UserIDRole] = "userID"; + roles[DirectChatsRole] = "directChats"; + + return roles; +} + +int UserDirectoryListModel::rowCount(const QModelIndex& parent) const { + if (parent.isValid()) + return 0; + + return users.count(); +} diff --git a/src/userdirectorylistmodel.h b/src/userdirectorylistmodel.h new file mode 100644 index 000000000..be76b4902 --- /dev/null +++ b/src/userdirectorylistmodel.h @@ -0,0 +1,62 @@ +#ifndef USERDIRECTORYLISTMODEL_H +#define USERDIRECTORYLISTMODEL_H + +#include +#include + +#include "connection.h" +#include "csapi/users.h" + +using namespace Quotient; + +class UserDirectoryListModel : public QAbstractListModel { + Q_OBJECT + Q_PROPERTY(Connection* connection READ connection WRITE setConnection NOTIFY + connectionChanged) + Q_PROPERTY( + QString keyword READ keyword WRITE setKeyword NOTIFY keywordChanged) + Q_PROPERTY(bool limited READ limited NOTIFY limitedChanged) + + public: + enum EventRoles { + NameRole = Qt::DisplayRole + 1, + AvatarRole, + UserIDRole, + DirectChatsRole, + }; + + UserDirectoryListModel(QObject* parent = nullptr); + + QVariant data(const QModelIndex& index, int role = NameRole) const override; + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + + QHash roleNames() const override; + + Connection* connection() const { return m_connection; } + void setConnection(Connection* value); + + QString keyword() const { return m_keyword; } + void setKeyword(const QString& value); + + bool limited() const { return m_limited; } + + Q_INVOKABLE void search(int count = 50); + + private: + Connection* m_connection = nullptr; + QString m_keyword; + bool m_limited = false; + + bool attempted = false; + + QVector users; + + SearchUserDirectoryJob* job = nullptr; + + signals: + void connectionChanged(); + void keywordChanged(); + void limitedChanged(); +}; + +#endif // USERDIRECTORYLISTMODEL_H