// SPDX-FileCopyrightText: 2023 Tobias Fella // SPDX-License-Identifier: GPL-2.0-or-later #include "neochatconnection.h" #include #include #include "jobs/neochatreportuserjob.h" #include "neochatroom.h" #include "spacehierarchycache.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #ifdef HAVE_KUNIFIEDPUSH #include #include #include #endif using namespace Quotient; using namespace Qt::StringLiterals; bool NeoChatConnection::m_globalUrlPreviewDefault = true; PushRuleAction::Action NeoChatConnection::m_defaultAction = PushRuleAction::Unknown; NeoChatConnection::NeoChatConnection(QObject *parent) : Connection(parent) { m_linkPreviewers.setMaxCost(20); connectSignals(); } NeoChatConnection::NeoChatConnection(const QUrl &server, QObject *parent) : Connection(server, parent) { m_linkPreviewers.setMaxCost(20); connectSignals(); } void NeoChatConnection::connectSignals() { connect(this, &NeoChatConnection::accountDataChanged, this, [this](const QString &type) { if (type == u"org.kde.neochat.account_label"_s) { Q_EMIT labelChanged(); } if (type == u"m.identity_server"_s) { Q_EMIT identityServerChanged(); } }); connect(this, &NeoChatConnection::requestFailed, this, [this](BaseJob *job) { if (job->error() == BaseJob::UserConsentRequired) { Q_EMIT userConsentRequired(job->errorUrl()); } }); connect(this, &NeoChatConnection::requestFailed, this, [this](BaseJob *job) { if (dynamic_cast(job) && job->jsonData()["errcode"_L1].toString() == "M_TOO_LARGE"_L1) { Q_EMIT showMessage(MessageType::Warning, i18n("File too large to download.
Contact your matrix server administrator for support.")); } }); connect(this, &NeoChatConnection::directChatsListChanged, this, [this](DirectChatsMap additions, DirectChatsMap removals) { Q_EMIT directChatInvitesChanged(); for (const auto &chatId : additions) { if (const auto chat = room(chatId)) { connect(chat, &Room::unreadStatsChanged, this, [this]() { refreshBadgeNotificationCount(); Q_EMIT directChatNotificationsChanged(); Q_EMIT directChatsHaveHighlightNotificationsChanged(); }); } } for (const auto &chatId : removals) { if (const auto chat = room(chatId)) { disconnect(chat, &Room::unreadStatsChanged, this, nullptr); } } }); connect(this, &NeoChatConnection::joinedRoom, this, [this](Room *room) { if (room->isDirectChat()) { connect(room, &Room::unreadStatsChanged, this, [this]() { Q_EMIT directChatNotificationsChanged(); Q_EMIT directChatsHaveHighlightNotificationsChanged(); }); } Q_EMIT roomInvitesChanged(); connect(room, &Room::unreadStatsChanged, this, [this]() { refreshBadgeNotificationCount(); Q_EMIT homeNotificationsChanged(); Q_EMIT homeHaveHighlightNotificationsChanged(); }); }); connect(this, &NeoChatConnection::leftRoom, this, [this](Room *room, Room *prev) { Q_UNUSED(room) if (prev && prev->isDirectChat()) { Q_EMIT directChatInvitesChanged(); Q_EMIT directChatNotificationsChanged(); Q_EMIT directChatsHaveHighlightNotificationsChanged(); } refreshBadgeNotificationCount(); Q_EMIT homeNotificationsChanged(); Q_EMIT homeHaveHighlightNotificationsChanged(); }); connect(&SpaceHierarchyCache::instance(), &SpaceHierarchyCache::spaceHierarchyChanged, this, [this]() { refreshBadgeNotificationCount(); Q_EMIT homeNotificationsChanged(); Q_EMIT homeHaveHighlightNotificationsChanged(); }); connect(this, &NeoChatConnection::globalUrlPreviewEnabledChanged, this, [this]() { if (!m_globalUrlPreviewDefault) { m_linkPreviewers.clear(); } }); // Fetch unstable features // TODO: Expose unstableFeatures() in libQuotient connect( this, &Connection::connected, this, [this] { callApi(BackgroundRequest).onResult([this](const auto &job) { m_canCheckMutualRooms = job->unstableFeatures().contains("uk.half-shot.msc2666.query_mutual_rooms"_L1); Q_EMIT canCheckMutualRoomsChanged(); m_canEraseData = job->unstableFeatures().contains("org.matrix.msc4025"_L1) || job->versions().count("v1.10"_L1); Q_EMIT canEraseDataChanged(); }); }, Qt::SingleShotConnection); connect(this, &Connection::sessionVerified, this, [this](const QString &userId, const QString &deviceId) { if (userId == this->userId() && deviceId == this->deviceId()) { Q_EMIT ownSessionVerified(); } }); } int NeoChatConnection::badgeNotificationCount() const { return m_badgeNotificationCount; } void NeoChatConnection::refreshBadgeNotificationCount() { int count = 0; for (const auto &r : allRooms()) { if (const auto room = static_cast(r)) { count += room->contextAwareNotificationCount(); if (room->joinState() == JoinState::Invite) { count++; } } } if (count != m_badgeNotificationCount) { m_badgeNotificationCount = count; Q_EMIT badgeNotificationCountChanged(m_badgeNotificationCount); } } bool NeoChatConnection::globalUrlPreviewEnabled() { return m_globalUrlPreviewDefault; } void NeoChatConnection::setGlobalUrlPreviewDefault(bool useByDefault) { NeoChatConnection::m_globalUrlPreviewDefault = useByDefault; } PushRuleAction::Action NeoChatConnection::keywordPushRuleDefault() const { return m_defaultAction; } void NeoChatConnection::setKeywordPushRuleDefault(PushRuleAction::Action defaultAction) { NeoChatConnection::m_defaultAction = defaultAction; } void NeoChatConnection::logout(bool serverSideLogout) { SettingsGroup(u"Accounts"_s).remove(userId()); QKeychain::DeletePasswordJob job(qAppName()); job.setAutoDelete(true); job.setKey(userId()); QEventLoop loop; QKeychain::DeletePasswordJob::connect(&job, &QKeychain::Job::finished, &loop, &QEventLoop::quit); job.start(); loop.exec(); if (!serverSideLogout) { return; } Connection::logout(); } bool NeoChatConnection::setAvatar(const QUrl &avatarSource) { QString decoded = avatarSource.path(); if (decoded.isEmpty()) { callApi(user()->id(), avatarSource); return true; } if (QImageReader(decoded).read().isNull()) { return false; } else { return user()->setAvatar(decoded); } } QVariantList NeoChatConnection::getSupportedRoomVersions() const { const auto &roomVersions = availableRoomVersions(); QVariantList supportedRoomVersions; for (const auto &v : roomVersions) { QVariantMap roomVersionMap; roomVersionMap.insert("id"_L1, v.id); roomVersionMap.insert("status"_L1, v.status); roomVersionMap.insert("isStable"_L1, v.isStable()); supportedRoomVersions.append(roomVersionMap); } return supportedRoomVersions; } bool NeoChatConnection::canCheckMutualRooms() const { return m_canCheckMutualRooms; } void NeoChatConnection::changePassword(const QString ¤tPassword, const QString &newPassword) { callApi(newPassword, false).onFailure([this, currentPassword, newPassword](const auto &job) { QJsonObject replyData = job->jsonData(); AuthenticationData authData; authData.session = replyData["session"_L1].toString(); authData.type = "m.login.password"_L1; authData.authInfo["password"_L1] = currentPassword; authData.authInfo["user"_L1] = user()->id(); authData.authInfo["identifier"_L1] = QJsonObject{{"type"_L1, "m.id.user"_L1}, {"user"_L1, user()->id()}}; auto innerJob = callApi(newPassword, false, authData) .then( [this]() { Q_EMIT passwordStatus(PasswordStatus::Success); }, [this](const auto &job) { Q_EMIT passwordStatus(job->jsonData()["errcode"_L1] == "M_FORBIDDEN"_L1 ? PasswordStatus::Wrong : PasswordStatus::Other); }); }); } void NeoChatConnection::setLabel(const QString &label) { QJsonObject json{ {"account_label"_L1, label}, }; setAccountData("org.kde.neochat.account_label"_L1, json); Q_EMIT labelChanged(); } QString NeoChatConnection::label() const { return accountDataJson("org.kde.neochat.account_label"_L1)["account_label"_L1].toString(); } void NeoChatConnection::deactivateAccount(const QString &password, const bool erase) { callApi().onFailure([password, erase, this](const auto &job) { QJsonObject replyData = job->jsonData(); AuthenticationData authData; authData.session = replyData["session"_L1].toString(); authData.authInfo["password"_L1] = password; authData.type = "m.login.password"_L1; authData.authInfo["user"_L1] = user()->id(); QJsonObject identifier = {{"type"_L1, "m.id.user"_L1}, {"user"_L1, user()->id()}}; authData.authInfo["identifier"_L1] = identifier; callApi(authData, QString{}, erase).onResult([this]() { logout(false); }); }); } bool NeoChatConnection::hasIdentityServer() const { if (!hasAccountData(u"m.identity_server"_s)) { return false; } const auto url = accountData(u"m.identity_server"_s)->contentPart("base_url"_L1); if (!url.isEmpty()) { return true; } return false; } QUrl NeoChatConnection::identityServer() const { if (!hasAccountData(u"m.identity_server"_s)) { return {}; } const auto url = accountData(u"m.identity_server"_s)->contentPart("base_url"_L1); if (!url.isEmpty()) { return url; } return {}; } QString NeoChatConnection::identityServerUIString() const { if (!hasIdentityServer()) { return i18nc("@info", "No identity server configured"); } return identityServer().toString(); } void NeoChatConnection::createRoom(const QString &name, const QString &topic, const QString &parent, bool setChildParent) { QList initialStateEvents; if (!parent.isEmpty()) { initialStateEvents.append(CreateRoomJob::StateEvent{ "m.space.parent"_L1, QJsonObject{ {"canonical"_L1, true}, {"via"_L1, QJsonArray{domain()}}, }, parent, }); } Connection::createRoom(Connection::PublishRoom, QString(), name, topic, QStringList(), {}, {}, {}, initialStateEvents) .then( [parent, setChildParent, this](const auto &job) { if (parent.isEmpty() || !setChildParent) { return; } if (auto parentRoom = room(parent)) { parentRoom->setState(u"m.space.child"_s, job->roomId(), QJsonObject{{"via"_L1, QJsonArray{domain()}}}); } }, [this](const auto &job) { Q_EMIT errorOccured(i18n("Room creation failed: %1", job->errorString())); }); } void NeoChatConnection::createSpace(const QString &name, const QString &topic, const QString &parent, bool setChildParent) { QList initialStateEvents; if (!parent.isEmpty()) { initialStateEvents.append(CreateRoomJob::StateEvent{ "m.space.parent"_L1, QJsonObject{ {"canonical"_L1, true}, {"via"_L1, QJsonArray{domain()}}, }, parent, }); } Connection::createRoom(Connection::UnpublishRoom, {}, name, topic, {}, {}, {}, false, initialStateEvents, {}, QJsonObject{{"type"_L1, "m.space"_L1}}) .then( [parent, setChildParent, this](const auto &job) { if (parent.isEmpty() || !setChildParent) { return; } if (auto parentRoom = room(parent)) { parentRoom->setState(u"m.space.child"_s, job->roomId(), QJsonObject{{"via"_L1, QJsonArray{domain()}}}); } }, [this](const auto &job) { Q_EMIT errorOccured(i18n("Space creation failed: %1", job->errorString())); }); } Quotient::ForgetRoomJob *NeoChatConnection::forgetRoom(const QString &id) { Q_EMIT roomAboutToBeLeft(id); return Connection::forgetRoom(id); } bool NeoChatConnection::directChatExists(Quotient::User *user) { return directChats().contains(user); } qsizetype NeoChatConnection::directChatNotifications() const { qsizetype notifications = 0; QStringList added; // The same ID can be in the list multiple times. for (const auto &chatId : directChats()) { if (!added.contains(chatId)) { if (const auto chat = room(chatId)) { notifications += dynamic_cast(chat)->contextAwareNotificationCount(); added += chatId; } } } return notifications; } bool NeoChatConnection::directChatsHaveHighlightNotifications() const { for (const auto &childId : directChats()) { if (const auto child = static_cast(room(childId))) { if (child->highlightCount() > 0) { return true; } } } return false; } qsizetype NeoChatConnection::homeNotifications() const { qsizetype notifications = 0; QStringList added; const auto &spaceHierarchyCache = SpaceHierarchyCache::instance(); for (const auto &r : allRooms()) { if (const auto room = static_cast(r)) { if (!added.contains(room->id()) && !room->isDirectChat() && !spaceHierarchyCache.isChild(room->id())) { notifications += dynamic_cast(room)->contextAwareNotificationCount(); added += room->id(); } } } return notifications; } bool NeoChatConnection::homeHaveHighlightNotifications() const { const auto &spaceHierarchyCache = SpaceHierarchyCache::instance(); for (const auto &r : allRooms()) { if (const auto room = static_cast(r)) { if (!room->isDirectChat() && !spaceHierarchyCache.isChild(room->id()) && room->highlightCount() > 0) { return true; } } } return false; } qsizetype NeoChatConnection::directChatInvites() const { const auto inviteRooms = rooms(JoinState::Invite); return std::ranges::count_if(inviteRooms, [](const auto room) { return room->isDirectChat(); }); } qsizetype NeoChatConnection::roomInvites() const { const auto inviteRooms = rooms(JoinState::Invite); return std::ranges::count_if(inviteRooms, [](const auto room) { return !room->isDirectChat(); }); } QCoro::Task NeoChatConnection::setupPushNotifications(QString endpoint) { #ifdef HAVE_KUNIFIEDPUSH QUrl gatewayEndpoint(endpoint); gatewayEndpoint.setPath(u"/_matrix/push/v1/notify"_s); QNetworkRequest checkGateway(gatewayEndpoint); auto reply = co_await NetworkAccessManager::instance()->get(checkGateway); // We want to check if this UnifiedPush server has a Matrix gateway // This is because Matrix does not natively support UnifiedPush const auto &replyJson = QJsonDocument::fromJson(reply->readAll()).object(); if (replyJson["unifiedpush"_L1]["gateway"_L1].toString() == u"matrix"_s) { callApi(endpoint, u"http"_s, u"org.kde.neochat"_s, u"NeoChat"_s, deviceId(), QString(), // profileTag is intentionally left empty for now, it's optional u"en-US"_s, PostPusherJob::PusherData{QUrl::fromUserInput(gatewayEndpoint.toString()), u" "_s}, false); qInfo() << "Registered for push notifications"; m_pushNotificationsEnabled = true; } else { qWarning() << "There's no gateway, not setting up push notifications."; m_pushNotificationsEnabled = false; } Q_EMIT enablePushNotificationsChanged(); #else Q_UNUSED(endpoint) co_return; #endif } QString NeoChatConnection::accountDataJsonString(const QString &type) const { return QString::fromUtf8(QJsonDocument(accountDataJson(type)).toJson()); } LinkPreviewer *NeoChatConnection::previewerForLink(const QUrl &link) { if (!m_globalUrlPreviewDefault) { return nullptr; } auto previewer = m_linkPreviewers.object(link); if (previewer != nullptr) { return previewer; } previewer = new LinkPreviewer(link, this); m_linkPreviewers.insert(link, previewer); return previewer; } KeyImport::Error NeoChatConnection::exportMegolmSessions(const QString &passphrase, const QString &path) { KeyImport keyImport; auto result = keyImport.exportKeys(passphrase, this); if (!result.has_value()) { return result.error(); } QUrl url(path); QFile file(url.toLocalFile()); auto ok = file.open(QFile::WriteOnly); if (!ok) { qWarning() << "Failed to open" << file.fileName() << file.errorString(); return KeyImport::OtherError; } file.write(result.value()); file.close(); return KeyImport::Success; } bool NeoChatConnection::canEraseData() const { return m_canEraseData; } bool NeoChatConnection::pushNotificationsAvailable() const { #ifdef HAVE_KUNIFIEDPUSH return true; #else return false; #endif } bool NeoChatConnection::enablePushNotifications() const { return m_pushNotificationsEnabled; } bool NeoChatConnection::isVerifiedSession() const { return isVerifiedDevice(userId(), deviceId()); } void NeoChatConnection::unlockSSSS(const QString &secret) { auto handler = new SSSSHandler(); handler->setConnection(this); connect(handler, &SSSSHandler::error, this, [secret, handler, this]() { disconnect(handler, &SSSSHandler::error, this, nullptr); if (!secret.isEmpty()) { connect(handler, &SSSSHandler::error, this, [handler, this]() { Q_EMIT keyBackupError(); delete handler; }); handler->unlockSSSSWithPassphrase(secret); } else { Q_EMIT keyBackupError(); } }); connect(handler, &SSSSHandler::keyBackupUnlocked, this, [handler, this]() { Q_EMIT keyBackupUnlocked(); connect(handler, &SSSSHandler::finished, handler, &SSSSHandler::deleteLater); }); if (secret.isEmpty()) { handler->unlockSSSSFromCrossSigning(); } else { handler->unlockSSSSFromSecurityKey(secret); } } void NeoChatConnection::reportUser(const QString &userId, const QString &reason) { callApi(userId, reason); } #include "moc_neochatconnection.cpp"