diff --git a/src/Database.cpp b/src/Database.cpp index a281635..178d042 100644 --- a/src/Database.cpp +++ b/src/Database.cpp @@ -1,383 +1,385 @@ /* * Kaidan - A user-friendly XMPP client for every device! * * Copyright (C) 2016-2020 Kaidan developers and contributors * (see the LICENSE file for a full list of copyright authors) * * Kaidan is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * In addition, as a special exception, the author of Kaidan gives * permission to link the code of its release with the OpenSSL * project's "OpenSSL" library (or with modified versions of it that * use the same license as the "OpenSSL" library), and distribute the * linked executables. You must obey the GNU General Public License in * all respects for all of the code used other than "OpenSSL". If you * modify this file, you may extend this exception to your version of * the file, but you are not obligated to do so. If you do not wish to * do so, delete this exception statement from your version. * * Kaidan is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Kaidan. If not, see . */ #include "Database.h" #include "Globals.h" #include "Utils.h" #include #include #include #include #include #include #include #include #include #include #include #include #define DATABASE_CONVERT_TO_VERSION(n) \ if (m_version < n) { \ convertDatabaseToV##n(); \ } // Both need to be updated on version bump: #define DATABASE_LATEST_VERSION 10 #define DATABASE_CONVERT_TO_LATEST_VERSION() DATABASE_CONVERT_TO_VERSION(10) #define SQL_BOOL "BOOL" #define SQL_INTEGER "INTEGER" #define SQL_INTEGER_NOT_NULL "INTEGER NOT NULL" #define SQL_TEXT "TEXT" #define SQL_TEXT_NOT_NULL "TEXT NOT NULL" #define SQL_BLOB "BLOB" #define SQL_CREATE_TABLE(tableName, contents) \ "CREATE TABLE IF NOT EXISTS '" QT_STRINGIFY(tableName) "' (" contents ")" #define SQL_LAST_ATTRIBUTE(name, dataType) \ "'" QT_STRINGIFY(name) "' " dataType #define SQL_ATTRIBUTE(name, dataType) \ SQL_LAST_ATTRIBUTE(name, dataType) "," Database::Database(QObject *parent) : QObject(parent) { } Database::~Database() { m_database.close(); } void Database::openDatabase() { m_database = QSqlDatabase::addDatabase("QSQLITE", DB_CONNECTION); if (!m_database.isValid()) qFatal("Cannot add database: %s", qPrintable(m_database.lastError().text())); const QDir writeDir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); if (!writeDir.mkpath(".")) { qFatal("Failed to create writable directory at %s", qPrintable(writeDir.absolutePath())); } // Ensure that we have a writable location on all devices. const QString fileName = writeDir.absoluteFilePath(DB_FILENAME); // open() will create the SQLite database if it doesn't exist. m_database.setDatabaseName(fileName); if (!m_database.open()) { qFatal("Cannot open database: %s", qPrintable(m_database.lastError().text())); } loadDatabaseInfo(); if (needToConvert()) convertDatabase(); } void Database::transaction() { if (!m_transactions) { // currently no transactions running if (!m_database.transaction()) { qWarning() << "Could not begin transaction on database:" << m_database.lastError().text(); } } // increase counter m_transactions++; } void Database::commit() { // reduce counter m_transactions--; Q_ASSERT(m_transactions >= 0); if (!m_transactions) { // no transaction requested anymore if (!m_database.commit()) { qWarning() << "Could not commit transaction on database:" << m_database.lastError().text(); } } } void Database::loadDatabaseInfo() { QStringList tables = m_database.tables(); if (!tables.contains(DB_TABLE_INFO)) { if (tables.contains(DB_TABLE_MESSAGES) && tables.contains(DB_TABLE_ROSTER)) // old Kaidan v0.1/v0.2 table m_version = 1; else m_version = 0; // we've got all we want; do not query for a db version return; } QSqlQuery query(m_database); Utils::execQuery(query, "SELECT version FROM dbinfo"); QSqlRecord record = query.record(); int versionCol = record.indexOf("version"); while (query.next()) { m_version = query.value(versionCol).toInt(); } } void Database::saveDatabaseInfo() { QSqlRecord updateRecord; updateRecord.append(Utils::createSqlField("version", m_version)); QSqlQuery query(m_database); Utils::execQuery( query, m_database.driver()->sqlStatement( QSqlDriver::UpdateStatement, DB_TABLE_INFO, updateRecord, false ) ); } bool Database::needToConvert() { return m_version < DATABASE_LATEST_VERSION; } void Database::convertDatabase() { qDebug() << "[database] Converting database to latest version from version" << m_version; transaction(); if (m_version == 0) createNewDatabase(); else DATABASE_CONVERT_TO_LATEST_VERSION(); saveDatabaseInfo(); commit(); } void Database::createNewDatabase() { createDbInfoTable(); createRosterTable(); createMessagesTable(); } void Database::createDbInfoTable() { QSqlQuery query(m_database); Utils::execQuery( query, SQL_CREATE_TABLE( dbinfo, SQL_LAST_ATTRIBUTE(version, SQL_INTEGER_NOT_NULL) ) ); QSqlRecord insertRecord; insertRecord.append(Utils::createSqlField("version", DATABASE_LATEST_VERSION)); Utils::execQuery( query, m_database.driver()->sqlStatement( QSqlDriver::InsertStatement, DB_TABLE_INFO, insertRecord, false ) ); } void Database::createRosterTable() { + // TODO: remove lastExchanged and lastMessage + QSqlQuery query(m_database); Utils::execQuery( query, SQL_CREATE_TABLE( Roster, SQL_ATTRIBUTE(jid, SQL_TEXT_NOT_NULL) SQL_ATTRIBUTE(name, SQL_TEXT) SQL_ATTRIBUTE(lastExchanged, SQL_TEXT_NOT_NULL) SQL_ATTRIBUTE(unreadMessages, SQL_INTEGER) SQL_LAST_ATTRIBUTE(lastMessage, SQL_TEXT) ) ); } void Database::createMessagesTable() { // TODO: the next time we change the messages table, we need to do: // * rename author to sender, edited to isEdited // * delete author_resource, recipient_resource // * remove 'NOT NULL' from id QSqlQuery query(m_database); Utils::execQuery( query, SQL_CREATE_TABLE( Messages, SQL_ATTRIBUTE(author, SQL_TEXT_NOT_NULL) SQL_ATTRIBUTE(author_resource, SQL_TEXT) SQL_ATTRIBUTE(recipient, SQL_TEXT_NOT_NULL) SQL_ATTRIBUTE(recipient_resource, SQL_TEXT) SQL_ATTRIBUTE(timestamp, SQL_TEXT) SQL_ATTRIBUTE(message, SQL_TEXT) SQL_ATTRIBUTE(id, SQL_TEXT_NOT_NULL) SQL_ATTRIBUTE(isSent, SQL_BOOL) SQL_ATTRIBUTE(isDelivered, SQL_BOOL) SQL_ATTRIBUTE(type, SQL_INTEGER) SQL_ATTRIBUTE(mediaUrl, SQL_TEXT) SQL_ATTRIBUTE(mediaSize, SQL_INTEGER) SQL_ATTRIBUTE(mediaContentType, SQL_TEXT) SQL_ATTRIBUTE(mediaLastModified, SQL_INTEGER) SQL_ATTRIBUTE(mediaLocation, SQL_TEXT) SQL_ATTRIBUTE(mediaThumb, SQL_BLOB) SQL_ATTRIBUTE(mediaHashes, SQL_TEXT) SQL_ATTRIBUTE(edited, SQL_BOOL) SQL_ATTRIBUTE(spoilerHint, SQL_TEXT) SQL_ATTRIBUTE(isSpoiler, SQL_BOOL) "FOREIGN KEY('author') REFERENCES Roster ('jid')," "FOREIGN KEY('recipient') REFERENCES Roster ('jid')" ) ); } void Database::convertDatabaseToV2() { // create a new dbinfo table createDbInfoTable(); m_version = 2; } void Database::convertDatabaseToV3() { DATABASE_CONVERT_TO_VERSION(2); QSqlQuery query(m_database); Utils::execQuery(query, "ALTER TABLE Roster ADD avatarHash TEXT"); m_version = 3; } void Database::convertDatabaseToV4() { DATABASE_CONVERT_TO_VERSION(3); QSqlQuery query(m_database); // SQLite doesn't support the ALTER TABLE drop columns feature, so we have to use a workaround. // we copy all rows into a back-up table (but without `avatarHash`), and then delete the old table // and copy everything to the normal table again Utils::execQuery(query, "CREATE TEMPORARY TABLE roster_backup(jid,name,lastExchanged," "unreadMessages,lastMessage,lastOnline,activity,status,mood);"); Utils::execQuery(query, "INSERT INTO roster_backup SELECT jid,name,lastExchanged,unreadMessages," "lastMessage,lastOnline,activity,status,mood FROM Roster;"); Utils::execQuery(query, "DROP TABLE Roster;"); Utils::execQuery(query, "CREATE TABLE Roster('jid' TEXT NOT NULL,'name' TEXT NOT NULL," "'lastExchanged' TEXT NOT NULL,'unreadMessages' INTEGER,'lastMessage' TEXT," "'lastOnline' TEXT,'activity' TEXT,'status' TEXT,'mood' TEXT);"); Utils::execQuery(query, "INSERT INTO Roster SELECT jid,name,lastExchanged,unreadMessages," "lastMessage,lastOnline,activity,status,mood FROM Roster_backup;"); Utils::execQuery(query, "DROP TABLE Roster_backup;"); m_version = 4; } void Database::convertDatabaseToV5() { DATABASE_CONVERT_TO_VERSION(4); QSqlQuery query(m_database); Utils::execQuery(query, "ALTER TABLE 'Messages' ADD 'type' INTEGER"); Utils::execQuery(query, "UPDATE Messages SET type = 0 WHERE type IS NULL"); Utils::execQuery(query, "ALTER TABLE 'Messages' ADD 'mediaUrl' TEXT"); m_version = 5; } void Database::convertDatabaseToV6() { DATABASE_CONVERT_TO_VERSION(5); QSqlQuery query(m_database); for (const QString &column : {"'mediaSize' INTEGER", "'mediaContentType' TEXT", "'mediaLastModified' INTEGER", "'mediaLocation' TEXT"}) { Utils::execQuery(query, QString("ALTER TABLE 'Messages' ADD ").append(column)); } m_version = 6; } void Database::convertDatabaseToV7() { DATABASE_CONVERT_TO_VERSION(6); QSqlQuery query(m_database); Utils::execQuery(query, "ALTER TABLE 'Messages' ADD 'mediaThumb' BLOB"); Utils::execQuery(query, "ALTER TABLE 'Messages' ADD 'mediaHashes' TEXT"); m_version = 7; } void Database::convertDatabaseToV8() { DATABASE_CONVERT_TO_VERSION(7); QSqlQuery query(m_database); Utils::execQuery(query, "CREATE TEMPORARY TABLE roster_backup(jid, name, lastExchanged, " "unreadMessages, lastMessage);"); Utils::execQuery(query, "INSERT INTO roster_backup SELECT jid, name, lastExchanged, unreadMessages, " "lastMessage FROM Roster;"); Utils::execQuery(query, "DROP TABLE Roster;"); Utils::execQuery(query, "CREATE TABLE IF NOT EXISTS Roster ('jid' TEXT NOT NULL,'name' TEXT," "'lastExchanged' TEXT NOT NULL, 'unreadMessages' INTEGER," "'lastMessage' TEXT);"); Utils::execQuery(query, "INSERT INTO Roster SELECT jid, name, lastExchanged, unreadMessages, " "lastMessage FROM Roster_backup;"); Utils::execQuery(query, "DROP TABLE roster_backup;"); m_version = 8; } void Database::convertDatabaseToV9() { DATABASE_CONVERT_TO_VERSION(8); QSqlQuery query(m_database); Utils::execQuery(query, "ALTER TABLE 'Messages' ADD 'edited' BOOL"); m_version = 9; } void Database::convertDatabaseToV10() { DATABASE_CONVERT_TO_VERSION(9); QSqlQuery query(m_database); Utils::execQuery(query, "ALTER TABLE 'Messages' ADD 'isSpoiler' BOOL"); Utils::execQuery(query, "ALTER TABLE 'Messages' ADD 'spoilerHint' TEXT"); m_version = 10; } diff --git a/src/Kaidan.cpp b/src/Kaidan.cpp index cc4b84c..f6eb26a 100644 --- a/src/Kaidan.cpp +++ b/src/Kaidan.cpp @@ -1,293 +1,294 @@ /* * Kaidan - A user-friendly XMPP client for every device! * * Copyright (C) 2016-2020 Kaidan developers and contributors * (see the LICENSE file for a full list of copyright authors) * * Kaidan is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * In addition, as a special exception, the author of Kaidan gives * permission to link the code of its release with the OpenSSL * project's "OpenSSL" library (or with modified versions of it that * use the same license as the "OpenSSL" library), and distribute the * linked executables. You must obey the GNU General Public License in * all respects for all of the code used other than "OpenSSL". If you * modify this file, you may extend this exception to your version of * the file, but you are not obligated to do so. If you do not wish to * do so, delete this exception statement from your version. * * Kaidan is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Kaidan. If not, see . */ #include "Kaidan.h" // Qt #include #include #include #include // QXmpp #include #include "qxmpp-exts/QXmppColorGenerator.h" #include "qxmpp-exts/QXmppUri.h" // Kaidan #include "AvatarFileStorage.h" #include "Database.h" #include "MessageDb.h" #include "MessageModel.h" #include "PresenceCache.h" #include "QmlUtils.h" #include "RosterDb.h" #include "RosterModel.h" Kaidan *Kaidan::s_instance; Kaidan::Kaidan(QGuiApplication *app, bool enableLogging, QObject *parent) : QObject(parent), m_database(new Database()), m_dbThrd(new QThread()), m_msgDb(new MessageDb()), m_rosterDb(new RosterDb(m_database)), m_cltThrd(new QThread()) { Q_ASSERT(!s_instance); s_instance = this; // Database setup m_database->moveToThread(m_dbThrd); m_msgDb->moveToThread(m_dbThrd); m_rosterDb->moveToThread(m_dbThrd); connect(m_dbThrd, &QThread::started, m_database, &Database::openDatabase); m_dbThrd->setObjectName("SqlDatabase"); m_dbThrd->start(); // Caching components m_caches = new ClientWorker::Caches(this, m_rosterDb, m_msgDb, this); // Connect the avatar changed signal of the avatarStorage with the NOTIFY signal // of the Q_PROPERTY for the avatar storage (so all avatars are updated in QML) connect(m_caches->avatarStorage, &AvatarFileStorage::avatarIdsChanged, this, &Kaidan::avatarStorageChanged); // // Load settings // - creds.jid = m_caches->settings->value(KAIDAN_SETTINGS_AUTH_JID).toString(); - creds.jidResourcePrefix = m_caches->settings->value(KAIDAN_SETTINGS_AUTH_JID_RESOURCE_PREFIX).toString(); - creds.password = QString(QByteArray::fromBase64(m_caches->settings->value( - KAIDAN_SETTINGS_AUTH_PASSWD).toString().toUtf8())); + setJid(m_caches->settings->value(KAIDAN_SETTINGS_AUTH_JID).toString()); + setJidResourcePrefix(m_caches->settings->value(KAIDAN_SETTINGS_AUTH_JID_RESOURCE_PREFIX).toString()); + setPassword(QByteArray::fromBase64( + m_caches->settings->value(KAIDAN_SETTINGS_AUTH_PASSWD).toString().toUtf8() + )); // Use a default prefix for the JID's resource part if no prefix is already set. if (creds.jidResourcePrefix.isEmpty()) setJidResourcePrefix(KAIDAN_JID_RESOURCE_DEFAULT_PREFIX); creds.isFirstTry = false; // // Start ClientWorker on new thread // m_client = new ClientWorker(m_caches, this, enableLogging, app); m_client->setCredentials(creds); m_client->moveToThread(m_cltThrd); connect(m_client, &ClientWorker::connectionErrorChanged, this, &Kaidan::setConnectionError); connect(m_cltThrd, &QThread::started, m_client, &ClientWorker::main); m_client->setObjectName("XmppClient"); m_cltThrd->start(); // account deletion connect(this, &Kaidan::deleteAccountFromClient, m_client, &ClientWorker::deleteAccountFromClient); connect(this, &Kaidan::deleteAccountFromClientAndServer, m_client, &ClientWorker::deleteAccountFromClientAndServer); } Kaidan::~Kaidan() { delete m_caches; delete m_database; s_instance = nullptr; } void Kaidan::start() { if (creds.jid.isEmpty() || creds.password.isEmpty()) emit newCredentialsNeeded(); else mainConnect(); } void Kaidan::mainConnect() { emit m_client->credentialsUpdated(creds); emit m_client->connectRequested(); } void Kaidan::mainDisconnect() { // disconnect the client if connected or connecting if (connectionState != ConnectionState::StateDisconnected) emit m_client->disconnectRequested(); } void Kaidan::setConnectionState(QXmppClient::State state) { if (this->connectionState != static_cast(state)) { this->connectionState = static_cast(state); emit connectionStateChanged(); // Open the possibly cached URI when connected. // This is needed because the XMPP URIs can't be opened when Kaidan is not connected. if (connectionState == ConnectionState::StateConnected && !openUriCache.isEmpty()) { // delay is needed because sometimes the RosterPage needs to be loaded first QTimer::singleShot(300, [=] () { emit xmppUriReceived(openUriCache); openUriCache = ""; }); } } } void Kaidan::setConnectionError(ClientWorker::ConnectionError error) { connectionError = error; emit connectionErrorChanged(); } void Kaidan::deleteCredentials() { // Delete the JID. m_caches->settings->remove(KAIDAN_SETTINGS_AUTH_JID); setJid(QString()); // Delete the password. m_caches->settings->remove(KAIDAN_SETTINGS_AUTH_PASSWD); setPassword(QString()); // Trigger the opening of the login page. emit newCredentialsNeeded(); } bool Kaidan::notificationsMuted(const QString &jid) { return m_caches->settings->value(QString("muted/") + jid, false).toBool(); } void Kaidan::setNotificationsMuted(const QString &jid, bool muted) { m_caches->settings->setValue(QString("muted/") + jid, muted); emit notificationsMutedChanged(jid); } void Kaidan::setJid(const QString &jid) { creds.jid = jid; // credentials were modified -> first try creds.isFirstTry = true; emit jidChanged(); } void Kaidan::setJidResourcePrefix(const QString &jidResourcePrefix) { // JID resource won't influence the authentication, so we don't need // to set the first try flag and can save it. creds.jidResourcePrefix = jidResourcePrefix; m_caches->settings->setValue(KAIDAN_SETTINGS_AUTH_JID_RESOURCE_PREFIX, jidResourcePrefix); emit jidResourcePrefixChanged(); } void Kaidan::setPassword(const QString &password) { creds.password = password; // credentials were modified -> first try creds.isFirstTry = true; emit passwordChanged(); } quint8 Kaidan::getConnectionError() const { return static_cast(connectionError); } void Kaidan::addOpenUri(const QString &uri) { if (!QXmppUri::isXmppUri(uri)) return; if (connectionState == ConnectionState::StateConnected) { emit xmppUriReceived(uri); } else { //: The link is an XMPP-URI (i.e. 'xmpp:kaidan@muc.kaidan.im?join' for joining a chat) emit passiveNotificationRequested(tr("The link will be opened after you have connected.")); openUriCache = uri; } } void Kaidan::loginByUri(const QString &uri) { // input does not start with 'xmpp:' if (!QXmppUri::isXmppUri(uri)) { notifyLoginUriNotFound(); return; } // parse QXmppUri parsedUri(uri); // no JID provided if (parsedUri.jid().isEmpty()) { notifyLoginUriNotFound(); return; } setJid(parsedUri.jid()); // URI has no login action or no password if (!parsedUri.hasAction(QXmppUri::Action::Login) || parsedUri.password().isEmpty()) { // reset password setPassword(QString()); emit passiveNotificationRequested(tr("No password found. Please enter it.")); return; } setPassword(parsedUri.password()); // try to connect mainConnect(); } void Kaidan::notifyLoginUriNotFound() { qWarning() << "[main]" << "No valid login URI found."; emit passiveNotificationRequested(tr("No valid login QR code found.")); } ClientWorker *Kaidan::getClient() const { return m_client; } RosterDb *Kaidan::rosterDb() const { return m_rosterDb; } MessageDb *Kaidan::messageDb() const { return m_msgDb; } Kaidan *Kaidan::instance() { return s_instance; } diff --git a/src/MessageDb.cpp b/src/MessageDb.cpp index 3a8c169..1ad3346 100644 --- a/src/MessageDb.cpp +++ b/src/MessageDb.cpp @@ -1,316 +1,344 @@ /* * Kaidan - A user-friendly XMPP client for every device! * * Copyright (C) 2016-2020 Kaidan developers and contributors * (see the LICENSE file for a full list of copyright authors) * * Kaidan is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * In addition, as a special exception, the author of Kaidan gives * permission to link the code of its release with the OpenSSL * project's "OpenSSL" library (or with modified versions of it that * use the same license as the "OpenSSL" library), and distribute the * linked executables. You must obey the GNU General Public License in * all respects for all of the code used other than "OpenSSL". If you * modify this file, you may extend this exception to your version of * the file, but you are not obligated to do so. If you do not wish to * do so, delete this exception statement from your version. * * Kaidan is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Kaidan. If not, see . */ #include "MessageDb.h" // Kaidan #include "Globals.h" #include "Message.h" #include "Utils.h" // Qt #include #include #include #include #include MessageDb *MessageDb::s_instance = nullptr; MessageDb::MessageDb(QObject *parent) : QObject(parent) { Q_ASSERT(!MessageDb::s_instance); s_instance = this; connect(this, &MessageDb::fetchMessagesRequested, this, &MessageDb::fetchMessages); } MessageDb::~MessageDb() { s_instance = nullptr; } MessageDb *MessageDb::instance() { return s_instance; } void MessageDb::parseMessagesFromQuery(QSqlQuery &query, QVector &msgs) { // get indexes of attributes QSqlRecord rec = query.record(); int idxFrom = rec.indexOf("author"); int idxTo = rec.indexOf("recipient"); int idxStamp = rec.indexOf("timestamp"); int idxId = rec.indexOf("id"); int idxBody = rec.indexOf("message"); int idxIsSent = rec.indexOf("isSent"); int idxIsDelivered = rec.indexOf("isDelivered"); int idxMediaType = rec.indexOf("type"); int idxOutOfBandUrl = rec.indexOf("mediaUrl"); int idxMediaContentType = rec.indexOf("mediaContentType"); int idxMediaLocation = rec.indexOf("mediaLocation"); int idxMediaSize = rec.indexOf("mediaSize"); int idxMediaLastModified = rec.indexOf("mediaLastModified"); int idxIsEdited = rec.indexOf("edited"); int idxSpoilerHint = rec.indexOf("spoilerHint"); int idxIsSpoiler = rec.indexOf("isSpoiler"); while (query.next()) { Message msg; msg.setFrom(query.value(idxFrom).toString()); msg.setTo(query.value(idxTo).toString()); msg.setStamp(QDateTime::fromString( query.value(idxStamp).toString(), Qt::ISODate )); msg.setId(query.value(idxId).toString()); msg.setBody(query.value(idxBody).toString()); msg.setIsSent(query.value(idxIsSent).toBool()); msg.setIsDelivered(query.value(idxIsDelivered).toBool()); msg.setMediaType(static_cast(query.value(idxMediaType).toInt())); msg.setOutOfBandUrl(query.value(idxOutOfBandUrl).toString()); msg.setMediaContentType(query.value(idxMediaContentType).toString()); msg.setMediaLocation(query.value(idxMediaLocation).toString()); msg.setMediaSize(query.value(idxMediaSize).toLongLong()); msg.setMediaLastModified(QDateTime::fromMSecsSinceEpoch( query.value(idxMediaLastModified).toLongLong() )); msg.setIsEdited(query.value(idxIsEdited).toBool()); msg.setSpoilerHint(query.value(idxSpoilerHint).toString()); msg.setIsSpoiler(query.value(idxIsSpoiler).toBool()); msgs << msg; } } QSqlRecord MessageDb::createUpdateRecord(const Message &oldMsg, const Message &newMsg) { QSqlRecord rec; if (oldMsg.from() != newMsg.from()) rec.append(Utils::createSqlField("author", newMsg.from())); if (oldMsg.to() != newMsg.to()) rec.append(Utils::createSqlField("recipient", newMsg.to())); if (oldMsg.stamp() != newMsg.stamp()) rec.append(Utils::createSqlField( "timestamp", newMsg.stamp().toString(Qt::ISODate) )); if (oldMsg.id() != newMsg.id()) { // TODO: remove as soon as 'NOT NULL' was removed from id column if (newMsg.id().isEmpty()) rec.append(Utils::createSqlField("id", QStringLiteral(" "))); else rec.append(Utils::createSqlField("id", newMsg.id())); } if (oldMsg.body() != newMsg.body()) rec.append(Utils::createSqlField("message", newMsg.body())); if (oldMsg.isSent() != newMsg.isSent()) rec.append(Utils::createSqlField("isSent", newMsg.isSent())); if (oldMsg.isDelivered() != newMsg.isDelivered()) rec.append(Utils::createSqlField("isDelivered", newMsg.isDelivered())); if (oldMsg.mediaType() != newMsg.mediaType()) rec.append(Utils::createSqlField("type", int(newMsg.mediaType()))); if (oldMsg.outOfBandUrl() != newMsg.outOfBandUrl()) rec.append(Utils::createSqlField("mediaUrl", newMsg.outOfBandUrl())); if (oldMsg.mediaContentType() != newMsg.mediaContentType()) rec.append(Utils::createSqlField( "mediaContentType", newMsg.mediaContentType() )); if (oldMsg.mediaLocation() != newMsg.mediaLocation()) rec.append(Utils::createSqlField( "mediaLocation", newMsg.mediaLocation() )); if (oldMsg.mediaSize() != newMsg.mediaSize()) rec.append(Utils::createSqlField("mediaSize", newMsg.mediaSize())); if (oldMsg.mediaLastModified() != newMsg.mediaLastModified()) rec.append(Utils::createSqlField( "mediaLastModified", newMsg.mediaLastModified().toMSecsSinceEpoch() )); if (oldMsg.isEdited() != newMsg.isEdited()) rec.append(Utils::createSqlField("edited", newMsg.isEdited())); if (oldMsg.spoilerHint() != newMsg.spoilerHint()) rec.append(Utils::createSqlField("spoilerHint", newMsg.spoilerHint())); if (oldMsg.isSpoiler() != newMsg.isSpoiler()) rec.append(Utils::createSqlField("isSpoiler", newMsg.isSpoiler())); return rec; } void MessageDb::fetchMessages(const QString &user1, const QString &user2, int index) { QSqlQuery query(QSqlDatabase::database(DB_CONNECTION)); query.setForwardOnly(true); QMap bindValues; bindValues[":user1"] = user1; bindValues[":user2"] = user2; bindValues[":index"] = index; bindValues[":limit"] = DB_MSG_QUERY_LIMIT; Utils::execQuery( query, "SELECT * FROM Messages " "WHERE (author = :user1 AND recipient = :user2) OR " "(author = :user2 AND recipient = :user1) " "ORDER BY timestamp DESC " "LIMIT :index, :limit", bindValues ); QVector messages; parseMessagesFromQuery(query, messages); emit messagesFetched(messages); } +Message MessageDb::fetchLastMessage(const QString &user1, const QString &user2) +{ + QSqlQuery query(QSqlDatabase::database(DB_CONNECTION)); + query.setForwardOnly(true); + + QMap bindValues = { + { QStringLiteral(":user1"), user1 }, + { QStringLiteral(":user2"), user2 }, + }; + + Utils::execQuery( + query, + "SELECT * FROM Messages " + "WHERE (author = :user1 AND recipient = :user2) OR " + "(author = :user2 AND recipient = :user1) " + "ORDER BY timestamp DESC " + "LIMIT 1", + bindValues + ); + + QVector messages; + parseMessagesFromQuery(query, messages); + + if (!messages.isEmpty()) + return messages.first(); + return {}; +} + void MessageDb::addMessage(const Message &msg) { QSqlDatabase db = QSqlDatabase::database(DB_CONNECTION); QSqlRecord record = db.record(DB_TABLE_MESSAGES); record.setValue("author", msg.from()); record.setValue("recipient", msg.to()); record.setValue("timestamp", msg.stamp().toString(Qt::ISODate)); record.setValue("message", msg.body()); record.setValue("id", msg.id().isEmpty() ? " " : msg.id()); record.setValue("isSent", msg.isSent()); record.setValue("isDelivered", msg.isDelivered()); record.setValue("type", int(msg.mediaType())); record.setValue("edited", msg.isEdited()); record.setValue("isSpoiler", msg.isSpoiler()); record.setValue("spoilerHint", msg.spoilerHint()); record.setValue("mediaUrl", msg.outOfBandUrl()); record.setValue("mediaContentType", msg.mediaContentType()); record.setValue("mediaLocation", msg.mediaLocation()); record.setValue("mediaSize", msg.mediaSize()); record.setValue("mediaLastModified", msg.mediaLastModified().toMSecsSinceEpoch()); QSqlQuery query(db); Utils::execQuery(query, db.driver()->sqlStatement( QSqlDriver::InsertStatement, DB_TABLE_MESSAGES, record, false )); } void MessageDb::removeMessage(const QString &id) { QSqlQuery query(QSqlDatabase::database(DB_CONNECTION)); Utils::execQuery( query, "DELETE FROM Messages WHERE id = ?", QVector() << id ); } void MessageDb::removeAllMessages() { QSqlQuery query(QSqlDatabase::database(DB_CONNECTION)); Utils::execQuery(query, "DELETE FROM Messages"); } void MessageDb::updateMessage(const QString &id, const std::function &updateMsg) { // load current message item from db QSqlDatabase db = QSqlDatabase::database(DB_CONNECTION); QSqlQuery query(db); query.setForwardOnly(true); Utils::execQuery( query, "SELECT * FROM Messages WHERE id = ? LIMIT 1", QVector() << id ); QVector msgs; parseMessagesFromQuery(query, msgs); // update loaded item if (!msgs.isEmpty()) { Message msg = msgs.first(); updateMsg(msg); // replace old message with updated one, if message has changed if (msgs.first() != msg) { // create an SQL record with only the differences QSqlRecord rec = createUpdateRecord(msgs.first(), msg); Utils::execQuery( query, db.driver()->sqlStatement( QSqlDriver::UpdateStatement, DB_TABLE_MESSAGES, rec, false ) + Utils::simpleWhereStatement(db.driver(), "id", id) ); } } } void MessageDb::updateMessageRecord(const QString &id, const QSqlRecord &updateRecord) { QSqlDatabase db = QSqlDatabase::database(DB_CONNECTION); QSqlQuery query(db); Utils::execQuery( query, db.driver()->sqlStatement( QSqlDriver::UpdateStatement, DB_TABLE_MESSAGES, updateRecord, false ) + Utils::simpleWhereStatement(db.driver(), "id", id) ); } void MessageDb::setMessageAsSent(const QString &msgId) { QSqlRecord rec; rec.append(Utils::createSqlField("isSent", true)); updateMessageRecord(msgId, rec); } void MessageDb::setMessageAsDelivered(const QString &msgId) { QSqlRecord rec; rec.append(Utils::createSqlField("isDelivered", true)); updateMessageRecord(msgId, rec); } diff --git a/src/MessageDb.h b/src/MessageDb.h index f14841e..878471f 100644 --- a/src/MessageDb.h +++ b/src/MessageDb.h @@ -1,144 +1,149 @@ /* * Kaidan - A user-friendly XMPP client for every device! * * Copyright (C) 2016-2020 Kaidan developers and contributors * (see the LICENSE file for a full list of copyright authors) * * Kaidan is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * In addition, as a special exception, the author of Kaidan gives * permission to link the code of its release with the OpenSSL * project's "OpenSSL" library (or with modified versions of it that * use the same license as the "OpenSSL" library), and distribute the * linked executables. You must obey the GNU General Public License in * all respects for all of the code used other than "OpenSSL". If you * modify this file, you may extend this exception to your version of * the file, but you are not obligated to do so. If you do not wish to * do so, delete this exception statement from your version. * * Kaidan is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Kaidan. If not, see . */ #ifndef MESSAGEDB_H #define MESSAGEDB_H #include #include class Message; class QSqlQuery; class QSqlRecord; /** * @class The MessageDb is used to query the 'messages' database table. It's used by the * MessageModel to load messages and by the MessageHandler to insert messages. * * All queries must be executed only after the Kaidan SQL connection has been opened in * the Database class. */ class MessageDb : public QObject { Q_OBJECT public: explicit MessageDb(QObject *parent = nullptr); ~MessageDb(); static MessageDb *instance(); /** * Parses a list of messages from a SELECT query. */ static void parseMessagesFromQuery(QSqlQuery &query, QVector &msgs); /** * Creates an @c QSqlRecord for updating an old message to a new message. * * @param oldMsg Full message as it is currently saved * @param newMsg Full message as it should be after the update query ran. */ static QSqlRecord createUpdateRecord(const Message &oldMsg, const Message &newMsg); signals: /** * Can be used to triggerd fetchMessages() */ void fetchMessagesRequested(const QString &user1, const QString &user2, int index); /** * Emitted, when new messages have been fetched */ void messagesFetched(const QVector &messages); public slots: /** * @brief Fetches more entries from the database and emits messagesFetched() with * the results. * * @param user1 Messages are from or to this JID. * @param user2 Messages are from or to this JID. * @param index Number of entries to be skipped, used for paging. */ void fetchMessages(const QString &user1, const QString &user2, int index); + /** + * Fetches the last message and returns it. + */ + Message fetchLastMessage(const QString &user1, const QString &user2); + /** * Adds a message to the database. */ void addMessage(const Message &msg); /** * Deletes a message from the database. */ void removeMessage(const QString &id); /** * Removes all messages from the database. */ void removeAllMessages(); /** * Loads a message, runs the update lambda and writes it to the DB again. * * @param updateMsg Function that changes the message */ void updateMessage(const QString &id, const std::function &updateMsg); /** * Updates message by @c UPDATE record: This means it doesn't load the message * from the database and writes it again, but executes an UPDATE query. * * @param updateRecord */ void updateMessageRecord(const QString &id, const QSqlRecord &updateRecord); /** * Marks a message as sent using an UPDATE query. */ void setMessageAsSent(const QString &msgId); /** * Marks a message as delivered using an UPDATE query. */ void setMessageAsDelivered(const QString &msgId); private: static MessageDb *s_instance; }; #endif // MESSAGEDB_H diff --git a/src/MessageHandler.cpp b/src/MessageHandler.cpp index bebcd91..812edca 100644 --- a/src/MessageHandler.cpp +++ b/src/MessageHandler.cpp @@ -1,296 +1,292 @@ /* * Kaidan - A user-friendly XMPP client for every device! * * Copyright (C) 2016-2020 Kaidan developers and contributors * (see the LICENSE file for a full list of copyright authors) * * Kaidan is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * In addition, as a special exception, the author of Kaidan gives * permission to link the code of its release with the OpenSSL * project's "OpenSSL" library (or with modified versions of it that * use the same license as the "OpenSSL" library), and distribute the * linked executables. You must obey the GNU General Public License in * all respects for all of the code used other than "OpenSSL". If you * modify this file, you may extend this exception to your version of * the file, but you are not obligated to do so. If you do not wish to * do so, delete this exception statement from your version. * * Kaidan is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Kaidan. If not, see . */ #include "MessageHandler.h" // Qt #include #include #include // QXmpp #include #include #include #include #include // Kaidan #include "Kaidan.h" #include "Message.h" #include "MessageModel.h" #include "Notifications.h" #include "MediaUtils.h" MessageHandler::MessageHandler(Kaidan *kaidan, QXmppClient *client, MessageModel *model, QObject *parent) : QObject(parent), kaidan(kaidan), client(client), model(model) { connect(client, &QXmppClient::messageReceived, this, &MessageHandler::handleMessage); connect(kaidan, &Kaidan::sendMessage, this, &MessageHandler::sendMessage); connect(kaidan, &Kaidan::correctMessage, this, &MessageHandler::correctMessage); client->addExtension(&receiptManager); connect(&receiptManager, &QXmppMessageReceiptManager::messageDelivered, this, [=] (const QString&, const QString &id) { emit model->setMessageAsDeliveredRequested(id); }); carbonManager = new QXmppCarbonManager(); client->addExtension(carbonManager); // messages sent to our account (forwarded from another client) connect(carbonManager, &QXmppCarbonManager::messageReceived, client, &QXmppClient::messageReceived); // messages sent from our account (but another client) connect(carbonManager, &QXmppCarbonManager::messageSent, client, &QXmppClient::messageReceived); // carbons discovery auto *discoManager = client->findExtension(); if (!discoManager) return; connect(discoManager, &QXmppDiscoveryManager::infoReceived, this, &MessageHandler::handleDiscoInfo); } MessageHandler::~MessageHandler() { delete carbonManager; } void MessageHandler::handleMessage(const QXmppMessage &msg) { if (msg.body().isEmpty() || msg.type() == QXmppMessage::Error) return; Message message; message.setFrom(QXmppUtils::jidToBareJid(msg.from())); message.setTo(QXmppUtils::jidToBareJid(msg.to())); message.setSentByMe(msg.from() == client->configuration().jidBare()); message.setId(msg.id()); message.setBody(msg.body()); message.setMediaType(MessageType::MessageText); // default to text message without media #if (QXMPP_VERSION) >= QT_VERSION_CHECK(1, 1, 0) message.setIsSpoiler(msg.isSpoiler()); message.setSpoilerHint(msg.spoilerHint()); #else for (const QXmppElement &extension : msg.extensions()) { if (extension.tagName() == "spoiler" && extension.attribute("xmlns") == NS_SPOILERS) { message.setIsSpoiler(true); message.setSpoilerHint(extension.value()); break; } } #endif // check if message contains a link and also check out of band url QStringList bodyWords = message.body().split(" "); bodyWords.prepend(msg.outOfBandUrl()); for (const QString &word : qAsConst(bodyWords)) { if (!MediaUtils::isHttp(word) && !MediaUtils::isGeoLocation(word)) { continue; } // check message type by file name in link // This is hacky, but needed without SIMS or an additional HTTP request. // Also, this can be useful when a user manually posts an HTTP url. const QUrl url(word); const QMimeType mimeType = MediaUtils::mimeType(url); const MessageType messageType = MediaUtils::messageType(mimeType); switch (messageType) { case MessageType::MessageImage: case MessageType::MessageAudio: case MessageType::MessageVideo: case MessageType::MessageDocument: case MessageType::MessageFile: case MessageType::MessageGeoLocation: message.setMediaType(messageType); if (messageType == MessageType::MessageGeoLocation) { message.setMediaLocation(url.toEncoded()); } message.setMediaContentType(mimeType.name()); message.setOutOfBandUrl(url.toEncoded()); break; case MessageType::MessageText: case MessageType::MessageUnknown: continue; } break; // we can only handle one link } // get possible delay (timestamp) message.setStamp((msg.stamp().isNull() || !msg.stamp().isValid()) ? QDateTime::currentDateTimeUtc() : msg.stamp().toUTC()); // save the message to the database // in case of message correction, replace old message if (msg.replaceId().isEmpty()) { emit model->addMessageRequested(message); } else { message.setIsEdited(true); message.setId(QString()); emit model->updateMessageRequested(msg.replaceId(), [=] (Message &m) { // replace completely m = message; }); } // Send a message notification // The contact can differ if the message is really from a contact or just // a forward of another of the user's clients. QString contactJid = message.sentByMe() ? message.to() : message.from(); // resolve user-defined name of this JID QString contactName = client->rosterManager().getRosterEntry(contactJid).name(); if (contactName.isEmpty()) contactName = contactJid; if (!message.sentByMe()) Notifications::sendMessageNotification(contactJid, contactName, msg.body()); // TODO: Move back following call to RosterManager::handleMessage when spoiler // messages are implemented in QXmpp const QString lastMessage = message.isSpoiler() ? message.spoilerHint().isEmpty() ? tr("Spoiler") : message.spoilerHint() : msg.body(); - emit kaidan->getRosterModel()->updateItemRequested( - contactJid, - [=] (RosterItem &item) { - item.setLastMessage(lastMessage); - } - ); + + emit kaidan->getRosterModel()->setLastMessageRequested(contactJid, lastMessage); } void MessageHandler::sendMessage(const QString& toJid, const QString& body, bool isSpoiler, const QString& spoilerHint) { // TODO: Add offline message cache and send when connnected again if (client->state() != QXmppClient::ConnectedState) { emit kaidan->passiveNotificationRequested( tr("Could not send message, as a result of not being connected.") ); qWarning() << "[client] [MessageHandler] Could not send message, as a result of " "not being connected."; return; } Message msg; msg.setFrom(client->configuration().jidBare()); msg.setTo(toJid); msg.setBody(body); msg.setId(QXmppUtils::generateStanzaHash(28)); msg.setReceiptRequested(true); msg.setSentByMe(true); msg.setMediaType(MessageType::MessageText); // text message without media msg.setStamp(QDateTime::currentDateTimeUtc()); if (isSpoiler) { msg.setIsSpoiler(isSpoiler); msg.setSpoilerHint(spoilerHint); // parsing/serialization of spoilers isn't implemented in QXmpp QXmppElementList extensions = msg.extensions(); QXmppElement spoiler = QXmppElement(); spoiler.setTagName("spoiler"); spoiler.setValue(msg.spoilerHint()); spoiler.setAttribute("xmlns", NS_SPOILERS); extensions.append(spoiler); msg.setExtensions(extensions); } else if (MediaUtils::isGeoLocation(msg.body())) { const QUrl url(msg.body()); const QMimeType mimeType = MediaUtils::mimeType(url); const MessageType messageType = MediaUtils::messageType(mimeType); msg.setMediaType(messageType); msg.setMediaLocation(msg.body()); msg.setMediaContentType(mimeType.name()); msg.setOutOfBandUrl(msg.body()); } emit model->addMessageRequested(msg); if (client->sendPacket(static_cast(msg))) emit model->setMessageAsSentRequested(msg.id()); else emit kaidan->passiveNotificationRequested(tr("Message could not be sent.")); // TODO: handle error } void MessageHandler::correctMessage(const QString& toJid, const QString& msgId, const QString& body) { // TODO: load old message from model and put everything into the new message // instead of only the new body // TODO: Add offline message cache and send when connnected again if (client->state() != QXmppClient::ConnectedState) { emit kaidan->passiveNotificationRequested( tr("Could not correct message, as a result of not being connected.") ); qWarning() << "[client] [MessageHandler] Could not correct message, as a result of " "not being connected."; return; } Message msg; msg.setFrom(client->configuration().jidBare()); msg.setTo(toJid); msg.setId(QXmppUtils::generateStanzaHash(28)); msg.setBody(body); msg.setReceiptRequested(true); msg.setSentByMe(true); msg.setMediaType(MessageType::MessageText); // text message without media msg.setIsEdited(true); msg.setReplaceId(msgId); emit model->updateMessageRequested(msgId, [=] (Message &msg) { msg.setBody(body); }); if (client->sendPacket(msg)) emit model->setMessageAsSentRequested(msg.id()); else emit kaidan->passiveNotificationRequested( tr("Message correction was not successful.")); } void MessageHandler::handleDiscoInfo(const QXmppDiscoveryIq &info) { if (info.from() != client->configuration().domain()) return; // enable carbons, if feature found if (info.features().contains(NS_CARBONS)) carbonManager->setCarbonsEnabled(true); } diff --git a/src/RosterDb.cpp b/src/RosterDb.cpp index e29957c..fab9a8e 100644 --- a/src/RosterDb.cpp +++ b/src/RosterDb.cpp @@ -1,271 +1,269 @@ /* * Kaidan - A user-friendly XMPP client for every device! * * Copyright (C) 2016-2020 Kaidan developers and contributors * (see the LICENSE file for a full list of copyright authors) * * Kaidan is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * In addition, as a special exception, the author of Kaidan gives * permission to link the code of its release with the OpenSSL * project's "OpenSSL" library (or with modified versions of it that * use the same license as the "OpenSSL" library), and distribute the * linked executables. You must obey the GNU General Public License in * all respects for all of the code used other than "OpenSSL". If you * modify this file, you may extend this exception to your version of * the file, but you are not obligated to do so. If you do not wish to * do so, delete this exception statement from your version. * * Kaidan is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Kaidan. If not, see . */ #include "RosterDb.h" // Kaidan #include "Database.h" #include "Globals.h" #include "Utils.h" +#include "RosterItem.h" +#include "Message.h" +#include "MessageDb.h" // Qt #include #include #include #include #include RosterDb *RosterDb::s_instance = nullptr; RosterDb::RosterDb(Database *db, QObject *parent) : QObject(parent), m_db(db) { Q_ASSERT(!RosterDb::s_instance); s_instance = this; connect(this, &RosterDb::fetchItemsRequested, this, &RosterDb::fetchItems); } RosterDb::~RosterDb() { s_instance = nullptr; } RosterDb *RosterDb::instance() { return s_instance; } void RosterDb::parseItemsFromQuery(QSqlQuery &query, QVector &items) { QSqlRecord rec = query.record(); int idxJid = rec.indexOf("jid"); int idxName = rec.indexOf("name"); - int idxLastExchanged = rec.indexOf("lastExchanged"); int idxUnreadMessages = rec.indexOf("unreadMessages"); - int idxLastMessage = rec.indexOf("lastMessage"); while (query.next()) { RosterItem item; item.setJid(query.value(idxJid).toString()); item.setName(query.value(idxName).toString()); - item.setLastExchanged(QDateTime::fromString( - query.value(idxLastExchanged).toString(), - Qt::ISODateWithMs - )); item.setUnreadMessages(query.value(idxUnreadMessages).toInt()); - item.setLastMessage(query.value(idxLastMessage).toString()); items << item; } } QSqlRecord RosterDb::createUpdateRecord(const RosterItem &oldItem, const RosterItem &newItem) { QSqlRecord rec; if (oldItem.jid() != newItem.jid()) rec.append(Utils::createSqlField("jid", newItem.jid())); if (oldItem.name() != newItem.name()) rec.append(Utils::createSqlField("name", oldItem.name())); - if (oldItem.lastMessage() != newItem.lastMessage()) - rec.append(Utils::createSqlField("lastMessage", newItem.lastMessage())); - if (oldItem.lastExchanged() != newItem.lastExchanged()) - rec.append(Utils::createSqlField( - "lastExchanged", - newItem.lastExchanged().toString(Qt::ISODateWithMs) - )); if (oldItem.unreadMessages() != newItem.unreadMessages()) rec.append(Utils::createSqlField( "unreadMessages", newItem.unreadMessages() )); return rec; } void RosterDb::addItem(const RosterItem &item) { addItems(QVector() << item); } void RosterDb::addItems(const QVector &items) { QSqlDatabase db = QSqlDatabase::database(DB_CONNECTION); m_db->transaction(); QSqlQuery query(db); Utils::prepareQuery(query, db.driver()->sqlStatement( QSqlDriver::InsertStatement, DB_TABLE_ROSTER, db.record(DB_TABLE_ROSTER), true )); for (const auto &item : items) { query.addBindValue(item.jid()); query.addBindValue(item.name()); - query.addBindValue(item.lastExchanged().toString(Qt::ISODateWithMs)); + query.addBindValue(QStringLiteral("")); // lastExchanged (NOT NULL) query.addBindValue(item.unreadMessages()); - query.addBindValue(item.lastMessage()); + query.addBindValue(QString()); // lastMessage Utils::execQuery(query); } m_db->commit(); } void RosterDb::removeItem(const QString &jid) { QSqlDatabase db = QSqlDatabase::database(DB_CONNECTION); QSqlQuery query(db); Utils::execQuery( query, "DELETE FROM Roster WHERE jid = ?", QVector() << jid ); } void RosterDb::updateItem(const QString &jid, const std::function &updateItem) { // load current roster item from db QSqlDatabase db = QSqlDatabase::database(DB_CONNECTION); QSqlQuery query(db); query.setForwardOnly(true); Utils::execQuery( query, "SELECT * FROM Roster WHERE jid = ? LIMIT 1", QVector() << jid ); QVector items; parseItemsFromQuery(query, items); // update loaded item if (!items.isEmpty()) { RosterItem item = items.first(); updateItem(item); // replace old item with updated one, if item has changed if (items.first() != item) { // create an SQL record with only the differences QSqlRecord rec = createUpdateRecord(items.first(), item); + if (rec.isEmpty()) + return; + Utils::execQuery( query, db.driver()->sqlStatement( QSqlDriver::UpdateStatement, DB_TABLE_ROSTER, rec, false ) + Utils::simpleWhereStatement(db.driver(), "jid", jid) ); } } } void RosterDb::replaceItems(const QHash &items) { // load current items QSqlDatabase db = QSqlDatabase::database(DB_CONNECTION); QSqlQuery query(db); query.setForwardOnly(true); Utils::execQuery(query, "SELECT * FROM Roster"); QVector currentItems; parseItemsFromQuery(query, currentItems); m_db->transaction(); QSet newJids = items.keys().toSet(); for (const auto &oldItem : qAsConst(currentItems)) { // We will remove the already existing JIDs, so we get a set of JIDs that // are completely new. // // By calling remove(), we also find out whether the JID is already // existing or not. if (newJids.remove(oldItem.jid())) { // item is also included in newJids -> update // name is (currently) the only attribute that is defined by the // XMPP roster and so could cause a change if (oldItem.name() != items[oldItem.jid()].name()) setItemName(oldItem.jid(), items[oldItem.jid()].name()); } else { // item is not included in newJids -> delete removeItem(oldItem.jid()); } } // now add the completely new JIDs for (const QString &jid : newJids) addItem(items[jid]); m_db->commit(); } void RosterDb::setItemName(const QString &jid, const QString &name) { QSqlDatabase db = QSqlDatabase::database(DB_CONNECTION); QSqlQuery query(db); QSqlRecord rec; rec.append(Utils::createSqlField("name", name)); Utils::execQuery( query, db.driver()->sqlStatement( QSqlDriver::UpdateStatement, DB_TABLE_ROSTER, rec, false ) + Utils::simpleWhereStatement(db.driver(), "jid", jid) ); } void RosterDb::clearAll() { QSqlQuery query(QSqlDatabase::database(DB_CONNECTION)); Utils::execQuery(query, "DELETE FROM Roster"); } -void RosterDb::fetchItems() +void RosterDb::fetchItems(const QString &accountId) { QSqlQuery query(QSqlDatabase::database(DB_CONNECTION)); query.setForwardOnly(true); Utils::execQuery(query, "SELECT * FROM Roster"); QVector items; parseItemsFromQuery(query, items); + for (auto &item : items) { + Message lastMessage = MessageDb::instance()->fetchLastMessage(accountId, item.jid()); + item.setLastExchanged(lastMessage.stamp()); + item.setLastMessage(lastMessage.body()); + } + emit itemsFetched(items); } diff --git a/src/RosterDb.h b/src/RosterDb.h index 85c0aad..2609366 100644 --- a/src/RosterDb.h +++ b/src/RosterDb.h @@ -1,88 +1,88 @@ /* * Kaidan - A user-friendly XMPP client for every device! * * Copyright (C) 2016-2020 Kaidan developers and contributors * (see the LICENSE file for a full list of copyright authors) * * Kaidan is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * In addition, as a special exception, the author of Kaidan gives * permission to link the code of its release with the OpenSSL * project's "OpenSSL" library (or with modified versions of it that * use the same license as the "OpenSSL" library), and distribute the * linked executables. You must obey the GNU General Public License in * all respects for all of the code used other than "OpenSSL". If you * modify this file, you may extend this exception to your version of * the file, but you are not obligated to do so. If you do not wish to * do so, delete this exception statement from your version. * * Kaidan is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Kaidan. If not, see . */ #ifndef ROSTERDB_H #define ROSTERDB_H // C++ #include // Qt #include class QSqlQuery; class QSqlRecord; // Kaidan -#include "RosterItem.h" +class RosterItem; class Database; class RosterDb : public QObject { Q_OBJECT public: RosterDb(Database *db, QObject *parent = nullptr); ~RosterDb(); static RosterDb *instance(); static void parseItemsFromQuery(QSqlQuery &query, QVector &items); /** * Creates an @c QSqlRecord for updating an old item to a new item. * * @param oldMsg Full item as it is currently saved * @param newMsg Full item as it should be after the update query ran. */ static QSqlRecord createUpdateRecord(const RosterItem &oldItem, const RosterItem &newItem); signals: - void fetchItemsRequested(); + void fetchItemsRequested(const QString &accountId); void itemsFetched(const QVector &items); public slots: void addItem(const RosterItem &item); void addItems(const QVector &items); void removeItem(const QString &jid); void updateItem(const QString &jid, const std::function &updateItem); void replaceItems(const QHash &items); void setItemName(const QString &jid, const QString &name); void clearAll(); private slots: - void fetchItems(); + void fetchItems(const QString &accountId); private: Database *m_db; static RosterDb *s_instance; }; #endif // ROSTERDB_H diff --git a/src/RosterManager.cpp b/src/RosterManager.cpp index 83ee07e..0373a22 100644 --- a/src/RosterManager.cpp +++ b/src/RosterManager.cpp @@ -1,218 +1,209 @@ /* * Kaidan - A user-friendly XMPP client for every device! * * Copyright (C) 2016-2020 Kaidan developers and contributors * (see the LICENSE file for a full list of copyright authors) * * Kaidan is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * In addition, as a special exception, the author of Kaidan gives * permission to link the code of its release with the OpenSSL * project's "OpenSSL" library (or with modified versions of it that * use the same license as the "OpenSSL" library), and distribute the * linked executables. You must obey the GNU General Public License in * all respects for all of the code used other than "OpenSSL". If you * modify this file, you may extend this exception to your version of * the file, but you are not obligated to do so. If you do not wish to * do so, delete this exception statement from your version. * * Kaidan is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Kaidan. If not, see . */ #include "RosterManager.h" // Kaidan #include "ClientWorker.h" #include "Globals.h" #include "Kaidan.h" #include "VCardManager.h" // QXmpp #include #include #include RosterManager::RosterManager(Kaidan *kaidan, QXmppClient *client, RosterModel *model, AvatarFileStorage *avatarStorage, VCardManager *vCardManager, QObject *parent) : QObject(parent), kaidan(kaidan), client(client), model(model), avatarStorage(avatarStorage), vCardManager(vCardManager), manager(client->rosterManager()) { connect(&manager, &QXmppRosterManager::rosterReceived, this, &RosterManager::populateRoster); connect(&manager, &QXmppRosterManager::itemAdded, this, [this, vCardManager, model] (const QString &jid) { emit model->addItemRequested(RosterItem(manager.getRosterEntry(jid))); vCardManager->fetchVCard(jid); }); connect(&manager, &QXmppRosterManager::itemChanged, this, [this, model] (const QString &jid) { emit model->updateItemRequested(m_chatPartner, [=] (RosterItem &item) { item.setName(manager.getRosterEntry(jid).name()); }); }); connect(&manager, &QXmppRosterManager::itemRemoved, model, &RosterModel::removeItemRequested); connect(&manager, &QXmppRosterManager::subscriptionReceived, this, [kaidan] (const QString &jid) { // emit signal to ask user emit kaidan->subscriptionRequestReceived(jid, QString()); }); connect(kaidan, &Kaidan::subscriptionRequestAnswered, this, [=] (QString jid, bool accepted) { if (accepted) { manager.acceptSubscription(jid); // do not send a subscription request if both users have already subscribed // each others presence if (manager.getRosterEntry(jid).subscriptionType() != QXmppRosterIq::Item::Both) manager.subscribe(jid); } else { manager.refuseSubscription(jid); } }); // update local copy of chat partner connect(kaidan->getMessageModel(), &MessageModel::chatPartnerChanged, this, [=] (const QString &jid) { m_chatPartner = jid; } ); // user actions connect(kaidan, &Kaidan::addContact, this, &RosterManager::addContact); connect(kaidan, &Kaidan::removeContact, this, &RosterManager::removeContact); connect(kaidan, &Kaidan::renameContact, this, &RosterManager::renameContact); connect(kaidan, &Kaidan::sendMessage, this, &RosterManager::handleSendMessage); connect(client, &QXmppClient::messageReceived, this, &RosterManager::handleMessage); } void RosterManager::populateRoster() { qDebug() << "[client] [RosterManager] Populating roster"; // create a new list of contacts QHash items; const QStringList bareJids = manager.getRosterBareJids(); const auto currentTime = QDateTime::currentDateTimeUtc(); for (const auto &jid : bareJids) { items[jid] = RosterItem(manager.getRosterEntry(jid), currentTime); if (avatarStorage->getHashOfJid(jid).isEmpty()) vCardManager->fetchVCard(jid); } // replace current contacts with new ones from server emit model->replaceItemsRequested(items); } void RosterManager::addContact(const QString &jid, const QString &name, const QString &msg) { if (client->state() == QXmppClient::ConnectedState) { manager.addItem(jid, name); manager.subscribe(jid, msg); } else { emit kaidan->passiveNotificationRequested( tr("Could not add contact, as a result of not being connected.") ); qWarning() << "[client] [RosterManager] Could not add contact, as a result of " "not being connected."; } } void RosterManager::removeContact(const QString &jid) { if (client->state() == QXmppClient::ConnectedState) { manager.unsubscribe(jid); manager.removeItem(jid); } else { emit kaidan->passiveNotificationRequested( tr("Could not remove contact, as a result of not being connected.") ); qWarning() << "[client] [RosterManager] Could not remove contact, as a result of " "not being connected."; } } void RosterManager::renameContact(const QString &jid, const QString &newContactName) { if (client->state() == QXmppClient::ConnectedState) { manager.renameItem(jid, newContactName); } else { emit kaidan->passiveNotificationRequested( tr("Could not rename contact, as a result of not being connected.") ); qWarning() << "[client] [RosterManager] Could not rename contact, as a result of " "not being connected."; } } void RosterManager::handleSendMessage(const QString &jid, const QString &message, bool isSpoiler, const QString &spoilerHint) { if (client->state() == QXmppClient::ConnectedState) { // update roster item const QString lastMessage = isSpoiler ? spoilerHint.isEmpty() ? tr("Spoiler") : spoilerHint : message; // sorting order in contact list - const QDateTime dateTime = QDateTime::currentDateTimeUtc(); - - emit model->updateItemRequested(jid, - [=] (RosterItem &item) { - item.setLastMessage(lastMessage); - item.setLastExchanged(dateTime); - }); + emit model->setLastExchangedRequested(jid, QDateTime::currentDateTimeUtc()); + emit model->setLastMessageRequested(jid, lastMessage); } } void RosterManager::handleMessage(const QXmppMessage &msg) { - if (msg.body().isEmpty() || msg.type() == QXmppMessage::Error) + if (msg.body().isEmpty() || msg.type() == QXmppMessage::Error) return; // msg.from() can be our JID, if it's a carbon/forward from another client QString fromJid = QXmppUtils::jidToBareJid(msg.from()); bool sentByMe = fromJid == client->configuration().jidBare(); QString contactJid = sentByMe ? QXmppUtils::jidToBareJid(msg.to()) : fromJid; // update last exchanged datetime (sorting order in contact list) - const QDateTime dateTime = QDateTime::currentDateTimeUtc(); + emit model->setLastExchangedRequested(contactJid, QDateTime::currentDateTimeUtc()); // update unread message counter, if chat is not active if (sentByMe) { // if we sent a message (with another device), reset counter - emit model->updateItemRequested(contactJid, - [dateTime] (RosterItem &item) { - item.setLastExchanged(dateTime); + emit model->updateItemRequested(contactJid, [](RosterItem &item) { item.setUnreadMessages(0); }); } else if (m_chatPartner != contactJid) { - emit model->updateItemRequested(contactJid, - [dateTime] (RosterItem &item) { - item.setLastExchanged(dateTime); + emit model->updateItemRequested(contactJid, [](RosterItem &item) { item.setUnreadMessages(item.unreadMessages() + 1); }); } } diff --git a/src/RosterModel.cpp b/src/RosterModel.cpp index 99caf50..24b90ba 100644 --- a/src/RosterModel.cpp +++ b/src/RosterModel.cpp @@ -1,244 +1,278 @@ /* * Kaidan - A user-friendly XMPP client for every device! * * Copyright (C) 2016-2020 Kaidan developers and contributors * (see the LICENSE file for a full list of copyright authors) * * Kaidan is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * In addition, as a special exception, the author of Kaidan gives * permission to link the code of its release with the OpenSSL * project's "OpenSSL" library (or with modified versions of it that * use the same license as the "OpenSSL" library), and distribute the * linked executables. You must obey the GNU General Public License in * all respects for all of the code used other than "OpenSSL". If you * modify this file, you may extend this exception to your version of * the file, but you are not obligated to do so. If you do not wish to * do so, delete this exception statement from your version. * * Kaidan is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Kaidan. If not, see . */ #include "RosterModel.h" // Kaidan #include "RosterDb.h" #include "MessageModel.h" +#include "Kaidan.h" // C++ #include // Qt #include #include #include #include RosterModel::RosterModel(RosterDb *rosterDb, QObject *parent) : QAbstractListModel(parent), rosterDb(rosterDb) { connect(rosterDb, &RosterDb::itemsFetched, this, &RosterModel::handleItemsFetched); connect(this, &RosterModel::addItemRequested, this, &RosterModel::addItem); connect(this, &RosterModel::addItemRequested, rosterDb, &RosterDb::addItem); connect(this, &RosterModel::removeItemRequested, this, &RosterModel::removeItem); connect(this, &RosterModel::removeItemRequested, rosterDb, &RosterDb::removeItem); connect(this, &RosterModel::updateItemRequested, this, &RosterModel::updateItem); connect(this, &RosterModel::updateItemRequested, rosterDb, &RosterDb::updateItem); connect(this, &RosterModel::replaceItemsRequested, this, &RosterModel::replaceItems); connect(this, &RosterModel::replaceItemsRequested, rosterDb, &RosterDb::replaceItems); - emit rosterDb->fetchItemsRequested(); + // This is only done in the model, the database is updated automatically by the new + // messages: + connect(this, &RosterModel::setLastMessageRequested, + this, &RosterModel::setLastMessage); + connect(this, &RosterModel::setLastExchangedRequested, + this, &RosterModel::setLastExchanged); + + connect(Kaidan::instance(), &Kaidan::jidChanged, this, [=]() { + beginResetModel(); + m_items.clear(); + endResetModel(); + + emit rosterDb->fetchItemsRequested(Kaidan::instance()->getJid()); + }); } void RosterModel::setMessageModel(MessageModel *model) { connect(model, &MessageModel::chatPartnerChanged, this, [=] (const QString &chatPartner) { // reset unread message counter emit updateItemRequested(chatPartner, [] (RosterItem &item) { item.setUnreadMessages(0); }); }); } bool RosterModel::isEmpty() const { return m_items.isEmpty(); } int RosterModel::rowCount(const QModelIndex&) const { return m_items.length(); } QHash RosterModel::roleNames() const { QHash roles; roles[JidRole] = "jid"; roles[NameRole] = "name"; roles[LastExchangedRole] = "lastExchanged"; roles[UnreadMessagesRole] = "unreadMessages"; roles[LastMessageRole] = "lastMessage"; return roles; } QVariant RosterModel::data(const QModelIndex &index, int role) const { if (!hasIndex(index.row(), index.column(), index.parent())) { qWarning() << "Could not get data from roster model." << index << role; return {}; } switch (role) { case JidRole: return m_items.at(index.row()).jid(); case NameRole: return m_items.at(index.row()).name(); case LastExchangedRole: return m_items.at(index.row()).lastExchanged(); case UnreadMessagesRole: return m_items.at(index.row()).unreadMessages(); case LastMessageRole: return m_items.at(index.row()).lastMessage(); } return {}; } void RosterModel::handleItemsFetched(const QVector &items) { beginResetModel(); m_items = items; - std::sort( - m_items.begin(), - m_items.end(), - [] (const RosterItem &a, const RosterItem &b) { - return a < b; - } - ); + std::sort(m_items.begin(), m_items.end()); endResetModel(); } void RosterModel::addItem(const RosterItem &item) { - // prepend the item, if no timestamp is set - if (item.lastExchanged().isNull()) { - insertContact(0, item); - return; - } - - // index where to add the new contact - int i = 0; - for (const auto &itrItem : qAsConst(m_items)) { - if (item < itrItem) { - insertContact(i, item); - return; - } - i++; - } - - // append the item to the end of the list - insertContact(i, item); + insertContact(positionToInsert(item), item); } void RosterModel::removeItem(const QString &jid) { QMutableVectorIterator itr(m_items); int i = 0; while (itr.hasNext()) { if (itr.next().jid() == jid) { beginRemoveRows(QModelIndex(), i, i); itr.remove(); endRemoveRows(); return; } i++; } } void RosterModel::updateItem(const QString &jid, const std::function &updateItem) { for (int i = 0; i < m_items.length(); i++) { if (m_items.at(i).jid() == jid) { // update item RosterItem item = m_items.at(i); updateItem(item); // check if item was actually modified if (m_items.at(i) == item) return; + // item was changed: refresh all roles + emit dataChanged(index(i), index(i), {}); + // check, if the position of the new item may be different - if (item.lastExchanged() == m_items.at(i).lastExchanged()) { - beginRemoveRows(QModelIndex(), i, i); - m_items.removeAt(i); - endRemoveRows(); - - // add the item at the same position - insertContact(i, item); - } else { - beginRemoveRows(QModelIndex(), i, i); - m_items.removeAt(i); - endRemoveRows(); - - // put to new position - addItem(item); - } - break; + updateItemPosition(i); + return; } } } void RosterModel::replaceItems(const QHash &items) { QVector newItems; - for (auto item : items) { + for (auto item : qAsConst(items)) { // find old item auto oldItem = std::find_if( m_items.begin(), m_items.end(), [&] (const RosterItem &oldItem) { return oldItem.jid() == item.jid(); } ); // use the old item's values, if found if (oldItem != m_items.end()) { item.setLastMessage(oldItem->lastMessage()); item.setLastExchanged(oldItem->lastExchanged()); item.setUnreadMessages(oldItem->unreadMessages()); } newItems << item; } // replace all items handleItemsFetched(newItems); } +void RosterModel::setLastMessage(const QString &contactJid, const QString &newLastMessage) +{ + for (int i = 0; i < m_items.length(); i++) { + if (m_items.at(i).jid() == contactJid) { + m_items[i].setLastMessage(newLastMessage); + emit dataChanged(index(i), index(i), QVector() << int(LastMessageRole)); + return; + } + } +} + +void RosterModel::setLastExchanged(const QString &contactJid, const QDateTime &newLastExchanged) +{ + for (int i = 0; i < m_items.length(); i++) { + if (m_items.at(i).jid() == contactJid) { + // update item + m_items[i].setLastExchanged(newLastExchanged); + emit dataChanged(index(i), index(i), QVector() << int(LastExchangedRole)); + + // Move row to correct position + updateItemPosition(i); + return; + } + } +} + void RosterModel::insertContact(int i, const RosterItem &item) { beginInsertRows(QModelIndex(), i, i); m_items.insert(i, item); endInsertRows(); } + +int RosterModel::updateItemPosition(int currentPosition) +{ + const int newPosition = positionToInsert(m_items.at(currentPosition)); + if (currentPosition == newPosition) + return currentPosition; + + beginMoveRows(QModelIndex(), currentPosition, currentPosition, QModelIndex(), newPosition); + m_items.move(currentPosition, newPosition); + endMoveRows(); + + return newPosition; +} + +int RosterModel::positionToInsert(const RosterItem &item) +{ + // prepend the item, if no timestamp is set + if (item.lastExchanged().isNull()) + return 0; + + for (int i = 0; i < m_items.size(); i++) { + if (item <= m_items.at(i)) + return i; + } + + // append + return m_items.size(); +} diff --git a/src/RosterModel.h b/src/RosterModel.h index f1b93a3..502fb3e 100644 --- a/src/RosterModel.h +++ b/src/RosterModel.h @@ -1,86 +1,93 @@ /* * Kaidan - A user-friendly XMPP client for every device! * * Copyright (C) 2016-2020 Kaidan developers and contributors * (see the LICENSE file for a full list of copyright authors) * * Kaidan is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * In addition, as a special exception, the author of Kaidan gives * permission to link the code of its release with the OpenSSL * project's "OpenSSL" library (or with modified versions of it that * use the same license as the "OpenSSL" library), and distribute the * linked executables. You must obey the GNU General Public License in * all respects for all of the code used other than "OpenSSL". If you * modify this file, you may extend this exception to your version of * the file, but you are not obligated to do so. If you do not wish to * do so, delete this exception statement from your version. * * Kaidan is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Kaidan. If not, see . */ #ifndef ROSTERMODEL_H #define ROSTERMODEL_H #include #include #include "RosterItem.h" class Kaidan; class RosterDb; class MessageModel; class RosterModel : public QAbstractListModel { Q_OBJECT + public: enum RosterItemRoles { JidRole, NameRole, LastExchangedRole, UnreadMessagesRole, LastMessageRole, }; RosterModel(RosterDb *rosterDb, QObject *parent = nullptr); void setMessageModel(MessageModel *model); Q_REQUIRED_RESULT bool isEmpty() const; Q_REQUIRED_RESULT int rowCount(const QModelIndex &parent = QModelIndex()) const override; Q_REQUIRED_RESULT QHash roleNames() const override; Q_REQUIRED_RESULT QVariant data(const QModelIndex &index, int role) const override; signals: void addItemRequested(const RosterItem &item); void removeItemRequested(const QString &jid); void updateItemRequested(const QString &jid, const std::function &updateItem); void replaceItemsRequested(const QHash &items); + void setLastMessageRequested(const QString &contactJid, const QString &newLastMessage); + void setLastExchangedRequested(const QString &contactJid, const QDateTime &newLastExchanged); private slots: void handleItemsFetched(const QVector &items); void addItem(const RosterItem &item); void removeItem(const QString &jid); void updateItem(const QString &jid, const std::function &updateItem); void replaceItems(const QHash &items); + void setLastMessage(const QString &contactJid, const QString &newLastMessage); + void setLastExchanged(const QString &contactJid, const QDateTime &newLastExchanged); private: void insertContact(int i, const RosterItem &item); + int updateItemPosition(int currentIndex); + int positionToInsert(const RosterItem &item); RosterDb *rosterDb; QVector m_items; }; #endif // ROSTERMODEL_H