diff --git a/matrique.pro b/matrique.pro index 85e6a42..5ba689a 100644 --- a/matrique.pro +++ b/matrique.pro @@ -29,7 +29,8 @@ SOURCES += src/main.cpp \ src/emojimodel.cpp \ src/matriqueroom.cpp \ src/userlistmodel.cpp \ - src/imageitem.cpp + src/imageitem.cpp \ + src/accountlistmodel.cpp RESOURCES += \ res.qrc @@ -89,4 +90,5 @@ HEADERS += \ src/emojimodel.h \ src/matriqueroom.h \ src/userlistmodel.h \ - src/imageitem.h + src/imageitem.h \ + src/accountlistmodel.h diff --git a/qml/Login.qml b/qml/Login.qml index 25b6db9..6ba5c19 100644 --- a/qml/Login.qml +++ b/qml/Login.qml @@ -151,11 +151,6 @@ Page { return } - var replaceViewFunction = function() { - if (matriqueController.isLogin) stackView.replace(roomPage) - matriqueController.isLoginChanged.disconnect(replaceViewFunction) - } - matriqueController.isLoginChanged.connect(replaceViewFunction) controller.loginWithCredentials(serverField.text, usernameField.text, passwordField.text) } } diff --git a/qml/Setting.qml b/qml/Setting.qml index 2fcf080..367d3fb 100644 --- a/qml/Setting.qml +++ b/qml/Setting.qml @@ -8,52 +8,64 @@ import "component" import "form" Page { - property var connection +// Page { +// id: accountForm +// parent: null - Page { +// padding: 64 + +// ColumnLayout { +// RowLayout { +// Layout.preferredHeight: 60 + +// ImageStatus { +// Layout.preferredWidth: height +// Layout.fillHeight: true + +// source: matriqueController.isLogin ? connection.localUser && connection.localUser.avatarUrl ? "image://mxc/" + connection.localUser.avatarUrl : "" : "qrc:/asset/img/avatar.png" +// displayText: matriqueController.isLogin && connection.localUser.displayName ? connection.localUser.displayName : "" +// } + +// ColumnLayout { +// Layout.fillWidth: true +// Layout.fillHeight: true + +// Label { +// font.pointSize: 18 +// text: matriqueController.isLogin ? connection.localUser.displayName : "" +// } + +// Label { +// font.pointSize: 12 +// text: matriqueController.isLogin ? connection.localUser.id : "" +// } +// } +// } + +// Button { +// text: "Logout" +// highlighted: true + +// onClicked: { +// matriqueController.logout() +// Qt.quit() +// } +// } +// } +// } + + Page{ id: accountForm + parent: null - padding: 64 +// Button { +// flat: true +// highlighted: true +// text: "Login" - ColumnLayout { - RowLayout { - Layout.preferredHeight: 60 - - ImageStatus { - Layout.preferredWidth: height - Layout.fillHeight: true - - source: matriqueController.isLogin ? connection.localUser && connection.localUser.avatarUrl ? "image://mxc/" + connection.localUser.avatarUrl : "" : "qrc:/asset/img/avatar.png" - displayText: matriqueController.isLogin && connection.localUser.displayName ? connection.localUser.displayName : "" - } - - ColumnLayout { - Layout.fillWidth: true - Layout.fillHeight: true - - Label { - font.pointSize: 18 - text: matriqueController.isLogin ? connection.localUser.displayName : "" - } - - Label { - font.pointSize: 12 - text: matriqueController.isLogin ? connection.localUser.id : "" - } - } - } - - Button { - text: "Logout" - highlighted: true - - onClicked: { - matriqueController.logout() - Qt.quit() - } - } - } +// onClicked: stackView.push(loginPage) +// } } Page { diff --git a/qml/main.qml b/qml/main.qml index e72f662..58bd27f 100644 --- a/qml/main.qml +++ b/qml/main.qml @@ -25,12 +25,6 @@ ApplicationWindow { Material.theme: MSettings.darkTheme ? Material.Dark : Material.Light - Settings { - property alias homeserver: matriqueController.homeserver - property alias userID: matriqueController.userID - property alias token: matriqueController.token - } - FontLoader { id: materialFont; source: "qrc:/asset/font/material.ttf" } Controller { @@ -77,15 +71,13 @@ ApplicationWindow { parent: null - connection: window.connection + connection: accountListView.currentConnection } Setting { id: settingPage parent: null - - connection: window.connection } RowLayout { @@ -104,25 +96,34 @@ ApplicationWindow { anchors.fill: parent spacing: 0 - SideNavButton { + ListView { + property var currentConnection: null + Layout.fillWidth: true - Layout.preferredHeight: width - - ImageStatus { - anchors.fill: parent - anchors.margins: 12 - - source: matriqueController.isLogin ? connection.localUser && connection.localUser.avatarUrl ? "image://mxc/" + connection.localUser.avatarUrl : "" : "qrc:/asset/img/avatar.png" - displayText: matriqueController.isLogin && connection.localUser.displayName ? connection.localUser.displayName : "" - } - - page: roomPage - } - - Rectangle { Layout.fillHeight: true - color: "transparent" + id: accountListView + + model: AccountListModel { controller: matriqueController } + + spacing: 0 + + delegate: SideNavButton { + width: parent.width + height: width + + ImageStatus { + anchors.fill: parent + anchors.margins: 12 + +// source: matriqueController.isLogin ? connection.localUser && connection.localUser.avatarUrl ? "image://mxc/" + connection.localUser.avatarUrl : "" : "qrc:/asset/img/avatar.png" + displayText: name + } + + page: roomPage + + onClicked: accountListView.currentConnection = connection + } } SideNavButton { @@ -241,7 +242,20 @@ ApplicationWindow { anchors.fill: parent icon: "\ue8b8" - color: parent.highlighted ? Material.accent : "white" + color: "white" + } + page: loginPage + } + + SideNavButton { + Layout.fillWidth: true + Layout.preferredHeight: width + + MaterialIcon { + anchors.fill: parent + + icon: "\ue8b8" + color: "white" } page: settingPage } @@ -272,13 +286,9 @@ ApplicationWindow { } } - Component.onCompleted: { - imageProvider.connection = matriqueController.connection - - if (matriqueController.userID && matriqueController.token) { - matriqueController.login(); - } else { - stackView.replace(loginPage); - } + Binding { + target: imageProvider + property: "connection" + value: matriqueController.connection } } diff --git a/src/accountlistmodel.cpp b/src/accountlistmodel.cpp new file mode 100644 index 0000000..e54c0e7 --- /dev/null +++ b/src/accountlistmodel.cpp @@ -0,0 +1,71 @@ +#include "accountlistmodel.h" + +AccountListModel::AccountListModel(QObject* parent) + : QAbstractListModel(parent) {} + +void AccountListModel::setController(Controller* value) { + if (m_controller != value) { + beginResetModel(); + m_connections.clear(); + + m_controller = value; + + for (auto c : m_controller->connections()) m_connections.append(c); + + connect(m_controller, &Controller::connectionAdded, this, + [=](Connection* conn) { + beginInsertRows(QModelIndex(), m_connections.count(), + m_connections.count()); + m_connections.append(conn); + endInsertRows(); + }); + connect(m_controller, &Controller::connectionDropped, this, + [=](Connection* conn) { + const auto it = + std::find(m_connections.begin(), m_connections.end(), conn); + if (it == m_connections.end()) + return; // Already deleted, nothing to do + const int row = it - m_connections.begin(); + beginRemoveRows(QModelIndex(), row, row); + m_connections.erase(it); + endRemoveRows(); + }); + emit controllerChanged(); + } +} + +QVariant AccountListModel::data(const QModelIndex& index, int role) const { + if (!index.isValid()) return QVariant(); + + if (index.row() >= m_controller->connections().count()) { + qDebug() + << "UserListModel, something's wrong: index.row() >= m_users.count()"; + return QVariant(); + } + auto m_connection = m_controller->connections().at(index.row()); + if (role == NameRole) { + return m_connection->user()->displayname(); + } + if (role == AvatarRole) { + return m_connection->user()->avatar(64); + } + if (role == ConnectionRole) { + return QVariant::fromValue(m_connection); + } + + return QVariant(); +} + +int AccountListModel::rowCount(const QModelIndex& parent) const { + if (parent.isValid()) return 0; + + return m_controller->connections().count(); +} + +QHash AccountListModel::roleNames() const { + QHash roles; + roles[NameRole] = "name"; + roles[AvatarRole] = "avatar"; + roles[ConnectionRole] = "connection"; + return roles; +} diff --git a/src/accountlistmodel.h b/src/accountlistmodel.h new file mode 100644 index 0000000..300d742 --- /dev/null +++ b/src/accountlistmodel.h @@ -0,0 +1,34 @@ +#ifndef ACCOUNTLISTMODEL_H +#define ACCOUNTLISTMODEL_H + +#include "controller.h" + +#include +#include + +class AccountListModel : public QAbstractListModel { + Q_OBJECT + Q_PROPERTY(Controller* controller READ controller WRITE setController NOTIFY + controllerChanged) + public: + enum EventRoles { NameRole = Qt::UserRole + 1, AvatarRole, ConnectionRole }; + + AccountListModel(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; + + Controller* controller() { return m_controller; } + void setController(Controller* value); + + private: + Controller* m_controller; + QVector m_connections; + + signals: + void controllerChanged(); +}; + +#endif // ACCOUNTLISTMODEL_H diff --git a/src/controller.cpp b/src/controller.cpp index 8097df8..f031ec7 100644 --- a/src/controller.cpp +++ b/src/controller.cpp @@ -1,6 +1,7 @@ #include "controller.h" #include "matriqueroom.h" +#include "settings.h" #include "events/eventcontent.h" #include "events/roommessageevent.h" @@ -8,7 +9,21 @@ #include "csapi/joining.h" #include -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include Controller::Controller(QObject* parent) : QObject(parent) { tray->setIcon(QIcon(":/asset/img/icon.png")); @@ -25,84 +40,187 @@ Controller::Controller(QObject* parent) : QObject(parent) { Connection::setRoomType(); - connect(m_connection, &Connection::connected, this, &Controller::connected); - connect(m_connection, &Connection::resolveError, this, - &Controller::reconnect); - connect(m_connection, &Connection::syncError, this, &Controller::reconnect); - connect(m_connection, &Connection::syncDone, this, &Controller::resync); - connect(m_connection, &Connection::connected, this, - &Controller::connectionChanged); - - connect(m_connection, &Connection::connected, [=] { setBusy(true); }); - connect(m_connection, &Connection::syncDone, [=] { setBusy(false); }); + invokeLogin(); } Controller::~Controller() { - m_connection->saveState(); - m_connection->stopSync(); - m_connection->deleteLater(); -} - -void Controller::login() { - if (!m_isLogin) { - m_connection->setHomeserver(QUrl(m_homeserver)); - m_connection->connectWithToken(m_userID, m_token, ""); - } + // m_connection->saveState(); + // m_connection->stopSync(); + // m_connection->deleteLater(); } void Controller::loginWithCredentials(QString serverAddr, QString user, QString pass) { - if (!m_isLogin) { - if (!user.isEmpty() && !pass.isEmpty()) { - m_connection->setHomeserver(QUrl(serverAddr)); - m_connection->connectToServer(user, pass, ""); - } - } else { - qCritical() << "You are already logged in."; + if (!user.isEmpty() && !pass.isEmpty()) { + Connection* m_connection = new Connection(this); + m_connection->setHomeserver(QUrl(serverAddr)); + m_connection->connectToServer(user, pass, ""); + connect(m_connection, &Connection::connected, [=] { + AccountSettings account(m_connection->userId()); + account.setKeepLoggedIn(true); + account.clearAccessToken(); // Drop the legacy - just in case + account.setHomeserver(m_connection->homeserver()); + account.setDeviceId(m_connection->deviceId()); + account.setDeviceName("Matrique"); + if (!saveAccessToken(account, m_connection->accessToken())) + qWarning() << "Couldn't save access token"; + account.sync(); + addConnection(m_connection); + }); } } -void Controller::logout() { - m_connection->logout(); - setUserID(""); - setToken(""); - setIsLogin(false); +void Controller::addConnection(Connection* c) { + Q_ASSERT_X(c, __FUNCTION__, "Attempt to add a null connection"); + + m_connections.push_back(c); + + connect(c, &Connection::syncDone, this, [=] { + // gotEvents(c); + + // Borrowed the logic from Quiark's code in Tensor to cache not too + // aggressively and not on the first sync. The static variable instance + // is created per-closure, meaning per-connection (which is why this + // code is not in gotEvents() ). + static int counter = 0; + if (++counter % 17 == 2) c->saveState(); + }); + connect(c, &Connection::loggedOut, this, [=] { dropConnection(c); }); + + using namespace QMatrixClient; + + c->sync(30000); + + emit connectionAdded(c); +} + +void Controller::dropConnection(Connection* c) { + Q_ASSERT_X(c, __FUNCTION__, "Attempt to drop a null connection"); + m_connections.removeOne(c); + + Q_ASSERT(!m_connections.contains(c) && !c->syncJob()); + emit connectionAdded(c); + c->deleteLater(); +} + +inline QString accessTokenFileName(const AccountSettings& account) { + QString fileName = account.userId(); + fileName.replace(':', '_'); + return QStandardPaths::writableLocation( + QStandardPaths::AppLocalDataLocation) + + '/' + fileName; +} + +void Controller::invokeLogin() { + using namespace QMatrixClient; + const auto accounts = SettingsGroup("Accounts").childGroups(); + bool autoLoggedIn = false; + for (const auto& accountId : accounts) { + AccountSettings account{accountId}; + if (!account.homeserver().isEmpty()) { + auto accessToken = loadAccessToken(account); + if (accessToken.isEmpty()) { + // Try to look in the legacy location (QSettings) and if found, + // migrate it from there to a file. + accessToken = account.accessToken().toLatin1(); + if (accessToken.isEmpty()) + continue; // No access token anywhere, no autologin + + saveAccessToken(account, accessToken); + account.clearAccessToken(); // Clean the old place + } + + autoLoggedIn = true; + auto c = new Connection(account.homeserver(), this); + auto deviceName = account.deviceName(); + connect(c, &Connection::connected, this, [=] { + c->loadState(); + addConnection(c); + }); + c->connectWithToken(account.userId(), accessToken, account.deviceId()); + } + } +} + +QByteArray Controller::loadAccessToken(const AccountSettings& account) { + QFile accountTokenFile{accessTokenFileName(account)}; + if (accountTokenFile.open(QFile::ReadOnly)) { + if (accountTokenFile.size() < 1024) return accountTokenFile.readAll(); + + qWarning() << "File" << accountTokenFile.fileName() << "is" + << accountTokenFile.size() + << "bytes long - too long for a token, ignoring it."; + } + qWarning() << "Could not open access token file" + << accountTokenFile.fileName(); + + return {}; +} + +bool Controller::saveAccessToken(const AccountSettings& account, + const QByteArray& accessToken) { + // (Re-)Make a dedicated file for access_token. + QFile accountTokenFile{accessTokenFileName(account)}; + accountTokenFile.remove(); // Just in case + + auto fileDir = QFileInfo(accountTokenFile).dir(); + if (!((fileDir.exists() || fileDir.mkpath(".")) && + accountTokenFile.open(QFile::WriteOnly))) { + emit errorOccured(); + } else { + // Try to restrict access rights to the file. The below is useless + // on Windows: FAT doesn't control access at all and NTFS is + // incompatible with the UNIX perms model used by Qt. If the attempt + // didn't have the effect, at least ask the user if it's fine to save + // the token to a file readable by others. + // TODO: use system-specific API to ensure proper access. + if ((accountTokenFile.setPermissions(QFile::ReadOwner | + QFile::WriteOwner) && + !(accountTokenFile.permissions() & + (QFile::ReadGroup | QFile::ReadOther)))) { + accountTokenFile.write(accessToken); + return true; + } + } + return false; } void Controller::connected() { - setHomeserver(m_connection->homeserver().toString()); - setUserID(m_connection->userId()); - setToken(m_connection->accessToken()); - m_connection->loadState(); - resync(); - setIsLogin(true); + // setHomeserver(m_connection->homeserver().toString()); + // setUserID(m_connection->userId()); + // setToken(m_connection->accessToken()); + // m_connection->loadState(); + // resync(); + // setIsLogin(true); } -void Controller::resync() { m_connection->sync(30000); } +void Controller::resync() { /*m_connection->sync(30000);*/ +} void Controller::reconnect() { - qDebug() << "Connection lost. Reconnecting..."; - m_connection->connectWithToken(m_userID, m_token, ""); + // qDebug() << "Connection lost. Reconnecting..."; + // m_connection->connectWithToken(m_userID, m_token, ""); } void Controller::joinRoom(const QString& alias) { - JoinRoomJob* joinRoomJob = m_connection->joinRoom(alias); - setBusy(true); - joinRoomJob->connect(joinRoomJob, &JoinRoomJob::finished, - [=] { setBusy(false); }); + // JoinRoomJob* joinRoomJob = m_connection->joinRoom(alias); + // setBusy(true); + // joinRoomJob->connect(joinRoomJob, &JoinRoomJob::finished, + // [=] { setBusy(false); }); } void Controller::createRoom(const QString& name, const QString& topic) { - CreateRoomJob* createRoomJob = - ((Connection*)m_connection) - ->createRoom(Connection::PublishRoom, "", name, topic, QStringList()); - setBusy(true); - createRoomJob->connect(createRoomJob, &CreateRoomJob::finished, - [=] { setBusy(false); }); + // CreateRoomJob* createRoomJob = + // ((Connection*)m_connection) + // ->createRoom(Connection::PublishRoom, "", name, topic, + // QStringList()); + // setBusy(true); + // createRoomJob->connect(createRoomJob, &CreateRoomJob::finished, + // [=] { setBusy(false); }); } void Controller::createDirectChat(const QString& userID) { - m_connection->requestDirectChat(userID); + // m_connection->requestDirectChat(userID); } void Controller::copyToClipboard(const QString& text) { diff --git a/src/controller.h b/src/controller.h index 7d05780..55cbd20 100644 --- a/src/controller.h +++ b/src/controller.h @@ -2,6 +2,7 @@ #define CONTROLLER_H #include "connection.h" +#include "settings.h" #include "user.h" #include @@ -15,12 +16,6 @@ using namespace QMatrixClient; class Controller : public QObject { Q_OBJECT - Q_PROPERTY(Connection* connection READ connection CONSTANT) - Q_PROPERTY(bool isLogin READ isLogin WRITE setIsLogin NOTIFY isLoginChanged) - Q_PROPERTY(QString homeserver READ homeserver WRITE setHomeserver NOTIFY - homeserverChanged) - Q_PROPERTY(QString userID READ userID WRITE setUserID NOTIFY userIDChanged) - Q_PROPERTY(QByteArray token READ token WRITE setToken NOTIFY tokenChanged) Q_PROPERTY(bool busy READ busy WRITE setBusy NOTIFY busyChanged) public: @@ -28,80 +23,50 @@ class Controller : public QObject { ~Controller(); // All the Q_INVOKABLEs. - Q_INVOKABLE void login(); Q_INVOKABLE void loginWithCredentials(QString, QString, QString); - Q_INVOKABLE void logout(); + + QVector connections() { return m_connections; } // All the non-Q_INVOKABLE functions. + void addConnection(Connection* c); + void dropConnection(Connection* c); // All the Q_PROPERTYs. - Connection* m_connection = new Connection(); - Connection* connection() { return m_connection; } - - bool isLogin() { return m_isLogin; } - void setIsLogin(bool n) { - if (n != m_isLogin) { - m_isLogin = n; - emit isLoginChanged(); - } - } - - QString userID() { return m_userID; } - void setUserID(QString n) { - if (n != m_userID) { - m_userID = n; - emit userIDChanged(); - } - } - - QByteArray token() { return m_token; } - void setToken(QByteArray n) { - if (n != m_token) { - m_token = n; - emit tokenChanged(); - } - } - - QString homeserver() { return m_homeserver; } - void setHomeserver(QString n) { - if (n != m_homeserver) { - m_homeserver = n; - emit homeserverChanged(); - } - } - bool busy() { return m_busy; } - void setBusy(bool b) { - if (b != m_busy) { - m_busy = b; + void setBusy(bool value) { + if (value != m_busy) { + m_busy = value; emit busyChanged(); } } + QVector m_connections; + private: QClipboard* m_clipboard = QApplication::clipboard(); QSystemTrayIcon* tray = new QSystemTrayIcon(); QMenu* trayMenu = new QMenu(); - bool m_isLogin = false; - QString m_userID; - QByteArray m_token; - QString m_homeserver; bool m_busy = false; void connected(); void resync(); void reconnect(); + QByteArray loadAccessToken(const AccountSettings& account); + bool saveAccessToken(const AccountSettings& account, + const QByteArray& accessToken); + void loadSettings(); + void saveSettings() const; + + private slots: + void invokeLogin(); signals: - void connectionChanged(); - void isLoginChanged(); - void userIDChanged(); - void tokenChanged(); - void homeserverChanged(); void busyChanged(); void errorOccured(); void toggleWindow(); + void connectionAdded(Connection* conn); + void connectionDropped(Connection* conn); public slots: void joinRoom(const QString& alias); diff --git a/src/main.cpp b/src/main.cpp index 3636474..9c692dc 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -3,6 +3,7 @@ #include #include +#include "accountlistmodel.h" #include "controller.h" #include "emojimodel.h" #include "imageitem.h" @@ -38,6 +39,7 @@ int main(int argc, char *argv[]) { qmlRegisterType("Matrique", 0, 1, "ImageItem"); qmlRegisterType("Matrique", 0, 1, "Controller"); + qmlRegisterType("Matrique", 0, 1, "AccountListModel"); qmlRegisterType("Matrique", 0, 1, "RoomListModel"); qmlRegisterType("Matrique", 0, 1, "UserListModel"); qmlRegisterType("Matrique", 0, 1, "MessageEventModel"); diff --git a/src/userlistmodel.h b/src/userlistmodel.h index d09adb7..068cfa6 100644 --- a/src/userlistmodel.h +++ b/src/userlistmodel.h @@ -17,10 +17,7 @@ class UserListModel : public QAbstractListModel { Q_PROPERTY( QMatrixClient::Room* room READ room WRITE setRoom NOTIFY roomChanged) public: - enum EventRoles { - NameRole = Qt::UserRole + 1, - AvatarRole - }; + enum EventRoles { NameRole = Qt::UserRole + 1, AvatarRole }; using User = QMatrixClient::User; @@ -30,8 +27,7 @@ class UserListModel : public QAbstractListModel { void setRoom(QMatrixClient::Room* room); User* userAt(QModelIndex index); - QVariant data(const QModelIndex& index, - int role = NameRole) const override; + QVariant data(const QModelIndex& index, int role = NameRole) const override; int rowCount(const QModelIndex& parent = QModelIndex()) const override; QHash roleNames() const override;