summaryrefslogtreecommitdiff
path: root/buteo-plugins/buteo-sync-plugin-fediverse-notifications
diff options
context:
space:
mode:
Diffstat (limited to 'buteo-plugins/buteo-sync-plugin-fediverse-notifications')
-rw-r--r--buteo-plugins/buteo-sync-plugin-fediverse-notifications/buteo-sync-plugin-fediverse-notifications.pro66
-rw-r--r--buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediverse-notifications.xml6
-rw-r--r--buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediverse.Notifications.xml17
-rw-r--r--buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediversedatatypesyncadaptor.cpp240
-rw-r--r--buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediversedatatypesyncadaptor.h70
-rw-r--r--buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediversenotificationsplugin.cpp96
-rw-r--r--buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediversenotificationsplugin.h54
-rw-r--r--buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediversenotificationssyncadaptor.cpp970
-rw-r--r--buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediversenotificationssyncadaptor.h104
9 files changed, 1623 insertions, 0 deletions
diff --git a/buteo-plugins/buteo-sync-plugin-fediverse-notifications/buteo-sync-plugin-fediverse-notifications.pro b/buteo-plugins/buteo-sync-plugin-fediverse-notifications/buteo-sync-plugin-fediverse-notifications.pro
new file mode 100644
index 0000000..0ba9d2f
--- /dev/null
+++ b/buteo-plugins/buteo-sync-plugin-fediverse-notifications/buteo-sync-plugin-fediverse-notifications.pro
@@ -0,0 +1,66 @@
+# SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd.
+#
+# SPDX-License-Identifier: BSD-3-Clause
+
+TARGET = fediverse-notifications-client
+
+QT -= gui
+
+include($$PWD/../buteo-common/buteo-common.pri)
+include($$PWD/../../common/common.pri)
+
+TS_FILE = $$OUT_PWD/lipstick-jolla-home-fediverse-notifications.ts
+EE_QM = $$OUT_PWD/lipstick-jolla-home-fediverse-notifications_eng_en.qm
+
+ts.commands += lupdate $$PWD -ts $$TS_FILE
+ts.CONFIG += no_check_exist no_link
+ts.output = $$TS_FILE
+ts.input = .
+
+ts_install.files = $$TS_FILE
+ts_install.path = /usr/share/translations/source
+ts_install.CONFIG += no_check_exist
+
+engineering_english.commands += lrelease -idbased $$TS_FILE -qm $$EE_QM
+engineering_english.CONFIG += no_check_exist no_link
+engineering_english.depends = ts
+engineering_english.input = $$TS_FILE
+engineering_english.output = $$EE_QM
+
+engineering_english_install.path = /usr/share/translations
+engineering_english_install.files = $$EE_QM
+engineering_english_install.CONFIG += no_check_exist
+
+QMAKE_EXTRA_TARGETS += ts engineering_english
+PRE_TARGETDEPS += ts engineering_english
+
+CONFIG += link_pkgconfig
+PKGCONFIG += mlite5 nemonotifications-qt5
+
+INCLUDEPATH += $$PWD
+
+SOURCES += \
+ $$PWD/fediversedatatypesyncadaptor.cpp \
+ $$PWD/fediversenotificationsplugin.cpp \
+ $$PWD/fediversenotificationssyncadaptor.cpp
+
+HEADERS += \
+ $$PWD/fediversedatatypesyncadaptor.h \
+ $$PWD/fediversenotificationsplugin.h \
+ $$PWD/fediversenotificationssyncadaptor.h
+
+OTHER_FILES += \
+ $$PWD/fediverse-notifications.xml \
+ $$PWD/fediverse.Notifications.xml
+
+TEMPLATE = lib
+CONFIG += plugin
+target.path = $$[QT_INSTALL_LIBS]/buteo-plugins-qt5/oopp
+
+sync.path = /etc/buteo/profiles/sync
+sync.files = fediverse.Notifications.xml
+
+client.path = /etc/buteo/profiles/client
+client.files = fediverse-notifications.xml
+
+INSTALLS += target sync client ts_install engineering_english_install
diff --git a/buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediverse-notifications.xml b/buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediverse-notifications.xml
new file mode 100644
index 0000000..81de349
--- /dev/null
+++ b/buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediverse-notifications.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. -->
+<!-- SPDX-License-Identifier: BSD-3-Clause -->
+<profile name="fediverse-notifications" type="client" >
+ <field name="Sync Direction" />
+</profile>
diff --git a/buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediverse.Notifications.xml b/buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediverse.Notifications.xml
new file mode 100644
index 0000000..bf0ecee
--- /dev/null
+++ b/buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediverse.Notifications.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. -->
+<!-- SPDX-License-Identifier: BSD-3-Clause -->
+<profile name="fediverse.Notifications" type="sync" >
+ <key name="category" value="eventfeed" />
+ <key name="enabled" value="true" />
+ <key name="use_accounts" value="false" />
+ <key name="destinationtype" value="online" />
+ <key name="hidden" value="true" />
+ <key name="displayname" value="Fediverse Notifications"/>
+
+ <schedule enabled="true" interval="30" days="1,2,3,4,5,6,7" syncconfiguredtime="" time="" />
+
+ <profile name="fediverse-notifications" type="client" >
+ <key name="Sync Direction" value="from-remote" />
+ </profile>
+</profile>
diff --git a/buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediversedatatypesyncadaptor.cpp b/buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediversedatatypesyncadaptor.cpp
new file mode 100644
index 0000000..3d71585
--- /dev/null
+++ b/buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediversedatatypesyncadaptor.cpp
@@ -0,0 +1,240 @@
+/****************************************************************************
+ **
+ ** 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 "fediversedatatypesyncadaptor.h"
+#include "fediverseauthutils.h"
+
+#include <QtCore/QLoggingCategory>
+#include <QtCore/QVariantMap>
+#include <QtNetwork/QNetworkRequest>
+
+// libaccounts-qt5
+#include <Accounts/Manager>
+#include <Accounts/Account>
+#include <Accounts/Service>
+#include <Accounts/AccountService>
+
+// libsignon-qt5
+#include <SignOn/Identity>
+#include <SignOn/AuthSession>
+#include <SignOn/SessionData>
+
+Q_LOGGING_CATEGORY(lcFediverseNotificationsSync, "buteo.plugin.fediverse.notifications.sync", QtWarningMsg)
+
+FediverseNotificationsDataTypeSyncAdaptor::FediverseNotificationsDataTypeSyncAdaptor(
+ SocialNetworkSyncAdaptor::DataType dataType,
+ QObject *parent)
+ : SocialNetworkSyncAdaptor(QStringLiteral("fediverse"), dataType, 0, parent)
+{
+}
+
+FediverseNotificationsDataTypeSyncAdaptor::~FediverseNotificationsDataTypeSyncAdaptor()
+{
+}
+
+void FediverseNotificationsDataTypeSyncAdaptor::sync(const QString &dataTypeString, int accountId)
+{
+ if (dataTypeString != SocialNetworkSyncAdaptor::dataTypeName(m_dataType)) {
+ qCWarning(lcFediverseNotificationsSync) << "Fediverse" << SocialNetworkSyncAdaptor::dataTypeName(m_dataType)
+ << "sync adaptor was asked to sync" << dataTypeString;
+ setStatus(SocialNetworkSyncAdaptor::Error);
+ return;
+ }
+
+ setStatus(SocialNetworkSyncAdaptor::Busy);
+ updateDataForAccount(accountId);
+ qCDebug(lcFediverseNotificationsSync) << "successfully triggered sync with profile:" << m_accountSyncProfile->name();
+}
+
+void FediverseNotificationsDataTypeSyncAdaptor::updateDataForAccount(int accountId)
+{
+ Accounts::Account *account = Accounts::Account::fromId(m_accountManager, accountId, this);
+ if (!account) {
+ qCWarning(lcFediverseNotificationsSync) << "existing account with id" << accountId << "couldn't be retrieved";
+ setStatus(SocialNetworkSyncAdaptor::Error);
+ return;
+ }
+
+ incrementSemaphore(accountId);
+ signIn(account);
+}
+
+QString FediverseNotificationsDataTypeSyncAdaptor::apiHost(int accountId) const
+{
+ return m_apiHosts.value(accountId, FediverseAuthUtils::defaultApiHost());
+}
+
+QString FediverseNotificationsDataTypeSyncAdaptor::authServiceName() const
+{
+ return syncServiceName();
+}
+
+void FediverseNotificationsDataTypeSyncAdaptor::errorHandler(QNetworkReply::NetworkError err)
+{
+ QNetworkReply *reply = qobject_cast<QNetworkReply*>(sender());
+ if (!reply) {
+ return;
+ }
+
+ const int accountId = reply->property("accountId").toInt();
+ const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
+
+ qCWarning(lcFediverseNotificationsSync) << SocialNetworkSyncAdaptor::dataTypeName(m_dataType)
+ << "request with account" << accountId
+ << "experienced error:" << err
+ << "HTTP:" << httpStatus;
+
+ reply->setProperty("isError", QVariant::fromValue<bool>(true));
+
+ if (httpStatus == 401 || err == QNetworkReply::AuthenticationRequiredError) {
+ Accounts::Account *account = Accounts::Account::fromId(m_accountManager, accountId, this);
+ if (account) {
+ setCredentialsNeedUpdate(account);
+ }
+ }
+}
+
+void FediverseNotificationsDataTypeSyncAdaptor::sslErrorsHandler(const QList<QSslError> &errs)
+{
+ QString sslerrs;
+ foreach (const QSslError &e, errs) {
+ sslerrs += e.errorString() + QLatin1String("; ");
+ }
+ if (!sslerrs.isEmpty()) {
+ sslerrs.chop(2);
+ }
+
+ qCWarning(lcFediverseNotificationsSync) << SocialNetworkSyncAdaptor::dataTypeName(m_dataType)
+ << "request with account" << sender()->property("accountId").toInt()
+ << "experienced ssl errors:" << sslerrs;
+ sender()->setProperty("isError", QVariant::fromValue<bool>(true));
+}
+
+void FediverseNotificationsDataTypeSyncAdaptor::setCredentialsNeedUpdate(Accounts::Account *account)
+{
+ qCInfo(lcFediverseNotificationsSync) << "sociald:Fediverse: setting CredentialsNeedUpdate to true for account:" << account->id();
+ Accounts::Service srv(m_accountManager->service(authServiceName()));
+ account->selectService(srv);
+ account->setValue(QStringLiteral("CredentialsNeedUpdate"), QVariant::fromValue<bool>(true));
+ account->setValue(QStringLiteral("CredentialsNeedUpdateFrom"), QVariant::fromValue<QString>(QString::fromLatin1("sociald-fediverse")));
+ account->selectService(Accounts::Service());
+ account->syncAndBlock();
+}
+
+void FediverseNotificationsDataTypeSyncAdaptor::signIn(Accounts::Account *account)
+{
+ const int accountId = account->id();
+ if (!checkAccount(account)) {
+ decrementSemaphore(accountId);
+ return;
+ }
+
+ Accounts::Service srv(m_accountManager->service(authServiceName()));
+ account->selectService(srv);
+
+ SignOn::Identity *identity = account->credentialsId() > 0
+ ? SignOn::Identity::existingIdentity(account->credentialsId())
+ : 0;
+ if (!identity) {
+ qCWarning(lcFediverseNotificationsSync) << "account" << accountId << "has no valid credentials, cannot sign in";
+ decrementSemaphore(accountId);
+ return;
+ }
+
+ Accounts::AccountService accSrv(account, srv);
+ const QString method = accSrv.authData().method();
+ const QString mechanism = accSrv.authData().mechanism();
+ SignOn::AuthSession *session = identity->createSession(method);
+ if (!session) {
+ qCWarning(lcFediverseNotificationsSync) << "could not create signon session for account" << accountId;
+ identity->deleteLater();
+ decrementSemaphore(accountId);
+ return;
+ }
+
+ QVariantMap signonSessionData = accSrv.authData().parameters();
+ FediverseAuthUtils::addSignOnSessionParameters(account, &signonSessionData);
+
+ connect(session, SIGNAL(response(SignOn::SessionData)),
+ this, SLOT(signOnResponse(SignOn::SessionData)),
+ Qt::UniqueConnection);
+ connect(session, SIGNAL(error(SignOn::Error)),
+ this, SLOT(signOnError(SignOn::Error)),
+ Qt::UniqueConnection);
+
+ session->setProperty("account", QVariant::fromValue<Accounts::Account*>(account));
+ session->setProperty("identity", QVariant::fromValue<SignOn::Identity*>(identity));
+ session->process(SignOn::SessionData(signonSessionData), mechanism);
+}
+
+void FediverseNotificationsDataTypeSyncAdaptor::signOnError(const SignOn::Error &error)
+{
+ SignOn::AuthSession *session = qobject_cast<SignOn::AuthSession*>(sender());
+ Accounts::Account *account = session->property("account").value<Accounts::Account*>();
+ SignOn::Identity *identity = session->property("identity").value<SignOn::Identity*>();
+ const int accountId = account->id();
+
+ qCWarning(lcFediverseNotificationsSync) << "credentials for account with id" << accountId
+ << "couldn't be retrieved:" << error.type() << error.message();
+
+ if (error.type() == SignOn::Error::UserInteraction) {
+ setCredentialsNeedUpdate(account);
+ }
+
+ session->disconnect(this);
+ identity->destroySession(session);
+ identity->deleteLater();
+ account->deleteLater();
+
+ setStatus(SocialNetworkSyncAdaptor::Error);
+ decrementSemaphore(accountId);
+}
+
+void FediverseNotificationsDataTypeSyncAdaptor::signOnResponse(const SignOn::SessionData &responseData)
+{
+ const QVariantMap data = FediverseAuthUtils::responseDataToMap(responseData);
+
+ QString accessToken;
+ SignOn::AuthSession *session = qobject_cast<SignOn::AuthSession*>(sender());
+ Accounts::Account *account = session->property("account").value<Accounts::Account*>();
+ SignOn::Identity *identity = session->property("identity").value<SignOn::Identity*>();
+ const int accountId = account->id();
+
+ accessToken = FediverseAuthUtils::accessToken(data);
+ if (accessToken.isEmpty()) {
+ qCWarning(lcFediverseNotificationsSync) << "signon response for account with id" << accountId
+ << "contained no access token; keys:" << data.keys();
+ }
+
+ m_apiHosts.insert(accountId, FediverseAuthUtils::normalizeApiHost(account->value(QStringLiteral("api/Host")).toString()));
+
+ session->disconnect(this);
+ identity->destroySession(session);
+ identity->deleteLater();
+ account->deleteLater();
+
+ if (!accessToken.isEmpty()) {
+ beginSync(accountId, accessToken);
+ } else {
+ setStatus(SocialNetworkSyncAdaptor::Error);
+ }
+
+ decrementSemaphore(accountId);
+}
diff --git a/buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediversedatatypesyncadaptor.h b/buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediversedatatypesyncadaptor.h
new file mode 100644
index 0000000..0acbc87
--- /dev/null
+++ b/buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediversedatatypesyncadaptor.h
@@ -0,0 +1,70 @@
+/****************************************************************************
+ **
+ ** 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
+ **
+ ****************************************************************************/
+
+#ifndef FEDIVERSENOTIFICATIONSDATATYPESYNCADAPTOR_H
+#define FEDIVERSENOTIFICATIONSDATATYPESYNCADAPTOR_H
+
+#include "socialnetworksyncadaptor.h"
+
+#include <QtCore/QMap>
+#include <QtNetwork/QNetworkReply>
+#include <QtNetwork/QSslError>
+
+namespace Accounts {
+ class Account;
+}
+namespace SignOn {
+ class Error;
+ class SessionData;
+}
+
+class FediverseNotificationsDataTypeSyncAdaptor : public SocialNetworkSyncAdaptor
+{
+ Q_OBJECT
+
+public:
+ FediverseNotificationsDataTypeSyncAdaptor(SocialNetworkSyncAdaptor::DataType dataType, QObject *parent);
+ virtual ~FediverseNotificationsDataTypeSyncAdaptor();
+
+ void sync(const QString &dataTypeString, int accountId) override;
+
+protected:
+ QString apiHost(int accountId) const;
+ virtual void updateDataForAccount(int accountId);
+ virtual QString authServiceName() const;
+ virtual void beginSync(int accountId, const QString &accessToken) = 0;
+
+protected Q_SLOTS:
+ virtual void errorHandler(QNetworkReply::NetworkError err);
+ virtual void sslErrorsHandler(const QList<QSslError> &errs);
+
+private Q_SLOTS:
+ void signOnError(const SignOn::Error &error);
+ void signOnResponse(const SignOn::SessionData &responseData);
+
+private:
+ void setCredentialsNeedUpdate(Accounts::Account *account);
+ void signIn(Accounts::Account *account);
+
+private:
+ QMap<int, QString> m_apiHosts;
+};
+
+#endif // FEDIVERSENOTIFICATIONSDATATYPESYNCADAPTOR_H
diff --git a/buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediversenotificationsplugin.cpp b/buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediversenotificationsplugin.cpp
new file mode 100644
index 0000000..c518e7e
--- /dev/null
+++ b/buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediversenotificationsplugin.cpp
@@ -0,0 +1,96 @@
+/****************************************************************************
+ **
+ ** 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 "fediversenotificationsplugin.h"
+#include "fediversenotificationssyncadaptor.h"
+#include "socialnetworksyncadaptor.h"
+
+#include <QCoreApplication>
+#include <QLocale>
+#include <QTranslator>
+
+namespace {
+class AppTranslator : public QTranslator
+{
+public:
+ explicit AppTranslator(QObject *parent)
+ : QTranslator(parent)
+ {
+ qApp->installTranslator(this);
+ }
+
+ ~AppTranslator() override
+ {
+ qApp->removeTranslator(this);
+ }
+};
+
+void ensureNotificationTranslations()
+{
+ static bool initialized = false;
+ if (initialized) {
+ return;
+ }
+
+ QCoreApplication *app = QCoreApplication::instance();
+ if (!app) {
+ return;
+ }
+
+ AppTranslator *engineeringEnglish = new AppTranslator(app);
+ engineeringEnglish->load(QStringLiteral("lipstick-jolla-home-fediverse-notifications_eng_en"),
+ QStringLiteral("/usr/share/translations"));
+
+ AppTranslator *translator = new AppTranslator(app);
+ translator->load(QLocale(),
+ QStringLiteral("lipstick-jolla-home-fediverse-notifications"),
+ QStringLiteral("-"),
+ QStringLiteral("/usr/share/translations"));
+
+ initialized = true;
+}
+}
+
+FediverseNotificationsPlugin::FediverseNotificationsPlugin(const QString& pluginName,
+ const Buteo::SyncProfile& profile,
+ Buteo::PluginCbInterface *callbackInterface)
+ : SocialdButeoPlugin(pluginName, profile, callbackInterface,
+ QStringLiteral("fediverse"),
+ SocialNetworkSyncAdaptor::dataTypeName(SocialNetworkSyncAdaptor::Notifications))
+{
+ ensureNotificationTranslations();
+}
+
+FediverseNotificationsPlugin::~FediverseNotificationsPlugin()
+{
+}
+
+SocialNetworkSyncAdaptor *FediverseNotificationsPlugin::createSocialNetworkSyncAdaptor()
+{
+ return new FediverseNotificationsSyncAdaptor(this);
+}
+
+Buteo::ClientPlugin* FediverseNotificationsPluginLoader::createClientPlugin(
+ const QString& pluginName,
+ const Buteo::SyncProfile& profile,
+ Buteo::PluginCbInterface* cbInterface)
+{
+ return new FediverseNotificationsPlugin(pluginName, profile, cbInterface);
+}
diff --git a/buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediversenotificationsplugin.h b/buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediversenotificationsplugin.h
new file mode 100644
index 0000000..002aeb6
--- /dev/null
+++ b/buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediversenotificationsplugin.h
@@ -0,0 +1,54 @@
+/****************************************************************************
+ **
+ ** 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
+ **
+ ****************************************************************************/
+
+#ifndef FEDIVERSENOTIFICATIONSPLUGIN_H
+#define FEDIVERSENOTIFICATIONSPLUGIN_H
+
+#include "socialdbuteoplugin.h"
+
+#include <buteosyncfw5/SyncPluginLoader.h>
+
+class Q_DECL_EXPORT FediverseNotificationsPlugin : public SocialdButeoPlugin
+{
+ Q_OBJECT
+
+public:
+ FediverseNotificationsPlugin(const QString& pluginName,
+ const Buteo::SyncProfile& profile,
+ Buteo::PluginCbInterface *cbInterface);
+ ~FediverseNotificationsPlugin();
+
+protected:
+ SocialNetworkSyncAdaptor *createSocialNetworkSyncAdaptor() override;
+};
+
+class FediverseNotificationsPluginLoader : public Buteo::SyncPluginLoader
+{
+ Q_OBJECT
+ Q_PLUGIN_METADATA(IID "org.sailfishos.plugins.sync.FediverseNotificationsPluginLoader")
+ Q_INTERFACES(Buteo::SyncPluginLoader)
+
+public:
+ Buteo::ClientPlugin* createClientPlugin(const QString& pluginName,
+ const Buteo::SyncProfile& profile,
+ Buteo::PluginCbInterface* cbInterface) override;
+};
+
+#endif // FEDIVERSENOTIFICATIONSPLUGIN_H
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 &notificationType)
+ {
+ 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 &timestampString)
+{
+ 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 &notificationId)
+{
+ 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 &notificationValue, 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 &notificationId, 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 &notificationData)
+{
+ 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 &notificationId)
+{
+ 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 &notificationId)
+{
+ 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;
+}
diff --git a/buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediversenotificationssyncadaptor.h b/buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediversenotificationssyncadaptor.h
new file mode 100644
index 0000000..24f2745
--- /dev/null
+++ b/buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediversenotificationssyncadaptor.h
@@ -0,0 +1,104 @@
+/****************************************************************************
+ **
+ ** 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
+ **
+ ****************************************************************************/
+
+#ifndef FEDIVERSENOTIFICATIONSSYNCADAPTOR_H
+#define FEDIVERSENOTIFICATIONSSYNCADAPTOR_H
+
+#include "fediversedatatypesyncadaptor.h"
+
+#include <QtCore/QDateTime>
+#include <QtCore/QHash>
+#include <QtCore/QSet>
+#include <QtNetwork/QNetworkReply>
+
+class Notification;
+
+class FediverseNotificationsSyncAdaptor : public FediverseNotificationsDataTypeSyncAdaptor
+{
+ Q_OBJECT
+
+public:
+ FediverseNotificationsSyncAdaptor(QObject *parent);
+ ~FediverseNotificationsSyncAdaptor();
+
+ QString syncServiceName() const override;
+
+protected:
+ QString authServiceName() const override;
+ void purgeDataForOldAccount(int oldId, SocialNetworkSyncAdaptor::PurgeMode mode) override;
+ void beginSync(int accountId, const QString &accessToken) override;
+ void finalize(int accountId) override;
+
+private:
+ struct PendingNotification {
+ QString notificationId;
+ QString summary;
+ QString body;
+ QString link;
+ QDateTime timestamp;
+ };
+
+ struct PendingSyncState {
+ QString accessToken;
+ bool markerKnown = false;
+ QString unreadFloorId;
+ QString lastFetchedId;
+ QString maxFetchedId;
+ QSet<QString> unreadNotificationIds;
+ QHash<QString, PendingNotification> pendingNotifications;
+ };
+
+ static QString sanitizeContent(const QString &content);
+ static QDateTime parseTimestamp(const QString &timestampString);
+ static int compareNotificationIds(const QString &left, const QString &right);
+ QString loadLastFetchedId(int accountId) const;
+ void saveLastFetchedId(int accountId, const QString &lastFetchedId);
+
+ void requestUnreadMarker(int accountId, const QString &accessToken);
+ void requestNotifications(int accountId,
+ const QString &accessToken,
+ const QString &minId,
+ const QString &maxId = QString());
+ void requestMarkRead(int accountId, const QString &accessToken, const QString &lastReadId);
+ void publishSystemNotification(int accountId, const PendingNotification &notificationData);
+ Notification *createNotification(int accountId, const QString &notificationId);
+ Notification *findNotification(int accountId, const QString &notificationId);
+ void closeAccountNotifications(int accountId, const QSet<QString> &keepNotificationIds = QSet<QString>());
+ static QString notificationObjectKey(int accountId, const QString &notificationId);
+ void maybeMarkAccountNotificationsRead(int accountId,
+ const QString &accessToken,
+ Notification *ignoredNotification = 0);
+ void markReadFromNotification(Notification *notification);
+ void removeCachedNotification(Notification *notification);
+
+private Q_SLOTS:
+ void finishedUnreadMarkerHandler();
+ void finishedNotificationsHandler();
+ void finishedMarkReadHandler();
+ void notificationClosedWithReason(uint reason);
+
+private:
+ QHash<int, QString> m_accessTokens;
+ QHash<int, PendingSyncState> m_pendingSyncStates;
+ QHash<int, QString> m_lastMarkedReadIds;
+ QHash<QString, Notification *> m_notificationObjects;
+};
+
+#endif // FEDIVERSENOTIFICATIONSSYNCADAPTOR_H