diff options
| author | Andrew Branson <andrew.branson@jolla.com> | 2026-04-03 22:55:30 +0200 |
|---|---|---|
| committer | Andrew Branson <andrew.branson@jolla.com> | 2026-04-04 11:55:25 +0200 |
| commit | a35c9fa159173388d88ef77e1d31f53488aad094 (patch) | |
| tree | e4691b5bbf054ca13e35d98d9df653bf9cdc0054 /buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediversenotificationssyncadaptor.cpp | |
| parent | 5f999f7a4712c4a4d1c89054b544064cfd4b769e (diff) | |
Generalize for all fediverse accounts
Diffstat (limited to 'buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediversenotificationssyncadaptor.cpp')
| -rw-r--r-- | buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediversenotificationssyncadaptor.cpp | 970 |
1 files changed, 970 insertions, 0 deletions
diff --git a/buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediversenotificationssyncadaptor.cpp b/buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediversenotificationssyncadaptor.cpp new file mode 100644 index 0000000..2a84637 --- /dev/null +++ b/buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediversenotificationssyncadaptor.cpp @@ -0,0 +1,970 @@ +/**************************************************************************** + ** + ** Copyright (C) 2013-2026 Jolla Ltd. + ** + ** This program/library is free software; you can redistribute it and/or + ** modify it under the terms of the GNU Lesser General Public License + ** version 2.1 as published by the Free Software Foundation. + ** + ** This program/library 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 + ** Lesser General Public License for more details. + ** + ** You should have received a copy of the GNU Lesser General Public + ** License along with this program/library; if not, write to the Free + ** Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + ** 02110-1301 USA + ** + ****************************************************************************/ + +#include "fediversenotificationssyncadaptor.h" +#include "fediversetextutils.h" + +#include <QtCore/QCoreApplication> +#include <QtCore/QLoggingCategory> +#include <QtCore/QJsonArray> +#include <QtCore/QJsonObject> +#include <QtCore/QJsonValue> +#include <QtCore/QUrl> +#include <QtCore/QUrlQuery> +#include <QtNetwork/QNetworkRequest> + +// libaccounts-qt5 +#include <Accounts/Account> +#include <Accounts/Manager> +#include <Accounts/Service> + +#include <notification.h> + +#include <algorithm> + +#define OPEN_URL_ACTION(openUrl) \ + Notification::remoteAction( \ + "default", \ + "", \ + "org.sailfishos.fileservice", \ + "/", \ + "org.sailfishos.fileservice", \ + "openUrl", \ + QVariantList() << openUrl \ + ) + +namespace { + Q_LOGGING_CATEGORY(lcFediverseNotifications, "buteo.plugin.fediverse.notifications", QtWarningMsg) + + const char *const NotificationCategory = "x-nemo.social.fediverse.notification"; + const char *const NotificationIdHint = "x-nemo.sociald.notification-id"; + const char *const LastFetchedNotificationIdKey = "LastFetchedNotificationId"; + const int NotificationsPageLimit = 80; + const uint NotificationDismissedReason = 1; + + //% "mentioned you" + const char *const TrIdMentionedYou = QT_TRID_NOOP("lipstick-jolla-home-la-fediverse-notification-mentioned_you"); + //% "boosted your post" + const char *const TrIdBoostedYourPost = QT_TRID_NOOP("lipstick-jolla-home-la-fediverse-notification-boosted_your_post"); + //% "favourited your post" + const char *const TrIdFavouritedYourPost = QT_TRID_NOOP("lipstick-jolla-home-la-fediverse-notification-favourited_your_post"); + //% "started following you" + const char *const TrIdStartedFollowingYou = QT_TRID_NOOP("lipstick-jolla-home-la-fediverse-notification-started_following_you"); + //% "requested to follow you" + const char *const TrIdRequestedToFollowYou = QT_TRID_NOOP("lipstick-jolla-home-la-fediverse-notification-requested_to_follow_you"); + //% "interacted with your poll" + const char *const TrIdInteractedWithYourPoll = QT_TRID_NOOP("lipstick-jolla-home-la-fediverse-notification-interacted_with_your_poll"); + //% "posted" + const char *const TrIdPosted = QT_TRID_NOOP("lipstick-jolla-home-la-fediverse-notification-posted"); + //% "updated a post" + const char *const TrIdUpdatedPost = QT_TRID_NOOP("lipstick-jolla-home-la-fediverse-notification-updated_post"); + //% "signed up" + const char *const TrIdSignedUp = QT_TRID_NOOP("lipstick-jolla-home-la-fediverse-notification-signed_up"); + //% "reported an account" + const char *const TrIdReportedAccount = QT_TRID_NOOP("lipstick-jolla-home-la-fediverse-notification-reported_account"); + //% "received a moderation warning" + const char *const TrIdReceivedModerationWarning = QT_TRID_NOOP("lipstick-jolla-home-la-fediverse-notification-received_moderation_warning"); + //% "quoted your post" + const char *const TrIdQuotedYourPost = QT_TRID_NOOP("lipstick-jolla-home-la-fediverse-notification-quoted_your_post"); + //% "updated a post that quoted you" + const char *const TrIdUpdatedQuotedPost = QT_TRID_NOOP("lipstick-jolla-home-la-fediverse-notification-updated_quoted_post"); + //% "sent you a notification" + const char *const TrIdSentNotification = QT_TRID_NOOP("lipstick-jolla-home-la-fediverse-notification-sent_notification"); + + //% "An admin blocked an instance" + const char *const TrIdAdminBlockedInstance = QT_TRID_NOOP("lipstick-jolla-home-la-fediverse-notification-admin_blocked_instance"); + //% "An admin blocked %1" + const char *const TrIdAdminBlockedTarget = QT_TRID_NOOP("lipstick-jolla-home-la-fediverse-notification-admin_blocked_target"); + //% "You blocked an instance" + const char *const TrIdYouBlockedInstance = QT_TRID_NOOP("lipstick-jolla-home-la-fediverse-notification-you_blocked_instance"); + //% "You blocked %1" + const char *const TrIdYouBlockedTarget = QT_TRID_NOOP("lipstick-jolla-home-la-fediverse-notification-you_blocked_target"); + //% "An account was suspended" + const char *const TrIdAccountSuspended = QT_TRID_NOOP("lipstick-jolla-home-la-fediverse-notification-account_suspended"); + //% "%1 was suspended" + const char *const TrIdTargetSuspended = QT_TRID_NOOP("lipstick-jolla-home-la-fediverse-notification-target_suspended"); + //% "Some follow relationships were severed" + const char *const TrIdRelationshipsSevered = QT_TRID_NOOP("lipstick-jolla-home-la-fediverse-notification-relationships_severed"); + //% "%1 (%2 followers, %3 following removed)" + const char *const TrIdRelationshipsSummary = QT_TRID_NOOP("lipstick-jolla-home-la-fediverse-notification-relationships_summary"); + + //% "A moderator sent you a warning" + const char *const TrIdModeratorWarningNone = QT_TRID_NOOP("lipstick-jolla-home-la-fediverse-notification-moderator_warning_none"); + //% "A moderator disabled your account" + const char *const TrIdModeratorWarningDisable = QT_TRID_NOOP("lipstick-jolla-home-la-fediverse-notification-moderator_warning_disable"); + //% "A moderator marked specific posts as sensitive" + const char *const TrIdModeratorWarningSpecificSensitive = QT_TRID_NOOP("lipstick-jolla-home-la-fediverse-notification-moderator_warning_specific_sensitive"); + //% "A moderator deleted specific posts" + const char *const TrIdModeratorWarningDeletePosts = QT_TRID_NOOP("lipstick-jolla-home-la-fediverse-notification-moderator_warning_delete_posts"); + //% "A moderator marked all your posts as sensitive" + const char *const TrIdModeratorWarningAllSensitive = QT_TRID_NOOP("lipstick-jolla-home-la-fediverse-notification-moderator_warning_all_sensitive"); + //% "A moderator limited your account" + const char *const TrIdModeratorWarningSilence = QT_TRID_NOOP("lipstick-jolla-home-la-fediverse-notification-moderator_warning_silence"); + //% "A moderator suspended your account" + const char *const TrIdModeratorWarningSuspend = QT_TRID_NOOP("lipstick-jolla-home-la-fediverse-notification-moderator_warning_suspend"); + + //% "Fediverse" + const char *const TrIdFediverse = QT_TRID_NOOP("lipstick-jolla-home-la-fediverse-notification-fediverse"); + //% "New notification" + const char *const TrIdNewNotification = QT_TRID_NOOP("lipstick-jolla-home-la-fediverse-notification-new_notification"); + + QString displayNameForAccount(const QJsonObject &account) + { + const QString displayName = account.value(QStringLiteral("display_name")).toString().trimmed(); + if (!displayName.isEmpty()) { + return displayName; + } + + const QString username = account.value(QStringLiteral("username")).toString().trimmed(); + if (!username.isEmpty()) { + return username; + } + + return account.value(QStringLiteral("acct")).toString().trimmed(); + } + + QString actionText(const QString &type) + { + if (type == QLatin1String("mention")) { + return qtTrId(TrIdMentionedYou); + } else if (type == QLatin1String("reblog")) { + return qtTrId(TrIdBoostedYourPost); + } else if (type == QLatin1String("favourite")) { + return qtTrId(TrIdFavouritedYourPost); + } else if (type == QLatin1String("follow")) { + return qtTrId(TrIdStartedFollowingYou); + } else if (type == QLatin1String("follow_request")) { + return qtTrId(TrIdRequestedToFollowYou); + } else if (type == QLatin1String("poll")) { + return qtTrId(TrIdInteractedWithYourPoll); + } else if (type == QLatin1String("status")) { + return qtTrId(TrIdPosted); + } else if (type == QLatin1String("update")) { + return qtTrId(TrIdUpdatedPost); + } else if (type == QLatin1String("admin.sign_up")) { + return qtTrId(TrIdSignedUp); + } else if (type == QLatin1String("admin.report")) { + return qtTrId(TrIdReportedAccount); + } else if (type == QLatin1String("moderation_warning")) { + return qtTrId(TrIdReceivedModerationWarning); + } else if (type == QLatin1String("quote")) { + return qtTrId(TrIdQuotedYourPost); + } else if (type == QLatin1String("quoted_update")) { + return qtTrId(TrIdUpdatedQuotedPost); + } + + return qtTrId(TrIdSentNotification); + } + + bool useSystemSummary(const QString ¬ificationType) + { + return notificationType == QLatin1String("severed_relationships") + || notificationType == QLatin1String("moderation_warning"); + } + + QString severedRelationshipsText(const QJsonObject &eventObject) + { + const QString eventType = eventObject.value(QStringLiteral("type")).toString(); + const QString targetName = eventObject.value(QStringLiteral("target_name")).toString().trimmed(); + const int followersCount = eventObject.value(QStringLiteral("followers_count")).toInt(); + const int followingCount = eventObject.value(QStringLiteral("following_count")).toInt(); + + QString action; + if (eventType == QLatin1String("domain_block")) { + action = targetName.isEmpty() + ? qtTrId(TrIdAdminBlockedInstance) + : qtTrId(TrIdAdminBlockedTarget).arg(targetName); + } else if (eventType == QLatin1String("user_domain_block")) { + action = targetName.isEmpty() + ? qtTrId(TrIdYouBlockedInstance) + : qtTrId(TrIdYouBlockedTarget).arg(targetName); + } else if (eventType == QLatin1String("account_suspension")) { + action = targetName.isEmpty() + ? qtTrId(TrIdAccountSuspended) + : qtTrId(TrIdTargetSuspended).arg(targetName); + } else { + action = qtTrId(TrIdRelationshipsSevered); + } + + const int affectedCount = followersCount + followingCount; + if (affectedCount <= 0) { + return action; + } + + return qtTrId(TrIdRelationshipsSummary) + .arg(action) + .arg(followersCount) + .arg(followingCount); + } + + QString moderationWarningText(const QJsonObject &warningObject) + { + const QString warningText = warningObject.value(QStringLiteral("text")).toString().trimmed(); + if (!warningText.isEmpty()) { + return warningText; + } + + const QString action = warningObject.value(QStringLiteral("action")).toString(); + if (action == QLatin1String("none")) { + return qtTrId(TrIdModeratorWarningNone); + } else if (action == QLatin1String("disable")) { + return qtTrId(TrIdModeratorWarningDisable); + } else if (action == QLatin1String("mark_statuses_as_sensitive")) { + return qtTrId(TrIdModeratorWarningSpecificSensitive); + } else if (action == QLatin1String("delete_statuses")) { + return qtTrId(TrIdModeratorWarningDeletePosts); + } else if (action == QLatin1String("sensitive")) { + return qtTrId(TrIdModeratorWarningAllSensitive); + } else if (action == QLatin1String("silence")) { + return qtTrId(TrIdModeratorWarningSilence); + } else if (action == QLatin1String("suspend")) { + return qtTrId(TrIdModeratorWarningSuspend); + } + + return QString(); + } + + bool hasActiveNotificationsForAccount(int accountId, const Notification *ignoredNotification = 0) + { + bool hasActiveNotifications = false; + const QList<QObject *> notifications = Notification::notifications(); + foreach (QObject *object, notifications) { + Notification *notification = qobject_cast<Notification *>(object); + if (notification + && notification != ignoredNotification + && notification->category() == QLatin1String(NotificationCategory) + && notification->hintValue("x-nemo.sociald.account-id").toInt() == accountId) { + hasActiveNotifications = true; + } + + delete object; + } + + return hasActiveNotifications; + } + + QString authorizeInteractionUrl(const QString &apiHost, const QString &targetUrl) + { + const QUrl parsedApiHost(apiHost); + const QUrl parsedTargetUrl(targetUrl); + if (!parsedApiHost.isValid() + || parsedApiHost.scheme().isEmpty() + || parsedApiHost.host().isEmpty() + || !parsedTargetUrl.isValid() + || parsedTargetUrl.scheme().isEmpty() + || parsedTargetUrl.host().isEmpty()) { + return targetUrl; + } + + // Links on the account's own instance should open directly. + const bool sameScheme = QString::compare(parsedApiHost.scheme(), parsedTargetUrl.scheme(), Qt::CaseInsensitive) == 0; + const bool sameHost = QString::compare(parsedApiHost.host(), parsedTargetUrl.host(), Qt::CaseInsensitive) == 0; + const int apiPort = parsedApiHost.port(parsedApiHost.scheme() == QLatin1String("https") ? 443 : 80); + const int targetPort = parsedTargetUrl.port(parsedTargetUrl.scheme() == QLatin1String("https") ? 443 : 80); + if (sameScheme && sameHost && apiPort == targetPort) { + return targetUrl; + } + + QUrl authorizeUrl(parsedApiHost); + authorizeUrl.setPath(QStringLiteral("/authorize_interaction")); + authorizeUrl.setQuery(QStringLiteral("uri=") + QString::fromUtf8(QUrl::toPercentEncoding(targetUrl))); + return authorizeUrl.toString(); + } + +} + +FediverseNotificationsSyncAdaptor::FediverseNotificationsSyncAdaptor(QObject *parent) + : FediverseNotificationsDataTypeSyncAdaptor(SocialNetworkSyncAdaptor::Notifications, parent) +{ + setInitialActive(true); +} + +FediverseNotificationsSyncAdaptor::~FediverseNotificationsSyncAdaptor() +{ +} + +QString FediverseNotificationsSyncAdaptor::syncServiceName() const +{ + return QStringLiteral("fediverse-notifications"); +} + +QString FediverseNotificationsSyncAdaptor::authServiceName() const +{ + return QStringLiteral("fediverse-microblog"); +} + +void FediverseNotificationsSyncAdaptor::purgeDataForOldAccount(int oldId, SocialNetworkSyncAdaptor::PurgeMode) +{ + closeAccountNotifications(oldId); + + m_accessTokens.remove(oldId); + m_pendingSyncStates.remove(oldId); + m_lastMarkedReadIds.remove(oldId); + saveLastFetchedId(oldId, QString()); +} + +void FediverseNotificationsSyncAdaptor::beginSync(int accountId, const QString &accessToken) +{ + m_accessTokens.insert(accountId, accessToken); + m_pendingSyncStates.remove(accountId); + requestUnreadMarker(accountId, accessToken); +} + +void FediverseNotificationsSyncAdaptor::finalize(int accountId) +{ + if (syncAborted()) { + qCInfo(lcFediverseNotifications) << "sync aborted, won't update notifications"; + } + + Q_UNUSED(accountId) +} + +QString FediverseNotificationsSyncAdaptor::sanitizeContent(const QString &content) +{ + return FediverseTextUtils::sanitizeContent(content); +} + +QDateTime FediverseNotificationsSyncAdaptor::parseTimestamp(const QString ×tampString) +{ + return FediverseTextUtils::parseTimestamp(timestampString); +} + +int FediverseNotificationsSyncAdaptor::compareNotificationIds(const QString &left, const QString &right) +{ + if (left == right) { + return 0; + } + + bool leftOk = false; + bool rightOk = false; + const qulonglong leftValue = left.toULongLong(&leftOk); + const qulonglong rightValue = right.toULongLong(&rightOk); + if (leftOk && rightOk) { + return leftValue < rightValue ? -1 : 1; + } + + if (left.size() != right.size()) { + return left.size() < right.size() ? -1 : 1; + } + return left < right ? -1 : 1; +} + +QString FediverseNotificationsSyncAdaptor::notificationObjectKey(int accountId, const QString ¬ificationId) +{ + return QString::number(accountId) + QLatin1Char(':') + notificationId; +} + +QString FediverseNotificationsSyncAdaptor::loadLastFetchedId(int accountId) const +{ + Accounts::Account *account = Accounts::Account::fromId(m_accountManager, accountId, 0); + if (!account) { + return QString(); + } + + Accounts::Service service(m_accountManager->service(syncServiceName())); + account->selectService(service); + const QString lastFetchedId = account->value(QString::fromLatin1(LastFetchedNotificationIdKey)).toString().trimmed(); + account->deleteLater(); + + return lastFetchedId; +} + +void FediverseNotificationsSyncAdaptor::saveLastFetchedId(int accountId, const QString &lastFetchedId) +{ + Accounts::Account *account = Accounts::Account::fromId(m_accountManager, accountId, 0); + if (!account) { + return; + } + + Accounts::Service service(m_accountManager->service(syncServiceName())); + account->selectService(service); + const QString storedId = account->value(QString::fromLatin1(LastFetchedNotificationIdKey)).toString().trimmed(); + if (storedId != lastFetchedId) { + account->setValue(QString::fromLatin1(LastFetchedNotificationIdKey), lastFetchedId); + account->syncAndBlock(); + } + + account->deleteLater(); +} + +void FediverseNotificationsSyncAdaptor::requestUnreadMarker(int accountId, const QString &accessToken) +{ + QUrl url(apiHost(accountId) + QStringLiteral("/api/v1/markers")); + + QUrlQuery query(url); + query.addQueryItem(QStringLiteral("timeline[]"), QStringLiteral("notifications")); + url.setQuery(query); + + QNetworkRequest request(url); + request.setRawHeader("Authorization", QStringLiteral("Bearer %1").arg(accessToken).toUtf8()); + + QNetworkReply *reply = m_networkAccessManager->get(request); + if (reply) { + reply->setProperty("accountId", accountId); + reply->setProperty("accessToken", accessToken); + connect(reply, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(errorHandler(QNetworkReply::NetworkError))); + connect(reply, SIGNAL(sslErrors(QList<QSslError>)), this, SLOT(sslErrorsHandler(QList<QSslError>))); + connect(reply, SIGNAL(finished()), this, SLOT(finishedUnreadMarkerHandler())); + + incrementSemaphore(accountId); + setupReplyTimeout(accountId, reply); + } else { + qCWarning(lcFediverseNotifications) << "unable to request notifications marker from Fediverse account with id" << accountId; + } +} + +void FediverseNotificationsSyncAdaptor::finishedUnreadMarkerHandler() +{ + QNetworkReply *reply = qobject_cast<QNetworkReply*>(sender()); + if (!reply) { + return; + } + + const bool isError = reply->property("isError").toBool(); + const int accountId = reply->property("accountId").toInt(); + const QString accessToken = reply->property("accessToken").toString(); + const QByteArray replyData = reply->readAll(); + + disconnect(reply); + reply->deleteLater(); + removeReplyTimeout(accountId, reply); + + bool ok = false; + const QJsonObject markerObject = parseJsonObjectReplyData(replyData, &ok); + if (isError || !ok) { + qCWarning(lcFediverseNotifications) << "unable to parse notifications marker data from request with account" + << accountId << ", got:" << QString::fromUtf8(replyData); + PendingSyncState fallbackState; + fallbackState.accessToken = accessToken; + fallbackState.lastFetchedId = loadLastFetchedId(accountId); + m_pendingSyncStates.insert(accountId, fallbackState); + requestNotifications(accountId, accessToken, fallbackState.lastFetchedId); + decrementSemaphore(accountId); + return; + } + + const QString markerId = markerObject.value(QStringLiteral("notifications")) + .toObject() + .value(QStringLiteral("last_read_id")) + .toVariant() + .toString() + .trimmed(); + + PendingSyncState state; + state.accessToken = accessToken; + state.markerKnown = true; + state.unreadFloorId = markerId; + state.lastFetchedId = loadLastFetchedId(accountId); + if (state.lastFetchedId.isEmpty() && !markerId.isEmpty()) { + // On first run, use the server unread marker floor to avoid historical flood. + state.lastFetchedId = markerId; + } + m_pendingSyncStates.insert(accountId, state); + requestNotifications(accountId, accessToken, markerId); + + decrementSemaphore(accountId); +} + +void FediverseNotificationsSyncAdaptor::requestNotifications(int accountId, + const QString &accessToken, + const QString &minId, + const QString &maxId) +{ + QUrl url(apiHost(accountId) + QStringLiteral("/api/v1/notifications")); + + QUrlQuery query(url); + query.addQueryItem(QStringLiteral("limit"), QString::number(NotificationsPageLimit)); + if (!minId.isEmpty()) { + query.addQueryItem(QStringLiteral("min_id"), minId); + } + if (!maxId.isEmpty()) { + query.addQueryItem(QStringLiteral("max_id"), maxId); + } + url.setQuery(query); + + QNetworkRequest request(url); + request.setRawHeader("Authorization", QStringLiteral("Bearer %1").arg(accessToken).toUtf8()); + + QNetworkReply *reply = m_networkAccessManager->get(request); + if (reply) { + reply->setProperty("accountId", accountId); + reply->setProperty("accessToken", accessToken); + reply->setProperty("minId", minId); + connect(reply, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(errorHandler(QNetworkReply::NetworkError))); + connect(reply, SIGNAL(sslErrors(QList<QSslError>)), this, SLOT(sslErrorsHandler(QList<QSslError>))); + connect(reply, SIGNAL(finished()), this, SLOT(finishedNotificationsHandler())); + + incrementSemaphore(accountId); + setupReplyTimeout(accountId, reply); + } else { + qCWarning(lcFediverseNotifications) << "unable to request notifications from Fediverse account with id" << accountId; + } +} + +void FediverseNotificationsSyncAdaptor::requestMarkRead(int accountId, + const QString &accessToken, + const QString &lastReadId) +{ + QUrl url(apiHost(accountId) + QStringLiteral("/api/v1/markers")); + QNetworkRequest request(url); + request.setRawHeader("Authorization", QStringLiteral("Bearer %1").arg(accessToken).toUtf8()); + request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/x-www-form-urlencoded")); + + QUrlQuery query; + query.addQueryItem(QStringLiteral("notifications[last_read_id]"), lastReadId); + const QByteArray payload = query.toString(QUrl::FullyEncoded).toUtf8(); + + QNetworkReply *reply = m_networkAccessManager->post(request, payload); + if (reply) { + reply->setProperty("accountId", accountId); + reply->setProperty("lastReadId", lastReadId); + connect(reply, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(errorHandler(QNetworkReply::NetworkError))); + connect(reply, SIGNAL(sslErrors(QList<QSslError>)), this, SLOT(sslErrorsHandler(QList<QSslError>))); + connect(reply, SIGNAL(finished()), this, SLOT(finishedMarkReadHandler())); + + incrementSemaphore(accountId); + setupReplyTimeout(accountId, reply); + } else { + qCWarning(lcFediverseNotifications) << "unable to update notifications marker for Fediverse account with id" << accountId; + } +} + +void FediverseNotificationsSyncAdaptor::finishedNotificationsHandler() +{ + QNetworkReply *reply = qobject_cast<QNetworkReply*>(sender()); + if (!reply) { + return; + } + + const bool isError = reply->property("isError").toBool(); + const int accountId = reply->property("accountId").toInt(); + const QString accessToken = reply->property("accessToken").toString(); + const QString minId = reply->property("minId").toString(); + const QByteArray replyData = reply->readAll(); + + disconnect(reply); + reply->deleteLater(); + removeReplyTimeout(accountId, reply); + + PendingSyncState state = m_pendingSyncStates.value(accountId); + if (state.accessToken.isEmpty()) { + state.accessToken = accessToken; + } + if (state.unreadFloorId.isEmpty() && !minId.isEmpty()) { + state.unreadFloorId = minId; + } + if (state.lastFetchedId.isEmpty() && !state.unreadFloorId.isEmpty()) { + state.lastFetchedId = state.unreadFloorId; + } else if (state.lastFetchedId.isEmpty() && !minId.isEmpty()) { + state.lastFetchedId = minId; + } + + bool ok = false; + const QJsonArray notifications = parseJsonArrayReplyData(replyData, &ok); + if (!isError && ok) { + if (!notifications.size()) { + qCDebug(lcFediverseNotifications) << "no notifications received for account" << accountId; + if (state.markerKnown) { + closeAccountNotifications(accountId); + } + m_pendingSyncStates.remove(accountId); + decrementSemaphore(accountId); + return; + } + + QString pageMinNotificationId; + + foreach (const QJsonValue ¬ificationValue, notifications) { + const QJsonObject notificationObject = notificationValue.toObject(); + if (notificationObject.isEmpty()) { + continue; + } + + const QString notificationId = notificationObject.value(QStringLiteral("id")).toVariant().toString(); + if (notificationId.isEmpty()) { + continue; + } + + if (pageMinNotificationId.isEmpty() + || compareNotificationIds(notificationId, pageMinNotificationId) < 0) { + pageMinNotificationId = notificationId; + } + + if (state.markerKnown) { + state.unreadNotificationIds.insert(notificationId); + } + + if (state.maxFetchedId.isEmpty() + || compareNotificationIds(notificationId, state.maxFetchedId) > 0) { + state.maxFetchedId = notificationId; + } + + if (!state.lastFetchedId.isEmpty() + && compareNotificationIds(notificationId, state.lastFetchedId) <= 0) { + continue; + } + + const QString notificationType = notificationObject.value(QStringLiteral("type")).toString(); + const QJsonObject actorObject = notificationObject.value(QStringLiteral("account")).toObject(); + const QJsonValue statusValue = notificationObject.value(QStringLiteral("status")); + const QJsonObject statusObject = statusValue.isObject() && !statusValue.isNull() + ? statusValue.toObject() + : QJsonObject(); + const QJsonObject eventObject = notificationObject.value(QStringLiteral("event")).toObject(); + const QJsonObject warningObject = notificationObject.value(QStringLiteral("moderation_warning")).toObject(); + + QDateTime eventTimestamp = parseTimestamp(notificationObject.value(QStringLiteral("created_at")).toString()); + if (!eventTimestamp.isValid()) { + eventTimestamp = parseTimestamp(statusObject.value(QStringLiteral("created_at")).toString()); + } + if (!eventTimestamp.isValid()) { + continue; + } + + const QString displayName = displayNameForAccount(actorObject); + const QString accountName = actorObject.value(QStringLiteral("acct")).toString(); + + const QString statusBody = sanitizeContent(statusObject.value(QStringLiteral("content")).toString()); + const QString action = actionText(notificationType); + QString body; + if (notificationType == QLatin1String("severed_relationships")) { + body = severedRelationshipsText(eventObject); + } else if (notificationType == QLatin1String("moderation_warning")) { + const QString warningText = moderationWarningText(warningObject); + body = warningText.isEmpty() + ? action + : QStringLiteral("%1: %2").arg(action, warningText); + } else if (notificationType == QLatin1String("mention") + || notificationType == QLatin1String("status") + || notificationType == QLatin1String("update") + || notificationType == QLatin1String("quote") + || notificationType == QLatin1String("quoted_update")) { + body = statusBody.isEmpty() ? action : statusBody; + } else { + body = statusBody.isEmpty() ? action : QStringLiteral("%1: %2").arg(action, statusBody); + } + + const QString statusId = statusObject.value(QStringLiteral("id")).toVariant().toString(); + + QString url = statusObject.value(QStringLiteral("url")).toString(); + if (url.isEmpty()) { + url = statusObject.value(QStringLiteral("uri")).toString(); + } + if (url.isEmpty()) { + url = actorObject.value(QStringLiteral("url")).toString(); + } + if (url.isEmpty() && !accountName.isEmpty() && !statusId.isEmpty()) { + url = QStringLiteral("%1/@%2/%3").arg(apiHost(accountId), accountName, statusId); + } else if (url.isEmpty() && !accountName.isEmpty()) { + url = QStringLiteral("%1/@%2").arg(apiHost(accountId), accountName); + } + if (useSystemSummary(notificationType)) { + url.clear(); + } + + PendingNotification pendingNotification; + pendingNotification.notificationId = notificationId; + pendingNotification.summary = useSystemSummary(notificationType) + ? qtTrId(TrIdFediverse) + : displayName; + pendingNotification.body = body; + pendingNotification.link = url; + pendingNotification.timestamp = eventTimestamp; + state.pendingNotifications.insert(notificationId, pendingNotification); + } + + const QString historyBoundaryId = !state.unreadFloorId.isEmpty() + ? state.unreadFloorId + : state.lastFetchedId; + if (notifications.size() >= NotificationsPageLimit + && !pageMinNotificationId.isEmpty() + && !historyBoundaryId.isEmpty() + && compareNotificationIds(pageMinNotificationId, historyBoundaryId) > 0) { + m_pendingSyncStates.insert(accountId, state); + requestNotifications(accountId, state.accessToken, historyBoundaryId, pageMinNotificationId); + decrementSemaphore(accountId); + return; + } + + if (state.pendingNotifications.size() > 0) { + QStringList notificationIds = state.pendingNotifications.keys(); + std::sort(notificationIds.begin(), notificationIds.end(), [](const QString &left, const QString &right) { + return compareNotificationIds(left, right) > 0; + }); + + foreach (const QString ¬ificationId, notificationIds) { + const PendingNotification pendingNotification = state.pendingNotifications.value(notificationId); + publishSystemNotification(accountId, pendingNotification); + } + } + + if (state.markerKnown) { + closeAccountNotifications(accountId, state.unreadNotificationIds); + } + + if (!state.maxFetchedId.isEmpty() + && (state.lastFetchedId.isEmpty() + || compareNotificationIds(state.maxFetchedId, state.lastFetchedId) > 0)) { + saveLastFetchedId(accountId, state.maxFetchedId); + } + + const QString markerId = !state.maxFetchedId.isEmpty() + ? state.maxFetchedId + : state.lastFetchedId; + const QString currentMarkerId = m_lastMarkedReadIds.value(accountId); + if (!markerId.isEmpty() + && !state.accessToken.isEmpty() + && state.markerKnown + && (currentMarkerId.isEmpty() + || compareNotificationIds(markerId, currentMarkerId) > 0)) { + maybeMarkAccountNotificationsRead(accountId, state.accessToken); + } + } else { + qCWarning(lcFediverseNotifications) << "unable to parse notifications data from request with account" << accountId + << ", got:" << QString::fromUtf8(replyData); + } + + m_pendingSyncStates.remove(accountId); + decrementSemaphore(accountId); +} + +void FediverseNotificationsSyncAdaptor::finishedMarkReadHandler() +{ + QNetworkReply *reply = qobject_cast<QNetworkReply *>(sender()); + if (!reply) { + return; + } + + const bool isError = reply->property("isError").toBool(); + const int accountId = reply->property("accountId").toInt(); + const QString lastReadId = reply->property("lastReadId").toString(); + const QByteArray replyData = reply->readAll(); + + disconnect(reply); + reply->deleteLater(); + removeReplyTimeout(accountId, reply); + + bool ok = false; + parseJsonObjectReplyData(replyData, &ok); + if (!isError && ok) { + const QString currentMarkerId = m_lastMarkedReadIds.value(accountId); + if (currentMarkerId.isEmpty() || compareNotificationIds(lastReadId, currentMarkerId) > 0) { + m_lastMarkedReadIds.insert(accountId, lastReadId); + } + } else { + qCWarning(lcFediverseNotifications) << "unable to update notifications marker for account" << accountId + << ", got:" << QString::fromUtf8(replyData); + } + + decrementSemaphore(accountId); +} + +void FediverseNotificationsSyncAdaptor::publishSystemNotification(int accountId, + const PendingNotification ¬ificationData) +{ + Notification *notification = createNotification(accountId, notificationData.notificationId); + notification->setItemCount(1); + notification->setTimestamp(notificationData.timestamp.isValid() + ? notificationData.timestamp + : QDateTime::currentDateTimeUtc()); + notification->setSummary(notificationData.summary.isEmpty() + ? qtTrId(TrIdFediverse) + : notificationData.summary); + notification->setBody(notificationData.body.isEmpty() + ? qtTrId(TrIdNewNotification) + : notificationData.body); + notification->setPreviewSummary(notificationData.summary); + notification->setPreviewBody(notificationData.body); + + const QString openUrl = notificationData.link.isEmpty() + ? apiHost(accountId) + QStringLiteral("/notifications") + : notificationData.link; + const QUrl parsedOpenUrl(openUrl); + const QString fallbackUrl = apiHost(accountId) + QStringLiteral("/notifications"); + const QString safeOpenUrl = parsedOpenUrl.isValid() + && !parsedOpenUrl.scheme().isEmpty() + && !parsedOpenUrl.host().isEmpty() + ? openUrl + : fallbackUrl; + notification->setRemoteAction(OPEN_URL_ACTION(authorizeInteractionUrl(apiHost(accountId), safeOpenUrl))); + notification->publish(); + if (notification->replacesId() == 0) { + qCWarning(lcFediverseNotifications) << "failed to publish Fediverse notification" + << notificationData.notificationId; + } +} + +void FediverseNotificationsSyncAdaptor::notificationClosedWithReason(uint reason) +{ + Notification *notification = qobject_cast<Notification *>(sender()); + removeCachedNotification(notification); + if (reason == NotificationDismissedReason) { + markReadFromNotification(notification); + } +} + +void FediverseNotificationsSyncAdaptor::maybeMarkAccountNotificationsRead(int accountId, + const QString &accessToken, + Notification *ignoredNotification) +{ + if (accountId <= 0 || accessToken.isEmpty()) { + return; + } + + if (hasActiveNotificationsForAccount(accountId, ignoredNotification)) { + return; + } + + const QString lastReadId = loadLastFetchedId(accountId); + if (lastReadId.isEmpty()) { + return; + } + + const QString currentMarkerId = m_lastMarkedReadIds.value(accountId); + if (!currentMarkerId.isEmpty() && compareNotificationIds(lastReadId, currentMarkerId) <= 0) { + return; + } + + requestMarkRead(accountId, accessToken, lastReadId); +} + +void FediverseNotificationsSyncAdaptor::markReadFromNotification(Notification *notification) +{ + if (!notification) { + return; + } + + const int accountId = notification->hintValue("x-nemo.sociald.account-id").toInt(); + const QString accessToken = m_accessTokens.value(accountId).trimmed(); + if (accountId <= 0 || accessToken.isEmpty()) { + return; + } + + maybeMarkAccountNotificationsRead(accountId, accessToken, notification); +} + +void FediverseNotificationsSyncAdaptor::removeCachedNotification(Notification *notification) +{ + if (!notification) { + return; + } + + const int accountId = notification->hintValue("x-nemo.sociald.account-id").toInt(); + const QString notificationId = notification->hintValue(NotificationIdHint).toString(); + if (accountId <= 0 || notificationId.isEmpty()) { + return; + } + + m_notificationObjects.remove(notificationObjectKey(accountId, notificationId)); +} + +void FediverseNotificationsSyncAdaptor::closeAccountNotifications(int accountId, + const QSet<QString> &keepNotificationIds) +{ + QStringList cachedKeys = m_notificationObjects.keys(); + foreach (const QString &objectKey, cachedKeys) { + Notification *notification = m_notificationObjects.value(objectKey); + if (!notification + || notification->hintValue("x-nemo.sociald.account-id").toInt() != accountId) { + continue; + } + + const QString notificationId = notification->hintValue(NotificationIdHint).toString(); + if (!notificationId.isEmpty() && keepNotificationIds.contains(notificationId)) { + continue; + } + + notification->close(); + m_notificationObjects.remove(objectKey); + notification->deleteLater(); + } + + QList<QObject *> notifications = Notification::notifications(); + foreach (QObject *object, notifications) { + Notification *notification = qobject_cast<Notification *>(object); + if (!notification) { + delete object; + continue; + } + + if (notification->category() == QLatin1String(NotificationCategory) + && notification->hintValue("x-nemo.sociald.account-id").toInt() == accountId) { + const QString notificationId = notification->hintValue(NotificationIdHint).toString(); + if (notificationId.isEmpty() || !keepNotificationIds.contains(notificationId)) { + notification->close(); + } + } + + if (notification->parent() != this) { + delete notification; + } + } +} + +Notification *FediverseNotificationsSyncAdaptor::createNotification(int accountId, const QString ¬ificationId) +{ + const QString objectKey = notificationObjectKey(accountId, notificationId); + Notification *notification = m_notificationObjects.value(objectKey); + if (!notification) { + notification = findNotification(accountId, notificationId); + } + if (!notification) { + notification = new Notification(this); + } else if (notification->parent() != this) { + notification->setParent(this); + } + + notification->setAppName(QStringLiteral("Fediverse")); + notification->setAppIcon(QStringLiteral("icon-l-fediverse")); + notification->setHintValue("x-nemo.sociald.account-id", accountId); + notification->setHintValue(NotificationIdHint, notificationId); + notification->setHintValue("x-nemo-feedback", QStringLiteral("social")); + notification->setHintValue("x-nemo-priority", 100); // Show on lockscreen + notification->setCategory(QLatin1String(NotificationCategory)); + + connect(notification, SIGNAL(closed(uint)), this, SLOT(notificationClosedWithReason(uint)), Qt::UniqueConnection); + m_notificationObjects.insert(objectKey, notification); + + return notification; +} + +Notification *FediverseNotificationsSyncAdaptor::findNotification(int accountId, const QString ¬ificationId) +{ + Notification *notification = 0; + QList<QObject *> notifications = Notification::notifications(); + foreach (QObject *object, notifications) { + Notification *castedNotification = qobject_cast<Notification *>(object); + if (castedNotification + && castedNotification->category() == QLatin1String(NotificationCategory) + && castedNotification->hintValue("x-nemo.sociald.account-id").toInt() == accountId + && castedNotification->hintValue(NotificationIdHint).toString() == notificationId) { + notification = castedNotification; + break; + } + } + + if (notification) { + notifications.removeAll(notification); + } + + qDeleteAll(notifications); + + return notification; +} |
